agenthub/src/cli/index.ts
chahinebrini 9c749beacb feat(activity): per-task timeline endpoint, board expand, --task/--tokens/--duration/--by flags
GET /tasks/:id/activity returns a time-sorted ActivityItem[] assembled from existing data (task created, handoffs by taskId, memory by relatedTasks, current status). memory add + task done gain optional tokens/duration/by metadata surfaced in the timeline. Board cards expand inline to show the timeline. Index gains taskId + relatedTasks columns with migrations. 74 -> 97 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:49:47 +02:00

431 lines
17 KiB
TypeScript

import { Command } from 'commander';
import { init } from './commands/init.js';
import { status } from './commands/status.js';
import { memoryAdd, memorySearch, memoryList } from './commands/memory.js';
import { taskCreate, taskList, taskShow, taskClaim, taskDone } from './commands/task.js';
import { handoffCreate, handoffRead, handoffList } from './commands/handoff.js';
import { decisionCreate, decisionList } from './commands/decision.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 { loadConfig } 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):
* use its configured serverUrl if set, 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).
*/
async function resolveContext(program: Command, cwd: string): Promise<ResolvedContext> {
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) {
try {
const config = loadConfig(root);
return { serverUrl: config.serverUrl, projectCwd: root };
} catch {
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<void>): Promise<void> {
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?`);
} 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.1.0')
.option('--server <url>', 'AgentHub server URL (env: AGENTHUB_SERVER)');
program
.command('init')
.description('Initialize AgentHub in the current directory')
.option('-n, --project-name <name>', 'Project name')
.option('-y, --yes', 'Use defaults without prompts')
.option('--server <url>', '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>', '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(`Memory saved as ${memory.id}.`);
});
} else {
await memoryAdd(projectCwd, {
title: options.title as string | undefined,
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(`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(`${id} claimed 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(`${id} marked as done.`);
});
} else {
taskDone(projectCwd, id, meta);
}
});
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(`Handoff created: ${handoff.id}`);
});
} 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(`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);
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('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();
});
// ─── 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)')
.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 });
});
// ─── 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;
}