697384145a
`autoInstallSkillPlugins` takes `run` as a seam, so every existing test stops short of the real `spawnSync` — the line that decides whether CLAUDE_CONFIG_DIR is pinned was untested. Restoring the unconditional pin here (the bug that sent `.claude.json` to `~/.claude/.claude.json`) kept the whole suite green. Export `runClaudePlugin` and drive it against a fake `claude` on PATH that prints the variable back: one test for CLAUDE_HOME set, one for unset. Mutation-checked — the unconditional pin fails the second.
228 lines
9.0 KiB
JavaScript
228 lines
9.0 KiB
JavaScript
/**
|
|
* @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ĩ <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. `agenthub` 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 === "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. `CLAUDE_CONFIG_DIR` is pinned
|
|
* only when the operator set `CLAUDE_HOME`, matching `cc-plugin-cli.js`'s
|
|
* `runClaude()`: left unset, the CLI's own defaults already write where
|
|
* `cc-discovery` reads, and pinning it unconditionally sent `.claude.json`
|
|
* to `~/.claude/.claude.json`, which nothing reads.
|
|
*
|
|
* @returns {{ok: boolean, output: string}}
|
|
*/
|
|
function runClaudePlugin(args, timeoutMs) {
|
|
const env = { ...process.env };
|
|
if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome();
|
|
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,
|
|
});
|
|
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,
|
|
// Exported only so a test can pin the env it spawns with. Every other test
|
|
// injects `run` and so never reaches the real spawn — which is how an
|
|
// unconditional CLAUDE_CONFIG_DIR pin could be restored here unnoticed.
|
|
runClaudePlugin,
|
|
};
|