/** * @file cc-plugin-cli.js * @description Drives the official `claude plugin …` CLI so the dashboard can * add/remove marketplaces and install/uninstall/enable/disable/update plugins * without the user opening a terminal. * * Why the CLI and not direct JSON edits: `installed_plugins.json`, * `known_marketplaces.json` and the plugin cache tree are written together by * the CLI (it clones the marketplace repo, resolves versions, prunes the * cache). Reproducing that here would drift the moment the CLI's schema moves. * * Hard constraints (do not relax without a follow-up review): * - `execFile` with an explicit argv array. NEVER a shell string — the * `source`/`spec` values come straight from a user-typed field. * - Every argument is validated BEFORE the spawn: no leading `-` (a value * can never become a flag), no control characters, and a shape allowlist * per argument kind. * - Mutating calls are serialized in-process; two dashboard clicks must not * interleave writes to the same manifest. * - `CLAUDE_CONFIG_DIR` is pinned ONLY when the operator set `CLAUDE_HOME`. * Left unset it stays unset, because the CLI's own defaults already write * where `cc-discovery` looks — pinning it unconditionally is what once * made every MCP add/remove write a file nothing reads. * - S3, a member's own config (`acct` on any operation): the shared `claude` * (`shared-tools.sharedPath`, ENOTSHARED when it is not published) runs * AS the member — `sudo -n -u ah- HOME= DISABLE_AUTOUPDATER=1 * …` in their home, with their own default config dir. Only * `--scope user`, and never `-y`: a plugin whose marketplace declares an * install command fails instead of running unseen as them. Each account * has its own queue, so one member's slow clone never holds up another's. * * @author Nguyễn Ngọc Trí Vĩ */ const { execFile: realExecFile } = require("node:child_process"); // Test seam: how `claude` (or `sudo … claude`) is spawned. let execFile = realExecFile; function __setExecFileImpl(fn) { execFile = fn || realExecFile; } const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); const DEFAULT_TIMEOUT_MS = 120_000; const MAX_OUTPUT_BYTES = 1024 * 1024; /** `owner/repo` — the GitHub shorthand `claude plugin marketplace add` accepts. */ // Both halves must START with an alphanumeric, so `./relative` is not mistaken // for `owner/repo`. const REPO_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/; /** `name` or `name@marketplace`. No slashes: a plugin spec is never a path. */ const PLUGIN_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(@[A-Za-z0-9][A-Za-z0-9._-]*)?$/; /** A marketplace's registered name (what `known_marketplaces.json` is keyed by). */ const MARKETPLACE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; /** Install scopes the CLI understands — note `local`, not `project-local`. */ const CLI_SCOPES = ["user", "project", "local"]; function makeError(code, message) { const err = new Error(message); err.code = code; return err; } /** * Baseline check applied to every argv element we interpolate. A value that * starts with `-` would be parsed as a flag by the CLI even inside an argv * array, so it is rejected rather than escaped. */ function assertArg(value, label) { if (typeof value !== "string" || !value.trim()) { throw makeError("EBADARG", `${label} is required`); } if (value.length > 512) throw makeError("EBADARG", `${label} is too long`); // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(value)) { throw makeError("EBADARG", `${label} contains control characters`); } if (value.startsWith("-")) throw makeError("EBADARG", `${label} may not start with "-"`); return value; } /** * A marketplace source is one of: `owner/repo`, an http(s) URL, or an absolute * path on this machine. Anything else (an ssh remote, a `file://` URL, a * relative path) is refused — the set the UI advertises is the set we accept. */ function assertSource(value) { assertArg(value, "source"); const v = value.trim(); if (v.includes("..")) throw makeError("EBADARG", "source may not contain '..'"); if (/^https?:\/\/\S+$/i.test(v)) return v; if (path.isAbsolute(v)) return v; if (REPO_RE.test(v)) return v; throw makeError("EBADARG", "source must be owner/repo, an http(s) URL, or an absolute path"); } function assertPluginSpec(value) { assertArg(value, "plugin"); if (!PLUGIN_SPEC_RE.test(value)) { throw makeError("EBADARG", "plugin must be or @"); } return value; } function assertMarketplaceName(value) { assertArg(value, "marketplace"); if (!MARKETPLACE_NAME_RE.test(value)) { throw makeError("EBADARG", "marketplace name is not a valid identifier"); } return value; } function assertCliScope(value, acct) { if (value === undefined || value === null || value === "") return "user"; if (!CLI_SCOPES.includes(value)) { throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`); } // A member's CLI runs in their home, which is no project: only their user // layer is theirs to change from here. if (acct && value !== "user") { throw makeError("EBADARG", "only scope user can be changed for your own config"); } return value; } // One in-process queue per account (the host's is "host") for every mutating // CLI call. The dashboard is a single Node process, so a promise chain is the // whole locking story — no file lock, no cross-process coordination. It does // NOT protect against a human running `claude plugin …` in a terminal at the // same moment; the CLI owns that race. const mutationQueues = new Map(); function serialize(fn, acct) { const key = acct ? acct.login : "host"; const queue = mutationQueues.get(key) || Promise.resolve(); const run = queue.then(fn, fn); mutationQueues.set( key, run.then( () => undefined, () => undefined ) ); return run; } /** * `[command, argv, options]` for `claude `: the host's own `claude` on * PATH, or — for `acct` (S3) — the shared one AS the member through sudo, * with only HOME and DISABLE_AUTOUPDATER set (sudo resets the rest; their * default config dir is their own `~/.claude`), in their home. */ function claudeCommand(args, acct) { if (!acct) { // Only pin CLAUDE_CONFIG_DIR when the operator explicitly set CLAUDE_HOME // (a non-default install). Left unset, the CLI's own default config dir // is `~/.claude` and it writes `.claude.json` NEXT TO it (`~/.claude.json`) // — the exact file cc-discovery.getClaudeJsonPath() reads (see that // function's own comment). Pinning CLAUDE_CONFIG_DIR unconditionally to // getClaudeHome() made the CLI write `~/.claude/.claude.json` instead — // a file nothing reads — so every default-install add/remove was inert. // When CLAUDE_HOME IS set, this pins it and the CLI writes // /.claude.json — which is what getClaudeJsonPath() reads in // that branch, so both cases line up. Keep the two in step. const env = { ...process.env }; if (process.env.CLAUDE_HOME) env.CLAUDE_CONFIG_DIR = getClaudeHome(); return ["claude", args, { env }]; } const claude = require("./shared-tools").sharedPath("claude"); if (!claude) throw makeError("ENOTSHARED", "claude is not in the shared tools yet"); // Never -y for a member, and never a --dangerously-* flag (none is built // here; the validators refuse any value starting with "-"). const safe = args.filter((a) => a !== "-y"); const [cmd, argv] = require("./run-as").sudoArgv(acct, claude, safe, { HOME: acct.home, DISABLE_AUTOUPDATER: "1", }); return [cmd, argv, { env: process.env, cwd: acct.home }]; } /** * Run `claude ` and resolve with the outcome. A non-zero exit is a * normal result (`ok:false`), not a rejection — callers map it to a response * body. Only a spawn failure (binary missing) rejects. * * @param {string[]} args argv AFTER the binary name; every element pre-validated. * @param {{timeoutMs?:number, acct?:object}} [opts] `acct`: run AS that member (S3). * @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>} */ // A Claude Code older than `plugin install|update -y` rejects the flag outright. // It also predates the confirmation prompt the flag answers, so the same // command without it is the correct retry, not a weaker one. const NO_YES_FLAG = /unknown option '-y'/; async function runClaude(args, opts = {}) { const result = await runClaudeOnce(args, opts); if (!opts.acct && !result.ok && args.includes("-y") && NO_YES_FLAG.test(result.stderr)) { return runClaudeOnce( args.filter((a) => a !== "-y"), opts ); } return result; } function runClaudeOnce(args, opts = {}) { const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS; return new Promise((resolve, reject) => { let cmd, argv, spawnOpts; try { [cmd, argv, spawnOpts] = claudeCommand(args, opts.acct); } catch (err) { reject(err); return; } const child = execFile( cmd, argv, { timeout, maxBuffer: MAX_OUTPUT_BYTES, ...spawnOpts, }, (err, stdout, stderr) => { const out = String(stdout || ""); const errOut = String(stderr || ""); if (err && (err.code === "ENOENT" || err.code === "EACCES")) { return reject( makeError( "ECLINOTFOUND", "the `claude` CLI was not found on PATH — install Claude Code or add it to the dashboard's PATH" ) ); } if (err && err.killed) { return reject(makeError("ETIMEOUT", `claude ${args[0]} timed out after ${timeout}ms`)); } const code = err ? (typeof err.code === "number" ? err.code : 1) : 0; resolve({ ok: !err, code, stdout: out, stderr: errOut, args }); } ); // Every command here is non-interactive. Closing stdin turns a would-be // prompt into an immediate CLI error instead of a hang until `timeout`. try { child.stdin?.end(); } catch { /* the child may already have exited */ } }); } // ── Operations ───────────────────────────────────────────────────────── // Each validates first, then queues. Validation errors throw synchronously // from the returned promise, which is what the routes turn into a 400. // `acct` (S3): run AS that member, on their own config — see the file header. const queued = (args, acct) => serialize(() => runClaude(args, { acct }), acct); async function marketplaceAdd({ source, scope, acct }) { const src = assertSource(source); const sc = assertCliScope(scope, acct); return queued(["plugin", "marketplace", "add", src, "--scope", sc], acct); } async function marketplaceRemove({ name, acct }) { const n = assertMarketplaceName(name); return queued(["plugin", "marketplace", "remove", n], acct); } async function marketplaceUpdate({ name, acct }) { const n = assertMarketplaceName(name); return queued(["plugin", "marketplace", "update", n], acct); } async function pluginInstall({ spec, scope, acct }) { const s = assertPluginSpec(spec); const sc = assertCliScope(scope, acct); // `-y` accepts the marketplace-declared install command without a prompt; // required because stdin is not a TTY here. Never for a member (dropped by // claudeCommand): there such a plugin fails instead. return queued(["plugin", "install", s, "--scope", sc, "-y"], acct); } async function pluginUninstall({ spec, acct }) { const s = assertPluginSpec(spec); return queued(["plugin", "uninstall", s], acct); } async function pluginEnable({ spec, acct }) { const s = assertPluginSpec(spec); return queued(["plugin", "enable", s], acct); } async function pluginDisable({ spec, acct }) { const s = assertPluginSpec(spec); return queued(["plugin", "disable", s], acct); } async function pluginUpdate({ spec, acct }) { const s = assertPluginSpec(spec); // `-y` for the same reason as pluginInstall: the CLI requires it whenever // stdin is not a TTY (runClaude closes stdin), and without it an update whose // marketplace declares an install command fails on a prompt nobody can answer. return queued(["plugin", "update", s, "-y"], acct); } /** * `claude plugin list --available --json` — every plugin offered by a * registered marketplace, plus what is already installed. Read-only, so it * skips the mutation queue. * * @param {{acct?: object}} [opts] `acct`: the member's own list (S3). * @returns {Promise<{installed:object[], available:object[]}>} */ async function listAvailable({ acct } = {}) { const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000, acct, }); if (!res.ok) { throw makeError("ECLIFAILED", cliMessage(res) || "claude plugin list failed"); } let parsed; try { parsed = JSON.parse(res.stdout); } catch { throw makeError("ECLIFAILED", "could not parse `claude plugin list --json` output"); } return { installed: Array.isArray(parsed.installed) ? parsed.installed : [], available: Array.isArray(parsed.available) ? parsed.available : [], }; } /** * Best-effort human message from a failed run. The CLI prints its errors with * a `✘` marker on either stream; fall back to the last non-empty line. */ function cliMessage(res) { const text = `${res.stderr || ""}\n${res.stdout || ""}`; const lines = text .split("\n") .map((l) => l.trim()) .filter(Boolean); const marked = lines.find((l) => l.includes("✘")); const picked = marked || lines[lines.length - 1] || ""; return picked.replace(/^✘\s*/, "").slice(0, 1000); } module.exports = { runClaude, cliMessage, // Exported so cc-mcp-cli.js queues onto this SAME chokepoint — one CLI // process against CLAUDE_CONFIG_DIR at a time, whether the click was a // plugin install or an MCP add. serialize, listAvailable, marketplaceAdd, marketplaceRemove, marketplaceUpdate, pluginInstall, pluginUninstall, pluginEnable, pluginDisable, pluginUpdate, // exported for tests assertSource, assertPluginSpec, assertMarketplaceName, assertCliScope, PLUGIN_SPEC_RE, __setExecFileImpl, };