/** * @file skill-plugins.js * @description The plugins AgentHub'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 AgentHub 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ĩ */ 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} */ 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. `agenthub` is dropped: it is the plugin asking. * * @returns {Map>} 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 === "agenthub") 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 /agenthub-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, };