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
+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,
};