diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6078ec38c..ea2054697 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -50,8 +50,20 @@ const OUTPUT_FORMATS = { table: { section: 'Human-readable formats', description: 'Formatted table with borders (default for lists)' }, tsv: { section: 'Machine-readable formats', description: 'Tab-separated values for Unix tools (cut, awk, grep, xargs)' }, json: { section: 'Machine-readable formats', description: 'JSON format for programmatic access and AI agents' }, + yaml: { section: 'Machine-readable formats', description: 'YAML format for human-friendly structured data' }, } as const; +/** + * Add the standard `-o, --output ` option to a command. + * @param cmd - The Commander command to add the option to. + * @param extra - Additional format names beyond the defaults (table, tsv, json, yaml). + * @returns The command (for chaining). + */ +function withOutputOption(cmd: Command, extra?: string[]): Command { + const formats = ['table', 'tsv', 'json', 'yaml', ...(extra ?? [])]; + return cmd.option("-o, --output ", `Output format: ${formats.join(', ')}`, "table"); +} + program .name("scope-mt") .description("Scope MT — AI coding agent benchmarking CLI") @@ -252,12 +264,15 @@ run } }); +withOutputOption( run .command("status") .description("Get status of a request") .requiredOption("-i, --id ", "Request ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; const { id } = options; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/requests/${id}`); @@ -269,6 +284,20 @@ run } const request = await response.json(); + + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: 'id', label: 'ID' }, + { key: 'workerType', label: 'Worker' }, + { key: 'status', label: 'Status' }, + { key: 'mode', label: 'Mode' }, + { key: 'createdAt', label: 'Created' }, + { key: 'completedAt', label: 'Completed' }, + ]; + console.log(formatData([request], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(request.id)}`); console.log(`${label('Worker:')} ${value(request.workerType)}`); console.log(`${label('Status:')} ${value(request.status)}`); @@ -281,12 +310,13 @@ run } }); +withOutputOption( run .command("get") .description("Get full details of a run") .requiredOption("-i, --id ", "Run ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { await runGetAction({ id: options.id, url: options.url, output: options.output }); }); @@ -350,13 +380,14 @@ run }); }); +withOutputOption( run .command("list") .description("List all requests") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") .option("-w, --worker ", "Filter by worker") .option("--include-deleted", "Include soft-deleted runs") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -682,12 +713,13 @@ const criteria = program configureHelp(criteria); +withOutputOption( criteria .command("list") .description("List all criteria") .option("-q, --query ", "Filter by ID or prompt text") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -734,12 +766,15 @@ criteria } }); +withOutputOption( criteria .command("get") .description("Get details of a single criterion") .requiredOption("-i, --id ", "Criterion ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/criteria/${options.id}`); @@ -754,6 +789,19 @@ criteria dependents: string[]; createdAt: string; updatedAt?: string; }; + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: 'id', label: 'ID' }, + { key: 'prompt', label: 'Prompt' }, + { key: 'dependsOn', label: 'Depends On', formatter: (item: any) => (item.dependsOn ?? []).join(', ') || '(none)' }, + { key: 'dependents', label: 'Dependents', formatter: (item: any) => (item.dependents ?? []).join(', ') || '(none)' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([c], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(c.id)}`); console.log(`${label('Prompt:')}`); for (const line of c.prompt.trim().split('\n')) { @@ -877,11 +925,14 @@ criteria } }); +withOutputOption( criteria .command("graph") .description("Display the criteria dependency graph as ASCII") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/criteria/graph`); @@ -896,6 +947,11 @@ criteria edges: Array<{ source: string; target: string }>; }; + if (isMachineReadable(format)) { + console.log(format === 'json' ? JSON.stringify(graph, null, 2) : format === 'yaml' ? yamlStringify(graph).trimEnd() : JSON.stringify(graph)); + return; + } + if (graph.nodes.length === 0) { console.log(warnBanner("No criteria in the graph.")); return; @@ -1100,12 +1156,13 @@ const promptFeature = program configureHelp(promptFeature); +withOutputOption( promptFeature .command("list") .description("List all prompt features") .option("-q, --query ", "Filter by ID or prompt text") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -1152,12 +1209,15 @@ promptFeature } }); +withOutputOption( promptFeature .command("get") .description("Get details of a single prompt feature") .requiredOption("-i, --id ", "Prompt feature ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/prompt-features/${options.id}`); @@ -1172,6 +1232,19 @@ promptFeature dependents: string[]; createdAt: string; updatedAt?: string; }; + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: 'id', label: 'ID' }, + { key: 'prompt', label: 'Prompt' }, + { key: 'dependsOn', label: 'Depends On', formatter: (item: any) => (item.dependsOn ?? []).join(', ') || '(none)' }, + { key: 'dependents', label: 'Dependents', formatter: (item: any) => (item.dependents ?? []).join(', ') || '(none)' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([f], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(f.id)}`); console.log(`${label('Prompt:')}`); for (const line of f.prompt.trim().split('\n')) { @@ -1295,11 +1368,14 @@ promptFeature } }); +withOutputOption( promptFeature .command("graph") .description("Display the prompt feature dependency graph as ASCII") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/prompt-features/graph`); @@ -1314,6 +1390,11 @@ promptFeature edges: Array<{ source: string; target: string }>; }; + if (isMachineReadable(format)) { + console.log(format === 'json' ? JSON.stringify(graph, null, 2) : format === 'yaml' ? yamlStringify(graph).trimEnd() : JSON.stringify(graph)); + return; + } + if (graph.nodes.length === 0) { console.log(warnBanner("No prompt features in the graph.")); return; @@ -1476,6 +1557,7 @@ promptFeature } }); +withOutputOption( promptFeature .command("extract") .description("Extract prompt features from a task text or scenario file (uses task prompt pipeline)") @@ -1484,7 +1566,9 @@ promptFeature .option("--model ", "LLM model to use for extraction") .option("--force", "Force re-extraction even if already extracted") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { let taskText = options.task; @@ -1501,7 +1585,7 @@ promptFeature console.error(errorText("Could not find 'task' field in scenario file")); process.exit(1); } - console.log(`${label('Scenario:')} ${value(basename(absPath))}`); + if (!isMachineReadable(format)) console.log(`${label('Scenario:')} ${value(basename(absPath))}`); } if (!taskText) { @@ -1510,7 +1594,7 @@ promptFeature } // Step 1: Register task prompt (idempotent) - console.log(`${label('Registering task prompt...')}`); + if (!isMachineReadable(format)) console.log(`${label('Registering task prompt...')}`); const createResponse = await fetch(`${normalizeUrl(options.url)}/api/v1/task-prompts`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1524,10 +1608,10 @@ promptFeature } const taskPromptDoc = await createResponse.json() as { _id: string }; - console.log(`${label('Task prompt ID:')} ${value(taskPromptDoc._id)}`); + if (!isMachineReadable(format)) console.log(`${label('Task prompt ID:')} ${value(taskPromptDoc._id)}`); // Step 2: Extract features on the task prompt entity - console.log(`${label('Extracting prompt features...')}`); + if (!isMachineReadable(format)) console.log(`${label('Extracting prompt features...')}`); const qs = options.force ? "?force=true" : ""; const body: Record = {}; @@ -1550,6 +1634,11 @@ promptFeature cached: boolean; }; + if (isMachineReadable(format)) { + console.log(format === 'json' ? JSON.stringify(extraction, null, 2) : format === 'yaml' ? yamlStringify(extraction).trimEnd() : JSON.stringify(extraction)); + return; + } + if (extraction.cached) { console.log(dimTimestamp('(cached — use --force to re-extract)')); } @@ -1674,13 +1763,15 @@ report } }); +withOutputOption( report .command("get") .description("Get a report by ID") .requiredOption("-i, --id ", "Report ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("--raw", "Output raw markdown without formatting") +, ['markdown']) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/reports/${options.id}`); @@ -1701,8 +1792,24 @@ report error?: string; }; - if (options.raw && report.content) { - console.log(report.content); + if (format === 'markdown') { + console.log(report.content ?? ''); + return; + } + + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: 'id', label: 'ID' }, + { key: 'requestId', label: 'Run' }, + { key: 'status', label: 'Status' }, + { key: 'reporter', label: 'Reporter', formatter: (r: any) => r.reporter ? `${r.reporter.name} (${r.reporter.agentId}@${r.reporter.agentVersion})` : '' }, + { key: 'model', label: 'Model', formatter: (r: any) => r.reporter?.model || '' }, + { key: 'content', label: 'Content', formatter: (r: any) => r.content || '' }, + { key: 'error', label: 'Error', formatter: (r: any) => r.error || '' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([report], fields, format)); return; } @@ -1733,12 +1840,13 @@ report } }); +withOutputOption( report .command("list") .description("List all reports (optionally filter by run)") .option("-r, --run ", "Filter by run ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -1870,11 +1978,12 @@ const reportTemplate = program configureHelp(reportTemplate); +withOutputOption( reportTemplate .command("list") .description("List all report templates") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -1924,12 +2033,15 @@ reportTemplate } }); +withOutputOption( reportTemplate .command("get") .description("Get details of a single report template") .requiredOption("-i, --id ", "Report template ID (slug)") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/report-templates/${options.id}`); @@ -1946,6 +2058,21 @@ reportTemplate createdAt: string; updatedAt?: string; }; + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: 'id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description', formatter: (item: any) => item.description || '' }, + { key: 'trigger', label: 'Trigger', formatter: (item: any) => item.trigger?.type ?? 'always' }, + { key: 'userPrompt', label: 'User Prompt' }, + { key: 'systemPrompt', label: 'System Prompt', formatter: (item: any) => item.systemPrompt ? `${item.systemPrompt.mode}: ${item.systemPrompt.content}` : '' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([t], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(t.id)}`); console.log(`${label('Name:')} ${t.name}`); if (t.description) console.log(`${label('Description:')} ${t.description}`); @@ -2321,11 +2448,12 @@ const agent = program configureHelp(agent); +withOutputOption( agent .command("list") .description("List all coding agents") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -2356,12 +2484,15 @@ agent } }); +withOutputOption( agent .command("get") .description("Get details of a coding agent") .requiredOption("-i, --id ", "Agent ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/agents/${encodeURIComponent(options.id)}`); if (!response.ok) { @@ -2370,6 +2501,21 @@ agent process.exit(1); } const agentDoc = await response.json(); + + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: '_id', label: 'ID' }, + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description', formatter: (a: any) => a.description || '' }, + { key: 'supportedModels', label: 'Supported Models', formatter: (a: any) => (a.supportedModels || []).join(', ') }, + { key: 'defaultModel', label: 'Default Model', formatter: (a: any) => a.defaultModel || '' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([agentDoc], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(agentDoc._id)}`); console.log(`${label('Name:')} ${value(agentDoc.name)}`); if (agentDoc.description) console.log(`${label('Description:')} ${agentDoc.description}`); @@ -2450,12 +2596,13 @@ const agentModel = agent configureHelp(agentModel); +withOutputOption( agentModel .command("list") .description("List supported models for a coding agent") .requiredOption("-i, --id ", "Agent ID") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -2627,11 +2774,12 @@ const mcpServer = mcp configureHelp(mcpServer); +withOutputOption( mcpServer .command("list") .description("List all MCP servers") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -2663,12 +2811,15 @@ mcpServer } }); +withOutputOption( mcpServer .command("get") .description("Get details of an MCP server") .requiredOption("-i, --id ", "MCP server slug") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/mcp/servers/${encodeURIComponent(options.id)}`); if (!response.ok) { @@ -2677,6 +2828,21 @@ mcpServer process.exit(1); } const server = await response.json(); + + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: '_id', label: 'Slug' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'url', label: 'URL' }, + { key: 'description', label: 'Description', formatter: (s: any) => s.description || '' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([server], fields, format)); + return; + } + console.log(`${label('Slug:')} ${value(server._id)}`); console.log(`${label('Name:')} ${value(server.name)}`); console.log(`${label('Type:')} ${value(server.type)}`); @@ -2826,11 +2992,12 @@ const skill = program configureHelp(skill); +withOutputOption( skill .command("list") .description("List all imported skills") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -2862,13 +3029,14 @@ skill } }); +withOutputOption( skill .command("search") .description("Search skills (internal + skills.sh registry)") .requiredOption("-q, --query ", "Search query") .option("--limit ", "Maximum results", parseInt) .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -2903,12 +3071,15 @@ skill } }); +withOutputOption( skill .command("get") .description("Get details of a skill") .requiredOption("-i, --id ", "Skill slug (e.g. vercel-labs/agent-skills/my-skill)") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/skills/${options.id}`); if (!response.ok) { @@ -2917,6 +3088,22 @@ skill process.exit(1); } const s = await response.json(); + + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: '_id', label: 'Slug' }, + { key: 'name', label: 'Name' }, + { key: 'source', label: 'Source' }, + { key: 'skillName', label: 'Skill Name' }, + { key: 'origin', label: 'Origin' }, + { key: 'description', label: 'Description', formatter: (sk: any) => sk.description || '' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([s], fields, format)); + return; + } + console.log(`${label('Slug:')} ${value(s._id)}`); console.log(`${label('Name:')} ${value(s.name)}`); console.log(`${label('Source:')} ${value(s.source)}`); @@ -2990,12 +3177,15 @@ skill } }); +withOutputOption( skill .command("resolve") .description("Resolve a skill from GitHub (fetch latest version and create a revision)") .requiredOption("-i, --id ", "Skill slug") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/skills/${options.id}/resolve`, { method: "POST", @@ -3006,6 +3196,18 @@ skill process.exit(1); } const revision = await response.json(); + + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: 'ref', label: 'Ref' }, + { key: 'commitHash', label: 'Commit' }, + { key: 'description', label: 'Description', formatter: (r: any) => r.description || '' }, + { key: 'archiveUrl', label: 'Archive URL', formatter: (r: any) => r.archiveUrl || '' }, + ]; + console.log(formatData([revision], fields, format)); + return; + } + console.log(successText(`Skill resolved to revision:`)); console.log(`${label('Ref:')} ${value(revision.ref)}`); console.log(`${label('Commit:')} ${value(revision.commitHash)}`); @@ -3017,13 +3219,14 @@ skill } }); +withOutputOption( skill .command("revisions") .description("List revisions for a skill") .requiredOption("-i, --id ", "Skill slug") .option("--limit ", "Maximum results", parseInt) .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -3066,13 +3269,14 @@ const insight = program configureHelp(insight); +withOutputOption( insight .command("list") .description("List all insights") .option("-q, --query ", "Search by keyword") .option("--blocked", "Show only blocked insights") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -3107,13 +3311,15 @@ insight } }); +withOutputOption( insight .command("get") .description("Get details of an insight (renders markdown description)") .requiredOption("-i, --id ", "Insight ID") - .option("--raw", "Print raw markdown without formatting") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +, ['markdown']) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/insights/${encodeURIComponent(options.id)}`); if (!response.ok) { @@ -3123,11 +3329,29 @@ insight } const insightDoc = await response.json(); - if (options.raw) { + if (format === 'markdown') { console.log(insightDoc.description); return; } + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: '_id', label: 'ID' }, + { key: 'title', label: 'Title' }, + { key: 'category', label: 'Category', formatter: (r: any) => r.category || '' }, + { key: 'tags', label: 'Tags', formatter: (r: any) => (r.tags ?? []).join(', ') }, + { key: 'votes', label: 'Votes', formatter: (r: any) => String(r.upvotes - r.downvotes) }, + { key: 'referenceCount', label: 'References', formatter: (r: any) => String(r.referenceCount) }, + { key: 'blocked', label: 'Blocked', formatter: (r: any) => r.blocked ? 'Yes' : 'No' }, + { key: 'createdBy', label: 'Created By' }, + { key: 'description', label: 'Description' }, + { key: 'createdAt', label: 'Created' }, + { key: 'updatedAt', label: 'Updated' }, + ]; + console.log(formatData([insightDoc], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(insightDoc._id)}`); console.log(`${label('Title:')} ${value(insightDoc.title)}`); if (insightDoc.category) console.log(`${label('Category:')} ${insightDoc.category}`); @@ -3342,6 +3566,7 @@ const taskPrompt = program configureHelp(taskPrompt); +withOutputOption( taskPrompt .command("list") .description("List all task prompts") @@ -3349,7 +3574,7 @@ taskPrompt .option("-l, --limit ", "Maximum number of results", "50") .option("--offset ", "Number of results to skip", "0") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") - .option("-o, --output ", "Output format: table, tsv, or json", "table") +) .action(async (options) => { const format = (options.output || 'table') as OutputFormat; try { @@ -3404,12 +3629,15 @@ taskPrompt } }); +withOutputOption( taskPrompt .command("get") .description("Get details of a single task prompt") .requiredOption("-i, --id ", "Task prompt ID (UUID)") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { const response = await fetch(`${normalizeUrl(options.url)}/api/v1/task-prompts/${encodeURIComponent(options.id)}`); @@ -3426,6 +3654,23 @@ taskPrompt createdAt: string; deletedAt?: string; }; + if (isMachineReadable(format)) { + const fields: DisplayField[] = [ + { key: '_id', label: 'ID' }, + { key: 'text', label: 'Text' }, + { key: 'features', label: 'Features', formatter: (item: any) => { + if (!item.features) return '(not extracted)'; + const detected = item.features.filter((f: any) => f.detected).length; + return `${detected}/${item.features.length} detected`; + }}, + { key: 'featuresExtractedAt', label: 'Extracted At' }, + { key: 'createdAt', label: 'Created' }, + { key: 'deletedAt', label: 'Deleted' }, + ]; + console.log(formatData([tp], fields, format)); + return; + } + console.log(`${label('ID:')} ${value(tp._id)}`); console.log(`${label('Created:')} ${value(tp.createdAt)}`); if (tp.deletedAt) console.log(`${label('Deleted:')} ${value(tp.deletedAt)}`); @@ -3533,6 +3778,7 @@ taskPrompt } }); +withOutputOption( taskPrompt .command("extract-features") .description("Extract prompt features for a task prompt") @@ -3540,9 +3786,13 @@ taskPrompt .option("--model ", "LLM model to use for extraction") .option("--force", "Force re-extraction even if already extracted") .option("-u, --url ", "API base URL", process.env.SCOPE_MT_API_URL || "http://localhost:3100") +) .action(async (options) => { + const format = (options.output || 'table') as OutputFormat; try { - console.log(`${label('Extracting prompt features for task prompt')} ${value(options.id)}${label('...')}`); + if (!isMachineReadable(format)) { + console.log(`${label('Extracting prompt features for task prompt')} ${value(options.id)}${label('...')}`); + } const qs = options.force ? "?force=true" : ""; const body: Record = {}; @@ -3567,6 +3817,11 @@ taskPrompt cached: boolean; }; + if (isMachineReadable(format)) { + console.log(format === 'json' ? JSON.stringify(extraction, null, 2) : format === 'yaml' ? yamlStringify(extraction).trimEnd() : JSON.stringify(extraction)); + return; + } + const detected = extraction.features.filter(r => r.detected); const notDetected = extraction.features.filter(r => !r.detected && r.evaluated); const skipped = extraction.features.filter(r => !r.evaluated); diff --git a/apps/cli/src/utils/formatters.test.ts b/apps/cli/src/utils/formatters.test.ts index 957b37c5e..9851c0785 100644 --- a/apps/cli/src/utils/formatters.test.ts +++ b/apps/cli/src/utils/formatters.test.ts @@ -6,6 +6,7 @@ import { formatAsTable, formatAsTSV, formatAsJSON, + formatAsYAML, formatData, isMachineReadable, getValueOrNA, @@ -14,7 +15,7 @@ import { } from './formatters.js'; import type { DisplayField, TableColumn } from './types.js'; -// ── Helper data ────────────────────────────────────────────────────────────── +// ── Helper data ───────────────────────────────────────────────────────────── const sampleItems = [ { id: 'abc-123', name: 'First', status: 'completed' }, @@ -28,7 +29,7 @@ const displayFields: DisplayField[] = [ { key: 'status', label: 'Status' }, ]; -// ── getValueOrNA ───────────────────────────────────────────────────────────── +// ── getValueOrNA ──────────────────────────────────────────────────────────── describe('getValueOrNA', () => { it('returns string value for non-null input', () => { @@ -44,7 +45,7 @@ describe('getValueOrNA', () => { }); }); -// ── formatDate ─────────────────────────────────────────────────────────────── +// ── formatDate ────────────────────────────────────────────────────────────── describe('formatDate', () => { it('formats a valid ISO date string', () => { @@ -62,7 +63,7 @@ describe('formatDate', () => { }); }); -// ── maxColumnWidth ─────────────────────────────────────────────────────────── +// ── maxColumnWidth ────────────────────────────────────────────────────────── describe('maxColumnWidth', () => { it('calculates width from data', () => { @@ -82,7 +83,7 @@ describe('maxColumnWidth', () => { }); }); -// ── formatAsTable ──────────────────────────────────────────────────────────── +// ── formatAsTable ─────────────────────────────────────────────────────────── describe('formatAsTable', () => { it('produces bordered table output', () => { @@ -155,7 +156,7 @@ describe('formatAsTable', () => { }); }); -// ── formatAsTSV ────────────────────────────────────────────────────────────── +// ── formatAsTSV ───────────────────────────────────────────────────────────── describe('formatAsTSV', () => { it('produces tab-separated output without headers', () => { @@ -198,7 +199,7 @@ describe('formatAsTSV', () => { }); }); -// ── formatAsJSON ───────────────────────────────────────────────────────────── +// ── formatAsJSON ──────────────────────────────────────────────────────────── describe('formatAsJSON', () => { it('produces valid JSON array', () => { @@ -233,7 +234,53 @@ describe('formatAsJSON', () => { }); }); -// ── formatData (dispatcher) ────────────────────────────────────────────────── +// ── formatAsYAML ──────────────────────────────────────────────────────────── + +describe('formatAsYAML', () => { + it('produces valid YAML output', () => { + const result = formatAsYAML(sampleItems, displayFields); + + // Should contain keys from displayFields + expect(result).toContain('id: abc-123'); + expect(result).toContain('name: First'); + expect(result).toContain('status: completed'); + }); + + it('produces a YAML sequence (list) with one entry per item', () => { + const result = formatAsYAML(sampleItems, displayFields); + + // YAML list items start with "- " + const listEntries = result.split('\n').filter((l: string) => l.startsWith('- ')); + expect(listEntries).toHaveLength(3); + }); + + it('applies custom formatters', () => { + const fields: DisplayField[] = [ + { key: 'id', label: 'ID', formatter: (item) => `prefix:${item.id}` }, + ]; + + const result = formatAsYAML(sampleItems, fields); + expect(result).toContain('prefix:abc-123'); + }); + + it('handles empty array', () => { + const result = formatAsYAML([], displayFields); + expect(result).toBe('[]'); + }); + + it('no trailing newline for clean piping', () => { + const result = formatAsYAML(sampleItems, displayFields); + expect(result.endsWith('\n')).toBe(false); + }); + + it('handles null/undefined values', () => { + const items = [{ id: 'test', name: null, status: undefined }]; + const result = formatAsYAML(items as any, displayFields); + expect(result).toContain('N/A'); + }); +}); + +// ── formatData (dispatcher) ───────────────────────────────────────────────── describe('formatData', () => { it('dispatches to table format', () => { @@ -257,13 +304,19 @@ describe('formatData', () => { expect(parsed[0].id).toBe('abc-123'); }); + it('dispatches to yaml format', () => { + const result = formatData(sampleItems, displayFields, 'yaml'); + expect(result).toContain('id: abc-123'); + expect(result).toContain('name: First'); + }); + it('throws on unsupported format', () => { - expect(() => formatData(sampleItems, displayFields, 'yaml' as any)).toThrow( - 'Unsupported output format: yaml', + expect(() => formatData(sampleItems, displayFields, 'csv' as any)).toThrow( + 'Unsupported output format: csv', ); }); - it('uses tableFormatter for table format, plain formatter for tsv/json', () => { + it('uses tableFormatter for table format, plain formatter for tsv/json/yaml', () => { const fields: DisplayField[] = [ { key: 'id', @@ -283,15 +336,20 @@ describe('formatData', () => { const jsonResult = formatData(sampleItems, fields, 'json'); expect(jsonResult).toContain('abc-123'); expect(jsonResult).not.toContain('STYLED:'); + + const yamlResult = formatData(sampleItems, fields, 'yaml'); + expect(yamlResult).toContain('abc-123'); + expect(yamlResult).not.toContain('STYLED:'); }); }); -// ── isMachineReadable ──────────────────────────────────────────────────────── +// ── isMachineReadable ─────────────────────────────────────────────────────── describe('isMachineReadable', () => { - it('returns true for tsv and json', () => { + it('returns true for tsv, json, and yaml', () => { expect(isMachineReadable('tsv')).toBe(true); expect(isMachineReadable('json')).toBe(true); + expect(isMachineReadable('yaml')).toBe(true); }); it('returns false for table', () => { diff --git a/apps/cli/src/utils/formatters.ts b/apps/cli/src/utils/formatters.ts index 136a3e863..ec10a1b6b 100644 --- a/apps/cli/src/utils/formatters.ts +++ b/apps/cli/src/utils/formatters.ts @@ -3,12 +3,13 @@ /** * Central formatting engine for CLI output. - * Supports table, TSV, and JSON output formats. - * Ported from ca-geo/packages/cli reference implementation, with JSON added. + * Supports table, TSV, JSON, and YAML output formats. + * Ported from ca-geo/packages/cli reference implementation, with JSON and YAML added. */ import Table from 'cli-table3'; import { stringify } from 'csv-stringify/sync'; +import { stringify as yamlStringify } from 'yaml'; import type { TableColumn, ListItem, OutputFormat, DisplayField } from './types.js'; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -143,11 +144,33 @@ export function formatAsJSON( return JSON.stringify(rows, null, 2); } +// ── Format: YAML ───────────────────────────────────────────────────────────── + +/** + * Format items as YAML. + * Uses the display fields' formatters to transform values, keyed by field key. + */ +export function formatAsYAML( + items: T[], + fields: DisplayField[], +): string { + const rows = items.map((item) => { + const obj: Record = {}; + for (const field of fields) { + obj[field.key] = field.formatter + ? field.formatter(item) + : getValueOrNA(item[field.key]); + } + return obj; + }); + return yamlStringify(rows).trimEnd(); +} + // ── Dispatcher ─────────────────────────────────────────────────────────────── /** * Format data based on output format. - * Routes to the appropriate formatter (table, tsv, or json). + * Routes to the appropriate formatter (table, tsv, json, or yaml). */ export function formatData( items: T[], @@ -169,15 +192,17 @@ export function formatData( return formatAsTSV(items, fields); case 'json': return formatAsJSON(items, fields); + case 'yaml': + return formatAsYAML(items, fields); default: throw new Error(`Unsupported output format: ${format}`); } } /** - * Check if the given format is machine-readable (tsv or json). + * Check if the given format is machine-readable (tsv, json, or yaml). * Used to suppress decorative output (banners, styled headers) for piping. */ export function isMachineReadable(format: OutputFormat): boolean { - return format === 'tsv' || format === 'json'; + return format === 'tsv' || format === 'json' || format === 'yaml'; } diff --git a/apps/cli/src/utils/types.ts b/apps/cli/src/utils/types.ts index 9fad3260b..8dde4f14d 100644 --- a/apps/cli/src/utils/types.ts +++ b/apps/cli/src/utils/types.ts @@ -7,10 +7,10 @@ */ /** Supported output formats for list commands */ -export type OutputFormat = 'table' | 'tsv' | 'json'; +export type OutputFormat = 'table' | 'tsv' | 'json' | 'yaml'; /** All valid output format values */ -export const OUTPUT_FORMAT_VALUES: OutputFormat[] = ['table', 'tsv', 'json']; +export const OUTPUT_FORMAT_VALUES: OutputFormat[] = ['table', 'tsv', 'json', 'yaml']; /** Display field descriptor — unified across all output formats */ export interface DisplayField {