Files
AgentHub/scripts/plugin-doctor.js
T
nntrivi2001 413b3f40b2 feat(doctor): report skill plugins the pipelines name but nothing provides
The ship-feature pipelines have always depended on the `superpowers`
plugin — skills/ship-feature/SKILL.md calls its skills by name, and the
node detect rules match `Skill` events named `brainstorming`,
`writing-plans`, `test-driven-development` — but no README, INSTALL,
manifest or SKILL.md said so, and /ccam-doctor did not check. Install the
`ccam` plugin without it and `/ccam:ship-feature-lane` runs partway, then
fails at the first skill call with no warning beforehand.

/ccam-doctor now derives the requirement from the loaded pipeline
templates rather than a hardcoded list: every `/plugin:skill` a template
names, as its own `skill` or as one node's `command`, has to come from an
installed and enabled plugin. Editing a template cannot leave the check
behind, and a custom template in DASHBOARD_PIPELINES_DIR naming some
other plugin is covered for free.

Three deliberate calls:
  - WARN, never FAIL. The dashboard, hooks, CLI and MCP all work without
    it; failing a doctor run over it would cry wolf.
  - A plugin present but explicitly disabled counts as missing — Claude
    Code will not resolve its skills either way.
  - The skills sitting in ~/.claude/skills/ count as satisfied. That is a
    legitimate hand-copied setup, and calling it broken is how a warning
    row teaches its reader to ignore it.

Docs state the prerequisite where someone installing would look: README
and INSTALL requirements, plus docs/PLUGINS.md for how the row decides.
The install line is `superpowers@claude-plugins-official`, verified
present in that marketplace's manifest.

Verified: npm run test:server (1379, +6 covering all three calls above
plus the partially-satisfied case), npm run test:client (459), prettier
and check-headers clean. `node scripts/plugin-doctor.js` here reports
`OK Skill plugins superpowers available`; the WARN branches were checked
against a temp CLAUDE_HOME.
2026-09-04 11:44:53 +07:00

222 lines
7.6 KiB
JavaScript

#!/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ĩ <vinnt@smartgift.vn>
*/
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<string, Set<string>>} 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 };