feat(plugin): install the pipelines' skill plugins on SessionStart

The ship-feature pipelines drive `/superpowers:*` commands as their per-node
drivers, so a fresh install had a dependency it was only told about — the
`/ccam-doctor` WARN row and a line in the README.

`server/lib/skill-plugins.js` now carries the whole thing, shared by the
doctor (reports) and the bootstrap (installs) so the two cannot disagree
about what is missing. The bootstrap installs at the very end of its DETACHED
worker — the install clones from the network and nothing in the 10 s
SessionStart hook may wait on it — retrying once behind a `marketplace add`,
logging a failure with the manual command, and never throwing: the dashboard,
hooks, CLI and MCP all work without these plugins. The outcome is recorded as
`state.skillPluginsSettled`, which `fastPathOk()` now also requires, so the
check runs exactly once per install and `/ccam-update` is the retry.
`CCAM_AUTO_INSTALL_PLUGINS=0|false|off` opts out.

Reporting is derived, installing is allowlisted. Requirements come from the
loaded templates, which is right for a report; installing from that same
source would not be, because a template is a file anyone can drop into
`DASHBOARD_PIPELINES_DIR` — it would turn "edit a JSON file" into "run this
vendor's install command on the next session". Auto-install consults
`AUTO_INSTALLABLE`, a constant in code (`superpowers` today), and reports
every other requirement as skipped.

Tests inject a `run` seam so no case reaches a real `claude plugin install`,
and cover the refusal explicitly.
This commit is contained in:
2026-09-04 14:03:12 +07:00
parent 413b3f40b2
commit 6cb7f4f843
11 changed files with 640 additions and 71 deletions
+9 -1
View File
@@ -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).
---
+2 -1
View File
@@ -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:<name>` (bare `/<name>` 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:<name>`. 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
+1 -1
View File
@@ -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` |
---
+14 -1
View File
@@ -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
+33
View File
@@ -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 |
+4 -1
View File
@@ -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.
+54
View File
@@ -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,
};
+19 -66
View File
@@ -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<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.
* 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() {
+45
View File
@@ -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", () => {
+240
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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, []);
});
});
+219
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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<string, {spec: string, marketplace: string}>}
*/
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<string, Set<string>>} 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,
};