From a473302a9f2d2de2ea81b0f98dacccbab43fd9f8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Feb 2026 12:33:18 +0700 Subject: [PATCH] refactor: use --json flag alias instead of --format json throughout CLI --- packages/cli/docs/output-architecture.md | 9 ++- packages/cli/src/cli.ts | 21 ++--- packages/cli/src/commands/agent/index.ts | 14 ++-- packages/cli/src/commands/agent/logs.ts | 55 ++++++++----- packages/cli/src/commands/daemon/index.ts | 9 +-- packages/cli/src/commands/permit/index.ts | 9 +-- packages/cli/src/commands/provider/index.ts | 14 +--- packages/cli/src/commands/worktree/index.ts | 8 +- packages/cli/src/output/with-output.ts | 20 ++++- packages/cli/tests/03-daemon.test.ts | 14 ++-- packages/cli/tests/04-agent-ls.test.ts | 14 ++-- packages/cli/tests/09-agent-inspect.test.ts | 10 +-- packages/cli/tests/11-agent-wait.test.ts | 10 +-- packages/cli/tests/12-permit-ls.test.ts | 14 ++-- packages/cli/tests/14-worktree.test.ts | 10 +-- packages/cli/tests/15-provider.test.ts | 20 ++--- .../cli/tests/e2e/agent-lifecycle.test.ts | 6 +- packages/cli/tests/e2e/permissions.test.ts | 4 +- .../src/server/agent/agent-sdk-types.ts | 40 +++++++++- .../server/agent/providers/claude-agent.ts | 79 ++++++++++++++----- .../server/agent/providers/codex-mcp-agent.ts | 73 ++++++++++++----- packages/server/src/shared/messages.ts | 27 ++++++- 22 files changed, 313 insertions(+), 167 deletions(-) diff --git a/packages/cli/docs/output-architecture.md b/packages/cli/docs/output-architecture.md index 186e2ff5e..a3778f29b 100644 --- a/packages/cli/docs/output-architecture.md +++ b/packages/cli/docs/output-architecture.md @@ -380,7 +380,7 @@ async function renderStream( ### NDJSON for Streaming -When `--format json` is used with streaming commands, output is newline-delimited JSON (NDJSON) for easy parsing: +When `--json` (or `--format json`) is used with streaming commands, output is newline-delimited JSON (NDJSON) for easy parsing: ``` {"timestamp":"2024-01-15T10:30:00Z","type":"stdout","content":"Hello"} @@ -451,8 +451,8 @@ E2E tests can verify both structured data (for correctness) and formatted output ```typescript // Verify JSON output is valid and contains expected data -test('agent list --format json', async () => { - const output = await ctx.paseo('agent list --format json') +test('agent list --json', async () => { + const output = await ctx.paseo('agent list --json') const data = JSON.parse(output.stdout) expect(data).toBeInstanceOf(Array) @@ -475,7 +475,8 @@ Add output options to the root command: ```typescript program - .option('-f, --format ', 'Output format: table, json, yaml', 'table') + .option('-o, --format ', 'Output format: table, json, yaml', 'table') + .option('--json', 'Output in JSON format (alias for --format json)') .option('-q, --quiet', 'Minimal output (IDs only)') .option('--no-headers', 'Omit table headers') .option('--no-color', 'Disable colored output') diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 721412956..7f0c5bb90 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,7 +32,8 @@ export function createCli(): Command { .description('Paseo CLI - control your AI coding agents from the command line') .version(VERSION, '-v, --version', 'output the version number') // Global output options - .option('-f, --format ', 'output format: table, json, yaml', 'table') + .option('-o, --format ', 'output format: table, json, yaml', 'table') + .option('--json', 'output in JSON format (alias for --format json)') .option('-q, --quiet', 'minimal output (IDs only)') .option('--no-headers', 'omit table headers') .option('--no-color', 'disable colored output') @@ -47,12 +48,7 @@ export function createCli(): Command { .option('--ui', 'Show only UI agents (equivalent to --label ui=true)') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((options, command) => { - if (options.json) { - command.parent.opts().format = 'json' - } - return withOutput(runLsCommand)(options, command) - }) + .action(withOutput(runLsCommand)) program .command('run') @@ -69,6 +65,7 @@ export function createCli(): Command { .option('--cwd ', 'Working directory (default: current)') .option('--label ', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runRunCommand)) @@ -96,6 +93,7 @@ export function createCli(): Command { .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') .option('--all', 'Stop all agents') .option('--cwd ', 'Stop all agents in directory') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runStopCommand)) @@ -106,6 +104,7 @@ export function createCli(): Command { .argument('', 'The message to send') .option('--no-wait', 'Return immediately without waiting for completion') .option('--image ', 'Attach image(s) to the message', collectMultiple, []) + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runSendCommand)) @@ -115,18 +114,14 @@ export function createCli(): Command { .argument('', 'Agent ID (or prefix)') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((id, options, command) => { - if (options.json) { - command.parent.opts().format = 'json' - } - return withOutput(runInspectCommand)(id, options, command) - }) + .action(withOutput(runInspectCommand)) program .command('wait') .description('Wait for an agent to become idle') .argument('', 'Agent ID (or prefix)') .option('--timeout ', 'Maximum wait time (default: 600)') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runWaitCommand)) diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index bd165d57b..0ef44363f 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -29,12 +29,7 @@ export function createAgentCommand(): Command { .option('--ui', 'Show only UI agents (equivalent to --label ui=true)') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((options, command) => { - if (options.json) { - command.parent.parent.opts().format = 'json' - } - return withOutput(runLsCommand)(options, command) - }) + .action(withOutput(runLsCommand)) agent .command('run') @@ -48,6 +43,7 @@ export function createAgentCommand(): Command { .option('--cwd ', 'Working directory (default: current)') .option('--label ', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runRunCommand)) @@ -74,6 +70,7 @@ export function createAgentCommand(): Command { .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') .option('--all', 'Stop all agents') .option('--cwd ', 'Stop all agents in directory') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runStopCommand)) @@ -83,6 +80,7 @@ export function createAgentCommand(): Command { .argument('', 'Agent ID (or prefix)') .argument('', 'The message to send') .option('--no-wait', 'Return immediately without waiting for completion') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runSendCommand)) @@ -90,6 +88,7 @@ export function createAgentCommand(): Command { .command('inspect') .description('Show detailed information about an agent') .argument('', 'Agent ID (or prefix)') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runInspectCommand)) @@ -98,6 +97,7 @@ export function createAgentCommand(): Command { .description('Wait for an agent to become idle') .argument('', 'Agent ID (or prefix)') .option('--timeout ', 'Maximum wait time (default: 600)') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runWaitCommand)) @@ -108,6 +108,7 @@ export function createAgentCommand(): Command { .argument('', 'Agent ID (or prefix)') .argument('[mode]', 'Mode to set (required unless --list)') .option('--list', 'List available modes for this agent') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runModeCommand)) @@ -116,6 +117,7 @@ export function createAgentCommand(): Command { .description('Archive an agent (soft-delete)') .argument('', 'Agent ID (or prefix)') .option('--force', 'Force archive running agent') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runArchiveCommand)) diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 9999cca6e..ba844ec77 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -20,6 +20,15 @@ export interface AgentLogsOptions extends CommandOptions { export type AgentLogsResult = void +function parseTailCount(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined + const parsed = Number.parseInt(raw, 10) + if (Number.isNaN(parsed) || parsed < 0) { + return undefined + } + return parsed +} + /** * Check if a timeline item matches the filter type */ @@ -110,6 +119,12 @@ export async function runLogsCommand( // For follow mode, we stream events continuously if (options.follow) { + if (options.tail !== undefined && parseTailCount(options.tail) === undefined) { + console.error(`Error: Invalid --tail value: ${options.tail}`) + console.error('Usage: --tail (where n is >= 0)') + await client.close().catch(() => {}) + process.exit(1) + } await runFollowMode(client, resolvedId, options) return } @@ -159,18 +174,22 @@ export async function runLogsCommand( timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter)) } - // Apply tail limit - if (options.tail) { - const tailCount = parseInt(options.tail, 10) - if (!isNaN(tailCount) && tailCount > 0) { - timelineItems = timelineItems.slice(-tailCount) - } + const tailCount = parseTailCount(options.tail) + if (options.tail !== undefined && tailCount === undefined) { + console.error(`Error: Invalid --tail value: ${options.tail}`) + console.error('Usage: --tail (where n is >= 0)') + await client.close().catch(() => {}) + process.exit(1) } await client.close() // Use curateAgentActivity to format the transcript - const transcript = curateAgentActivity(timelineItems) + if (tailCount === 0) { + return + } + + const transcript = curateAgentActivity(timelineItems, tailCount !== undefined ? { maxItems: tailCount } : undefined) console.log(transcript) } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -188,6 +207,9 @@ async function runFollowMode( agentId: string, options: AgentLogsOptions ): Promise { + const DEFAULT_FOLLOW_TAIL = 10 + const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL + // First, get existing timeline const snapshotPromise = new Promise((resolve) => { const timeout = setTimeout(() => resolve([]), 10000) @@ -218,22 +240,17 @@ async function runFollowMode( existingItems = existingItems.filter((item) => matchesFilter(item, options.filter)) } - // Apply tail to existing items - if (options.tail) { - const tailCount = parseInt(options.tail, 10) - if (!isNaN(tailCount) && tailCount > 0) { - existingItems = existingItems.slice(-tailCount) + // Print existing transcript (tail-like behavior) + if (tailCount > 0) { + const existingTranscript = curateAgentActivity(existingItems, { maxItems: tailCount }) + if (existingTranscript !== 'No activity to display.') { + console.log(existingTranscript) } } - // Print existing transcript - const existingTranscript = curateAgentActivity(existingItems) - if (existingTranscript !== 'No activity to display.') { - console.log(existingTranscript) - } - // Subscribe to new events - console.log('\n--- Following logs (Ctrl+C to stop) ---\n') + const tailLabel = tailCount === 0 ? 'no history' : `last ${tailCount} entr${tailCount === 1 ? 'y' : 'ies'}` + console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`) const unsubscribe = client.on('agent_stream', (msg: unknown) => { const message = msg as AgentStreamMessage diff --git a/packages/cli/src/commands/daemon/index.ts b/packages/cli/src/commands/daemon/index.ts index ea65b488c..57936a7d9 100644 --- a/packages/cli/src/commands/daemon/index.ts +++ b/packages/cli/src/commands/daemon/index.ts @@ -15,22 +15,19 @@ export function createDaemonCommand(): Command { .description('Show daemon status') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((options, command) => { - if (options.json) { - command.parent.parent.opts().format = 'json' - } - return withOutput(runStatusCommand)(options, command) - }) + .action(withOutput(runStatusCommand)) daemon .command('stop') .description('Stop the daemon') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runStopCommand)) daemon .command('restart') .description('Restart the daemon') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runRestartCommand)) diff --git a/packages/cli/src/commands/permit/index.ts b/packages/cli/src/commands/permit/index.ts index 9eb9e0e50..89cec5a9f 100644 --- a/packages/cli/src/commands/permit/index.ts +++ b/packages/cli/src/commands/permit/index.ts @@ -12,12 +12,7 @@ export function createPermitCommand(): Command { .description('List all pending permissions') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((options, command) => { - if (options.json) { - command.parent.parent.opts().format = 'json' - } - return withOutput(runLsCommand)(options, command) - }) + .action(withOutput(runLsCommand)) permit .command('allow') @@ -26,6 +21,7 @@ export function createPermitCommand(): Command { .argument('[req_id]', 'Permission request ID (optional if --all)') .option('--all', 'Allow all pending permissions for this agent') .option('--input ', 'Modified input parameters (JSON)') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runAllowCommand)) @@ -37,6 +33,7 @@ export function createPermitCommand(): Command { .option('--all', 'Deny all pending permissions for this agent') .option('--message ', 'Denial reason message') .option('--interrupt', 'Stop agent after denial') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runDenyCommand)) diff --git a/packages/cli/src/commands/provider/index.ts b/packages/cli/src/commands/provider/index.ts index 7a614d447..1f226831e 100644 --- a/packages/cli/src/commands/provider/index.ts +++ b/packages/cli/src/commands/provider/index.ts @@ -11,12 +11,7 @@ export function createProviderCommand(): Command { .description('List available providers and status') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((options, command) => { - if (options.json) { - command.parent.parent.opts().format = 'json' - } - return withOutput(runLsCommand)(options, command) - }) + .action(withOutput(runLsCommand)) provider .command('models') @@ -24,12 +19,7 @@ export function createProviderCommand(): Command { .argument('', 'Provider name (claude, codex, opencode)') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((provider, options, command) => { - if (options.json) { - command.parent.parent.opts().format = 'json' - } - return withOutput(runModelsCommand)(provider, options, command) - }) + .action(withOutput(runModelsCommand)) return provider } diff --git a/packages/cli/src/commands/worktree/index.ts b/packages/cli/src/commands/worktree/index.ts index ee0495f20..4c7dd7c09 100644 --- a/packages/cli/src/commands/worktree/index.ts +++ b/packages/cli/src/commands/worktree/index.ts @@ -11,17 +11,13 @@ export function createWorktreeCommand(): Command { .description('List Paseo-managed git worktrees') .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') - .action((options, command) => { - if (options.json) { - command.parent.parent.opts().format = 'json' - } - return withOutput(runLsCommand)(options, command) - }) + .action(withOutput(runLsCommand)) worktree .command('archive') .description('Archive a worktree (removes worktree and associated branch)') .argument('', 'Worktree name or branch name') + .option('--json', 'Output in JSON format') .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runArchiveCommand)) diff --git a/packages/cli/src/output/with-output.ts b/packages/cli/src/output/with-output.ts index 509e0bb29..4028d69de 100644 --- a/packages/cli/src/output/with-output.ts +++ b/packages/cli/src/output/with-output.ts @@ -5,7 +5,7 @@ */ import type { Command } from 'commander' -import type { AnyCommandResult, OutputOptions } from './types.js' +import type { AnyCommandResult, CommandError, OutputOptions } from './types.js' import { render, renderError, toCommandError, defaultOutputOptions } from './render.js' /** Options that include output settings from global options */ @@ -13,10 +13,26 @@ export interface CommandOptions extends Partial { [key: string]: unknown } +function normalizeFormat(raw: unknown): OutputOptions['format'] { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : '' + + // Common user expectation: "cli" means "table/human" + if (value === 'cli') return 'table' + + if (value === 'table' || value === 'json' || value === 'yaml') return value + + const error: CommandError = { + code: 'INVALID_FORMAT', + message: `Unsupported output format: ${String(raw)}`, + details: 'Supported formats: table, json, yaml', + } + throw error +} + /** Extract output options from command options */ function extractOutputOptions(options: CommandOptions): OutputOptions { return { - format: (options.format as OutputOptions['format']) ?? defaultOutputOptions.format, + format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format), quiet: options.quiet ?? defaultOutputOptions.quiet, noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false noColor: options.color === false, // Commander uses --no-color -> color: false diff --git a/packages/cli/tests/03-daemon.test.ts b/packages/cli/tests/03-daemon.test.ts index fd818d80f..ccd5894df 100644 --- a/packages/cli/tests/03-daemon.test.ts +++ b/packages/cli/tests/03-daemon.test.ts @@ -9,7 +9,7 @@ * Tests: * - daemon --help shows subcommands * - daemon status fails gracefully when daemon not running - * - daemon status --format json outputs valid JSON (even for errors) + * - daemon status --json outputs valid JSON (even for errors) * - daemon stop handles daemon not running gracefully */ @@ -56,11 +56,11 @@ try { console.log('✓ daemon status fails gracefully when not running\n') } - // Test 3: daemon status --format json outputs valid JSON (even for errors) + // Test 3: daemon status --json outputs valid JSON (even for errors) { - console.log('Test 3: daemon status --format json outputs JSON') + console.log('Test 3: daemon status --json outputs JSON') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --format json`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo daemon status --json`.nothrow() // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') // But output should be valid JSON @@ -68,14 +68,14 @@ try { if (output.length > 0) { try { JSON.parse(output) - console.log('✓ daemon status --format json outputs valid JSON\n') + console.log('✓ daemon status --json outputs valid JSON\n') } catch { // If stdout is empty, check if stderr has the error (acceptable for now) - console.log('✓ daemon status --format json handled error (output may be in stderr)\n') + console.log('✓ daemon status --json handled error (output may be in stderr)\n') } } else { // Empty stdout is acceptable if error is in stderr - console.log('✓ daemon status --format json handled error gracefully\n') + console.log('✓ daemon status --json handled error gracefully\n') } } diff --git a/packages/cli/tests/04-agent-ls.test.ts b/packages/cli/tests/04-agent-ls.test.ts index 5aadc8774..7c599289b 100644 --- a/packages/cli/tests/04-agent-ls.test.ts +++ b/packages/cli/tests/04-agent-ls.test.ts @@ -13,7 +13,7 @@ * - paseo --help shows ls command * - paseo ls --help shows options * - paseo ls returns empty list or error when no daemon - * - paseo ls --format json returns valid JSON (or error) + * - paseo ls --json returns valid JSON (or error) * - paseo ls -a flag is accepted * - paseo ls -g flag is accepted */ @@ -71,11 +71,11 @@ try { console.log('✓ paseo ls handles daemon not running\n') } - // Test 4: paseo ls --format json returns valid JSON error + // Test 4: paseo ls --json returns valid JSON error { - console.log('Test 4: paseo ls --format json handles errors') + console.log('Test 4: paseo ls --json handles errors') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls --format json`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo ls --json`.nothrow() // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') // But output should be valid JSON if present @@ -83,13 +83,13 @@ try { if (output.length > 0) { try { JSON.parse(output) - console.log('✓ paseo ls --format json outputs valid JSON error\n') + console.log('✓ paseo ls --json outputs valid JSON error\n') } catch { // Empty or stderr-only output is acceptable - console.log('✓ paseo ls --format json handled error (output may be in stderr)\n') + console.log('✓ paseo ls --json handled error (output may be in stderr)\n') } } else { - console.log('✓ paseo ls --format json handled error gracefully\n') + console.log('✓ paseo ls --json handled error gracefully\n') } } diff --git a/packages/cli/tests/09-agent-inspect.test.ts b/packages/cli/tests/09-agent-inspect.test.ts index 04fa58f2f..638e43ff4 100644 --- a/packages/cli/tests/09-agent-inspect.test.ts +++ b/packages/cli/tests/09-agent-inspect.test.ts @@ -98,15 +98,15 @@ try { console.log('-q (quiet) flag is accepted with inspect\n') } - // Test 6: --format json flag is accepted with inspect + // Test 6: --json flag is accepted with inspect { - console.log('Test 6: --format json flag is accepted with inspect') + console.log('Test 6: --json flag is accepted with inspect') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format json inspect abc123`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow() const output = result.stdout + result.stderr - assert(!output.includes('unknown option'), 'should accept --format json flag') + assert(!output.includes('unknown option'), 'should accept --json flag') assert(!output.includes('error: option'), 'should not have option parsing error') - console.log('--format json flag is accepted with inspect\n') + console.log('--json flag is accepted with inspect\n') } // Test 7: --format yaml flag is accepted with inspect diff --git a/packages/cli/tests/11-agent-wait.test.ts b/packages/cli/tests/11-agent-wait.test.ts index 6ce9d21bc..574d766bb 100644 --- a/packages/cli/tests/11-agent-wait.test.ts +++ b/packages/cli/tests/11-agent-wait.test.ts @@ -112,15 +112,15 @@ try { console.log('-q (quiet) flag is accepted with wait\n') } - // Test 7: --format json flag is accepted with wait + // Test 7: --json flag is accepted with wait { - console.log('Test 7: --format json flag is accepted with wait') + console.log('Test 7: --json flag is accepted with wait') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format json wait abc123`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow() const output = result.stdout + result.stderr - assert(!output.includes('unknown option'), 'should accept --format json flag') + assert(!output.includes('unknown option'), 'should accept --json flag') assert(!output.includes('error: option'), 'should not have option parsing error') - console.log('--format json flag is accepted with wait\n') + console.log('--json flag is accepted with wait\n') } // Test 8: --format yaml flag is accepted with wait diff --git a/packages/cli/tests/12-permit-ls.test.ts b/packages/cli/tests/12-permit-ls.test.ts index 55dedd672..c910fa535 100644 --- a/packages/cli/tests/12-permit-ls.test.ts +++ b/packages/cli/tests/12-permit-ls.test.ts @@ -13,7 +13,7 @@ * - permit --help shows subcommands * - permit ls --help shows options * - permit ls returns error when no daemon - * - permit ls --format json handles errors + * - permit ls --json handles errors */ import assert from 'node:assert' @@ -67,11 +67,11 @@ try { console.log('✓ permit ls handles daemon not running\n') } - // Test 4: permit ls --format json handles errors + // Test 4: permit ls --json handles errors { - console.log('Test 4: permit ls --format json handles errors') + console.log('Test 4: permit ls --json handles errors') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --format json`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow() // Should still fail (daemon not running) assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') // But output should be valid JSON if present @@ -79,13 +79,13 @@ try { if (output.length > 0) { try { JSON.parse(output) - console.log('✓ permit ls --format json outputs valid JSON error\n') + console.log('✓ permit ls --json outputs valid JSON error\n') } catch { // Empty or stderr-only output is acceptable - console.log('✓ permit ls --format json handled error (output may be in stderr)\n') + console.log('✓ permit ls --json handled error (output may be in stderr)\n') } } else { - console.log('✓ permit ls --format json handled error gracefully\n') + console.log('✓ permit ls --json handled error gracefully\n') } } diff --git a/packages/cli/tests/14-worktree.test.ts b/packages/cli/tests/14-worktree.test.ts index 49d554a41..e498f9233 100644 --- a/packages/cli/tests/14-worktree.test.ts +++ b/packages/cli/tests/14-worktree.test.ts @@ -142,15 +142,15 @@ try { console.log('✓ -q (quiet) flag is accepted with worktree ls\n') } - // Test 10: -f json flag is accepted with worktree ls + // Test 10: --json flag is accepted with worktree ls { - console.log('Test 10: -f json flag is accepted with worktree ls') + console.log('Test 10: --json flag is accepted with worktree ls') const result = - await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -f json worktree ls`.nothrow() + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow() const output = result.stdout + result.stderr - assert(!output.includes('unknown option'), 'should accept -f json flag') + assert(!output.includes('unknown option'), 'should accept --json flag') assert(!output.includes('error: option'), 'should not have option parsing error') - console.log('✓ -f json flag is accepted with worktree ls\n') + console.log('✓ --json flag is accepted with worktree ls\n') } // Test 11: paseo --help shows worktree subcommand diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index dac5ca569..f2494cf80 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -9,13 +9,13 @@ * Tests: * - provider --help shows subcommands * - provider ls lists all providers - * - provider ls --format json outputs valid JSON + * - provider ls --json outputs valid JSON * - provider ls --quiet outputs provider names only * - provider models claude lists claude models * - provider models codex lists codex models * - provider models opencode lists opencode models * - provider models unknown fails with error - * - provider models --format json outputs valid JSON + * - provider models --json outputs valid JSON */ import assert from 'node:assert' @@ -47,10 +47,10 @@ console.log('=== Provider Commands ===\n') console.log('✓ provider ls lists all providers\n') } -// Test 3: provider ls --format json outputs valid JSON +// Test 3: provider ls --json outputs valid JSON { - console.log('Test 3: provider ls --format json outputs valid JSON') - const result = await $`npx paseo provider ls --format json`.nothrow() + console.log('Test 3: provider ls --json outputs valid JSON') + const result = await $`npx paseo provider ls --json`.nothrow() assert.strictEqual(result.exitCode, 0, 'should exit 0') const data = JSON.parse(result.stdout.trim()) assert(Array.isArray(data), 'output should be an array') @@ -58,7 +58,7 @@ console.log('=== Provider Commands ===\n') assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude') assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex') assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode') - console.log('✓ provider ls --format json outputs valid JSON\n') + console.log('✓ provider ls --json outputs valid JSON\n') } // Test 4: provider ls --quiet outputs provider names only @@ -119,16 +119,16 @@ console.log('=== Provider Commands ===\n') console.log('✓ provider models unknown fails with error\n') } -// Test 9: provider models --format json outputs valid JSON +// Test 9: provider models --json outputs valid JSON { - console.log('Test 9: provider models --format json outputs valid JSON') - const result = await $`npx paseo provider models claude --format json`.nothrow() + console.log('Test 9: provider models --json outputs valid JSON') + const result = await $`npx paseo provider models claude --json`.nothrow() assert.strictEqual(result.exitCode, 0, 'should exit 0') const data = JSON.parse(result.stdout.trim()) assert(Array.isArray(data), 'output should be an array') assert.strictEqual(data.length, 3, 'should have 3 models for claude') assert(data.every((m: { model: string; id: string }) => m.model && m.id), 'each model should have name and id') - console.log('✓ provider models --format json outputs valid JSON\n') + console.log('✓ provider models --json outputs valid JSON\n') } // Test 10: provider models --quiet outputs model IDs only diff --git a/packages/cli/tests/e2e/agent-lifecycle.test.ts b/packages/cli/tests/e2e/agent-lifecycle.test.ts index f9b6d8955..d1875884f 100644 --- a/packages/cli/tests/e2e/agent-lifecycle.test.ts +++ b/packages/cli/tests/e2e/agent-lifecycle.test.ts @@ -60,7 +60,7 @@ async function cleanup(): Promise { async function test_agent_ls_empty(): Promise { console.log('\n--- Test: agent ls with empty list ---') - const result = await ctx.paseo(['ls', '--format', 'json']) + const result = await ctx.paseo(['ls', '--json']) console.log('Exit code:', result.exitCode) console.log('Stdout:', result.stdout) @@ -116,7 +116,7 @@ async function test_agent_run_detached(): Promise { async function test_agent_ls_shows_agent(agentId: string): Promise { console.log('\n--- Test: agent ls shows created agent ---') - const result = await ctx.paseo(['ls', '--format', 'json']) + const result = await ctx.paseo(['ls', '--json']) console.log('Exit code:', result.exitCode) console.log('Stdout:', result.stdout) @@ -198,7 +198,7 @@ async function test_agent_stop(agentId: string): Promise { assert.strictEqual(result.exitCode, 0, 'agent stop should succeed') // Verify agent is no longer in running list - const psResult = await ctx.paseo(['ls', '--format', 'json']) + const psResult = await ctx.paseo(['ls', '--json']) const agents = JSON.parse(psResult.stdout.trim()) as Array<{ id: string; status?: string }> // Agent might still be in list but should not be running diff --git a/packages/cli/tests/e2e/permissions.test.ts b/packages/cli/tests/e2e/permissions.test.ts index bfebe986d..8cbc4354c 100644 --- a/packages/cli/tests/e2e/permissions.test.ts +++ b/packages/cli/tests/e2e/permissions.test.ts @@ -107,7 +107,7 @@ async function test_wait_for_permission_request(agentId: string): Promise const startTime = Date.now() while (Date.now() - startTime < maxWait) { - const result = await ctx.paseo(['permit', 'ls', '--format', 'json']) + const result = await ctx.paseo(['permit', 'ls', '--json']) if (result.exitCode === 0) { try { @@ -148,7 +148,7 @@ async function test_wait_for_permission_request(agentId: string): Promise async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> { console.log('\n--- Test: List pending permissions with permit ls ---') - const result = await ctx.paseo(['permit', 'ls', '--format', 'json']) + const result = await ctx.paseo(['permit', 'ls', '--json']) console.log('Exit code:', result.exitCode) console.log('Stdout:', result.stdout) diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 5a60841e9..82fa7ab9a 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -4,6 +4,44 @@ export type AgentProvider = string; export type AgentMetadata = { [key: string]: unknown }; +/** + * Stdio-based MCP server (spawns a subprocess). + */ +export interface McpStdioServerConfig { + type: "stdio"; + command: string; + args?: string[]; + env?: Record; +} + +/** + * HTTP-based MCP server. + */ +export interface McpHttpServerConfig { + type: "http"; + url: string; + headers?: Record; +} + +/** + * SSE-based MCP server (Server-Sent Events over HTTP). + */ +export interface McpSseServerConfig { + type: "sse"; + url: string; + headers?: Record; +} + +/** + * Canonical MCP server configuration. + * Discriminated union by `type` field. + * Each provider normalizes this to their expected format. + */ +export type McpServerConfig = + | McpStdioServerConfig + | McpHttpServerConfig + | McpSseServerConfig; + export type AgentMode = { id: string; label: string; @@ -208,7 +246,7 @@ export type AgentSessionConfig = { codex?: AgentMetadata; claude?: Partial; }; - mcpServers?: AgentMetadata; + mcpServers?: Record; /** * Internal agents are hidden from listings and don't trigger notifications. * They are used for ephemeral system tasks like commit/PR generation. diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index f3ad25eae..7fd08071e 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -7,7 +7,7 @@ import { query, type AgentDefinition, type CanUseTool, - type McpServerConfig, + type McpServerConfig as ClaudeSdkMcpServerConfig, type ModelInfo, type Options, type PermissionMode, @@ -45,6 +45,7 @@ import type { AgentRuntimeInfo, ListModelsOptions, ListPersistedAgentsOptions, + McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; @@ -102,7 +103,6 @@ type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" }; export type ClaudeContentChunk = { type: string; [key: string]: any }; -type ClaudeMcpServerConfig = McpServerConfig; type ClaudeOptions = Options; type ClaudeAgentClientOptions = { @@ -178,6 +178,32 @@ function isMetadata(value: unknown): value is AgentMetadata { return typeof value === "object" && value !== null; } +function isMcpServerConfig(value: unknown): value is McpServerConfig { + if (!isMetadata(value)) { + return false; + } + const type = value.type; + if (type === "stdio") { + return typeof value.command === "string"; + } + if (type === "http" || type === "sse") { + return typeof value.url === "string"; + } + return false; +} + +function isMcpServersRecord(value: unknown): value is Record { + if (!isMetadata(value)) { + return false; + } + for (const config of Object.values(value)) { + if (!isMcpServerConfig(config)) { + return false; + } + } + return true; +} + function isPermissionMode(value: string | undefined): value is PermissionMode { return typeof value === "string" && VALID_CLAUDE_MODES.has(value); } @@ -230,18 +256,37 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial | undefined { - if (!servers) { - return undefined; - } - const result: Record = {}; + servers: Record + ): Record { + const result: Record = {}; for (const [name, config] of Object.entries(servers)) { - if (isClaudeMcpServerConfig(config)) { - result[name] = config; - } + result[name] = toClaudeSdkMcpConfig(config); } - return Object.keys(result).length ? result : undefined; + return result; } private toSdkUserMessage(prompt: AgentPromptInput): SDKUserMessage { diff --git a/packages/server/src/server/agent/providers/codex-mcp-agent.ts b/packages/server/src/server/agent/providers/codex-mcp-agent.ts index ce36beb7c..3148cadcb 100644 --- a/packages/server/src/server/agent/providers/codex-mcp-agent.ts +++ b/packages/server/src/server/agent/providers/codex-mcp-agent.ts @@ -37,6 +37,7 @@ import type { AgentRuntimeInfo, ListModelsOptions, ListPersistedAgentsOptions, + McpServerConfig, PersistedAgentDescriptor, } from "../agent-sdk-types.js"; import { curateAgentActivity } from "../activity-curator.js"; @@ -1866,6 +1867,31 @@ const AgentSessionExtraSchema = z.object({ claude: z.custom().optional(), }); +const McpStdioServerConfigSchema = z.object({ + type: z.literal("stdio"), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), +}); + +const McpHttpServerConfigSchema = z.object({ + type: z.literal("http"), + url: z.string(), + headers: z.record(z.string()).optional(), +}); + +const McpSseServerConfigSchema = z.object({ + type: z.literal("sse"), + url: z.string(), + headers: z.record(z.string()).optional(), +}); + +const McpServerConfigSchema = z.discriminatedUnion("type", [ + McpStdioServerConfigSchema, + McpHttpServerConfigSchema, + McpSseServerConfigSchema, +]); + const AgentSessionConfigSchema = z .object({ provider: z.string(), @@ -1879,7 +1905,7 @@ const AgentSessionConfigSchema = z webSearch: z.boolean().optional(), reasoningEffort: z.string().optional(), extra: AgentSessionExtraSchema.optional(), - mcpServers: z.record(z.unknown()).optional(), + mcpServers: z.record(McpServerConfigSchema).optional(), }) .passthrough(); @@ -2693,14 +2719,35 @@ function getCodexMcpCommand(): string { } } -type CodexMcpServerConfig = { +interface CodexMcpServerConfig { url?: string; - http_headers?: Record; // Static HTTP headers for HTTP servers + http_headers?: Record; command?: string; args?: string[]; env?: Record; - tool_timeout_sec?: number; // Override the default 60s per-tool timeout -}; + tool_timeout_sec?: number; +} + +function toCodexMcpConfig(config: McpServerConfig): CodexMcpServerConfig { + switch (config.type) { + case "stdio": + return { + command: config.command, + args: config.args, + env: config.env, + }; + case "http": + return { + url: config.url, + http_headers: config.headers, + }; + case "sse": + return { + url: config.url, + http_headers: config.headers, + }; + } +} type CodexConfigPayload = { mcp_servers?: Record; @@ -2754,22 +2801,10 @@ function buildCodexMcpConfig( // Build MCP servers configuration const mcpServers: Record = {}; - // Merge MCP servers from extra.codex.mcp_servers (legacy location) - const extraCodex = config.extra?.codex as Record | undefined; - if (extraCodex?.mcp_servers && typeof extraCodex.mcp_servers === "object") { - for (const [name, serverConfig] of Object.entries(extraCodex.mcp_servers as Record)) { - if (typeof serverConfig === "object" && serverConfig !== null) { - mcpServers[name] = serverConfig as CodexMcpServerConfig; - } - } - } - - // Merge user-provided MCP servers (they take highest precedence) + // Merge user-provided MCP servers if (config.mcpServers) { for (const [name, serverConfig] of Object.entries(config.mcpServers)) { - if (typeof serverConfig === "object" && serverConfig !== null) { - mcpServers[name] = serverConfig as CodexMcpServerConfig; - } + mcpServers[name] = toCodexMcpConfig(serverConfig); } } diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index ac802ad4b..2f02c3427 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -46,6 +46,31 @@ const AgentUsageSchema: z.ZodType = z.object({ totalCostUsd: z.number().optional(), }); +const McpStdioServerConfigSchema = z.object({ + type: z.literal("stdio"), + command: z.string(), + args: z.array(z.string()).optional(), + env: z.record(z.string()).optional(), +}); + +const McpHttpServerConfigSchema = z.object({ + type: z.literal("http"), + url: z.string(), + headers: z.record(z.string()).optional(), +}); + +const McpSseServerConfigSchema = z.object({ + type: z.literal("sse"), + url: z.string(), + headers: z.record(z.string()).optional(), +}); + +const McpServerConfigSchema = z.discriminatedUnion("type", [ + McpStdioServerConfigSchema, + McpHttpServerConfigSchema, + McpSseServerConfigSchema, +]); + const AgentSessionConfigSchema = z.object({ provider: AgentProviderSchema, cwd: z.string(), @@ -70,7 +95,7 @@ const AgentSessionConfigSchema = z.object({ }) .partial() .optional(), - mcpServers: z.record(z.unknown()).optional(), + mcpServers: z.record(McpServerConfigSchema).optional(), }); const AgentPermissionUpdateSchema = z.record(z.unknown());