diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ac5c95df..1bacc0a9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1113,7 +1113,15 @@ install into `~/.claude/agent-dashboard/runtime/`, the `~/.local/bin/ccam` launcher, and the detached server spawn with `NODE_PATH` and `DASHBOARD_CLIENT_DIST` pointed at that runtime directory. It never writes into the plugin cache, which Claude Code garbage-collects and replaces on every -update. Full behavior: [`docs/PLUGINS.md`](docs/PLUGINS.md). +update. + +Last in that worker — deliberately, because it clones from the network and +nothing above it may wait — it installs the plugins the pipeline templates drive +(`superpowers`) via `server/lib/skill-plugins.js`, the same module +`/ccam-doctor`'s `Skill plugins` row reports from. The report is derived from +the templates; the install consults a fixed allowlist in that file instead, +because a template is user-writable. Full behavior: +[`docs/PLUGINS.md`](docs/PLUGINS.md). --- diff --git a/CLAUDE.md b/CLAUDE.md index 5957f725..890f8c7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: e109d28 (2026-09-04) + Workspace single console render site._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. +_Last synced: e109d28 (2026-09-04) + Workspace single console render site + skill-plugin auto-install._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -14,6 +14,7 @@ _Last synced: e109d28 (2026-09-04) + Workspace single console render site._ A ch - `mcp/`: local MCP server exposing dashboard operations as tools. **`mcp/build/` is committed on purpose** — plugin MCP servers start before any bootstrap could build them; `scripts/check-mcp-build.js` (content hash in `mcp/build/.srchash`, run by pre-commit and `/ccam-doctor`) keeps it honest. Rebuild with `npm run mcp:build`, never hand-edit `mcp/build/`. - `skills/`: this repo's skills. Because the repo root **is** the `ccam` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/ccam:` (bare `/` works as an alias). Put a skill here only if it should reach plugin users; `ccam skills install` remains a fallback that copies `ship-feature-lane` into `~/.claude/skills/` for plain checkouts. - `agents/`: the same story for subagents — plugin root, so all five (`qc-local`, `senior-gate-reviewer`, `backend-reviewer`, `frontend-reviewer`, `mcp-reviewer`) ship as `ccam:`. This is also the source `ccam lanes agents install` copies the two pipeline agents from into a lane's `.claude/agents/`, which is what keeps the bare `subagent_type: qc-local` working there. One file per agent, never a second copy. - `.claude-plugin/`: the marketplace plus the root `ccam` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/ccam/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agent-dashboard/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`. +- **Reporting a required plugin is derived; installing one is allowlisted.** `server/lib/skill-plugins.js` reads the `/plugin:skill` strings out of the loaded pipeline templates — right for the `/ccam-doctor` row, wrong for an install: a template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`, so installing whatever name it contains would make "edit a JSON file" mean "run this vendor's install command next session". The bootstrap installs only what `AUTO_INSTALLABLE` (a constant in that file, `superpowers` today) lists, in its DETACHED worker, never in the 10 s hook, recorded once as `state.skillPluginsSettled` (also gating `fastPathOk`), never throwing, and off under `CCAM_AUTO_INSTALL_PLUGINS=0|false|off`. Do not derive an install target from a template. ## Lanes diff --git a/INSTALL.md b/INSTALL.md index f4b53573..c301efb1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -32,7 +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` | +| `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. **Installed automatically** by the plugin's `SessionStart` bootstrap (opt out with `CCAM_AUTO_INSTALL_PLUGINS=0`); by hand: `claude plugin install superpowers@claude-plugins-official` | --- diff --git a/README.md b/README.md index 4eb4ddd0..7b48a02f 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,25 @@ by the dashboard. `/ccam:ship-feature` and `/ccam:ship-feature-lane` drive 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: +it as a `WARN` row. + +**You do not normally have to install it.** The plugin's `SessionStart` +bootstrap installs it for you, once, in its detached worker — so a session never +waits on the clone, and the current session needs `/reload-plugins` before the +skills resolve (any later session picks them up on its own). The outcome is +logged to `~/.claude/agent-dashboard/runtime/bootstrap.log`, and `/ccam-update` +retries a failed one. To install it by hand instead, or after opting out: ```bash claude plugin install superpowers@claude-plugins-official ``` +Set `CCAM_AUTO_INSTALL_PLUGINS=0` to opt out of the automatic install. Only the +plugins on a fixed allowlist in `server/lib/skill-plugins.js` are ever installed +this way — `superpowers` today. A custom pipeline template naming some other +plugin's command is *reported* by `/ccam-doctor` and never installed, because a +template is a file anyone can drop into `DASHBOARD_PIPELINES_DIR`. + ## 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 268cca3f..68f1de58 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -199,6 +199,39 @@ 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. +Both the derivation and the missing/satisfied rules live in +`server/lib/skill-plugins.js`, shared with the bootstrap below so the row and +the install can never disagree about what is missing. + +#### Automatic install, and why it is allowlisted + +`plugin-bootstrap.js` installs the missing plugins itself, at the very end of +its **detached worker** — never in the 10 s `SessionStart` hook, because the +install clones from the network. It runs `claude plugin install +superpowers@claude-plugins-official --scope user -y`; if that fails it registers +the marketplace (`claude plugin marketplace add anthropics/claude-plugins-official`) +and retries exactly once. Failure is logged with the manual command and nothing +throws — the dashboard, hooks, CLI and MCP do not need these plugins. + +The result is recorded as `skillPluginsSettled` in the bootstrap's `state.json`, +which is also part of `fastPathOk()`: the check runs **once** per install, then +the hot path reads one flag. `/ccam-update` (`--force`) is the retry. +`CCAM_AUTO_INSTALL_PLUGINS=0` (or `false`/`off`) turns it off entirely. + +An installed plugin only resolves in the **current** session after +`/reload-plugins`; a new session picks it up on its own. The log says so, since +the worker is detached and cannot tell the session that started it. + +**Reporting is derived, installing is allowlisted.** The row above reads plugin +names out of the templates, which is right for a report. Installing from the +same source would not be: a template is a file anyone can drop into +`DASHBOARD_PIPELINES_DIR`, so it would turn "edit a JSON file" into "run this +vendor's plugin install command on the next session". Auto-install therefore +consults `AUTO_INSTALLABLE`, a fixed map in `server/lib/skill-plugins.js`, and +skips every other requirement — visible in the doctor, never installed. Adding +an entry there is a decision to install someone else's code on a user's machine +without asking; it belongs in a review, not in a config file. + ### 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 47b007ce..2125668e 100644 --- a/plugins/ccam/commands/ccam-doctor.md +++ b/plugins/ccam/commands/ccam-doctor.md @@ -24,7 +24,10 @@ Print the output verbatim, then add at most three lines of guidance based on the - `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. + stops partway. The bootstrap normally installs these itself, so this row also + means that attempt has not run or did not succeed — say `/ccam-update` retries + it, and print the `claude plugin install …` line from the output as the manual + route. - `WARN Dashboard UI` → run `/ccam-open` to build the bundle. No preamble, no summary of things that passed. diff --git a/scripts/plugin-bootstrap.js b/scripts/plugin-bootstrap.js index 4a5f7f78..1ccd79cd 100644 --- a/scripts/plugin-bootstrap.js +++ b/scripts/plugin-bootstrap.js @@ -103,6 +103,10 @@ function fastPathOk(rt = runtimeDir()) { if (state.pluginRoot !== PLUGIN_ROOT) return false; if (state.depsHash !== depsHash()) return false; if (!fs.existsSync(path.join(rt, "node_modules"))) return false; + // A machine can be fully set up and still be missing the plugins the + // pipelines drive. Reading one recorded flag keeps this hot path cheap — the + // actual check (manifest + every user SKILL.md) runs once, in the worker. + if (!state.skillPluginsSettled) return false; return serverIsLive(); } @@ -397,6 +401,46 @@ function log(rt, line) { } } +/** + * Install the plugins CCAM's pipelines drive but this machine cannot resolve — + * today `superpowers`, which `/ccam:ship-feature(-lane)` calls step by step. A + * fresh `ccam` install otherwise runs those skills partway and fails at the + * first call with no warning beforehand. + * + * Off by setting `CCAM_AUTO_INSTALL_PLUGINS` to `0`, `false` or `off`; that + * leaves `/ccam-doctor`'s WARN row as the only notice, which is the right + * behaviour for anyone who wants to choose their own plugins. + * + * One-shot: the outcome is recorded in the runtime state and the fast path + * takes it into account, so a machine with no network does not re-attempt a + * clone on every session. `/ccam-update` (`--force`) retries. + * + * Best-effort throughout. Nothing here may throw or block: the dashboard, + * hooks, CLI and MCP all work without these plugins. + * + * @returns {"off"|"nothing-missing"|"installed"|"failed"|"error"} or the previously + * recorded outcome when this run had nothing to settle. + */ +function autoInstallSkillPlugins(rt, { force = false } = {}) { + const flag = process.env.CCAM_AUTO_INSTALL_PLUGINS; + if (flag === "0" || flag === "false" || flag === "off") return "off"; + // Return the PREVIOUS outcome, not a marker: writeState records whatever this + // returns, and overwriting "installed" with "already-settled" would lose the + // only record of what actually happened. + const settled = readState(rt)?.skillPluginsSettled; + if (!force && settled) return settled; + + try { + const { autoInstallSkillPlugins: install } = require("../server/lib/skill-plugins"); + const summary = install((line) => log(rt, line)); + if (!summary.attempted.length) return "nothing-missing"; + return summary.failed.length ? "failed" : "installed"; + } catch (err) { + log(rt, `skill-plugin auto-install skipped: ${err.message}`); + return "error"; + } +} + /** * The real work. Runs detached from the SessionStart hook, so its only channel * is the log file; `/ccam-update` runs it in the foreground with `force`, which @@ -506,12 +550,21 @@ function bootstrap(rt = runtimeDir(), { force = false } = {}) { } } + // Last, and deliberately so: it clones from the network, and nothing above + // may wait on it. Runs here rather than in the foreground pass because this + // whole function is already detached from the 10 s SessionStart hook. + const skillPlugins = autoInstallSkillPlugins(rt, { force }); + writeState( { pluginVersion: pluginVersion(), depsHash: wantDeps, pluginRoot: PLUGIN_ROOT, claudeHome: getClaudeHome(), + // Settles the fast path: whatever the outcome, the next SessionStart + // must not spawn a worker just to retry a network clone. Cleared by + // `/ccam-update` (force), which is the retry. + skillPluginsSettled: skillPlugins, updatedAt: new Date().toISOString(), }, rt @@ -578,5 +631,6 @@ module.exports = { livePids, stopDashboard, startDashboard, + autoInstallSkillPlugins, bootstrap, }; diff --git a/scripts/plugin-doctor.js b/scripts/plugin-doctor.js index 6a3a7dc5..e8420de4 100644 --- a/scripts/plugin-doctor.js +++ b/scripts/plugin-doctor.js @@ -103,81 +103,34 @@ function diagnose() { } /** - * 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. + * WARN (never FAIL) when a plugin the pipelines depend on cannot be resolved: + * 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. * - * `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. + * The requirement derivation and the missing/satisfied rules live in + * `server/lib/skill-plugins.js`, shared with the bootstrap that installs them. */ function skillPluginStatus() { - const requirements = pipelineSkillRequirements(); - if (!requirements.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"]; + const skillPlugins = require("../server/lib/skill-plugins"); + const required = skillPlugins.requiredSkillPlugins(); + if (!required.size) return ["ok", "Skill plugins", "no pipeline names an external plugin"]; - let plugins = []; - let userSkills = new Set(); + let missing; try { - const discovery = require("../server/lib/cc-discovery"); - plugins = discovery.readPlugins().plugins; - userSkills = new Set(discovery.readSkills({ scope: "user" }).map((s) => s.name)); + missing = skillPlugins.missingSkillPlugins(); } 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(", ")})`); + if (!missing.length) { + return ["ok", "Skill plugins", `${[...required.keys()].sort().join(", ")} available`]; } - - 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`]; + return [ + "warn", + "Skill plugins", + `${missing.map((m) => `${m.name} (needed for ${m.skills.join(", ")})`).join("; ")} — ` + + `the pipelines name these commands; install with ` + + `claude plugin install ${missing.map((m) => m.autoInstall?.spec || m.name).join(" ")}`, + ]; } function countLegacyHookEntries() { diff --git a/server/__tests__/plugin-bootstrap.test.js b/server/__tests__/plugin-bootstrap.test.js index aeecdf98..26cae8ed 100644 --- a/server/__tests__/plugin-bootstrap.test.js +++ b/server/__tests__/plugin-bootstrap.test.js @@ -113,6 +113,51 @@ describe("fast path", () => { boot.writeState({ pluginVersion: pkg.version, depsHash: boot.depsHash() }, RT); assert.equal(boot.fastPathOk(RT), false); }); + + it("is false while the skill-plugin check has never settled", () => { + // A machine can be fully installed and still lack the plugins the + // pipelines drive; the worker has to run once to find that out. + const pkg = require("../../package.json"); + fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true }); + boot.writeState( + { + pluginVersion: pkg.version, + depsHash: boot.depsHash(), + pluginRoot: path.resolve(__dirname, "..", ".."), + }, + RT + ); + assert.equal(boot.fastPathOk(RT), false); + }); +}); + +describe("skill-plugin auto-install gate", () => { + // Both cases here must return WITHOUT reaching server/lib/skill-plugins.js — + // that module shells out to `claude plugin install`, and no test may. + beforeEach(resetRuntime); + after(() => delete process.env.CCAM_AUTO_INSTALL_PLUGINS); + + for (const flag of ["0", "false", "off"]) { + it(`is off when CCAM_AUTO_INSTALL_PLUGINS=${flag}`, () => { + process.env.CCAM_AUTO_INSTALL_PLUGINS = flag; + assert.equal(boot.autoInstallSkillPlugins(RT), "off"); + }); + } + + it("runs once: a settled state is returned verbatim, not re-attempted", () => { + delete process.env.CCAM_AUTO_INSTALL_PLUGINS; + boot.writeState({ skillPluginsSettled: "installed" }, RT); + + // Verbatim matters: writeState records whatever comes back, so a marker + // here would erase the only record of what actually happened. + assert.equal(boot.autoInstallSkillPlugins(RT), "installed"); + }); + + it("is off under the flag even when forced, so /ccam-update honours the opt-out", () => { + process.env.CCAM_AUTO_INSTALL_PLUGINS = "0"; + boot.writeState({ skillPluginsSettled: "failed" }, RT); + assert.equal(boot.autoInstallSkillPlugins(RT, { force: true }), "off"); + }); }); describe("bootstrap lock", () => { diff --git a/server/__tests__/skill-plugins.test.js b/server/__tests__/skill-plugins.test.js new file mode 100644 index 00000000..72a0a566 --- /dev/null +++ b/server/__tests__/skill-plugins.test.js @@ -0,0 +1,240 @@ +/** + * @file Tests server/lib/skill-plugins.js — the dependency the ship-feature + * pipelines have on other plugins (`superpowers` today): deriving it from the + * templates, deciding what is actually missing, and the unattended install the + * SessionStart bootstrap performs. + * + * The security-relevant case is covered first: a pipeline template is a file + * anyone may drop into DASHBOARD_PIPELINES_DIR, so a plugin name appearing + * there must be REPORTED but never INSTALLED. Every case injects a fake `run` + * — no test may reach a real `claude plugin install`. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { describe, it, after, beforeEach } = 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-skill-plugins-home-")); +const TMP_PIPELINES = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-skill-plugins-tpl-")); +process.env.CLAUDE_HOME = TMP_HOME; + +const skillPlugins = require("../lib/skill-plugins"); +const pipelines = require("../lib/pipelines"); + +after(() => { + fs.rmSync(TMP_HOME, { recursive: true, force: true }); + fs.rmSync(TMP_PIPELINES, { recursive: true, force: true }); + delete process.env.CLAUDE_HOME; + delete process.env.DASHBOARD_PIPELINES_DIR; + pipelines.reload(); +}); + +/** An empty Claude home: no plugins installed, no user skills. */ +function emptyHome() { + 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({})); +} + +/** Point the loader at a throwaway template naming `command` on one node. */ +function useTemplate(doc) { + fs.writeFileSync(path.join(TMP_PIPELINES, "test.json"), JSON.stringify(doc)); + process.env.DASHBOARD_PIPELINES_DIR = TMP_PIPELINES; + pipelines.reload(); +} + +function forgetTemplate() { + fs.rmSync(path.join(TMP_PIPELINES, "test.json"), { force: true }); + delete process.env.DASHBOARD_PIPELINES_DIR; + pipelines.reload(); +} + +/** Records the argv of every call and answers with a fixed result. */ +function fakeRun(results) { + const calls = []; + const queue = [...results]; + const run = (args) => { + calls.push(args); + return queue.length > 1 ? queue.shift() : queue[0]; + }; + run.calls = calls; + return run; +} + +const collect = () => { + const lines = []; + const log = (line) => lines.push(line); + log.lines = lines; + return log; +}; + +beforeEach(() => { + emptyHome(); + forgetTemplate(); +}); + +describe("requiredSkillPlugins", () => { + it("collects the plugin behind every /plugin:skill a template names, node or pipeline level", () => { + const required = skillPlugins.requiredSkillPlugins(); + + assert.ok(required.has("superpowers"), "the built-in templates name superpowers"); + assert.ok(required.get("superpowers").has("writing-plans")); + }); + + it("ignores ccam's own commands and anything that is not a /plugin:skill", () => { + useTemplate({ + id: "tpl-shell", + skill: "/ccam:ship-feature", + nodes: [ + { id: "a", command: "npm test" }, + { id: "b", command: "/reload-plugins" }, + { id: "c", command: "" }, + ], + }); + + const required = skillPlugins.requiredSkillPlugins(); + + assert.equal(required.has("ccam"), false, "ccam is the plugin asking"); + // Only the built-ins' superpowers requirement survives. + assert.deepEqual([...required.keys()], ["superpowers"]); + }); +}); + +describe("missingSkillPlugins", () => { + it("reports a required plugin that is absent, with the skills that need it", () => { + const missing = skillPlugins.missingSkillPlugins(); + const sp = missing.find((m) => m.name === "superpowers"); + + assert.ok(sp, "superpowers must be reported missing in an empty home"); + assert.ok(sp.skills.includes("writing-plans")); + assert.equal(sp.autoInstall.spec, "superpowers@claude-plugins-official"); + }); + + it("counts the skills as satisfied when they sit in ~/.claude/skills instead", () => { + for (const name of skillPlugins.requiredSkillPlugins().get("superpowers")) { + 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` + ); + } + + assert.deepEqual(skillPlugins.missingSkillPlugins(), []); + }); + + it("counts an installed but explicitly disabled plugin as missing", () => { + const dir = path.join(TMP_HOME, "plugins", "sp"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(TMP_HOME, "plugins", "installed_plugins.json"), + JSON.stringify({ plugins: { "superpowers@x": [{ installPath: dir, scope: "user" }] } }) + ); + fs.writeFileSync( + path.join(TMP_HOME, "settings.json"), + JSON.stringify({ enabledPlugins: { "superpowers@x": false } }) + ); + + assert.ok(skillPlugins.missingSkillPlugins().some((m) => m.name === "superpowers")); + }); +}); + +describe("autoInstallSkillPlugins", () => { + it("installs an allowlisted plugin with an argv array, never a shell string", () => { + const run = fakeRun([{ ok: true, output: "" }]); + const log = collect(); + + const summary = skillPlugins.autoInstallSkillPlugins(log, { run }); + + assert.deepEqual(summary.installed, ["superpowers"]); + assert.deepEqual(run.calls, [ + ["plugin", "install", "superpowers@claude-plugins-official", "--scope", "user", "-y"], + ]); + assert.ok( + log.lines.some((l) => l.includes("/reload-plugins")), + "the user must be told the current session will not see it yet" + ); + }); + + it("registers the marketplace and retries exactly once when the install fails", () => { + const run = fakeRun([ + { ok: false, output: "unknown marketplace" }, + { ok: true, output: "" }, + { ok: true, output: "" }, + ]); + + const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run }); + + assert.deepEqual(summary.installed, ["superpowers"]); + assert.equal(run.calls.length, 3); + assert.deepEqual(run.calls[1], [ + "plugin", + "marketplace", + "add", + "anthropics/claude-plugins-official", + "--scope", + "user", + ]); + }); + + it("reports a failure with the manual command, and never throws", () => { + const run = fakeRun([{ ok: false, output: "the `claude` CLI is not on PATH" }]); + const log = collect(); + + const summary = skillPlugins.autoInstallSkillPlugins(log, { run }); + + assert.deepEqual(summary.failed, ["superpowers"]); + assert.ok( + log.lines.some((l) => + l.includes("claude plugin install superpowers@claude-plugins-official") + ), + "a failed auto-install must leave the user a command to run" + ); + }); + + // The one that matters. A template is a file on disk; if the plugin name in + // it drove the install, dropping a JSON file into DASHBOARD_PIPELINES_DIR + // would run an arbitrary vendor's install command on the next session. + it("REFUSES to install a plugin a template names but the allowlist does not", () => { + useTemplate({ + id: "tpl-evil", + name: "Untrusted", + nodes: [{ id: "a", command: "/attacker-plugin:payload" }], + }); + const run = fakeRun([{ ok: true, output: "" }]); + + const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run }); + + assert.ok(summary.skipped.includes("attacker-plugin"), "it must be reported as skipped"); + assert.equal( + run.calls.some((args) => args.join(" ").includes("attacker-plugin")), + false, + "no claude invocation may ever mention a plugin outside AUTO_INSTALLABLE" + ); + // It is still visible to the reader, just never installed. + assert.ok(skillPlugins.missingSkillPlugins().some((m) => m.name === "attacker-plugin")); + }); + + it("does nothing at all when every requirement already resolves", () => { + for (const name of skillPlugins.requiredSkillPlugins().get("superpowers")) { + 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 run = fakeRun([{ ok: true, output: "" }]); + + const summary = skillPlugins.autoInstallSkillPlugins(collect(), { run }); + + assert.deepEqual(summary.attempted, []); + assert.deepEqual(run.calls, []); + }); +}); diff --git a/server/lib/skill-plugins.js b/server/lib/skill-plugins.js new file mode 100644 index 00000000..43fe3d1c --- /dev/null +++ b/server/lib/skill-plugins.js @@ -0,0 +1,219 @@ +/** + * @file skill-plugins.js + * @description The plugins CCAM's pipelines depend on: which ones a template + * names, which of those are actually resolvable, and the narrow set the + * SessionStart bootstrap is allowed to install on the user's behalf. + * + * Shared by `scripts/plugin-doctor.js` (reports) and + * `scripts/plugin-bootstrap.js` (installs) so the two can never disagree about + * what is missing. It lives here rather than in either script because + * plugin-doctor already requires plugin-bootstrap — the other direction would + * be a cycle. + * + * ## Reporting is derived, installing is allowlisted + * `requiredSkillPlugins()` reads the loaded pipeline templates, including any + * custom one from `DASHBOARD_PIPELINES_DIR`. That is right for a report: if a + * template names `/foo:bar`, the reader should be told `foo` is missing. + * + * It is NOT right for an install. A template is a file anyone can drop into + * that directory, so installing whatever plugin name it happens to contain + * would turn "edit a JSON file" into "run this vendor's plugin install command + * on the next session" — a supply-chain path CCAM must not open. Auto-install + * therefore consults {@link AUTO_INSTALLABLE}, a fixed map in this file, and + * ignores every requirement outside it. + * + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { spawnSync } = require("node:child_process"); +const { getClaudeHome } = require("./claude-home"); + +/** + * Plugins the bootstrap may install unattended, keyed by the plugin name a + * template refers to. `spec` is the exact `claude plugin install` argument; + * `marketplace` is the `owner/repo` to register first if that spec cannot be + * resolved because the marketplace is not known on this machine. + * + * Adding an entry here is a decision to install someone else's code on a + * user's machine without asking — it belongs in a review, not in a config file. + * + * @type {Record} + */ +const AUTO_INSTALLABLE = { + superpowers: { + spec: "superpowers@claude-plugins-official", + marketplace: "anthropics/claude-plugins-official", + }, +}; + +/** Slash commands are addressed `/plugin:skill`; a bare `/command` has no plugin. */ +const SLASH_COMMAND_RE = /^\/([A-Za-z0-9_-]+):([A-Za-z0-9_-]+)/; + +/** + * Every `/plugin:skill` the loaded pipeline templates name — as a pipeline's + * own `skill` or as one node's `command` — grouped by the plugin that has to + * provide it. Derived rather than hardcoded so editing a template cannot leave + * the check behind. `ccam` is dropped: it is the plugin asking. + * + * @returns {Map>} plugin name → the skill names it must ship. + */ +function requiredSkillPlugins() { + const requirements = new Map(); + let pipelines = []; + try { + pipelines = require("./pipelines").listPipelines(); + } catch { + return requirements; // templates unreadable — a different failure, not this one + } + for (const pipeline of pipelines) { + for (const command of [pipeline.skill, ...pipeline.nodes.map((n) => n.command)]) { + const match = SLASH_COMMAND_RE.exec(command || ""); + if (!match) continue; // a shell command, or a bare /command + const [, plugin, skill] = match; + if (plugin === "ccam") continue; + if (!requirements.has(plugin)) requirements.set(plugin, new Set()); + requirements.get(plugin).add(skill); + } + } + return requirements; +} + +/** + * Which required plugins cannot be resolved right now. + * + * A plugin present but explicitly disabled counts as missing — Claude Code will + * not resolve its skills either way. A plugin whose skills all exist as plain + * user skills under `~/.claude/skills/` counts as satisfied: hand-copying them + * is a legitimate setup, and calling it broken is how a warning teaches its + * reader to ignore it. + * + * @returns {{name: string, skills: string[], autoInstall: {spec: string, marketplace: string}|null}[]} + * Empty when everything resolves. `null` autoInstall means report only. + * @throws When the installed-plugin manifest cannot be read at all. + */ +function missingSkillPlugins() { + const requirements = requiredSkillPlugins(); + if (!requirements.size) return []; + + const discovery = require("./cc-discovery"); + const installed = discovery.readPlugins().plugins; + const userSkills = new Set(discovery.readSkills({ scope: "user" }).map((s) => s.name)); + + const missing = []; + for (const [name, skills] of requirements) { + const present = installed.find( + (p) => p.name === name && p.installPathExists && p.enabled !== false + ); + if (present) continue; + const absent = [...skills].filter((s) => !userSkills.has(s)).sort(); + if (!absent.length) continue; + missing.push({ name, skills: absent, autoInstall: AUTO_INSTALLABLE[name] || null }); + } + return missing; +} + +/** + * `claude plugin …`, synchronously. The bootstrap is a synchronous script, so + * it cannot use the promise-based `cc-plugin-cli.js` the HTTP routes go + * through; the argument validation that module carries is also moot here, + * because every argv element below comes from {@link AUTO_INSTALLABLE} — a + * constant in this file, never a request or a template. + * + * Still an argv array, never a shell string, and `CLAUDE_CONFIG_DIR` is pinned + * to the same home `cc-discovery` reads so a write here is a write the + * dashboard can see. + * + * @returns {{ok: boolean, output: string}} + */ +function runClaudePlugin(args, timeoutMs) { + const result = spawnSync("claude", args, { + timeout: timeoutMs, + encoding: "utf8", + maxBuffer: 1024 * 1024, + // Non-interactive: a would-be prompt must fail fast, not hang out the timeout. + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, CLAUDE_CONFIG_DIR: getClaudeHome() }, + }); + const output = `${result.stdout || ""}${result.stderr || ""}`.trim(); + if (result.error) { + const enoent = result.error.code === "ENOENT"; + return { + ok: false, + output: enoent ? "the `claude` CLI is not on PATH" : String(result.error.message), + }; + } + return { ok: result.status === 0, output }; +} + +/** + * Install the allowlisted plugins that are missing. Called from the bootstrap's + * DETACHED worker, so a slow clone never delays a session; its only channel is + * the caller's log. + * + * Best-effort by design: a failure is logged and the run moves on. The + * dashboard, hooks, CLI and MCP all work without these plugins — only the + * ship-feature pipelines need them — so nothing here may throw into the + * bootstrap. + * + * @param {(line: string) => void} log + * @param {{timeoutMs?: number, run?: typeof runClaudePlugin}} [options] `run` is + * a seam for tests: nothing else may reach a real `claude plugin install`. + * @returns {{attempted: string[], installed: string[], failed: string[], skipped: string[]}} + */ +function autoInstallSkillPlugins(log, { timeoutMs = 180_000, run = runClaudePlugin } = {}) { + const summary = { attempted: [], installed: [], failed: [], skipped: [] }; + + let missing = []; + try { + missing = missingSkillPlugins(); + } catch { + return summary; // manifest unreadable — the doctor reports it, this stays quiet + } + + for (const entry of missing) { + if (!entry.autoInstall) { + // Reported by /ccam-doctor, never installed: not on the allowlist. + summary.skipped.push(entry.name); + continue; + } + const { spec, marketplace } = entry.autoInstall; + summary.attempted.push(entry.name); + log(`installing the ${entry.name} plugin — the pipelines drive ${entry.skills.join(", ")}`); + + let result = run(["plugin", "install", spec, "--scope", "user", "-y"], timeoutMs); + if (!result.ok) { + // The most likely cause on a fresh machine is that the marketplace has + // never been registered, which `install` cannot do for us. Register it + // and retry exactly once. + const added = run( + ["plugin", "marketplace", "add", marketplace, "--scope", "user"], + timeoutMs + ); + if (added.ok) { + result = run(["plugin", "install", spec, "--scope", "user", "-y"], timeoutMs); + } + } + + if (result.ok) { + summary.installed.push(entry.name); + log( + `installed ${spec} — run /reload-plugins to use it in THIS session ` + + `(a new session picks it up on its own)` + ); + } else { + summary.failed.push(entry.name); + log( + `could not install ${spec}: ${result.output || "unknown error"} — ` + + `install it yourself with: claude plugin install ${spec}` + ); + } + } + return summary; +} + +module.exports = { + AUTO_INSTALLABLE, + requiredSkillPlugins, + missingSkillPlugins, + autoInstallSkillPlugins, +};