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:
parent
c5a17a0a69
commit
9ab8946c9d
7 changed files with 1454 additions and 0 deletions
|
|
@ -0,0 +1,192 @@
|
|||
/**
|
||||
* GET /:username/collections/:slug/skills.md
|
||||
* Generate AI-powered skills documentation for a collection
|
||||
* Clean URL endpoint for machine-readable skills documentation
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { fetchMultiplePackageSources } from '~/lib/ai/package-source-fetcher';
|
||||
import { generateSkillsMarkdown } from '~/lib/ai/skills-generator';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 120; // Extended for source fetching + AI generation
|
||||
|
||||
// Cache for 1 week
|
||||
const CACHE_DURATION_SECONDS = 7 * 24 * 60 * 60; // 604800 seconds
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ username: string; slug: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /:username/collections/:slug/skills.md
|
||||
* Generate skills.md markdown for a collection
|
||||
*/
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
const { username: rawUsername, slug } = await context.params;
|
||||
const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername;
|
||||
|
||||
// Check for cachebust query param
|
||||
const cachebust = request.nextUrl.searchParams.has('cachebust');
|
||||
|
||||
// Find the user first
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true, username: true },
|
||||
});
|
||||
|
||||
if (!user || !user.username) {
|
||||
return new Response('User not found', {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
// Find the collection by slug belonging to this user
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
userId: user.id,
|
||||
},
|
||||
include: {
|
||||
tools: {
|
||||
include: {
|
||||
tool: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
inputSchema: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
npmVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
take: 50, // Limit tools for performance
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!collection) {
|
||||
return new Response('Collection not found', {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
// Only return if public
|
||||
if (!collection.isPublic) {
|
||||
return new Response('This collection is not public', {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
// Check cache if not busting
|
||||
if (!cachebust && collection.skillsMarkdown && collection.skillsGeneratedAt) {
|
||||
// Check if cache is still valid (within 1 week)
|
||||
const cacheAge = Date.now() - collection.skillsGeneratedAt.getTime();
|
||||
if (cacheAge < CACHE_DURATION_SECONDS * 1000) {
|
||||
return new Response(collection.skillsMarkdown, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
|
||||
'X-Cache': 'HIT',
|
||||
'X-Cache-Age': Math.floor(cacheAge / 1000).toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tools data
|
||||
const tools = collection.tools.map((ct) => ({
|
||||
id: ct.tool.id,
|
||||
name: ct.tool.name,
|
||||
description: ct.tool.description,
|
||||
packageName: ct.tool.package.npmPackageName,
|
||||
inputSchema: ct.tool.inputSchema,
|
||||
}));
|
||||
|
||||
if (tools.length === 0) {
|
||||
return new Response('Collection has no tools', {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get unique packages to fetch
|
||||
const uniquePackages = [
|
||||
...new Map(
|
||||
collection.tools.map((ct) => [
|
||||
ct.tool.package.npmPackageName,
|
||||
{
|
||||
name: ct.tool.package.npmPackageName,
|
||||
version: ct.tool.package.npmVersion,
|
||||
},
|
||||
])
|
||||
).values(),
|
||||
];
|
||||
|
||||
// Fetch package sources (limit to 5 packages for performance)
|
||||
const packageSources = await fetchMultiplePackageSources(uniquePackages.slice(0, 5));
|
||||
|
||||
// Build MCP URLs
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://tpmjs.com';
|
||||
const mcpUrls = {
|
||||
http: `${baseUrl}/api/mcp/${username}/${slug}/http`,
|
||||
sse: `${baseUrl}/api/mcp/${username}/${slug}/sse`,
|
||||
};
|
||||
|
||||
// Generate skills markdown
|
||||
const skillsMarkdown = await generateSkillsMarkdown(
|
||||
{
|
||||
id: collection.id,
|
||||
name: collection.name,
|
||||
slug: collection.slug || slug,
|
||||
description: collection.description,
|
||||
username: user.username,
|
||||
},
|
||||
tools,
|
||||
packageSources,
|
||||
mcpUrls
|
||||
);
|
||||
|
||||
// Save to database for caching
|
||||
await prisma.collection.update({
|
||||
where: { id: collection.id },
|
||||
data: {
|
||||
skillsMarkdown,
|
||||
skillsGeneratedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(skillsMarkdown, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown; charset=utf-8',
|
||||
'Cache-Control': `public, s-maxage=${CACHE_DURATION_SECONDS}, stale-while-revalidate=86400`,
|
||||
'X-Cache': 'MISS',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Skills.md Error] GET /:username/collections/:slug/skills.md:', error);
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to generate skills documentation';
|
||||
|
||||
return new Response(`Error: ${message}`, {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
}
|
||||
333
apps/web/src/lib/ai/package-source-fetcher.ts
Normal file
333
apps/web/src/lib/ai/package-source-fetcher.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/**
|
||||
* Package Source Fetcher
|
||||
* Fetches full source code for npm packages via multiple CDN sources
|
||||
*/
|
||||
|
||||
export interface PackageFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface PackageSource {
|
||||
packageName: string;
|
||||
version: string;
|
||||
files: PackageFile[];
|
||||
packageJson: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Maximum files to fetch per package
|
||||
const MAX_FILES_PER_PACKAGE = 10;
|
||||
// Maximum file size to fetch (50KB)
|
||||
const MAX_FILE_SIZE = 50 * 1024;
|
||||
// Timeout for individual fetches
|
||||
const FETCH_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Fetch with timeout
|
||||
*/
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number = FETCH_TIMEOUT_MS
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'tpmjs-skills-generator/1.0',
|
||||
},
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch package.json from npm registry
|
||||
*/
|
||||
async function fetchPackageJson(
|
||||
packageName: string,
|
||||
version?: string
|
||||
): Promise<{ packageJson: Record<string, unknown>; resolvedVersion: string }> {
|
||||
const registryUrl = `https://registry.npmjs.org/${packageName}`;
|
||||
const response = await fetchWithTimeout(registryUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch package.json for ${packageName}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const resolvedVersion = version || data['dist-tags']?.latest;
|
||||
|
||||
if (!resolvedVersion) {
|
||||
throw new Error(`Could not resolve version for ${packageName}`);
|
||||
}
|
||||
|
||||
const versionData = data.versions?.[resolvedVersion];
|
||||
if (!versionData) {
|
||||
throw new Error(`Version ${resolvedVersion} not found for ${packageName}`);
|
||||
}
|
||||
|
||||
return { packageJson: versionData, resolvedVersion };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of entry point files from package.json
|
||||
*/
|
||||
function getEntryPoints(packageJson: Record<string, unknown>): string[] {
|
||||
const entryPoints: string[] = [];
|
||||
|
||||
// Check main entry point
|
||||
if (typeof packageJson.main === 'string') {
|
||||
entryPoints.push(packageJson.main);
|
||||
}
|
||||
|
||||
// Check module entry point (ES modules)
|
||||
if (typeof packageJson.module === 'string') {
|
||||
entryPoints.push(packageJson.module);
|
||||
}
|
||||
|
||||
// Check exports field (modern packages)
|
||||
if (packageJson.exports) {
|
||||
const exports = packageJson.exports as Record<string, unknown>;
|
||||
extractExportPaths(exports, entryPoints);
|
||||
}
|
||||
|
||||
// Check types entry point
|
||||
if (typeof packageJson.types === 'string') {
|
||||
entryPoints.push(packageJson.types);
|
||||
}
|
||||
if (typeof packageJson.typings === 'string') {
|
||||
entryPoints.push(packageJson.typings);
|
||||
}
|
||||
|
||||
// Default fallbacks if nothing found
|
||||
if (entryPoints.length === 0) {
|
||||
entryPoints.push('index.js', 'index.ts', 'src/index.js', 'src/index.ts');
|
||||
}
|
||||
|
||||
// Dedupe and normalize paths
|
||||
const normalized = [...new Set(entryPoints)].map((p) => (p.startsWith('./') ? p.slice(2) : p));
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively extract paths from exports field
|
||||
*/
|
||||
function extractExportPaths(exports: Record<string, unknown>, paths: string[]): void {
|
||||
for (const value of Object.values(exports)) {
|
||||
if (typeof value === 'string') {
|
||||
paths.push(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Handle conditional exports like { "import": "./dist/index.mjs", "require": "./dist/index.cjs" }
|
||||
extractExportPaths(value as Record<string, unknown>, paths);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch a file from unpkg CDN
|
||||
*/
|
||||
async function fetchFromUnpkg(
|
||||
packageName: string,
|
||||
version: string,
|
||||
filePath: string
|
||||
): Promise<string | null> {
|
||||
const url = `https://unpkg.com/${packageName}@${version}/${filePath}`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(url);
|
||||
if (!response.ok) return null;
|
||||
|
||||
// Check content length before reading
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength && parseInt(contentLength, 10) > MAX_FILE_SIZE) {
|
||||
console.warn(`File too large: ${filePath} (${contentLength} bytes)`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (text.length > MAX_FILE_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch a file from esm.sh CDN (with ?raw for source)
|
||||
*/
|
||||
async function fetchFromEsmSh(
|
||||
packageName: string,
|
||||
version: string,
|
||||
filePath: string
|
||||
): Promise<string | null> {
|
||||
// esm.sh requires different URL patterns for raw source
|
||||
const url = `https://esm.sh/${packageName}@${version}/${filePath}?raw`;
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(url);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const text = await response.text();
|
||||
if (text.length > MAX_FILE_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single file from CDN (tries multiple sources)
|
||||
*/
|
||||
async function fetchFile(
|
||||
packageName: string,
|
||||
version: string,
|
||||
filePath: string
|
||||
): Promise<string | null> {
|
||||
// Try unpkg first (more reliable for source files)
|
||||
let content = await fetchFromUnpkg(packageName, version, filePath);
|
||||
if (content) return content;
|
||||
|
||||
// Try esm.sh as fallback
|
||||
content = await fetchFromEsmSh(packageName, version, filePath);
|
||||
if (content) return content;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative import path to an absolute path
|
||||
*/
|
||||
function resolveImportPath(importPath: string, baseDir: string): string {
|
||||
if (importPath.startsWith('./')) {
|
||||
return baseDir ? `${baseDir}/${importPath.slice(2)}` : importPath.slice(2);
|
||||
}
|
||||
|
||||
if (importPath.startsWith('../')) {
|
||||
const parts = baseDir.split('/');
|
||||
const importParts = importPath.split('/');
|
||||
const upCount = importParts.filter((p) => p === '..').length;
|
||||
const remaining = importParts.slice(upCount).join('/');
|
||||
const baseParts = parts.slice(0, parts.length - upCount);
|
||||
return baseParts.length > 0 ? `${baseParts.join('/')}/${remaining}` : remaining;
|
||||
}
|
||||
|
||||
return importPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file path variations with common extensions
|
||||
*/
|
||||
function addPathVariations(resolvedPath: string, imports: string[]): void {
|
||||
if (/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(resolvedPath)) {
|
||||
imports.push(resolvedPath);
|
||||
} else {
|
||||
imports.push(`${resolvedPath}.ts`);
|
||||
imports.push(`${resolvedPath}.js`);
|
||||
imports.push(`${resolvedPath}/index.ts`);
|
||||
imports.push(`${resolvedPath}/index.js`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover additional source files from a fetched file
|
||||
*/
|
||||
function discoverImports(content: string, basePath: string): string[] {
|
||||
const imports: string[] = [];
|
||||
const baseDir = basePath.includes('/') ? basePath.substring(0, basePath.lastIndexOf('/')) : '';
|
||||
|
||||
// Match import/export statements for local files
|
||||
const importRegex = /(?:import|export).*from\s+['"](\.[^'"]+)['"]/g;
|
||||
const matches = content.matchAll(importRegex);
|
||||
|
||||
for (const match of matches) {
|
||||
const importPath = match[1];
|
||||
if (!importPath) continue;
|
||||
|
||||
const resolvedPath = resolveImportPath(importPath, baseDir);
|
||||
addPathVariations(resolvedPath, imports);
|
||||
}
|
||||
|
||||
return imports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch source files for an npm package
|
||||
*/
|
||||
export async function fetchPackageSource(
|
||||
packageName: string,
|
||||
version?: string
|
||||
): Promise<PackageSource> {
|
||||
// 1. Fetch package.json to get metadata and entry points
|
||||
const { packageJson, resolvedVersion } = await fetchPackageJson(packageName, version);
|
||||
|
||||
// 2. Get entry points to fetch
|
||||
const entryPoints = getEntryPoints(packageJson);
|
||||
const filesToFetch = new Set<string>();
|
||||
const fetchedFiles: PackageFile[] = [];
|
||||
const fetchedPaths = new Set<string>();
|
||||
|
||||
// Add entry points to fetch queue
|
||||
for (const entry of entryPoints) {
|
||||
filesToFetch.add(entry);
|
||||
}
|
||||
|
||||
// 3. Fetch files breadth-first, discovering imports
|
||||
const queue = [...filesToFetch];
|
||||
|
||||
while (queue.length > 0 && fetchedFiles.length < MAX_FILES_PER_PACKAGE) {
|
||||
const filePath = queue.shift();
|
||||
if (!filePath) break;
|
||||
|
||||
// Skip if already fetched
|
||||
if (fetchedPaths.has(filePath)) continue;
|
||||
fetchedPaths.add(filePath);
|
||||
|
||||
// Try to fetch the file
|
||||
const content = await fetchFile(packageName, resolvedVersion, filePath);
|
||||
if (!content) continue;
|
||||
|
||||
fetchedFiles.push({ path: filePath, content });
|
||||
|
||||
// Discover imports and add to queue
|
||||
const imports = discoverImports(content, filePath);
|
||||
for (const importPath of imports) {
|
||||
if (!fetchedPaths.has(importPath) && !queue.includes(importPath)) {
|
||||
queue.push(importPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packageName,
|
||||
version: resolvedVersion,
|
||||
files: fetchedFiles,
|
||||
packageJson,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch sources for multiple packages concurrently
|
||||
*/
|
||||
export async function fetchMultiplePackageSources(
|
||||
packages: Array<{ name: string; version?: string }>
|
||||
): Promise<PackageSource[]> {
|
||||
const results = await Promise.allSettled(
|
||||
packages.map(({ name, version }) => fetchPackageSource(name, version))
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<PackageSource> => r.status === 'fulfilled')
|
||||
.map((r) => r.value);
|
||||
}
|
||||
299
apps/web/src/lib/ai/skills-generator.ts
Normal file
299
apps/web/src/lib/ai/skills-generator.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
/**
|
||||
* AI-powered skills documentation generator for tool collections
|
||||
* Uses Vercel AI SDK to analyze package source code and generate comprehensive skills.md
|
||||
*/
|
||||
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
import type { PackageSource } from './package-source-fetcher';
|
||||
|
||||
export interface CollectionData {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface ToolData {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
packageName: string;
|
||||
inputSchema: unknown | null;
|
||||
}
|
||||
|
||||
export interface McpUrls {
|
||||
http: string;
|
||||
sse: string;
|
||||
}
|
||||
|
||||
const SKILLS_SYSTEM_PROMPT = `You are an expert technical writer generating machine-consumable skills documentation for AI agents.
|
||||
|
||||
Your task is to analyze actual source code from npm packages and generate a comprehensive skills.md document that serves as a capability contract for AI agents like Claude Code, MCP clients, and tool routers.
|
||||
|
||||
Guidelines:
|
||||
1. **Accuracy First**: Only document capabilities you can verify from the source code
|
||||
2. **Real Examples**: Generate code examples based on actual function signatures and patterns in the source
|
||||
3. **Input/Output Schemas**: Extract exact TypeScript types when available
|
||||
4. **Source Analysis**: Provide insights about implementation details that help agents use tools correctly
|
||||
5. **Integration Focus**: Show multiple ways to integrate (ESM, NPM, CLI, REST API, MCP)
|
||||
6. **Constraints**: Document what tools can and cannot do based on code analysis
|
||||
|
||||
Format the output as clean, well-organized Markdown with proper code blocks and syntax highlighting.`;
|
||||
|
||||
function buildSkillsPrompt(
|
||||
collection: CollectionData,
|
||||
tools: ToolData[],
|
||||
packageSources: PackageSource[],
|
||||
mcpUrls: McpUrls
|
||||
): string {
|
||||
const baseUrl = mcpUrls.http.replace(/\/api\/mcp\/.*$/, '');
|
||||
const collectionUrl = `${baseUrl}/${collection.username}/collections/${collection.slug}`;
|
||||
|
||||
// Build package source context
|
||||
const sourceContext = packageSources
|
||||
.map((pkg) => {
|
||||
const filesContent = pkg.files
|
||||
.map((f) => `### ${f.path}\n\`\`\`typescript\n${f.content.slice(0, 5000)}\n\`\`\``)
|
||||
.join('\n\n');
|
||||
|
||||
return `## Package: ${pkg.packageName}@${pkg.version}
|
||||
|
||||
**package.json exports:**
|
||||
\`\`\`json
|
||||
${JSON.stringify(
|
||||
{
|
||||
main: pkg.packageJson.main,
|
||||
module: pkg.packageJson.module,
|
||||
exports: pkg.packageJson.exports,
|
||||
types: pkg.packageJson.types,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}
|
||||
\`\`\`
|
||||
|
||||
**Source Files:**
|
||||
${filesContent}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
// Build tools context
|
||||
const toolsContext = tools
|
||||
.map(
|
||||
(t) => `- **${t.name}** (${t.packageName})
|
||||
Description: ${t.description}
|
||||
Input Schema: ${t.inputSchema ? JSON.stringify(t.inputSchema, null, 2) : 'Not available'}`
|
||||
)
|
||||
.join('\n\n');
|
||||
|
||||
return `Generate a skills.md document for this collection.
|
||||
|
||||
## Collection Info
|
||||
- **Name:** ${collection.name}
|
||||
- **Owner:** @${collection.username}
|
||||
- **Slug:** ${collection.slug}
|
||||
- **Description:** ${collection.description || 'No description'}
|
||||
- **Collection URL:** ${collectionUrl}
|
||||
|
||||
## MCP Endpoints
|
||||
- **HTTP:** ${mcpUrls.http}
|
||||
- **SSE:** ${mcpUrls.sse}
|
||||
|
||||
## Tools in Collection (${tools.length} total)
|
||||
${toolsContext}
|
||||
|
||||
## Package Source Code
|
||||
Analyze the following source code to understand tool implementations:
|
||||
|
||||
${sourceContext}
|
||||
|
||||
---
|
||||
|
||||
Generate the skills.md document following this EXACT structure:
|
||||
|
||||
# Agent Skills Declaration: ${collection.name}
|
||||
|
||||
> Machine-consumable capability contract for AI agents (Claude Code, MCP clients, tool routers)
|
||||
|
||||
## 1. Agent Identity
|
||||
|
||||
**Collection:** ${collection.name}
|
||||
**Owner:** @${collection.username}
|
||||
**Primary Role:** [Derive from tools and description]
|
||||
**Tool Count:** ${tools.length} tools from ${new Set(tools.map((t) => t.packageName)).size} packages
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Skills
|
||||
|
||||
[For each tool, include:]
|
||||
### Skill: [tool.name]
|
||||
**Package:** \`[package.npmPackageName]\`
|
||||
**Description:** [tool.description]
|
||||
|
||||
**Input Schema:**
|
||||
\`\`\`typescript
|
||||
[Extract from source code]
|
||||
\`\`\`
|
||||
|
||||
**Output Format:** [Analyze from source]
|
||||
|
||||
**Source Analysis:**
|
||||
[AI-generated insights from reading actual code - what does this tool actually do internally?]
|
||||
|
||||
**Example Usage:**
|
||||
\`\`\`typescript
|
||||
[Real example based on source code patterns]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 3. Direct Usage (Without MCP/CLI/REST)
|
||||
|
||||
### ESM Import (Browser/Deno)
|
||||
\`\`\`typescript
|
||||
[Generate real ESM import examples using esm.sh]
|
||||
\`\`\`
|
||||
|
||||
### NPM Import (Node.js)
|
||||
\`\`\`typescript
|
||||
[Generate real NPM import examples]
|
||||
\`\`\`
|
||||
|
||||
### Multi-Tool Workflow
|
||||
\`\`\`typescript
|
||||
[Generate a workflow example combining multiple tools]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent Integration Methods
|
||||
|
||||
### 4.1 CLI (\`tpm run\`)
|
||||
|
||||
Install CLI:
|
||||
\`\`\`bash
|
||||
npm install -g @tpmjs/cli
|
||||
\`\`\`
|
||||
|
||||
Run a tool:
|
||||
\`\`\`bash
|
||||
tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name] --args '{"key": "value"}'
|
||||
\`\`\`
|
||||
|
||||
With environment variables:
|
||||
\`\`\`bash
|
||||
SOME_API_KEY=xxx tpm run --collection ${collection.username}/${collection.slug} --tool [tool-name]
|
||||
\`\`\`
|
||||
|
||||
### 4.2 REST API (JSON-RPC 2.0)
|
||||
|
||||
**Endpoint:** \`POST ${mcpUrls.http}\`
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST ${mcpUrls.http} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-d '{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "[mcp-tool-name]",
|
||||
"arguments": { ... }
|
||||
}
|
||||
}'
|
||||
\`\`\`
|
||||
|
||||
**List available tools:**
|
||||
\`\`\`bash
|
||||
curl -X POST ${mcpUrls.http} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \\
|
||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||
\`\`\`
|
||||
|
||||
### 4.3 MCP Server
|
||||
|
||||
**URLs:**
|
||||
- HTTP: \`${mcpUrls.http}\`
|
||||
- SSE: \`${mcpUrls.sse}\`
|
||||
|
||||
**Claude Desktop Config (\`claude_desktop_config.json\`):**
|
||||
\`\`\`json
|
||||
{
|
||||
"mcpServers": {
|
||||
"${collection.slug}": {
|
||||
"url": "${mcpUrls.sse}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Local MCP Server (via CLI):**
|
||||
\`\`\`bash
|
||||
tpm mcp serve ${collection.username}/${collection.slug}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraints & Safety
|
||||
|
||||
**This collection does NOT:**
|
||||
[Generate based on tool analysis - what are the limitations?]
|
||||
|
||||
**Rate Limits:**
|
||||
- Free tier: 100 requests/hour
|
||||
- Authenticated: 1000 requests/hour
|
||||
|
||||
**Authentication:**
|
||||
- Public collections: No auth required for read
|
||||
- Tool execution: API key required
|
||||
|
||||
---
|
||||
|
||||
## 6. Versioning
|
||||
|
||||
**Skills Version:** 1.0.0
|
||||
**Generated:** [Current timestamp]
|
||||
**Collection Updated:** [Will be filled in]
|
||||
|
||||
---
|
||||
|
||||
## 7. Canonical References
|
||||
|
||||
- TPMJS: https://tpmjs.com
|
||||
- MCP Protocol: https://modelcontextprotocol.io
|
||||
- Collection Page: ${collectionUrl}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate skills.md markdown for a collection
|
||||
*/
|
||||
export async function generateSkillsMarkdown(
|
||||
collection: CollectionData,
|
||||
tools: ToolData[],
|
||||
packageSources: PackageSource[],
|
||||
mcpUrls: McpUrls
|
||||
): Promise<string> {
|
||||
if (tools.length === 0) {
|
||||
throw new Error('Collection must have at least one tool to generate skills documentation');
|
||||
}
|
||||
|
||||
const prompt = buildSkillsPrompt(collection, tools, packageSources, mcpUrls);
|
||||
|
||||
const { text } = await generateText({
|
||||
model: openai('gpt-4.1-mini'),
|
||||
system: SKILLS_SYSTEM_PROMPT,
|
||||
prompt,
|
||||
temperature: 0.3, // Lower temperature for more factual output
|
||||
});
|
||||
|
||||
return text;
|
||||
}
|
||||
287
docs/PRD-omega.md
Normal file
287
docs/PRD-omega.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# Omega PRD (Draft v0.1)
|
||||
|
||||
Project name: Omega
|
||||
Path: `/omega`
|
||||
Owner: TPMJS
|
||||
Status: Draft
|
||||
|
||||
## 1) Executive Summary
|
||||
Omega is a TPMJS side project that demonstrates what happens when a single agent can discover and execute tools from the entire TPM registry. The vision is playful and bold: "the last robot/AI you ever need" (Skynet energy, but friendly). Omega will ship as a dedicated chat experience at `/omega` and a public REST API that lets anyone start and continue conversations. The product must highlight tool discovery, selection, and execution at scale, while handling missing credentials gracefully.
|
||||
|
||||
## 2) Problem Statement
|
||||
TPMJS has powerful registry and tool execution capabilities, but the end-user experience does not yet show how these components combine into a single, continuous, tool-driven agent. Users need a simple way to:
|
||||
- Ask for outcomes, not tools
|
||||
- Watch the agent discover the right tool(s)
|
||||
- Provide credentials only when needed
|
||||
- Continue execution without restarting
|
||||
|
||||
## 3) Vision
|
||||
Omega is a single agent with access to the TPM registry, able to choose and run the right tools to complete tasks. Users can chat or call an API and see a transparent, step-by-step tool workflow. It is a public showcase that can become a standalone product over time.
|
||||
|
||||
## 4) Goals and Success Metrics
|
||||
### Goals
|
||||
- Showcase tool discovery and execution at scale (1M+ tools).
|
||||
- Deliver a great developer experience (DX) with simple API endpoints.
|
||||
- Make credential handling safe, clear, and resumable.
|
||||
- Provide a polished chat UI that is fast and transparent.
|
||||
|
||||
### Success Metrics (Early)
|
||||
- Conversations created per week
|
||||
- % conversations that use at least one tool
|
||||
- Median time to first tool call
|
||||
- Completion rate (user gets a result without abandoning)
|
||||
- Avg. tool-call count per conversation
|
||||
- User feedback: "felt like a real agent"
|
||||
|
||||
## 5) Non-Goals (v0)
|
||||
- Multi-agent orchestration
|
||||
- Enterprise SSO or complex permission tiers
|
||||
- Guaranteed deterministic tool selection
|
||||
- Complex billing or metering
|
||||
|
||||
## 6) Target Users and Use Cases
|
||||
### Personas
|
||||
- Developers evaluating TPMJS or building on it (primary for v0)
|
||||
- Power users exploring tool chains
|
||||
- Curious users experimenting with AI + tools
|
||||
|
||||
### Example Use Cases
|
||||
- "Summarize a website and turn it into a checklist"
|
||||
- "Analyze a CSV and generate insights"
|
||||
- "Generate a proposal and send it via email"
|
||||
- "Search for a tool to do X and run it"
|
||||
|
||||
## 7) Product Requirements
|
||||
|
||||
### 7.1 Chat UI (`/omega`)
|
||||
- Landing intro before first chat
|
||||
- New conversation
|
||||
- Message stream with agent responses
|
||||
- Tool activity panel (tool name, inputs, outputs, status)
|
||||
- Debug mode (raw JSON) and "view raw" toggles for tool I/O
|
||||
- Shareable conversation URL
|
||||
- Requires login (v0)
|
||||
- Credential prompt when missing env vars
|
||||
- Resume execution after credentials are provided
|
||||
- Planning panel for proposed plans (only when requested or task is complex)
|
||||
- Cancel running tool execution or full conversation run
|
||||
- Multi-user conversations: shared links allow anyone with URL to send messages
|
||||
- Show participant display names and Gravatar avatars (email-based)
|
||||
- Export conversation to JSON and Markdown
|
||||
- Lazy-load long outputs and message history (Virtuoso-based)
|
||||
- Auto-generate conversation title after first response
|
||||
- System status banner (registry + executor health)
|
||||
- Empty state includes creative sample prompts
|
||||
- Light/dark theme supported (use TPMJS style guide)
|
||||
|
||||
### 7.2 REST API
|
||||
Endpoints (initial):
|
||||
- `POST /api/omega/conversations`
|
||||
Create a conversation. Returns conversation ID and optional title.
|
||||
- `POST /api/omega/conversations/:id/messages`
|
||||
Send a message and stream response (SSE).
|
||||
- `GET /api/omega/conversations/:id`
|
||||
Fetch conversation history.
|
||||
- `POST /api/omega/conversations/:id/credentials`
|
||||
Provide missing env vars and resume.
|
||||
- `POST /api/omega/conversations/:id/plan`
|
||||
Optional: generate plan(s) without execution.
|
||||
- API access requires TPMJS API key (standard user keys).
|
||||
- Shared conversations accept either logged-in session or TPMJS API key.
|
||||
|
||||
### 7.3 Tool Discovery and Execution
|
||||
- Agent can search registry and execute tools.
|
||||
- Default path: registry search + execute tools (entire registry).
|
||||
- Registry execution uses TPMJS sandbox executor.
|
||||
- Allow tool usage transparency in UI and API responses.
|
||||
- Allow users to pin preferred tools for the agent to prioritize.
|
||||
- Pinned tools are always included in the plan/tool set.
|
||||
- Max tool calls per message: 200 (AI SDK `maxSteps`).
|
||||
|
||||
### 7.6 System Prompt and Safety Controls
|
||||
- Omega ships with a comprehensive default system prompt.
|
||||
- Users can extend/override with a custom prompt in Omega settings.
|
||||
- Safe Mode toggle: require explicit approval before tool execution (human-in-the-loop).
|
||||
|
||||
### 7.4 Planning
|
||||
- Agent can propose one or more plans before running tools.
|
||||
- Plans show tool order, purpose, expected outputs, and confidence.
|
||||
- User can select a plan or ask for another.
|
||||
- Planning can be auto-run for complex tasks or when explicitly requested.
|
||||
|
||||
### 7.5 Credentials / Missing Env Vars
|
||||
- Detect missing env vars for tools.
|
||||
- Pause execution when required env vars missing.
|
||||
- Provide a credential prompt in UI (inline modal).
|
||||
- Resume execution after credentials are supplied.
|
||||
- Allow API callers to supply env vars in requests.
|
||||
- Store credentials encrypted per-user in Omega settings.
|
||||
|
||||
## 8) UX Flows
|
||||
|
||||
### Flow A: Standard Chat
|
||||
1. User sends a message.
|
||||
2. Agent responds, streams tokens.
|
||||
3. Tool calls appear in tool panel.
|
||||
4. Tool results appear inline.
|
||||
5. Conversation persists.
|
||||
|
||||
### Flow B: Missing Credentials
|
||||
1. Tool requires env vars.
|
||||
2. Agent pauses and returns `MISSING_ENV_VARS`.
|
||||
3. UI displays required keys and descriptions.
|
||||
4. User provides keys.
|
||||
5. Execution resumes from same step.
|
||||
|
||||
### Flow C: Planning
|
||||
1. User sends a request.
|
||||
2. Agent responds with one or more plans.
|
||||
3. User selects a plan or asks for changes.
|
||||
4. Execution starts.
|
||||
|
||||
### Flow D: Tool Failure + Alternatives
|
||||
1. Tool fails or missing credentials.
|
||||
2. Agent searches for alternative tools.
|
||||
3. Agent presents options and required keys in a table.
|
||||
4. User selects a tool; execution resumes.
|
||||
|
||||
## 9) Data Model (High-Level)
|
||||
- Conversation: id, userId (optional), createdAt, updatedAt, title
|
||||
- Message: id, conversationId, role, content, createdAt
|
||||
- ToolRun: id, conversationId, messageId, toolId, input, output, status, duration
|
||||
- Plan: id, conversationId, messageId, steps, selectedPlanId, createdAt
|
||||
- CredentialRequest: id, conversationId, toolRunId, requiredKeys, status, createdAt
|
||||
- CredentialStore: userId, keyName, encryptedValue, scope (global/tool)
|
||||
|
||||
## 10) Security and Privacy
|
||||
- Encrypt stored credentials at rest.
|
||||
- Never expose full secret values in UI or logs.
|
||||
- Redact secrets from tool outputs when needed.
|
||||
- Rate limit API endpoints to prevent abuse.
|
||||
- Log rate limit events (even if limits are effectively unlimited at launch).
|
||||
- Audit logs for tool calls and credential access.
|
||||
|
||||
## 11) Technical Foundations (Reuse Existing)
|
||||
From current codebase:
|
||||
- Chat + SSE (agent conversations, tool-call streaming, message persistence):
|
||||
`apps/web/src/app/api/agents/[id]/conversation/[conversationId]/route.ts`
|
||||
- Pretty URL variant for agent chat (API key scopes + owner/public checks):
|
||||
`apps/web/src/app/api/[username]/agents/[agentSlug]/conversation/[conversationId]/route.ts`
|
||||
- Chat UI with tool panel + streaming + Virtuoso pagination:
|
||||
`apps/web/src/app/agents/[id]/chat/[chatId]/page.tsx`
|
||||
- Tool execution wrapper + AI SDK tool definition builder:
|
||||
`apps/web/src/lib/ai-agent/tool-executor-agent.ts`
|
||||
- Executor resolution + sandbox execution:
|
||||
`apps/web/src/lib/executors/index.ts`
|
||||
- Registry meta-tools for search + execute:
|
||||
`packages/tools/registrySearch`, `packages/tools/registryExecute`
|
||||
- Env var detection and missing var reporting:
|
||||
`apps/web/src/lib/agents/env-helpers.ts`
|
||||
- Env var cascade and caller-provided env support:
|
||||
`apps/web/src/lib/agents/build-tools.ts`
|
||||
- MCP handler with env-var validation for non-owners:
|
||||
`apps/web/src/lib/mcp/handlers.ts`
|
||||
- Credential encryption utilities and API key storage:
|
||||
`apps/web/src/lib/crypto/api-keys.ts`, `apps/web/src/app/api/user/api-keys/route.ts`
|
||||
- Existing planning tool for generatePlans:
|
||||
`packages/tools/official/tool-selection-plan`
|
||||
|
||||
## 12) Product Decisions (Initial)
|
||||
- Primary audience: developers.
|
||||
- Auth: login required (v0).
|
||||
- Tool access: entire registry via registry search + execute.
|
||||
- Model: default `gpt-4.1-mini`; users choose provider/model if they have keys.
|
||||
- Planning: only on explicit request or when task complexity is high.
|
||||
- Credentials: stored per-user, scoped to Omega, managed in Omega settings.
|
||||
- Safety: no additional tool restrictions beyond existing TPMJS policies.
|
||||
- Sharing: anyone with conversation URL can participate.
|
||||
- Retention: conversations are stored indefinitely.
|
||||
- Tool output: summarized by default with "view raw" toggle.
|
||||
- Cancel: users can stop a running tool or full run.
|
||||
- Rate limits: system in place, effectively unlimited at launch.
|
||||
- Participants: show display names + Gravatar avatars.
|
||||
- API auth: requires standard TPMJS API key.
|
||||
- Tool selection UX: agent-only (no manual marketplace UI for v0).
|
||||
- Files: uploads deferred to a later phase.
|
||||
- Streaming: stream tool outputs where supported by the AI SDK.
|
||||
- UI: follow TPMJS style guide (no bespoke theme for v0).
|
||||
- Conversation IDs: UUIDs.
|
||||
- Credentials: per-user only (no team/workspace creds in v0).
|
||||
- Conversation context: keep full history (no auto-summarization in v0).
|
||||
- Compliance: no special compliance requirements at launch.
|
||||
- Tool budget indicator: not required.
|
||||
- Shared conversation execution: only owner-stored credentials may run tools.
|
||||
- Large outputs: lazy-load via virtualization.
|
||||
- Export: JSON and Markdown supported.
|
||||
- Memory: per-conversation only.
|
||||
- Conversation titles: auto-generated after first response (AI SDK).
|
||||
- Reactions: not required for v0.
|
||||
- Tool reasoning: show only when model returns reasoning tokens.
|
||||
- Deletion: users cannot delete conversations in v0.
|
||||
- API keys: use existing TPMJS API keys only.
|
||||
- Discovery: conversations are not listed; URL-only access.
|
||||
- Context control: no reset button, use new chat instead.
|
||||
- Tool allowlist: no allowlist (pins only).
|
||||
- Audit/log retention: store all tool I/O indefinitely.
|
||||
- Shared chat auth: accept session auth or TPMJS API key.
|
||||
- Landing: intro view before first chat.
|
||||
- Empty state: creative sample prompts.
|
||||
- Theme: light/dark via style guide.
|
||||
|
||||
## 13) API Response Contracts (Draft)
|
||||
### Missing Env Vars
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Missing required environment variables",
|
||||
"details": {
|
||||
"code": "MISSING_ENV_VARS",
|
||||
"missingVars": [
|
||||
{ "name": "FIRECRAWL_API_KEY", "description": "API key for Firecrawl" }
|
||||
],
|
||||
"hint": "Provide these variables in the 'env' field of your request"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Plan Response
|
||||
```json
|
||||
{
|
||||
"plan": [
|
||||
{ "stepNumber": 1, "toolName": "registrySearchTool", "purpose": "Find tools for web scraping" },
|
||||
{ "stepNumber": 2, "toolName": "registryExecuteTool", "purpose": "Run the chosen tool" }
|
||||
],
|
||||
"confidence": 0.74,
|
||||
"reasoning": "Found 2 relevant tools for this task"
|
||||
}
|
||||
```
|
||||
|
||||
## 14) Performance and Reliability
|
||||
- SSE streams should stay responsive under load.
|
||||
- Tool execution timeouts (default 5 minutes).
|
||||
- Concurrency controls per conversation.
|
||||
- Circuit breaker for broken tools or executors.
|
||||
|
||||
## 15) Observability
|
||||
- Log tool calls and errors.
|
||||
- Track token usage per conversation.
|
||||
- Surface tool success/failure rates.
|
||||
- Store conversation logs for debugging.
|
||||
|
||||
## 16) Risks and Mitigations
|
||||
- Tool quality variance: provide confidence and fallbacks.
|
||||
- Missing credentials: pause/resume flow with clear UX.
|
||||
- Abuse risk: rate limiting + auth.
|
||||
- Tool execution cost: usage tracking + limits.
|
||||
|
||||
## 17) Rollout Plan
|
||||
1. Internal alpha (team-only)
|
||||
2. Public beta with rate limits
|
||||
3. Full public launch
|
||||
|
||||
## 18) Open Questions
|
||||
- System prompt ownership and defaults (versioning and updates)?
|
||||
- Safe Mode default on/off?
|
||||
- How many plans should Omega propose by default?
|
||||
- Webhook support for API events (future).
|
||||
- Profiles for system prompts (future).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
321
packages/cli/src/commands/run.ts
Normal file
321
packages/cli/src/commands/run.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue