chore(api): tranche C parity burn-down and CLI integration scaffolding

This commit is contained in:
Nyk 2026-03-20 23:53:02 +07:00
parent 69e89a97a1
commit 7b104952cc
10 changed files with 3142 additions and 381 deletions

View File

@ -30,6 +30,9 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
- name: API contract parity
run: pnpm api:parity
- name: Lint - name: Lint
run: pnpm lint run: pnpm lint

76
docs/cli-agent-control.md Normal file
View File

@ -0,0 +1,76 @@
# Mission Control CLI for Agent-Complete Operations (v1 scaffold)
This repository now includes a first-party CLI scaffold at:
- scripts/mc-cli.cjs
It is 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
## Quick start
1) Ensure Mission Control API is running.
2) Set environment variables or use profile flags:
- MC_URL=http://127.0.0.1:3000
- MC_API_KEY=your-key
3) Run commands:
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
- 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
## Exit code contract
- 0 success
- 2 usage error
- 3 auth error (401)
- 4 permission error (403)
- 5 network/timeout
- 6 server error (5xx)
## API contract parity gate
To detect drift between Next.js route handlers and openapi.json, use:
node scripts/check-api-contract-parity.mjs \
--root . \
--openapi openapi.json \
--ignore-file scripts/api-contract-parity.ignore
Machine output:
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.
Baseline policy in this repo:
- `scripts/api-contract-parity.ignore` currently stores a temporary baseline of known drift.
- CI enforces no regressions beyond baseline.
- When you fix a mismatch, remove its line from ignore file in the same PR.
- Goal is monotonic burn-down to an empty ignore file.
## 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.
- Add integration tests that run the CLI against a test server fixture.

View File

@ -0,0 +1,259 @@
# Mission Control Platform Hardening + Full Agent CLI/TUI PRD
> For Hermes: execute this plan in iterative vertical slices (contract parity -> CLI core -> TUI -> hardening), with tests at each slice.
Goal
Build a production-grade Mission Control operator surface for autonomous agents via a first-party CLI (and optional lightweight TUI), while fixing platform inconsistencies discovered in audit: API contract drift, uneven reliability controls, and incomplete automation ergonomics.
Architecture
Mission Control remains the source of truth with REST + SSE endpoints. A first-party CLI consumes those APIs with profile-based auth and machine-friendly output. TUI is layered on top of CLI API client primitives for shared behavior. API contract reliability is enforced through route-to-spec parity checks in CI.
Tech Stack
- Existing: Next.js app-router API, SQLite, Node runtime, SSE
- New: Node CLI runtime (no heavy deps required for v1), optional TUI in terminal ANSI mode
- Testing: existing Playwright/Vitest patterns + CLI smoke tests + OpenAPI parity checks
---
## 1) Problem statement
Current Mission Control backend has strong capabilities for agent orchestration, but external automation quality is constrained by:
1. API surface drift between route handlers, openapi.json, and /api/index.
2. No first-party comprehensive CLI for operators/agents.
3. Uneven hardening around operational concerns (auth posture defaults, multi-instance rate limiting strategy, spawn history durability).
4. Incomplete UX for non-interactive agent workflows (idempotent commands, stable JSON output, strict exit codes).
Result: agents can use Mission Control partially, but not yet with high confidence as a full control plane.
## 2) Product objectives
Primary objectives
1. Deliver a first-party CLI with functional parity across core agent workflows.
2. Add optional TUI for rapid situational awareness and interactive operations.
3. Establish API contract parity as an enforceable quality gate.
4. Improve reliability and security defaults for autonomous operation.
Success criteria
- 95%+ of documented operator workflows executable via CLI without web UI.
- Contract parity CI gate blocks drift between route handlers and OpenAPI.
- CLI supports machine mode: stable JSON schemas and deterministic exit codes.
- TUI can monitor and trigger core actions (agents/tasks/sessions/events).
Non-goals (v1)
- Replacing the web UI.
- Building an advanced ncurses framework dependency stack if not needed.
- Supporting all historical/legacy endpoint aliases immediately.
## 3) Personas and workflows
Personas
1. Autonomous agent runtime (headless, non-interactive).
2. Human operator (terminal-first incident response).
3. Platform maintainer (release and contract governance).
Critical workflows
- Poll task queue and claim work.
- Manage agents (register/update/diagnose/wake).
- Manage sessions (list/control/continue/transcript).
- Observe events in real time.
- Track token usage and attribution.
- Manage skills, cron jobs, and direct CLI connections.
## 4) Functional requirements
### A. API contract governance
- FR-A1: A parity checker must compare discovered route handlers and OpenAPI paths/methods.
- FR-A2: CI fails on non-ignored mismatches.
- FR-A3: Ignore list must be explicit and reviewable.
- FR-A4: /api/index should be validated or generated from same contract source.
### B. CLI v1 requirements
- FR-B1: Profile-based configuration (URL + auth mode + key/cookie).
- FR-B2: Commands must support --json output and strict exit codes.
- FR-B3: Support key domains:
- auth
- agents
- tasks
- sessions
- connect
- tokens
- skills
- cron
- events watch
- raw request fallback
- FR-B4: Non-interactive defaults suitable for autonomous agents.
- FR-B5: Request timeout + retry controls for reliable automation.
### C. TUI v1 requirements (optional but included)
- FR-C1: Dashboard with agents/tasks/sessions summary panels.
- FR-C2: Keyboard-driven refresh/navigation.
- FR-C3: Trigger key operations (wake agent, queue poll, session controls).
- FR-C4: Clear degraded mode messaging if endpoints unavailable.
### D. Platform hardening requirements
- FR-D1: Document and enforce least-privilege auth guidance for agent keys.
- FR-D2: Expose explicit warning/controls for global admin API key usage.
- FR-D3: Add durable spawn history persistence (DB-backed) replacing log scraping fallback.
- FR-D4: Add scalable rate-limit strategy plan (in-memory now, pluggable backend next).
## 5) CLI command map (v1)
mc auth
- login --username --password
- logout
- whoami
mc agents
- list
- get --id
- create --name --role
- update --id ...fields
- delete --id
- wake --id
- diagnostics --id
- heartbeat --id
- memory get|set --id
- soul get|set --id
mc tasks
- list [filters]
- get --id
- create --title [--description --priority --assigned-to]
- update --id ...fields
- delete --id
- queue --agent [--max-capacity]
- comments list/add --id
- broadcast --id
mc sessions
- list
- control --id --action monitor|pause|terminate
- continue --kind claude-code|codex-cli --id --prompt
- transcript --id [--source]
mc connect
- register --tool-name --agent-name [...]
- list
- disconnect --connection-id
mc tokens
- list
- stats
- by-agent [--days]
- export --format json|csv
mc skills
- list
- content --source --name
- upsert --source --name --file
- delete --source --name
- check --source --name
mc cron
- list
- create/update/pause/resume/remove/run
mc events
- watch [--types]
mc raw
- raw --method GET --path /api/... [--body '{}']
## 6) UX and interface requirements
- Default output must be concise human-readable; --json returns machine-stable payload.
- All non-2xx responses include normalized error object and non-zero exit.
- Exit code taxonomy:
- 0 success
- 2 usage error
- 3 auth error
- 4 permission error
- 5 network/timeout
- 6 server error
- Pagination/filter flags normalized across list commands.
## 7) Security requirements
- Do not log raw API keys or cookies.
- Redact sensitive headers in verbose/debug output.
- Provide per-profile auth scope awareness (viewer/operator/admin implied risk labeling).
- Strong guidance: prefer agent-scoped keys over global admin key.
## 8) Reliability requirements
- Configurable timeout/retry/backoff.
- Safe JSON parsing and clear error surfaces.
- SSE reconnection strategy for watch mode.
- Graceful handling for partial endpoint availability.
## 9) Testing strategy
Unit
- CLI arg parsing and request mapping.
- Output modes and exit codes.
- API parity checker route extraction and mismatch detection.
Integration
- CLI against local Mission Control test server.
- Auth modes (API key, login session where enabled).
- Session control, queue polling, skills CRUD.
E2E
- Playwright/terminal-driven smoke for critical command paths.
- TUI render and keyboard navigation smoke tests.
Contract tests
- OpenAPI parity check in CI.
- Optional index parity check in CI.
## 10) Rollout plan
Phase 0: Contract stabilization
- Add parity checker and fail CI on drift.
- Resolve existing mismatches.
Phase 1: CLI core
- Ship profile/auth client + core command groups (auth/agents/tasks/sessions/connect).
Phase 2: CLI expansion
- tokens/skills/cron/events/raw + transcript ergonomics.
Phase 3: TUI
- Live dashboard + action shortcuts.
Phase 4: Hardening
- durable spawn history
- auth warnings and safeguards
- scalable rate-limit backend abstraction
## 11) Risks and mitigations
Risk: Large API surface causes long-tail parity gaps.
Mitigation: enforce parity checker + allowlist for temporary exceptions.
Risk: Auth complexity across cookie/key/proxy modes.
Mitigation: profile abstraction + explicit mode selection and diagnostics.
Risk: CLI churn if endpoint contracts continue changing.
Mitigation: typed response normalizers + compatibility layer + semver release notes.
## 12) Acceptance criteria
- PRD approved by maintainers.
- CLI provides end-to-end control for core workflows.
- Contract parity CI gate active and green.
- TUI displays operational state and triggers key actions.
- Security and reliability hardening changes documented and tested.
## 13) Immediate implementation tasks (next 1-2 PRs)
PR 1
1. Add API parity checker script and CI command.
2. Add first-party CLI scaffold with command routing and normalized request layer.
3. Add docs for CLI profiles/auth/output contract.
PR 2
1. Implement full command matrix.
2. Add TUI dashboard shell.
3. Add CLI integration tests.
4. Introduce durable spawn history model and endpoint alignment.

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@
"description": "OpenClaw Mission Control — open-source agent orchestration dashboard", "description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
"scripts": { "scripts": {
"verify:node": "node scripts/check-node-version.mjs", "verify:node": "node scripts/check-node-version.mjs",
"api:parity": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore",
"api:parity:json": "node scripts/check-api-contract-parity.mjs --root . --openapi openapi.json --ignore-file scripts/api-contract-parity.ignore --json",
"dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}", "dev": "pnpm run verify:node && next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
"build": "pnpm run verify:node && next build", "build": "pnpm run verify:node && next build",
"start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}", "start": "pnpm run verify:node && next start --hostname 0.0.0.0 --port ${PORT:-3000}",

View File

@ -0,0 +1,66 @@
# API contract parity baseline ignore list
# One operation per line: METHOD /api/path
# Keep this list shrinking over time; remove entries when route/spec parity is fixed.
DELETE /api/agents/{id}/memory
DELETE /api/backup
DELETE /api/integrations
DELETE /api/memory
DELETE /api/notifications
DELETE /api/projects/{id}/agents
GET /api/agents/evals
GET /api/agents/optimize
GET /api/agents/sync
GET /api/backup
GET /api/channels
GET /api/claude-tasks
GET /api/cleanup
GET /api/gateways/discover
GET /api/gateways/health/history
GET /api/github/sync
GET /api/gnap
GET /api/hermes
GET /api/hermes/memory
GET /api/hermes/tasks
GET /api/index
GET /api/local/agents-doc
GET /api/local/flight-deck
GET /api/memory/context
GET /api/memory/graph
GET /api/memory/health
GET /api/memory/links
GET /api/nodes
GET /api/notifications/deliver
GET /api/pipelines/run
GET /api/projects/{id}/agents
GET /api/schedule-parse
GET /api/security-audit
GET /api/security-scan
GET /api/spawn
GET /api/super/os-users
GET /api/system-monitor
GET /api/tasks/outcomes
GET /api/tasks/regression
GET /api/tokens/by-agent
PATCH /api/auth/me
POST /api/agents/evals
POST /api/agents/register
POST /api/auth/google/disconnect
POST /api/channels
POST /api/github/sync
POST /api/gnap
POST /api/hermes
POST /api/local/flight-deck
POST /api/local/terminal
POST /api/logs
POST /api/memory/process
POST /api/nodes
POST /api/projects/{id}/agents
POST /api/releases/update
POST /api/security-scan/agent
POST /api/security-scan/fix
POST /api/standup
POST /api/super/os-users
POST /api/super/provision-jobs/{id}
POST /api/tokens/rotate
PUT /api/integrations
PUT /api/notifications

View File

@ -0,0 +1,153 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
function toPosix(input) {
return input.split(path.sep).join('/')
}
function normalizeSegment(segment) {
if (segment.startsWith('[[...') && segment.endsWith(']]')) return `{${segment.slice(5, -2)}}`
if (segment.startsWith('[...') && segment.endsWith(']')) return `{${segment.slice(4, -1)}}`
if (segment.startsWith('[') && segment.endsWith(']')) return `{${segment.slice(1, -1)}}`
return segment
}
function routeFileToApiPath(projectRoot, fullPath) {
const rel = toPosix(path.relative(projectRoot, fullPath))
const withoutRoute = rel.replace(/\/route\.tsx?$/, '')
const trimmed = withoutRoute.startsWith('src/app/api') ? withoutRoute.slice('src/app/api'.length) : withoutRoute
const parts = trimmed.split('/').filter(Boolean).map(normalizeSegment)
return `/api${parts.length ? `/${parts.join('/')}` : ''}`
}
function extractHttpMethods(source) {
const methods = []
for (const method of HTTP_METHODS) {
const constExport = new RegExp(`export\\s+const\\s+${method}\\s*=`, 'm')
const fnExport = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\s*\\(`, 'm')
if (constExport.test(source) || fnExport.test(source)) methods.push(method)
}
return methods
}
function walkRouteFiles(dir, out = []) {
if (!fs.existsSync(dir)) return out
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walkRouteFiles(full, out)
else if (entry.isFile() && /route\.tsx?$/.test(entry.name)) out.push(full)
}
return out
}
function normalizeOperation(operation) {
const [method = '', ...pathParts] = String(operation || '').trim().split(' ')
const normalizedMethod = method.toUpperCase()
const normalizedPath = pathParts.join(' ').trim()
return `${normalizedMethod} ${normalizedPath}`
}
function parseIgnoreArg(ignoreArg) {
if (!ignoreArg) return []
return ignoreArg
.split(',')
.map((x) => normalizeOperation(x))
.filter(Boolean)
}
function parseArgs(argv) {
const flags = {}
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i]
if (!token.startsWith('--')) continue
const key = token.slice(2)
const next = argv[i + 1]
if (!next || next.startsWith('--')) {
flags[key] = true
continue
}
flags[key] = next
i += 1
}
return flags
}
function run() {
const flags = parseArgs(process.argv.slice(2))
const projectRoot = path.resolve(String(flags.root || process.cwd()))
const openapiPath = path.resolve(projectRoot, String(flags.openapi || 'openapi.json'))
const ignoreFile = flags['ignore-file'] ? path.resolve(projectRoot, String(flags['ignore-file'])) : null
const ignoreInline = parseIgnoreArg(flags.ignore)
let ignore = new Set(ignoreInline)
if (ignoreFile && fs.existsSync(ignoreFile)) {
const lines = fs
.readFileSync(ignoreFile, 'utf8')
.split('\n')
.map((x) => x.trim())
.filter((x) => x && !x.startsWith('#'))
.map((x) => normalizeOperation(x))
ignore = new Set([...ignore, ...lines])
}
const openapi = JSON.parse(fs.readFileSync(openapiPath, 'utf8'))
const openapiOps = new Set()
for (const [rawPath, pathItem] of Object.entries(openapi.paths || {})) {
for (const method of Object.keys(pathItem || {})) {
const upper = method.toUpperCase()
if (HTTP_METHODS.includes(upper)) {
openapiOps.add(`${upper} ${rawPath}`)
}
}
}
const routeFiles = walkRouteFiles(path.join(projectRoot, 'src/app/api'))
const routeOps = new Set()
for (const file of routeFiles) {
const source = fs.readFileSync(file, 'utf8')
const methods = extractHttpMethods(source)
const apiPath = routeFileToApiPath(projectRoot, file)
for (const method of methods) routeOps.add(`${method} ${apiPath}`)
}
const missingInOpenApi = [...routeOps].filter((op) => !openapiOps.has(op) && !ignore.has(op)).sort()
const missingInRoutes = [...openapiOps].filter((op) => !routeOps.has(op) && !ignore.has(op)).sort()
const summary = {
ok: missingInOpenApi.length === 0 && missingInRoutes.length === 0,
totals: {
routeOperations: routeOps.size,
openapiOperations: openapiOps.size,
ignoredOperations: ignore.size,
},
missingInOpenApi,
missingInRoutes,
}
if (flags.json) {
console.log(JSON.stringify(summary, null, 2))
} else {
console.log('API contract parity check')
console.log(`- route operations: ${summary.totals.routeOperations}`)
console.log(`- openapi operations: ${summary.totals.openapiOperations}`)
console.log(`- ignored entries: ${summary.totals.ignoredOperations}`)
if (missingInOpenApi.length) {
console.log('\nMissing in OpenAPI:')
for (const op of missingInOpenApi) console.log(` - ${op}`)
}
if (missingInRoutes.length) {
console.log('\nMissing in routes:')
for (const op of missingInRoutes) console.log(` - ${op}`)
}
if (!missingInOpenApi.length && !missingInRoutes.length) {
console.log('\n✅ Contract parity OK')
}
}
process.exit(summary.ok ? 0 : 1)
}
run()

353
scripts/mc-cli.cjs Normal file
View File

@ -0,0 +1,353 @@
#!/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();

View File

@ -0,0 +1,100 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectOpenApiOperations,
compareApiContractParity,
extractHttpMethods,
routeFileToApiPath,
runApiContractParityCheck,
} from '@/lib/api-contract-parity'
const tempDirs: string[] = []
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true })
}
})
describe('api-contract-parity helpers', () => {
it('maps Next.js route files to OpenAPI-style API paths', () => {
expect(routeFileToApiPath('src/app/api/agents/route.ts')).toBe('/api/agents')
expect(routeFileToApiPath('src/app/api/tasks/[id]/route.ts')).toBe('/api/tasks/{id}')
expect(routeFileToApiPath('src/app/api/files/[...slug]/route.ts')).toBe('/api/files/{slug}')
expect(routeFileToApiPath('src/app/api/optional/[[...tail]]/route.ts')).toBe('/api/optional/{tail}')
})
it('extracts exported HTTP methods from route modules', () => {
const source = `
export const GET = async () => {}
export const POST = async () => {}
const internal = 'ignore me'
`
expect(extractHttpMethods(source).sort()).toEqual(['GET', 'POST'])
})
it('normalizes OpenAPI operations', () => {
const operations = collectOpenApiOperations({
paths: {
'/api/tasks': { get: {}, post: {} },
'/api/tasks/{id}': { delete: {}, patch: {} },
},
})
expect(operations).toEqual([
'DELETE /api/tasks/{id}',
'GET /api/tasks',
'PATCH /api/tasks/{id}',
'POST /api/tasks',
])
})
it('reports mismatches with optional ignore list', () => {
const report = compareApiContractParity({
routeOperations: [
{ method: 'GET', path: '/api/tasks', sourceFile: 'a' },
{ method: 'POST', path: '/api/tasks', sourceFile: 'a' },
{ method: 'DELETE', path: '/api/tasks/{id}', sourceFile: 'b' },
],
openapiOperations: ['GET /api/tasks', 'PATCH /api/tasks/{id}', 'DELETE /api/tasks/{id}'],
ignore: ['PATCH /api/tasks/{id}'],
})
expect(report.missingInOpenApi).toEqual(['POST /api/tasks'])
expect(report.missingInRoutes).toEqual([])
expect(report.ignoredOperations).toEqual(['PATCH /api/tasks/{id}'])
})
it('scans a project root and compares route operations to openapi', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-contract-'))
tempDirs.push(root)
const routeDir = path.join(root, 'src/app/api/tasks/[id]')
fs.mkdirSync(routeDir, { recursive: true })
fs.writeFileSync(path.join(root, 'src/app/api/tasks/route.ts'), 'export const GET = async () => {};\n', 'utf8')
fs.writeFileSync(path.join(routeDir, 'route.ts'), 'export const DELETE = async () => {};\n', 'utf8')
fs.writeFileSync(
path.join(root, 'openapi.json'),
JSON.stringify({
openapi: '3.0.0',
paths: {
'/api/tasks': { get: {} },
'/api/tasks/{id}': { delete: {}, patch: {} },
},
}),
'utf8',
)
const report = runApiContractParityCheck({
projectRoot: root,
ignore: ['PATCH /api/tasks/{id}'],
})
expect(report.missingInOpenApi).toEqual([])
expect(report.missingInRoutes).toEqual([])
expect(report.ignoredOperations).toEqual(['PATCH /api/tasks/{id}'])
})
})

View File

@ -0,0 +1,176 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
export type ContractOperation = string
export interface RouteOperation {
method: string
path: string
sourceFile: string
}
export interface ParityReport {
routeOperations: ContractOperation[]
openapiOperations: ContractOperation[]
missingInOpenApi: ContractOperation[]
missingInRoutes: ContractOperation[]
ignoredOperations: ContractOperation[]
}
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const
function toPosix(input: string): string {
return input.split(path.sep).join('/')
}
function normalizeSegment(segment: string): string {
if (segment.startsWith('[[...') && segment.endsWith(']]')) {
return `{${segment.slice(5, -2)}}`
}
if (segment.startsWith('[...') && segment.endsWith(']')) {
return `{${segment.slice(4, -1)}}`
}
if (segment.startsWith('[') && segment.endsWith(']')) {
return `{${segment.slice(1, -1)}}`
}
return segment
}
export function routeFileToApiPath(routeFile: string, apiRoot = 'src/app/api'): string {
const normalizedFile = toPosix(routeFile)
const normalizedRoot = toPosix(apiRoot)
const routeWithoutExt = normalizedFile.replace(/\/route\.tsx?$/, '')
const relative = routeWithoutExt.startsWith(normalizedRoot)
? routeWithoutExt.slice(normalizedRoot.length)
: routeWithoutExt
const segments = relative
.split('/')
.filter(Boolean)
.map(normalizeSegment)
return `/api${segments.length ? `/${segments.join('/')}` : ''}`
}
export function extractHttpMethods(source: string): string[] {
const methods = new Set<string>()
for (const method of HTTP_METHODS) {
const constExport = new RegExp(`export\\s+const\\s+${method}\\s*=`, 'm')
const fnExport = new RegExp(`export\\s+(?:async\\s+)?function\\s+${method}\\s*\\(`, 'm')
if (constExport.test(source) || fnExport.test(source)) methods.add(method)
}
return Array.from(methods)
}
function walkRouteFiles(dir: string, found: string[] = []): string[] {
if (!fs.existsSync(dir)) return found
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
walkRouteFiles(fullPath, found)
} else if (entry.isFile() && /route\.tsx?$/.test(entry.name)) {
found.push(fullPath)
}
}
return found
}
export function collectRouteOperations(projectRoot: string): RouteOperation[] {
const apiRoot = path.join(projectRoot, 'src', 'app', 'api')
const routeFiles = walkRouteFiles(apiRoot)
const operations: RouteOperation[] = []
for (const file of routeFiles) {
const source = fs.readFileSync(file, 'utf8')
const methods = extractHttpMethods(source)
const apiPath = routeFileToApiPath(toPosix(path.relative(projectRoot, file)))
for (const method of methods) {
operations.push({ method, path: apiPath, sourceFile: file })
}
}
return operations
}
export function collectOpenApiOperations(openapi: any): ContractOperation[] {
const operations = new Set<ContractOperation>()
const paths = openapi?.paths ?? {}
for (const [rawPath, pathItem] of Object.entries(paths)) {
const normalizedPath = String(rawPath)
for (const method of Object.keys(pathItem as Record<string, unknown>)) {
const upper = method.toUpperCase()
if ((HTTP_METHODS as readonly string[]).includes(upper)) {
operations.add(`${upper} ${normalizedPath}`)
}
}
}
return Array.from(operations).sort()
}
function toContractOperation(method: string, apiPath: string): ContractOperation {
return `${method.toUpperCase()} ${apiPath}`
}
function normalizeOperation(operation: string): ContractOperation {
const [method = '', ...pathParts] = operation.trim().split(' ')
const normalizedMethod = method.toUpperCase()
const normalizedPath = pathParts.join(' ').trim()
return `${normalizedMethod} ${normalizedPath}` as ContractOperation
}
export function compareApiContractParity(params: {
routeOperations: RouteOperation[]
openapiOperations: ContractOperation[]
ignore?: string[]
}): ParityReport {
const ignored = new Set((params.ignore ?? []).map((x) => normalizeOperation(x)))
const routeOperations = Array.from(new Set(params.routeOperations.map((op) => toContractOperation(op.method, op.path)))).sort()
const openapiOperations = Array.from(new Set(params.openapiOperations.map((op) => normalizeOperation(op)))).sort()
const routeSet = new Set(routeOperations)
const openapiSet = new Set(openapiOperations)
const ignoredOperations: ContractOperation[] = []
const missingInOpenApi: ContractOperation[] = []
for (const op of routeOperations) {
if (ignored.has(op)) {
ignoredOperations.push(op)
continue
}
if (!openapiSet.has(op)) missingInOpenApi.push(op)
}
const missingInRoutes: ContractOperation[] = []
for (const op of openapiOperations) {
if (ignored.has(op)) {
if (!ignoredOperations.includes(op as ContractOperation)) ignoredOperations.push(op as ContractOperation)
continue
}
if (!routeSet.has(op)) missingInRoutes.push(op as ContractOperation)
}
return {
routeOperations: routeOperations as ContractOperation[],
openapiOperations: openapiOperations as ContractOperation[],
missingInOpenApi,
missingInRoutes,
ignoredOperations: ignoredOperations.sort(),
}
}
export function loadOpenApiFile(projectRoot: string, openapiPath = 'openapi.json'): any {
const filePath = path.join(projectRoot, openapiPath)
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
export function runApiContractParityCheck(params: {
projectRoot: string
openapiPath?: string
ignore?: string[]
}): ParityReport {
const projectRoot = path.resolve(params.projectRoot)
const openapi = loadOpenApiFile(projectRoot, params.openapiPath)
const routeOperations = collectRouteOperations(projectRoot)
const openapiOperations = collectOpenApiOperations(openapi)
return compareApiContractParity({ routeOperations, openapiOperations, ignore: params.ignore })
}