Files
Codewalkers/apps/server/cli/index.ts
Lukas May e86a743c0b 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>
2026-03-06 16:26:15 +01:00

1948 lines
69 KiB
TypeScript

/**
* Codewalkers CLI
*
* Commander-based CLI with help system and version display.
* Supports server mode via --server flag.
*/
import { Command } from 'commander';
import { VERSION } from '../index.js';
import { CoordinationServer } from '../server/index.js';
import { GracefulShutdown } from '../server/shutdown.js';
import { createDefaultTrpcClient } from './trpc-client.js';
import { createContainer } from '../container.js';
import { findWorkspaceRoot, writeCwrc, defaultCwConfig } from '../config/index.js';
import { createModuleLogger } from '../logger/index.js';
/** Environment variable for custom port */
const CW_PORT_ENV = 'CW_PORT';
/**
* Starts the coordination server in foreground mode.
* Server runs until terminated via SIGTERM/SIGINT.
*/
async function startServer(port?: number, debug?: boolean): Promise<void> {
// Get port from option, env var, or default
const serverPort = port ??
(process.env[CW_PORT_ENV] ? parseInt(process.env[CW_PORT_ENV], 10) : undefined);
const log = createModuleLogger('server');
// Create full dependency graph
const container = await createContainer({ debug });
// Create and start server
const server = new CoordinationServer(
{ port: serverPort },
container.processManager,
container.logManager,
container.eventBus,
container.toContextDeps(),
);
try {
await server.start();
} catch (error) {
log.fatal({ err: error }, 'failed to start server');
console.error('Failed to start server:', (error as Error).message);
process.exit(1);
}
// Install graceful shutdown handlers
const shutdown = new GracefulShutdown(server, container.processManager, container.logManager, container.previewManager);
shutdown.install();
}
/**
* Creates and configures the CLI program.
*
* @param serverHandler - Optional handler to be called for server mode
* @returns Configured Commander program ready for parsing
*/
export function createCli(serverHandler?: (port?: number) => Promise<void>): Command {
const program = new Command();
program
.name('cw')
.description('Multi-agent workspace for orchestrating multiple Claude Code agents')
.version(VERSION, '-v, --version', 'Display version number');
// Server mode options (global flags)
program
.option('-s, --server', 'Start the coordination server')
.option('-p, --port <number>', 'Port for the server (default: 3847, env: CW_PORT)', parseInt)
.option('-d, --debug', 'Enable debug mode (archive agent workdirs before cleanup)');
// Handle the case where --server is provided without a command
// This makes --server work as a standalone action
program.hook('preAction', async (_thisCommand, _actionCommand) => {
const opts = program.opts();
if (opts.server && serverHandler) {
await serverHandler(opts.port);
process.exit(0);
}
});
// Status command - shows workspace status via tRPC
program
.command('status')
.description('Show workspace status')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const health = await client.health.query();
console.log('Coordination Server Status');
console.log('==========================');
console.log(`Status: ${health.status}`);
console.log(`Uptime: ${health.uptime}s`);
console.log(`Processes: ${health.processCount}`);
} catch {
console.log('Server not running or unreachable. Start with: cw --server');
}
});
// Init command - create .cwrc in current directory
program
.command('init')
.description('Initialize a cw workspace in the current directory')
.action(() => {
const cwd = process.cwd();
const existing = findWorkspaceRoot(cwd);
if (existing && existing === cwd) {
console.log(`Workspace already initialized at ${cwd}`);
return;
}
if (existing) {
console.log(`Parent workspace found at ${existing}`);
console.log(`Creating nested workspace at ${cwd}`);
}
writeCwrc(cwd, defaultCwConfig());
console.log(`Initialized cw workspace at ${cwd}`);
});
// ID generation command (stateless — no server, no tRPC)
program
.command('id')
.description('Generate a unique nanoid (works offline, no server needed)')
.option('-n, --count <count>', 'Number of IDs to generate', '1')
.action(async (options: { count: string }) => {
const { nanoid } = await import('nanoid');
const count = parseInt(options.count, 10) || 1;
for (let i = 0; i < count; i++) {
console.log(nanoid());
}
});
// Agent command group
const agentCommand = program
.command('agent')
.description('Manage agents');
// cw agent spawn --name <name> --task <taskId> <prompt>
agentCommand
.command('spawn <prompt>')
.description('Spawn a new agent to work on a task')
.option('--name <name>', 'Human-readable name for the agent (auto-generated if omitted)')
.requiredOption('--task <taskId>', 'Task ID to assign to agent')
.option('--cwd <path>', 'Working directory for agent')
.option('--provider <provider>', 'Agent provider (claude, codex, gemini, cursor, auggie, amp, opencode)')
.option('--initiative <id>', 'Initiative ID (creates worktrees for all linked projects)')
.action(async (prompt: string, options: { name?: string; task: string; cwd?: string; provider?: string; initiative?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnAgent.mutate({
name: options.name,
taskId: options.task,
prompt,
cwd: options.cwd,
provider: options.provider,
initiativeId: options.initiative,
});
console.log(`Agent '${agent.name}' spawned`);
console.log(` ID: ${agent.id}`);
console.log(` Task: ${agent.taskId}`);
console.log(` Status: ${agent.status}`);
console.log(` Worktree: ${agent.worktreeId}`);
} catch (error) {
console.error('Failed to spawn agent:', (error as Error).message);
process.exit(1);
}
});
// cw agent stop <name>
agentCommand
.command('stop <name>')
.description('Stop a running agent by name')
.action(async (name: string) => {
try {
const client = createDefaultTrpcClient();
const result = await client.stopAgent.mutate({ name });
console.log(`Agent '${result.name}' stopped`);
} catch (error) {
console.error('Failed to stop agent:', (error as Error).message);
process.exit(1);
}
});
// cw agent delete <name>
agentCommand
.command('delete <name>')
.description('Delete an agent and clean up its workdir, branches, and logs')
.action(async (name: string) => {
try {
const client = createDefaultTrpcClient();
const result = await client.deleteAgent.mutate({ name });
console.log(`Agent '${result.name}' deleted`);
} catch (error) {
console.error('Failed to delete agent:', (error as Error).message);
process.exit(1);
}
});
// cw agent list
agentCommand
.command('list')
.description('List all agents')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const agents = await client.listAgents.query();
if (agents.length === 0) {
console.log('No agents found');
return;
}
console.log('Agents:');
for (const agent of agents) {
const status = agent.status === 'waiting_for_input' ? 'WAITING' : agent.status.toUpperCase();
console.log(` ${agent.name} [${status}] - ${agent.taskId}`);
}
} catch (error) {
console.error('Failed to list agents:', (error as Error).message);
process.exit(1);
}
});
// cw agent get <name>
agentCommand
.command('get <name>')
.description('Get agent details by name')
.action(async (name: string) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.getAgent.query({ name });
if (!agent) {
console.log(`Agent '${name}' not found`);
return;
}
console.log(`Agent: ${agent.name}`);
console.log(` ID: ${agent.id}`);
console.log(` Task: ${agent.taskId}`);
console.log(` Session: ${agent.sessionId ?? '(none)'}`);
console.log(` Worktree: ${agent.worktreeId}`);
console.log(` Status: ${agent.status}`);
console.log(` Created: ${agent.createdAt}`);
console.log(` Updated: ${agent.updatedAt}`);
} catch (error) {
console.error('Failed to get agent:', (error as Error).message);
process.exit(1);
}
});
// cw agent resume <name> <answers>
// Accepts either JSON object {"q1": "answer1", "q2": "answer2"} or single answer
agentCommand
.command('resume <name> <answers>')
.description('Resume an agent with answers (JSON object or single answer string)')
.action(async (name: string, answersInput: string) => {
try {
const client = createDefaultTrpcClient();
// Try parsing as JSON first, fallback to single answer format
let answers: Record<string, string>;
try {
const parsed = JSON.parse(answersInput);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
answers = parsed;
} else {
// Not a valid object, treat as single answer
answers = { q1: answersInput };
}
} catch {
// Not valid JSON, treat as single answer with default question ID
answers = { q1: answersInput };
}
const result = await client.resumeAgent.mutate({ name, answers });
console.log(`Agent '${result.name}' resumed`);
} catch (error) {
console.error('Failed to resume agent:', (error as Error).message);
process.exit(1);
}
});
// cw agent result <name>
agentCommand
.command('result <name>')
.description('Get agent execution result')
.action(async (name: string) => {
try {
const client = createDefaultTrpcClient();
const result = await client.getAgentResult.query({ name });
if (!result) {
console.log('No result available (agent may still be running)');
return;
}
console.log(`Result: ${result.success ? 'SUCCESS' : 'FAILED'}`);
console.log(` Message: ${result.message}`);
if (result.filesModified?.length) {
console.log(` Files modified: ${result.filesModified.join(', ')}`);
}
} catch (error) {
console.error('Failed to get result:', (error as Error).message);
process.exit(1);
}
});
// Task command group
const taskCommand = program
.command('task')
.description('Manage tasks');
// cw task list --parent <parentTaskId> | --phase <phaseId> | --initiative <initiativeId>
taskCommand
.command('list')
.description('List tasks (by parent task, phase, or initiative)')
.option('--parent <parentTaskId>', 'Parent task ID (for child tasks)')
.option('--phase <phaseId>', 'Phase ID')
.option('--initiative <initiativeId>', 'Initiative ID')
.action(async (options: { parent?: string; phase?: string; initiative?: string }) => {
try {
const client = createDefaultTrpcClient();
let tasks;
if (options.parent) {
tasks = await client.listTasks.query({ parentTaskId: options.parent });
} else if (options.phase) {
tasks = await client.listPhaseTasks.query({ phaseId: options.phase });
} else if (options.initiative) {
tasks = await client.listInitiativeTasks.query({ initiativeId: options.initiative });
} else {
console.error('One of --parent, --phase, or --initiative is required');
process.exit(1);
}
if (tasks.length === 0) {
console.log('No tasks found');
return;
}
// Count by status
const pending = tasks.filter(t => t.status === 'pending').length;
const inProgress = tasks.filter(t => t.status === 'in_progress').length;
const completed = tasks.filter(t => t.status === 'completed').length;
const blocked = tasks.filter(t => t.status === 'blocked').length;
console.log('Tasks:');
console.log('');
for (const task of tasks) {
const statusLabel = task.status === 'in_progress' ? 'IN_PROGRESS' : task.status.toUpperCase();
const priorityLabel = task.priority === 'high' ? '[HIGH]' : task.priority === 'low' ? '[low]' : '';
console.log(` ${task.order}. ${task.name} [${statusLabel}] ${task.type} ${priorityLabel}`);
}
console.log('');
console.log(`Summary: ${pending} pending, ${inProgress} in progress, ${completed} completed, ${blocked} blocked`);
} catch (error) {
console.error('Failed to list tasks:', (error as Error).message);
process.exit(1);
}
});
// cw task get <taskId>
taskCommand
.command('get <taskId>')
.description('Get task details by ID')
.action(async (taskId: string) => {
try {
const client = createDefaultTrpcClient();
const task = await client.getTask.query({ id: taskId });
console.log(`Task: ${task.name}`);
console.log(` ID: ${task.id}`);
console.log(` Phase: ${task.phaseId ?? '(none)'}`);
console.log(` Initiative: ${task.initiativeId ?? '(none)'}`);
console.log(` Parent Task: ${task.parentTaskId ?? '(none)'}`);
console.log(` Description: ${task.description ?? '(none)'}`);
console.log(` Category: ${task.category}`);
console.log(` Type: ${task.type}`);
console.log(` Priority: ${task.priority}`);
console.log(` Status: ${task.status}`);
console.log(` Order: ${task.order}`);
console.log(` Created: ${task.createdAt}`);
console.log(` Updated: ${task.updatedAt}`);
} catch (error) {
console.error('Failed to get task:', (error as Error).message);
process.exit(1);
}
});
// cw task status <taskId> <status>
taskCommand
.command('status <taskId> <status>')
.description('Update task status (pending, in_progress, completed, blocked)')
.action(async (taskId: string, status: string) => {
const validStatuses = ['pending', 'in_progress', 'completed', 'blocked'];
if (!validStatuses.includes(status)) {
console.error(`Invalid status: ${status}`);
console.error(`Valid statuses: ${validStatuses.join(', ')}`);
process.exit(1);
}
try {
const client = createDefaultTrpcClient();
const task = await client.updateTaskStatus.mutate({
id: taskId,
status: status as 'pending' | 'in_progress' | 'completed' | 'blocked',
});
console.log(`Task '${task.name}' status updated to: ${task.status}`);
} catch (error) {
console.error('Failed to update task status:', (error as Error).message);
process.exit(1);
}
});
// Message command group
const messageCommand = program
.command('message')
.description('View agent messages and questions');
// cw message list [--agent <agentId>] [--status <status>]
messageCommand
.command('list')
.description('List messages from agents')
.option('--agent <agentId>', 'Filter by agent ID')
.option('--status <status>', 'Filter by status (pending, read, responded)')
.action(async (options: { agent?: string; status?: string }) => {
// Validate status if provided
if (options.status && !['pending', 'read', 'responded'].includes(options.status)) {
console.error(`Invalid status: ${options.status}`);
console.error('Valid statuses: pending, read, responded');
process.exit(1);
}
try {
const client = createDefaultTrpcClient();
const messages = await client.listMessages.query({
agentId: options.agent,
status: options.status as 'pending' | 'read' | 'responded' | undefined,
});
if (messages.length === 0) {
console.log('No messages found');
return;
}
// Count pending
const pendingCount = messages.filter(m => m.status === 'pending').length;
if (pendingCount > 0) {
console.log(`(${pendingCount} pending)\n`);
}
console.log('Messages:');
console.log('');
for (const msg of messages) {
const idShort = msg.id.slice(0, 8);
const agentId = msg.senderId?.slice(0, 8) ?? 'user';
const content = msg.content.length > 50 ? msg.content.slice(0, 47) + '...' : msg.content;
const statusLabel = msg.status.toUpperCase();
const createdAt = new Date(msg.createdAt).toLocaleString();
console.log(` ${idShort} ${agentId} ${msg.type} [${statusLabel}] ${createdAt}`);
console.log(` ${content}`);
}
} catch (error) {
console.error('Failed to list messages:', (error as Error).message);
process.exit(1);
}
});
// cw message read <messageId>
messageCommand
.command('read <messageId>')
.description('Read full message content')
.action(async (messageId: string) => {
try {
const client = createDefaultTrpcClient();
const message = await client.getMessage.query({ id: messageId });
console.log(`Message: ${message.id}`);
console.log(` From: ${message.senderType} (${message.senderId ?? 'user'})`);
console.log(` To: ${message.recipientType} (${message.recipientId ?? 'user'})`);
console.log(` Type: ${message.type}`);
console.log(` Status: ${message.status}`);
console.log(` Created: ${new Date(message.createdAt).toLocaleString()}`);
console.log('');
console.log('Content:');
console.log(message.content);
if (message.status === 'pending' && message.requiresResponse) {
console.log('');
console.log('---');
console.log('This message requires a response.');
console.log(`Use: cw message respond ${message.id} "<your response>"`);
}
} catch (error) {
console.error('Failed to read message:', (error as Error).message);
process.exit(1);
}
});
// cw message respond <messageId> <response>
messageCommand
.command('respond <messageId> <response>')
.description('Respond to a message')
.action(async (messageId: string, response: string) => {
try {
const client = createDefaultTrpcClient();
const responseMessage = await client.respondToMessage.mutate({
id: messageId,
response,
});
console.log(`Response recorded (${responseMessage.id.slice(0, 8)})`);
console.log('');
console.log('If the agent is waiting, you may want to resume it:');
console.log(' cw agent list (to see waiting agents)');
console.log(' cw agent resume <name> "<response>"');
} catch (error) {
console.error('Failed to respond to message:', (error as Error).message);
process.exit(1);
}
});
// Dispatch command group
const dispatchCommand = program
.command('dispatch')
.description('Control task dispatch queue');
// cw dispatch queue <taskId>
dispatchCommand
.command('queue <taskId>')
.description('Queue a task for dispatch')
.action(async (taskId: string) => {
try {
const client = createDefaultTrpcClient();
await client.queueTask.mutate({ taskId });
console.log(`Task '${taskId}' queued for dispatch`);
} catch (error) {
console.error('Failed to queue task:', (error as Error).message);
process.exit(1);
}
});
// cw dispatch next
dispatchCommand
.command('next')
.description('Dispatch next available task to an agent')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const result = await client.dispatchNext.mutate();
if (result.success) {
console.log('Task dispatched successfully');
console.log(` Task: ${result.taskId}`);
console.log(` Agent: ${result.agentId}`);
} else {
console.log('Dispatch failed');
console.log(` Task: ${result.taskId || '(none)'}`);
console.log(` Reason: ${result.reason}`);
}
} catch (error) {
console.error('Failed to dispatch:', (error as Error).message);
process.exit(1);
}
});
// cw dispatch status
dispatchCommand
.command('status')
.description('Show dispatch queue status')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const state = await client.getQueueState.query();
console.log('Dispatch Queue Status');
console.log('=====================');
console.log(`Queued: ${state.queued.length}`);
console.log(`Ready: ${state.ready.length}`);
console.log(`Blocked: ${state.blocked.length}`);
if (state.ready.length > 0) {
console.log('');
console.log('Ready tasks:');
for (const task of state.ready) {
const priority = task.priority === 'high' ? '[HIGH]' : task.priority === 'low' ? '[low]' : '';
console.log(` ${task.taskId} ${priority}`);
}
}
if (state.blocked.length > 0) {
console.log('');
console.log('Blocked tasks:');
for (const bt of state.blocked) {
console.log(` ${bt.taskId}: ${bt.reason}`);
}
}
} catch (error) {
console.error('Failed to get queue status:', (error as Error).message);
process.exit(1);
}
});
// cw dispatch complete <taskId>
dispatchCommand
.command('complete <taskId>')
.description('Mark a task as complete')
.action(async (taskId: string) => {
try {
const client = createDefaultTrpcClient();
await client.completeTask.mutate({ taskId });
console.log(`Task '${taskId}' marked as complete`);
} catch (error) {
console.error('Failed to complete task:', (error as Error).message);
process.exit(1);
}
});
// Merge command group
const mergeCommand = program
.command('merge')
.description('Manage merge queue');
// cw merge queue <taskId>
mergeCommand
.command('queue <taskId>')
.description('Queue a completed task for merge')
.action(async (taskId: string) => {
try {
const client = createDefaultTrpcClient();
await client.queueMerge.mutate({ taskId });
console.log(`Task '${taskId}' queued for merge`);
} catch (error) {
console.error('Failed to queue for merge:', (error as Error).message);
process.exit(1);
}
});
// cw merge status
mergeCommand
.command('status')
.description('Show merge queue status')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const state = await client.getMergeQueueStatus.query();
console.log('Merge Queue Status');
console.log('==================');
console.log(`Queued: ${state.queued.length}`);
console.log(`In Progress: ${state.inProgress.length}`);
console.log(`Merged: ${state.merged.length}`);
console.log(`Conflicted: ${state.conflicted.length}`);
if (state.conflicted.length > 0) {
console.log('');
console.log('Conflicts:');
for (const c of state.conflicted) {
console.log(` ${c.taskId}: ${c.conflicts.join(', ')}`);
}
}
} catch (error) {
console.error('Failed to get merge queue status:', (error as Error).message);
process.exit(1);
}
});
// cw merge next
mergeCommand
.command('next')
.description('Show next task ready to merge')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const next = await client.getNextMergeable.query();
if (next) {
console.log(`Next mergeable: ${next.taskId} (priority: ${next.priority})`);
} else {
console.log('No tasks ready to merge');
}
} catch (error) {
console.error('Failed to get next mergeable:', (error as Error).message);
process.exit(1);
}
});
// Coordinate command - process all ready merges
program
.command('coordinate')
.description('Process all ready merges in dependency order')
.option('-t, --target <branch>', 'Target branch for merges', 'main')
.action(async (options: { target: string }) => {
try {
const client = createDefaultTrpcClient();
console.log(`Processing merges to ${options.target}...`);
const { results } = await client.processMerges.mutate({
targetBranch: options.target,
});
const succeeded = results.filter(r => r.success).length;
const conflicted = results.filter(r => !r.success).length;
console.log('');
console.log('Results:');
console.log(` Merged: ${succeeded}`);
console.log(` Conflicts: ${conflicted}`);
if (conflicted > 0) {
console.log('');
console.log('Conflicted tasks (bounce-back tasks created):');
for (const r of results.filter(r => !r.success)) {
console.log(` ${r.taskId}: ${r.conflicts?.join(', ')}`);
}
}
} catch (error) {
console.error('Failed to process merges:', (error as Error).message);
process.exit(1);
}
});
// Initiative command group
const initiativeCommand = program
.command('initiative')
.description('Manage initiatives');
// cw initiative create <name>
initiativeCommand
.command('create <name>')
.description('Create a new initiative')
.option('--project <ids...>', 'Project IDs to associate')
.action(async (name: string, options: { project?: string[] }) => {
try {
const client = createDefaultTrpcClient();
const initiative = await client.createInitiative.mutate({
name,
projectIds: options.project,
});
console.log(`Created initiative: ${initiative.id}`);
console.log(` Name: ${initiative.name}`);
if (options.project && options.project.length > 0) {
console.log(` Projects: ${options.project.length} associated`);
}
} catch (error) {
console.error('Failed to create initiative:', (error as Error).message);
process.exit(1);
}
});
// cw initiative list
initiativeCommand
.command('list')
.description('List all initiatives')
.option('-s, --status <status>', 'Filter by status (active, completed, archived)')
.action(async (options: { status?: string }) => {
// Validate status if provided
const validStatuses = ['active', 'completed', 'archived'];
if (options.status && !validStatuses.includes(options.status)) {
console.error(`Invalid status: ${options.status}`);
console.error(`Valid statuses: ${validStatuses.join(', ')}`);
process.exit(1);
}
try {
const client = createDefaultTrpcClient();
const initiatives = await client.listInitiatives.query(
options.status ? { status: options.status as 'active' | 'completed' | 'archived' } : undefined
);
if (initiatives.length === 0) {
console.log('No initiatives found');
return;
}
for (const init of initiatives) {
console.log(`${init.id} ${init.status.padEnd(10)} ${init.name}`);
}
} catch (error) {
console.error('Failed to list initiatives:', (error as Error).message);
process.exit(1);
}
});
// cw initiative get <id>
initiativeCommand
.command('get <id>')
.description('Get initiative details')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
const initiative = await client.getInitiative.query({ id });
console.log(`ID: ${initiative.id}`);
console.log(`Name: ${initiative.name}`);
console.log(`Status: ${initiative.status}`);
console.log(`Created: ${new Date(initiative.createdAt).toISOString()}`);
} catch (error) {
console.error('Failed to get initiative:', (error as Error).message);
process.exit(1);
}
});
// cw initiative phases <initiative-id>
initiativeCommand
.command('phases <initiativeId>')
.description('List phases for an initiative')
.action(async (initiativeId: string) => {
try {
const client = createDefaultTrpcClient();
const phases = await client.listPhases.query({ initiativeId });
if (phases.length === 0) {
console.log('No phases found');
return;
}
for (const phase of phases) {
const idx = phases.indexOf(phase);
console.log(`${(idx + 1).toString().padStart(2)}. ${phase.name} [${phase.status}]`);
if (phase.content) {
console.log(` ${phase.content}`);
}
}
} catch (error) {
console.error('Failed to list phases:', (error as Error).message);
process.exit(1);
}
});
// Architect command group
const architectCommand = program
.command('architect')
.description('Architect agent workflow');
// cw architect discuss <initiative-id>
architectCommand
.command('discuss <initiativeId>')
.description('Start discussion phase for an initiative')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-c, --context <context>', 'Initial context')
.action(async (initiativeId: string, options: { name?: string; context?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectDiscuss.mutate({
name: options.name,
initiativeId,
context: options.context,
});
console.log(`Started architect agent in discuss mode`);
console.log(` Agent: ${agent.name} (${agent.id})`);
console.log(` Mode: ${agent.mode}`);
console.log(` Initiative: ${initiativeId}`);
} catch (error) {
console.error('Failed to start discuss:', (error as Error).message);
process.exit(1);
}
});
// cw architect plan <initiative-id>
architectCommand
.command('plan <initiativeId>')
.description('Plan phases for an initiative')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-s, --summary <summary>', 'Context summary from discuss phase')
.action(async (initiativeId: string, options: { name?: string; summary?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectPlan.mutate({
name: options.name,
initiativeId,
contextSummary: options.summary,
});
console.log(`Started architect agent in plan mode`);
console.log(` Agent: ${agent.name} (${agent.id})`);
console.log(` Mode: ${agent.mode}`);
console.log(` Initiative: ${initiativeId}`);
} catch (error) {
console.error('Failed to start plan:', (error as Error).message);
process.exit(1);
}
});
// cw architect detail <phase-id>
architectCommand
.command('detail <phaseId>')
.description('Detail a phase into tasks')
.option('--name <name>', 'Agent name (auto-generated if omitted)')
.option('-t, --task-name <taskName>', 'Name for the detail task')
.option('-c, --context <context>', 'Additional context')
.action(async (phaseId: string, options: { name?: string; taskName?: string; context?: string }) => {
try {
const client = createDefaultTrpcClient();
const agent = await client.spawnArchitectDetail.mutate({
name: options.name,
phaseId,
taskName: options.taskName,
context: options.context,
});
console.log(`Started architect agent in detail mode`);
console.log(` Agent: ${agent.name} (${agent.id})`);
console.log(` Mode: ${agent.mode}`);
console.log(` Phase: ${phaseId}`);
} catch (error) {
console.error('Failed to start detail:', (error as Error).message);
process.exit(1);
}
});
// Phase command group
const phaseCommand = program
.command('phase')
.description('Phase dependency and dispatch management');
// cw phase add-dependency --phase <id> --depends-on <id>
phaseCommand
.command('add-dependency')
.description('Add a dependency between phases')
.requiredOption('--phase <id>', 'Phase that depends on another')
.requiredOption('--depends-on <id>', 'Phase that must complete first')
.action(async (options: { phase: string; dependsOn: string }) => {
try {
const client = createDefaultTrpcClient();
await client.createPhaseDependency.mutate({
phaseId: options.phase,
dependsOnPhaseId: options.dependsOn,
});
console.log(`Added dependency: phase ${options.phase} depends on ${options.dependsOn}`);
} catch (error) {
console.error('Failed to add dependency:', (error as Error).message);
process.exit(1);
}
});
// cw phase dependencies <phaseId>
phaseCommand
.command('dependencies <phaseId>')
.description('List dependencies for a phase')
.action(async (phaseId: string) => {
try {
const client = createDefaultTrpcClient();
const result = await client.getPhaseDependencies.query({ phaseId });
if (result.dependencies.length === 0) {
console.log('No dependencies');
return;
}
console.log('Dependencies:');
for (const dep of result.dependencies) {
console.log(` ${dep}`);
}
} catch (error) {
console.error('Failed to get dependencies:', (error as Error).message);
process.exit(1);
}
});
// cw phase queue <phaseId>
phaseCommand
.command('queue <phaseId>')
.description('Queue a phase for execution')
.action(async (phaseId: string) => {
try {
const client = createDefaultTrpcClient();
await client.queuePhase.mutate({ phaseId });
console.log(`Phase ${phaseId} queued for execution`);
} catch (error) {
console.error('Failed to queue phase:', (error as Error).message);
process.exit(1);
}
});
// cw phase dispatch
phaseCommand
.command('dispatch')
.description('Dispatch next available phase')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const result = await client.dispatchNextPhase.mutate();
if (result.success) {
console.log('Phase dispatched successfully');
console.log(` Phase: ${result.phaseId}`);
} else {
console.log('Dispatch failed');
console.log(` Phase: ${result.phaseId || '(none)'}`);
console.log(` Reason: ${result.reason}`);
}
} catch (error) {
console.error('Failed to dispatch phase:', (error as Error).message);
process.exit(1);
}
});
// cw phase queue-status
phaseCommand
.command('queue-status')
.description('Show phase queue status')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const state = await client.getPhaseQueueState.query();
console.log('Phase Queue Status');
console.log('==================');
console.log(`Queued: ${state.queued.length}`);
console.log(`Ready: ${state.ready.length}`);
console.log(`Blocked: ${state.blocked.length}`);
if (state.ready.length > 0) {
console.log('');
console.log('Ready phases:');
for (const phase of state.ready) {
console.log(` ${phase.phaseId}`);
}
}
if (state.blocked.length > 0) {
console.log('');
console.log('Blocked phases:');
for (const bp of state.blocked) {
console.log(` ${bp.phaseId}: ${bp.reason}`);
}
}
} catch (error) {
console.error('Failed to get queue status:', (error as Error).message);
process.exit(1);
}
});
// Project command group
const projectCommand = program
.command('project')
.description('Manage registered projects');
// cw project register --name <name> --url <url>
projectCommand
.command('register')
.description('Register a git repository as a project')
.requiredOption('--name <name>', 'Project name')
.requiredOption('--url <url>', 'Git repository URL')
.action(async (options: { name: string; url: string }) => {
try {
const client = createDefaultTrpcClient();
const project = await client.registerProject.mutate({
name: options.name,
url: options.url,
});
console.log(`Registered project: ${project.id}`);
console.log(` Name: ${project.name}`);
console.log(` URL: ${project.url}`);
if (project.hasPreviewConfig) {
console.log(' Preview: .cw-preview.yml detected — preview deployments ready');
} else {
console.log(' Preview: No .cw-preview.yml found. Run `cw preview setup` for instructions.');
}
} catch (error) {
console.error('Failed to register project:', (error as Error).message);
process.exit(1);
}
});
// cw project list
projectCommand
.command('list')
.description('List all registered projects')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const projects = await client.listProjects.query();
if (projects.length === 0) {
console.log('No projects registered');
return;
}
for (const p of projects) {
console.log(`${p.id} ${p.name} ${p.url}`);
}
} catch (error) {
console.error('Failed to list projects:', (error as Error).message);
process.exit(1);
}
});
// cw project delete <id>
projectCommand
.command('delete <id>')
.description('Delete a registered project')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.deleteProject.mutate({ id });
console.log(`Deleted project: ${id}`);
} catch (error) {
console.error('Failed to delete project:', (error as Error).message);
process.exit(1);
}
});
// cw project sync [name] --all
projectCommand
.command('sync [name]')
.description('Sync project clone(s) from remote')
.option('--all', 'Sync all projects')
.action(async (name: string | undefined, options: { all?: boolean }) => {
try {
const client = createDefaultTrpcClient();
if (options.all) {
const results = await client.syncAllProjects.mutate();
for (const r of results) {
const status = r.success ? 'ok' : `FAILED: ${r.error}`;
console.log(`${r.projectName}: ${status}`);
}
} else if (name) {
const projects = await client.listProjects.query();
const project = projects.find((p) => p.name === name || p.id === name);
if (!project) {
console.error(`Project not found: ${name}`);
process.exit(1);
}
const result = await client.syncProject.mutate({ id: project.id });
if (result.success) {
console.log(`Synced ${result.projectName}: fetched=${result.fetched}, fast-forwarded=${result.fastForwarded}`);
} else {
console.error(`Sync failed: ${result.error}`);
process.exit(1);
}
} else {
console.error('Specify a project name or use --all');
process.exit(1);
}
} catch (error) {
console.error('Failed to sync:', (error as Error).message);
process.exit(1);
}
});
// cw project status [name]
projectCommand
.command('status [name]')
.description('Show sync status for a project')
.action(async (name: string | undefined) => {
try {
const client = createDefaultTrpcClient();
const projects = await client.listProjects.query();
const targets = name
? projects.filter((p) => p.name === name || p.id === name)
: projects;
if (targets.length === 0) {
console.log(name ? `Project not found: ${name}` : 'No projects registered');
return;
}
for (const project of targets) {
const status = await client.getProjectSyncStatus.query({ id: project.id });
const fetchedStr = status.lastFetchedAt
? new Date(status.lastFetchedAt).toLocaleString()
: 'never';
console.log(`${project.name}:`);
console.log(` Last fetched: ${fetchedStr}`);
console.log(` Ahead: ${status.ahead} Behind: ${status.behind}`);
}
} catch (error) {
console.error('Failed to get status:', (error as Error).message);
process.exit(1);
}
});
// Account command group
const accountCommand = program
.command('account')
.description('Manage provider accounts for agent spawning');
// cw account add
accountCommand
.command('add')
.description('Extract current Claude login and register as an account')
.option('--provider <provider>', 'Provider name', 'claude')
.option('--email <email>', 'Email (for manual registration without auto-extract)')
.option('--token <token>', 'Setup token from `claude setup-token` (requires --email)')
.action(async (options: { provider: string; email?: string; token?: string }) => {
try {
const client = createDefaultTrpcClient();
if (options.token) {
// Token-based registration
if (!options.email) {
console.error('Error: --email is required when using --token');
process.exit(1);
}
const credentials = JSON.stringify({
claudeAiOauth: { accessToken: options.token },
});
const configJson = JSON.stringify({ hasCompletedOnboarding: true });
const existing = await client.listAccounts.query();
const alreadyRegistered = existing.find((a: any) => a.email === options.email);
if (alreadyRegistered) {
await client.updateAccountAuth.mutate({
id: alreadyRegistered.id,
configJson,
credentials,
});
console.log(`Updated credentials for account: ${alreadyRegistered.id}`);
console.log(` Email: ${options.email}`);
console.log(` Token updated (setup token)`);
return;
}
const account = await client.addAccount.mutate({
email: options.email,
provider: options.provider,
configJson,
credentials,
});
console.log(`Registered account: ${account.id}`);
console.log(` Email: ${account.email}`);
console.log(` Provider: ${account.provider}`);
console.log(` Auth: setup token`);
} else if (options.email) {
// Manual registration — guard against duplicates
const existing = await client.listAccounts.query();
const alreadyRegistered = existing.find((a: any) => a.email === options.email);
if (alreadyRegistered) {
console.log(`Account '${options.email}' already registered (${alreadyRegistered.id})`);
return;
}
const account = await client.addAccount.mutate({
email: options.email,
provider: options.provider,
});
console.log(`Registered account: ${account.id}`);
console.log(` Email: ${account.email}`);
console.log(` Provider: ${account.provider}`);
} else {
// Auto-extract from current Claude login
const { extractCurrentClaudeAccount } = await import('../agent/accounts/index.js');
const extracted = await extractCurrentClaudeAccount();
// Check if already registered
const existing = await client.listAccounts.query();
const alreadyRegistered = existing.find((a: any) => a.email === extracted.email);
if (alreadyRegistered) {
// Compare refresh tokens to detect staleness
let credentialsChanged = true;
try {
const dbCreds = alreadyRegistered.credentials ? JSON.parse(alreadyRegistered.credentials) : null;
const sourceCreds = JSON.parse(extracted.credentials);
const dbRefreshToken = dbCreds?.claudeAiOauth?.refreshToken;
const sourceRefreshToken = sourceCreds?.claudeAiOauth?.refreshToken;
credentialsChanged = dbRefreshToken !== sourceRefreshToken;
} catch {
// Parse error — assume changed, update to be safe
}
// Upsert: always update to be safe
await client.updateAccountAuth.mutate({
id: alreadyRegistered.id,
configJson: JSON.stringify(extracted.configJson),
credentials: extracted.credentials,
});
if (credentialsChanged) {
console.log(`Updated credentials for account: ${alreadyRegistered.id}`);
console.log(` Email: ${extracted.email}`);
console.log(` Refresh token changed (source had fresher credentials)`);
} else {
console.log(`Credentials current for account: ${alreadyRegistered.id}`);
console.log(` Email: ${extracted.email}`);
console.log(` Refresh token unchanged`);
}
return;
}
// Create DB record with credentials stored in DB
const account = await client.addAccount.mutate({
email: extracted.email,
provider: options.provider,
configJson: JSON.stringify(extracted.configJson),
credentials: extracted.credentials,
});
console.log(`Registered account: ${account.id}`);
console.log(` Email: ${account.email}`);
console.log(` Provider: ${account.provider}`);
}
} catch (error) {
console.error('Failed to add account:', (error as Error).message);
process.exit(1);
}
});
// cw account list
accountCommand
.command('list')
.description('List all registered accounts')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const accounts = await client.listAccounts.query();
if (accounts.length === 0) {
console.log('No accounts registered');
return;
}
for (const acct of accounts) {
const status = acct.isExhausted ? 'EXHAUSTED' : 'AVAILABLE';
const until = acct.exhaustedUntil ? ` (until ${new Date(acct.exhaustedUntil).toLocaleString()})` : '';
console.log(`${acct.id} ${acct.email} ${acct.provider} [${status}${until}]`);
}
} catch (error) {
console.error('Failed to list accounts:', (error as Error).message);
process.exit(1);
}
});
// cw account remove <id>
accountCommand
.command('remove <id>')
.description('Remove an account')
.action(async (id: string) => {
try {
const client = createDefaultTrpcClient();
await client.removeAccount.mutate({ id });
console.log(`Removed account: ${id}`);
} catch (error) {
console.error('Failed to remove account:', (error as Error).message);
process.exit(1);
}
});
// cw account refresh
accountCommand
.command('refresh')
.description('Clear expired exhaustion flags')
.action(async () => {
try {
const client = createDefaultTrpcClient();
const result = await client.refreshAccounts.mutate();
console.log(`Cleared ${result.cleared} expired exhaustions`);
} catch (error) {
console.error('Failed to refresh accounts:', (error as Error).message);
process.exit(1);
}
});
// cw account extract
accountCommand
.command('extract')
.description('Extract current Claude credentials for use with the UI (does not require server)')
.option('--email <email>', 'Verify extracted account matches this email')
.action(async (options: { email?: string }) => {
try {
const { extractCurrentClaudeAccount } = await import('../agent/accounts/index.js');
const extracted = await extractCurrentClaudeAccount();
if (options.email && extracted.email !== options.email) {
console.error(`Account '${options.email}' not found (active account is '${extracted.email}')`);
process.exit(1);
return;
}
const output = {
email: extracted.email,
configJson: JSON.stringify(extracted.configJson),
credentials: extracted.credentials,
};
console.log(JSON.stringify(output, null, 2));
} catch (error) {
console.error('Failed to extract account:', (error as Error).message);
process.exit(1);
}
});
// Preview command group
const previewCommand = program
.command('preview')
.description('Manage Docker-based preview deployments');
// cw preview start --initiative <id> --project <id> --branch <branch> [--phase <id>]
// cw preview start --agent <id> (agent-simplified: server resolves everything)
previewCommand
.command('start')
.description('Start a preview deployment')
.option('--initiative <id>', 'Initiative ID')
.option('--project <id>', 'Project ID')
.option('--branch <branch>', 'Branch to deploy')
.option('--phase <id>', 'Phase ID')
.option('--agent <id>', 'Agent ID (server resolves initiative/project/branch)')
.action(async (options: { initiative?: string; project?: string; branch?: string; phase?: string; agent?: string }) => {
try {
const client = createDefaultTrpcClient();
console.log('Starting preview deployment...');
let preview;
if (options.agent) {
preview = await client.startPreviewForAgent.mutate({ agentId: options.agent });
} else {
if (!options.initiative || !options.project || !options.branch) {
console.error('Either --agent or all of --initiative, --project, --branch are required');
process.exit(1);
}
preview = await client.startPreview.mutate({
initiativeId: options.initiative,
projectId: options.project,
branch: options.branch,
phaseId: options.phase,
});
}
console.log(`Preview started: ${preview.id}`);
console.log(` URL: ${preview.url}`);
console.log(` Branch: ${preview.branch}`);
console.log(` Mode: ${preview.mode}`);
console.log(` Status: ${preview.status}`);
console.log(` Services: ${preview.services.map(s => `${s.name} (${s.state})`).join(', ')}`);
} catch (error) {
console.error('Failed to start preview:', (error as Error).message);
process.exit(1);
}
});
// cw preview stop <previewId>
// cw preview stop --agent <id>
previewCommand
.command('stop [previewId]')
.description('Stop a preview deployment')
.option('--agent <id>', 'Stop preview by agent ID')
.action(async (previewId: string | undefined, options: { agent?: string }) => {
try {
const client = createDefaultTrpcClient();
if (options.agent) {
await client.stopPreviewByAgent.mutate({ agentId: options.agent });
console.log(`Previews for agent '${options.agent}' stopped`);
} else if (previewId) {
await client.stopPreview.mutate({ previewId });
console.log(`Preview '${previewId}' stopped`);
} else {
console.error('Either <previewId> or --agent <id> is required');
process.exit(1);
}
} catch (error) {
console.error('Failed to stop preview:', (error as Error).message);
process.exit(1);
}
});
// cw preview list [--initiative <id>]
previewCommand
.command('list')
.description('List active preview deployments')
.option('--initiative <id>', 'Filter by initiative ID')
.action(async (options: { initiative?: string }) => {
try {
const client = createDefaultTrpcClient();
const previews = await client.listPreviews.query(
options.initiative ? { initiativeId: options.initiative } : undefined,
);
if (previews.length === 0) {
console.log('No active previews');
return;
}
for (const p of previews) {
console.log(`${p.id} ${p.url} ${p.branch} ${p.mode} [${p.status.toUpperCase()}]`);
}
} catch (error) {
console.error('Failed to list previews:', (error as Error).message);
process.exit(1);
}
});
// cw preview status <previewId>
previewCommand
.command('status <previewId>')
.description('Get preview deployment status')
.action(async (previewId: string) => {
try {
const client = createDefaultTrpcClient();
const preview = await client.getPreviewStatus.query({ previewId });
if (!preview) {
console.log(`Preview '${previewId}' not found`);
return;
}
console.log(`Preview: ${preview.id}`);
console.log(` URL: ${preview.url}`);
console.log(` Branch: ${preview.branch}`);
console.log(` Mode: ${preview.mode}`);
console.log(` Status: ${preview.status}`);
console.log(` Initiative: ${preview.initiativeId}`);
console.log(` Project: ${preview.projectId}`);
if (preview.services.length > 0) {
console.log(' Services:');
for (const svc of preview.services) {
console.log(` ${svc.name}: ${svc.state} (health: ${svc.health})`);
}
}
} catch (error) {
console.error('Failed to get preview status:', (error as Error).message);
process.exit(1);
}
});
// cw preview setup [--auto --project <id>]
previewCommand
.command('setup')
.description('Show preview deployment setup instructions')
.option('--auto', 'Auto-create initiative and spawn agent to set up preview config')
.option('--project <id>', 'Project ID (required with --auto)')
.option('--provider <name>', 'Agent provider (optional, with --auto)')
.action(async (options: { auto?: boolean; project?: string; provider?: string }) => {
if (!options.auto) {
console.log(`Preview Deployment Setup
========================
Prerequisites:
- Docker installed and running
- Project registered in Codewalkers (cw project register)
Step 1: Add .cw-preview.yml to your project root
Minimal (single service with Dockerfile):
version: 1
services:
app:
build: .
port: 3000
Multi-service (frontend + API + database):
version: 1
services:
frontend:
build:
context: .
dockerfile: apps/web/Dockerfile
port: 3000
route: /
healthcheck:
path: /
backend:
build:
context: .
dockerfile: packages/api/Dockerfile
port: 8080
route: /api
healthcheck:
path: /health
db:
image: postgres:16-alpine
internal: true
env:
POSTGRES_PASSWORD: preview
Step 2: Commit .cw-preview.yml to your repo
Step 3: Start a preview
cw preview start --initiative <id> --project <id> --branch <branch>
Without .cw-preview.yml, previews auto-discover:
1. docker-compose.yml / compose.yml
2. Dockerfile (single service, port 3000)
Full reference: docs/preview.md`);
return;
}
// --auto mode
if (!options.project) {
console.error('--project <id> is required with --auto');
process.exit(1);
}
try {
const client = createDefaultTrpcClient();
// Look up project
const projects = await client.listProjects.query();
const project = projects.find((p: { id: string }) => p.id === options.project);
if (!project) {
console.error(`Project '${options.project}' not found`);
process.exit(1);
}
// Create initiative
const initiative = await client.createInitiative.mutate({
name: `Preview setup: ${project.name}`,
projectIds: [project.id],
});
// Create root page with setup guide
const rootPage = await client.getRootPage.query({ initiativeId: initiative.id });
const { markdownToTiptapJson } = await import('../agent/markdown-to-tiptap.js');
const setupMarkdown = `# Preview Setup for ${project.name}
Analyze the project structure and create a \`.cw-preview.yml\` configuration file for Docker-based preview deployments.
## What to do
1. Examine the project's build system, frameworks, and service architecture
2. Identify all services (frontend, backend, database, etc.)
3. Create a \`.cw-preview.yml\` at the project root with appropriate service definitions
4. Include dev mode configuration for hot-reload support
5. Add healthcheck endpoints where applicable
6. Commit the file to the repository
## Reference
See the Codewalkers documentation for .cw-preview.yml format and options.`;
await client.updatePage.mutate({
id: rootPage.id,
content: JSON.stringify(markdownToTiptapJson(setupMarkdown)),
});
// Spawn refine agent
const spawnInput: { initiativeId: string; instruction: string; provider?: string } = {
initiativeId: initiative.id,
instruction: 'Analyze the project and create a .cw-preview.yml for preview deployments. Follow the setup guide in the initiative pages.',
};
if (options.provider) {
spawnInput.provider = options.provider;
}
const agent = await client.spawnArchitectRefine.mutate(spawnInput);
console.log(`Created initiative: ${initiative.id}`);
console.log(`Spawned agent: ${agent.id} (${agent.name})`);
} catch (error) {
console.error('Failed to set up preview:', (error as Error).message);
process.exit(1);
}
});
// =========================================================================
// Inter-agent conversation commands
// =========================================================================
// cw listen --agent-id <id>
program
.command('listen')
.description('Listen for pending conversations via SSE subscription')
.requiredOption('--agent-id <id>', 'Agent ID to listen for')
.action(async (options: { agentId: string }) => {
try {
const client = createDefaultTrpcClient();
const subscription = client.onPendingConversation.subscribe(
{ agentId: options.agentId },
{
onData(envelope) {
const conv = envelope.data;
console.log(JSON.stringify({
conversationId: conv.conversationId,
fromAgentId: conv.fromAgentId,
question: conv.question,
phaseId: conv.phaseId,
taskId: conv.taskId,
}));
subscription.unsubscribe();
process.exit(0);
},
onError(err) {
console.error('Failed to listen:', err.message);
subscription.unsubscribe();
process.exit(1);
},
},
);
} catch (error) {
console.error('Failed to listen:', (error as Error).message);
process.exit(1);
}
});
// cw ask <question> --from <agentId> --agent-id|--phase-id|--task-id <target>
program
.command('ask <question>')
.description('Ask a question to another agent and wait for the answer via SSE')
.requiredOption('--from <agentId>', 'Your agent ID (the asker)')
.option('--agent-id <id>', 'Target agent ID')
.option('--phase-id <id>', 'Target phase ID (find running agent in phase)')
.option('--task-id <id>', 'Target task ID (find running agent for task)')
.action(async (question: string, options: {
from: string;
agentId?: string;
phaseId?: string;
taskId?: string;
}) => {
try {
const client = createDefaultTrpcClient();
// Create the conversation
const conversation = await client.createConversation.mutate({
fromAgentId: options.from,
toAgentId: options.agentId,
phaseId: options.phaseId,
taskId: options.taskId,
question,
});
// Subscribe for answer via SSE
const subscription = client.onConversationAnswer.subscribe(
{ conversationId: conversation.id },
{
onData(envelope) {
console.log(envelope.data.answer);
subscription.unsubscribe();
process.exit(0);
},
onError(err) {
console.error('Failed to ask:', err.message);
subscription.unsubscribe();
process.exit(1);
},
},
);
} catch (error) {
console.error('Failed to ask:', (error as Error).message);
process.exit(1);
}
});
// cw answer <answer> --conversation-id <id>
program
.command('answer <answer>')
.description('Answer a pending conversation')
.requiredOption('--conversation-id <id>', 'Conversation ID to answer')
.action(async (answer: string, options: { conversationId: string }) => {
try {
const client = createDefaultTrpcClient();
await client.answerConversation.mutate({
id: options.conversationId,
answer,
});
console.log(JSON.stringify({ conversationId: options.conversationId, status: 'answered' }));
} catch (error) {
console.error('Failed to answer:', (error as Error).message);
process.exit(1);
}
});
// ── 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;
}
/**
* Runs the CLI, handling server mode and commands.
*/
export async function runCli(): Promise<void> {
// Check for server flag early, before Commander processes
const hasServerFlag = process.argv.includes('--server') || process.argv.includes('-s');
if (hasServerFlag) {
// Get port from args if present
const portIndex = process.argv.findIndex(arg => arg === '-p' || arg === '--port');
const port = portIndex !== -1 && process.argv[portIndex + 1]
? parseInt(process.argv[portIndex + 1], 10)
: undefined;
const debug = process.argv.includes('--debug') || process.argv.includes('-d');
await startServer(port, debug);
// Server runs indefinitely until signal
return;
}
// Normal CLI processing
const program = createCli();
program.parse(process.argv);
}