feat: add skills.md endpoint and CLI run command

- Add /:username/collections/:slug/skills.md endpoint for AI-generated skills docs
- Analyze npm package source code to generate comprehensive capability contracts
- Add `tpm run` CLI command to execute tools from collections via MCP
- Cache generated skills.md in database with 1-week TTL
This commit is contained in:
Ajax Davis 2026-01-21 14:50:07 +10:00
parent c5a17a0a69
commit 9ab8946c9d
7 changed files with 1454 additions and 0 deletions

View file

@ -72,6 +72,24 @@ tpm collection add # Add tools to a collection
tpm collection remove # Remove tools from a collection
```
### Run Tools
Execute tools directly from collections via the MCP API:
```bash
# Basic usage
tpm run -c ajax/unsandbox -t execute --args '{"code":"print(1)","language":"python"}'
# With environment variables
tpm run -c ajax/my-collection -t myTool --env API_KEY=xxx --env DEBUG=true
# Using process environment
OPENAI_API_KEY=xxx tpm run -c ajax/ai-tools -t generate
# JSON output for scripting
tpm run -c ajax/tools -t base64Encode --args '{"data":"hello"}' --json | jq .result
```
### MCP Integration
```bash

View file

@ -0,0 +1,321 @@
import { Command, Flags } from '@oclif/core';
import { getApiKey, getApiUrl } from '../lib/config.js';
import { createOutput, type OutputFormatter } from '../lib/output.js';
interface JsonRpcResponse {
jsonrpc: '2.0';
id: string | number | null;
result?: {
content?: Array<{ type: string; text: string }>;
[key: string]: unknown;
};
error?: {
code: number;
message: string;
data?: unknown;
};
}
interface RunFlags {
collection: string;
tool: string;
args: string;
env?: string[];
json: boolean;
verbose: boolean;
timeout: number;
}
/**
* Parse and validate collection identifier
*/
function parseCollection(collection: string): { username: string; slug: string } | null {
const parts = collection.split('/');
if (parts.length !== 2) return null;
const [username, slug] = parts;
if (!username || !slug) return null;
return { username, slug };
}
/**
* Parse JSON tool arguments
*/
function parseToolArgs(argsStr: string): Record<string, unknown> | null {
try {
return JSON.parse(argsStr) as Record<string, unknown>;
} catch {
return null;
}
}
/**
* Build environment variables from process.env and explicit flags
*/
function buildEnvVars(envFlags: string[] | undefined, output: OutputFormatter): Record<string, string> {
const envVars: Record<string, string> = {};
// Add process env vars that might be relevant
for (const [key, value] of Object.entries(process.env)) {
if (value && (key.endsWith('_API_KEY') || key.endsWith('_TOKEN') || key.endsWith('_SECRET'))) {
envVars[key] = value;
}
}
// Add explicitly passed env vars (override process env)
if (envFlags) {
for (const envPair of envFlags) {
const [key, ...valueParts] = envPair.split('=');
if (key && valueParts.length > 0) {
envVars[key] = valueParts.join('=');
} else {
output.warning(`Invalid env var format: ${envPair}. Use: KEY=value`);
}
}
}
return envVars;
}
/**
* Display verbose request info
*/
function displayVerboseInfo(
output: OutputFormatter,
mcpUrl: string,
tool: string,
toolArgs: Record<string, unknown>,
envVars: Record<string, string>
): void {
output.info(`MCP Endpoint: ${mcpUrl}`);
output.info(`Tool: ${tool}`);
output.info(`Arguments: ${JSON.stringify(toolArgs, null, 2)}`);
if (Object.keys(envVars).length > 0) {
output.info(`Environment: ${Object.keys(envVars).join(', ')}`);
}
output.divider();
}
/**
* Handle HTTP error response
*/
async function handleHttpError(response: Response, output: OutputFormatter): Promise<void> {
const errorText = await response.text();
try {
const errorJson = JSON.parse(errorText) as { error?: { message?: string }; message?: string };
output.error(errorJson.error?.message || errorJson.message || `HTTP ${response.status}`);
} catch {
output.error(`HTTP ${response.status}: ${errorText}`);
}
}
/**
* Display MCP response content
*/
function displayContent(result: JsonRpcResponse['result'], output: OutputFormatter): void {
if (!result) {
output.text('(empty result)');
return;
}
if (result.content && Array.isArray(result.content)) {
for (const item of result.content) {
displayContentItem(item, output);
}
} else {
output.text(JSON.stringify(result, null, 2));
}
}
/**
* Display a single content item
*/
function displayContentItem(item: { type: string; text: string }, output: OutputFormatter): void {
if (item.type === 'text') {
try {
const parsed = JSON.parse(item.text);
output.text(JSON.stringify(parsed, null, 2));
} catch {
output.text(item.text);
}
} else if (item.type === 'image') {
output.info('[Image content]');
} else {
output.text(JSON.stringify(item, null, 2));
}
}
/**
* Handle execution error
*/
function handleExecutionError(error: unknown, flags: RunFlags, output: OutputFormatter): void {
if (error instanceof Error) {
if (error.name === 'AbortError') {
output.error(`Request timed out after ${flags.timeout} seconds`);
} else {
output.error(error.message);
}
} else {
output.error('Unknown error occurred');
}
if (flags.verbose) {
output.info(`Full error: ${String(error)}`);
}
}
export default class Run extends Command {
static description = 'Execute a tool from a collection via MCP';
static examples = [
'<%= config.bin %> run -c ajax/unsandbox -t execute --args \'{"code":"print(1)","language":"python"}\'',
'<%= config.bin %> run --collection ajax/ajax-collection --tool base64Encode --args \'{"data":"hello"}\'',
'OPENAI_API_KEY=xxx <%= config.bin %> run -c ajax/my-collection -t myTool',
'<%= config.bin %> run -c ajax/tools -t search --args \'{"query":"test"}\' --json',
'<%= config.bin %> run -c ajax/tools -t search --env API_KEY=xxx --env DEBUG=true',
];
static flags = {
collection: Flags.string({
char: 'c',
description: 'Collection identifier (username/slug)',
required: true,
}),
tool: Flags.string({
char: 't',
description: 'Tool name (MCP format: package--toolName)',
required: true,
}),
args: Flags.string({
char: 'a',
description: 'Tool arguments as JSON string',
default: '{}',
}),
env: Flags.string({
char: 'e',
description: 'Environment variables (key=value)',
multiple: true,
}),
json: Flags.boolean({
description: 'Output result as JSON',
default: false,
}),
verbose: Flags.boolean({
char: 'v',
description: 'Show verbose output',
default: false,
}),
timeout: Flags.integer({
description: 'Request timeout in seconds',
default: 60,
}),
};
async run(): Promise<void> {
const { flags } = await this.parse(Run);
const output = createOutput(flags);
// Parse and validate inputs
const collection = parseCollection(flags.collection);
if (!collection) {
output.error('Invalid collection format. Use: username/slug');
return;
}
const toolArgs = parseToolArgs(flags.args);
if (!toolArgs) {
output.error('Invalid JSON in --args flag');
return;
}
// Build request components
const envVars = buildEnvVars(flags.env, output);
const baseUrl = getApiUrl().replace(/\/api$/, '');
const mcpUrl = `${baseUrl}/api/mcp/${collection.username}/${collection.slug}/http`;
const apiKey = getApiKey();
if (!apiKey) {
output.warning('No API key configured. Some operations may fail.');
output.warning('Run `tpm auth login` to authenticate.');
}
if (flags.verbose) {
displayVerboseInfo(output, mcpUrl, flags.tool, toolArgs, envVars);
}
// Execute the request
await this.executeToolCall(flags, output, mcpUrl, apiKey, toolArgs, envVars);
}
private async executeToolCall(
flags: RunFlags,
output: OutputFormatter,
mcpUrl: string,
apiKey: string | undefined,
toolArgs: Record<string, unknown>,
envVars: Record<string, string>
): Promise<void> {
const jsonRpcRequest = {
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: {
name: flags.tool,
arguments: toolArgs,
...(Object.keys(envVars).length > 0 ? { env: envVars } : {}),
},
};
const spinner = output.spinner(`Executing ${flags.tool}...`);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), flags.timeout * 1000);
const response = await fetch(mcpUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify(jsonRpcRequest),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
spinner.fail('Request failed');
await handleHttpError(response, output);
return;
}
const result = (await response.json()) as JsonRpcResponse;
spinner.stop();
this.handleResult(flags, output, result);
} catch (error) {
spinner.fail('Execution failed');
handleExecutionError(error, flags, output);
}
}
private handleResult(flags: RunFlags, output: OutputFormatter, result: JsonRpcResponse): void {
if (result.error) {
output.error(`Tool execution failed: ${result.error.message}`);
if (flags.verbose && result.error.data) {
output.info(`Error data: ${JSON.stringify(result.error.data, null, 2)}`);
}
return;
}
if (flags.json) {
output.json(result.result);
return;
}
output.success('Tool execution complete');
output.divider();
displayContent(result.result, output);
}
}

View file

@ -458,6 +458,10 @@ model Collection {
useCases Json? @map("use_cases") @db.JsonB
useCasesGeneratedAt DateTime? @map("use_cases_generated_at")
// AI-generated skills documentation
skillsMarkdown String? @map("skills_markdown") @db.Text
skillsGeneratedAt DateTime? @map("skills_generated_at")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")