From 6f8d7eaad3fb33381c6a6cbcc77aaf6ce5bc128e Mon Sep 17 00:00:00 2001 From: "kai.zhang" Date: Thu, 21 May 2026 14:39:57 +0800 Subject: [PATCH 1/3] Add Paseo schedule commands to CLI --- src/__tests__/cli/commands/paseo.test.ts | 110 +++++++++++++++ src/__tests__/cli/services/paseo.test.ts | 161 ++++++++++++++++++++++ src/cli/commands/paseo.ts | 81 +++++++++++ src/cli/index.ts | 40 ++++++ src/cli/services/paseo.ts | 163 +++++++++++++++++++++++ 5 files changed, 555 insertions(+) create mode 100644 src/__tests__/cli/commands/paseo.test.ts create mode 100644 src/__tests__/cli/services/paseo.test.ts create mode 100644 src/cli/commands/paseo.ts create mode 100644 src/cli/services/paseo.ts diff --git a/src/__tests__/cli/commands/paseo.test.ts b/src/__tests__/cli/commands/paseo.test.ts new file mode 100644 index 0000000000..11693e5808 --- /dev/null +++ b/src/__tests__/cli/commands/paseo.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; + +vi.mock('../../../cli/services/paseo', () => ({ + createPaseoSchedule: vi.fn(), + listPaseoSchedules: vi.fn(), + getPaseoScheduleLogs: vi.fn(), +})); + +vi.mock('../../../cli/output/formatter', () => ({ + formatError: vi.fn((message: string) => `Error: ${message}`), +})); + +import { + paseoScheduleCreate, + paseoScheduleList, + paseoScheduleLogs, +} from '../../../cli/commands/paseo'; +import { + createPaseoSchedule, + getPaseoScheduleLogs, + listPaseoSchedules, +} from '../../../cli/services/paseo'; + +describe('paseo command', () => { + let consoleSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let processExitSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit(${code})`); + }); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + it('creates a Paseo schedule and prints stdout', async () => { + vi.mocked(createPaseoSchedule).mockResolvedValue({ + stdout: 'ID NAME\nabc demo\n', + stderr: '', + }); + + await paseoScheduleCreate('do work', { + every: '2m', + name: 'demo', + provider: 'codex', + cwd: '/repo', + maxRuns: '2', + expiresIn: '10m', + }); + + expect(createPaseoSchedule).toHaveBeenCalledWith('do work', { + every: '2m', + name: 'demo', + provider: 'codex', + cwd: '/repo', + maxRuns: '2', + expiresIn: '10m', + }); + expect(consoleSpy).toHaveBeenCalledWith('ID NAME\nabc demo'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('lists schedules', async () => { + vi.mocked(listPaseoSchedules).mockResolvedValue({ stdout: 'schedules\n', stderr: '' }); + + await paseoScheduleList({ json: true, host: '127.0.0.1:6767' }); + + expect(listPaseoSchedules).toHaveBeenCalledWith({ + json: true, + host: '127.0.0.1:6767', + }); + expect(consoleSpy).toHaveBeenCalledWith('schedules'); + }); + + it('shows schedule logs', async () => { + vi.mocked(getPaseoScheduleLogs).mockResolvedValue({ stdout: 'logs\n', stderr: '' }); + + await paseoScheduleLogs('abc123', { cliPath: '/bin/paseo' }); + + expect(getPaseoScheduleLogs).toHaveBeenCalledWith('abc123', { cliPath: '/bin/paseo' }); + expect(consoleSpy).toHaveBeenCalledWith('logs'); + }); + + it('prints JSON errors when json mode is enabled', async () => { + vi.mocked(listPaseoSchedules).mockRejectedValue(new Error('daemon unavailable')); + + await expect(paseoScheduleList({ json: true })).rejects.toThrow('process.exit(1)'); + + const output = JSON.parse(consoleErrorSpy.mock.calls[0][0]); + expect(output).toEqual({ success: false, error: 'daemon unavailable' }); + }); + + it('prints human-readable errors otherwise', async () => { + vi.mocked(listPaseoSchedules).mockRejectedValue(new Error('daemon unavailable')); + + await expect(paseoScheduleList({})).rejects.toThrow('process.exit(1)'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error: daemon unavailable'); + }); +}); diff --git a/src/__tests__/cli/services/paseo.test.ts b/src/__tests__/cli/services/paseo.test.ts new file mode 100644 index 0000000000..524ac0f813 --- /dev/null +++ b/src/__tests__/cli/services/paseo.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('fs', () => ({ + statSync: vi.fn(), + accessSync: vi.fn(), + constants: { X_OK: 1 }, +})); + +vi.mock('os', () => ({ + platform: vi.fn(() => 'darwin'), +})); + +vi.mock('child_process', () => ({ + spawn: vi.fn(), +})); + +import { EventEmitter } from 'events'; +import { Readable } from 'stream'; +import * as fs from 'fs'; +import * as os from 'os'; +import { spawn } from 'child_process'; +import { + createPaseoSchedule, + getPaseoScheduleLogs, + listPaseoSchedules, + resolvePaseoCliPath, + runPaseoCommand, +} from '../../../cli/services/paseo'; + +function mockSpawnResult(code: number, stdout = '', stderr = ''): void { + const child = new EventEmitter() as EventEmitter & { + stdout: Readable; + stderr: Readable; + }; + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + + vi.mocked(spawn).mockReturnValue(child as any); + + setImmediate(() => { + if (stdout) child.stdout.emit('data', Buffer.from(stdout)); + if (stderr) child.stderr.emit('data', Buffer.from(stderr)); + child.emit('close', code); + }); +} + +describe('paseo service', () => { + const originalEnv = process.env.PASEO_CLI_PATH; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.PASEO_CLI_PATH; + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any); + vi.mocked(fs.accessSync).mockReturnValue(undefined); + }); + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.PASEO_CLI_PATH; + } else { + process.env.PASEO_CLI_PATH = originalEnv; + } + }); + + it('prefers an explicit CLI path', () => { + expect(resolvePaseoCliPath('/tmp/paseo')).toBe('/tmp/paseo'); + }); + + it('uses PASEO_CLI_PATH before bundled defaults', () => { + process.env.PASEO_CLI_PATH = '/env/paseo'; + expect(resolvePaseoCliPath()).toBe('/env/paseo'); + }); + + it('uses the macOS bundled Paseo CLI when executable', () => { + expect(resolvePaseoCliPath()).toBe('/Applications/Paseo.app/Contents/Resources/bin/paseo'); + }); + + it('falls back to PATH command when bundled CLI is unavailable', () => { + vi.mocked(fs.statSync).mockImplementation(() => { + throw new Error('missing'); + }); + expect(resolvePaseoCliPath()).toBe('paseo'); + }); + + it('runs Paseo commands and returns output', async () => { + mockSpawnResult(0, 'ok\n', 'warn\n'); + + const result = await runPaseoCommand(['schedule', 'ls'], { cliPath: '/bin/paseo' }); + + expect(spawn).toHaveBeenCalledWith('/bin/paseo', ['schedule', 'ls'], { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + expect(result).toEqual({ stdout: 'ok\n', stderr: 'warn\n' }); + }); + + it('rejects failed Paseo commands with stderr and exit code', async () => { + mockSpawnResult(2, '', 'bad option\n'); + + await expect(runPaseoCommand(['bad'], { cliPath: '/bin/paseo' })).rejects.toThrow('bad option'); + }); + + it('builds schedule create arguments', async () => { + mockSpawnResult(0, 'created\n'); + + await createPaseoSchedule('do work', { + cliPath: '/bin/paseo', + every: '2m', + name: 'demo', + provider: 'codex', + cwd: '/repo', + maxRuns: '2', + expiresIn: '10m', + json: true, + }); + + expect(spawn).toHaveBeenCalledWith( + '/bin/paseo', + [ + 'schedule', + 'create', + '--every', + '2m', + '--name', + 'demo', + '--provider', + 'codex', + '--cwd', + '/repo', + '--max-runs', + '2', + '--expires-in', + '10m', + '--json', + 'do work', + ], + expect.any(Object) + ); + }); + + it('builds schedule list and logs arguments', async () => { + mockSpawnResult(0, ''); + await listPaseoSchedules({ cliPath: '/bin/paseo', host: '127.0.0.1:6767' }); + + expect(spawn).toHaveBeenLastCalledWith( + '/bin/paseo', + ['schedule', 'ls', '--host', '127.0.0.1:6767'], + expect.any(Object) + ); + + mockSpawnResult(0, ''); + await getPaseoScheduleLogs('abc123', { cliPath: '/bin/paseo', json: true }); + + expect(spawn).toHaveBeenLastCalledWith( + '/bin/paseo', + ['schedule', 'logs', '--json', 'abc123'], + expect.any(Object) + ); + }); +}); diff --git a/src/cli/commands/paseo.ts b/src/cli/commands/paseo.ts new file mode 100644 index 0000000000..7e6a79ea27 --- /dev/null +++ b/src/cli/commands/paseo.ts @@ -0,0 +1,81 @@ +// Paseo command group for Maestro CLI + +import { + createPaseoSchedule, + getPaseoScheduleLogs, + listPaseoSchedules, + type PaseoCommandResult, +} from '../services/paseo'; +import { formatError } from '../output/formatter'; + +interface PaseoBaseOptions { + cliPath?: string; + host?: string; + json?: boolean; +} + +interface PaseoScheduleCreateCommandOptions extends PaseoBaseOptions { + every?: string; + cron?: string; + name?: string; + target?: string; + provider?: string; + mode?: string; + cwd?: string; + maxRuns?: string; + expiresIn?: string; + runNow?: boolean; + noRunNow?: boolean; +} + +function printResult(result: PaseoCommandResult): void { + if (result.stdout.trim()) { + console.log(result.stdout.trimEnd()); + } + if (result.stderr.trim()) { + console.error(result.stderr.trimEnd()); + } +} + +function printError(error: unknown, json?: boolean): void { + const message = error instanceof Error ? error.message : 'Unknown error'; + if (json) { + console.error(JSON.stringify({ success: false, error: message }, null, 2)); + } else { + console.error(formatError(message)); + } + process.exit(1); +} + +export async function paseoScheduleCreate( + prompt: string, + options: PaseoScheduleCreateCommandOptions +): Promise { + try { + const result = await createPaseoSchedule(prompt, options); + printResult(result); + } catch (error) { + printError(error, options.json); + } +} + +export async function paseoScheduleList(options: PaseoBaseOptions): Promise { + try { + const result = await listPaseoSchedules(options); + printResult(result); + } catch (error) { + printError(error, options.json); + } +} + +export async function paseoScheduleLogs( + scheduleId: string, + options: PaseoBaseOptions +): Promise { + try { + const result = await getPaseoScheduleLogs(scheduleId, options); + printResult(result); + } catch (error) { + printError(error, options.json); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 05d868e744..3a7568ec6b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,6 +23,7 @@ import { settingsAgentSet, settingsAgentReset, } from './commands/settings-agent'; +import { paseoScheduleCreate, paseoScheduleList, paseoScheduleLogs } from './commands/paseo'; // Read version from package.json at runtime function getVersion(): string { @@ -120,6 +121,45 @@ program .option('-s, --session ', 'Resume an existing agent session (for multi-turn conversations)') .action(send); +// Paseo commands - create and inspect Paseo-managed schedules from Maestro +const paseo = program.command('paseo').description('Manage Paseo tasks from Maestro'); +const paseoSchedule = paseo.command('schedule').description('Manage Paseo recurring schedules'); + +paseoSchedule + .command('create ') + .description('Create a Paseo schedule') + .option('--every ', 'Fixed interval cadence (for example: 5m, 1h)') + .option('--cron ', 'Cron cadence expression') + .option('--name ', 'Schedule name') + .option('--target ', 'Run target: self, new-agent, or agent id') + .option('--provider ', 'Paseo provider, or provider/model') + .option('--mode ', 'Provider-specific mode') + .option('--cwd ', 'Working directory for scheduled runs') + .option('--max-runs ', 'Maximum number of runs') + .option('--expires-in ', 'Time to live for the schedule') + .option('--run-now', 'Fire one immediate run on creation') + .option('--no-run-now', 'Wait for the first cadence interval') + .option('--host ', 'Paseo daemon host target') + .option('--cli-path ', 'Path to the Paseo CLI binary') + .option('--json', 'Ask Paseo for JSON output') + .action(paseoScheduleCreate); + +paseoSchedule + .command('ls') + .description('List Paseo schedules') + .option('--host ', 'Paseo daemon host target') + .option('--cli-path ', 'Path to the Paseo CLI binary') + .option('--json', 'Ask Paseo for JSON output') + .action(paseoScheduleList); + +paseoSchedule + .command('logs ') + .description('Show Paseo schedule run logs') + .option('--host ', 'Paseo daemon host target') + .option('--cli-path ', 'Path to the Paseo CLI binary') + .option('--json', 'Ask Paseo for JSON output') + .action(paseoScheduleLogs); + // Settings commands const settings = program.command('settings').description('View and manage Maestro configuration'); diff --git a/src/cli/services/paseo.ts b/src/cli/services/paseo.ts new file mode 100644 index 0000000000..499f58d2a7 --- /dev/null +++ b/src/cli/services/paseo.ts @@ -0,0 +1,163 @@ +// Paseo CLI adapter for Maestro CLI +// Wraps the local Paseo CLI so Maestro can create and inspect Paseo-managed work. + +import * as childProcess from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; + +const MACOS_BUNDLED_PASEO_CLI = '/Applications/Paseo.app/Contents/Resources/bin/paseo'; + +export interface PaseoExecOptions { + cliPath?: string; +} + +export interface PaseoCommandResult { + stdout: string; + stderr: string; +} + +export interface PaseoScheduleCreateOptions extends PaseoExecOptions { + every?: string; + cron?: string; + name?: string; + target?: string; + provider?: string; + mode?: string; + cwd?: string; + maxRuns?: string; + expiresIn?: string; + runNow?: boolean; + noRunNow?: boolean; + host?: string; + json?: boolean; +} + +export interface PaseoScheduleListOptions extends PaseoExecOptions { + host?: string; + json?: boolean; +} + +export interface PaseoScheduleLogsOptions extends PaseoExecOptions { + host?: string; + json?: boolean; +} + +function isExecutable(filePath: string): boolean { + try { + const stats = fs.statSync(filePath); + if (!stats.isFile()) return false; + if (os.platform() !== 'win32') { + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +export function resolvePaseoCliPath(explicitPath?: string): string { + if (explicitPath) return explicitPath; + if (process.env.PASEO_CLI_PATH) return process.env.PASEO_CLI_PATH; + if (os.platform() === 'darwin' && isExecutable(MACOS_BUNDLED_PASEO_CLI)) { + return MACOS_BUNDLED_PASEO_CLI; + } + return 'paseo'; +} + +export function runPaseoCommand( + args: string[], + options: PaseoExecOptions = {} +): Promise { + return new Promise((resolve, reject) => { + const cliPath = resolvePaseoCliPath(options.cliPath); + const child = childProcess.spawn(cliPath, args, { + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout?.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.on('error', (error) => { + reject(new Error(`Failed to run Paseo CLI (${cliPath}): ${error.message}`)); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + return; + } + + const details = [stderr.trim(), stdout.trim(), `Paseo exited with code ${code}`] + .filter(Boolean) + .join('\n'); + reject(new Error(details)); + }); + }); +} + +function addOption(args: string[], flag: string, value?: string): void { + if (value !== undefined && value !== '') { + args.push(flag, value); + } +} + +function addCommonOptions(args: string[], options: { host?: string; json?: boolean }): void { + addOption(args, '--host', options.host); + if (options.json) { + args.push('--json'); + } +} + +export function createPaseoSchedule( + prompt: string, + options: PaseoScheduleCreateOptions +): Promise { + const args = ['schedule', 'create']; + + addOption(args, '--every', options.every); + addOption(args, '--cron', options.cron); + addOption(args, '--name', options.name); + addOption(args, '--target', options.target); + addOption(args, '--provider', options.provider); + addOption(args, '--mode', options.mode); + addOption(args, '--cwd', options.cwd); + addOption(args, '--max-runs', options.maxRuns); + addOption(args, '--expires-in', options.expiresIn); + + if (options.runNow) { + args.push('--run-now'); + } + if (options.noRunNow) { + args.push('--no-run-now'); + } + + addCommonOptions(args, options); + args.push(prompt); + + return runPaseoCommand(args, options); +} + +export function listPaseoSchedules(options: PaseoScheduleListOptions): Promise { + const args = ['schedule', 'ls']; + addCommonOptions(args, options); + return runPaseoCommand(args, options); +} + +export function getPaseoScheduleLogs( + scheduleId: string, + options: PaseoScheduleLogsOptions +): Promise { + const args = ['schedule', 'logs']; + addCommonOptions(args, options); + args.push(scheduleId); + return runPaseoCommand(args, options); +} From 5b7c5b8dec28196fa23039754c6d557d26d414a9 Mon Sep 17 00:00:00 2001 From: "kai.zhang" Date: Thu, 21 May 2026 15:11:45 +0800 Subject: [PATCH 2/3] Address Paseo CLI review feedback --- src/__tests__/cli/services/paseo.test.ts | 61 +++++++++++++++++++++++- src/cli/commands/paseo.ts | 1 - src/cli/services/paseo.ts | 12 ++--- 3 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/__tests__/cli/services/paseo.test.ts b/src/__tests__/cli/services/paseo.test.ts index 524ac0f813..af69eea177 100644 --- a/src/__tests__/cli/services/paseo.test.ts +++ b/src/__tests__/cli/services/paseo.test.ts @@ -27,7 +27,10 @@ import { runPaseoCommand, } from '../../../cli/services/paseo'; -function mockSpawnResult(code: number, stdout = '', stderr = ''): void { +function mockSpawnChild(): EventEmitter & { + stdout: Readable; + stderr: Readable; +} { const child = new EventEmitter() as EventEmitter & { stdout: Readable; stderr: Readable; @@ -37,6 +40,12 @@ function mockSpawnResult(code: number, stdout = '', stderr = ''): void { vi.mocked(spawn).mockReturnValue(child as any); + return child; +} + +function mockSpawnResult(code: number | null, stdout = '', stderr = ''): void { + const child = mockSpawnChild(); + setImmediate(() => { if (stdout) child.stdout.emit('data', Buffer.from(stdout)); if (stderr) child.stderr.emit('data', Buffer.from(stderr)); @@ -101,6 +110,26 @@ describe('paseo service', () => { await expect(runPaseoCommand(['bad'], { cliPath: '/bin/paseo' })).rejects.toThrow('bad option'); }); + it('rejects failed Paseo commands that close without an exit code', async () => { + mockSpawnResult(null); + + await expect(runPaseoCommand(['bad'], { cliPath: '/bin/paseo' })).rejects.toThrow( + 'Paseo exited without an exit code' + ); + }); + + it('rejects when the Paseo process fails to spawn', async () => { + const child = mockSpawnChild(); + + setImmediate(() => { + child.emit('error', new Error('ENOENT')); + }); + + await expect( + runPaseoCommand(['schedule', 'ls'], { cliPath: '/missing/paseo' }) + ).rejects.toThrow('Failed to run Paseo CLI (/missing/paseo): ENOENT'); + }); + it('builds schedule create arguments', async () => { mockSpawnResult(0, 'created\n'); @@ -139,6 +168,36 @@ describe('paseo service', () => { ); }); + it('builds schedule create arguments with explicit immediate run control', async () => { + mockSpawnResult(0, 'created\n'); + + await createPaseoSchedule('do work now', { + cliPath: '/bin/paseo', + every: '2m', + runNow: true, + }); + + expect(spawn).toHaveBeenLastCalledWith( + '/bin/paseo', + ['schedule', 'create', '--every', '2m', '--run-now', 'do work now'], + expect.any(Object) + ); + + mockSpawnResult(0, 'created\n'); + + await createPaseoSchedule('do work later', { + cliPath: '/bin/paseo', + every: '2m', + runNow: false, + }); + + expect(spawn).toHaveBeenLastCalledWith( + '/bin/paseo', + ['schedule', 'create', '--every', '2m', '--no-run-now', 'do work later'], + expect.any(Object) + ); + }); + it('builds schedule list and logs arguments', async () => { mockSpawnResult(0, ''); await listPaseoSchedules({ cliPath: '/bin/paseo', host: '127.0.0.1:6767' }); diff --git a/src/cli/commands/paseo.ts b/src/cli/commands/paseo.ts index 7e6a79ea27..ded00a2305 100644 --- a/src/cli/commands/paseo.ts +++ b/src/cli/commands/paseo.ts @@ -25,7 +25,6 @@ interface PaseoScheduleCreateCommandOptions extends PaseoBaseOptions { maxRuns?: string; expiresIn?: string; runNow?: boolean; - noRunNow?: boolean; } function printResult(result: PaseoCommandResult): void { diff --git a/src/cli/services/paseo.ts b/src/cli/services/paseo.ts index 499f58d2a7..c8adca1439 100644 --- a/src/cli/services/paseo.ts +++ b/src/cli/services/paseo.ts @@ -27,7 +27,6 @@ export interface PaseoScheduleCreateOptions extends PaseoExecOptions { maxRuns?: string; expiresIn?: string; runNow?: boolean; - noRunNow?: boolean; host?: string; json?: boolean; } @@ -96,9 +95,9 @@ export function runPaseoCommand( return; } - const details = [stderr.trim(), stdout.trim(), `Paseo exited with code ${code}`] - .filter(Boolean) - .join('\n'); + const exitDetail = + code === null ? 'Paseo exited without an exit code' : `Paseo exited with code ${code}`; + const details = [stderr.trim(), stdout.trim(), exitDetail].filter(Boolean).join('\n'); reject(new Error(details)); }); }); @@ -133,10 +132,9 @@ export function createPaseoSchedule( addOption(args, '--max-runs', options.maxRuns); addOption(args, '--expires-in', options.expiresIn); - if (options.runNow) { + if (options.runNow === true) { args.push('--run-now'); - } - if (options.noRunNow) { + } else if (options.runNow === false) { args.push('--no-run-now'); } From c18b39fef911680bc4d54ac7f659a10a59fb49c2 Mon Sep 17 00:00:00 2001 From: "kai.zhang" Date: Thu, 21 May 2026 15:32:07 +0800 Subject: [PATCH 3/3] Add titled Paseo run command --- src/__tests__/cli/commands/paseo.test.ts | 26 ++++++++++ src/__tests__/cli/services/paseo.test.ts | 66 ++++++++++++++++++++++++ src/cli/commands/paseo.ts | 21 ++++++++ src/cli/index.ts | 25 ++++++++- src/cli/services/paseo.ts | 35 +++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/__tests__/cli/commands/paseo.test.ts b/src/__tests__/cli/commands/paseo.test.ts index 11693e5808..a8d33ba446 100644 --- a/src/__tests__/cli/commands/paseo.test.ts +++ b/src/__tests__/cli/commands/paseo.test.ts @@ -4,6 +4,7 @@ vi.mock('../../../cli/services/paseo', () => ({ createPaseoSchedule: vi.fn(), listPaseoSchedules: vi.fn(), getPaseoScheduleLogs: vi.fn(), + runPaseoAgent: vi.fn(), })); vi.mock('../../../cli/output/formatter', () => ({ @@ -11,6 +12,7 @@ vi.mock('../../../cli/output/formatter', () => ({ })); import { + paseoRun, paseoScheduleCreate, paseoScheduleList, paseoScheduleLogs, @@ -19,6 +21,7 @@ import { createPaseoSchedule, getPaseoScheduleLogs, listPaseoSchedules, + runPaseoAgent, } from '../../../cli/services/paseo'; describe('paseo command', () => { @@ -70,6 +73,29 @@ describe('paseo command', () => { expect(processExitSpy).not.toHaveBeenCalled(); }); + it('runs a titled Paseo agent and prints stdout', async () => { + vi.mocked(runPaseoAgent).mockResolvedValue({ + stdout: 'agent-123\n', + stderr: '', + }); + + await paseoRun('do visible work', { + title: 'Visible Work', + provider: 'codex', + cwd: '/repo', + detach: true, + }); + + expect(runPaseoAgent).toHaveBeenCalledWith('do visible work', { + title: 'Visible Work', + provider: 'codex', + cwd: '/repo', + detach: true, + }); + expect(consoleSpy).toHaveBeenCalledWith('agent-123'); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + it('lists schedules', async () => { vi.mocked(listPaseoSchedules).mockResolvedValue({ stdout: 'schedules\n', stderr: '' }); diff --git a/src/__tests__/cli/services/paseo.test.ts b/src/__tests__/cli/services/paseo.test.ts index af69eea177..a395899e13 100644 --- a/src/__tests__/cli/services/paseo.test.ts +++ b/src/__tests__/cli/services/paseo.test.ts @@ -25,6 +25,7 @@ import { listPaseoSchedules, resolvePaseoCliPath, runPaseoCommand, + runPaseoAgent, } from '../../../cli/services/paseo'; function mockSpawnChild(): EventEmitter & { @@ -198,6 +199,71 @@ describe('paseo service', () => { ); }); + it('builds run arguments with title and detached execution by default', async () => { + mockSpawnResult(0, 'agent-123\n'); + + await runPaseoAgent('do visible work', { + cliPath: '/bin/paseo', + title: 'Visible Work', + provider: 'codex', + mode: 'bypass', + cwd: '/repo', + host: '127.0.0.1:6767', + json: true, + }); + + expect(spawn).toHaveBeenLastCalledWith( + '/bin/paseo', + [ + 'run', + '--title', + 'Visible Work', + '--provider', + 'codex', + '--mode', + 'bypass', + '--cwd', + '/repo', + '--detach', + '--host', + '127.0.0.1:6767', + '--json', + 'do visible work', + ], + expect.any(Object) + ); + }); + + it('builds run arguments without detach when explicitly disabled', async () => { + mockSpawnResult(0, 'done\n'); + + await runPaseoAgent('do foreground work', { + cliPath: '/bin/paseo', + title: 'Foreground Work', + model: 'gpt-5.4', + thinking: 'high', + detach: false, + waitTimeout: '5m', + }); + + expect(spawn).toHaveBeenLastCalledWith( + '/bin/paseo', + [ + 'run', + '--title', + 'Foreground Work', + '--model', + 'gpt-5.4', + '--thinking', + 'high', + '--wait-timeout', + '5m', + 'do foreground work', + ], + expect.any(Object) + ); + }); + it('builds schedule list and logs arguments', async () => { mockSpawnResult(0, ''); await listPaseoSchedules({ cliPath: '/bin/paseo', host: '127.0.0.1:6767' }); diff --git a/src/cli/commands/paseo.ts b/src/cli/commands/paseo.ts index ded00a2305..9a07e45aaf 100644 --- a/src/cli/commands/paseo.ts +++ b/src/cli/commands/paseo.ts @@ -4,6 +4,7 @@ import { createPaseoSchedule, getPaseoScheduleLogs, listPaseoSchedules, + runPaseoAgent, type PaseoCommandResult, } from '../services/paseo'; import { formatError } from '../output/formatter'; @@ -27,6 +28,17 @@ interface PaseoScheduleCreateCommandOptions extends PaseoBaseOptions { runNow?: boolean; } +interface PaseoRunCommandOptions extends PaseoBaseOptions { + title?: string; + provider?: string; + model?: string; + thinking?: string; + mode?: string; + cwd?: string; + detach?: boolean; + waitTimeout?: string; +} + function printResult(result: PaseoCommandResult): void { if (result.stdout.trim()) { console.log(result.stdout.trimEnd()); @@ -46,6 +58,15 @@ function printError(error: unknown, json?: boolean): void { process.exit(1); } +export async function paseoRun(prompt: string, options: PaseoRunCommandOptions): Promise { + try { + const result = await runPaseoAgent(prompt, options); + printResult(result); + } catch (error) { + printError(error, options.json); + } +} + export async function paseoScheduleCreate( prompt: string, options: PaseoScheduleCreateCommandOptions diff --git a/src/cli/index.ts b/src/cli/index.ts index 3a7568ec6b..8e16e4f1ce 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -23,7 +23,12 @@ import { settingsAgentSet, settingsAgentReset, } from './commands/settings-agent'; -import { paseoScheduleCreate, paseoScheduleList, paseoScheduleLogs } from './commands/paseo'; +import { + paseoRun, + paseoScheduleCreate, + paseoScheduleList, + paseoScheduleLogs, +} from './commands/paseo'; // Read version from package.json at runtime function getVersion(): string { @@ -123,6 +128,24 @@ program // Paseo commands - create and inspect Paseo-managed schedules from Maestro const paseo = program.command('paseo').description('Manage Paseo tasks from Maestro'); + +paseo + .command('run ') + .description('Create and start a titled Paseo agent') + .option('--title ', 'Paseo agent title') + .option('--provider <provider>', 'Paseo provider, or provider/model') + .option('--model <model>', 'Model to use') + .option('--thinking <id>', 'Thinking option ID to use for this run') + .option('--mode <mode>', 'Provider-specific mode') + .option('--cwd <path>', 'Working directory for the agent') + .option('--detach', 'Run in background', true) + .option('--no-detach', 'Wait for the run instead of detaching') + .option('--wait-timeout <duration>', 'Maximum time to wait when not detached') + .option('--host <host>', 'Paseo daemon host target') + .option('--cli-path <path>', 'Path to the Paseo CLI binary') + .option('--json', 'Ask Paseo for JSON output') + .action(paseoRun); + const paseoSchedule = paseo.command('schedule').description('Manage Paseo recurring schedules'); paseoSchedule diff --git a/src/cli/services/paseo.ts b/src/cli/services/paseo.ts index c8adca1439..cff14bad2d 100644 --- a/src/cli/services/paseo.ts +++ b/src/cli/services/paseo.ts @@ -31,6 +31,19 @@ export interface PaseoScheduleCreateOptions extends PaseoExecOptions { json?: boolean; } +export interface PaseoRunOptions extends PaseoExecOptions { + title?: string; + provider?: string; + model?: string; + thinking?: string; + mode?: string; + cwd?: string; + detach?: boolean; + waitTimeout?: string; + host?: string; + json?: boolean; +} + export interface PaseoScheduleListOptions extends PaseoExecOptions { host?: string; json?: boolean; @@ -144,6 +157,28 @@ export function createPaseoSchedule( return runPaseoCommand(args, options); } +export function runPaseoAgent( + prompt: string, + options: PaseoRunOptions +): Promise<PaseoCommandResult> { + const args = ['run']; + + addOption(args, '--title', options.title); + addOption(args, '--provider', options.provider); + addOption(args, '--model', options.model); + addOption(args, '--thinking', options.thinking); + addOption(args, '--mode', options.mode); + addOption(args, '--cwd', options.cwd); + if (options.detach !== false) { + args.push('--detach'); + } + addOption(args, '--wait-timeout', options.waitTimeout); + addCommonOptions(args, options); + args.push(prompt); + + return runPaseoCommand(args, options); +} + export function listPaseoSchedules(options: PaseoScheduleListOptions): Promise<PaseoCommandResult> { const args = ['schedule', 'ls']; addCommonOptions(args, options);