734 lines
25 KiB
JavaScript
Executable File
734 lines
25 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/*
|
|
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');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
|
|
const EXIT = {
|
|
OK: 0,
|
|
USAGE: 2,
|
|
AUTH: 3,
|
|
FORBIDDEN: 4,
|
|
NETWORK: 5,
|
|
SERVER: 6,
|
|
};
|
|
|
|
function parseArgs(argv) {
|
|
const out = { _: [], flags: {} };
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const token = argv[i];
|
|
if (!token.startsWith('--')) {
|
|
out._.push(token);
|
|
continue;
|
|
}
|
|
const key = token.slice(2);
|
|
const next = argv[i + 1];
|
|
if (!next || next.startsWith('--')) {
|
|
out.flags[key] = true;
|
|
continue;
|
|
}
|
|
out.flags[key] = next;
|
|
i += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function usage() {
|
|
console.log(`Mission Control CLI
|
|
|
|
Usage:
|
|
mc <group> <action> [--flags]
|
|
|
|
Groups:
|
|
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 <name> profile name (default: default)
|
|
--url <base_url> override profile URL
|
|
--api-key <key> override profile API key
|
|
--json JSON output
|
|
--timeout-ms <n> request timeout (default 20000)
|
|
--help show help
|
|
|
|
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 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
|
|
`);
|
|
}
|
|
|
|
function profilePath(name) {
|
|
return path.join(os.homedir(), '.mission-control', 'profiles', `${name}.json`);
|
|
}
|
|
|
|
function ensureParentDir(filePath) {
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
}
|
|
|
|
function loadProfile(name) {
|
|
const p = profilePath(name);
|
|
if (!fs.existsSync(p)) {
|
|
return {
|
|
name,
|
|
url: process.env.MC_URL || 'http://127.0.0.1:3000',
|
|
apiKey: process.env.MC_API_KEY || '',
|
|
cookie: process.env.MC_COOKIE || '',
|
|
};
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
return {
|
|
name,
|
|
url: parsed.url || process.env.MC_URL || 'http://127.0.0.1:3000',
|
|
apiKey: parsed.apiKey || process.env.MC_API_KEY || '',
|
|
cookie: parsed.cookie || process.env.MC_COOKIE || '',
|
|
};
|
|
} catch {
|
|
return {
|
|
name,
|
|
url: process.env.MC_URL || 'http://127.0.0.1:3000',
|
|
apiKey: process.env.MC_API_KEY || '',
|
|
cookie: process.env.MC_COOKIE || '',
|
|
};
|
|
}
|
|
}
|
|
|
|
function saveProfile(profile) {
|
|
const p = profilePath(profile.name);
|
|
ensureParentDir(p);
|
|
fs.writeFileSync(p, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
|
|
}
|
|
|
|
function normalizeBaseUrl(url) {
|
|
return String(url || '').replace(/\/+$/, '');
|
|
}
|
|
|
|
function mapStatusToExit(status) {
|
|
if (status === 401) return EXIT.AUTH;
|
|
if (status === 403) return EXIT.FORBIDDEN;
|
|
if (status >= 500) return EXIT.SERVER;
|
|
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);
|
|
const headers = { Accept: 'application/json' };
|
|
if (apiKey) headers['x-api-key'] = apiKey;
|
|
if (cookie) headers['Cookie'] = cookie;
|
|
let payload;
|
|
if (body !== undefined) {
|
|
headers['Content-Type'] = 'application/json';
|
|
payload = JSON.stringify(body);
|
|
}
|
|
const url = `${normalizeBaseUrl(baseUrl)}${route.startsWith('/') ? route : `/${route}`}`;
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers,
|
|
body: payload,
|
|
signal: controller.signal,
|
|
});
|
|
clearTimeout(timer);
|
|
const text = await res.text();
|
|
let data;
|
|
try {
|
|
data = text ? JSON.parse(text) : {};
|
|
} catch {
|
|
data = { raw: text };
|
|
}
|
|
return {
|
|
ok: res.ok,
|
|
status: res.status,
|
|
data,
|
|
setCookie: res.headers.get('set-cookie') || '',
|
|
url,
|
|
method,
|
|
};
|
|
} catch (err) {
|
|
clearTimeout(timer);
|
|
if (String(err?.name || '') === 'AbortError') {
|
|
return { ok: false, status: 0, data: { error: `Request timeout after ${timeoutMs}ms` }, timeout: true, url, method };
|
|
}
|
|
return { ok: false, status: 0, data: { error: err?.message || 'Network error' }, network: true, url, method };
|
|
}
|
|
}
|
|
|
|
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));
|
|
return;
|
|
}
|
|
if (result.ok) {
|
|
console.log(`OK ${result.status} ${result.method} ${result.url}`);
|
|
if (result.data && Object.keys(result.data).length > 0) {
|
|
console.log(JSON.stringify(result.data, null, 2));
|
|
}
|
|
return;
|
|
}
|
|
console.error(`ERROR ${result.status || 'NETWORK'} ${result.method} ${result.url}`);
|
|
console.error(JSON.stringify(result.data, null, 2));
|
|
}
|
|
|
|
// --- 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 <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));
|
|
if (parsed.flags.help || parsed._.length === 0) {
|
|
usage();
|
|
process.exit(EXIT.OK);
|
|
}
|
|
|
|
const asJson = Boolean(parsed.flags.json);
|
|
const profileName = String(parsed.flags.profile || 'default');
|
|
const profile = loadProfile(profileName);
|
|
const baseUrl = parsed.flags.url ? String(parsed.flags.url) : profile.url;
|
|
const apiKey = parsed.flags['api-key'] ? String(parsed.flags['api-key']) : profile.apiKey;
|
|
const timeoutMs = Number(parsed.flags['timeout-ms'] || 20000);
|
|
|
|
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 {
|
|
// Raw passthrough
|
|
if (group === 'raw') {
|
|
const method = String(required(parsed.flags, 'method')).toUpperCase();
|
|
const route = String(required(parsed.flags, 'path'));
|
|
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));
|
|
}
|
|
|
|
// Events watch (SSE)
|
|
if (group === 'events' && action === 'watch') {
|
|
await handleEventsWatch(parsed.flags, { ...ctx, timeoutMs: Number(parsed.flags['timeout-ms'] || 3600000) });
|
|
return;
|
|
}
|
|
|
|
// 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,
|
|
route,
|
|
body,
|
|
timeoutMs,
|
|
});
|
|
|
|
printResult(result, asJson);
|
|
process.exit(result.ok ? EXIT.OK : mapStatusToExit(result.status));
|
|
} catch (err) {
|
|
const message = err?.message || String(err);
|
|
if (asJson) {
|
|
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
|
|
} else {
|
|
console.error(`USAGE ERROR: ${message}`);
|
|
}
|
|
process.exit(EXIT.USAGE);
|
|
}
|
|
}
|
|
|
|
run();
|