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:
+9
-1
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user