refactor: use --json flag alias instead of --format json throughout CLI

This commit is contained in:
Mohamed Boudra
2026-02-01 12:33:18 +07:00
parent dac1538d1f
commit a473302a9f
22 changed files with 313 additions and 167 deletions

View File

@@ -380,7 +380,7 @@ async function renderStream<T>(
### NDJSON for Streaming ### 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"} {"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 ```typescript
// Verify JSON output is valid and contains expected data // Verify JSON output is valid and contains expected data
test('agent list --format json', async () => { test('agent list --json', async () => {
const output = await ctx.paseo('agent list --format json') const output = await ctx.paseo('agent list --json')
const data = JSON.parse(output.stdout) const data = JSON.parse(output.stdout)
expect(data).toBeInstanceOf(Array) expect(data).toBeInstanceOf(Array)
@@ -475,7 +475,8 @@ Add output options to the root command:
```typescript ```typescript
program program
.option('-f, --format <format>', 'Output format: table, json, yaml', 'table') .option('-o, --format <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('-q, --quiet', 'Minimal output (IDs only)')
.option('--no-headers', 'Omit table headers') .option('--no-headers', 'Omit table headers')
.option('--no-color', 'Disable colored output') .option('--no-color', 'Disable colored output')

View File

@@ -32,7 +32,8 @@ export function createCli(): Command {
.description('Paseo CLI - control your AI coding agents from the command line') .description('Paseo CLI - control your AI coding agents from the command line')
.version(VERSION, '-v, --version', 'output the version number') .version(VERSION, '-v, --version', 'output the version number')
// Global output options // Global output options
.option('-f, --format <format>', 'output format: table, json, yaml', 'table') .option('-o, --format <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('-q, --quiet', 'minimal output (IDs only)')
.option('--no-headers', 'omit table headers') .option('--no-headers', 'omit table headers')
.option('--no-color', 'disable colored output') .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('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => { .action(withOutput(runLsCommand))
if (options.json) {
command.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
program program
.command('run') .command('run')
@@ -69,6 +65,7 @@ export function createCli(): Command {
.option('--cwd <path>', 'Working directory (default: current)') .option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand)) .action(withOutput(runRunCommand))
@@ -96,6 +93,7 @@ export function createCli(): Command {
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents') .option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory') .option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand)) .action(withOutput(runStopCommand))
@@ -106,6 +104,7 @@ export function createCli(): Command {
.argument('<prompt>', 'The message to send') .argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion') .option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, []) .option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand)) .action(withOutput(runSendCommand))
@@ -115,18 +114,14 @@ export function createCli(): Command {
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((id, options, command) => { .action(withOutput(runInspectCommand))
if (options.json) {
command.parent.opts().format = 'json'
}
return withOutput(runInspectCommand)(id, options, command)
})
program program
.command('wait') .command('wait')
.description('Wait for an agent to become idle') .description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)') .option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand)) .action(withOutput(runWaitCommand))

View File

@@ -29,12 +29,7 @@ export function createAgentCommand(): Command {
.option('--ui', 'Show only UI agents (equivalent to --label ui=true)') .option('--ui', 'Show only UI agents (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => { .action(withOutput(runLsCommand))
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
agent agent
.command('run') .command('run')
@@ -48,6 +43,7 @@ export function createAgentCommand(): Command {
.option('--cwd <path>', 'Working directory (default: current)') .option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, []) .option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--ui', 'Mark as UI agent (equivalent to --label ui=true)') .option('--ui', 'Mark as UI agent (equivalent to --label ui=true)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRunCommand)) .action(withOutput(runRunCommand))
@@ -74,6 +70,7 @@ export function createAgentCommand(): Command {
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified') .argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents') .option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory') .option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand)) .action(withOutput(runStopCommand))
@@ -83,6 +80,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send') .argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion') .option('--no-wait', 'Return immediately without waiting for completion')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runSendCommand)) .action(withOutput(runSendCommand))
@@ -90,6 +88,7 @@ export function createAgentCommand(): Command {
.command('inspect') .command('inspect')
.description('Show detailed information about an agent') .description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runInspectCommand)) .action(withOutput(runInspectCommand))
@@ -98,6 +97,7 @@ export function createAgentCommand(): Command {
.description('Wait for an agent to become idle') .description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)') .option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand)) .action(withOutput(runWaitCommand))
@@ -108,6 +108,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.argument('[mode]', 'Mode to set (required unless --list)') .argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent') .option('--list', 'List available modes for this agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runModeCommand)) .action(withOutput(runModeCommand))
@@ -116,6 +117,7 @@ export function createAgentCommand(): Command {
.description('Archive an agent (soft-delete)') .description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID (or prefix)') .argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent') .option('--force', 'Force archive running agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand)) .action(withOutput(runArchiveCommand))

View File

@@ -20,6 +20,15 @@ export interface AgentLogsOptions extends CommandOptions {
export type AgentLogsResult = void 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 * Check if a timeline item matches the filter type
*/ */
@@ -110,6 +119,12 @@ export async function runLogsCommand(
// For follow mode, we stream events continuously // For follow mode, we stream events continuously
if (options.follow) { if (options.follow) {
if (options.tail !== undefined && parseTailCount(options.tail) === undefined) {
console.error(`Error: Invalid --tail value: ${options.tail}`)
console.error('Usage: --tail <n> (where n is >= 0)')
await client.close().catch(() => {})
process.exit(1)
}
await runFollowMode(client, resolvedId, options) await runFollowMode(client, resolvedId, options)
return return
} }
@@ -159,18 +174,22 @@ export async function runLogsCommand(
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter)) timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
} }
// Apply tail limit const tailCount = parseTailCount(options.tail)
if (options.tail) { if (options.tail !== undefined && tailCount === undefined) {
const tailCount = parseInt(options.tail, 10) console.error(`Error: Invalid --tail value: ${options.tail}`)
if (!isNaN(tailCount) && tailCount > 0) { console.error('Usage: --tail <n> (where n is >= 0)')
timelineItems = timelineItems.slice(-tailCount) await client.close().catch(() => {})
} process.exit(1)
} }
await client.close() await client.close()
// Use curateAgentActivity to format the transcript // 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) console.log(transcript)
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -188,6 +207,9 @@ async function runFollowMode(
agentId: string, agentId: string,
options: AgentLogsOptions options: AgentLogsOptions
): Promise<void> { ): Promise<void> {
const DEFAULT_FOLLOW_TAIL = 10
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL
// First, get existing timeline // First, get existing timeline
const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => { const snapshotPromise = new Promise<AgentTimelineItem[]>((resolve) => {
const timeout = setTimeout(() => resolve([]), 10000) const timeout = setTimeout(() => resolve([]), 10000)
@@ -218,22 +240,17 @@ async function runFollowMode(
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter)) existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
} }
// Apply tail to existing items // Print existing transcript (tail-like behavior)
if (options.tail) { if (tailCount > 0) {
const tailCount = parseInt(options.tail, 10) const existingTranscript = curateAgentActivity(existingItems, { maxItems: tailCount })
if (!isNaN(tailCount) && tailCount > 0) { if (existingTranscript !== 'No activity to display.') {
existingItems = existingItems.slice(-tailCount) console.log(existingTranscript)
} }
} }
// Print existing transcript
const existingTranscript = curateAgentActivity(existingItems)
if (existingTranscript !== 'No activity to display.') {
console.log(existingTranscript)
}
// Subscribe to new events // 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 unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage const message = msg as AgentStreamMessage

View File

@@ -15,22 +15,19 @@ export function createDaemonCommand(): Command {
.description('Show daemon status') .description('Show daemon status')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => { .action(withOutput(runStatusCommand))
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runStatusCommand)(options, command)
})
daemon daemon
.command('stop') .command('stop')
.description('Stop the daemon') .description('Stop the daemon')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand)) .action(withOutput(runStopCommand))
daemon daemon
.command('restart') .command('restart')
.description('Restart the daemon') .description('Restart the daemon')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRestartCommand)) .action(withOutput(runRestartCommand))

View File

@@ -12,12 +12,7 @@ export function createPermitCommand(): Command {
.description('List all pending permissions') .description('List all pending permissions')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => { .action(withOutput(runLsCommand))
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
permit permit
.command('allow') .command('allow')
@@ -26,6 +21,7 @@ export function createPermitCommand(): Command {
.argument('[req_id]', 'Permission request ID (optional if --all)') .argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Allow all pending permissions for this agent') .option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)') .option('--input <json>', 'Modified input parameters (JSON)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runAllowCommand)) .action(withOutput(runAllowCommand))
@@ -37,6 +33,7 @@ export function createPermitCommand(): Command {
.option('--all', 'Deny all pending permissions for this agent') .option('--all', 'Deny all pending permissions for this agent')
.option('--message <msg>', 'Denial reason message') .option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial') .option('--interrupt', 'Stop agent after denial')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runDenyCommand)) .action(withOutput(runDenyCommand))

View File

@@ -11,12 +11,7 @@ export function createProviderCommand(): Command {
.description('List available providers and status') .description('List available providers and status')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => { .action(withOutput(runLsCommand))
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
provider provider
.command('models') .command('models')
@@ -24,12 +19,7 @@ export function createProviderCommand(): Command {
.argument('<provider>', 'Provider name (claude, codex, opencode)') .argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((provider, options, command) => { .action(withOutput(runModelsCommand))
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runModelsCommand)(provider, options, command)
})
return provider return provider
} }

View File

@@ -11,17 +11,13 @@ export function createWorktreeCommand(): Command {
.description('List Paseo-managed git worktrees') .description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format') .option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action((options, command) => { .action(withOutput(runLsCommand))
if (options.json) {
command.parent.parent.opts().format = 'json'
}
return withOutput(runLsCommand)(options, command)
})
worktree worktree
.command('archive') .command('archive')
.description('Archive a worktree (removes worktree and associated branch)') .description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name') .argument('<name>', 'Worktree name or branch name')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)') .option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand)) .action(withOutput(runArchiveCommand))

View File

@@ -5,7 +5,7 @@
*/ */
import type { Command } from 'commander' 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' import { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
/** Options that include output settings from global options */ /** Options that include output settings from global options */
@@ -13,10 +13,26 @@ export interface CommandOptions extends Partial<OutputOptions> {
[key: string]: unknown [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 */ /** Extract output options from command options */
function extractOutputOptions(options: CommandOptions): OutputOptions { function extractOutputOptions(options: CommandOptions): OutputOptions {
return { return {
format: (options.format as OutputOptions['format']) ?? defaultOutputOptions.format, format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
quiet: options.quiet ?? defaultOutputOptions.quiet, quiet: options.quiet ?? defaultOutputOptions.quiet,
noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false
noColor: options.color === false, // Commander uses --no-color -> color: false noColor: options.color === false, // Commander uses --no-color -> color: false

View File

@@ -9,7 +9,7 @@
* Tests: * Tests:
* - daemon --help shows subcommands * - daemon --help shows subcommands
* - daemon status fails gracefully when daemon not running * - 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 * - daemon stop handles daemon not running gracefully
*/ */
@@ -56,11 +56,11 @@ try {
console.log('✓ daemon status fails gracefully when not running\n') 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 = 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) // Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON // But output should be valid JSON
@@ -68,14 +68,14 @@ try {
if (output.length > 0) { if (output.length > 0) {
try { try {
JSON.parse(output) JSON.parse(output)
console.log('✓ daemon status --format json outputs valid JSON\n') console.log('✓ daemon status --json outputs valid JSON\n')
} catch { } catch {
// If stdout is empty, check if stderr has the error (acceptable for now) // 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 { } else {
// Empty stdout is acceptable if error is in stderr // 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')
} }
} }

View File

@@ -13,7 +13,7 @@
* - paseo --help shows ls command * - paseo --help shows ls command
* - paseo ls --help shows options * - paseo ls --help shows options
* - paseo ls returns empty list or error when no daemon * - 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 -a flag is accepted
* - paseo ls -g flag is accepted * - paseo ls -g flag is accepted
*/ */
@@ -71,11 +71,11 @@ try {
console.log('✓ paseo ls handles daemon not running\n') 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 = 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) // Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON if present // But output should be valid JSON if present
@@ -83,13 +83,13 @@ try {
if (output.length > 0) { if (output.length > 0) {
try { try {
JSON.parse(output) 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 { } catch {
// Empty or stderr-only output is acceptable // 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 { } else {
console.log('✓ paseo ls --format json handled error gracefully\n') console.log('✓ paseo ls --json handled error gracefully\n')
} }
} }

View File

@@ -98,15 +98,15 @@ try {
console.log('-q (quiet) flag is accepted with inspect\n') 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 = 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 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') 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 // Test 7: --format yaml flag is accepted with inspect

View File

@@ -112,15 +112,15 @@ try {
console.log('-q (quiet) flag is accepted with wait\n') 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 = 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 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') 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 // Test 8: --format yaml flag is accepted with wait

View File

@@ -13,7 +13,7 @@
* - permit --help shows subcommands * - permit --help shows subcommands
* - permit ls --help shows options * - permit ls --help shows options
* - permit ls returns error when no daemon * - permit ls returns error when no daemon
* - permit ls --format json handles errors * - permit ls --json handles errors
*/ */
import assert from 'node:assert' import assert from 'node:assert'
@@ -67,11 +67,11 @@ try {
console.log('✓ permit ls handles daemon not running\n') 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 = 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) // Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
// But output should be valid JSON if present // But output should be valid JSON if present
@@ -79,13 +79,13 @@ try {
if (output.length > 0) { if (output.length > 0) {
try { try {
JSON.parse(output) 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 { } catch {
// Empty or stderr-only output is acceptable // 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 { } else {
console.log('✓ permit ls --format json handled error gracefully\n') console.log('✓ permit ls --json handled error gracefully\n')
} }
} }

View File

@@ -142,15 +142,15 @@ try {
console.log('✓ -q (quiet) flag is accepted with worktree ls\n') 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 = 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 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') 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 // Test 11: paseo --help shows worktree subcommand

View File

@@ -9,13 +9,13 @@
* Tests: * Tests:
* - provider --help shows subcommands * - provider --help shows subcommands
* - provider ls lists all providers * - 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 ls --quiet outputs provider names only
* - provider models claude lists claude models * - provider models claude lists claude models
* - provider models codex lists codex models * - provider models codex lists codex models
* - provider models opencode lists opencode models * - provider models opencode lists opencode models
* - provider models unknown fails with error * - provider models unknown fails with error
* - provider models --format json outputs valid JSON * - provider models --json outputs valid JSON
*/ */
import assert from 'node:assert' import assert from 'node:assert'
@@ -47,10 +47,10 @@ console.log('=== Provider Commands ===\n')
console.log('✓ provider ls lists all providers\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') console.log('Test 3: provider ls --json outputs valid JSON')
const result = await $`npx paseo provider ls --format json`.nothrow() const result = await $`npx paseo provider ls --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0') assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim()) const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array') 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 === 'claude'), 'should include claude')
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex') assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode') 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 // 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') 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') console.log('Test 9: provider models --json outputs valid JSON')
const result = await $`npx paseo provider models claude --format json`.nothrow() const result = await $`npx paseo provider models claude --json`.nothrow()
assert.strictEqual(result.exitCode, 0, 'should exit 0') assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim()) const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array') assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 models for claude') 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') 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 // Test 10: provider models --quiet outputs model IDs only

View File

@@ -60,7 +60,7 @@ async function cleanup(): Promise<void> {
async function test_agent_ls_empty(): Promise<void> { async function test_agent_ls_empty(): Promise<void> {
console.log('\n--- Test: agent ls with empty list ---') 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('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout) console.log('Stdout:', result.stdout)
@@ -116,7 +116,7 @@ async function test_agent_run_detached(): Promise<string> {
async function test_agent_ls_shows_agent(agentId: string): Promise<void> { async function test_agent_ls_shows_agent(agentId: string): Promise<void> {
console.log('\n--- Test: agent ls shows created agent ---') 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('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout) console.log('Stdout:', result.stdout)
@@ -198,7 +198,7 @@ async function test_agent_stop(agentId: string): Promise<void> {
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed') assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
// Verify agent is no longer in running list // 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 }> const agents = JSON.parse(psResult.stdout.trim()) as Array<{ id: string; status?: string }>
// Agent might still be in list but should not be running // Agent might still be in list but should not be running

View File

@@ -107,7 +107,7 @@ async function test_wait_for_permission_request(agentId: string): Promise<void>
const startTime = Date.now() const startTime = Date.now()
while (Date.now() - startTime < maxWait) { 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) { if (result.exitCode === 0) {
try { try {
@@ -148,7 +148,7 @@ async function test_wait_for_permission_request(agentId: string): Promise<void>
async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> { async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> {
console.log('\n--- Test: List pending permissions with permit ls ---') 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('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout) console.log('Stdout:', result.stdout)

View File

@@ -4,6 +4,44 @@ export type AgentProvider = string;
export type AgentMetadata = { [key: string]: unknown }; export type AgentMetadata = { [key: string]: unknown };
/**
* Stdio-based MCP server (spawns a subprocess).
*/
export interface McpStdioServerConfig {
type: "stdio";
command: string;
args?: string[];
env?: Record<string, string>;
}
/**
* HTTP-based MCP server.
*/
export interface McpHttpServerConfig {
type: "http";
url: string;
headers?: Record<string, string>;
}
/**
* SSE-based MCP server (Server-Sent Events over HTTP).
*/
export interface McpSseServerConfig {
type: "sse";
url: string;
headers?: Record<string, string>;
}
/**
* 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 = { export type AgentMode = {
id: string; id: string;
label: string; label: string;
@@ -208,7 +246,7 @@ export type AgentSessionConfig = {
codex?: AgentMetadata; codex?: AgentMetadata;
claude?: Partial<ClaudeAgentOptions>; claude?: Partial<ClaudeAgentOptions>;
}; };
mcpServers?: AgentMetadata; mcpServers?: Record<string, McpServerConfig>;
/** /**
* Internal agents are hidden from listings and don't trigger notifications. * Internal agents are hidden from listings and don't trigger notifications.
* They are used for ephemeral system tasks like commit/PR generation. * They are used for ephemeral system tasks like commit/PR generation.

View File

@@ -7,7 +7,7 @@ import {
query, query,
type AgentDefinition, type AgentDefinition,
type CanUseTool, type CanUseTool,
type McpServerConfig, type McpServerConfig as ClaudeSdkMcpServerConfig,
type ModelInfo, type ModelInfo,
type Options, type Options,
type PermissionMode, type PermissionMode,
@@ -45,6 +45,7 @@ import type {
AgentRuntimeInfo, AgentRuntimeInfo,
ListModelsOptions, ListModelsOptions,
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js"; import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
@@ -102,7 +103,6 @@ type ClaudeAgentConfig = AgentSessionConfig & { provider: "claude" };
export type ClaudeContentChunk = { type: string; [key: string]: any }; export type ClaudeContentChunk = { type: string; [key: string]: any };
type ClaudeMcpServerConfig = McpServerConfig;
type ClaudeOptions = Options; type ClaudeOptions = Options;
type ClaudeAgentClientOptions = { type ClaudeAgentClientOptions = {
@@ -178,6 +178,32 @@ function isMetadata(value: unknown): value is AgentMetadata {
return typeof value === "object" && value !== null; 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<string, McpServerConfig> {
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 { function isPermissionMode(value: string | undefined): value is PermissionMode {
return typeof value === "string" && VALID_CLAUDE_MODES.has(value); return typeof value === "string" && VALID_CLAUDE_MODES.has(value);
} }
@@ -230,18 +256,37 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
result.extra = extra; result.extra = extra;
} }
} }
if (isMetadata(metadata.mcpServers)) { if (isMcpServersRecord(metadata.mcpServers)) {
result.mcpServers = metadata.mcpServers; result.mcpServers = metadata.mcpServers;
} }
return result; return result;
} }
function isClaudeMcpServerConfig(value: unknown): value is ClaudeMcpServerConfig { function toClaudeSdkMcpConfig(
if (!isMetadata(value)) { config: McpServerConfig
return false; ): ClaudeSdkMcpServerConfig {
switch (config.type) {
case "stdio":
return {
type: "stdio",
command: config.command,
args: config.args,
env: config.env,
};
case "http":
return {
type: "http",
url: config.url,
headers: config.headers,
};
case "sse":
return {
type: "sse",
url: config.url,
headers: config.headers,
};
} }
return typeof value.type === "string" || typeof value.command === "string" || typeof value.url === "string";
} }
function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk { function isClaudeContentChunk(value: unknown): value is ClaudeContentChunk {
@@ -783,10 +828,7 @@ class ClaudeAgentSession implements AgentSession {
}; };
if (this.config.mcpServers) { if (this.config.mcpServers) {
const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers); base.mcpServers = this.normalizeMcpServers(this.config.mcpServers);
if (normalizedUserServers) {
base.mcpServers = normalizedUserServers;
}
} }
if (this.config.model) { if (this.config.model) {
@@ -800,18 +842,13 @@ class ClaudeAgentSession implements AgentSession {
} }
private normalizeMcpServers( private normalizeMcpServers(
servers: ClaudeAgentConfig["mcpServers"] servers: Record<string, McpServerConfig>
): Record<string, ClaudeMcpServerConfig> | undefined { ): Record<string, ClaudeSdkMcpServerConfig> {
if (!servers) { const result: Record<string, ClaudeSdkMcpServerConfig> = {};
return undefined;
}
const result: Record<string, ClaudeMcpServerConfig> = {};
for (const [name, config] of Object.entries(servers)) { for (const [name, config] of Object.entries(servers)) {
if (isClaudeMcpServerConfig(config)) { result[name] = toClaudeSdkMcpConfig(config);
result[name] = config;
}
} }
return Object.keys(result).length ? result : undefined; return result;
} }
private toSdkUserMessage(prompt: AgentPromptInput): SDKUserMessage { private toSdkUserMessage(prompt: AgentPromptInput): SDKUserMessage {

View File

@@ -37,6 +37,7 @@ import type {
AgentRuntimeInfo, AgentRuntimeInfo,
ListModelsOptions, ListModelsOptions,
ListPersistedAgentsOptions, ListPersistedAgentsOptions,
McpServerConfig,
PersistedAgentDescriptor, PersistedAgentDescriptor,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { curateAgentActivity } from "../activity-curator.js"; import { curateAgentActivity } from "../activity-curator.js";
@@ -1866,6 +1867,31 @@ const AgentSessionExtraSchema = z.object({
claude: z.custom<AgentSessionExtra["claude"]>().optional(), claude: z.custom<AgentSessionExtra["claude"]>().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 const AgentSessionConfigSchema = z
.object({ .object({
provider: z.string(), provider: z.string(),
@@ -1879,7 +1905,7 @@ const AgentSessionConfigSchema = z
webSearch: z.boolean().optional(), webSearch: z.boolean().optional(),
reasoningEffort: z.string().optional(), reasoningEffort: z.string().optional(),
extra: AgentSessionExtraSchema.optional(), extra: AgentSessionExtraSchema.optional(),
mcpServers: z.record(z.unknown()).optional(), mcpServers: z.record(McpServerConfigSchema).optional(),
}) })
.passthrough(); .passthrough();
@@ -2693,14 +2719,35 @@ function getCodexMcpCommand(): string {
} }
} }
type CodexMcpServerConfig = { interface CodexMcpServerConfig {
url?: string; url?: string;
http_headers?: Record<string, string>; // Static HTTP headers for HTTP servers http_headers?: Record<string, string>;
command?: string; command?: string;
args?: string[]; args?: string[];
env?: Record<string, string>; env?: Record<string, string>;
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 = { type CodexConfigPayload = {
mcp_servers?: Record<string, CodexMcpServerConfig>; mcp_servers?: Record<string, CodexMcpServerConfig>;
@@ -2754,22 +2801,10 @@ function buildCodexMcpConfig(
// Build MCP servers configuration // Build MCP servers configuration
const mcpServers: Record<string, CodexMcpServerConfig> = {}; const mcpServers: Record<string, CodexMcpServerConfig> = {};
// Merge MCP servers from extra.codex.mcp_servers (legacy location) // Merge user-provided MCP servers
const extraCodex = config.extra?.codex as Record<string, unknown> | undefined;
if (extraCodex?.mcp_servers && typeof extraCodex.mcp_servers === "object") {
for (const [name, serverConfig] of Object.entries(extraCodex.mcp_servers as Record<string, unknown>)) {
if (typeof serverConfig === "object" && serverConfig !== null) {
mcpServers[name] = serverConfig as CodexMcpServerConfig;
}
}
}
// Merge user-provided MCP servers (they take highest precedence)
if (config.mcpServers) { if (config.mcpServers) {
for (const [name, serverConfig] of Object.entries(config.mcpServers)) { for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (typeof serverConfig === "object" && serverConfig !== null) { mcpServers[name] = toCodexMcpConfig(serverConfig);
mcpServers[name] = serverConfig as CodexMcpServerConfig;
}
} }
} }

View File

@@ -46,6 +46,31 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
totalCostUsd: z.number().optional(), 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({ const AgentSessionConfigSchema = z.object({
provider: AgentProviderSchema, provider: AgentProviderSchema,
cwd: z.string(), cwd: z.string(),
@@ -70,7 +95,7 @@ const AgentSessionConfigSchema = z.object({
}) })
.partial() .partial()
.optional(), .optional(),
mcpServers: z.record(z.unknown()).optional(), mcpServers: z.record(McpServerConfigSchema).optional(),
}); });
const AgentPermissionUpdateSchema = z.record(z.unknown()); const AgentPermissionUpdateSchema = z.record(z.unknown());