/** * @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 to the same CLAUDE_HOME the dashboard * reads, so the CLI writes where `cc-discovery` looks. * * @author Nguyễn Ngọc Trí Vĩ */ const { execFile } = require("node:child_process"); 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) { if (value === undefined || value === null || value === "") return "user"; if (!CLI_SCOPES.includes(value)) { throw makeError("EBADARG", `scope must be one of ${CLI_SCOPES.join(", ")}`); } return value; } // One in-process queue 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. let mutationQueue = Promise.resolve(); function serialize(fn) { const run = mutationQueue.then(fn, fn); mutationQueue = run.then( () => undefined, () => undefined ); return run; } /** * 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}} [opts] * @returns {Promise<{ok:boolean, code:number|null, stdout:string, stderr:string, args:string[]}>} */ function runClaude(args, opts = {}) { const timeout = opts.timeoutMs || DEFAULT_TIMEOUT_MS; return new Promise((resolve, reject) => { const child = execFile( "claude", args, { timeout, maxBuffer: MAX_OUTPUT_BYTES, // Pin the CLI to the same config dir cc-discovery reads, so a write // made here is a write the dashboard can see. env: { ...process.env, CLAUDE_CONFIG_DIR: getClaudeHome() }, }, (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. async function marketplaceAdd({ source, scope }) { const src = assertSource(source); const sc = assertCliScope(scope); return serialize(() => runClaude(["plugin", "marketplace", "add", src, "--scope", sc])); } async function marketplaceRemove({ name }) { const n = assertMarketplaceName(name); return serialize(() => runClaude(["plugin", "marketplace", "remove", n])); } async function marketplaceUpdate({ name }) { const n = assertMarketplaceName(name); return serialize(() => runClaude(["plugin", "marketplace", "update", n])); } async function pluginInstall({ spec, scope }) { const s = assertPluginSpec(spec); const sc = assertCliScope(scope); // `-y` accepts the marketplace-declared install command without a prompt; // required because stdin is not a TTY here. return serialize(() => runClaude(["plugin", "install", s, "--scope", sc, "-y"])); } async function pluginUninstall({ spec }) { const s = assertPluginSpec(spec); return serialize(() => runClaude(["plugin", "uninstall", s])); } async function pluginEnable({ spec }) { const s = assertPluginSpec(spec); return serialize(() => runClaude(["plugin", "enable", s])); } async function pluginDisable({ spec }) { const s = assertPluginSpec(spec); return serialize(() => runClaude(["plugin", "disable", s])); } async function pluginUpdate({ spec }) { const s = assertPluginSpec(spec); return serialize(() => runClaude(["plugin", "update", s])); } /** * `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. * * @returns {Promise<{installed:object[], available:object[]}>} */ async function listAvailable() { const res = await runClaude(["plugin", "list", "--available", "--json"], { timeoutMs: 30_000 }); 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, listAvailable, marketplaceAdd, marketplaceRemove, marketplaceUpdate, pluginInstall, pluginUninstall, pluginEnable, pluginDisable, pluginUpdate, // exported for tests assertSource, assertPluginSpec, assertMarketplaceName, assertCliScope, PLUGIN_SPEC_RE, };