From f96a4ee3dfdc53a6e8eb4a610d2fe861585e9ab6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 28 Jan 2026 23:41:14 +0700 Subject: [PATCH] feat(cli): add agent mode command Add the `paseo agent mode` command for changing and listing agent operational modes. Supports setting a mode or listing available modes with the --list flag. Usage: paseo agent mode # Set mode paseo agent mode --list # List available modes --- packages/cli/src/commands/agent/index.ts | 10 ++ packages/cli/src/commands/agent/mode.ts | 154 +++++++++++++++++++++++ packages/cli/tests/10-agent-mode.test.ts | 141 +++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 packages/cli/src/commands/agent/mode.ts create mode 100644 packages/cli/tests/10-agent-mode.test.ts diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts index 7c273592e..6f08a4e08 100644 --- a/packages/cli/src/commands/agent/index.ts +++ b/packages/cli/src/commands/agent/index.ts @@ -3,6 +3,7 @@ import { runPsCommand } from './ps.js' import { runRunCommand } from './run.js' import { runSendCommand } from './send.js' import { runStopCommand } from './stop.js' +import { runModeCommand } from './mode.js' import { withOutput } from '../../output/index.js' export function createAgentCommand(): Command { @@ -47,5 +48,14 @@ export function createAgentCommand(): Command { .option('--host ', 'Daemon host:port (default: localhost:6767)') .action(withOutput(runStopCommand)) + agent + .command('mode') + .description("Change an agent's operational mode") + .argument('', 'Agent ID (or prefix)') + .argument('[mode]', 'Mode to set (required unless --list)') + .option('--list', 'List available modes for this agent') + .option('--host ', 'Daemon host:port (default: localhost:6767)') + .action(withOutput(runModeCommand)) + return agent } diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts new file mode 100644 index 000000000..340551746 --- /dev/null +++ b/packages/cli/src/commands/agent/mode.ts @@ -0,0 +1,154 @@ +import type { Command } from 'commander' +import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js' +import type { + CommandOptions, + OutputSchema, + CommandError, + AnyCommandResult, +} from '../../output/index.js' + +/** Mode item for list display */ +export interface ModeListItem { + id: string + label: string + description: string +} + +/** Result for setting mode */ +export interface SetModeResult { + agentId: string + mode: string +} + +/** Schema for mode list output */ +export const modeListSchema: OutputSchema = { + idField: 'id', + columns: [ + { header: 'MODE', field: 'id', width: 15 }, + { header: 'LABEL', field: 'label', width: 25 }, + { header: 'DESCRIPTION', field: 'description', width: 40 }, + ], +} + +/** Schema for set mode output */ +export const setModeSchema: OutputSchema = { + idField: 'agentId', + columns: [ + { header: 'AGENT ID', field: 'agentId', width: 12 }, + { header: 'MODE', field: 'mode', width: 20 }, + ], +} + +export interface AgentModeOptions extends CommandOptions { + list?: boolean +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AgentModeResult = AnyCommandResult + +export async function runModeCommand( + id: string, + mode: string | undefined, + options: AgentModeOptions, + _command: Command +): Promise { + const host = getDaemonHost({ host: options.host as string | undefined }) + + // Validate arguments + if (!options.list && !mode) { + const error: CommandError = { + code: 'MISSING_ARGUMENT', + message: 'Mode argument required unless --list is specified', + details: 'Usage: paseo agent mode | paseo agent mode --list ', + } + throw error + } + + let client + try { + client = await connectToDaemon({ host: options.host as string | undefined }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + const error: CommandError = { + code: 'DAEMON_NOT_RUNNING', + message: `Cannot connect to daemon at ${host}: ${message}`, + details: 'Start the daemon with: paseo daemon start', + } + throw error + } + + try { + // Request session state to get agent information + client.requestSessionState() + + // Wait a moment for the session state to be populated + await new Promise((resolve) => setTimeout(resolve, 500)) + + const agents = client.listAgents() + + // Resolve agent ID + const resolvedId = resolveAgentId(id, agents) + if (!resolvedId) { + const error: CommandError = { + code: 'AGENT_NOT_FOUND', + message: `No agent found matching: ${id}`, + details: 'Use `paseo agent ps` to list available agents', + } + throw error + } + + const agent = agents.find((a) => a.id === resolvedId) + if (!agent) { + const error: CommandError = { + code: 'AGENT_NOT_FOUND', + message: `Agent not found after resolution: ${resolvedId}`, + } + throw error + } + + if (options.list) { + // List available modes for this agent + const availableModes = agent.availableModes ?? [] + + await client.close() + + const items: ModeListItem[] = availableModes.map((m) => ({ + id: m.id, + label: m.label ?? m.id, + description: m.description ?? '', + })) + + return { + type: 'list', + data: items, + schema: modeListSchema, + } + } else { + // Set the agent mode + await client.setAgentMode(resolvedId, mode!) + + await client.close() + + return { + type: 'single', + data: { + agentId: resolvedId.slice(0, 7), + mode: mode!, + }, + schema: setModeSchema, + } + } + } catch (err) { + await client.close().catch(() => {}) + // Re-throw if it's already a CommandError + if (err && typeof err === 'object' && 'code' in err) { + throw err + } + const message = err instanceof Error ? err.message : String(err) + const error: CommandError = { + code: 'MODE_OPERATION_FAILED', + message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`, + } + throw error + } +} diff --git a/packages/cli/tests/10-agent-mode.test.ts b/packages/cli/tests/10-agent-mode.test.ts new file mode 100644 index 000000000..32f4993f4 --- /dev/null +++ b/packages/cli/tests/10-agent-mode.test.ts @@ -0,0 +1,141 @@ +#!/usr/bin/env npx tsx + +/** + * Phase 10: Agent Mode Command Tests + * + * Tests the agent mode command - changing and listing agent operational modes. + * Since daemon may not be running, we test both: + * - Help and argument parsing + * - Graceful error handling when daemon not running + * - All flags are accepted + * + * Tests: + * - agent mode --help shows options + * - agent mode requires id argument + * - agent mode handles daemon not running + * - agent mode --list flag is accepted + */ + +import assert from 'node:assert' +import { $ } from 'zx' +import { mkdtemp, rm } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +$.verbose = false + +console.log('=== Agent Mode Command Tests ===\n') + +// Get random port that's definitely not in use (never 6767) +const port = 10000 + Math.floor(Math.random() * 50000) +const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-')) + +try { + // Test 1: agent mode --help shows options + { + console.log('Test 1: agent mode --help shows options') + const result = await $`npx paseo agent mode --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'agent mode --help should exit 0') + assert(result.stdout.includes('--list'), 'help should mention --list flag') + assert(result.stdout.includes('--host'), 'help should mention --host option') + assert(result.stdout.includes(''), 'help should mention id argument') + assert(result.stdout.includes('[mode]'), 'help should mention optional mode argument') + console.log('✓ agent mode --help shows options\n') + } + + // Test 2: agent mode requires id argument + { + console.log('Test 2: agent mode requires id argument') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow() + assert.notStrictEqual(result.exitCode, 0, 'should fail without id') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('missing') || + output.toLowerCase().includes('required') || + output.toLowerCase().includes('argument') || + output.toLowerCase().includes('id') + assert(hasError, 'error should mention missing argument') + console.log('✓ agent mode requires id argument\n') + } + + // Test 3: agent mode handles daemon not running + { + console.log('Test 3: agent mode handles daemon not running') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow() + // Should fail because daemon not running + assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('daemon') || + output.toLowerCase().includes('connect') || + output.toLowerCase().includes('cannot') + assert(hasError, 'error message should mention connection issue') + console.log('✓ agent mode handles daemon not running\n') + } + + // Test 4: agent mode --list flag is accepted + { + console.log('Test 4: agent mode --list flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --list flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent mode --list flag is accepted\n') + } + + // Test 5: agent mode with ID and --host flag is accepted + { + console.log('Test 5: agent mode with ID and --host flag is accepted') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept --host flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ agent mode with ID and --host flag is accepted\n') + } + + // Test 6: agent shows mode in subcommands + { + console.log('Test 6: agent --help shows mode subcommand') + const result = await $`npx paseo agent --help`.nothrow() + assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0') + assert(result.stdout.includes('mode'), 'help should mention mode subcommand') + console.log('✓ agent --help shows mode subcommand\n') + } + + // Test 7: -q (quiet) flag is accepted with agent mode + { + console.log('Test 7: -q (quiet) flag is accepted with agent mode') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow() + const output = result.stdout + result.stderr + assert(!output.includes('unknown option'), 'should accept -q flag') + assert(!output.includes('error: option'), 'should not have option parsing error') + console.log('✓ -q (quiet) flag is accepted with agent mode\n') + } + + // Test 8: agent mode requires mode argument when not using --list + { + console.log('Test 8: agent mode requires mode argument when not using --list') + const result = + await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow() + // Should fail because mode is required unless --list is specified + assert.notStrictEqual(result.exitCode, 0, 'should fail without mode argument') + const output = result.stdout + result.stderr + const hasError = + output.toLowerCase().includes('missing') || + output.toLowerCase().includes('required') || + output.toLowerCase().includes('mode') || + output.toLowerCase().includes('daemon') // If daemon error comes first, that's also valid + assert(hasError, 'error should mention missing mode or connection issue') + console.log('✓ agent mode requires mode argument when not using --list\n') + } +} finally { + // Clean up temp directory + await rm(paseoHome, { recursive: true, force: true }) +} + +console.log('=== All agent mode tests passed ===')