feat: Add all 9 cw errand CLI subcommands with tests
Wires errand command group into CLI with start, list, chat, diff, complete, merge, resolve, abandon, and delete subcommands. All commands call tRPC procedures via createDefaultTrpcClient(). The start command validates description length client-side (≤200 chars) before making any network calls. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
266
apps/server/cli/errand.test.ts
Normal file
266
apps/server/cli/errand.test.ts
Normal file
@@ -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 <id> 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 <id> 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 <branch> into <baseBranch>" 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 <id> 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 <id> 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);
|
||||
});
|
||||
});
|
||||
@@ -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>')
|
||||
.description('Start a new errand session')
|
||||
.requiredOption('--project <id>', 'Project ID')
|
||||
.option('--base <branch>', '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 <id>', 'Filter by project')
|
||||
.option('--status <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 <id> <message>')
|
||||
.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 <id>')
|
||||
.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 <id>')
|
||||
.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 <id>')
|
||||
.description('Merge errand branch into target branch')
|
||||
.option('--target <branch>', '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 <id>')
|
||||
.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 <id>')
|
||||
.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 <id>')
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <description> --project <id> [--base <branch>]` | Start a new errand session (description ≤200 chars) |
|
||||
| `list [--project <id>] [--status <status>]` | List errands; status: active\|pending_review\|conflict\|merged\|abandoned |
|
||||
| `chat <id> <message>` | Deliver a message to the running errand agent |
|
||||
| `diff <id>` | Print unified git diff between base branch and errand branch |
|
||||
| `complete <id>` | Mark errand as done and ready for review |
|
||||
| `merge <id> [--target <branch>]` | Merge errand branch into target branch |
|
||||
| `resolve <id>` | Print worktree path and conflicting files for manual resolution |
|
||||
| `abandon <id>` | Stop agent, remove worktree and branch, keep DB record as abandoned |
|
||||
| `delete <id>` | Stop agent, remove worktree, delete branch, and delete DB record |
|
||||
|
||||
### Accounts (`cw account`)
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
|
||||
Reference in New Issue
Block a user