diff --git a/apps/server/cli/errand.test.ts b/apps/server/cli/errand.test.ts new file mode 100644 index 0000000..0b2f5bb --- /dev/null +++ b/apps/server/cli/errand.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCli } from './index.js'; + +const mockClient = { + errand: { + create: { mutate: vi.fn() }, + list: { query: vi.fn() }, + get: { query: vi.fn() }, + diff: { query: vi.fn() }, + complete: { mutate: vi.fn() }, + merge: { mutate: vi.fn() }, + delete: { mutate: vi.fn() }, + sendMessage: { mutate: vi.fn() }, + abandon: { mutate: vi.fn() }, + }, +}; + +vi.mock('./trpc-client.js', () => ({ + createDefaultTrpcClient: () => mockClient, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitCode = 0; + + vi.spyOn(process.stdout, 'write').mockImplementation((s: any) => { stdoutLines.push(String(s)); return true; }); + vi.spyOn(process.stderr, 'write').mockImplementation((s: any) => { stderrLines.push(String(s)); return true; }); + vi.spyOn(console, 'log').mockImplementation((...a: any[]) => { stdoutLines.push(a.join(' ')); }); + vi.spyOn(console, 'error').mockImplementation((...a: any[]) => { stderrLines.push(a.join(' ')); }); + vi.spyOn(process, 'exit').mockImplementation((code?: any) => { exitCode = code ?? 0; throw new Error(`process.exit(${code})`); }); + + const program = createCli(); + try { + await program.parseAsync(['node', 'cw', ...args]); + } catch (e: any) { + if (!e.message?.startsWith('process.exit')) throw e; + } + + vi.restoreAllMocks(); + return { + stdout: stdoutLines.join('\n'), + stderr: stderrLines.join('\n'), + exitCode, + }; +} + +describe('cw errand start', () => { + it('calls errand.create.mutate with correct args and prints output', async () => { + mockClient.errand.create.mutate.mockResolvedValueOnce({ + id: 'errand-abc123', + branch: 'cw/errand/fix-typo-errand-ab', + agentId: 'agent-xyz', + }); + const { stdout, exitCode } = await runCli(['errand', 'start', 'fix typo', '--project', 'proj-1']); + expect(mockClient.errand.create.mutate).toHaveBeenCalledWith({ + description: 'fix typo', + projectId: 'proj-1', + baseBranch: undefined, + }); + expect(stdout).toContain('Errand started'); + expect(stdout).toContain('errand-abc123'); + expect(exitCode).toBe(0); + }); + + it('exits 1 and prints length error without calling tRPC when description > 200 chars', async () => { + const longDesc = 'x'.repeat(201); + const { stderr, exitCode } = await runCli(['errand', 'start', longDesc, '--project', 'proj-1']); + expect(mockClient.errand.create.mutate).not.toHaveBeenCalled(); + expect(stderr).toContain('description must be ≤200 characters (201 given)'); + expect(exitCode).toBe(1); + }); + + it('passes --base option as baseBranch', async () => { + mockClient.errand.create.mutate.mockResolvedValueOnce({ id: 'e1', branch: 'b', agentId: 'a' }); + await runCli(['errand', 'start', 'fix thing', '--project', 'p1', '--base', 'develop']); + expect(mockClient.errand.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ baseBranch: 'develop' }) + ); + }); +}); + +describe('cw errand list', () => { + it('prints tab-separated rows for errands', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([ + { id: 'errand-abc123full', description: 'fix the bug', branch: 'cw/errand/fix-bug-errand-ab', status: 'active', agentAlias: 'my-agent' }, + ]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('errand-a'); // id.slice(0,8) + expect(stdout).toContain('fix the bug'); + expect(stdout).toContain('active'); + expect(stdout).toContain('my-agent'); + }); + + it('prints "No errands found" on empty result', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('No errands found'); + }); + + it('truncates description at 60 chars with ellipsis', async () => { + const longDesc = 'a'.repeat(65); + mockClient.errand.list.query.mockResolvedValueOnce([ + { id: 'x'.repeat(16), description: longDesc, branch: 'b', status: 'active', agentAlias: null }, + ]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('a'.repeat(57) + '...'); + }); + + it('passes --status filter to query', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([]); + await runCli(['errand', 'list', '--status', 'active']); + expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ status: 'active' })); + }); + + it('passes --project filter to query', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([]); + await runCli(['errand', 'list', '--project', 'proj-99']); + expect(mockClient.errand.list.query).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'proj-99' })); + }); + + it('shows "-" for null agentAlias', async () => { + mockClient.errand.list.query.mockResolvedValueOnce([ + { id: 'x'.repeat(16), description: 'test', branch: 'b', status: 'active', agentAlias: null }, + ]); + const { stdout } = await runCli(['errand', 'list']); + expect(stdout).toContain('-'); + }); +}); + +describe('cw errand chat', () => { + it('calls sendMessage.mutate with no stdout on success', async () => { + mockClient.errand.sendMessage.mutate.mockResolvedValueOnce({ success: true }); + const { stdout, exitCode } = await runCli(['errand', 'chat', 'e1', 'hello there']); + expect(mockClient.errand.sendMessage.mutate).toHaveBeenCalledWith({ id: 'e1', message: 'hello there' }); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(0); + }); + + it('exits 1 and prints error when tRPC throws (agent not running)', async () => { + mockClient.errand.sendMessage.mutate.mockRejectedValueOnce(new Error('Agent is not running (status: stopped)')); + const { stderr, exitCode } = await runCli(['errand', 'chat', 'e1', 'msg']); + expect(stderr).toContain('Agent is not running'); + expect(exitCode).toBe(1); + }); +}); + +describe('cw errand diff', () => { + it('writes raw diff to stdout and exits 0', async () => { + mockClient.errand.diff.query.mockResolvedValueOnce({ diff: 'diff --git a/foo.ts b/foo.ts\n+++ change' }); + const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']); + expect(stdout).toContain('diff --git'); + expect(exitCode).toBe(0); + }); + + it('produces no output on empty diff and exits 0', async () => { + mockClient.errand.diff.query.mockResolvedValueOnce({ diff: '' }); + const { stdout, exitCode } = await runCli(['errand', 'diff', 'e1']); + expect(stdout.trim()).toBe(''); + expect(exitCode).toBe(0); + }); + + it('exits 1 with "Errand not found" on NOT_FOUND error', async () => { + mockClient.errand.diff.query.mockRejectedValueOnce(new Error('NOT_FOUND: errand not found')); + const { stderr, exitCode } = await runCli(['errand', 'diff', 'missing-id']); + expect(stderr).toContain('Errand missing-id not found'); + expect(exitCode).toBe(1); + }); +}); + +describe('cw errand complete', () => { + it('prints "Errand marked as ready for review"', async () => { + mockClient.errand.complete.mutate.mockResolvedValueOnce({}); + const { stdout, exitCode } = await runCli(['errand', 'complete', 'errand-1']); + expect(stdout).toContain('Errand errand-1 marked as ready for review'); + expect(exitCode).toBe(0); + }); +}); + +describe('cw errand merge', () => { + it('prints "Merged into " on clean merge', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review', + conflictFiles: [], projectPath: '/path/to/repo', + }); + mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' }); + const { stdout, exitCode } = await runCli(['errand', 'merge', 'e1']); + expect(stdout).toContain('Merged cw/errand/fix-bug-e1 into main'); + expect(exitCode).toBe(0); + }); + + it('exits 1 and prints conflicting files on conflict', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', branch: 'cw/errand/fix-bug-e1', baseBranch: 'main', status: 'pending_review', + conflictFiles: [], projectPath: '/repo', + }); + const conflictError = Object.assign(new Error('Merge conflict'), { + data: { conflictFiles: ['src/a.ts', 'src/b.ts'] }, + }); + mockClient.errand.merge.mutate.mockRejectedValueOnce(conflictError); + const { stderr, exitCode } = await runCli(['errand', 'merge', 'e1']); + expect(stderr).toContain('Merge conflict in 2 file(s)'); + expect(stderr).toContain('src/a.ts'); + expect(stderr).toContain('src/b.ts'); + expect(stderr).toContain('Run: cw errand resolve e1'); + expect(exitCode).toBe(1); + }); + + it('uses --target override instead of baseBranch', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', branch: 'cw/errand/fix-e1', baseBranch: 'main', status: 'pending_review', + conflictFiles: [], projectPath: '/repo', + }); + mockClient.errand.merge.mutate.mockResolvedValueOnce({ status: 'merged' }); + const { stdout } = await runCli(['errand', 'merge', 'e1', '--target', 'develop']); + expect(stdout).toContain('Merged cw/errand/fix-e1 into develop'); + expect(mockClient.errand.merge.mutate).toHaveBeenCalledWith({ id: 'e1', target: 'develop' }); + }); +}); + +describe('cw errand resolve', () => { + it('prints worktree path and conflicting files when status is conflict', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', status: 'conflict', conflictFiles: ['src/a.ts', 'src/b.ts'], + projectPath: '/home/user/project', branch: 'cw/errand/fix-e1', baseBranch: 'main', + }); + const { stdout, exitCode } = await runCli(['errand', 'resolve', 'e1']); + expect(stdout).toContain('/home/user/project/.cw-worktrees/e1'); + expect(stdout).toContain('src/a.ts'); + expect(stdout).toContain('src/b.ts'); + expect(stdout).toContain('cw errand merge e1'); + expect(exitCode).toBe(0); + }); + + it('exits 1 with status message when errand is not in conflict', async () => { + mockClient.errand.get.query.mockResolvedValueOnce({ + id: 'e1', status: 'pending_review', conflictFiles: [], projectPath: '/repo', + }); + const { stderr, exitCode } = await runCli(['errand', 'resolve', 'e1']); + expect(stderr).toContain('is not in conflict'); + expect(stderr).toContain('pending_review'); + expect(exitCode).toBe(1); + }); +}); + +describe('cw errand abandon', () => { + it('prints "Errand abandoned"', async () => { + mockClient.errand.abandon.mutate.mockResolvedValueOnce({}); + const { stdout, exitCode } = await runCli(['errand', 'abandon', 'errand-1']); + expect(stdout).toContain('Errand errand-1 abandoned'); + expect(exitCode).toBe(0); + }); +}); + +describe('cw errand delete', () => { + it('prints "Errand deleted"', async () => { + mockClient.errand.delete.mutate.mockResolvedValueOnce({ success: true }); + const { stdout, exitCode } = await runCli(['errand', 'delete', 'errand-1']); + expect(stdout).toContain('Errand errand-1 deleted'); + expect(exitCode).toBe(0); + }); +}); diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 007035c..8fc0425 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1728,6 +1728,195 @@ See the Codewalkers documentation for .cw-preview.yml format and options.`; } }); + // ── Errand commands ──────────────────────────────────────────────── + const errandCommand = program + .command('errand') + .description('Manage lightweight interactive agent sessions for small changes'); + + errandCommand + .command('start ') + .description('Start a new errand session') + .requiredOption('--project ', 'Project ID') + .option('--base ', 'Base branch to create errand from (default: main)') + .action(async (description: string, options: { project: string; base?: string }) => { + if (description.length > 200) { + console.error(`Error: description must be ≤200 characters (${description.length} given)`); + process.exit(1); + } + try { + const client = createDefaultTrpcClient(); + const errand = await client.errand.create.mutate({ + description, + projectId: options.project, + baseBranch: options.base, + }); + console.log('Errand started'); + console.log(` ID: ${errand.id}`); + console.log(` Branch: ${errand.branch}`); + console.log(` Agent: ${errand.agentId}`); + } catch (error) { + console.error('Failed to start errand:', (error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('list') + .description('List errands') + .option('--project ', 'Filter by project') + .option('--status ', 'Filter by status: active|pending_review|conflict|merged|abandoned') + .action(async (options: { project?: string; status?: string }) => { + try { + const client = createDefaultTrpcClient(); + const errands = await client.errand.list.query({ + projectId: options.project, + status: options.status as any, + }); + if (errands.length === 0) { + console.log('No errands found'); + return; + } + for (const e of errands) { + const desc = e.description.length > 60 ? e.description.slice(0, 57) + '...' : e.description; + console.log([e.id.slice(0, 8), desc, e.branch, e.status, e.agentAlias ?? '-'].join('\t')); + } + } catch (error) { + console.error('Failed to list errands:', (error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('chat ') + .description('Deliver a message to the running errand agent') + .action(async (id: string, message: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.sendMessage.mutate({ id, message }); + // No stdout on success — agent response appears in UI log stream + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('diff ') + .description('Print unified git diff between base branch and errand branch') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + const { diff } = await client.errand.diff.query({ id }); + if (diff) process.stdout.write(diff); + // Empty diff: no output, exit 0 — not an error + } catch (error) { + const msg = (error as Error).message; + if (msg.includes('not found') || msg.includes('NOT_FOUND')) { + console.error(`Errand ${id} not found`); + } else { + console.error(msg); + } + process.exit(1); + } + }); + + errandCommand + .command('complete ') + .description('Mark errand as done and ready for review') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.complete.mutate({ id }); + console.log(`Errand ${id} marked as ready for review`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('merge ') + .description('Merge errand branch into target branch') + .option('--target ', 'Target branch (default: baseBranch stored in DB)') + .action(async (id: string, options: { target?: string }) => { + try { + const client = createDefaultTrpcClient(); + const errand = await client.errand.get.query({ id }); + await client.errand.merge.mutate({ id, target: options.target }); + const target = options.target ?? errand.baseBranch; + console.log(`Merged ${errand.branch} into ${target}`); + } catch (error) { + const err = error as any; + const conflictFiles: string[] | undefined = + err?.data?.conflictFiles ?? err?.shape?.data?.conflictFiles; + if (conflictFiles) { + console.error(`Merge conflict in ${conflictFiles.length} file(s):`); + for (const f of conflictFiles) console.error(` ${f}`); + console.error(`Run: cw errand resolve ${id}`); + } else { + console.error((error as Error).message); + } + process.exit(1); + } + }); + + errandCommand + .command('resolve ') + .description('Print worktree path and conflicting files for manual resolution') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + const errand = await client.errand.get.query({ id }); + if (errand.status !== 'conflict') { + console.error(`Errand ${id} is not in conflict (status: ${errand.status})`); + process.exit(1); + } + // projectPath is added to errand.get by Task 1; cast until type is updated + const projectPath = (errand as any).projectPath as string | null | undefined; + const worktreePath = projectPath + ? `${projectPath}/.cw-worktrees/${id}` + : `.cw-worktrees/${id}`; + console.log(`Resolve conflicts in worktree: ${worktreePath}`); + console.log('Conflicting files:'); + for (const f of errand.conflictFiles ?? []) { + console.log(` ${f}`); + } + console.log('After resolving: stage and commit changes in the worktree, then run:'); + console.log(` cw errand merge ${id}`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('abandon ') + .description('Stop agent, remove worktree and branch, keep DB record as abandoned') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.abandon.mutate({ id }); + console.log(`Errand ${id} abandoned`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + + errandCommand + .command('delete ') + .description('Stop agent, remove worktree, delete branch, and delete DB record') + .action(async (id: string) => { + try { + const client = createDefaultTrpcClient(); + await client.errand.delete.mutate({ id }); + console.log(`Errand ${id} deleted`); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }); + return program; } diff --git a/docs/cli-config.md b/docs/cli-config.md index 7def20e..a249d64 100644 --- a/docs/cli-config.md +++ b/docs/cli-config.md @@ -116,6 +116,19 @@ Uses **Commander.js** for command parsing. All three commands output JSON for programmatic agent consumption. +### Errand Sessions (`cw errand`) +| Command | Description | +|---------|-------------| +| `start --project [--base ]` | Start a new errand session (description ≤200 chars) | +| `list [--project ] [--status ]` | List errands; status: active\|pending_review\|conflict\|merged\|abandoned | +| `chat ` | Deliver a message to the running errand agent | +| `diff ` | Print unified git diff between base branch and errand branch | +| `complete ` | Mark errand as done and ready for review | +| `merge [--target ]` | Merge errand branch into target branch | +| `resolve ` | Print worktree path and conflicting files for manual resolution | +| `abandon ` | Stop agent, remove worktree and branch, keep DB record as abandoned | +| `delete ` | Stop agent, remove worktree, delete branch, and delete DB record | + ### Accounts (`cw account`) | Command | Description | |---------|-------------|