Files
Claude-Code-Monitor/bin/ccam.js
T

3250 lines
121 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <command>`, or interactively at the
* `ccam ` prompt via `ccam repl`.
*
* Server status · start · repl (interactive shell)
* Monitoring health · stats · kanban · tail
* Data browsing sessions · session <id> · agents · events
* Insights analytics · workflows · runs · cost
* Alerting alerts · alerts ack <id> · alerts ack-all · rules
* Webhooks webhooks · webhooks test <id>
* Pricing pricing · pricing set/delete/reset
* Import import rescan · import path <dir> · import-data <file>
* 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ĩ <vinnt@smartgift.vn>
*/
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, b, options) => api("GET", p, undefined, options);
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 <session-id>"));
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 <pattern> --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 <dir>"));
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 <export.json>"));
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 <N> and/or --days <M>"));
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 <pid>` (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 <path> [--title <text>] [--base <branch>] [--slug <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 <path> --title <text> [--pipeline <id>]");
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 <repo>` — 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 <repo> [--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 [<path>]` — 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} <id> [--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 <path> --title <text>");
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 = {};
if (laneArgs.includes("--no-build")) body.build = false;
if (laneArgs.includes("--qc")) body.qc = true;
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 [<id>] <service> [--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 [<id>] <name> [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}`
);
}
if (sub === "sync-base") {
const mode = laneArgs.includes("--check")
? "check"
: laneArgs.includes("--continue")
? "continue"
: "merge";
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
const result = await post(
`/api/lanes/${laneId}/sync-base`,
{ mode, branch },
{ allowError: true }
);
if (result.status) {
console.error(`✖ sync-base → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
if (result.code === 5) {
console.error(`✖ lane #${laneId} — MIGRATION NUMBER COLLISION (nothing merged):`);
for (const c of result.collisions) {
console.error(` ${c.file} collides with ${c.collidesWith} — rename to ${c.suggestion}`);
}
process.exitCode = 5;
return;
}
if (result.code === 4) {
console.error(`✖ lane #${laneId} — MERGE CONFLICT (left in place).`);
console.error(` conflicted: ${result.conflictedFiles.join(", ")}`);
console.error(" resolve, then: git add <resolved files> && git commit --no-edit");
console.error(
` then: ccam lanes sync-base --continue ${branch ? branch + " " : ""}${laneId}`
);
process.exitCode = 4;
return;
}
if (mode === "check") {
if (result.devDelta === null) {
console.log("DEV_DELTA: unknown (no merge-base with origin/development)");
} else {
console.log(
`DEV_DELTA: ${result.devDelta.length} file(s) changed on origin/development since merge-base`
);
for (const f of result.devDelta) console.log(` ${f}`);
if (result.overlap.length) {
console.log(
`DEV_OVERLAP: ${result.overlap.length} file(s) — the upstream delta touches the feature's files:`
);
for (const f of result.overlap) console.log(` ${f}`);
} else {
console.log("DEV_OVERLAP: none");
}
}
console.log(`lane #${laneId} preflight vs origin/development: OK`);
return;
}
console.log(
`lane #${laneId} — synced with origin/development (re-enter the pipeline at the gates)`
);
return;
}
}
/**
* The default lock holder identity for the calling lane: `lane<slot>` when
* the lane has one allocated, else `lane<id>` — 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 [<name>]` — 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 <name> [--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 <name> [--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 <name> [--holder X]`. */
async function cmdLockRelease(args) {
const name = args.find((arg) => !arg.startsWith("--"));
if (!name) {
console.error("usage: ccam lock release <name> [--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 <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.
*/
function fmtFeatureRow(f) {
const marker = f.archived_at ? " " : "▶ ";
return `${marker}${f.slug.padEnd(24)} ${String(f.stage).padEnd(12)} ${f.progress}%${
f.archived_at ? ` (archived ${fmtTime(f.archived_at)})` : ""
}`;
}
/** `ccam feature list [<id>] [--cwd path]` — every feature this lane has activated. */
async function cmdFeatureList(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
if (!features.length) {
console.log("no features activated yet — start one with: ccam feature activate <slug>");
return;
}
for (const f of features) console.log(fmtFeatureRow(f));
}
/** `ccam feature activate <slug> [--title X] [<id>] [--cwd path]`. */
async function cmdFeatureActivate(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
console.error("usage: ccam feature activate <slug> [--title text]");
process.exitCode = 1;
return;
}
const flag = (name) => {
const i = args.indexOf(`--${name}`);
return i > -1 ? args[i + 1] : undefined;
};
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
slug,
title: flag("title"),
});
console.log(
`${c.green("✔")} lane #${lane.id} now on feature "${feature.slug}" (stage: ${feature.stage}, ${feature.progress}%)`
);
}
/** `ccam feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
/** `ccam lanes proof-link [<id>] [--cwd path]` — converge the clone-root
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
* by anything else in this codebase; a session or hook calls it explicitly. */
async function cmdLanesProofLink(args) {
const resolved = await resolveLaneArg(args);
if (!resolved) return;
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
console.log(
linked
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
: "proof/ already linked, nothing to do"
);
}
async function cmdFeatureShow(args) {
const slug = args.find((arg) => !arg.startsWith("--"));
if (!slug) {
console.error("usage: ccam feature show <slug>");
process.exitCode = 1;
return;
}
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
if (!resolved) return;
const result = await get(
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
undefined,
{ allowError: true }
);
if (result.status) {
console.error(`✖ feature "${slug}" → ${result.data?.error?.message || result.status}`);
process.exitCode = 1;
return;
}
const f = result.feature;
console.log(`${f.slug} ${f.archived_at ? "(archived)" : "(active)"}`);
console.log(` stage: ${f.stage} status: ${f.status} progress: ${f.progress}%`);
for (const node of f.pipeline_nodes) console.log(` ${node.state.padEnd(18)} ${node.label}`);
}
async function cmdStage(args) {
const stage = args[0];
if (!stage || stage.startsWith("--")) {
console.error(
"usage: ccam stage <stage> [--lane <id>] [--cwd <path>] [--status <s>] [--evidence <text>] [--note <text>] [--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 <cmd>`, 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 <id>", "", "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 <id>", "", "Acknowledge one alert"],
["alerts ack-all", "", "Acknowledge every unacked alert"],
["rules", "", "List alert rules"],
["webhooks", "", "List webhook targets"],
["webhooks test <id>", "", "Send a synthetic test alert to a target"],
],
],
[
"Pricing",
[
["pricing", "", "List model pricing rules"],
[
"pricing set <pattern>",
"",
"--input/--output N, plus --cache-*, --fast-*, --intro-* --intro-until YYYY-MM-DD",
],
["pricing delete <pattern>", "", "Delete a pricing rule"],
["pricing reset", "", "Reset pricing rules to defaults"],
],
],
[
"Import",
[
["import rescan", "", "Re-scan ~/.claude/projects"],
["import path <dir>", "", "Import every .jsonl under a directory"],
["import-data <file>", "", "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 <id>", "", "Probe SSH connectivity to a source"],
["remote-sources sync [id]", "", "Pull history now (all sources if id omitted)"],
["remote-sources rm <id>", "[--purge]", "Remove a source (--purge also deletes its data)"],
],
],
[
"Lanes",
[
["lanes", "", "List lanes with stage, liveness and progress"],
["lanes add", "--cwd <path> --title <text>", "Create a new lane bound to a directory"],
[
"lanes add",
"--repo <path> [--title <text>] [--base <branch>] [--slug <slug>]",
"Provision a managed worktree lane",
],
[
"lanes profile init",
"<repo> [--force]",
"Detect a Node.js project and scaffold .ccam/profile/",
],
[
"lanes profile check",
"[<path>]",
"Validate a profile (path defaults to cwd, not a lane id)",
],
[
"lanes reset|remove|purge",
"<id> [--force] [--keep-db] --yes",
"Show preflight facts, then perform a destructive lane action (--keep-db: reset only)",
],
[
"lanes up|down",
"[<id>] [--no-build] [--qc]",
"Boot or stop the lane's own app stack (--no-build: up only, skip the build step; --qc: up only, inject QC_BOOT_ENV for a deterministic stack; id defaults to the lane owning this directory)",
],
["lanes runtime", "[<id>]", "Slot, ports, service health and the last boot error"],
["lanes logs", "[<id>] <svc> [--tail N]", "Tail one of the lane's hook or service logs"],
["lanes hook", "[<id>] <name> [args…]", "Run a profile hook (ci-gate, e2e, migrate, …)"],
[
"lanes sync-base",
"[<id>] [--check|--continue] [branch]",
"Fetch + migration-collision preflight, or merge origin/development into the feature branch (--check: read-only; --continue: finish after a resolved conflict; bare: merge, branch defaults to the lane's current branch)",
],
[
"lanes proof-link",
"[<id>]",
"Converge clone-root proof/ onto .playwright-mcp/proof (idempotent, never automatic)",
],
[
"lock status|acquire|release",
"[<name>] [--holder X] [--timeout N]",
"Cross-lane named lock (serialize builds/e2e across all lanes; holder defaults to the calling lane)",
],
["stage <stage> [flags]", "", "Report the current pipeline stage for a lane"],
["feature list", "[<id>]", "List every feature this lane has activated, archived or live"],
[
"feature activate",
"<slug> [--title text] [<id>]",
"Switch to a feature by slug, archiving the current one first (echoes the canonicalized slug)",
],
[
"feature show",
"<slug> [<id>]",
"Show one feature's saved pipeline (works on an archived one too)",
],
],
],
[
"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 <command> [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 <session-id>"));
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 <cmd>") +
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 <command>` shows one command's details")));
console.log(row("commands", "Compact list of every command, grouped"));
console.log(
row("watch <cmd> [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 <cmd…>` — 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 <cmd>` → 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 <cmd…>` (default 2s) or `watch <secs> <cmd…>`.
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] <command …> 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 <cmd>` 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 <repo> [--force] | ccam lanes profile check [<path>]"
);
process.exitCode = 1;
return;
}
if (["reset", "remove", "purge"].includes(rest[0])) {
return cmdLanesLifecycle(rest[0], rest.slice(1));
}
if (["up", "down", "runtime", "logs", "hook", "sync-base"].includes(rest[0])) {
return cmdLanesRuntime(rest[0], rest.slice(1));
}
if (rest[0] === "proof-link") return cmdLanesProofLink(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 [<name>] | ccam lock acquire <name> [--holder X] [--timeout N] | ccam lock release <name> [--holder X]"
);
process.exitCode = 1;
return;
}
case "stage":
return cmdStage(rest);
case "feature": {
const sub = rest[0];
if (sub === "list") return cmdFeatureList(rest.slice(1));
if (sub === "activate") return cmdFeatureActivate(rest.slice(1));
if (sub === "show") return cmdFeatureShow(rest.slice(1));
console.error(
"usage: ccam feature list | ccam feature activate <slug> [--title text] | ccam feature show <slug>"
);
process.exitCode = 1;
return;
}
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);
});