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
This commit is contained in:
Nyk 2026-03-21 19:30:28 +07:00
parent 7b104952cc
commit f2747b5330
2 changed files with 607 additions and 140 deletions

View File

@ -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 <session-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 <name> [--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 <unix>] [--until <unix>] [--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 <session-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.

View File

@ -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');
@ -48,13 +50,17 @@ Usage:
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
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
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:
@ -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,13 +284,357 @@ 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 value;
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 += `&section=${encodeURIComponent(section)}`;
if (flags.privileged) qs += '&privileged=1';
return { method: 'GET', route: `/api/agents/${id}/attribution${qs}` };
},
// Subcommand: agents memory get|set|clear --id <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 <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 <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);
},
});
}
process.exit(EXIT.OK);
}
// --- Main ---
async function run() {
const parsed = parseArgs(process.argv.slice(2));
@ -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,
});