Files
olp/bin/olp.mjs
T
e69e908dae feat+test+docs: D64-D67 — olp Node CLI + doctor framework + per-provider doctor checks + ADR 0002 Amendment 7 (#42)
* feat+test+docs: D64+D65+D66+D67 — olp Node CLI + olp doctor framework + per-provider doctor checks + ADR 0002 Amendment 7

Second substantive Phase 4 implementation. 4 D-days bundled per Iron Rule
11 IDR — CLI dispatches to doctor; doctor calls into provider plugins via
the new contract method; ADR amendment authorizes the contract change.
Single PR is the minimum reviewable unit for "does plugin amendment +
plugin impl + doctor consumer line up?"

## D64 — bin/olp.mjs Node CLI scaffold

Operator surface for OLP. Node not bash (per ADR 0010 § Notes — bash with
python3 JSON parsing is a known fragile point; OLP standardizes on Node).

Subcommands:
- status / health / usage / models / cache — HTTP calls to existing endpoints
- providers — local: cross-references models-registry.json + config.json
- chain show [<model>] — local: prints routing.chains from ~/.olp/config.json
- logs [N] [--level X] — reads ~/.olp/logs/audit.ndjson via audit-query
- restart — launchctl (macOS) / systemctl --user (Linux), best-effort
- keys ... — delegates to bin/olp-keys.mjs runCli (no logic duplicated)
- doctor [--check <id|category>] [--json] — D65 framework
- help / --help / -h

Token / URL resolution:
- OLP_PROXY_URL env → OLP_PORT env → http://127.0.0.1:4567 (D60 default)
- OLP_API_KEY env → OLP_OWNER_TOKEN env (filesystem manifest tokens are
  one-way SHA-256 per ADR 0007 § 5 — not recoverable; CLI surfaces
  helpful 401 message pointing at olp-keys keygen)

Output:
- Default: human-readable ANSI-colored text (no chalk dep, auto-suppressed
  under --json)
- --json: raw JSON for scripting
- Exit codes: 0=ok / 1=usage / 2=network|HTTP / 3=auth

No npm deps. Built-ins only.

Installed via package.json bin entry so `npx olp <subcommand>` works.

## D65 — lib/doctor.mjs framework

Ports OCP scripts/doctor.mjs (the bedrock of AI-driven self-repair per the
OCP audit's #2 inheritance candidate). Machine-readable next_action so a
Claude Code / Cursor / etc. agent can self-repair OLP.

Check shape:
  { id, category, async run(): { status: 'ok'|'fail'|'warn', message, evidence? } }

Built-in checks: server.running, server.version, config.exists,
config.providers_enabled, config.chains_configured, auth.owner_key_exists,
system.node_version. Per-provider checks collected dynamically via
provider.doctorChecks() per D67.

--json output:
  { checks: [...], kind: noop|update|fix_oauth|fix_config|fresh_install|
    fix_server|fix_provider, next_action: { ai_executable: [],
    human_required: [], verify: 'olp doctor' }, summary }

--check <id-or-category> for tight repair-loop fast paths.

## D66 — Per-provider doctorChecks() implementations

Each shipped plugin contributes its own checks (lives in plugin file so the
provider's maintainer updates it naturally):

- anthropic.mjs: cli_available (claude --version) + oauth_token_present
  (~/.claude/.credentials.json OR ANTHROPIC_OAUTH_TOKEN env)
- codex.mjs: cli_available (codex --version) + auth_present
  (~/.codex/config.json)
- mistral.mjs: cli_available (vibe --version) + api_key_present
  (MISTRAL_API_KEY env OR ~/.vibe/.env)

Each fail returns evidence.fix_commands (for ai_executable[]) or
evidence.human_required (e.g., 'run: claude auth login').

## D67 — ADR 0002 Amendment 7

New amendment adds OPTIONAL provider.doctorChecks(): DoctorCheck[] to the
Provider contract. Backwards compatible — plugins without doctorChecks()
contribute no provider checks (default behavior). Validator extended in
lib/providers/base.mjs validateProvider.

## Test count

636 → 658 (+22 tests across Suites 32, 33).

- Suite 32 — bin/olp.mjs CLI scaffold (10 tests): parseArgv, USAGE,
  unknown-subcommand, providers local + --json, chain show, status via
  ephemeral server with owner token, ECONNREFUSED → exit 2,
  resolveBearerToken precedence
- Suite 33 — lib/doctor.mjs framework (12 tests): all kind branches
  (noop / fresh_install / fix_server / fix_oauth / fix_provider),
  collectProviderChecks reads doctorChecks(), throwing plugin captured,
  --check filter, built-in checks against temp HOME, anthropic plugin
  probe set, resolveProxyUrl precedence, deriveKind/deriveNextAction units

## Scope discipline

server.mjs UNTOUCHED. All HTTP subcommands consume EXISTING endpoints.
No new endpoints. No /health.anonymousKey. No olp-connect. No Telegram
plugin. No IDE docs bundle. No CHANGELOG / package.json version bump
(Phase 4 close handles versioning; only package.json bin entries updated).

## Known limitations (flagged for reviewer)

- olp restart not unit-tested (would require mocking child_process.spawn
  in invasive way; manual smoke-test only at this D-day)
- olp logs --level filtering matches optional level field if present in
  audit-event objects; appendAuditEvent already populates it where
  meaningful — no schema change needed in this bundle
- olp usage panel shape inferred from lib/audit-query.mjs exports; if
  /v0/management/dashboard-data wire shape differs in subtle ways,
  formatter degrades to '?' but --json always works

## Authority

- ADR 0010 § Phase 4 D-day plan D64-D67 line
- ADR 0002 Amendment 7 (this commit — new amendment)
- OCP ocp bash wrapper /Users/taodeng/ocp/ocp (subcommand reference,
  translated to Node)
- OCP scripts/doctor.mjs /Users/taodeng/ocp/scripts/doctor.mjs
  (framework reference)
- 2026-05-26 brainstorm (Top 5 OCP inheritance candidates, item 2:
  olp doctor machine-readable next_action)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: D64-D67 reviewer P2 fold-in — shell-quote ai_executable paths + launchctl kickstart caveat

Reviewer APPROVE — 0 P0/P1, 2 P2 hardening notes folded in.

P2-1 — Shell-quote interpolated paths in fix_commands.

lib/doctor.mjs config.exists fix_commands previously interpolated
${olpHome} / ${configPath} unquoted into the printf template. A
malicious OLP_HOME env value containing shell metacharacters could
inject commands into the suggested-fix string an AI agent (or human)
pastes back into a terminal.

Added _shellQuote(s) helper (POSIX single-quote-wrap with escape for
embedded single quotes per POSIX shell rules). Risk surface is narrow
at family scale (operator local env, single-user proxy), but hardening
cost is one helper.

P2-2 — Document launchctl kickstart -k env-stale pitfall.

cmdRestart header now carries an explicit caveat that `launchctl
kickstart -k` does NOT re-read the plist EnvironmentVariables block —
launchd uses cached env from the most recent bootstrap. This is a
known OCP institutional lesson (PIT INDEX in cc-rules MEMORY.md). The
comment documents the bootout/bootstrap dance for env reloads and
notes that the Phase 4 installer (post-D73) will expose `olp restart
--full` for the safer reload path.

658/658 tests still pass; the _shellQuote change is invisible to
existing tests because the test fixtures use safe paths.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:48:20 +10:00

735 lines
30 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* bin/olp.mjs — OLP operator CLI (Phase 4 / D64)
*
* Authority: ADR 0010 § Phase 4 D64-D67. Ports OCP's `ocp` bash wrapper
* (https://github.com/dtzp555-max/ocp /ocp) to Node.js, eliminating the
* python3 JSON-parsing fragility called out in the ADR.
*
* Subcommands:
* status GET /v0/management/status (owner-only)
* health GET /health
* usage GET /v0/management/dashboard-data (owner-only)
* models GET /v1/models
* cache GET /cache/stats (owner-only)
* providers local: models-registry + config providers.enabled
* chain show [<model>] local: ~/.olp/config.json routing.chains
* logs [N] [--level X] local: read ~/.olp/logs/audit.ndjson via audit-query
* restart launchctl (macOS) / systemctl --user (Linux)
* doctor [--check X] run lib/doctor.mjs runDoctor + format
* keys ... delegate to bin/olp-keys.mjs
* help | --help usage
*
* Global flags:
* --json emit raw JSON (silences human-readable output)
* --proxy-url=<url> override resolved proxy URL
* --olp-home=<path> override ~/.olp
*
* URL resolution:
* OLP_PROXY_URL env (full URL) → http://127.0.0.1:${OLP_PORT || 4567}
*
* Auth (Bearer token) resolution:
* 1. OLP_API_KEY env
* 2. OLP_OWNER_TOKEN env (synthetic env-owner per ADR 0007 § 9.4)
* 3. Most recently used active owner-tier key from listKeys() ← plaintext NOT recoverable from disk
*
* Note: the third option only works during the same session in which `olp-keys
* keygen --owner` was run if the operator captured the token + set OLP_OWNER_TOKEN.
* listKeys() returns manifests; manifest.token_hash is one-way. The CLI therefore
* reports "no owner token available" + remediation instructions if env vars are
* absent — it does NOT try to crack the hash.
*
* Exit codes:
* 0 = success
* 1 = bad usage / unknown subcommand
* 2 = network or HTTP error (4xx/5xx)
* 3 = auth missing / forbidden
*/
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { URL } from 'node:url';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { spawn as spawnProc } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';
import { runDoctor, resolveProxyUrl, resolveOlpHome } from '../lib/doctor.mjs';
import { listKeys } from '../lib/keys.mjs';
import { readAuditWindow } from '../lib/audit-query.mjs';
import modelsRegistry from '../models-registry.json' with { type: 'json' };
import { runCli as runKeysCli } from './olp-keys.mjs';
// ── ANSI helpers (no chalk dep) ───────────────────────────────────────────
const ANSI = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
function colorize(s, code, useColor) {
if (!useColor) return s;
return `${code}${s}${ANSI.reset}`;
}
function statusBadge(status, useColor) {
const map = {
ok: { txt: 'PASS', col: ANSI.green },
warn: { txt: 'WARN', col: ANSI.yellow },
fail: { txt: 'FAIL', col: ANSI.red },
};
const m = map[status] ?? { txt: String(status).toUpperCase(), col: ANSI.gray };
return colorize(`[${m.txt}]`, m.col, useColor);
}
// ── Arg parser (mirror bin/olp-keys.mjs shape) ────────────────────────────
export function parseArgv(argv) {
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq > 0) {
flags[a.slice(2, eq)] = a.slice(eq + 1);
} else {
const name = a.slice(2);
const next = argv[i + 1];
if (next !== undefined && !next.startsWith('--')) {
flags[name] = next;
i++;
} else {
flags[name] = true;
}
}
} else {
positional.push(a);
}
}
return { positional, flags };
}
// ── Output helpers (respect --json) ───────────────────────────────────────
function makeIO(opts) {
const out = opts.out ?? (s => process.stdout.write(s));
const err = opts.err ?? (s => process.stderr.write(s));
const wantJson = opts.json === true;
// When wantJson, the only stdout writer used is `emitJson`. `log` becomes a no-op
// (debug noise suppression per the bundle requirements). `errln` always writes
// to stderr.
const log = (...parts) => { if (!wantJson) out(parts.join(' ') + '\n'); };
const errln = (...parts) => err(parts.join(' ') + '\n');
const emitJson = (obj) => out(JSON.stringify(obj, null, 2) + '\n');
return { log, errln, emitJson, wantJson, useColor: !wantJson && (opts.useColor ?? true) };
}
// ── HTTP helper ───────────────────────────────────────────────────────────
async function httpFetch(url, { method = 'GET', headers = {}, timeoutMs = 15000 } = {}) {
return new Promise(resolve => {
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
let urlObj;
try { urlObj = new URL(url); }
catch (e) { return finish({ ok: false, error: `invalid url: ${e?.message ?? e}` }); }
const isHttps = urlObj.protocol === 'https:';
const reqFn = isHttps ? httpsRequest : httpRequest;
let req;
try {
req = reqFn(url, { method, headers, timeout: timeoutMs }, res => {
let data = '';
res.on('data', c => { data += c; });
res.on('end', () => finish({ ok: true, status: res.statusCode, body: data, headers: res.headers }));
});
} catch (e) {
return finish({ ok: false, error: String(e?.message ?? e) });
}
req.on('error', e => finish({ ok: false, error: String(e?.message ?? e), code: e?.code }));
req.on('timeout', () => {
try { req.destroy(new Error(`timeout after ${timeoutMs}ms`)); } catch { /* ignore */ }
});
req.end();
});
}
// ── Token resolution ──────────────────────────────────────────────────────
/**
* Resolve a Bearer token. Returns the plaintext token string or null.
* Precedence: OLP_API_KEY → OLP_OWNER_TOKEN → null (manifests are one-way hashed).
*/
export function resolveBearerToken() {
if (process.env.OLP_API_KEY) return process.env.OLP_API_KEY;
if (process.env.OLP_OWNER_TOKEN) return process.env.OLP_OWNER_TOKEN;
return null;
}
function authHeaders() {
const tok = resolveBearerToken();
return tok ? { Authorization: `Bearer ${tok}` } : {};
}
// ── HTTP-error → exit code mapping ────────────────────────────────────────
function httpErrorToExit(res, io) {
if (!res.ok) {
if (res.code === 'ECONNREFUSED' || (res.error && res.error.includes('ECONNREFUSED'))) {
io.errln(`Error: OLP server unreachable (${res.error}). Is it running?`);
io.errln(`Hint: run 'npx olp restart' (or 'npm start' for foreground) — see 'npx olp doctor' for the full diagnostic.`);
return 2;
}
io.errln(`Error: network error: ${res.error}`);
return 2;
}
if (res.status === 401) {
io.errln(`Error: 401 unauthorized — set OLP_API_KEY env (Bearer token) or OLP_OWNER_TOKEN.`);
io.errln(`Hint: 'npx olp-keys keygen --owner' creates a new owner-tier key (capture the plaintext token).`);
return 3;
}
if (res.status === 403) {
io.errln(`Error: 403 forbidden — current key is not owner-tier (this endpoint is owner-only).`);
return 3;
}
if (res.status >= 400) {
io.errln(`Error: HTTP ${res.status}: ${res.body.slice(0, 200)}`);
return 2;
}
return 0;
}
// ── Human-readable formatters ─────────────────────────────────────────────
function formatBytes(n) {
if (typeof n !== 'number' || !Number.isFinite(n)) return '?';
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`;
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)}M`;
return `${(n / 1024 / 1024 / 1024).toFixed(1)}G`;
}
function formatMs(ms) {
if (typeof ms !== 'number' || !Number.isFinite(ms)) return '?';
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600000) return `${Math.floor(ms / 60000)}m${Math.floor((ms % 60000) / 1000)}s`;
return `${Math.floor(ms / 3600000)}h${Math.floor((ms % 3600000) / 60000)}m`;
}
// ── Subcommand: status ────────────────────────────────────────────────────
async function cmdStatus(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v0/management/status`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize('OLP status', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
io.log(` version: ${body.version}`);
io.log(` uptime: ${body.uptime_human} (${formatMs(body.uptime_ms)})`);
io.log(` started: ${body.started_at}`);
io.log(` providers: ${body.providers?.enabled ?? '?'} enabled / ${body.providers?.available ?? '?'} available`);
if (body.providers?.status && typeof body.providers.status === 'object') {
for (const [name, s] of Object.entries(body.providers.status)) {
const okIcon = s?.ok ? colorize('ok', ANSI.green, io.useColor) : colorize('FAIL', ANSI.red, io.useColor);
io.log(` - ${name.padEnd(12)} ${okIcon} ${s?.error ? `(${s.error})` : ''}`);
}
}
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
if (body.stats?.cache) {
const c = body.stats.cache;
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.entries ?? '?'}`);
}
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
io.log(` recent errors: ${body.recent_errors.length}`);
for (const e of body.recent_errors.slice(0, 5)) {
io.log(` - [${e.at ?? '?'}] ${e.provider ?? '?'} ${e.path ?? '?'}${(e.message ?? '').slice(0, 80)}`);
}
}
return 0;
}
// ── Subcommand: health ────────────────────────────────────────────────────
async function cmdHealth(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/health`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize(`OLP /health → ${res.status}`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const [k, v] of Object.entries(body)) {
if (typeof v === 'object' && v !== null) {
io.log(` ${k}:`);
for (const [k2, v2] of Object.entries(v)) {
io.log(` ${k2}: ${typeof v2 === 'object' ? JSON.stringify(v2) : v2}`);
}
} else {
io.log(` ${k}: ${v}`);
}
}
return 0;
}
// ── Subcommand: usage ─────────────────────────────────────────────────────
async function cmdUsage(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v0/management/dashboard-data`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
const u24 = body.usage_24h ?? body.usage24h ?? body['24h'] ?? {};
if (typeof u24 === 'object' && Object.keys(u24).length > 0) {
io.log(` requests: ${u24.requests ?? '?'}`);
io.log(` cache hits: ${u24.cache_hits ?? '?'}`);
io.log(` fallbacks: ${u24.fallbacks ?? '?'}`);
} else {
io.log(' (no 24h usage data — server may not have processed any requests yet)');
}
if (Array.isArray(body.providers)) {
io.log('');
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const p of body.providers) {
io.log(` ${String(p.name ?? '?').padEnd(12)} ${p.percent_used != null ? `${p.percent_used}% used` : 'no quota api'}`);
}
}
if (Array.isArray(body.top_fallback_chains)) {
io.log('');
io.log(colorize('Top fallback chains', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const f of body.top_fallback_chains.slice(0, 10)) {
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
}
}
return 0;
}
// ── Subcommand: models ────────────────────────────────────────────────────
async function cmdModels(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/v1/models`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize(`OLP models (${(body.data ?? []).length})`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const m of body.data ?? []) {
io.log(` ${m.id.padEnd(35)} ${colorize(`(${m.owned_by ?? '?'})`, ANSI.gray, io.useColor)}`);
}
return 0;
}
// ── Subcommand: cache ─────────────────────────────────────────────────────
async function cmdCache(flags, io) {
const url = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const res = await httpFetch(`${url}/cache/stats`, { headers: authHeaders() });
const ec = httpErrorToExit(res, io);
if (ec !== 0) return ec;
let body;
try { body = JSON.parse(res.body); }
catch { io.errln('Error: server returned non-JSON body'); return 2; }
if (io.wantJson) { io.emitJson(body); return 0; }
io.log(colorize('OLP cache', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
io.log(` entries: ${body.entries ?? '?'}`);
io.log(` hits: ${body.hits ?? 0}`);
io.log(` misses: ${body.misses ?? 0}`);
io.log(` evictions:${body.evictions ?? 0}`);
io.log(` bytes: ${formatBytes(body.bytes ?? 0)} (max ${formatBytes(body.maxBytes ?? 0)})`);
return 0;
}
// ── Subcommand: providers (local) ─────────────────────────────────────────
function cmdProviders(flags, io) {
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const configPath = join(olpHome, 'config.json');
let enabled = {};
try {
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
enabled = cfg?.providers?.enabled ?? {};
} catch { /* fine — empty enabled map */ }
const providers = modelsRegistry?.providers ?? {};
const rows = [];
for (const [name, p] of Object.entries(providers)) {
rows.push({
name,
displayName: p?.displayName ?? name,
tier: p?.tier ?? '?',
modelCount: (p?.models ?? []).length,
enabled: enabled[name] === true,
candidate: p?.candidate === true,
});
}
if (io.wantJson) {
io.emitJson({ providers: rows, config_path: configPath });
return 0;
}
io.log(colorize(`OLP providers (${rows.length} in registry)`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const r of rows) {
const enabledTxt = r.enabled
? colorize('enabled ', ANSI.green, io.useColor)
: colorize('disabled', ANSI.gray, io.useColor);
const candTxt = r.candidate ? colorize('(candidate)', ANSI.yellow, io.useColor) : '';
io.log(` ${r.name.padEnd(10)} ${enabledTxt} tier ${r.tier} models ${String(r.modelCount).padStart(2)} ${candTxt}`);
}
io.log('');
io.log(colorize(`config: ${configPath}`, ANSI.dim, io.useColor));
return 0;
}
// ── Subcommand: chain show ────────────────────────────────────────────────
function cmdChainShow(positional, flags, io) {
const target = positional[0] ?? null; // model name, or null = print all
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const configPath = join(olpHome, 'config.json');
let chains = {};
try {
const cfg = JSON.parse(readFileSync(configPath, 'utf8'));
chains = cfg?.routing?.chains ?? {};
} catch { /* empty */ }
if (io.wantJson) {
if (target) {
io.emitJson({ model: target, chain: chains[target] ?? null });
} else {
io.emitJson({ chains });
}
return 0;
}
io.log(colorize('OLP routing.chains', ANSI.bold, io.useColor));
io.log('─'.repeat(60));
if (Object.keys(chains).length === 0) {
io.log(` (no chains configured in ${configPath})`);
return 0;
}
if (target) {
const chain = chains[target];
if (!chain) {
io.errln(`Error: model "${target}" not in routing.chains (configured: ${Object.keys(chains).join(', ')}).`);
return 1;
}
io.log(` ${target}:`);
for (const hop of chain) {
io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
}
} else {
for (const [model, chain] of Object.entries(chains)) {
io.log(` ${model}:`);
for (const hop of chain) {
io.log(` → ${typeof hop === 'string' ? hop : JSON.stringify(hop)}`);
}
}
}
return 0;
}
// ── Subcommand: logs ──────────────────────────────────────────────────────
async function cmdLogs(positional, flags, io) {
const n = positional[0] ? parseInt(positional[0], 10) : 20;
if (!Number.isFinite(n) || n <= 0) {
io.errln(`Error: invalid log count "${positional[0]}"`);
return 1;
}
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
// readAuditWindow is a generator over [startMs, endMs). Default window = last 24h.
const windowMs = flags['window-ms'] ? parseInt(flags['window-ms'], 10) : 24 * 3600 * 1000;
const endMs = Date.now();
const startMs = endMs - windowMs;
let events = [];
try {
for (const ev of readAuditWindow({ startMs, endMs, olpHome })) {
events.push(ev);
}
} catch (e) {
io.errln(`Error: readAuditWindow failed: ${e?.message ?? e}`);
return 2;
}
let filtered = events;
if (flags.level) {
filtered = filtered.filter(e => e.level === flags.level);
}
// Tail (audit events are already chronological per generator order).
filtered = filtered.slice(-n);
if (io.wantJson) {
io.emitJson({ events: filtered });
return 0;
}
io.log(colorize(`OLP logs (last ${filtered.length} of ${events.length}${flags.level ? `, level=${flags.level}` : ''})`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const e of filtered) {
// Audit event shape per lib/audit.mjs: { ts, event, ...data }. `level` is
// not always present in audit ndjson (it is in stderr-side logEvent).
const level = (e.level ?? 'info').toUpperCase();
const levelColor =
level === 'ERROR' ? ANSI.red
: level === 'WARN' ? ANSI.yellow
: ANSI.gray;
const summary = e.message ?? e.error ?? '';
io.log(` ${colorize(level.padEnd(5), levelColor, io.useColor)} ${e.ts ?? '?'} ${e.event ?? '?'} ${summary}`);
}
return 0;
}
// ── Subcommand: restart ───────────────────────────────────────────────────
async function cmdRestart(flags, io) {
// macOS: launchctl kickstart -k gui/$(id -u)/dev.olp.proxy
// Linux: systemctl --user restart olp-proxy
// Neither installed → fall through to a helpful error.
//
// CAVEAT (D64-D67 reviewer P2-2; known OCP institutional lesson per
// ~/.cc-rules/memory/auto/MEMORY.md PIT INDEX): `launchctl kickstart -k`
// does NOT re-read the plist's EnvironmentVariables block — launchd
// sticks to its cached env from the most recent bootstrap. If you edited
// ~/Library/LaunchAgents/dev.olp.proxy.plist's env, this subcommand will
// silently use stale values. Use `launchctl bootout gui/<uid>/dev.olp.proxy`
// followed by `launchctl bootstrap gui/<uid> ~/Library/LaunchAgents/dev.olp.proxy.plist`
// to force a clean env reload. The Phase 4 installer (planned post-D73)
// will expose `olp restart --full` for the bootout/bootstrap dance.
const platform = process.platform;
const uid = process.getuid?.() ?? null;
let cmd, args;
if (platform === 'darwin') {
if (uid == null) {
io.errln('Error: cannot resolve UID on this platform; cannot drive launchctl');
return 2;
}
cmd = 'launchctl';
args = ['kickstart', '-k', `gui/${uid}/dev.olp.proxy`];
} else if (platform === 'linux') {
cmd = 'systemctl';
args = ['--user', 'restart', 'olp-proxy'];
} else {
io.errln(`Error: platform "${platform}" not supported for 'olp restart'.`);
io.errln(`Hint: run 'npm start' (or whatever launches your OLP server) manually.`);
return 2;
}
// Spawn + wait for exit; bubble up any error.
const result = await new Promise(resolve => {
const child = spawnProc(cmd, args, { stdio: io.wantJson ? 'ignore' : 'inherit' });
child.on('error', e => resolve({ code: -1, error: e }));
child.on('exit', code => resolve({ code }));
});
if (result.code === 0) {
if (io.wantJson) {
io.emitJson({ ok: true, cmd, args });
} else {
io.log(colorize(`Restart issued (${cmd} ${args.join(' ')})`, ANSI.green, io.useColor));
}
return 0;
}
if (result.error?.code === 'ENOENT' || result.code === 127) {
io.errln(`Error: '${cmd}' not found on this system.`);
if (platform === 'darwin') {
io.errln(`Hint: no launchd service 'dev.olp.proxy' installed; run 'npm start' manually.`);
} else {
io.errln(`Hint: no systemd user unit 'olp-proxy' installed; run 'npm start' manually.`);
}
return 2;
}
io.errln(`Error: ${cmd} ${args.join(' ')} exited with code ${result.code}`);
return 2;
}
// ── Subcommand: doctor ────────────────────────────────────────────────────
async function cmdDoctor(flags, io) {
const olpHome = resolveOlpHome({ olpHome: flags['olp-home'] });
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
const result = await runDoctor({
olpHome,
proxyUrl,
checkFilter,
});
if (io.wantJson) {
io.emitJson(result);
return result.fail_count === 0 ? 0 : 2;
}
io.log(colorize(`OLP doctor — ${result.summary}`, ANSI.bold, io.useColor));
io.log('─'.repeat(60));
for (const c of result.checks) {
io.log(` ${statusBadge(c.status, io.useColor)} ${c.id.padEnd(36)} ${c.message}`);
}
io.log('');
io.log(` fail=${result.fail_count} warn=${result.warn_count} ok=${result.ok_count} kind=${result.kind}`);
if (result.next_action.ai_executable.length > 0) {
io.log('');
io.log(colorize('Next (AI-executable):', ANSI.cyan, io.useColor));
for (const cmd of result.next_action.ai_executable) io.log(` $ ${cmd}`);
}
if (result.next_action.human_required.length > 0) {
io.log('');
io.log(colorize('Next (human-required):', ANSI.yellow, io.useColor));
for (const step of result.next_action.human_required) io.log(` • ${step}`);
}
io.log('');
io.log(colorize(`verify: ${result.next_action.verify}`, ANSI.dim, io.useColor));
return result.fail_count === 0 ? 0 : 2;
}
// ── Subcommand: keys (delegate) ───────────────────────────────────────────
async function cmdKeys(rest, io) {
// Re-use bin/olp-keys.mjs's runCli. Its `out`/`err` writers receive raw strings
// (no \n needed since the underlying CLI emits them itself).
return await runKeysCli(rest, {
out: s => process.stdout.write(s),
err: s => process.stderr.write(s),
});
}
// ── Usage ──────────────────────────────────────────────────────────────────
const USAGE = `OLP operator CLI — Phase 4 (ADR 0010)
Usage:
olp <subcommand> [args] [--json] [--proxy-url=<url>] [--olp-home=<path>]
Subcommands:
status GET /v0/management/status (owner-only)
health GET /health
usage GET /v0/management/dashboard-data (owner-only)
models GET /v1/models
cache GET /cache/stats (owner-only)
providers list providers (registry + config providers.enabled)
chain show [<model>] print routing.chains from ~/.olp/config.json
logs [N] [--level X] last N audit events from ~/.olp/logs/audit.ndjson
restart launchctl (macOS) / systemctl --user (Linux)
doctor [--check X] run diagnostic checks (id, category, or prefix filter)
keys [args ...] delegate to bin/olp-keys.mjs
help print this message
Global flags:
--json emit raw JSON (silences human-readable formatting)
--proxy-url=<url> override resolved proxy URL
--olp-home=<path> override ~/.olp
Env:
OLP_PROXY_URL full URL (overrides OLP_PORT)
OLP_PORT port for default URL (default: 4567)
OLP_API_KEY Bearer token for the proxy
OLP_OWNER_TOKEN synthetic env-owner token (ADR 0007 § 9.4)
OLP_HOME ~/.olp override
Exit codes:
0 success
1 bad usage / unknown subcommand
2 network or HTTP error (4xx/5xx)
3 auth missing / forbidden`;
// ── runCli (testable entry) ───────────────────────────────────────────────
/**
* Run the CLI with explicit argv + IO streams. Returns the exit code (no
* process.exit). Exported for tests.
*
* @param {string[]} argv args after the script name
* @param {object} [opts]
* @param {(s: string) => void} [opts.out]
* @param {(s: string) => void} [opts.err]
* @param {boolean} [opts.useColor] default true; tests pass false for deterministic strings
* @returns {Promise<number>}
*/
export async function runCli(argv, opts = {}) {
if (argv.length === 0 || argv.includes('--help') || argv.includes('-h') || argv[0] === 'help') {
const io = makeIO({ ...opts, json: false });
io.log(USAGE);
return argv.length === 0 ? 1 : 0;
}
const [subcommand, ...rest] = argv;
// `olp keys ...` passes the remaining argv straight to bin/olp-keys.mjs.
if (subcommand === 'keys') {
const io = makeIO({ ...opts, json: false });
return await cmdKeys(rest, io);
}
const { positional, flags } = parseArgv(rest);
const json = flags.json === true;
const io = makeIO({ ...opts, json });
switch (subcommand) {
case 'status': return await cmdStatus(flags, io);
case 'health': return await cmdHealth(flags, io);
case 'usage': return await cmdUsage(flags, io);
case 'models': return await cmdModels(flags, io);
case 'cache': return await cmdCache(flags, io);
case 'providers': return cmdProviders(flags, io);
case 'chain': {
// `olp chain show [model]`
const sub = positional[0];
if (sub !== 'show') {
io.errln(`Error: unknown 'chain' subcommand "${sub}". Try: olp chain show [model]`);
return 1;
}
return cmdChainShow(positional.slice(1), flags, io);
}
case 'logs': return await cmdLogs(positional, flags, io);
case 'restart': return await cmdRestart(flags, io);
case 'doctor': return await cmdDoctor(flags, io);
default:
io.errln(`Error: unknown subcommand "${subcommand}".`);
io.errln(USAGE);
return 1;
}
}
// ── Main guard ────────────────────────────────────────────────────────────
function _isMain() {
if (!process.argv[1]) return false;
try {
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]);
} catch { return false; }
}
if (_isMain()) {
runCli(process.argv.slice(2))
.then(code => process.exit(code))
.catch(e => {
process.stderr.write(`Fatal: ${e?.stack ?? e}\n`);
process.exit(2);
});
}