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:
+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() {
|
||||
|
||||
Reference in New Issue
Block a user