114823ceef
skill-plugins.js carried its own copy of the unconditional pin, with the same comment claiming it keeps writes where cc-discovery reads. Same rule as runClaude now: pin only when the operator set CLAUDE_HOME. Also corrects three comments that described the old behaviour — the confidently-wrong pair at these two call sites is what let two readers believe reads and writes were aligned when they were not.
224 lines
8.7 KiB
JavaScript
224 lines
8.7 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,
|
|
};
|