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
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 <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('--no-headers', 'Omit table headers')
.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')
.version(VERSION, '-v, --version', 'output the version number')
// 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('--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 <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 <path>', 'Working directory (default: current)')
.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('--json', 'Output in JSON format')
.option('--host <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 <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
@@ -106,6 +104,7 @@ export function createCli(): Command {
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.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)')
.action(withOutput(runSendCommand))
@@ -115,18 +114,14 @@ export function createCli(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <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('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.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('--json', 'Output in JSON format')
.option('--host <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 <path>', 'Working directory (default: current)')
.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('--json', 'Output in JSON format')
.option('--host <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 <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runStopCommand))
@@ -83,6 +80,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--json', 'Output in JSON format')
.option('--host <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('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <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('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: 600)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runWaitCommand))
@@ -108,6 +108,7 @@ export function createAgentCommand(): Command {
.argument('<id>', '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 <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('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))

View File

@@ -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 <n> (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 <n> (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<void> {
const DEFAULT_FOLLOW_TAIL = 10
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL
// First, get existing timeline
const snapshotPromise = new Promise<AgentTimelineItem[]>((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

View File

@@ -15,22 +15,19 @@ export function createDaemonCommand(): Command {
.description('Show daemon status')
.option('--json', 'Output in JSON format')
.option('--host <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 <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 <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runRestartCommand))

View File

@@ -12,12 +12,7 @@ export function createPermitCommand(): Command {
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <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 <json>', 'Modified input parameters (JSON)')
.option('--json', 'Output in JSON format')
.option('--host <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 <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runDenyCommand))

View File

@@ -11,12 +11,7 @@ export function createProviderCommand(): Command {
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <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>', 'Provider name (claude, codex, opencode)')
.option('--json', 'Output in JSON format')
.option('--host <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
}

View File

@@ -11,17 +11,13 @@ export function createWorktreeCommand(): Command {
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <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('<name>', 'Worktree name or branch name')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.action(withOutput(runArchiveCommand))

View File

@@ -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<OutputOptions> {
[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

View File

@@ -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')
}
}

View File

@@ -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')
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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')
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -60,7 +60,7 @@ async function cleanup(): Promise<void> {
async function test_agent_ls_empty(): Promise<void> {
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<string> {
async function test_agent_ls_shows_agent(agentId: string): Promise<void> {
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<void> {
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

View File

@@ -107,7 +107,7 @@ async function test_wait_for_permission_request(agentId: string): Promise<void>
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<void>
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)

View File

@@ -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<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 = {
id: string;
label: string;
@@ -208,7 +246,7 @@ export type AgentSessionConfig = {
codex?: AgentMetadata;
claude?: Partial<ClaudeAgentOptions>;
};
mcpServers?: AgentMetadata;
mcpServers?: Record<string, McpServerConfig>;
/**
* Internal agents are hidden from listings and don't trigger notifications.
* They are used for ephemeral system tasks like commit/PR generation.

View File

@@ -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<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 {
return typeof value === "string" && VALID_CLAUDE_MODES.has(value);
}
@@ -230,18 +256,37 @@ function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<Age
result.extra = extra;
}
}
if (isMetadata(metadata.mcpServers)) {
if (isMcpServersRecord(metadata.mcpServers)) {
result.mcpServers = metadata.mcpServers;
}
return result;
}
function isClaudeMcpServerConfig(value: unknown): value is ClaudeMcpServerConfig {
if (!isMetadata(value)) {
return false;
function toClaudeSdkMcpConfig(
config: McpServerConfig
): 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 {
@@ -783,10 +828,7 @@ class ClaudeAgentSession implements AgentSession {
};
if (this.config.mcpServers) {
const normalizedUserServers = this.normalizeMcpServers(this.config.mcpServers);
if (normalizedUserServers) {
base.mcpServers = normalizedUserServers;
}
base.mcpServers = this.normalizeMcpServers(this.config.mcpServers);
}
if (this.config.model) {
@@ -800,18 +842,13 @@ class ClaudeAgentSession implements AgentSession {
}
private normalizeMcpServers(
servers: ClaudeAgentConfig["mcpServers"]
): Record<string, ClaudeMcpServerConfig> | undefined {
if (!servers) {
return undefined;
}
const result: Record<string, ClaudeMcpServerConfig> = {};
servers: Record<string, McpServerConfig>
): Record<string, ClaudeSdkMcpServerConfig> {
const result: Record<string, ClaudeSdkMcpServerConfig> = {};
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 {

View File

@@ -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<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
.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<string, string>; // Static HTTP headers for HTTP servers
http_headers?: Record<string, string>;
command?: string;
args?: 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 = {
mcp_servers?: Record<string, CodexMcpServerConfig>;
@@ -2754,22 +2801,10 @@ function buildCodexMcpConfig(
// Build MCP servers configuration
const mcpServers: Record<string, CodexMcpServerConfig> = {};
// Merge MCP servers from extra.codex.mcp_servers (legacy location)
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)
// 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);
}
}

View File

@@ -46,6 +46,31 @@ const AgentUsageSchema: z.ZodType<AgentUsage> = 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());