#!/usr/bin/env node /** * @file plugin-doctor.js * @description Reports the health of a plugin-installed CCAM: Node version, * bootstrap state, runtime dependencies, server liveness, duplicate hook * entries (the one failure that silently doubles every token and cost figure), * the `ccam` CLI launcher and its PATH, whether the committed MCP build still * matches `mcp/src`, and whether the slash commands the pipeline templates name * as node drivers are actually resolvable (the `superpowers` plugin, today). * Read-only — it diagnoses, it never repairs. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("fs"); const os = require("os"); const path = require("path"); const boot = require("./plugin-bootstrap"); const { getSettingsPath, getDataDir } = require("../server/lib/claude-home"); const { mcpBuildStatus } = require("./check-mcp-build"); const PLUGIN_ROOT = path.resolve(__dirname, ".."); /** @returns {{level:"ok"|"warn"|"fail", label:string, detail:string}[]} */ function diagnose() { const rt = boot.runtimeDir(); const out = []; const add = (level, label, detail) => out.push({ level, label, detail }); add( boot.nodeVersionOk() ? "ok" : "fail", "Node", boot.nodeVersionOk() ? `v${process.versions.node}` : `v${process.versions.node} — CCAM needs >= ${boot.MIN_NODE.join(".")} (node:sqlite)` ); add("ok", "Plugin root", PLUGIN_ROOT); add("ok", "Data dir", getDataDir()); const state = boot.readState(rt); if (!state) { add( "warn", "Bootstrap", `no state recorded — start a new session, or check ${rt}/bootstrap.log` ); } else { const moved = state.pluginRoot !== PLUGIN_ROOT; add( moved ? "warn" : "ok", "Bootstrap", moved ? `recorded against a previous plugin version (${state.pluginRoot}) — run /ccam-update` : `last run ${state.updatedAt}` ); } const deps = fs.existsSync(path.join(rt, "node_modules")); add( deps ? "ok" : "fail", "Runtime deps", deps ? path.join(rt, "node_modules") : "missing — run /ccam-update" ); const live = boot.liveServers(); add( live.length ? "ok" : "fail", "Server", live.length ? `listening on ${live.map((s) => `http://localhost:${s.port}`).join(", ")}` : `not running — see ${path.join(rt, "server.log")}` ); const dup = countLegacyHookEntries(); add( dup ? "fail" : "ok", "Hooks", dup ? `${dup} entr${dup === 1 ? "y" : "ies"} in ${getSettingsPath()} duplicate the plugin's hooks — ` + `every event is counted twice. Remove them (a new session does it automatically).` : "provided by the plugin only" ); add(...cliStatus()); add(...skillPluginStatus()); const mcp = mcpBuildStatus(PLUGIN_ROOT); add( mcp.ok ? "ok" : "fail", "MCP build", mcp.ok ? "matches mcp/src" : `${mcp.reason} — run npm run mcp:build` ); const dist = process.env.DASHBOARD_CLIENT_DIST || path.join(rt, "client-dist"); add( fs.existsSync(path.join(dist, "index.html")) ? "ok" : "warn", "Dashboard UI", fs.existsSync(path.join(dist, "index.html")) ? dist : "not built yet — run /ccam-open" ); return out; } /** * Slash commands the loaded pipeline templates name — as the pipeline's own * driver (`skill`) or as one node's driver (`command`) — grouped by the plugin * that has to provide them. Derived from the templates rather than hardcoded so * a custom template dropped in `DASHBOARD_PIPELINES_DIR` is checked too, and so * editing a built-in one cannot leave this check behind. * * `ccam` itself is dropped: it is the plugin running the diagnosis. * * @returns {Map>} plugin name → skill names it must ship. */ function pipelineSkillRequirements() { const requirements = new Map(); let pipelines = []; try { pipelines = require("../server/lib/pipelines").listPipelines(); } catch { return requirements; // templates unreadable — a separate failure, not this row's } for (const pipeline of pipelines) { for (const cmd of [pipeline.skill, ...pipeline.nodes.map((n) => n.command)]) { const match = /^\/([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)/.exec(cmd || ""); if (!match) continue; // a shell command, or a bare /command with no plugin const [, plugin, skill] = match; if (plugin === "ccam") continue; if (!requirements.has(plugin)) requirements.set(plugin, new Set()); requirements.get(plugin).add(skill); } } return requirements; } /** * WARN (never FAIL) when a plugin the pipelines depend on is absent: the * dashboard, hooks, CLI and MCP all work without it — only the pipeline skills * that drive it stop partway, so this must not fail a doctor run. * * A plugin listed but explicitly disabled counts as missing (Claude Code will * not resolve its skills), and a plugin whose skills are all present as plain * user skills counts as satisfied — copying them into `~/.claude/skills/` * instead of installing the plugin is a legitimate setup, and reporting it as * missing would train the reader to ignore this row. */ function skillPluginStatus() { const requirements = pipelineSkillRequirements(); if (!requirements.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"]; let plugins = []; let userSkills = new Set(); try { const discovery = require("../server/lib/cc-discovery"); plugins = discovery.readPlugins().plugins; userSkills = new Set(discovery.readSkills({ scope: "user" }).map((s) => s.name)); } catch { return ["warn", "Skill plugins", "could not read the installed-plugin manifest"]; } const missing = []; for (const [name, skills] of requirements) { const installed = plugins.find( (p) => p.name === name && p.installPathExists && p.enabled !== false ); if (installed) continue; if ([...skills].every((s) => userSkills.has(s))) continue; const absent = [...skills].filter((s) => !userSkills.has(s)).sort(); missing.push(`${name} (needed for ${absent.join(", ")})`); } return missing.length ? [ "warn", "Skill plugins", `${missing.join("; ")} — the pipelines name these commands; install with ` + `claude plugin install ${missing.map((m) => m.split(" ")[0]).join(" ")}`, ] : ["ok", "Skill plugins", `${[...requirements.keys()].sort().join(", ")} available`]; } function countLegacyHookEntries() { try { const settings = JSON.parse(fs.readFileSync(getSettingsPath(), "utf8")); if (!settings.hooks) return 0; const isLegacy = boot.isCheckoutHookEntry(); return Object.values(settings.hooks) .filter(Array.isArray) .reduce((n, entries) => n + entries.filter(isLegacy).length, 0); } catch { return 0; } } function cliStatus() { const dir = path.join(os.homedir(), ".local", "bin"); const file = path.join(dir, process.platform === "win32" ? "ccam.cmd" : "ccam"); if (!fs.existsSync(file)) return ["warn", "ccam CLI", `no launcher at ${file}`]; const onPath = (process.env.PATH || "") .split(path.delimiter) .some((p) => path.resolve(p) === path.resolve(dir)); return onPath ? ["ok", "ccam CLI", file] : [ "warn", "ccam CLI", `${file} exists but ${dir} is not on PATH — add: export PATH="${dir}:$PATH"`, ]; } function report() { const marks = { ok: "OK ", warn: "WARN", fail: "FAIL" }; const rows = diagnose(); for (const r of rows) console.log(`${marks[r.level]} ${r.label.padEnd(14)} ${r.detail}`); return rows.some((r) => r.level === "fail") ? 1 : 0; } if (require.main === module) process.exitCode = report(); module.exports = { diagnose, report, countLegacyHookEntries };