import { Command } from 'commander'; import { init } from './commands/init.js'; import { status } from './commands/status.js'; import { memoryAdd, memorySearch, memoryList, type MemoryAddOptions } from './commands/memory.js'; import { taskCreate, taskList, taskShow, taskClaim, taskDone, taskReview, taskReopen, taskAssign } from './commands/task.js'; import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js'; import { decisionCreate, decisionList } from './commands/decision.js'; import { messageSend, inboxList } from './commands/message.js'; import { agentSetup, hookContext } from './commands/agentSetup.js'; import { delegate } from './commands/delegate.js'; import { serverStart } from './commands/server.js'; import { update } from './commands/update.js'; import { watchEvents } from './commands/watch.js'; import { startAgent } from './commands/start.js'; import { workAgent } from './commands/work.js'; import { startMcpServer } from '../mcp/server.js'; import { installMcp } from '../mcp/install.js'; import { loadConfig, saveConfig } from '../core/config.js'; import { findProjectRoot } from '../core/paths.js'; import { discoverServer } from '../discovery.js'; import { remoteClient, RemoteError } from './remoteClient.js'; interface ResolvedContext { serverUrl?: string; projectCwd: string; } /** * Decide, for a single command invocation, whether to talk to a remote server * or operate on local files, and which directory holds the project. * * Precedence: * 1. --server flag / AGENTHUB_SERVER env var (explicit wins) * 2. Nearest `.agenthub` project found by walking up from cwd (cwd-robust): * a. config has serverUrl → use it (instant). * b. config has NO serverUrl → try zero-config LAN auto-discovery; if a * server is found, persist it to the project config (so subsequent * commands are instant) and use it; otherwise local mode at that root. * 3. No project anywhere → try zero-config LAN auto-discovery; if a server is * found, use it; otherwise fall back to local mode (which then errors * gracefully instead of crashing with a raw stack trace). */ // The project root for the current invocation, set by resolveContext and read // by runRemote for IP-change self-heal. Safe as module state: the CLI runs // exactly one command per process. let activeProjectCwd: string | undefined; async function resolveContext(program: Command, cwd: string): Promise { activeProjectCwd = cwd; const flag = (program.opts().server as string | undefined) || process.env.AGENTHUB_SERVER; if (flag) return { serverUrl: flag, projectCwd: cwd }; const root = findProjectRoot(cwd); if (root) { activeProjectCwd = root; let config; try { config = loadConfig(root); } catch { return { projectCwd: root }; } if (config.serverUrl) return { serverUrl: config.serverUrl, projectCwd: root }; // Initialized project but no server configured yet: auto-find one on the // LAN and remember it, so the agent connects with zero manual setup. const discovered = await discoverServer(2000); if (discovered) { try { saveConfig(root, { ...config, serverUrl: discovered }); } catch { // Best-effort persist; still use the discovered server this run. } console.error(`AgentHub: discovered server at ${discovered} — saved to project config.`); return { serverUrl: discovered, projectCwd: root }; } return { projectCwd: root }; } const discovered = await discoverServer(2000); if (discovered) { console.error(`No local AgentHub project found — using discovered server at ${discovered}.`); return { serverUrl: discovered, projectCwd: cwd }; } return { projectCwd: cwd }; } async function runRemote(serverUrl: string, fn: () => Promise): Promise { try { await fn(); } catch (err) { if (err instanceof RemoteError) { if (err.status === 0) { console.error(`AgentHub server at ${serverUrl} is not reachable. Is 'agenthub server start --host 0.0.0.0' running?`); // Self-heal on IP change: the saved address may be stale. Try to // re-discover the live server and update the project config, so the // next command connects automatically. if (activeProjectCwd) { const found = await discoverServer(2000); if (found && found !== serverUrl) { try { const config = loadConfig(activeProjectCwd); saveConfig(activeProjectCwd, { ...config, serverUrl: found }); console.error(`AgentHub: server moved to ${found} — updated config. Re-run your command.`); } catch { console.error(`AgentHub: found server at ${found} — re-run with --server ${found}.`); } } } } else { console.error(`AgentHub server error (${err.status}): ${err.message}`); } process.exit(1); } throw err; } } export function createProgram(cwd: string): Command { const program = new Command('agenthub') .description('Local coordination layer for AI coding agents') .version('0.9.1') .option('--server ', 'AgentHub server URL (env: AGENTHUB_SERVER)'); program .command('init') .description('Initialize AgentHub in the current directory') .option('-n, --project-name ', 'Project name') .option('-y, --yes', 'Use defaults without prompts') .option('--server ', 'Connect to a remote AgentHub server; use "auto" to discover one on the LAN') .action((options) => { init(cwd, { ...options, server: options.server || (program.opts().server as string | undefined) }); }); program .command('status') .description('Show project status') .option('-u, --update', 'Regenerate status before showing') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const body = options.update ? await remoteClient.updateStatus(serverUrl) : await remoteClient.getStatus(serverUrl); console.log(body); }); } else { status(projectCwd, options); } }); const memoryCmd = new Command('memory').description('Manage memory entries'); memoryCmd .command('add') .description('Add a memory entry') .option('--title ', 'Title') .option('--category <category>', 'Category') .option('--content <content>', 'Content') .option( '--task <ids>', 'Link to task IDs (comma-separated, e.g. TSK-0001 or TSK-0001,TSK-0002)', ) .option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)') .option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)') .option('--by <agent>', 'Agent that produced this result (optional)') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); // Parse CLI string values into the types the service expects. const relatedTasks: string[] = options.task ? (Array.isArray(options.task) ? options.task : options.task .split(',') .map((s: string) => s.trim()) .filter(Boolean)) : []; const tokens = options.tokens != null ? Number(options.tokens) : undefined; const duration = options.duration != null ? Number(options.duration) : undefined; if (serverUrl) { await runRemote(serverUrl, async () => { const memory = await remoteClient.addMemory(serverUrl, { title: options.title as string | undefined, category: options.category as string | undefined, content: options.content as string | undefined, relatedTasks, tokens, duration, by: options.by as string | undefined, } as Partial<import('../core/schema.js').Memory>); console.log(`AgentHub: Memory saved ${memory.id} ${memory.title}`); }); } else { await memoryAdd(projectCwd, { title: options.title as string | undefined, category: options.category as MemoryAddOptions['category'], content: options.content as string | undefined, relatedTasks, tokens, duration, by: options.by as string | undefined, }); } }); memoryCmd .command('search <query>') .description('Search memory and tasks') .action(async (query) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const results = await remoteClient.searchMemory(serverUrl, query); if (results.length === 0) { console.log('No results found.'); return; } for (const r of results) console.log(`[${r.type}] ${r.id}: ${r.title}`); }); } else { memorySearch(projectCwd, query); } }); memoryCmd .command('list') .description('List memory entries') .action(async () => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const memories = await remoteClient.listMemory(serverUrl); if (memories.length === 0) { console.log('No memory entries found.'); return; } for (const m of memories) console.log(`${m.id}: ${m.title}`); }); } else { memoryList(projectCwd); } }); program.addCommand(memoryCmd); const taskCmd = new Command('task').description('Manage tasks'); taskCmd .command('create') .description('Create a task') .option('--title <title>', 'Title') .option('--role <role>', 'Role') .option('--priority <priority>', 'Priority') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const task = await remoteClient.createTask(serverUrl, options); console.log(`AgentHub: Task created ${task.id} ${task.title}`); }); } else { await taskCreate(projectCwd, options); } }); taskCmd .command('list') .description('List tasks') .option('--status <status>', 'Filter by status') .option('--role <role>', 'Filter by role') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const tasks = await remoteClient.listTasks(serverUrl, options); if (tasks.length === 0) { console.log('No tasks found.'); return; } for (const t of tasks) console.log(`${t.id} [${t.status}] (${t.role ?? 'unassigned'}) ${t.title}`); }); } else { taskList(projectCwd, options); } }); taskCmd .command('show <id>') .description('Show a task') .action(async (id) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const { task, body } = await remoteClient.getTask(serverUrl, id); console.log(`# ${task.title}`); console.log(`Status: ${task.status} | Role: ${task.role ?? '-'} | Priority: ${task.priority}`); console.log('\n' + body); }); } else { taskShow(projectCwd, id); } }); taskCmd .command('claim <id>') .description('Claim a task') .requiredOption('--agent <agent>', 'Agent name') .action(async (id, options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { await remoteClient.claimTask(serverUrl, id, options.agent); console.log(`AgentHub: Task claimed ${id} by ${options.agent}`); }); } else { taskClaim(projectCwd, id, options.agent); } }); taskCmd .command('done <id>') .description('Mark a task as done') .option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)') .option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)') .option('--by <agent>', 'Agent that completed the task (optional)') .action(async (id, options) => { const meta = { tokens: options.tokens != null ? Number(options.tokens) : undefined, duration: options.duration != null ? Number(options.duration) : undefined, by: options.by as string | undefined, }; const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { await remoteClient.doneTask(serverUrl, id, meta); console.log(`AgentHub: Task done ${id}`); }); } else { taskDone(projectCwd, id, meta); } }); taskCmd .command('assign <id>') .description('Address an open task to an agent (architect): it stays open and the agent\'s `work` auto-claims it') .requiredOption('--agent <agent>', 'Agent name') .action(async (id, options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { await remoteClient.assignTask(serverUrl, id, options.agent); console.log(`AgentHub: Task assigned ${id} → ${options.agent}`); }); } else { taskAssign(projectCwd, id, options.agent); } }); taskCmd .command('review <id>') .description('Submit a task for architect review (implementer: use this instead of done)') .action(async (id) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { await remoteClient.reviewTask(serverUrl, id); console.log(`AgentHub: Task review ${id} (awaiting architect review)`); }); } else { taskReview(projectCwd, id); } }); taskCmd .command('reopen <id>') .description('Re-trigger a task after review (architect: send back to implementer)') .action(async (id) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { await remoteClient.reopenTask(serverUrl, id); console.log(`AgentHub: Task reopened ${id}`); }); } else { taskReopen(projectCwd, id); } }); program.addCommand(taskCmd); const handoffCmd = new Command('handoff').description('Manage handoffs'); handoffCmd .command('create') .description('Create a handoff') .option('--fromRole <role>', 'From role') .option('--toRole <role>', 'To role') .option('--taskId <id>', 'Related task id') .option('--summary <summary>', 'Summary') .option('--context <context>', 'Context') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const handoff = await remoteClient.createHandoff(serverUrl, options); console.log(`AgentHub: Handoff created ${handoff.id} → ${handoff.toRole}`); }); } else { await handoffCreate(projectCwd, options); } }); handoffCmd .command('read <id>') .description('Read a handoff') .action(async (id) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const { handoff, body } = await remoteClient.getHandoff(serverUrl, id); console.log(`# ${handoff.summary}`); console.log(`From: ${handoff.fromRole} → ${handoff.toRole}`); if (handoff.taskId) console.log(`Task: ${handoff.taskId}`); console.log('\n' + body); }); } else { handoffRead(projectCwd, id); } }); handoffCmd .command('list') .description('List handoffs') .action(async () => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const handoffs = await remoteClient.listHandoffs(serverUrl); if (handoffs.length === 0) { console.log('No handoffs found.'); return; } for (const h of handoffs) console.log(`${h.id}: ${h.title}`); }); } else { handoffList(projectCwd); } }); program.addCommand(handoffCmd); const decisionCmd = new Command('decision').description('Manage decisions'); decisionCmd .command('create') .description('Create a decision record') .option('--title <title>', 'Title') .option('--context <context>', 'Context') .option('--decision <decision>', 'Decision') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const decision = await remoteClient.createDecision(serverUrl, options); console.log(`AgentHub: Decision recorded ${decision.id}`); }); } else { await decisionCreate(projectCwd, options); } }); decisionCmd .command('list') .description('List decisions') .action(async () => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const decisions = await remoteClient.listDecisions(serverUrl); if (decisions.length === 0) { console.log('No decisions found.'); return; } for (const d of decisions) console.log(`${d.id}: ${d.title}`); }); } else { decisionList(projectCwd); } }); program.addCommand(decisionCmd); // ─── messaging ─────────────────────────────────────────────────────────── program .command('message <to> <text>') .description('Send a direct message to another agent') .requiredOption('--from <agent>', 'Sender agent name') .option('--task <id>', 'Related task ID') .action(async (to: string, text: string, options: { from: string; task?: string }) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); const payload = { from: options.from, to, text, taskId: options.task }; if (serverUrl) { await runRemote(serverUrl, async () => { const m = await remoteClient.sendMessage(serverUrl, payload); console.log(`AgentHub: Message sent ${m.id} (${m.from} → ${m.to})`); }); } else { messageSend(projectCwd, payload); } }); program .command('inbox') .description('Read messages addressed to an agent') .requiredOption('--agent <agent>', 'Agent whose inbox to read') .option('--unread', 'Only unread messages') .action(async (options: { agent: string; unread?: boolean }) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const msgs = await remoteClient.getInbox(serverUrl, options.agent, !!options.unread); if (msgs.length === 0) { console.log(`No messages for ${options.agent}.`); return; } for (const m of msgs) { console.log(`${m.status === 'unread' ? '●' : ' '} ${m.id} ${m.from} → ${m.to}${m.taskId ? ` [${m.taskId}]` : ''}: ${m.text}`); } }); } else { inboxList(projectCwd, { agent: options.agent, unreadOnly: !!options.unread }); } }); // ─── auto-start ────────────────────────────────────────────────────────── const agentCmd = new Command('agent').description('Per-agent machine setup'); agentCmd .command('setup') .description('Auto-start: write a Claude Code SessionStart hook so sessions enter the work loop') .requiredOption('--agent <name>', 'This machine\'s agent name') .requiredOption('--role <role>', 'This agent\'s role (implementer / architect / tester)') .option('--user', 'Install for all projects (~/.claude) instead of just this one') .action((options: { agent: string; role: string; user?: boolean }) => { agentSetup(cwd, { agent: options.agent, role: options.role, user: options.user }); }); program.addCommand(agentCmd); // Called by the SessionStart hook; prints the work-loop instruction as context. program .command('hook-context') .description('Print the auto-start work-loop instruction (used by the SessionStart hook)') .requiredOption('--agent <name>', 'Agent name') .requiredOption('--role <role>', 'Agent role') .action((options: { agent: string; role: string }) => { hookContext({ agent: options.agent, role: options.role }); }); program .command('delegate') .description('Suggest or auto-delegate open tasks') .option('--auto', 'Create handoff automatically') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, async () => { const result = await remoteClient.delegate(serverUrl, options.auto ?? false); if (!result.suggestion) { console.log('No open tasks to delegate.'); return; } const s = result.suggestion; console.log('Suggested delegation:'); console.log(` Task: ${s.task.id} — ${s.task.title}`); console.log(` Role: ${s.role}`); console.log(` Preferred agent: ${s.preferredAgent}`); if (result.handoff) console.log('AgentHub: Handoff created automatically'); else console.log('Run with --auto to create the handoff.'); }); } else { await delegate(projectCwd, options); } }); program .command('update') .description('Update AgentHub to the latest version') .action(async () => { await update(); }); // ─── mcp ─────────────────────────────────────────────────────────────────── program .command('mcp [action]') .description('Start the MCP server (stdio). `agenthub mcp install` writes .mcp.json so MCP-aware agents auto-register.') .option('--print', 'Dry run: print the config instead of writing .mcp.json') .action(async (action: string | undefined, options: { print?: boolean }) => { if (action === 'install') { installMcp(cwd, { print: options.print }); return; } await startMcpServer(cwd); }); // ─── hello (presence) ────────────────────────────────────────────────────── program .command('hello') .description('Announce this agent to the hub (shows "<agent> joined" in watchers)') .requiredOption('--agent <name>', 'Agent name') .option('--role <role>', 'Agent role (architect/implementer/reviewer/tester)') .action(async (options) => { const { serverUrl } = await resolveContext(program, cwd); const role = options.role as string | undefined; if (serverUrl) { await runRemote(serverUrl, async () => { await remoteClient.announce(serverUrl, options.agent, role); console.log(`AgentHub: ${options.agent} joined${role ? ` (${role})` : ''}`); }); } else { // No server: nothing to announce to, but confirm locally. console.log(`AgentHub: ${options.agent} joined${role ? ` (${role})` : ''} (local — no server to notify)`); } }); // ─── start (onboarding) ──────────────────────────────────────────────────── program .command('start') .description('Onboard: announce, claim the task addressed to you, print it + its handoff') .requiredOption('--agent <name>', 'Agent name') .option('--role <role>', 'Role (default: implementer)', 'implementer') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); if (serverUrl) { await runRemote(serverUrl, () => startAgent({ serverUrl, projectCwd, agent: options.agent, role: options.role })); } else { await startAgent({ projectCwd, agent: options.agent, role: options.role }); } }); // ─── work (auto-claim) ────────────────────────────────────────────────────── program .command('work') .description('Wait for a task addressed to you, claim it, and print it (auto-claim loop)') .requiredOption('--agent <name>', 'Agent name') .option('--role <role>', 'Role (default: implementer)', 'implementer') .option('--timeout <sec>', 'Stop waiting after N seconds (default: wait indefinitely)') .action(async (options) => { const { serverUrl, projectCwd } = await resolveContext(program, cwd); const timeoutSec = options.timeout != null ? Number(options.timeout) : undefined; const ctx = { serverUrl, projectCwd, agent: options.agent, role: options.role, timeoutSec }; if (serverUrl) { await runRemote(serverUrl, () => workAgent(ctx)); } else { await workAgent(ctx); } }); // ─── watch ─────────────────────────────────────────────────────────────── program .command('watch') .description('Stream live AgentHub events from a running server (SSE)') .option('--once', 'Exit 0 after the first event (useful as a blocking wait for agents)') .option('--role <role>', 'Client-side role filter (only show events for this role)') .option('--await-review', 'Exit when an implementer submits (task → review); architect review-queue notifier') .action(async (options) => { const { serverUrl } = await resolveContext(program, cwd); if (!serverUrl) { console.error('No AgentHub server found. Start one with: agenthub server start --host 0.0.0.0'); process.exit(1); return; } await watchEvents(serverUrl, { once: options.once as boolean | undefined, role: options.role as string | undefined, awaitReview: options.awaitReview as boolean | undefined, }); }); // ─── server ────────────────────────────────────────────────────────────── const serverCmd = new Command('server').description('Optional local API server'); serverCmd .command('start') .description('Start the optional AgentHub API server') .option('-p, --port <port>', 'Port', '3377') .option('-h, --host <host>', 'Host to bind to', '127.0.0.1') .action((options) => { // Serve the project root if we are inside one, so `server start` works // from any subdirectory and always serves the single source of truth. const projectCwd = findProjectRoot(cwd) ?? cwd; serverStart(projectCwd, { port: parseInt(options.port, 10), host: options.host }); }); program.addCommand(serverCmd); return program; }