#!/usr/bin/env node /** * @file ccam — the Claude Code Agent Monitor command-line interface. * * A dependency-free umbrella CLI that brings the full dashboard feature * surface to the terminal. After the normal project setup (`npm run setup`, * which links this binary via `npm link`), every command is available * directly from any shell as `ccam `, or interactively at the * `ccam ›` prompt via `ccam repl`. * * Server status · start · repl (interactive shell) * Monitoring health · stats · kanban · tail * Data browsing sessions · session · agents · events * Insights analytics · workflows · runs · cost * Alerting alerts · alerts ack · alerts ack-all · rules * Webhooks webhooks · webhooks test * Pricing pricing · pricing set/delete/reset * Import import rescan · import path · import-data * Administration doctor · info · export · cleanup · reinstall-hooks · * update-check · clear-data --yes · open · version * * The REPL (`ccam repl`, aliases `shell` / `i`) runs each entered line as a * short-lived child `ccam` process, so its behavior is identical to the * one-shot CLI and no single command — a non-zero exit, a blocking `tail`, an * offline refusal — can take the shell down with it. It adds tab-completion, * persisted arrow-key history, and a live server-status prompt. * * Port resolution mirrors the hook handler: an explicit * CLAUDE_DASHBOARD_PORT / DASHBOARD_PORT env var wins, otherwise the live * server is discovered from ~/.claude/.agent-dashboard.json (written by every * running dashboard, PID-liveness-checked on read), falling back to 4820. * * Read commands are always safe. Mutating commands are explicit user actions * (ack, cleanup, pricing set, import) and the one destructive command — * `clear-data` — additionally requires the --yes flag before it will run. * * @author Nguyễn Ngọc Trí Vĩ */ const path = require("node:path"); const fs = require("node:fs"); const { spawn } = require("node:child_process"); // Resolve the repo root relative to this script's REAL location so the CLI // works both from a checkout (./bin/ccam.js) and through the global symlink // `npm link` creates (which points back into the checkout). const REPO_ROOT = path.resolve(path.dirname(fs.realpathSync(__filename)), ".."); // ── Presentation layer ────────────────────────────────────────────────────── // Styling follows the informal CLI conventions: colors are enabled on a TTY, // disabled when output is piped or redirected, force-disabled by the NO_COLOR // env var (https://no-color.org) or a --no-color flag anywhere on the command // line, and force-enabled by FORCE_COLOR / CCAM_COLOR=1 (useful for `watch` // or CI logs that render ANSI). Every helper degrades to plain text, so piped // output stays grep/script-friendly byte-for-byte. const useColor = (() => { if (process.env.NO_COLOR || process.argv.includes("--no-color")) return false; const force = process.env.FORCE_COLOR; if (force != null && force !== "" && force !== "0" && force !== "false") return true; if (process.env.CCAM_COLOR === "1") return true; return Boolean(process.stdout.isTTY); })(); /** Build a style function from SGR open/close codes (close restores state so * styles nest — e.g. bold inside a colored string). */ const sgr = (open, close) => (s) => (useColor ? `\x1b[${open}m${s}\x1b[${close}m` : String(s)); const c = { bold: sgr(1, 22), dim: sgr(2, 22), italic: sgr(3, 23), underline: sgr(4, 24), red: sgr(31, 39), green: sgr(32, 39), yellow: sgr(33, 39), blue: sgr(34, 39), magenta: sgr(35, 39), cyan: sgr(36, 39), gray: sgr(90, 39), }; /** Per-status icon + color, shared by every table, lane, and detail view. */ const STATUS_THEME = { active: { icon: "●", paint: c.green }, working: { icon: "◐", paint: c.green }, waiting: { icon: "○", paint: c.yellow }, completed: { icon: "✔", paint: c.gray }, error: { icon: "✖", paint: c.red }, abandoned: { icon: "◦", paint: c.gray }, connected: { icon: "●", paint: c.green }, idle: { icon: "·", paint: c.gray }, running: { icon: "●", paint: c.green }, }; function colorStatus(s) { const t = STATUS_THEME[s]; return t ? t.paint(`${t.icon} ${s}`) : s || "-"; } /** Hook/event types get stable colors so the feed is scannable at a glance. */ const EVENT_COLOR = { SessionStart: c.green, SessionEnd: c.gray, Stop: c.magenta, SubagentStop: c.magenta, PreToolUse: c.cyan, PostToolUse: c.blue, UserPromptSubmit: c.yellow, Notification: c.yellow, PreCompact: c.gray, }; const paintEvent = (t) => (EVENT_COLOR[t] || c.cyan)(t); /** Section heading: a colored sidebar glyph + bold title + dim subtitle. */ function heading(title, sub) { console.log(c.cyan("▍") + c.bold(title) + (sub ? c.dim(` — ${sub}`) : "")); } /** Aligned key/value line used by detail views. */ function kvLine(key, value, keyWidth = 9) { console.log(` ${c.dim(String(key).padEnd(keyWidth))} ${value}`); } /** Horizontal bar for inline charts: value scaled against max. */ function bar(value, max, width = 16, paint = c.cyan) { const v = Number(value) || 0; const m = Math.max(Number(max) || 0, 1); // Any non-zero value renders at least one block so small counts stay visible // next to a dominant maximum. let filled = Math.min(width, Math.round((v / m) * width)); if (v > 0 && filled === 0) filled = 1; return paint("█".repeat(Math.max(0, filled))) + c.dim("░".repeat(Math.max(0, width - filled))); } /** Usable terminal width, with a sane default when not a TTY (pipes, tests). */ function termWidth() { const w = process.stdout.columns; return Number.isFinite(w) && w > 40 ? w : 120; } /** * Resolve the dashboard base URL. Env override first (matches the hook * handler's contract), then the live-server discovery file, then the default * port. The discovery module is best-effort and never throws. */ function baseUrl() { const envPort = process.env.CLAUDE_DASHBOARD_PORT || process.env.DASHBOARD_PORT; if (envPort) return `http://127.0.0.1:${envPort}`; try { const { resolveDashboardPort } = require( path.join(REPO_ROOT, "server", "lib", "server-info.js") ); const port = resolveDashboardPort(); if (port) return `http://127.0.0.1:${port}`; } catch { /* discovery unavailable — fall through to the default */ } return "http://127.0.0.1:4820"; } /** Thrown when the dashboard server does not answer — the dispatcher decides * whether the active command has an offline fallback or must abort. */ class ServerDownError extends Error {} /** Perform an API request; throws ServerDownError when the server is down. */ async function api(method, pathname, body, options = {}) { const url = `${baseUrl()}${pathname}`; let res; try { res = await fetch(url, { method, headers: body ? { "Content-Type": "application/json" } : undefined, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30_000), }); } catch { throw new ServerDownError(); } let data = null; try { data = await res.json(); } catch { /* non-JSON body */ } if (!res.ok) { if (options.allowError) return { status: res.status, data }; const msg = data?.error?.message || `HTTP ${res.status}`; console.error(c.red(`✖ ${method} ${pathname} → ${msg}`)); process.exit(1); } return data; } const get = (p) => api("GET", p); const post = (p, b, options) => api("POST", p, b, options); /** * Print the standard "server is not running" indicator and exit 1. Every * command that needs the API funnels through this, so the guidance is * identical everywhere: the ccam CLI talks to the local dashboard server, * and `ccam start` (or npm run dev / npm start) brings one up. */ function serverDownExit(reason) { console.error(`${c.red("○ Dashboard server is NOT running")} ${c.dim(`(tried ${baseUrl()})`)}`); if (reason) console.error(c.dim(` No offline fallback for this command: ${reason}.`)); console.error(c.dim(" This command needs the server. Start it with one of:")); console.error( ` ${c.bold("ccam start")} ${c.dim("# production server in the background")}` ); console.error( ` ${c.bold("npm run dev")} ${c.dim("# dev mode (hot reload), foreground")}` ); console.error(` ${c.bold("npm start")} ${c.dim("# production mode, foreground")}`); process.exit(1); } /** True when the dashboard answers /api/health at the resolved URL. */ async function serverIsUp() { try { const res = await fetch(`${baseUrl()}/api/health`, { signal: AbortSignal.timeout(2_500) }); return res.ok; } catch { return false; } } /** Minimal flag parser: --key value / --key. Positionals returned in order. */ function parseArgs(argv) { const flags = {}; const positional = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { const key = a.slice(2); const next = argv[i + 1]; if (next != null && !next.startsWith("--")) { flags[key] = next; i++; } else { flags[key] = true; } } else { positional.push(a); } } return { flags, positional }; } const stripAnsi = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*m/g, ""); /** * Render rows as a box-drawn table: bold headers, dim borders, right-aligned * numeric columns, and width fitting — when the natural table is wider than * the terminal, the widest column is progressively narrowed and its cells * clipped with an ellipsis, so the frame never wraps mid-row. */ function table(headers, rows) { const cells = rows.map((r) => r.map((x) => String(x ?? ""))); // A column is numeric (→ right-aligned) when every non-empty cell looks // like a number, money amount, token count, or percentage. const numeric = headers.map( (_, i) => cells.length > 0 && cells.every((r) => { const v = stripAnsi(r[i]).trim(); return v === "" || v === "-" || /^\$?[\d,.]+[%kMB]?$/.test(v); }) ); const widths = headers.map((h, i) => Math.max(stripAnsi(h).length, ...cells.map((r) => stripAnsi(r[i]).length), 1) ); const frameWidth = () => widths.reduce((a, w) => a + w + 3, 1); while (frameWidth() > termWidth() && Math.max(...widths) > 8) { widths[widths.indexOf(Math.max(...widths))]--; } // Clipping drops per-cell styling for simplicity — a truncated cell is // plain text with a trailing ellipsis. const clip = (s, w) => { const plain = stripAnsi(s); return plain.length <= w ? s : `${plain.slice(0, Math.max(0, w - 1))}…`; }; const pad = (s, w, right) => { const v = clip(s, w); const gap = " ".repeat(Math.max(0, w - stripAnsi(v).length)); return right ? gap + v : v + gap; }; const rule = (l, m, r) => c.dim(l + widths.map((w) => "─".repeat(w + 2)).join(m) + r); const line = (cols, styleFn) => c.dim("│") + cols .map( (cell, i) => ` ${styleFn ? styleFn(pad(cell, widths[i], numeric[i])) : pad(cell, widths[i], numeric[i])} ` ) .join(c.dim("│")) + c.dim("│"); console.log(rule("╭", "┬", "╮")); console.log(line(headers, c.bold)); console.log(rule("├", "┼", "┤")); for (const r of cells) console.log(line(r)); if (!cells.length) { const inner = widths.reduce((a, w) => a + w + 2, 0) + widths.length - 1; console.log(c.dim("│") + c.dim(pad(" (no rows)", inner)) + c.dim("│")); } console.log(rule("╰", "┴", "╯")); } function fmtDuration(startIso, endIso) { if (!startIso) return "-"; const ms = (endIso ? new Date(endIso) : new Date()) - new Date(startIso); if (!Number.isFinite(ms) || ms < 0) return "-"; const m = Math.floor(ms / 60000); if (m < 1) return `${Math.floor(ms / 1000)}s`; if (m < 60) return `${m}m`; return `${Math.floor(m / 60)}h${m % 60}m`; } const fmtTime = (iso) => (iso ? String(iso).replace("T", " ").slice(0, 19) : "-"); /** Compact relative timestamp ("4m ago") for freshness-at-a-glance columns. */ function fmtAgo(iso) { if (!iso) return "-"; const ms = Date.now() - new Date(iso).getTime(); if (!Number.isFinite(ms)) return "-"; if (ms < 0) return "now"; const s = Math.floor(ms / 1000); if (s < 60) return `${s}s ago`; const m = Math.floor(s / 60); if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 48) return `${h}h ago`; return `${Math.floor(h / 24)}d ago`; } const fmtModel = (m) => (m ? m.replace(/^claude-/, "").slice(0, 22) : "-"); const fmtCost = (n) => `$${Number(n ?? 0).toFixed(4)}`; const fmtTokens = (n) => { const v = Number(n ?? 0); if (v >= 1e9) return `${(v / 1e9).toFixed(1)}B`; if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`; if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`; return String(v); }; // ── Offline mode ──────────────────────────────────────────────────────────── // When the server is down, read-only commands fall back to reading the // SQLite database directly (readonly, second reader is safe under WAL). // Commands that need server-side logic (cost math, analytics aggregation, // live capture, mutations with broadcasts) refuse instead — running them // against the raw DB would produce unreliable or divergent results. /** DB path resolution mirrors server/db.js: env override, then data/. */ function dbPath() { return process.env.DASHBOARD_DB_PATH || path.join(REPO_ROOT, "data", "dashboard.db"); } /** * Open the dashboard database for reading. Tries better-sqlite3 from the * repo's node_modules first, then Node's built-in node:sqlite (Node 22+). * Never creates a database file (existence is checked first), and the CLI * only ever issues SELECTs through the returned handle. The connection is * deliberately NOT opened with SQLite's readonly flag: a strict readonly * connection cannot attach a live WAL's shared-memory index and would * silently read the pre-WAL (stale/empty) state when another process has the * database open — a normal connection under WAL reads consistently instead. */ function openDbReadonly() { const file = dbPath(); if (!fs.existsSync(file)) return null; try { const Database = require(path.join(REPO_ROOT, "node_modules", "better-sqlite3")); const db = new Database(file, { fileMustExist: true }); return { all: (sql, ...p) => db.prepare(sql).all(...p) }; } catch { /* fall through to node:sqlite */ } try { const { DatabaseSync } = require("node:sqlite"); const db = new DatabaseSync(file); return { all: (sql, ...p) => db.prepare(sql).all(...p) }; } catch { return null; } } /** One-time banner explaining that results come straight from the DB file. */ function offlineBanner() { console.log( `${c.yellow("⚠ Offline mode")} ${c.dim(`— server not running; reading ${dbPath()} directly.`)}` ); console.log( c.dim(" Data is as of the last capture — live capture and full features need the server: ") + c.bold("ccam start") ); console.log(); } /** * Display-side staleness correction. While the server is down, its * dead-session liveness reap is not running, so sessions that were quit * after the server stopped still sit in the DB as active/waiting. Rather * than print those stale statuses, run the SAME process-liveness probe the * server's watchdog uses (server/lib/session-liveness.js) and correct the * DISPLAYED status of any active session whose cwd has no running `claude` * process — the database itself is never modified (that stays the server's * job). Returns the number of corrected sessions, the set of their ids (so * agent rows can be corrected consistently), and whether the probe could * answer at all (it can't on Windows or inside containers — in that case a * staleness caveat is printed instead). */ function livenessCorrect(sessions) { let probe; try { probe = require(path.join(REPO_ROOT, "server", "lib", "session-liveness.js")).probeLiveCwds(); } catch { probe = { available: false }; } const hadActive = sessions.some((s) => s.status === "active"); if (!probe.available) return { available: false, hadActive, corrected: 0, deadIds: new Set() }; const deadIds = new Set(); for (const s of sessions) { if (s.status !== "active" || !s.cwd) continue; let resolved; try { resolved = path.resolve(s.cwd); } catch { continue; } if (!probe.cwds.has(resolved)) { s.status = "completed"; s.awaiting_input_since = null; deadIds.add(s.id); } } return { available: true, corrected: deadIds.size, deadIds }; } /** Correct agent rows belonging to sessions the probe found dead. */ function livenessCorrectAgents(agents, deadIds) { let n = 0; for (const a of agents) { if (deadIds.has(a.session_id) && a.status !== "completed" && a.status !== "error") { a.status = "completed"; a.awaiting_input_since = null; n++; } } return n; } /** Footnote for corrected output / caveat when the probe cannot answer. */ function livenessNote(result) { if (!result.available) { if (!result.hadActive) return; // nothing that could be stale was shown console.log( c.dim( "※ Statuses are as stored: sessions that ended while the server was down may still show active/waiting (liveness probe unavailable on this platform)." ) ); } else if (result.corrected > 0) { console.log( c.dim( `※ ${result.corrected} session(s) displayed as completed by the process-liveness probe — no running claude process owns them. The database is only updated once the server runs again.` ) ); } } /** Offline data providers shaped exactly like their API counterparts. */ const offlineData = { sessions(db, flags) { const conds = []; const params = []; if (flags.status) { conds.push("s.status = ?"); params.push(flags.status); } if (flags.q) { conds.push("(s.id LIKE ? OR s.name LIKE ? OR s.cwd LIKE ?)"); const like = `%${flags.q}%`; params.push(like, like, like); } const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; const limit = Number(flags.limit || 20); const rows = db.all( `SELECT s.*, (SELECT COUNT(*) FROM agents a WHERE a.session_id = s.id) AS agent_count FROM sessions s ${where} ORDER BY s.updated_at DESC LIMIT ?`, ...params, limit ); const total = db.all(`SELECT COUNT(*) AS n FROM sessions s ${where}`, ...params)[0].n; return { sessions: rows, total }; }, agents(db, flags) { const conds = []; const params = []; if (flags.status) { conds.push("status = ?"); params.push(flags.status); } if (flags.session) { conds.push("session_id = ?"); params.push(flags.session); } const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; return { agents: db.all( `SELECT * FROM agents ${where} ORDER BY started_at DESC LIMIT ?`, ...params, Number(flags.limit || 20) ), }; }, events(db, flags) { const conds = []; const params = []; if (flags.session) { conds.push("session_id = ?"); params.push(flags.session); } const where = conds.length ? `WHERE ${conds.join(" AND ")}` : ""; return { events: db.all( `SELECT * FROM events ${where} ORDER BY created_at DESC LIMIT ?`, ...params, Number(flags.limit || 20) ), }; }, stats(db) { const one = (sql, ...p) => db.all(sql, ...p)[0].n; const dist = {}; for (const r of db.all("SELECT status, COUNT(*) AS n FROM sessions GROUP BY status")) { dist[r.status] = r.n; } const midnight = new Date(); midnight.setHours(0, 0, 0, 0); return { total_sessions: one("SELECT COUNT(*) AS n FROM sessions"), active_sessions: one("SELECT COUNT(*) AS n FROM sessions WHERE status = 'active'"), total_agents: one("SELECT COUNT(*) AS n FROM agents"), active_agents: one("SELECT COUNT(*) AS n FROM agents WHERE status = 'working'"), total_events: one("SELECT COUNT(*) AS n FROM events"), events_today: one( "SELECT COUNT(*) AS n FROM events WHERE created_at >= ?", midnight.toISOString() ), ws_connections: 0, sessions_by_status: dist, }; }, }; // ── Monitoring ────────────────────────────────────────────────────────────── async function cmdHealth() { const h = await get("/api/health"); const ver = h.version ? ` v${h.version}` : ""; console.log(`${c.green("●")} Dashboard ${c.bold("up")}${ver} at ${baseUrl()} (${h.timestamp})`); } function renderStats(s, source) { heading("Dashboard stats", source); table( ["Metric", "Value"], [ ["Total sessions", s.total_sessions], ["Active sessions", s.active_sessions], ["Total agents", s.total_agents], ["Active agents", s.active_agents], ["Total events", s.total_events], ["Events today", s.events_today], ["WS connections", s.ws_connections], ] ); const entries = Object.entries(s.sessions_by_status || {}); if (entries.length) { console.log(`\n${c.bold("Sessions by status")}`); const max = Math.max(...entries.map(([, v]) => v)); const w = Math.max(...entries.map(([k]) => k.length)) + 2; for (const [k, v] of entries) { const t = STATUS_THEME[k] || { icon: "·", paint: (x) => x }; console.log(` ${t.paint(`${t.icon} ${k}`.padEnd(w))} ${bar(v, max)} ${c.bold(String(v))}`); } } } async function cmdStats() { renderStats(await get("/api/stats"), baseUrl()); } /** Text rendering of the Kanban board: sessions and agents grouped by status * lanes, each lane a colored header rule with tree-branch item rows. */ function renderKanban(sess, ag) { const group = (items, key) => { const g = {}; for (const it of items) (g[it[key]] ||= []).push(it); return g; }; const lane = (col, items, render) => { const t = STATUS_THEME[col] || { icon: "·", paint: (x) => x }; const label = `${t.icon} ${col} (${items.length})`; const ruleLen = Math.max(2, 34 - stripAnsi(label).length); console.log(`\n ${t.paint(label)} ${c.dim("─".repeat(ruleLen))}`); const shown = items.slice(0, 10); shown.forEach((it, i) => { const branch = i === items.length - 1 ? "└─" : "├─"; render(it, c.dim(branch)); }); if (items.length > 10) console.log(c.dim(` └─ … ${items.length - 10} more`)); }; heading("Sessions"); const sg = group(sess.sessions || [], "status"); for (const col of ["active", "waiting", "completed", "error", "abandoned"]) { lane(col, sg[col] || [], (s, branch) => { console.log(` ${branch} ${c.dim(s.id.slice(0, 8))} ${(s.name || "").slice(0, 52)}`); }); } console.log(); heading("Agents"); const agr = group(ag.agents || [], "status"); for (const col of ["working", "waiting", "completed", "error"]) { lane(col, agr[col] || [], (a, branch) => { const tool = a.current_tool ? c.cyan(` [${a.current_tool}]`) : ""; console.log(` ${branch} ${c.dim(a.id.slice(0, 8))} ${(a.name || "").slice(0, 48)}${tool}`); }); } } async function cmdKanban() { const [sess, ag] = await Promise.all([ get("/api/sessions?limit=200"), get("/api/agents?limit=400"), ]); renderKanban(sess, ag); } /** * Live event feed (the Activity Feed, in the terminal). Polls /api/events on * a short interval and prints only rows newer than the last one seen — * dependency-free tailing without a WebSocket client. Ctrl+C to stop. */ async function cmdTail(flags) { const params = new URLSearchParams(); if (flags.session) params.set("session_id", flags.session); params.set("limit", "25"); let lastSeen = null; await get(`/api/health`); // fail fast (and route offline messaging) before announcing console.log(c.dim(`Tailing events from ${baseUrl()} — Ctrl+C to stop`)); for (;;) { const data = await get(`/api/events?${params}`); const events = (data.events || []).slice().reverse(); // oldest → newest for (const e of events) { const key = `${e.id ?? e.created_at}:${e.event_type}`; if (lastSeen && key <= lastSeen && e.id == null) continue; if (e.id != null && lastSeen != null && Number(e.id) <= Number(lastSeen)) continue; if (lastSeen !== null || events.indexOf(e) >= events.length - 10) { console.log( `${c.dim(fmtTime(e.created_at))} ${paintEvent((e.event_type || "").padEnd(16))} ${(e.summary || "").slice(0, 90)}` ); } lastSeen = e.id != null ? Number(e.id) : key; } await new Promise((r) => setTimeout(r, 2000)); } } // ── Data browsing ─────────────────────────────────────────────────────────── function renderSessions(data) { const rows = (data.sessions || []).map((s) => [ s.id.slice(0, 8), colorStatus(s.status), (s.name || "").slice(0, 44), s.agent_count ?? "-", fmtDuration(s.started_at, s.ended_at), fmtModel(s.model), c.dim(fmtAgo(s.updated_at || s.started_at)), ]); table(["ID", "Status", "Name", "Agents", "Duration", "Model", "Updated"], rows); console.log(c.dim(`\n${rows.length} of ${data.total ?? rows.length} session(s)`)); } // ── Session detail building blocks (shared by online and offline paths so // the two render byte-identically) ──────────────────────────────────────── /** Title + aligned metadata card for one session row. */ function renderSessionMeta(s) { console.log(`${c.cyan("▍")}${c.bold(s.name || s.id)} ${c.dim(`(${s.id})`)}`); kvLine("Status", colorStatus(s.status)); kvLine("Model", fmtModel(s.model)); kvLine("Duration", fmtDuration(s.started_at, s.ended_at)); kvLine("Cwd", s.cwd || "-"); } /** Agent hierarchy as a real tree (├─/└─ with continuation rails). */ function renderAgentTree(agents) { console.log(`\n${c.cyan("▍")}${c.bold("Agents")} ${c.dim(`(${agents.length})`)}`); const byParent = {}; for (const a of agents) (byParent[a.parent_agent_id || ""] ||= []).push(a); const walk = (parentId, prefix) => { const kids = byParent[parentId] || []; kids.forEach((a, i) => { const last = i === kids.length - 1; const tool = a.current_tool ? c.cyan(` [${a.current_tool}]`) : ""; console.log( ` ${c.dim(prefix + (last ? "└─ " : "├─ "))}${colorStatus(a.status)} ` + `${a.type === "main" ? c.bold(a.name) : a.name}${tool} ${c.dim(fmtDuration(a.started_at, a.ended_at))}` ); walk(a.id, prefix + (last ? " " : "│ ")); }); }; walk("", ""); } /** Recent-events block with per-type colors. */ function renderEventLines(events) { console.log(`\n${c.cyan("▍")}${c.bold("Recent events")}`); for (const e of events) { console.log( ` ${c.dim(fmtTime(e.created_at))} ${paintEvent((e.event_type || "").padEnd(16))} ${(e.summary || "").slice(0, 70)}` ); } } async function cmdSessions(flags) { const params = new URLSearchParams(); if (flags.status) params.set("status", flags.status); if (flags.q) params.set("q", flags.q); params.set("limit", flags.limit || "20"); renderSessions(await get(`/api/sessions?${params}`)); } /** Session detail: metadata, cost, agent tree, and the most recent events. */ async function cmdSession(positional) { const id = positional[0]; if (!id) { console.error(c.red("✖ Usage: ccam session ")); process.exit(1); } const d = await get(`/api/sessions/${encodeURIComponent(id)}`); const s = d.session || d; renderSessionMeta(s); let cost = null; try { cost = await get(`/api/pricing/cost/${encodeURIComponent(id)}`); } catch { /* pricing may 404 for unknown ids */ } if (cost) kvLine("Cost", c.cyan(c.bold(fmtCost(cost.total_cost)))); const agents = d.agents || []; if (agents.length) renderAgentTree(agents); const events = (d.events || []).slice(0, 10); if (events.length) renderEventLines(events); } function renderAgents(data) { const rows = (data.agents || []).map((a) => [ a.id.slice(0, 8), colorStatus(a.status), a.type, (a.name || "").slice(0, 40), a.current_tool || "-", fmtDuration(a.started_at, a.ended_at), ]); table(["ID", "Status", "Type", "Name", "Tool", "Duration"], rows); } async function cmdAgents(flags) { const params = new URLSearchParams(); if (flags.status) params.set("status", flags.status); if (flags.session) params.set("session_id", flags.session); params.set("limit", flags.limit || "20"); renderAgents(await get(`/api/agents?${params}`)); } function renderEvents(data) { const rows = (data.events || []).map((e) => [ fmtTime(e.created_at), e.event_type, e.tool_name || "-", (e.summary || "").slice(0, 60), ]); table(["Time", "Type", "Tool", "Summary"], rows); } async function cmdEvents(flags) { const params = new URLSearchParams(); if (flags.session) params.set("session_id", flags.session); params.set("limit", flags.limit || "20"); renderEvents(await get(`/api/events?${params}`)); } // ── Insights ──────────────────────────────────────────────────────────────── async function cmdAnalytics() { const a = await get("/api/analytics"); heading("Analytics", baseUrl()); const t = a.tokens || {}; table( ["Tokens", "Count"], [ ["Input", fmtTokens(t.total_input)], ["Output", fmtTokens(t.total_output)], ["Cache read", fmtTokens(t.total_cache_read)], ["Cache write", fmtTokens(t.total_cache_write)], ] ); const tools = (a.tool_usage || []).slice(0, 10); if (tools.length) { console.log(`\n${c.bold("Top tools")}`); const max = Math.max(...tools.map((x) => Number(x.count) || 0)); const w = Math.max(...tools.map((x) => String(x.tool_name || x.tool).length)); for (const x of tools) { console.log( ` ${String(x.tool_name || x.tool).padEnd(w)} ${bar(x.count, max)} ${c.bold(String(x.count))}` ); } } const types = (a.agent_types || []).slice(0, 8); if (types.length) { console.log(`\n${c.bold("Agent types")}`); const max = Math.max(...types.map((x) => Number(x.count) || 0)); const w = Math.max(...types.map((x) => String(x.subagent_type || x.type || "main").length)); for (const x of types) { const name = String(x.subagent_type || x.type || "main"); console.log( ` ${name.padEnd(w)} ${bar(x.count, max, 16, c.magenta)} ${c.bold(String(x.count))}` ); } } if (a.avg_events_per_session != null) { console.log(`\nAvg events/session: ${c.cyan(Number(a.avg_events_per_session).toFixed(1))}`); } } async function cmdWorkflows(flags) { if (flags.session) { const d = await get(`/api/workflows/session/${encodeURIComponent(flags.session)}`); heading("Workflow drill-in", `session ${flags.session}`); const agents = d.agents || d.tree || []; console.log(`agents: ${Array.isArray(agents) ? agents.length : "-"}`); return; } const w = await get("/api/workflows"); const s = w.stats || {}; heading("Workflow intelligence", baseUrl()); table( ["Metric", "Value"], [ ["Sessions analyzed", s.totalSessions ?? "-"], ["Total agents", s.totalAgents ?? "-"], ["Subagents", s.totalSubagents ?? "-"], ["Avg subagents/session", s.avgSubagents ?? "-"], ["Success rate", s.successRate != null ? `${s.successRate}%` : "-"], ["Avg depth", s.avgDepth ?? "-"], ["Compactions", s.totalCompactions ?? "-"], ] ); const patterns = (w.patterns && w.patterns.patterns) || []; if (patterns.length) { console.log(`\n${c.bold("Detected patterns")} (top ${Math.min(5, patterns.length)})`); for (const p of patterns.slice(0, 5)) { console.log( ` ${c.cyan(String(p.count ?? "-"))}× ${(p.label || p.chain || "").toString().slice(0, 80)}` ); } } } async function cmdRuns(flags) { const params = new URLSearchParams(); if (flags.session) params.set("session_id", flags.session); const data = await get(`/api/workflows/runs?${params}`); const runs = data.runs || data.items || []; const rows = runs .slice(0, Number(flags.limit || 20)) .map((r) => [ (r.run_id || "").slice(0, 14), colorStatus(r.status), (r.name || "").slice(0, 28), r.agent_count ?? "-", fmtTokens(r.total_tokens), r.total_tool_calls ?? "-", fmtDuration(r.started_at, r.ended_at), ]); table(["Run", "Status", "Name", "Agents", "Tokens", "Tools", "Duration"], rows); } async function cmdCost(flags) { // Mirror the two cost endpoints: the whole-database aggregate, or one // session's cost when --session is given (same response shape, so the // rendering below is shared). const sessionId = flags.session; const cost = sessionId ? await get(`/api/pricing/cost/${encodeURIComponent(sessionId)}`) : await get("/api/pricing/cost"); if (sessionId) heading("Session cost", sessionId); console.log(`${c.bold("Total estimated cost:")} ${c.cyan(c.bold(fmtCost(cost.total_cost)))}`); const breakdown = (cost.breakdown || []).slice(0, 15); if (breakdown.length) { console.log(); const max = Math.max(...breakdown.map((b) => Number(b.cost) || 0)); const w = Math.max(...breakdown.map((b) => fmtModel(b.model).length)); for (const b of breakdown) { console.log( ` ${fmtModel(b.model).padEnd(w)} ${bar(b.cost, max, 20, c.green)} ${c.bold(fmtCost(b.cost))}` ); } } // Server-tool surcharges billed on top of tokens (web search $/1k, code // execution container-time beyond the org free allowance). The API always // returns feature_costs; show the line only when something is actually // billed so a plain token-only total stays uncluttered. const fc = cost.feature_costs || {}; const featureLines = []; if ((fc.web_search_cost || 0) > 0) featureLines.push(`web search ${c.bold(fmtCost(fc.web_search_cost))}`); if ((fc.code_execution_cost || 0) > 0) featureLines.push(`code execution ${c.bold(fmtCost(fc.code_execution_cost))}`); if (featureLines.length) { console.log(); console.log(`${c.dim("Server-tool surcharges:")} ${featureLines.join(c.dim(" · "))}`); } // The API prices unmatched models at $0 and reports them in unpriced_models // so the total stays honest — surface that here instead of silently showing // an under-reported number (e.g. right after a brand-new model id ships). const unpriced = cost.unpriced_models || []; if (unpriced.length) { console.log(); console.log( c.yellow( `⚠ ${unpriced.length} model(s) have usage but no pricing rule — excluded from the total:` ) ); for (const u of unpriced) { const tokens = (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_read_tokens || 0) + (u.cache_write_tokens || 0); console.log(` ${c.bold(u.model)} ${c.dim(`${fmtTokens(tokens)} tokens`)}`); } console.log(c.dim(" Add a rule with: ccam pricing set --input N --output N")); } } // ── Alerts & webhooks ─────────────────────────────────────────────────────── async function cmdAlerts(flags, positional) { const sub = positional[0]; if (sub === "ack" && positional[1]) { await post(`/api/alerts/${positional[1]}/ack`); console.log(`${c.green("✔")} Alert ${positional[1]} acknowledged`); return; } if (sub === "ack-all") { const r = await post("/api/alerts/ack-all"); console.log(`${c.green("✔")} Acknowledged ${r.acknowledged ?? "all"} alert(s)`); return; } const params = new URLSearchParams(); if (flags.unacked) params.set("unacked", "true"); params.set("limit", flags.limit || "20"); const data = await get(`/api/alerts?${params}`); const rows = (data.alerts || []).map((a) => [ a.id, a.acknowledged_at ? c.dim("acked") : c.yellow("open"), fmtTime(a.triggered_at), (a.rule_name || "").slice(0, 24), (a.message || "").slice(0, 52), ]); table(["ID", "State", "Triggered", "Rule", "Message"], rows); console.log(c.dim(`\n${data.unacked ?? 0} unacknowledged of ${data.total ?? rows.length}`)); } async function cmdRules() { const data = await get("/api/alerts/rules"); const rows = (data.rules || []).map((r) => [ r.id, r.enabled ? c.green("on") : c.dim("off"), r.rule_type, (r.name || "").slice(0, 32), `${r.cooldown_minutes ?? "-"}m`, ]); table(["ID", "Enabled", "Type", "Name", "Cooldown"], rows); } async function cmdWebhooks(flags, positional) { const sub = positional[0]; if (sub === "test" && positional[1]) { const r = await post(`/api/webhooks/${positional[1]}/test`); const ok = r.ok || r.success; console.log( ok ? `${c.green("✔")} Test delivery succeeded (HTTP ${r.status ?? "?"}, ${r.attempts ?? 1} attempt(s))` : `${c.red("✖")} Test delivery failed: ${r.error || `HTTP ${r.status}`}` ); process.exit(ok ? 0 : 1); } const data = await get("/api/webhooks"); const rows = (data.targets || []).map((t) => [ t.id, t.enabled ? c.green("on") : c.dim("off"), t.type, (t.name || "").slice(0, 28), t.url_masked || t.url || "-", ]); table(["ID", "Enabled", "Provider", "Name", "URL"], rows); } // ── Remote data sources ───────────────────────────────────────────────────── /** * `ccam remote-sources [list|add|test|sync|rm]` — manage the SSH machines this * dashboard pulls Claude Code history from (server/routes/remote-sources.js). * No secrets are handled here; auth defers to the host's SSH stack. */ async function cmdRemoteSources(flags, positional) { const sub = positional[0] || "list"; if (sub === "add") { if (!flags.label || !flags.host) { console.error(c.red("✖ add requires --label and --host")); console.error( c.dim( " e.g. ccam remote-sources add --label 'Dev box' --host son@dev --port 22 --identity ~/.ssh/id_ed25519" ) ); process.exit(1); } const body = { label: String(flags.label), host: String(flags.host), ssh_port: flags.port != null ? Number(flags.port) : null, identity_file: flags.identity != null ? String(flags.identity) : null, remote_home: flags["remote-home"] != null ? String(flags["remote-home"]) : null, enabled: flags.disabled ? false : true, }; const r = await post("/api/remote-sources", body); console.log(`${c.green("✔")} Added remote source ${c.bold(r.source.label)} (${r.source.id})`); return; } if (sub === "test" && positional[1]) { const r = await post(`/api/remote-sources/${positional[1]}/test`); console.log(r.ok ? `${c.green("✔")} ${r.message}` : `${c.red("✖")} ${r.message}`); process.exit(r.ok ? 0 : 1); } if (sub === "sync") { if (positional[1]) { const r = await post(`/api/remote-sources/${positional[1]}/sync`); console.log( `${c.green("✔")} Synced ${positional[1]}: ${r.imported ?? 0} imported, ${r.sessions_tagged ?? 0} tagged` ); } else { // No id → sync every source sequentially. const { sources = [] } = await get("/api/remote-sources"); for (const s of sources) { const r = await post(`/api/remote-sources/${s.id}/sync`); console.log( ` ${c.bold(s.label)}: ${r.imported ?? 0} imported, ${r.sessions_tagged ?? 0} tagged` ); } console.log(`${c.green("✔")} Synced ${sources.length} source(s)`); } return; } if (sub === "rm" && positional[1]) { const purge = !!flags.purge; const r = await api( "DELETE", `/api/remote-sources/${positional[1]}${purge ? "?purge=true" : ""}` ); console.log( `${c.green("✔")} Removed ${positional[1]}${purge ? ` (purged ${r.purged} session(s))` : " (data kept)"}` ); return; } // Default: list. const { sources = [] } = await get("/api/remote-sources"); const rows = sources.map((s) => [ s.id, s.enabled ? c.green("on") : c.dim("off"), s.status, (s.label || "").slice(0, 24), `${s.host}${s.ssh_port ? `:${s.ssh_port}` : ""}`, String(s.session_count ?? 0), s.last_sync_at ? new Date(s.last_sync_at).toLocaleString() : "-", ]); table(["ID", "Auto", "Status", "Label", "Host", "Sessions", "Last sync"], rows); if (sources.length > 0) { const totalSessions = sources.reduce((n, s) => n + (s.session_count || 0), 0); const enabled = sources.filter((s) => s.enabled).length; console.log( c.dim( ` ${sources.length} source(s), ${enabled} auto-syncing, ${totalSessions} session(s) collected` ) ); } else { console.log(c.dim(" No remote sources configured. Add one: ccam remote-sources add --help")); } } // ── Pricing ───────────────────────────────────────────────────────────────── /** Render pricing rules — shared by the online list and the offline fallback * so both show the full rate surface (standard, fast-mode, intro promo). */ function renderPricingTable(rules) { const fastCol = (p) => (p.fast_input_per_mtok || 0) > 0 ? `$${p.fast_input_per_mtok}/$${p.fast_output_per_mtok}` : "-"; const introCol = (p) => p.intro_until ? `$${p.intro_input_per_mtok}/$${p.intro_output_per_mtok} ≤${p.intro_until}` : "-"; const rows = (rules || []).map((p) => [ p.model_pattern, (p.display_name || "").slice(0, 24), `$${p.input_per_mtok}`, `$${p.output_per_mtok}`, `$${p.cache_read_per_mtok}`, `$${p.cache_write_per_mtok}`, fastCol(p), introCol(p), ]); table( ["Pattern", "Name", "In/M", "Out/M", "CacheR/M", "CacheW/M", "Fast In/Out", "Intro In/Out"], rows ); } async function cmdPricing(flags, positional) { const sub = positional[0]; if (sub === "set" && positional[1]) { const body = { model_pattern: positional[1], display_name: flags.name || positional[1], input_per_mtok: Number(flags.input ?? 0), output_per_mtok: Number(flags.output ?? 0), cache_read_per_mtok: Number(flags["cache-read"] ?? 0), cache_write_per_mtok: Number(flags["cache-write"] ?? 0), }; if (flags["cache-write-1h"] !== undefined) body.cache_write_1h_per_mtok = Number(flags["cache-write-1h"]); if (flags["fast-input"] !== undefined) body.fast_input_per_mtok = Number(flags["fast-input"]); if (flags["fast-output"] !== undefined) body.fast_output_per_mtok = Number(flags["fast-output"]); // The intro block is only sent when at least one --intro-* flag is present: // per the API contract, a PUT that omits every intro field preserves an // existing promo, so a plain rate edit can never clobber one. const INTRO_FLAGS = { "intro-input": "intro_input_per_mtok", "intro-output": "intro_output_per_mtok", "intro-cache-read": "intro_cache_read_per_mtok", "intro-cache-write": "intro_cache_write_per_mtok", "intro-cache-write-1h": "intro_cache_write_1h_per_mtok", }; const introProvided = flags["intro-until"] !== undefined || Object.keys(INTRO_FLAGS).some((f) => flags[f] !== undefined); if (introProvided) { for (const [flag, field] of Object.entries(INTRO_FLAGS)) body[field] = Number(flags[flag] ?? 0); // A bare --intro-until (no date) clears the promo, mirroring the API. body.intro_until = typeof flags["intro-until"] === "string" ? flags["intro-until"] : ""; } await api("PUT", "/api/pricing", body); console.log(`${c.green("✔")} Pricing rule saved for ${c.bold(positional[1])}`); return; } if (sub === "delete" && positional[1]) { await api("DELETE", `/api/pricing/${encodeURIComponent(positional[1])}`); console.log(`${c.green("✔")} Pricing rule deleted: ${positional[1]}`); return; } if (sub === "reset") { await post("/api/settings/reset-pricing"); console.log(`${c.green("✔")} Pricing rules reset to defaults`); return; } const data = await get("/api/pricing"); renderPricingTable(data.pricing || data.rules || []); } // ── Updates ───────────────────────────────────────────────────────────────── async function cmdUpdateCheck() { console.log(c.dim("Checking the canonical remote — this can take a few seconds…")); // POST /check (rather than GET /status) so a dashboard open in the browser // sees the same fresh result via the update_status websocket broadcast. const s = await post("/api/updates/check"); heading("Dashboard updates", s.repo_root || baseUrl()); if (!s.git_repo) { console.log(`${c.yellow("○")} ${s.message}`); return; } if (s.fetch_error) { console.log(`${c.yellow("!")} ${s.message} ${c.dim(`(${s.fetch_error})`)}`); return; } if (s.update_available) { console.log(`${c.yellow("⬆")} ${s.message}`); if (s.situation_note) console.log(c.dim(` ${s.situation_note}`)); if (s.manual_command) { console.log(`\n ${c.bold("To update, run:")}`); console.log(` ${c.cyan(s.manual_command)}`); } } else { console.log(`${c.green("✔")} ${s.message || "Your checkout is up to date."}`); } } // ── Import ────────────────────────────────────────────────────────────────── async function cmdImport(flags, positional) { const sub = positional[0]; if (sub === "rescan") { console.log(c.dim("Rescanning ~/.claude/projects — this can take a while…")); const r = await post("/api/import/rescan"); console.log( `${c.green("✔")} imported ${r.imported ?? 0}, backfilled ${r.backfilled ?? 0}, skipped ${r.skipped ?? 0}, errors ${r.errors ?? 0}` ); return; } if (sub === "path" && positional[1]) { console.log(c.dim(`Scanning ${positional[1]} …`)); const r = await post("/api/import/scan-path", { path: positional[1] }); console.log( `${c.green("✔")} imported ${r.imported ?? 0}, backfilled ${r.backfilled ?? 0}, skipped ${r.skipped ?? 0}, errors ${r.errors ?? 0}` ); return; } console.error(c.red("✖ Usage: ccam import rescan | ccam import path ")); process.exit(1); } // ── Administration ────────────────────────────────────────────────────────── async function cmdDoctor() { let failed = false; await get("/api/health"); heading("ccam doctor", baseUrl()); console.log(`${c.green("✔")} API reachable ${baseUrl()}`); const info = await get("/api/settings/info"); const hooks = info.hooks || {}; if (hooks.installed) { console.log( `${c.green("✔")} Claude Code hooks installed (${hooks.path || "~/.claude/settings.json"})` ); } else { failed = true; console.log(`${c.red("✖")} Claude Code hooks NOT installed — run: npm run install-hooks`); } const db = info.db || {}; console.log( `${c.green("✔")} Database ${db.path || "?"} (${((db.size || 0) / 1048576).toFixed(1)} MB)` ); for (const [t, n] of Object.entries(db.counts || {})) { console.log(`${c.dim("·")} rows: ${t} ${n}`); } const srv = info.server || {}; console.log( `${c.green("✔")} Server uptime ${Math.floor((srv.uptime || 0) / 60)} min (node ${srv.node_version || "?"})` ); console.log(`${c.green("✔")} WS connections ${srv.ws_connections ?? 0}`); try { const { sources = [] } = await get("/api/remote-sources"); if (sources.length === 0) { console.log(`${c.green("✔")} Remote sources none configured`); } else { const errored = sources.filter((s) => s.status === "error"); console.log( `${errored.length ? c.yellow("!") : c.green("✔")} Remote sources ${sources.length} configured` + (errored.length ? ` (${errored.length} in error)` : "") ); for (const s of sources) { const mark = s.status === "error" ? c.red("✖") : s.status === "ok" ? c.green("·") : c.dim("·"); console.log( `${mark} ${s.label || s.id} ${s.status}` + (s.last_sync_at ? ` last sync ${s.last_sync_at}` : "") + (s.last_error ? ` ${c.red(s.last_error)}` : "") ); } if (errored.length) failed = true; } } catch (err) { failed = true; console.log(`${c.red("✖")} Remote sources ${err.message || err}`); } if (failed) process.exit(1); } async function cmdInfo() { const info = await get("/api/settings/info"); console.log(JSON.stringify(info, null, 2)); } async function cmdExport(positional) { const file = positional[0] || `ccam-export-${new Date().toISOString().slice(0, 10)}.json`; const data = await get("/api/settings/export"); fs.writeFileSync(file, JSON.stringify(data, null, 2)); console.log( `${c.green("✔")} Exported to ${c.bold(file)} (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)` ); } // Restore a bundle produced by `ccam export` (or the dashboard's Export data). // The local server reads the file from disk, so we pass an absolute path rather // than uploading — idempotent and non-destructive (existing sessions skipped). async function cmdImportData(positional) { const target = positional[0]; if (!target) { console.error(c.red("✖ Usage: ccam import-data ")); process.exit(1); } const abs = path.resolve(process.cwd(), target); if (!fs.existsSync(abs)) { console.error(c.red(`✖ File not found: ${abs}`)); process.exit(1); } const r = await post("/api/settings/import", { path: abs }); console.log( `${c.green("✔")} Restored: ${r.sessions_imported ?? 0} sessions added, ` + `${r.sessions_skipped ?? 0} already present, ${r.events ?? 0} events, ` + `${r.model_pricing ?? 0} pricing rules ` + `(${r.agents ?? 0} agents · ${r.workflows ?? 0} workflows · ${r.dashboard_runs ?? 0} runs · ${r.alert_rules ?? 0} rules)` ); } async function cmdCleanup(flags) { const body = {}; if (flags.hours) body.abandon_hours = Number(flags.hours); if (flags.days) body.purge_days = Number(flags.days); if (!flags.hours && !flags.days) { console.error(c.red("✖ Usage: ccam cleanup --hours and/or --days ")); console.error(c.dim(" --hours N abandon active sessions idle for N hours")); console.error(c.dim(" --days M purge completed sessions older than M days")); process.exit(1); } const r = await post("/api/settings/cleanup", body); console.log(`${c.green("✔")} Cleanup done: ${JSON.stringify(r)}`); } async function cmdClearData(flags) { if (flags.yes !== true) { console.error(c.red("✖ clear-data deletes ALL sessions, agents, events, and token usage.")); console.error(c.dim(" Re-run with --yes to confirm: ccam clear-data --yes")); process.exit(1); } await post("/api/settings/clear-data"); console.log(`${c.green("✔")} All data cleared (schema preserved)`); } async function cmdReinstallHooks() { await post("/api/settings/reinstall-hooks"); console.log(`${c.green("✔")} Claude Code hooks reinstalled`); } /** * Start the dashboard server in the background (production mode, serving the * built client) and wait until /api/health answers. No-ops with a pointer to * the live URL when a server is already up. The child is fully detached with * its output appended to data/ccam-server.log, so closing this terminal does * not stop the dashboard; stop it later with `kill ` (the PID is * printed and registered in ~/.claude/.agent-dashboard.json). */ async function cmdStart(flags) { if (await serverIsUp()) { console.log(`${c.green("●")} Dashboard already running at ${c.bold(baseUrl())}`); return; } const clientDist = path.join(REPO_ROOT, "client", "dist", "index.html"); if (!fs.existsSync(clientDist)) { console.error(c.red("✖ client/dist is missing — the production server needs a built client.")); console.error(c.dim(" Build it once with: npm run build (then re-run: ccam start)")); console.error(c.dim(" Or run the dev servers instead: npm run dev")); process.exit(1); } const logDir = path.join(REPO_ROOT, "data"); fs.mkdirSync(logDir, { recursive: true }); const logFile = path.join(logDir, "ccam-server.log"); const out = fs.openSync(logFile, "a"); const env = { ...process.env, NODE_ENV: "production" }; if (flags.port) env.DASHBOARD_PORT = String(flags.port); const child = spawn(process.execPath, [path.join(REPO_ROOT, "server", "index.js")], { detached: true, stdio: ["ignore", out, out], env, cwd: REPO_ROOT, }); child.unref(); // On a TTY, animate a braille spinner in place; when piped, fall back to a // dot-per-poll trail so progress still shows without cursor control. const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const live = Boolean(process.stdout.isTTY); let tick = 0; const announce = `Starting dashboard server (pid ${child.pid}, log ${logFile})`; if (live) { process.stdout.write(`${c.cyan(spinner[0])} ${c.dim(announce)}`); } else { process.stdout.write(c.dim(`${announce} `)); } const clearLine = () => { if (live) process.stdout.write("\r\x1b[K"); else process.stdout.write("\n"); }; const deadline = Date.now() + 30_000; while (Date.now() < deadline) { if (await serverIsUp()) { clearLine(); console.log( `${c.green("●")} Dashboard ${c.bold("up")} at ${c.bold(baseUrl())} ${c.dim(`(pid ${child.pid})`)}` ); console.log(c.dim(` Stop it with: kill ${child.pid}`)); return; } tick++; if (live) { process.stdout.write(`\r${c.cyan(spinner[tick % spinner.length])} ${c.dim(announce)}`); } else { process.stdout.write(c.dim(".")); } await new Promise((r) => setTimeout(r, live ? 250 : 500)); } clearLine(); console.error(`${c.red("✖ Server did not become healthy within 30 s")} — check ${logFile}`); process.exit(1); } /** Up/down indicator without exiting non-zero noise — the at-a-glance check. */ async function cmdStatus() { if (await serverIsUp()) { const h = await get("/api/health"); console.log( `${c.green("●")} Dashboard server is ${c.bold("running")} at ${baseUrl()} (${h.timestamp})` ); } else { console.log( `${c.red("○")} Dashboard server is ${c.bold("NOT running")} ${c.dim(`(tried ${baseUrl()})`)}` ); console.log(c.dim(" Start it with: ccam start (or npm run dev / npm start)")); process.exit(1); } } function cmdOpen() { const url = baseUrl(); const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; spawn(opener, [url], { shell: process.platform === "win32", detached: true, stdio: "ignore", }).unref(); console.log(`${c.green("✔")} Opening ${url}`); } /** * `ccam lanes add` — adopt a working directory or provision a managed worktree. */ async function cmdLanesAdd(args) { const flag = (name) => { const i = args.indexOf(`--${name}`); return i > -1 ? args[i + 1] : undefined; }; const cwd = flag("cwd"); const repo = flag("repo"); const title = flag("title"); const pipeline = flag("pipeline"); const base = flag("base"); const slug = flag("slug"); if (repo) { if (cwd || pipeline) { console.error( "usage: ccam lanes add --repo [--title ] [--base ] [--slug ]" ); process.exitCode = 1; return; } const body = { sourceRepo: repo, title: title || path.basename(repo) }; if (base) body.base = base; if (slug) body.slug = slug; const { lane: provisioning } = await post("/api/lanes/worktree", body); const deadline = Date.now() + 30_000; let lane = provisioning; while (lane.status === "provisioning" && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 250)); ({ lane } = await get(`/api/lanes/${provisioning.id}`)); } if (lane.status === "provisioning") { console.error(`✖ Timed out waiting for worktree lane #${lane.id} to finish provisioning.`); process.exitCode = 1; return; } if (lane.status === "failed") { console.error( `✖ Worktree lane #${lane.id} failed: ${lane.notes || "no failure details reported"}` ); process.exitCode = 1; return; } console.log( `${c.green("✔")} Worktree lane #${lane.id} ready: ${lane.title || lane.cwd} (status: ${lane.status})` ); return; } if (!cwd || !title) { console.error("usage: ccam lanes add --cwd --title [--pipeline ]"); process.exitCode = 1; return; } const body = { cwd, title }; if (pipeline) body.pipeline = pipeline; const { lane } = await post("/api/lanes", body); console.log(`${c.green("✔")} Created lane #${lane.id}: ${lane.title}`); } /** * `ccam lanes profile init ` — detect a Node.js project and scaffold * `.ccam/profile/`. Pure filesystem action against the SOURCE repo; does not * talk to the dashboard server at all. */ function cmdLanesProfileInit(args) { const repo = args.find((arg) => !arg.startsWith("--")); const force = args.includes("--force"); if (!repo) { console.error("usage: ccam lanes profile init [--force]"); process.exitCode = 1; return; } const resolved = path.resolve(repo); if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) { console.error(`✖ not a directory: ${resolved}`); process.exitCode = 1; return; } const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js")); const facts = laneDetect.detectNode(resolved); if (!facts) { console.error( `✖ No detectable Node.js project at ${resolved} (looked for backend/package.json +\n` + " frontend/package.json, or a root package.json).\n" + " Auto-scaffolding currently supports Node.js repos in that layout only.\n" + " Write .ccam/profile/ by hand — see docs/LANES.md." ); process.exitCode = 1; return; } let result; try { result = laneDetect.scaffoldProfile(resolved, facts, { force }); } catch (err) { if (err.code === "EPROFILEEXISTS") { console.error(`✖ ${err.message} — pass --force to overwrite it.`); process.exitCode = 1; return; } throw err; } console.log(`${c.green("✔")} Scaffolded .ccam/profile/ at ${resolved} (${facts.layout})`); for (const file of result.written) console.log(` wrote ${file}`); if (result.todos.length) { console.log(`\n${c.yellow(`⚠ ${result.todos.length} item(s) need manual attention:`)}`); for (const todo of result.todos) console.log(` ${todo}`); } console.log(`\nNext: ccam lanes profile check ${repo}`); } /** * `ccam lanes profile check []` — validate a profile without needing a * lane to exist for it yet. Defaults to the current directory, NOT lane-id * resolution (unlike every other `lanes` subcommand) — this is meant to run * against a bare repo right after `profile init`. */ async function cmdLanesProfileCheck(args) { const target = args.find((arg) => !arg.startsWith("--")) || process.cwd(); const resolved = path.resolve(target); const laneDetect = require(path.join(REPO_ROOT, "server", "lib", "lane-detect.js")); const result = await laneDetect.checkProfile(resolved); if (result.errors.length === 0) { console.log(`${c.green("✔")} profile at ${resolved} looks good`); } else { console.log(`${c.red(`✖ ${result.errors.length} problem(s) at ${resolved}:`)}`); for (const error of result.errors) console.log(` ${error}`); } for (const warning of result.warnings) console.log(`${c.yellow("⚠")} ${warning}`); process.exitCode = result.ok ? 0 : 1; } // Keep these confirmation facts in lockstep with expectedFields() in // server/routes/lanes.js. The server remains authoritative and rejects an // incomplete or stale echo, while the CLI shows exactly what it will send. const LANE_PREFLIGHT_FIELDS = { reset: ["head", "dirty", "untracked", "unpushed"], remove: ["head", "dirty", "untracked", "unpushed"], purge: ["sessions", "events", "tokenRows"], }; function printLaneFacts(title, facts, fields) { const width = Math.max(...fields.map((field) => field.length)); console.log(title); for (const field of fields) { const value = facts[field] === null ? "(none)" : String(facts[field]); console.log(` ${field.padEnd(width)} ${value}`); } } /** `ccam lanes reset|remove|purge` — destructive work only after a fact check. */ async function cmdLanesLifecycle(action, args) { const id = args.find((arg) => !arg.startsWith("--")); const fields = LANE_PREFLIGHT_FIELDS[action]; if (!id) { const extra = action === "reset" ? " [--keep-db]" : ""; console.error(`usage: ccam lanes ${action} [--force]${extra} --yes`); process.exitCode = 1; return; } const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`); printLaneFacts(`Preflight for ${action} lane #${id}:`, preflight, fields); const keepDb = action === "reset" && args.includes("--keep-db"); if (preflight.database) { const fate = action === "remove" ? "dropped" : keepDb ? "kept as-is" : "dropped and recreated"; console.log(` database: ${preflight.database} (${fate})`); } if (Array.isArray(preflight.warnings) && preflight.warnings.length) { console.log("Warnings:"); for (const warning of preflight.warnings) console.log(` ${warning}`); } if (!args.includes("--yes")) { console.error(`Refusing to ${action} lane #${id} without --yes. Re-run with --yes to proceed.`); process.exitCode = 1; return; } // Only `reset` is impossible for an adopted lane. `remove` IS supported by the // server: it drops the dashboard's record and never touches the directory // (server/routes/lanes.js scopes the worktree teardown to managed lanes), so // refusing it here would make a permitted action unreachable from the CLI. if ( action === "reset" && Array.isArray(preflight.blocked) && preflight.blocked.includes("adopted") ) { console.error( `Lane #${id} points at a directory you own. It cannot be reset; use \`ccam lanes remove ${id} --yes\` to drop only the dashboard's record of it.` ); process.exitCode = 1; return; } const expect = Object.fromEntries(fields.map((field) => [field, preflight[field]])); const body = { confirm: true, expect }; if (args.includes("--force")) body.force = true; if (keepDb) body.keepDb = true; const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true }); if (result.status) { const error = result.data?.error || {}; console.error(`✖ ${action} lane #${id} → ${error.message || `HTTP ${result.status}`}`); if (result.status === 409 && error.code === "ESTALE") { printLaneFacts("Expected preflight:", error.expected || {}, fields); printLaneFacts("Current preflight:", error.current || {}, fields); } process.exitCode = 1; return; } if (action === "reset") console.log(`${c.green("✔")} Reset lane #${id}.`); if (action === "remove") console.log(`${c.green("✔")} Removed lane #${id}.`); if (action === "purge") { const purged = result.purged || {}; console.log( `${c.green("✔")} Purged lane #${id}: ${purged.sessions} sessions, ${purged.events} events, ${purged.tokenRows} token rows.` ); } } /** * `ccam lanes` — one row per lane: what it is, where it is in its pipeline, and * whether the driving session is still breathing. */ /** * Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the * pipeline's node order — the same rule `LaneCard.tsx`'s * `detectionLeadsDeclaration` uses, so the terminal and the browser never * disagree about which one is the headline. Unmatched ids sort as -1, so an * unmatched detected stage never outranks a matched declaration. */ function detectionLeadsDeclaration(l) { if (!l.detected_stage) return false; const ids = (l.pipeline_nodes || []).map((n) => n.id); return ids.indexOf(l.detected_stage) > ids.indexOf(l.stage); } async function cmdLanes() { const { lanes, counts } = await get("/api/lanes"); if (!lanes.length) { console.log("no lanes yet — create one with: ccam lanes add --cwd --title "); return; } for (const l of lanes) { const needs = l.needs_action ? ` ⚠ ${l.needs_action}` : ""; const detected = detectionLeadsDeclaration(l) ? ` ⇢ detected:${l.detected_stage}` : ""; console.log( `#${l.id} ${(l.title || l.cwd).padEnd(38).slice(0, 38)} ` + `${String(l.stage).padEnd(12)} ${String(l.status).padEnd(9)} ` + `${String(l.liveness).padEnd(6)} ${String(l.progress).padStart(3)}%${needs}${detected}` ); } console.log( `\n${counts.total} lanes · ${counts.running} running · ${counts.needs_you} need you · ${counts.dead} dead` ); } /** * Which lane a command is about: an explicit id, or the lane owning the working * directory (longest path-boundary match, the same rule the server uses). A * session running inside a lane never has to know its own id. * * Only the FIRST positional counts as an id, and only when it is all digits. * Scanning the whole argv for a number would swallow flag values — * `ccam lanes logs web --tail 4096` would have addressed lane 4096. * * @param {string[]} args - Arguments after the subcommand. * @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed. */ async function resolveLaneArg(args) { const flagValue = (name) => { const i = args.indexOf(`--${name}`); return i > -1 ? args[i + 1] : undefined; }; const explicit = flagValue("lane"); if (explicit) return { laneId: explicit, rest: args }; if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) }; const cwd = require("path").resolve(flagValue("cwd") || process.cwd()); const { lanes } = await get("/api/lanes"); const match = lanes .filter((l) => cwd === l.cwd || cwd.startsWith(`${l.cwd}/`)) .sort((a, b) => b.cwd.length - a.cwd.length)[0]; if (!match) { console.error(`no lane owns ${cwd} — create one with: ccam lanes add --cwd ${cwd}`); process.exitCode = 1; return null; } return { laneId: match.id, rest: args }; } /** One line per declared port: name, number, listening, and any base drift. */ function printRuntime(runtime) { if (!runtime.available) { console.log("no .ccam/profile for this lane — nothing to run."); if (runtime.searched) for (const p of runtime.searched) console.log(` looked in ${p}`); return; } if (!runtime.provisioned) { console.log("profile found, runtime not provisioned yet — run: ccam lanes up"); return; } console.log(`slot ${runtime.slot} · profile ${runtime.profileDir}`); if (runtime.database) { console.log(` database ${runtime.database.name} (test: ${runtime.database.testName})`); } if (runtime.redisIndex != null) { console.log(` redis logical db ${runtime.redisIndex}`); } for (const [name, info] of Object.entries(runtime.ports)) { const drift = info.port && info.port !== info.expected ? ` ⚠ base expects ${info.expected}` : ""; console.log( ` ${name.padEnd(10)} :${String(info.port ?? "-").padEnd(6)} ` + `${info.listening ? "listening" : "down"}${drift}` ); } for (const service of runtime.services) { console.log( ` ${service.name.padEnd(10)} pid ${service.pid} ${service.alive ? "alive" : "gone"}` ); } if (runtime.lastError) { console.log(` last error: ${runtime.lastError.code || ""} ${runtime.lastError.message}`); } if (runtime.logs.length) console.log(` logs: ${runtime.logs.join(", ")} (${runtime.logDir})`); } /** * `ccam lanes up|down|runtime|logs|hook` — a lane's own application stack, as * opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one * lane id. * * Every subcommand resolves the lane from the working directory when no id is * given, so a session inside a lane can call them without knowing its id — this * is the surface the driving skill uses. */ async function cmdLanesRuntime(sub, args) { const resolved = await resolveLaneArg(args); if (!resolved) return; const { laneId, rest: laneArgs } = resolved; if (sub === "runtime") { printRuntime(await get(`/api/lanes/${laneId}/runtime`)); return; } if (sub === "up") { const body = laneArgs.includes("--no-build") ? { build: false } : {}; const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true }); if (result.status) { console.error(`✖ up lane #${laneId} → ${result.data?.error?.message || result.status}`); process.exitCode = 1; return; } // The server answers 202 and boots in the background; poll until the stack // reports healthy or a boot error lands, so the command exits on a real // outcome rather than on "accepted". console.log(`lane #${laneId} booting…`); const deadline = Date.now() + 15 * 60 * 1000; for (;;) { await new Promise((r) => setTimeout(r, 2000)); const runtime = await get(`/api/lanes/${laneId}/runtime`); if (runtime.healthy) { printRuntime(runtime); return; } if (runtime.lastError) { console.error(`✖ ${runtime.lastError.code || ""} ${runtime.lastError.message}`); printRuntime(runtime); process.exitCode = 1; return; } if (Date.now() > deadline) { console.error("✖ timed out waiting for the stack to become healthy"); printRuntime(runtime); process.exitCode = 1; return; } } } if (sub === "down") { const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true }); if (result.status) { console.error(`✖ down lane #${laneId} → ${result.data?.error?.message || result.status}`); process.exitCode = 1; return; } console.log(`lane #${laneId} down (${result.killed?.length || 0} processes stopped)`); return; } if (sub === "logs") { const svc = laneArgs.find((arg) => !arg.startsWith("--")); if (!svc) { console.error("usage: ccam lanes logs [] [--tail bytes]"); process.exitCode = 1; return; } const i = laneArgs.indexOf("--tail"); const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : ""; const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`); if (!log.available) { console.log("no logs for this lane yet."); return; } if (log.truncated) console.log(`… (showing the tail of ${log.size} bytes)`); process.stdout.write(log.text); return; } if (sub === "hook") { const name = laneArgs[0]; if (!name || name.startsWith("--")) { console.error("usage: ccam lanes hook [] [args…]"); process.exitCode = 1; return; } const result = await post( `/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`, { args: laneArgs.slice(1) }, { allowError: true } ); if (result.status) { console.error(`✖ hook ${name} → ${result.data?.error?.message || result.status}`); process.exitCode = 1; return; } console.log( `lane #${laneId} running hook ${name} — follow it with: ccam lanes logs ${laneId} ${name}` ); } } /** * The default lock holder identity for the calling lane: `lane` when * the lane has one allocated, else `lane` — a lane's own row id — as a * fallback for a lane that has never brought its runtime up. `--holder` * always overrides both. * * @param {string[]} argsAfterName - Args AFTER the lock name has already been * consumed by the caller (mirrors `cmdStage`'s `resolveLaneArg(args.slice(1))` * call) — `resolveLaneArg` treats a leading all-digits positional as a lane * id, so the lock name itself must never reach it (a lock literally named * e.g. "3" would otherwise be misread as lane 3). */ async function defaultHolder(argsAfterName) { const explicit = (() => { const i = argsAfterName.indexOf("--holder"); return i > -1 ? argsAfterName[i + 1] : undefined; })(); if (explicit) return explicit; const resolved = await resolveLaneArg(argsAfterName); if (!resolved) return null; const { lane } = await get(`/api/lanes/${resolved.laneId}`); return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`; } function fmtLockRow(lock) { const mins = Math.floor(lock.ageSec / 60); return `${lock.name.padEnd(20)} held by ${lock.holder.padEnd(10)} for ${mins}m`; } /** `ccam lock status []` — one lock, or every held lock. */ async function cmdLockStatus(args) { const name = args.find((arg) => !arg.startsWith("--")); if (name) { const { locks } = await get("/api/locks"); const lock = locks.find((l) => l.name === name); console.log(lock ? fmtLockRow(lock) : `${name}: free`); return; } const { locks } = await get("/api/locks"); if (!locks.length) { console.log("no locks held"); return; } for (const lock of locks) console.log(fmtLockRow(lock)); } /** * `ccam lock acquire [--holder X] [--timeout N]` — polls until the * lock is free (or `--timeout` seconds elapse). Prints a status line every * ~60s of continued waiting so a long wait never reads as a hung command — * this is the CLI-side "heartbeat" the design calls for; it is terminal * output, not a dashboard liveness signal. */ async function cmdLockAcquire(args) { const name = args.find((arg) => !arg.startsWith("--")); if (!name) { console.error("usage: ccam lock acquire [--holder X] [--timeout seconds]"); process.exitCode = 1; return; } // Strip the lock name before handing args to defaultHolder/resolveLaneArg — // see defaultHolder's doc comment for why the name must never reach it. const holder = await defaultHolder(args.filter((a) => a !== name)); if (!holder) return; // resolveLaneArg already printed an error const timeoutIdx = args.indexOf("--timeout"); const timeoutMs = timeoutIdx > -1 && args[timeoutIdx + 1] ? Number(args[timeoutIdx + 1]) * 1000 : null; const deadline = timeoutMs ? Date.now() + timeoutMs : null; const startedAt = Date.now(); let lastPrinted = 0; for (;;) { const result = await post( `/api/locks/${encodeURIComponent(name)}/acquire`, { holder }, { allowError: true, } ); if (result.status === undefined || result.data?.acquired) { console.log(`${c.green("✔")} acquired lock "${name}" as ${holder}`); return; } if (Date.now() - lastPrinted >= 60_000) { const waited = Math.floor((Date.now() - startedAt) / 1000); console.log( `… still waiting for lock "${name}" (held by ${result.data?.holder ?? "unknown"}, waited ${waited}s)` ); lastPrinted = Date.now(); } if (deadline && Date.now() >= deadline) { console.error(`✖ timed out waiting for lock "${name}"`); process.exitCode = 1; return; } await new Promise((resolve) => setTimeout(resolve, 2000)); } } /** `ccam lock release [--holder X]`. */ async function cmdLockRelease(args) { const name = args.find((arg) => !arg.startsWith("--")); if (!name) { console.error("usage: ccam lock release [--holder X]"); process.exitCode = 1; return; } const holder = await defaultHolder(args.filter((a) => a !== name)); if (!holder) return; const result = await post( `/api/locks/${encodeURIComponent(name)}/release`, { holder }, { allowError: true, } ); if (result.status) { console.error(`✖ release lock "${name}" → ${result.data?.error?.message || result.status}`); process.exitCode = 1; return; } console.log(`${c.green("✔")} released lock "${name}"`); } /** * `ccam stage [flags]` — the lane equivalent of Shipyard's * `state.sh N set stage=…`. A skill calls this at each phase boundary so the * dashboard shows a declared stage instead of an inferred one. */ async function cmdStage(args) { const stage = args[0]; if (!stage || stage.startsWith("--")) { console.error( "usage: ccam stage [--lane ] [--cwd ] [--status ] [--evidence ] [--note ] [--result pass|fail]" ); process.exitCode = 1; return; } const flag = (name) => { const i = args.indexOf(`--${name}`); return i > -1 ? args[i + 1] : undefined; }; // `--lane` wins, otherwise the lane owning this directory. The stage name is // args[0], so only what follows it can carry a lane reference. const resolved = await resolveLaneArg(args.slice(1)); if (!resolved) return; const laneId = resolved.laneId; const { lane } = await post(`/api/lanes/${laneId}/stage`, { stage, status: flag("status"), evidence: flag("evidence"), note: flag("note"), result: flag("result"), }); console.log(`lane #${lane.id} → ${lane.stage} (${lane.progress}%)`); } // ── Command catalog ───────────────────────────────────────────────────────── // One source of truth for every command's group, invocation, and one-line // description. The one-shot `help`, the REPL's categorized help / `commands` / // per-command `help `, the tab-completer's command list, and unknown- // command detection are all derived from this so they can never drift apart. // Each item: [invocation, argsHint, description]. const COMMAND_GROUPS = [ [ "Server", [ ["status", "", "Up/down indicator for the dashboard server"], ["start", "[--port N]", "Start the server in the background and wait for healthy"], ["repl", "", "Open the interactive shell (also: shell, i)"], ], ], [ "Monitoring", [ ["health", "", "Check the dashboard is up"], ["stats", "", "Totals, today's events, status distribution chart"], ["kanban", "", "Sessions + agents grouped by status columns"], ["tail", "[--session id]", "Live event feed in the terminal (Ctrl+C stops)"], ], ], [ "Data", [ ["sessions", "[opts]", "List sessions (--status, --q, --limit)"], ["session ", "", "Session detail: agents tree, cost, recent events"], ["agents", "[opts]", "List agents (--status, --session, --limit)"], ["events", "[opts]", "List events (--session, --limit)"], ], ], [ "Insights", [ ["analytics", "", "Token totals, top-tool and agent-type charts"], ["workflows", "[--session id]", "Workflow intelligence stats and patterns"], ["runs", "[--session id]", "Dynamic Workflow-tool runs"], ["cost", "[--session id]", "Total estimated cost (per-model chart; --session scopes to one)"], ], ], [ "Alerts & Webhooks", [ ["alerts", "[--unacked]", "Fired-alert feed"], ["alerts ack ", "", "Acknowledge one alert"], ["alerts ack-all", "", "Acknowledge every unacked alert"], ["rules", "", "List alert rules"], ["webhooks", "", "List webhook targets"], ["webhooks test ", "", "Send a synthetic test alert to a target"], ], ], [ "Pricing", [ ["pricing", "", "List model pricing rules"], [ "pricing set ", "", "--input/--output N, plus --cache-*, --fast-*, --intro-* --intro-until YYYY-MM-DD", ], ["pricing delete ", "", "Delete a pricing rule"], ["pricing reset", "", "Reset pricing rules to defaults"], ], ], [ "Import", [ ["import rescan", "", "Re-scan ~/.claude/projects"], ["import path ", "", "Import every .jsonl under a directory"], ["import-data ", "", "Restore a dashboard export (.json) — merge machines"], ], ], [ "Remote sources", [ ["remote-sources", "", "List remote (SSH) data sources + status"], [ "remote-sources add", "", "--label X --host user@host [--port N --identity path --remote-home path]", ], ["remote-sources test ", "", "Probe SSH connectivity to a source"], ["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"], ["remote-sources rm ", "[--purge]", "Remove a source (--purge also deletes its data)"], ], ], [ "Lanes", [ ["lanes", "", "List lanes with stage, liveness and progress"], ["lanes add", "--cwd --title ", "Create a new lane bound to a directory"], [ "lanes add", "--repo [--title ] [--base ] [--slug ]", "Provision a managed worktree lane", ], [ "lanes profile init", " [--force]", "Detect a Node.js project and scaffold .ccam/profile/", ], [ "lanes profile check", "[]", "Validate a profile (path defaults to cwd, not a lane id)", ], [ "lanes reset|remove|purge", " [--force] [--keep-db] --yes", "Show preflight facts, then perform a destructive lane action (--keep-db: reset only)", ], [ "lanes up|down", "[] [--no-build]", "Boot or stop the lane's own app stack (--no-build: up only; id defaults to the lane owning this directory)", ], ["lanes runtime", "[]", "Slot, ports, service health and the last boot error"], ["lanes logs", "[] [--tail N]", "Tail one of the lane's hook or service logs"], ["lanes hook", "[] [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"], [ "lock status|acquire|release", "[] [--holder X] [--timeout N]", "Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)", ], ["stage [flags]", "", "Report the current pipeline stage for a lane"], ], ], [ "Administration", [ ["doctor", "", "Connectivity, hooks, database, and remote sources diagnosis"], ["info", "", "Raw system info JSON"], ["export", "[file.json]", "Export all data as JSON"], ["cleanup", "--hours N --days M", "Abandon stale / purge old sessions"], ["reinstall-hooks", "", "Reinstall Claude Code hooks"], ["update-check", "", "Check whether the dashboard checkout is behind upstream"], ["clear-data", "--yes", "Delete ALL data (requires --yes)"], ["open", "", "Open the dashboard in your browser"], ["version", "", "Print the ccam version (also: --version, -v)"], ], ], ]; /** Render one catalog line: ` cmd args description`. */ function catalogRow(name, args, desc) { const left = `${c.cyan(name)}${args ? ` ${c.dim(args)}` : ""}`; const pad = " ".repeat(Math.max(2, 30 - stripAnsi(left).length)); return ` ${left}${pad}${desc}`; } /** The shared discovery/output/note footer shown under the command list. */ function helpFooter() { return ` ${c.bold("Server discovery:")} CLAUDE_DASHBOARD_PORT / DASHBOARD_PORT env vars win, otherwise the live server is found via ~/.claude/.agent-dashboard.json, falling back to http://127.0.0.1:4820. ${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped. Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / CCAM_COLOR=1. ${c.bold("Note:")} ccam talks to the local dashboard server — API-backed commands require it to be running (${c.bold("ccam start")} brings one up in the background). Prefer an interactive session? Run ${c.bold("ccam repl")} for a shell with completion, history, and a live status prompt.`; } function cmdHelp() { const section = (title) => `\n${c.cyan("▍")}${c.bold(title)}`; const body = COMMAND_GROUPS.map( ([group, items]) => `${section(group)}\n` + items.map(([n, a, d]) => catalogRow(n, a, d)).join("\n") ).join("\n"); console.log( `${c.cyan("▍")}${c.bold("ccam")} — Claude Code Agent Monitor CLI\n\n` + `${c.bold("Usage:")} ccam [options]\n` + body + "\n" + helpFooter() ); } /** Read the package version (single source of truth: package.json). */ function pkgVersion() { try { return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version; } catch { return null; } } /** Print the CLI/package version. */ function cmdVersion() { const v = pkgVersion(); console.log(v ? `ccam ${v}` : "ccam (version unknown)"); } // ── Offline command handlers & dispatch ──────────────────────────────────── // Each handler mirrors its online command's output using offlineData // providers. Commands absent from this map cannot run correctly without the // server (live capture, server-side aggregation/cost math, or mutations that // must go through the server's transaction + broadcast path) — the // dispatcher tells the user so, with the reason. const OFFLINE_HANDLERS = { async stats() { const db = requireDb(); const s = offlineData.stats(db); // Correct the status distribution the same way the session list is // corrected, so counts and rows never disagree. const rows = db.all("SELECT id, status, cwd FROM sessions"); const fix = livenessCorrect(rows); if (fix.available) { const dist = {}; for (const r of rows) dist[r.status] = (dist[r.status] || 0) + 1; s.sessions_by_status = dist; s.active_sessions = dist.active || 0; } renderStats(s, `${dbPath()} (offline)`); livenessNote(fix); }, async sessions(flags) { const data = offlineData.sessions(requireDb(), flags); const fix = livenessCorrect(data.sessions); renderSessions(data); livenessNote(fix); }, async agents(flags) { const db = requireDb(); const data = offlineData.agents(db, flags); const sessions = db.all("SELECT id, status, cwd FROM sessions WHERE status = 'active'"); const fix = livenessCorrect(sessions); if (fix.deadIds.size) livenessCorrectAgents(data.agents, fix.deadIds); renderAgents(data); livenessNote(fix); }, async events(flags) { renderEvents(offlineData.events(requireDb(), flags)); }, async kanban() { const db = requireDb(); const sess = offlineData.sessions(db, { limit: "200" }); const ag = offlineData.agents(db, { limit: "400" }); const fix = livenessCorrect(sess.sessions); if (fix.deadIds.size) livenessCorrectAgents(ag.agents, fix.deadIds); renderKanban(sess, ag); livenessNote(fix); }, async session(flags, positional) { const id = positional[0]; if (!id) { console.error(c.red("✖ Usage: ccam session ")); process.exit(1); } const db = requireDb(); const rows = db.all("SELECT * FROM sessions WHERE id = ?", id); if (!rows.length) { console.error(c.red(`✖ Session not found: ${id}`)); process.exit(1); } const s = rows[0]; const fix = livenessCorrect([s]); renderSessionMeta(s); kvLine("Cost", c.dim("requires the server (pricing math runs server-side)")); const agents = db.all("SELECT * FROM agents WHERE session_id = ? ORDER BY started_at ASC", id); if (fix.deadIds.size) livenessCorrectAgents(agents, fix.deadIds); if (agents.length) renderAgentTree(agents); const events = db.all( "SELECT * FROM events WHERE session_id = ? ORDER BY created_at DESC LIMIT 10", id ); if (events.length) renderEventLines(events); livenessNote(fix); }, async pricing(flags, positional) { if (positional[0]) { serverDownExit( "pricing changes must go through the server (cache invalidation + broadcasts)" ); } const db = requireDb(); renderPricingTable(db.all("SELECT * FROM model_pricing ORDER BY LENGTH(model_pattern) DESC")); }, async alerts(flags, positional) { if (positional[0]) { serverDownExit("acknowledging alerts is a server-side mutation"); } const db = requireDb(); const conds = flags.unacked ? "WHERE acknowledged_at IS NULL" : ""; const rows = db .all( `SELECT * FROM alert_events ${conds} ORDER BY triggered_at DESC LIMIT ?`, Number(flags.limit || 20) ) .map((a) => [ a.id, a.acknowledged_at ? c.dim("acked") : c.yellow("open"), fmtTime(a.triggered_at), (a.rule_name || "").slice(0, 24), (a.message || "").slice(0, 52), ]); const unacked = db.all( "SELECT COUNT(*) AS n FROM alert_events WHERE acknowledged_at IS NULL" )[0].n; const total = db.all("SELECT COUNT(*) AS n FROM alert_events")[0].n; table(["ID", "State", "Triggered", "Rule", "Message"], rows); console.log(c.dim(`\n${unacked} unacknowledged of ${total}`)); }, async rules() { const db = requireDb(); const rows = db .all("SELECT * FROM alert_rules ORDER BY id") .map((r) => [ r.id, r.enabled ? c.green("on") : c.dim("off"), r.rule_type, (r.name || "").slice(0, 32), `${r.cooldown_minutes ?? "-"}m`, ]); table(["ID", "Enabled", "Type", "Name", "Cooldown"], rows); }, async export(flags, positional) { const db = requireDb(); const file = positional[0] || `ccam-export-${new Date().toISOString().slice(0, 10)}.json`; const payload = { exported_at: new Date().toISOString(), exported_offline: true, sessions: db.all("SELECT * FROM sessions ORDER BY started_at DESC"), agents: db.all("SELECT * FROM agents ORDER BY started_at DESC"), events: db.all("SELECT * FROM events ORDER BY created_at DESC"), token_usage: db.all("SELECT * FROM token_usage"), model_pricing: db.all("SELECT * FROM model_pricing ORDER BY LENGTH(model_pattern) DESC"), }; fs.writeFileSync(file, JSON.stringify(payload, null, 2)); console.log( `${c.green("✔")} Exported to ${c.bold(file)} (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)` ); }, async doctor() { heading("ccam doctor", "offline"); let failed = true; // offline is always a failure for live monitoring console.log( `${c.red("○")} Dashboard server NOT running ${c.dim(`(tried ${baseUrl()})`)} — start with: ${c.bold("ccam start")}` ); console.log( `${c.dim("·")} Remote sources require a live server (ccam remote-sources / Settings)` ); const file = dbPath(); if (fs.existsSync(file)) { const db = requireDb(); console.log( `${c.green("✔")} Database ${file} (${(fs.statSync(file).size / 1048576).toFixed(1)} MB)` ); for (const t of ["sessions", "agents", "events", "model_pricing", "token_usage"]) { try { console.log( `${c.dim("·")} rows: ${t} ${db.all(`SELECT COUNT(*) AS n FROM ${t}`)[0].n}` ); } catch { /* table may not exist on very old DBs */ } } } else { failed = true; console.log(`${c.red("✖")} Database not found at ${file}`); } try { const settingsPath = path.join( process.env.CLAUDE_HOME || path.join(require("node:os").homedir(), ".claude"), "settings.json" ); const installed = fs.existsSync(settingsPath) && fs.readFileSync(settingsPath, "utf8").includes("hook-handler.js"); if (installed) { console.log(`${c.green("✔")} Claude Code hooks installed (${settingsPath})`); } else { failed = true; console.log(`${c.red("✖")} Claude Code hooks NOT installed — run: npm run install-hooks`); } } catch { console.log(`${c.dim("·")} Claude Code hooks could not be checked`); } if (failed) process.exit(1); }, }; /** Reasons shown when a server-only command is attempted offline. */ const SERVER_ONLY_REASONS = { tail: "live capture needs the running server (hooks only post to a live server)", analytics: "analytics aggregation runs server-side", workflows: "workflow intelligence is computed server-side", runs: "workflow-run reconstruction runs server-side", cost: "cost math (pricing rules, compaction baselines) runs server-side", webhooks: "webhook configuration and test deliveries are server-side", import: "imports must go through the server's ingestion pipeline", "import-data": "restoring an export writes to the database through the server", cleanup: "cleanup is a server-side mutation", "clear-data": "data wipes must go through the server", "reinstall-hooks": "hook installation is performed by the server", "update-check": "the update check runs server-side (git fetch against the canonical remote)", info: "system info (uptime, memory, WS connections) only exists on a running server", health: "health is, by definition, a check against the running server", }; /** Open the DB read-only or exit with guidance. */ function requireDb() { const db = openDbReadonly(); if (!db) { console.error(c.red(`✖ No readable database at ${dbPath()}`)); console.error(c.dim(" Nothing has been captured yet, or no SQLite driver is available.")); console.error(c.dim(" Start the server to begin capturing: ccam start")); process.exit(1); } return db; } // ── Interactive REPL (ccam shell) ─────────────────────────────────────────── // `ccam repl` opens a persistent prompt where commands are typed WITHOUT the // `ccam` prefix. Each command runs as a short-lived child `ccam` process, so // behavior is byte-identical to the one-shot CLI and a command that exits // non-zero, blocks (tail), or refuses offline can never take the shell down // with it. Colors, tab-completion, arrow-key history (persisted), and a live // server-status prompt make it a comfortable place to live while monitoring. /** Split a REPL input line into argv, honoring single/double quotes so * `session "my id"` and `pricing set 'foo%'` tokenize correctly. */ function tokenizeLine(line) { const re = /"([^"]*)"|'([^']*)'|(\S+)/g; const out = []; let m; while ((m = re.exec(line)) !== null) out.push(m[1] ?? m[2] ?? m[3]); return out; } /** Known flags, offered by the completer after a command word. */ const REPL_FLAGS = [ "--status", "--session", "--limit", "--q", "--unacked", "--yes", "--port", "--hours", "--days", "--input", "--output", "--cache-read", "--cache-write", "--cache-write-1h", "--fast-input", "--fast-output", "--intro-input", "--intro-output", "--intro-cache-read", "--intro-cache-write", "--intro-cache-write-1h", "--intro-until", "--name", "--no-color", ]; /** readline completer: commands on the first word, subcommands on the second, * flags once a `--` token is being typed. Returns [hits, prefix]. */ function replCompleter(line) { const toks = line.split(/\s+/); const word = toks[toks.length - 1]; if (toks.length <= 1) { const hits = COMMANDS.filter((cmd) => cmd.startsWith(word)); return [hits.length ? hits : COMMANDS, word]; } const subs = SUBCOMMANDS[toks[0]]; if (subs && toks.length === 2 && !word.startsWith("--")) { const hits = subs.filter((s) => s.startsWith(word)); return [hits.length ? hits : subs, word]; } if (word.startsWith("--")) { const hits = REPL_FLAGS.filter((f) => f.startsWith(word)); return [hits.length ? hits : REPL_FLAGS, word]; } return [[], word]; } // The CCAM word-mark, shown once when the interactive shell starts (and via // the `banner` built-in). Kept as raw text so the backslashes render verbatim. const BANNER_ART = String.raw` _____ _____ _____ _____ /\ \ /\ \ /\ \ /\ \ /::\ \ /::\ \ /::\ \ /::\____\ /::::\ \ /::::\ \ /::::\ \ /::::| | /::::::\ \ /::::::\ \ /::::::\ \ /:::::| | /:::/\:::\ \ /:::/\:::\ \ /:::/\:::\ \ /::::::| | /:::/ \:::\ \ /:::/ \:::\ \ /:::/__\:::\ \ /:::/|::| | /:::/ \:::\ \ /:::/ \:::\ \ /::::\ \:::\ \ /:::/ |::| | /:::/ / \:::\ \ /:::/ / \:::\ \ /::::::\ \:::\ \ /:::/ |::|___|______ /:::/ / \:::\ \ /:::/ / \:::\ \ /:::/\:::\ \:::\ \ /:::/ |::::::::\ \ /:::/____/ \:::\____\/:::/____/ \:::\____\/:::/ \:::\ \:::\____\/:::/ |:::::::::\____\ \:::\ \ \::/ /\:::\ \ \::/ /\::/ \:::\ /:::/ /\::/ / ~~~~~/:::/ / \:::\ \ \/____/ \:::\ \ \/____/ \/____/ \:::\/:::/ / \/____/ /:::/ / \:::\ \ \:::\ \ \::::::/ / /:::/ / \:::\ \ \:::\ \ \::::/ / /:::/ / \:::\ \ \:::\ \ /:::/ / /:::/ / \:::\ \ \:::\ \ /:::/ / /:::/ / \:::\ \ \:::\ \ /:::/ / /:::/ / \:::\____\ \:::\____\ /:::/ / /:::/ / \::/ / \::/ / \::/ / \::/ / \/____/ \/____/ \/____/ \/____/ `; /** Print the entry banner: the word-mark (when the terminal is wide enough), * a tagline with version and live status, and the one-line orientation tips. */ function replBanner(up) { const v = pkgVersion(); if (termWidth() >= 100) { console.log(c.cyan(BANNER_ART)); } else { console.log(`\n${c.cyan("▍")}${c.bold("ccam")}`); } const dot = up ? c.green("●") : c.red("○"); const where = up ? baseUrl().replace(/^https?:\/\//, "") : "offline"; console.log( ` ${c.bold("Claude Code Agent Monitor")} ${c.dim("· interactive shell")}` + (v ? c.dim(` · v${v}`) : "") + ` ${dot} ${c.dim(where)}` ); console.log( c.dim(" Type commands without the 'ccam' prefix — e.g. ") + c.bold("sessions --limit 5") ); console.log( c.dim(" ") + c.bold("help") + c.dim(" all commands · ") + c.bold("help ") + c.dim(" details · Tab completes · ↑/↓ history · ") + c.bold("exit") + c.dim(" to quit") ); console.log(); } /** Categorized REPL help: shell built-ins first, then the full command * catalog (identical groups/descriptions to `ccam help`). */ function replHelp() { const row = (name, desc) => ` ${c.cyan(name.padEnd(20))}${desc}`; console.log(`\n${c.cyan("▍")}${c.bold("ccam shell")} ${c.dim("— built-ins")}`); console.log(row("help", "This help. " + c.dim("`help ` shows one command's details"))); console.log(row("commands", "Compact list of every command, grouped")); console.log( row("watch [secs]", "Re-run a command on a timer, screen-clearing (Ctrl+C stops)") ); console.log(row("history", "Show recent command history")); console.log(row("banner", "Reprint the welcome banner")); console.log(row("clear, cls", "Clear the screen")); console.log(row("exit, quit, q", "Leave the shell (also Ctrl+D)")); for (const [group, items] of COMMAND_GROUPS) { console.log(`\n${c.cyan("▍")}${c.bold(group)}`); for (const [name, args, desc] of items) console.log(catalogRow(name, args, desc)); } console.log( `\n${c.dim("Read commands work offline; server-only commands print the reason. Each line runs isolated, so nothing takes the shell down.")}\n` ); } /** Detailed help for one command (or command prefix): every catalog entry * whose invocation starts with the token. Returns false if none matched. */ function commandHelp(token) { const matches = []; for (const [group, items] of COMMAND_GROUPS) { for (const [name, args, desc] of items) { if (name === token || name.startsWith(token + " ")) matches.push([group, name, args, desc]); } } if (!matches.length) return false; console.log(`\n${c.cyan("▍")}${c.bold(token)} ${c.dim(`— ${matches[0][0]}`)}`); for (const [, name, args, desc] of matches) { console.log(catalogRow(name, args, desc)); } const subs = SUBCOMMANDS[token]; if (subs) console.log(c.dim(` subcommands: ${subs.join(", ")}`)); console.log(); return true; } /** Compact grouped command list for the `commands` built-in. */ function replCommandsList() { for (const [group, items] of COMMAND_GROUPS) { const names = items .map(([n]) => c.cyan(n.split(" ")[0])) .filter((v, i, a) => a.indexOf(v) === i); console.log(` ${c.bold(group.padEnd(20))}${[...new Set(names)].join(" ")}`); } } /** * The interactive shell. Reads lines, dispatches each as a child `ccam` * process (inheriting stdio so tables/colors/streaming render natively), * and keeps a persisted history. On a TTY the prompt shows live server * status (● up / ○ offline) and the resolved host; piped input (tests, * scripts) runs each line and exits at EOF. */ async function cmdRepl() { const readline = require("node:readline"); const isTty = Boolean(process.stdin.isTTY && process.stdout.isTTY); const historyFile = path.join(REPO_ROOT, "data", ".ccam_repl_history"); let history = []; try { history = fs.readFileSync(historyFile, "utf8").split("\n").filter(Boolean).slice(-500); } catch { /* no history yet */ } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: isTty, completer: isTty ? replCompleter : undefined, history: history.slice().reverse(), // readline wants most-recent first historySize: 500, prompt: "", }); // Cache the health probe briefly so the prompt does not hammer the server. let statusCache = { at: 0, up: false }; async function serverUpCached() { const now = Date.now(); if (now - statusCache.at < 3000) return statusCache.up; const up = await serverIsUp(); statusCache = { at: now, up }; return up; } async function renderPrompt() { if (!isTty) return; // piped input needs no prompt const up = await serverUpCached(); const dot = up ? c.green("●") : c.red("○"); const where = up ? c.dim(baseUrl().replace(/^https?:\/\//, "")) : c.dim("offline"); rl.setPrompt(`${dot} ${c.bold("ccam")} ${where} ${c.cyan("›")} `); rl.prompt(); } // Ctrl+C must never kill the shell: a no-op process handler keeps the // parent alive while a child (e.g. `tail`) is the real SIGINT target; the // readline SIGINT event (fires only at the prompt) just resets the line. let childActive = false; const sigintNoop = () => {}; process.on("SIGINT", sigintNoop); rl.on("SIGINT", () => { if (childActive) return; console.log(c.dim(" (type exit or press Ctrl+D to quit)")); renderPrompt(); }); /** Run one child `ccam` invocation with inherited stdio. */ function runChild(argv) { return new Promise((resolve) => { childActive = true; const env = { ...process.env }; if (useColor) env.FORCE_COLOR = "1"; env.CCAM_REPL = "1"; const child = spawn(process.execPath, [__filename, ...argv], { stdio: "inherit", env, }); child.on("close", () => { childActive = false; resolve(); }); child.on("error", (err) => { childActive = false; console.error(c.red(`✖ ${err?.message || err}`)); resolve(); }); }); } /** * `watch ` — re-run a command on a timer, clearing the screen each * tick, until Ctrl+C. A dedicated SIGINT listener sets an abort flag so the * loop unwinds cleanly (during a child the signal also reaches the child; * between ticks the interruptible sleep notices the flag) — the shell * itself always survives. */ async function runWatch(argv, intervalMs) { let abort = false; const onSig = () => { abort = true; }; process.prependListener("SIGINT", onSig); try { while (!abort) { if (isTty) process.stdout.write("\x1b[2J\x1b[H"); console.log( c.dim( `⟳ watch: ccam ${argv.join(" ")} — every ${Math.round(intervalMs / 1000)}s · ${new Date().toLocaleTimeString()} · Ctrl+C to stop` ) ); await runChild(argv); if (abort) break; await new Promise((r) => { const poll = setInterval(() => { if (abort) { clearInterval(poll); r(); } }, 100); setTimeout(() => { clearInterval(poll); r(); }, intervalMs); }); } } finally { process.removeListener("SIGINT", onSig); } } // Welcome banner (word-mark + status + tips) on an interactive terminal. if (isTty) replBanner(await serverUpCached()); // `for await` pulls one line at a time and only requests the next once the // body finishes — this serializes command execution (crucial for piped // input, where naive 'line' listeners would interleave async handlers). await renderPrompt(); for await (const raw of rl) { const line = raw.trim(); if (!line) { await renderPrompt(); continue; } const toks = tokenizeLine(line); const head = toks[0]; // Shell built-ins run in-process (no child spawn). if (["exit", "quit", "q", ":q"].includes(head)) break; if (head === "clear" || head === "cls") { if (isTty) process.stdout.write("\x1b[2J\x1b[H"); await renderPrompt(); continue; } if (head === "banner") { replBanner(await serverUpCached()); await renderPrompt(); continue; } if (head === "help" || head === "?") { // `help` → everything; `help ` → that command's details. if (toks.length === 1) replHelp(); else if (!commandHelp(toks[1])) { console.log(c.dim(`No such command: ${toks[1]}. Type `) + c.bold("commands") + c.dim(".")); } await renderPrompt(); continue; } if (head === "commands") { replCommandsList(); await renderPrompt(); continue; } if (head === "watch") { if (!isTty) { console.log(c.dim("watch needs an interactive terminal.")); await renderPrompt(); continue; } // `watch ` (default 2s) or `watch `. let rest2 = toks.slice(1); let secs = 2; if (rest2.length && /^\d+$/.test(rest2[0])) { secs = Math.max(1, Number(rest2[0])); rest2 = rest2.slice(1); } if (!rest2.length) { console.log(c.dim("Usage: watch [seconds] e.g. watch 5 stats")); } else { await runWatch(rest2, secs * 1000); } await renderPrompt(); continue; } if (head === "history") { const recent = history.slice(-30); recent.forEach((h, i) => console.log(`${c.dim(String(history.length - recent.length + i + 1).padStart(4))} ${h}`) ); await renderPrompt(); continue; } if (head === "repl" || head === "shell" || head === "i") { console.log(c.dim("Already in the ccam shell.")); await renderPrompt(); continue; } // Record history (skip consecutive duplicates), then dispatch as a child. if (history[history.length - 1] !== line) { history.push(line); try { fs.mkdirSync(path.dirname(historyFile), { recursive: true }); fs.appendFileSync(historyFile, line + "\n"); } catch { /* history is best-effort */ } } await runChild(toks); await renderPrompt(); } rl.close(); if (isTty) console.log(c.dim("Bye.")); process.removeListener("SIGINT", sigintNoop); } // ── Command dispatch ──────────────────────────────────────────────────────── // The single source of truth for "argv → command handler". Both the top-level // entry point and the interactive REPL route through here, so every command // behaves identically whether typed as `ccam ` or entered at the `ccam ›` // prompt. Handlers that need the server throw ServerDownError; the caller // decides whether to fall back to an offline reader or report the refusal. /** All top-level command tokens, derived from COMMAND_GROUPS (first word of * each invocation) plus the always-available `help`. Used by the REPL * completer and unknown-command detection so they never drift from help. */ const COMMANDS = (() => { const set = new Set(); for (const [, items] of COMMAND_GROUPS) { for (const [name] of items) set.add(name.split(" ")[0]); } set.add("help"); return [...set]; })(); /** Subcommand tokens per command, used by the REPL's tab completer. */ const SUBCOMMANDS = { alerts: ["ack", "ack-all"], pricing: ["set", "delete", "reset"], webhooks: ["test"], import: ["rescan", "path"], }; /** Run one parsed command. Returns the handler's promise; may throw * ServerDownError (for the caller's offline routing) or call process.exit * for usage/validation failures. `cmd === "repl"` is handled by the entry * points, not here, to avoid recursive REPLs. */ async function runCommand(argv) { const [cmd, ...rest] = argv; const { flags, positional } = parseArgs(rest); switch (cmd) { case "health": return cmdHealth(); case "status": return cmdStatus(); case "start": return cmdStart(flags); case "stats": return cmdStats(); case "kanban": return cmdKanban(); case "tail": return cmdTail(flags); case "sessions": return cmdSessions(flags); case "session": return cmdSession(positional); case "agents": return cmdAgents(flags); case "events": return cmdEvents(flags); case "analytics": return cmdAnalytics(); case "workflows": return cmdWorkflows(flags); case "runs": return cmdRuns(flags); case "cost": return cmdCost(flags); case "alerts": return cmdAlerts(flags, positional); case "rules": return cmdRules(); case "webhooks": return cmdWebhooks(flags, positional); case "remote-sources": case "remotes": return cmdRemoteSources(flags, positional); case "pricing": return cmdPricing(flags, positional); case "import": return cmdImport(flags, positional); case "doctor": return cmdDoctor(); case "info": return cmdInfo(); case "export": return cmdExport(positional); case "import-data": return cmdImportData(positional); case "cleanup": return cmdCleanup(flags); case "clear-data": return cmdClearData(flags); case "reinstall-hooks": return cmdReinstallHooks(); case "update-check": return cmdUpdateCheck(); case "lanes": if (rest[0] === "add") { return cmdLanesAdd(rest.slice(1)); } if (rest[0] === "profile") { if (rest[1] === "init") return cmdLanesProfileInit(rest.slice(2)); if (rest[1] === "check") return cmdLanesProfileCheck(rest.slice(2)); console.error( "usage: ccam lanes profile init [--force] | ccam lanes profile check []" ); process.exitCode = 1; return; } if (["reset", "remove", "purge"].includes(rest[0])) { return cmdLanesLifecycle(rest[0], rest.slice(1)); } if (["up", "down", "runtime", "logs", "hook"].includes(rest[0])) { return cmdLanesRuntime(rest[0], rest.slice(1)); } return cmdLanes(); case "lock": { const sub = rest[0]; if (sub === "status") return cmdLockStatus(rest.slice(1)); if (sub === "acquire") return cmdLockAcquire(rest.slice(1)); if (sub === "release") return cmdLockRelease(rest.slice(1)); console.error( "usage: ccam lock status [] | ccam lock acquire [--holder X] [--timeout N] | ccam lock release [--holder X]" ); process.exitCode = 1; return; } case "stage": return cmdStage(rest); case "open": return cmdOpen(); case "version": case "--version": case "-v": return cmdVersion(); case "help": case "--help": case "-h": case undefined: return cmdHelp(); default: console.error(c.red(`✖ Unknown command: ${cmd}`)); cmdHelp(); process.exit(1); } } /** * Route a ServerDownError: run the command's offline handler under the * offline banner if one exists, otherwise print the server-required refusal. * Shared by the top-level entry and (indirectly, via a child process) the * REPL. `throwOnExit` makes the terminal refusal throw instead of exiting so * an embedding loop can stay alive. */ async function handleServerDown(argv) { const cmd = argv[0]; const { flags, positional } = parseArgs(argv.slice(1)); const handler = OFFLINE_HANDLERS[cmd]; if (handler) { offlineBanner(); try { await handler(flags, positional); return; } catch (offErr) { console.error(c.red(`✖ Offline read failed: ${offErr?.message || offErr}`)); process.exit(1); } } serverDownExit(SERVER_ONLY_REASONS[cmd]); } // ── Entry point ───────────────────────────────────────────────────────────── async function main() { // --no-color is a global presentation flag (already consumed by the color // detector at module load) — strip it so it can appear anywhere without // being mistaken for a command or a subcommand flag. const argv = process.argv.slice(2).filter((a) => a !== "--no-color"); const cmd = argv[0]; if (cmd === "repl" || cmd === "shell" || cmd === "i") return cmdRepl(); return runCommand(argv); } main().catch(async (err) => { if (err instanceof ServerDownError) { const argv = process.argv.slice(2).filter((a) => a !== "--no-color"); await handleServerDown(argv); return; } console.error(c.red(`✖ ${err?.message || err}`)); process.exit(1); });