Files
AgentHub/server/lib/skill-plugins.js
T
nntrivi2001 6cb7f4f843 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.
2026-09-04 14:03:12 +07:00

220 lines
8.5 KiB
JavaScript

/**
* @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,
};