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.
294 lines
11 KiB
JavaScript
294 lines
11 KiB
JavaScript
/**
|
|
* @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.
|
|
*
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 <name> or <name>@<marketplace>");
|
|
}
|
|
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 <args…>` 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) => {
|
|
// 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_HOME>/.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();
|
|
const child = execFile(
|
|
"claude",
|
|
args,
|
|
{
|
|
timeout,
|
|
maxBuffer: MAX_OUTPUT_BYTES,
|
|
env,
|
|
},
|
|
(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,
|
|
// 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,
|
|
};
|