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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user