mission-control/scripts/mc-cli.cjs

354 lines
13 KiB
JavaScript

#!/usr/bin/env node
/*
Mission Control CLI (v1 scaffold)
- Zero heavy dependencies
- API-key first for agent automation
- JSON mode + stable exit codes
*/
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
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
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 tasks queue --agent Aegis --max-capacity 2
mc sessions control --id abc123 --action terminate
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;
}
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 };
}
}
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));
}
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;
}
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];
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));
}
}
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 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,
},
};
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));
}
const cfg = map[group] && map[group][action];
if (!cfg) {
usage();
process.exit(EXIT.USAGE);
}
const result = await httpRequest({
baseUrl,
apiKey,
cookie: profile.cookie,
method: cfg.method,
route: cfg.route,
body: cfg.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();