From 413b3f40b2ff83dfb378980144c484638485eac1 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Fri, 4 Sep 2026 11:44:53 +0700 Subject: [PATCH] feat(doctor): report skill plugins the pipelines name but nothing provides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- INSTALL.md | 1 + README.md | 13 ++ docs/PLUGINS.md | 21 ++- plugins/ccam/commands/ccam-doctor.md | 3 + scripts/plugin-doctor.js | 85 +++++++++++- .../plugin-doctor-skill-plugins.test.js | 123 ++++++++++++++++++ 6 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 server/__tests__/plugin-doctor-skill-plugins.test.js diff --git a/INSTALL.md b/INSTALL.md index 68f66e67..f4b53573 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -32,6 +32,7 @@ dashboard, run it from source, or stay on Node 20. | Claude Code | 2.x+ | Required for hook integration | | Python | 3.6+ | Optional — statusline utility only | | Git | Any | For cloning the repository | +| `superpowers` plugin | Any | Optional — required only by `/ccam:ship-feature` and `/ccam:ship-feature-lane`, which drive its skills step by step (and which the built-in pipeline templates name as their per-node commands). Nothing else in CCAM needs it; `/ccam-doctor` reports it as `WARN` when absent. Install: `claude plugin install superpowers@claude-plugins-official` | --- diff --git a/README.md b/README.md index 077b6df2..4eb4ddd0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,19 @@ are verified on — node 25 currently breaks 6 server tests through a better-sqlite3 ABI mismatch and 20 client tests through a global `localStorage` change. +The **`superpowers` plugin is required by the ship-feature pipelines only**, not +by the dashboard. `/ccam:ship-feature` and `/ccam:ship-feature-lane` drive +`superpowers:brainstorming`, `writing-plans`, `test-driven-development`, +`requesting-code-review` and `verification-before-completion` phase by phase, +and both built-in pipeline templates name those same slash commands as their +per-node drivers. Without it the dashboard, hooks, CLI and MCP all work +normally — the two pipeline skills are what stop partway. `/ccam-doctor` reports +it as a `WARN` row. Install with: + +```bash +claude plugin install superpowers@claude-plugins-official +``` + ## Install as a Claude Code plugin Two commands on a machine that has nothing but Claude Code, no clone and no diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index cf9b89fc..268cca3f 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -176,10 +176,29 @@ reboot, same as always. | Command | Does | |---|---| -| `/ccam-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, MCP build freshness, UI bundle | +| `/ccam-doctor` | Node version, bootstrap state, runtime deps, server liveness, duplicate hooks, CLI launcher + PATH, **skill plugins** (see below), MCP build freshness, UI bundle | | `/ccam-update` | Pull the latest `ccam` release via `claude plugin marketplace update` + `claude plugin update ccam@ -y`, then reinstall dependencies and restart the server (`plugin-bootstrap.js --force`). The plugin pull needs a manual `/reload-plugins` afterward to take effect in the current session — not scriptable from a command. | | `/ccam-open` | Rebuild the UI bundle and print the dashboard URL. Re-runs the bootstrap first when the server is down or still executing a plugin root that a mid-session `/plugin` update retired, so a fresh bundle never talks to an older server's routes | +#### The `Skill plugins` row + +Derived from the loaded pipeline templates, not hardcoded: every `/plugin:skill` +string a template names — as the pipeline's own `skill` or as one node's +`command` — has to come from an installed, enabled plugin. Today that means +**`superpowers`**, which `/ccam:ship-feature` and `/ccam:ship-feature-lane` +drive step by step (`brainstorming`, `writing-plans`, +`test-driven-development`, `requesting-code-review`, +`verification-before-completion`, `finishing-a-development-branch`). Install it +with `claude plugin install superpowers@claude-plugins-official`. + +The row is a **WARN, never a FAIL**: the dashboard, hooks, CLI and MCP all work +without it, and only those two pipeline skills stop partway. A plugin listed but +explicitly disabled counts as missing — Claude Code will not resolve its skills. +The skills present as plain user skills under `~/.claude/skills/` count as +satisfied, so a hand-copied setup is not reported as broken. A custom template +in `DASHBOARD_PIPELINES_DIR` naming some other plugin's command is checked the +same way. + ### Where things live | Thing | Location | Survives a plugin update | diff --git a/plugins/ccam/commands/ccam-doctor.md b/plugins/ccam/commands/ccam-doctor.md index 606a09ce..47b007ce 100644 --- a/plugins/ccam/commands/ccam-doctor.md +++ b/plugins/ccam/commands/ccam-doctor.md @@ -22,6 +22,9 @@ Print the output verbatim, then add at most three lines of guidance based on the - `FAIL MCP build` → `mcp/build/` is out of date; run `npm run mcp:build` in a checkout and commit it. - `WARN ccam CLI` → print the exact `export PATH=...` line from the output. +- `WARN Skill plugins` → the pipelines name slash commands no installed plugin + provides. The dashboard is unaffected; `/ccam:ship-feature(-lane)` is what + stops partway. Print the `claude plugin install …` line from the output. - `WARN Dashboard UI` → run `/ccam-open` to build the bundle. No preamble, no summary of things that passed. diff --git a/scripts/plugin-doctor.js b/scripts/plugin-doctor.js index 91e06a9a..6a3a7dc5 100644 --- a/scripts/plugin-doctor.js +++ b/scripts/plugin-doctor.js @@ -4,8 +4,10 @@ * @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, and whether the committed MCP build - * still matches `mcp/src`. Read-only — it diagnoses, it never repairs. + * 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ĩ */ @@ -81,6 +83,7 @@ function diagnose() { ); add(...cliStatus()); + add(...skillPluginStatus()); const mcp = mcpBuildStatus(PLUGIN_ROOT); add( @@ -99,6 +102,84 @@ function diagnose() { 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")); diff --git a/server/__tests__/plugin-doctor-skill-plugins.test.js b/server/__tests__/plugin-doctor-skill-plugins.test.js new file mode 100644 index 00000000..dd841d6a --- /dev/null +++ b/server/__tests__/plugin-doctor-skill-plugins.test.js @@ -0,0 +1,123 @@ +/** + * @file Tests the `Skill plugins` row of scripts/plugin-doctor.js — the check + * that tells a fresh install its pipelines name slash commands no installed + * plugin provides (today: `superpowers`, which `/ccam:ship-feature-lane` drives + * step by step). The row must WARN and never FAIL: without that plugin the + * dashboard, hooks, CLI and MCP all still work, so failing a doctor run over it + * would cry wolf. Every case runs against an injected temp CLAUDE_HOME. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const TMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-doctor-skills-")); +process.env.CLAUDE_HOME = TMP_HOME; + +const { diagnose } = require("../../scripts/plugin-doctor"); + +after(() => { + fs.rmSync(TMP_HOME, { recursive: true, force: true }); + delete process.env.CLAUDE_HOME; +}); + +/** Rewrite the injected Claude home, then read back just the row under test. */ +function rowFor({ plugins = {}, enabledPlugins = {}, userSkills = [], installDirs = [] }) { + fs.rmSync(TMP_HOME, { recursive: true, force: true }); + fs.mkdirSync(path.join(TMP_HOME, "plugins"), { recursive: true }); + fs.mkdirSync(path.join(TMP_HOME, "skills"), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, "plugins", "installed_plugins.json"), + JSON.stringify({ plugins }) + ); + fs.writeFileSync(path.join(TMP_HOME, "settings.json"), JSON.stringify({ enabledPlugins })); + // Created AFTER the wipe above: readPlugins() only counts an instance whose + // installPath still exists on disk. + for (const dir of installDirs) fs.mkdirSync(dir, { recursive: true }); + for (const name of userSkills) { + fs.mkdirSync(path.join(TMP_HOME, "skills", name), { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, "skills", name, "SKILL.md"), + `---\nname: ${name}\n---\n\nbody\n` + ); + } + const row = diagnose().find((r) => r.label === "Skill plugins"); + assert.ok(row, "the doctor must always emit a Skill plugins row"); + return row; +} + +const SP_DIR = path.join(TMP_HOME, "plugins", "sp"); + +/** An installed superpowers instance; `enabled` mirrors settings.json. */ +function installedSuperpowers(enabled) { + return { + plugins: { "superpowers@official": [{ installPath: SP_DIR, scope: "user", version: "1.0.0" }] }, + enabledPlugins: { "superpowers@official": enabled }, + installDirs: [SP_DIR], + }; +} + +/** The skills the built-in templates name, read from the templates themselves. */ +function requiredSuperpowersSkills() { + const { listPipelines } = require("../lib/pipelines"); + const names = new Set(); + for (const p of listPipelines()) { + for (const cmd of [p.skill, ...p.nodes.map((n) => n.command)]) { + const m = /^\/superpowers:([A-Za-z0-9_-]+)/.exec(cmd || ""); + if (m) names.add(m[1]); + } + } + return [...names]; +} + +describe("plugin-doctor: Skill plugins row", () => { + it("warns, never fails, when a plugin the pipelines name is not installed", () => { + const row = rowFor({}); + + assert.equal(row.level, "warn", "a missing skill plugin must not fail the doctor run"); + assert.match(row.detail, /superpowers/); + assert.match(row.detail, /claude plugin install superpowers/); + }); + + it("names the specific skills that are unavailable, so the reader can verify the claim", () => { + const row = rowFor({}); + + for (const skill of requiredSuperpowersSkills()) { + assert.match(row.detail, new RegExp(skill.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + }); + + it("passes once the plugin is installed and enabled", () => { + const row = rowFor(installedSuperpowers(true)); + + assert.equal(row.level, "ok", row.detail); + assert.match(row.detail, /superpowers available/); + }); + + it("treats an explicitly disabled plugin as missing - Claude Code will not resolve its skills", () => { + const row = rowFor(installedSuperpowers(false)); + + assert.equal(row.level, "warn", row.detail); + assert.match(row.detail, /superpowers/); + }); + + it("accepts the skills copied into ~/.claude/skills instead of installed as a plugin", () => { + // A legitimate setup. Reporting it as missing would train the reader to + // ignore this row, which is the only failure mode a warning really has. + const row = rowFor({ userSkills: requiredSuperpowersSkills() }); + + assert.equal(row.level, "ok", row.detail); + }); + + it("still warns when only SOME of the required skills are present locally", () => { + const [first, ...rest] = requiredSuperpowersSkills(); + const row = rowFor({ userSkills: [first] }); + + assert.equal(row.level, "warn", row.detail); + assert.ok(!row.detail.includes(`${first},`), `${first} is present and must not be listed`); + for (const skill of rest) assert.match(row.detail, new RegExp(skill)); + }); +});