8a82895c65
Adds a root `ccam` plugin (`.claude-plugin/plugin.json`, `"source": "./"`) so
`/plugin marketplace add` + `/plugin install ccam@...` is enough on a machine
with nothing but Claude Code: no clone, no npm run setup, no manual npm start.
- scripts/plugin-bootstrap.js: SessionStart hook. Fast-path exit, Node >=22.5
gate (node:sqlite), mkdir lock with stale reclaim, deps installed into
~/.claude/agent-dashboard/runtime/ (never the plugin cache), legacy
checkout-hook cleanup (backed up), ~/.local/bin/ccam launcher, eager UI
build so client routes like /run work immediately, detached server spawn.
- scripts/plugin-open.js, scripts/plugin-doctor.js: /ccam-open, /ccam-doctor.
- server/index.js: DASHBOARD_CLIENT_DIST override (plugin cache is read-only).
- mcp/build/ is committed (plugin MCP servers start before any bootstrap could
build them) and kept honest by scripts/check-mcp-build.js (content hash,
not mtime), enforced by pre-commit when mcp/src changes.
- plugins/ccam-dashboard/.mcp.json moved under plugins/ccam/ with a working
${CLAUDE_PLUGIN_ROOT} path (the old relative path never resolved from a
marketplace-cached subdir).
- Docs: README, INSTALL, SETUP, ARCHITECTURE, CLAUDE.md, docs/PLUGINS.md,
docs/MCP.md, docs/CLI.md, docs/HOOKS.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
396 lines
17 KiB
JavaScript
396 lines
17 KiB
JavaScript
/**
|
||
* @file repl.ts
|
||
* @description Implements a REPL (Read-Eval-Print Loop) transport for the MCP server, allowing users to interact with the dashboard API and invoke registered tools directly from the command line. The REPL provides an interactive prompt with command history and tab completion for tool names and commands. It supports built-in commands for listing tools, showing configuration, and performing health checks, as well as invoking any registered tool with JSON or key=value arguments. The REPL is designed for ease of use and quick experimentation during development or debugging sessions.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
/* =============================================================================
|
||
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
|
||
* =============================================================================
|
||
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
|
||
*
|
||
* ## Design constraints
|
||
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
|
||
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
|
||
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
|
||
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
|
||
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
|
||
*
|
||
* ## Remote data & SSH
|
||
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
|
||
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
|
||
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
|
||
*
|
||
* ## Observability
|
||
* Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four
|
||
* provisioned boards (overview, sessions, tools, alerts). Native npm scripts and
|
||
* Docker Compose profiles are documented in `monitoring/README.md`.
|
||
*
|
||
* ## Internal dependencies
|
||
* - `../config/app-config.js`
|
||
* - `../clients/dashboard-api-client.js`
|
||
* - `../core/logger.js`
|
||
* - `../ui/banner.js`
|
||
* - `../ui/formatter.js`
|
||
* - `../core/tool-registry.js`
|
||
*
|
||
* ## Public surface
|
||
* - `startRepl` — exported API; see TSDoc on the symbol for behavior.
|
||
* - `ReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
|
||
* - `createReplToolCollector` — exported API; see TSDoc on the symbol for behavior.
|
||
*
|
||
* ## Testing pointers
|
||
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
|
||
* - Server contract changes require `npm run test:server` and OpenAPI sync.
|
||
* - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`.
|
||
*
|
||
* ## Related docs
|
||
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
|
||
* - `docs/API.md` — REST reference.
|
||
* - `.claude/skills/file-headers/` — mandatory `@author` header policy.
|
||
* ============================================================================= */
|
||
/* -----------------------------------------------------------------------------
|
||
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
|
||
* -----------------------------------------------------------------------------
|
||
* **startRepl**
|
||
* Part of this module's public contract. Downstream imports should treat
|
||
* the signature and return type as stable unless release notes say otherwise.
|
||
* When behavior changes, update the `@file` overview and relevant tests.
|
||
*
|
||
* **ReplToolCollector**
|
||
* Part of this module's public contract. Downstream imports should treat
|
||
* the signature and return type as stable unless release notes say otherwise.
|
||
* When behavior changes, update the `@file` overview and relevant tests.
|
||
*
|
||
* **createReplToolCollector**
|
||
* Part of this module's public contract. Downstream imports should treat
|
||
* the signature and return type as stable unless release notes say otherwise.
|
||
* When behavior changes, update the `@file` overview and relevant tests.
|
||
*
|
||
* ----------------------------------------------------------------------------- */
|
||
import * as readline from "node:readline";
|
||
import { printBanner, printServerInfo, printShutdown } from "../ui/banner.js";
|
||
import * as c from "../ui/colors.js";
|
||
import { formatToolResult, formatToolError, table, sectionHeader, badge, } from "../ui/formatter.js";
|
||
/** Static `dashboard_<tool> -> domain` lookup mirroring `tools/domains/*.ts`
|
||
* module boundaries, kept literal since `collectAllTools` has no domain
|
||
* concept. Must be updated by hand alongside `index.ts`'s identical copy. */
|
||
const TOOL_DOMAINS = {
|
||
dashboard_health_check: "observability",
|
||
dashboard_get_stats: "observability",
|
||
dashboard_get_analytics: "observability",
|
||
dashboard_get_system_info: "observability",
|
||
dashboard_export_data: "observability",
|
||
dashboard_get_operational_snapshot: "observability",
|
||
dashboard_list_sessions: "sessions",
|
||
dashboard_get_session: "sessions",
|
||
dashboard_create_session: "sessions",
|
||
dashboard_update_session: "sessions",
|
||
dashboard_list_agents: "agents",
|
||
dashboard_get_agent: "agents",
|
||
dashboard_create_agent: "agents",
|
||
dashboard_update_agent: "agents",
|
||
dashboard_list_events: "events",
|
||
dashboard_ingest_hook_event: "events",
|
||
dashboard_get_pricing_rules: "pricing",
|
||
dashboard_get_total_cost: "pricing",
|
||
dashboard_get_session_cost: "pricing",
|
||
dashboard_upsert_pricing_rule: "pricing",
|
||
dashboard_delete_pricing_rule: "pricing",
|
||
dashboard_reset_pricing_defaults: "pricing",
|
||
dashboard_cleanup_data: "maintenance",
|
||
dashboard_reimport_history: "maintenance",
|
||
dashboard_reinstall_hooks: "maintenance",
|
||
dashboard_clear_all_data: "maintenance",
|
||
};
|
||
const DOMAIN_COLORS = {
|
||
observability: c.brightCyan,
|
||
sessions: c.brightGreen,
|
||
agents: c.brightMagenta,
|
||
events: c.brightYellow,
|
||
pricing: (t) => c.bold(c.yellow(t)),
|
||
maintenance: c.brightRed,
|
||
};
|
||
/** Renders a `[domain]` badge in that domain's color, or muted if unknown. */
|
||
function domainBadge(domain) {
|
||
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
|
||
return colorFn(`[${domain}]`);
|
||
}
|
||
/**
|
||
* Starts the interactive REPL and owns the process lifecycle from here —
|
||
* `index.ts` returns immediately, since `readline`'s `"close"` event is this
|
||
* transport's shutdown path. Unlike stdio/http, it never constructs an
|
||
* `McpServer`: `tools` (from `collectAllTools`) is a flat, directly-
|
||
* invokable handler list, so typing a tool name calls its handler
|
||
* in-process, subject to the same `AppConfig` policy flags.
|
||
*/
|
||
export async function startRepl(config, api, logger, tools) {
|
||
printBanner();
|
||
printServerInfo({
|
||
transport: "repl (interactive)",
|
||
version: config.serverVersion,
|
||
dashboard: config.dashboardBaseUrl.toString(),
|
||
mutations: config.allowMutations,
|
||
destructive: config.allowDestructive,
|
||
tools: tools.length,
|
||
});
|
||
process.stdout.write(` ${c.muted("Type")} ${c.accent("help")} ${c.muted("for commands,")} ${c.accent("tools")} ${c.muted("to list available tools,")} ${c.accent("exit")} ${c.muted("to quit.")}\n\n`);
|
||
const rl = readline.createInterface({
|
||
input: process.stdin,
|
||
output: process.stdout,
|
||
prompt: ` ${c.bold(c.brightCyan("mcp"))}${c.dim(c.cyan("›"))} `,
|
||
completer: (line) => {
|
||
const allCompletions = [
|
||
...tools.map((t) => t.name),
|
||
"help",
|
||
"tools",
|
||
"domains",
|
||
"exit",
|
||
"quit",
|
||
"clear",
|
||
"health",
|
||
"stats",
|
||
"status",
|
||
"config",
|
||
];
|
||
const hits = allCompletions.filter((cmd) => cmd.startsWith(line.trim()));
|
||
return [hits.length ? hits : allCompletions, line];
|
||
},
|
||
});
|
||
const toolMap = new Map();
|
||
for (const t of tools)
|
||
toolMap.set(t.name, t);
|
||
rl.prompt();
|
||
rl.on("line", async (line) => {
|
||
const input = line.trim();
|
||
if (!input) {
|
||
rl.prompt();
|
||
return;
|
||
}
|
||
try {
|
||
await handleCommand(input, config, api, tools, toolMap, logger);
|
||
}
|
||
catch (err) {
|
||
process.stdout.write(` ${c.error("Error:")} ${err instanceof Error ? err.message : String(err)}\n`);
|
||
}
|
||
rl.prompt();
|
||
});
|
||
rl.on("close", () => {
|
||
printShutdown();
|
||
process.exit(0);
|
||
});
|
||
}
|
||
/**
|
||
* Dispatches one entered line to a built-in command, or to
|
||
* {@link invokeToolByName} if it matches a known tool name. Built-ins always
|
||
* take precedence. `health`/`stats`/`status` are shortcuts invoking
|
||
* `dashboard_health_check`/`dashboard_get_stats`/
|
||
* `dashboard_get_operational_snapshot` with no arguments.
|
||
*/
|
||
async function handleCommand(input, config, _api, tools, toolMap, logger) {
|
||
const [command, ...rest] = input.split(/\s+/);
|
||
const argsRaw = rest.join(" ").trim();
|
||
switch (command.toLowerCase()) {
|
||
case "help":
|
||
printHelp();
|
||
return;
|
||
case "tools":
|
||
printToolList(tools, argsRaw || undefined);
|
||
return;
|
||
case "domains":
|
||
printDomains(tools);
|
||
return;
|
||
case "health":
|
||
await invokeToolByName("dashboard_health_check", {}, toolMap, logger);
|
||
return;
|
||
case "stats":
|
||
await invokeToolByName("dashboard_get_stats", {}, toolMap, logger);
|
||
return;
|
||
case "status":
|
||
await invokeToolByName("dashboard_get_operational_snapshot", {}, toolMap, logger);
|
||
return;
|
||
case "config":
|
||
printConfig(config);
|
||
return;
|
||
case "clear":
|
||
process.stdout.write("\x1b[2J\x1b[0;0H");
|
||
return;
|
||
case "exit":
|
||
case "quit":
|
||
case "q":
|
||
printShutdown();
|
||
process.exit(0);
|
||
default:
|
||
if (toolMap.has(command)) {
|
||
const args = parseArgs(argsRaw);
|
||
await invokeToolByName(command, args, toolMap, logger);
|
||
}
|
||
else {
|
||
process.stdout.write(` ${c.warn("?")} Unknown command: ${c.bold(c.brightWhite(command))} ${c.muted("— type 'help' for available commands")}\n`);
|
||
}
|
||
}
|
||
}
|
||
/** Invokes a tool handler by name directly (no MCP protocol), printing an
|
||
* "Invoking..." line then the formatted result/error. This is the REPL's
|
||
* own error boundary — a thrown error is caught/logged here, not converted
|
||
* to a `CallToolResult`. Args pass through unvalidated (no Zod check). */
|
||
async function invokeToolByName(name, args, toolMap, logger) {
|
||
const tool = toolMap.get(name);
|
||
if (!tool) {
|
||
process.stdout.write(` ${c.error("✘")} Tool not found: ${c.bold(name)}\n`);
|
||
return;
|
||
}
|
||
const domain = tool.domain;
|
||
process.stdout.write(` ${c.dim(c.cyan("⟳"))} ${c.muted("Invoking")} ${c.bold(c.brightWhite(name))} ${domainBadge(domain)}${Object.keys(args).length > 0 ? " " + c.muted(JSON.stringify(args)) : ""}\n`);
|
||
const start = performance.now();
|
||
try {
|
||
const result = await tool.handler(args);
|
||
const elapsed = Math.round(performance.now() - start);
|
||
process.stdout.write(formatToolResult(name, result, elapsed) + "\n\n");
|
||
}
|
||
catch (err) {
|
||
const elapsed = Math.round(performance.now() - start);
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
logger.error("REPL tool invocation failed", { tool: name, error: msg });
|
||
process.stdout.write(formatToolError(name, msg, elapsed) + "\n\n");
|
||
}
|
||
}
|
||
/** Parses REPL tool args as a JSON object literal, or (if that fails)
|
||
* space-separated `key=value` pairs with `true`/`false`/numeric coercion.
|
||
* Not schema-aware. Empty input returns `{}`. */
|
||
function parseArgs(raw) {
|
||
if (!raw)
|
||
return {};
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
|
||
return parsed;
|
||
return {};
|
||
}
|
||
catch {
|
||
// Try key=value pairs
|
||
const args = {};
|
||
const pairs = raw.match(/(\w+)=("(?:\\"|[^"])*"|\S+)/g);
|
||
if (pairs) {
|
||
for (const pair of pairs) {
|
||
const eqIndex = pair.indexOf("=");
|
||
const key = pair.slice(0, eqIndex);
|
||
let value = pair.slice(eqIndex + 1);
|
||
if (typeof value === "string" && value.startsWith('"') && value.endsWith('"')) {
|
||
value = value.slice(1, -1);
|
||
}
|
||
if (value === "true")
|
||
value = true;
|
||
else if (value === "false")
|
||
value = false;
|
||
else if (!isNaN(Number(value)) && value !== "")
|
||
value = Number(value);
|
||
args[key] = value;
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
}
|
||
/** Prints the built-in command reference and example tool invocations. */
|
||
function printHelp() {
|
||
process.stdout.write(sectionHeader("Available Commands"));
|
||
const commands = [
|
||
["help", "Show this help message"],
|
||
["tools [domain]", "List tools (optionally filtered by domain)"],
|
||
["domains", "List all tool domains"],
|
||
["health", "Quick dashboard health check"],
|
||
["stats", "Dashboard overview statistics"],
|
||
["status", "Full operational snapshot"],
|
||
["config", "Show current configuration"],
|
||
["clear", "Clear the screen"],
|
||
["exit", "Quit the REPL"],
|
||
["<tool_name> [json]", "Invoke a tool with optional JSON args"],
|
||
["<tool_name> k=v ...", "Invoke a tool with key=value args"],
|
||
];
|
||
const maxCmd = Math.max(...commands.map(([cmd]) => cmd.length));
|
||
for (const [cmd, desc] of commands) {
|
||
process.stdout.write(` ${c.accent(cmd.padEnd(maxCmd + 2))} ${c.muted(desc)}\n`);
|
||
}
|
||
process.stdout.write("\n");
|
||
process.stdout.write(sectionHeader("Examples"));
|
||
process.stdout.write(` ${c.green('dashboard_list_sessions {"limit": 5}')}\n`);
|
||
process.stdout.write(` ${c.green("dashboard_get_session session_id=abc123")}\n`);
|
||
process.stdout.write(` ${c.green("dashboard_list_agents status=working limit=10")}\n\n`);
|
||
}
|
||
/** Prints a table of tools (name, domain, truncated description) for
|
||
* `tools`/`tools <domain>` (case-insensitive domain match). */
|
||
function printToolList(tools, domainFilter) {
|
||
const filtered = domainFilter
|
||
? tools.filter((t) => t.domain === domainFilter.toLowerCase())
|
||
: tools;
|
||
if (filtered.length === 0) {
|
||
process.stdout.write(` ${c.warn("!")} No tools found${domainFilter ? ` for domain '${domainFilter}'` : ""}\n`);
|
||
return;
|
||
}
|
||
const title = domainFilter ? `Tools — ${domainFilter}` : `All Tools (${filtered.length})`;
|
||
process.stdout.write(sectionHeader(title));
|
||
const rows = filtered.map((t) => ({
|
||
name: t.name,
|
||
domain: t.domain,
|
||
description: t.description.length > 50 ? t.description.slice(0, 47) + "..." : t.description,
|
||
}));
|
||
process.stdout.write(table([
|
||
{ key: "name", label: "Tool", width: 38, color: c.brightWhite },
|
||
{
|
||
key: "domain",
|
||
label: "Domain",
|
||
width: 14,
|
||
color: (t) => {
|
||
const fn = DOMAIN_COLORS[t] ?? c.muted;
|
||
return fn(t);
|
||
},
|
||
},
|
||
{ key: "description", label: "Description", width: 52, color: c.muted },
|
||
], rows) + "\n\n");
|
||
}
|
||
/** Prints tool counts per domain (sorted) for the `domains` command. */
|
||
function printDomains(tools) {
|
||
const domainCounts = new Map();
|
||
for (const t of tools) {
|
||
domainCounts.set(t.domain, (domainCounts.get(t.domain) ?? 0) + 1);
|
||
}
|
||
process.stdout.write(sectionHeader("Tool Domains"));
|
||
for (const [domain, count] of [...domainCounts.entries()].sort()) {
|
||
const colorFn = DOMAIN_COLORS[domain] ?? c.muted;
|
||
process.stdout.write(` ${colorFn("●")} ${c.bold(c.brightWhite(domain.padEnd(18)))} ${c.muted(`${count} tools`)}\n`);
|
||
}
|
||
process.stdout.write(`\n ${c.muted("Use")} ${c.accent("tools <domain>")} ${c.muted("to filter by domain.")}\n\n`);
|
||
}
|
||
/** Prints the resolved {@link AppConfig} for `config`, including the live
|
||
* Mutations/Destructive policy state (warning color when enabled). */
|
||
function printConfig(config) {
|
||
process.stdout.write(sectionHeader("Configuration"));
|
||
const pairs = [
|
||
["Server Name", c.brightWhite(config.serverName)],
|
||
["Version", c.brightCyan(config.serverVersion)],
|
||
["Dashboard URL", c.green(config.dashboardBaseUrl.toString())],
|
||
["Transport", c.accent(config.transport.toUpperCase())],
|
||
["Timeout", c.muted(`${config.requestTimeoutMs}ms`)],
|
||
["Retries", c.muted(String(config.retryCount))],
|
||
["Retry Backoff", c.muted(`${config.retryBackoffMs}ms`)],
|
||
["Mutations", config.allowMutations ? c.warn("ENABLED") : badge("disabled")],
|
||
["Destructive", config.allowDestructive ? c.error("ENABLED") : badge("disabled")],
|
||
["Log Level", c.muted(config.logLevel)],
|
||
];
|
||
for (const [k, v] of pairs) {
|
||
process.stdout.write(` ${c.label(k.padEnd(18))} ${v}\n`);
|
||
}
|
||
process.stdout.write("\n");
|
||
}
|
||
/** Constructs an empty {@link ReplToolCollector}, tagging each registered
|
||
* tool via {@link TOOL_DOMAINS} (falling back to `"unknown"`). */
|
||
export function createReplToolCollector() {
|
||
const tools = [];
|
||
return {
|
||
tools,
|
||
register(name, description, handler) {
|
||
const domain = TOOL_DOMAINS[name] ?? "unknown";
|
||
tools.push({ name, description, handler, domain });
|
||
},
|
||
};
|
||
}
|