Files
AgentHub/server/lib/skill-plugins.js
T
nntrivi2001 aff863c69f fix(plugins): retry claude plugin install|update without -y on an old CLI
An older Claude Code has no -y on `plugin install|update` and exits with
"error: unknown option '-y'", so the dashboard's Update now chain failed at
its plugin_update step (and so did Update all, the plugin buttons and the
superpowers auto-install). Those versions also predate the confirmation
prompt -y answers, so every wrapper now retries the same argv without it
when, and only when, the CLI rejects the flag. /agenthub-update, which the
failure message points users to, falls back the same way.
2026-09-23 16:46:20 +07:00

240 lines
9.4 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 result = runClaudePluginOnce(args, timeoutMs);
// An older Claude Code rejects `-y` and never prompts; retry without it.
if (!result.ok && args.includes("-y") && /unknown option '-y'/.test(result.output)) {
return runClaudePluginOnce(
args.filter((a) => a !== "-y"),
timeoutMs
);
}
return result;
}
function runClaudePluginOnce(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,
};