From f2747b53300777805d31e064a3a423c630fed73b Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Sat, 21 Mar 2026 19:30:28 +0700 Subject: [PATCH] feat(cli): v2 rewrite with full command coverage and lazy evaluation - Fix eager required() evaluation bug (route map was evaluated at parse time, causing unrelated commands to crash on missing flags) - Refactor to lazy command handler pattern (functions only execute when their group+action matches) - Add compound subcommands: agents memory get|set|clear, agents soul get|set|templates, tasks comments list|add - Add missing PRD commands: agents attribution, tasks broadcast, sessions transcript, tokens agent-costs/task-costs/trends/export/rotate, status health/overview/dashboard/gateway/models/capabilities, export audit/tasks/activities/pipelines - Add proper SSE streaming for events watch (NDJSON in --json mode, human-readable otherwise, graceful Ctrl+C shutdown) - Add optional() helper for flags with defaults - Update docs/cli-agent-control.md with full v2 command reference --- docs/cli-agent-control.md | 129 ++++++-- scripts/mc-cli.cjs | 618 ++++++++++++++++++++++++++++++-------- 2 files changed, 607 insertions(+), 140 deletions(-) diff --git a/docs/cli-agent-control.md b/docs/cli-agent-control.md index 1b5b5a8..48b3f2b 100644 --- a/docs/cli-agent-control.md +++ b/docs/cli-agent-control.md @@ -1,15 +1,16 @@ -# Mission Control CLI for Agent-Complete Operations (v1 scaffold) +# Mission Control CLI for Agent-Complete Operations (v2) -This repository now includes a first-party CLI scaffold at: +This repository includes a first-party CLI at: - scripts/mc-cli.cjs -It is designed for autonomous/headless usage first: +Designed for autonomous/headless usage first: - API key auth support -- profile persistence (~/.mission-control/profiles/*.json) -- stable JSON mode (`--json`) -- deterministic exit code categories -- command groups mapped to Mission Control API resources +- Profile persistence (~/.mission-control/profiles/*.json) +- Stable JSON mode (`--json`) with NDJSON for streaming +- Deterministic exit code categories +- SSE streaming for real-time event watching +- Compound subcommands for memory, soul, comments ## Quick start @@ -21,22 +22,104 @@ It is designed for autonomous/headless usage first: 3) Run commands: +```bash node scripts/mc-cli.cjs agents list --json node scripts/mc-cli.cjs tasks queue --agent Aegis --max-capacity 2 --json node scripts/mc-cli.cjs sessions control --id --action terminate +``` -## Supported groups in scaffold +## Command groups -- auth: login, logout, whoami -- agents: list/get/create/update/delete/wake/diagnostics/heartbeat -- tasks: list/get/create/update/delete/queue -- sessions: list/control/continue -- connect: register/list/disconnect -- tokens: list/stats/by-agent -- skills: list/content/check/upsert/delete -- cron: list/create/update/pause/resume/remove/run -- events: watch (basic HTTP fallback) -- raw: generic request passthrough +### auth +- login --username --password +- logout +- whoami + +### agents +- list +- get --id +- create --name --role [--body '{}'] +- update --id [--body '{}'] +- delete --id +- wake --id +- diagnostics --id +- heartbeat --id +- attribution --id [--hours 24] [--section identity,cost] [--privileged] +- memory get --id +- memory set --id --content "..." [--append] +- memory set --id --file ./memory.md +- memory clear --id +- soul get --id +- soul set --id --content "..." +- soul set --id --file ./soul.md +- soul set --id --template operator +- soul templates --id [--template name] + +### tasks +- list +- get --id +- create --title [--body '{}'] +- update --id [--body '{}'] +- delete --id +- queue --agent [--max-capacity 2] +- broadcast --id --message "..." +- comments list --id +- comments add --id --content "..." [--parent-id 5] + +### sessions +- list +- control --id --action monitor|pause|terminate +- continue --kind claude-code|codex-cli --id --prompt "..." +- transcript --kind claude-code|codex-cli|hermes --id [--limit 40] [--source] + +### connect +- register --tool-name --agent-name [--body '{}'] +- list +- disconnect --connection-id + +### tokens +- list [--timeframe hour|day|week|month|all] +- stats [--timeframe] +- by-agent [--days 30] +- agent-costs [--timeframe] +- task-costs [--timeframe] +- trends [--timeframe] +- export [--format json|csv] [--timeframe] [--limit] +- rotate (shows current key info) +- rotate --confirm (generates new key -- admin only) + +### skills +- list +- content --source --name +- check --source --name +- upsert --source --name --file ./skill.md +- delete --source --name + +### cron +- list +- create/update/pause/resume/remove/run [--body '{}'] + +### events +- watch [--types agent,task] [--timeout-ms 3600000] + + Streams SSE events to stdout. In `--json` mode, outputs NDJSON (one JSON object per line). Press Ctrl+C to stop. + +### status +- health (no auth required) +- overview +- dashboard +- gateway +- models +- capabilities + +### export (admin) +- audit [--format json|csv] [--since ] [--until ] [--limit] +- tasks [--format json|csv] [--since] [--until] [--limit] +- activities [--format json|csv] [--since] [--until] [--limit] +- pipelines [--format json|csv] [--since] [--until] [--limit] + +### raw +- raw --method GET --path /api/... [--body '{}'] ## Exit code contract @@ -51,14 +134,18 @@ node scripts/mc-cli.cjs sessions control --id --action terminate To detect drift between Next.js route handlers and openapi.json, use: +```bash node scripts/check-api-contract-parity.mjs \ --root . \ --openapi openapi.json \ --ignore-file scripts/api-contract-parity.ignore +``` Machine output: +```bash node scripts/check-api-contract-parity.mjs --json +``` The checker scans `src/app/api/**/route.ts(x)`, derives operations (METHOD + /api/path), compares against OpenAPI operations, and exits non-zero on mismatch. @@ -70,7 +157,7 @@ Baseline policy in this repo: ## Next steps -- Promote scripts to package.json scripts (`mc`, `api:parity`). -- Add retry/backoff and SSE stream mode for `events watch`. -- Add richer pagination/filter UX and CSV export for reporting commands. +- Promote script to package.json bin entry (`mc`). +- Add retry/backoff for transient failures. - Add integration tests that run the CLI against a test server fixture. +- Add richer pagination/filter flags for list commands. diff --git a/scripts/mc-cli.cjs b/scripts/mc-cli.cjs index ce7bf3f..a41db11 100644 --- a/scripts/mc-cli.cjs +++ b/scripts/mc-cli.cjs @@ -1,9 +1,11 @@ #!/usr/bin/env node /* - Mission Control CLI (v1 scaffold) + Mission Control CLI (v2) - Zero heavy dependencies - API-key first for agent automation - JSON mode + stable exit codes + - Lazy command resolution (no eager required() calls) + - SSE streaming for events watch */ const fs = require('node:fs'); @@ -46,16 +48,20 @@ Usage: mc [--flags] Groups: - auth login/logout/whoami - agents list/get/create/update/delete/wake/diagnostics/heartbeat - tasks list/get/create/update/delete/queue/comment - sessions list/control/continue - connect register/list/disconnect - tokens list/stats/by-agent - skills list/content/upsert/delete/check - cron list/create/update/pause/resume/remove/run - events watch - raw request fallback + auth login/logout/whoami + agents list/get/create/update/delete/wake/diagnostics/heartbeat + memory get|set|clear / soul get|set|templates / attribution + tasks list/get/create/update/delete/queue + comments list|add / broadcast + sessions list/control/continue/transcript + connect register/list/disconnect + tokens list/stats/by-agent/agent-costs/task-costs/export/rotate + skills list/content/upsert/delete/check + cron list/create/update/pause/resume/remove/run + events watch + status health/overview/dashboard/gateway/models/capabilities + export audit/tasks/activities/pipelines + raw request fallback Common flags: --profile profile name (default: default) @@ -67,8 +73,16 @@ Common flags: Examples: mc agents list --json + mc agents memory get --id 5 + mc agents soul set --id 5 --template operator mc tasks queue --agent Aegis --max-capacity 2 - mc sessions control --id abc123 --action terminate + mc tasks comments list --id 42 + mc tasks comments add --id 42 --content "Looks good" + mc sessions transcript --kind claude-code --id abc123 + mc tokens agent-costs --timeframe week + mc tokens export --format csv + mc status health + mc events watch --types agent,task mc raw --method GET --path /api/status --json `); } @@ -126,6 +140,25 @@ function mapStatusToExit(status) { return EXIT.USAGE; } +function required(flags, key) { + const value = flags[key]; + if (value === undefined || value === true || String(value).trim() === '') { + throw new Error(`Missing required flag --${key}`); + } + return value; +} + +function optional(flags, key, fallback) { + const value = flags[key]; + if (value === undefined || value === true) return fallback; + return String(value); +} + +function bodyFromFlags(flags) { + if (flags.body) return JSON.parse(String(flags.body)); + return undefined; +} + async function httpRequest({ baseUrl, apiKey, cookie, method, route, body, timeoutMs = 20000 }) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -171,6 +204,70 @@ async function httpRequest({ baseUrl, apiKey, cookie, method, route, body, timeo } } +async function sseStream({ baseUrl, apiKey, cookie, route, timeoutMs, onEvent, onError }) { + const headers = { Accept: 'text/event-stream' }; + if (apiKey) headers['x-api-key'] = apiKey; + if (cookie) headers['Cookie'] = cookie; + const url = `${normalizeBaseUrl(baseUrl)}${route}`; + + const controller = new AbortController(); + let timer; + if (timeoutMs && timeoutMs < Infinity) { + timer = setTimeout(() => controller.abort(), timeoutMs); + } + + // Graceful shutdown on SIGINT/SIGTERM + const shutdown = () => { controller.abort(); }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + try { + const res = await fetch(url, { headers, signal: controller.signal }); + if (!res.ok) { + const text = await res.text(); + onError({ status: res.status, data: text }); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + // Parse SSE frames + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + let currentData = ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + currentData += line.slice(6); + } else if (line === '' && currentData) { + try { + const event = JSON.parse(currentData); + onEvent(event); + } catch { + // Non-JSON data line, emit raw + onEvent({ raw: currentData }); + } + currentData = ''; + } + } + } + } catch (err) { + if (err?.name === 'AbortError') return; // clean shutdown + onError({ error: err?.message || 'SSE connection error' }); + } finally { + if (timer) clearTimeout(timer); + process.removeListener('SIGINT', shutdown); + process.removeListener('SIGTERM', shutdown); + } +} + function printResult(result, asJson) { if (asJson) { console.log(JSON.stringify(result, null, 2)); @@ -187,14 +284,358 @@ function printResult(result, asJson) { console.error(JSON.stringify(result.data, null, 2)); } -function required(flags, key) { - const value = flags[key]; - if (value === undefined || value === true || String(value).trim() === '') { - throw new Error(`Missing required flag --${key}`); +// --- Command handlers --- +// Each returns { method, route, body? } or handles the request directly and returns null. + +const commands = { + auth: { + async login(flags, ctx) { + const username = required(flags, 'username'); + const password = required(flags, 'password'); + const result = await httpRequest({ + baseUrl: ctx.baseUrl, + method: 'POST', + route: '/api/auth/login', + body: { username, password }, + timeoutMs: ctx.timeoutMs, + }); + if (result.ok && result.setCookie) { + ctx.profile.url = ctx.baseUrl; + ctx.profile.cookie = result.setCookie.split(';')[0]; + if (ctx.apiKey) ctx.profile.apiKey = ctx.apiKey; + saveProfile(ctx.profile); + result.data = { ...result.data, profile: ctx.profile.name, saved_cookie: true }; + } + return result; + }, + async logout(flags, ctx) { + const result = await httpRequest({ baseUrl: ctx.baseUrl, apiKey: ctx.apiKey, cookie: ctx.profile.cookie, method: 'POST', route: '/api/auth/logout', timeoutMs: ctx.timeoutMs }); + if (result.ok) { + ctx.profile.cookie = ''; + saveProfile(ctx.profile); + } + return result; + }, + whoami: () => ({ method: 'GET', route: '/api/auth/me' }), + }, + + agents: { + list: () => ({ method: 'GET', route: '/api/agents' }), + get: (flags) => ({ method: 'GET', route: `/api/agents/${required(flags, 'id')}` }), + create: (flags) => ({ + method: 'POST', + route: '/api/agents', + body: bodyFromFlags(flags) || { name: required(flags, 'name'), role: required(flags, 'role') }, + }), + update: (flags) => ({ + method: 'PUT', + route: `/api/agents/${required(flags, 'id')}`, + body: bodyFromFlags(flags) || {}, + }), + delete: (flags) => ({ method: 'DELETE', route: `/api/agents/${required(flags, 'id')}` }), + wake: (flags) => ({ method: 'POST', route: `/api/agents/${required(flags, 'id')}/wake` }), + diagnostics: (flags) => ({ method: 'GET', route: `/api/agents/${required(flags, 'id')}/diagnostics` }), + heartbeat: (flags) => ({ method: 'POST', route: `/api/agents/${required(flags, 'id')}/heartbeat` }), + attribution: (flags) => { + const id = required(flags, 'id'); + const hours = optional(flags, 'hours', '24'); + const section = optional(flags, 'section', undefined); + let qs = `?hours=${encodeURIComponent(hours)}`; + if (section) qs += `§ion=${encodeURIComponent(section)}`; + if (flags.privileged) qs += '&privileged=1'; + return { method: 'GET', route: `/api/agents/${id}/attribution${qs}` }; + }, + // Subcommand: agents memory get|set|clear --id + memory: (flags) => { + const id = required(flags, 'id'); + const sub = flags._sub; + if (sub === 'get' || !sub) return { method: 'GET', route: `/api/agents/${id}/memory` }; + if (sub === 'set') { + const content = flags.content || flags.file + ? fs.readFileSync(required(flags, 'file'), 'utf8') + : required(flags, 'content'); + return { + method: 'PUT', + route: `/api/agents/${id}/memory`, + body: { working_memory: content, append: Boolean(flags.append) }, + }; + } + if (sub === 'clear') return { method: 'DELETE', route: `/api/agents/${id}/memory` }; + throw new Error(`Unknown agents memory subcommand: ${sub}. Use get|set|clear`); + }, + // Subcommand: agents soul get|set|templates --id + soul: (flags) => { + const id = required(flags, 'id'); + const sub = flags._sub; + if (sub === 'get' || !sub) return { method: 'GET', route: `/api/agents/${id}/soul` }; + if (sub === 'set') { + const body = {}; + if (flags.template) body.template_name = flags.template; + else if (flags.file) body.soul_content = fs.readFileSync(String(flags.file), 'utf8'); + else body.soul_content = required(flags, 'content'); + return { method: 'PUT', route: `/api/agents/${id}/soul`, body }; + } + if (sub === 'templates') { + const template = optional(flags, 'template', undefined); + const qs = template ? `?template=${encodeURIComponent(template)}` : ''; + return { method: 'PATCH', route: `/api/agents/${id}/soul${qs}` }; + } + throw new Error(`Unknown agents soul subcommand: ${sub}. Use get|set|templates`); + }, + }, + + tasks: { + list: () => ({ method: 'GET', route: '/api/tasks' }), + get: (flags) => ({ method: 'GET', route: `/api/tasks/${required(flags, 'id')}` }), + create: (flags) => ({ + method: 'POST', + route: '/api/tasks', + body: bodyFromFlags(flags) || { title: required(flags, 'title') }, + }), + update: (flags) => ({ + method: 'PUT', + route: `/api/tasks/${required(flags, 'id')}`, + body: bodyFromFlags(flags) || {}, + }), + delete: (flags) => ({ method: 'DELETE', route: `/api/tasks/${required(flags, 'id')}` }), + queue: (flags) => { + const agent = required(flags, 'agent'); + let qs = `?agent=${encodeURIComponent(agent)}`; + if (flags['max-capacity']) qs += `&max_capacity=${encodeURIComponent(String(flags['max-capacity']))}`; + return { method: 'GET', route: `/api/tasks/queue${qs}` }; + }, + broadcast: (flags) => ({ + method: 'POST', + route: `/api/tasks/${required(flags, 'id')}/broadcast`, + body: { message: required(flags, 'message') }, + }), + // Subcommand: tasks comments list|add --id + comments: (flags) => { + const id = required(flags, 'id'); + const sub = flags._sub; + if (sub === 'list' || !sub) return { method: 'GET', route: `/api/tasks/${id}/comments` }; + if (sub === 'add') { + const body = { content: required(flags, 'content') }; + if (flags['parent-id']) body.parent_id = Number(flags['parent-id']); + return { method: 'POST', route: `/api/tasks/${id}/comments`, body }; + } + throw new Error(`Unknown tasks comments subcommand: ${sub}. Use list|add`); + }, + }, + + sessions: { + list: () => ({ method: 'GET', route: '/api/sessions' }), + control: (flags) => ({ + method: 'POST', + route: `/api/sessions/${required(flags, 'id')}/control`, + body: { action: required(flags, 'action') }, + }), + continue: (flags) => ({ + method: 'POST', + route: '/api/sessions/continue', + body: { + kind: required(flags, 'kind'), + id: required(flags, 'id'), + prompt: required(flags, 'prompt'), + }, + }), + transcript: (flags) => { + const kind = required(flags, 'kind'); + const id = required(flags, 'id'); + let qs = `?kind=${encodeURIComponent(kind)}&id=${encodeURIComponent(id)}`; + if (flags.limit) qs += `&limit=${encodeURIComponent(String(flags.limit))}`; + if (flags.source) qs += `&source=${encodeURIComponent(String(flags.source))}`; + return { method: 'GET', route: `/api/sessions/transcript${qs}` }; + }, + }, + + connect: { + register: (flags) => ({ + method: 'POST', + route: '/api/connect', + body: bodyFromFlags(flags) || { tool_name: required(flags, 'tool-name'), agent_name: required(flags, 'agent-name') }, + }), + list: () => ({ method: 'GET', route: '/api/connect' }), + disconnect: (flags) => ({ + method: 'DELETE', + route: '/api/connect', + body: { connection_id: required(flags, 'connection-id') }, + }), + }, + + tokens: { + list: (flags) => { + let qs = '?action=list'; + if (flags.timeframe) qs += `&timeframe=${encodeURIComponent(String(flags.timeframe))}`; + return { method: 'GET', route: `/api/tokens${qs}` }; + }, + stats: (flags) => { + let qs = '?action=stats'; + if (flags.timeframe) qs += `&timeframe=${encodeURIComponent(String(flags.timeframe))}`; + return { method: 'GET', route: `/api/tokens${qs}` }; + }, + 'by-agent': (flags) => ({ + method: 'GET', + route: `/api/tokens/by-agent?days=${encodeURIComponent(String(flags.days || '30'))}`, + }), + 'agent-costs': (flags) => { + let qs = '?action=agent-costs'; + if (flags.timeframe) qs += `&timeframe=${encodeURIComponent(String(flags.timeframe))}`; + return { method: 'GET', route: `/api/tokens${qs}` }; + }, + 'task-costs': (flags) => { + let qs = '?action=task-costs'; + if (flags.timeframe) qs += `&timeframe=${encodeURIComponent(String(flags.timeframe))}`; + return { method: 'GET', route: `/api/tokens${qs}` }; + }, + trends: (flags) => { + let qs = '?action=trends'; + if (flags.timeframe) qs += `&timeframe=${encodeURIComponent(String(flags.timeframe))}`; + return { method: 'GET', route: `/api/tokens${qs}` }; + }, + export: (flags) => { + const format = optional(flags, 'format', 'json'); + let qs = `?action=export&format=${encodeURIComponent(format)}`; + if (flags.timeframe) qs += `&timeframe=${encodeURIComponent(String(flags.timeframe))}`; + if (flags.limit) qs += `&limit=${encodeURIComponent(String(flags.limit))}`; + return { method: 'GET', route: `/api/tokens${qs}` }; + }, + rotate: (flags) => { + if (flags.confirm) return { method: 'POST', route: '/api/tokens/rotate' }; + return { method: 'GET', route: '/api/tokens/rotate' }; + }, + }, + + skills: { + list: () => ({ method: 'GET', route: '/api/skills' }), + content: (flags) => ({ + method: 'GET', + route: `/api/skills?mode=content&source=${encodeURIComponent(required(flags, 'source'))}&name=${encodeURIComponent(required(flags, 'name'))}`, + }), + check: (flags) => ({ + method: 'GET', + route: `/api/skills?mode=check&source=${encodeURIComponent(required(flags, 'source'))}&name=${encodeURIComponent(required(flags, 'name'))}`, + }), + upsert: (flags) => ({ + method: 'PUT', + route: '/api/skills', + body: { + source: required(flags, 'source'), + name: required(flags, 'name'), + content: fs.readFileSync(required(flags, 'file'), 'utf8'), + }, + }), + delete: (flags) => ({ + method: 'DELETE', + route: `/api/skills?source=${encodeURIComponent(required(flags, 'source'))}&name=${encodeURIComponent(required(flags, 'name'))}`, + }), + }, + + cron: { + list: () => ({ method: 'GET', route: '/api/cron' }), + create: (flags) => ({ method: 'POST', route: '/api/cron', body: bodyFromFlags(flags) || {} }), + update: (flags) => ({ method: 'POST', route: '/api/cron', body: bodyFromFlags(flags) || {} }), + pause: (flags) => ({ method: 'POST', route: '/api/cron', body: bodyFromFlags(flags) || {} }), + resume: (flags) => ({ method: 'POST', route: '/api/cron', body: bodyFromFlags(flags) || {} }), + remove: (flags) => ({ method: 'POST', route: '/api/cron', body: bodyFromFlags(flags) || {} }), + run: (flags) => ({ method: 'POST', route: '/api/cron', body: bodyFromFlags(flags) || {} }), + }, + + status: { + health: () => ({ method: 'GET', route: '/api/status?action=health' }), + overview: () => ({ method: 'GET', route: '/api/status?action=overview' }), + dashboard: () => ({ method: 'GET', route: '/api/status?action=dashboard' }), + gateway: () => ({ method: 'GET', route: '/api/status?action=gateway' }), + models: () => ({ method: 'GET', route: '/api/status?action=models' }), + capabilities: () => ({ method: 'GET', route: '/api/status?action=capabilities' }), + }, + + export: { + audit: (flags) => { + const format = optional(flags, 'format', 'json'); + let qs = `?type=audit&format=${encodeURIComponent(format)}`; + if (flags.since) qs += `&since=${encodeURIComponent(String(flags.since))}`; + if (flags.until) qs += `&until=${encodeURIComponent(String(flags.until))}`; + if (flags.limit) qs += `&limit=${encodeURIComponent(String(flags.limit))}`; + return { method: 'GET', route: `/api/export${qs}` }; + }, + tasks: (flags) => { + const format = optional(flags, 'format', 'json'); + let qs = `?type=tasks&format=${encodeURIComponent(format)}`; + if (flags.since) qs += `&since=${encodeURIComponent(String(flags.since))}`; + if (flags.until) qs += `&until=${encodeURIComponent(String(flags.until))}`; + if (flags.limit) qs += `&limit=${encodeURIComponent(String(flags.limit))}`; + return { method: 'GET', route: `/api/export${qs}` }; + }, + activities: (flags) => { + const format = optional(flags, 'format', 'json'); + let qs = `?type=activities&format=${encodeURIComponent(format)}`; + if (flags.since) qs += `&since=${encodeURIComponent(String(flags.since))}`; + if (flags.until) qs += `&until=${encodeURIComponent(String(flags.until))}`; + if (flags.limit) qs += `&limit=${encodeURIComponent(String(flags.limit))}`; + return { method: 'GET', route: `/api/export${qs}` }; + }, + pipelines: (flags) => { + const format = optional(flags, 'format', 'json'); + let qs = `?type=pipelines&format=${encodeURIComponent(format)}`; + if (flags.since) qs += `&since=${encodeURIComponent(String(flags.since))}`; + if (flags.until) qs += `&until=${encodeURIComponent(String(flags.until))}`; + if (flags.limit) qs += `&limit=${encodeURIComponent(String(flags.limit))}`; + return { method: 'GET', route: `/api/export${qs}` }; + }, + }, +}; + +// --- Events watch (SSE streaming) --- + +async function handleEventsWatch(flags, ctx) { + const types = optional(flags, 'types', undefined); + let route = '/api/events'; + if (types) route += `?types=${encodeURIComponent(types)}`; + + if (ctx.asJson) { + // JSON mode: one JSON object per line (NDJSON) + await sseStream({ + baseUrl: ctx.baseUrl, + apiKey: ctx.apiKey, + cookie: ctx.profile.cookie, + route, + timeoutMs: ctx.timeoutMs, + onEvent: (event) => { + if (event.type === 'heartbeat') return; + console.log(JSON.stringify(event)); + }, + onError: (err) => { + console.error(JSON.stringify({ ok: false, error: err })); + process.exit(EXIT.SERVER); + }, + }); + } else { + console.log(`Watching events at ${normalizeBaseUrl(ctx.baseUrl)}${route}`); + console.log('Press Ctrl+C to stop.\n'); + await sseStream({ + baseUrl: ctx.baseUrl, + apiKey: ctx.apiKey, + cookie: ctx.profile.cookie, + route, + timeoutMs: ctx.timeoutMs, + onEvent: (event) => { + if (event.type === 'heartbeat') return; + const ts = event.timestamp ? new Date(event.timestamp).toISOString() : new Date().toISOString(); + const type = event.type || event.data?.mutation || 'event'; + console.log(`[${ts}] ${type}: ${JSON.stringify(event.data || event)}`); + }, + onError: (err) => { + console.error(`SSE error: ${JSON.stringify(err)}`); + process.exit(EXIT.SERVER); + }, + }); } - return value; + process.exit(EXIT.OK); } +// --- Main --- + async function run() { const parsed = parseArgs(process.argv.slice(2)); if (parsed.flags.help || parsed._.length === 0) { @@ -211,129 +652,68 @@ async function run() { const group = parsed._[0]; const action = parsed._[1]; + // For compound subcommands like: agents memory get / tasks comments add + const sub = parsed._[2]; + + const ctx = { baseUrl, apiKey, profile, timeoutMs, asJson }; try { - if (group === 'auth') { - if (action === 'login') { - const username = required(parsed.flags, 'username'); - const password = required(parsed.flags, 'password'); - const result = await httpRequest({ - baseUrl, - method: 'POST', - route: '/api/auth/login', - body: { username, password }, - timeoutMs, - }); - if (result.ok && result.setCookie) { - profile.url = baseUrl; - profile.cookie = result.setCookie.split(';')[0]; - if (apiKey) profile.apiKey = apiKey; - saveProfile(profile); - result.data = { ...result.data, profile: profile.name, saved_cookie: true }; - } - printResult(result, asJson); - process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); - } - if (action === 'logout') { - const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'POST', route: '/api/auth/logout', timeoutMs }); - if (result.ok) { - profile.cookie = ''; - saveProfile(profile); - } - printResult(result, asJson); - process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); - } - if (action === 'whoami') { - const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'GET', route: '/api/auth/me', timeoutMs }); - printResult(result, asJson); - process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); - } - } - + // Raw passthrough if (group === 'raw') { const method = String(required(parsed.flags, 'method')).toUpperCase(); const route = String(required(parsed.flags, 'path')); - const body = parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : undefined; + const body = bodyFromFlags(parsed.flags); const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method, route, body, timeoutMs }); printResult(result, asJson); process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); } - const map = { - agents: { - list: { method: 'GET', route: '/api/agents' }, - get: { method: 'GET', route: `/api/agents/${required(parsed.flags, 'id')}` }, - create: { method: 'POST', route: '/api/agents', body: { name: required(parsed.flags, 'name'), role: required(parsed.flags, 'role') } }, - update: { method: 'PUT', route: `/api/agents/${required(parsed.flags, 'id')}`, body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - delete: { method: 'DELETE', route: `/api/agents/${required(parsed.flags, 'id')}` }, - wake: { method: 'POST', route: `/api/agents/${required(parsed.flags, 'id')}/wake` }, - diagnostics: { method: 'GET', route: `/api/agents/${required(parsed.flags, 'id')}/diagnostics` }, - heartbeat: { method: 'POST', route: `/api/agents/${required(parsed.flags, 'id')}/heartbeat` }, - }, - tasks: { - list: { method: 'GET', route: '/api/tasks' }, - get: { method: 'GET', route: `/api/tasks/${required(parsed.flags, 'id')}` }, - create: { method: 'POST', route: '/api/tasks', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : { title: required(parsed.flags, 'title') } }, - update: { method: 'PUT', route: `/api/tasks/${required(parsed.flags, 'id')}`, body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - delete: { method: 'DELETE', route: `/api/tasks/${required(parsed.flags, 'id')}` }, - queue: { method: 'GET', route: `/api/tasks/queue?agent=${encodeURIComponent(required(parsed.flags, 'agent'))}${parsed.flags['max-capacity'] ? `&max_capacity=${encodeURIComponent(String(parsed.flags['max-capacity']))}` : ''}` }, - }, - sessions: { - list: { method: 'GET', route: '/api/sessions' }, - control: { method: 'POST', route: `/api/sessions/${required(parsed.flags, 'id')}/control`, body: { action: required(parsed.flags, 'action') } }, - continue: { method: 'POST', route: '/api/sessions/continue', body: { kind: required(parsed.flags, 'kind'), id: required(parsed.flags, 'id'), prompt: required(parsed.flags, 'prompt') } }, - }, - connect: { - register: { method: 'POST', route: '/api/connect', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : { tool_name: required(parsed.flags, 'tool-name'), agent_name: required(parsed.flags, 'agent-name') } }, - list: { method: 'GET', route: '/api/connect' }, - disconnect: { method: 'DELETE', route: '/api/connect', body: { connection_id: required(parsed.flags, 'connection-id') } }, - }, - tokens: { - list: { method: 'GET', route: '/api/tokens?action=list' }, - stats: { method: 'GET', route: '/api/tokens?action=stats' }, - 'by-agent': { method: 'GET', route: `/api/tokens/by-agent?days=${encodeURIComponent(String(parsed.flags.days || '30'))}` }, - }, - skills: { - list: { method: 'GET', route: '/api/skills' }, - content: { method: 'GET', route: `/api/skills?mode=content&source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` }, - check: { method: 'GET', route: `/api/skills?mode=check&source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` }, - upsert: { method: 'PUT', route: '/api/skills', body: { source: required(parsed.flags, 'source'), name: required(parsed.flags, 'name'), content: fs.readFileSync(required(parsed.flags, 'file'), 'utf8') } }, - delete: { method: 'DELETE', route: `/api/skills?source=${encodeURIComponent(required(parsed.flags, 'source'))}&name=${encodeURIComponent(required(parsed.flags, 'name'))}` }, - }, - cron: { - list: { method: 'GET', route: '/api/cron' }, - create: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - update: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - pause: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - resume: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - remove: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - run: { method: 'POST', route: '/api/cron', body: parsed.flags.body ? JSON.parse(String(parsed.flags.body)) : {} }, - }, - events: { - watch: null, - }, - }; - + // Events watch (SSE) if (group === 'events' && action === 'watch') { - const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, method: 'GET', route: '/api/events', timeoutMs: Number(parsed.flags['timeout-ms'] || 3600000) }); - // Basic fallback: if server doesn't stream in this fetch mode, print response payload - printResult(result, asJson); - process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status)); + await handleEventsWatch(parsed.flags, { ...ctx, timeoutMs: Number(parsed.flags['timeout-ms'] || 3600000) }); + return; } - const cfg = map[group] && map[group][action]; - if (!cfg) { + // Look up group and action in the commands map + const groupMap = commands[group]; + if (!groupMap) { + console.error(`Unknown group: ${group}`); usage(); process.exit(EXIT.USAGE); } + let handler = groupMap[action]; + if (!handler) { + console.error(`Unknown action: ${group} ${action}`); + usage(); + process.exit(EXIT.USAGE); + } + + // Inject sub-command into flags for compound commands (memory, soul, comments) + if (sub && typeof handler === 'function') { + parsed.flags._sub = sub; + } + + // Execute handler + const result_or_config = await (typeof handler === 'function' + ? handler(parsed.flags, ctx) + : handler); + + // If handler returned an http result directly (auth login/logout) + if (result_or_config && 'ok' in result_or_config && 'status' in result_or_config) { + printResult(result_or_config, asJson); + process.exit(result_or_config.ok ? EXIT.OK : mapStatusToExit(result_or_config.status)); + } + + // Otherwise it returned { method, route, body? } — execute the request + const { method, route, body } = result_or_config; const result = await httpRequest({ baseUrl, apiKey, cookie: profile.cookie, - method: cfg.method, - route: cfg.route, - body: cfg.body, + method, + route, + body, timeoutMs, });