feat(cc-config): manage plugins, marketplaces, hooks and settings from the dashboard
The Claude Config page was read-only for these four surfaces. It can now: - register a marketplace from a pasted GitHub `owner/repo`, http(s) URL or absolute path, and install / enable / disable / update / uninstall plugins, all delegated to the `claude plugin …` CLI so its registry stays consistent; - edit hooks and settings.json keys in the user, project and project-local layers. Server: - `server/lib/cc-plugin-cli.js` wraps the CLI with `execFile` and an explicit argv array — never a shell string. Every argument is validated before the spawn (shape allowlist per kind, no control characters, no leading `-`, no `..`), `CLAUDE_CONFIG_DIR` is pinned to the dashboard's CLAUDE_HOME, stdin is closed so a prompt errors instead of hanging, and mutating calls are serialized in-process. - `cc-mutate.patchSettings` merges individual dotted keys into one settings layer. It re-reads the file unredacted, so keys the UI never displayed — including secret-valued ones — survive byte-for-byte. A payload carrying the `<redacted>` placeholder is refused (EREDACTED), as are `__proto__` / `prototype` / `constructor` segments; a settings file that does not parse is reported (EBADJSON) rather than clobbered. Backup first, then atomic write. - Six additive routes on /api/cc-config; a non-zero CLI exit is a 422 carrying the CLI's own message, a missing binary a 503. Client: install/add bars, per-card plugin and marketplace actions behind a two-click confirm, and edit modes for hooks and settings that send only the keys that changed. New i18n keys in en + vi; the explainer banners that said these surfaces were read-only now describe what the writes actually do. Tests: 61 new server tests (CLI argv construction and validation, the settings key-merge, and the six routes against a fake `claude` on PATH).
This commit is contained in:
+146
-6
@@ -6,12 +6,13 @@
|
||||
* statusline, hook scripts. Powers the Claude Config Explorer dashboard
|
||||
* page.
|
||||
*
|
||||
* Read paths cover every surface. Write paths exist only for low-risk
|
||||
* text-file artifacts (skills, agents, commands, output styles, memory, and
|
||||
* per-project auto-memory files) plus the structured keybindings.json editor,
|
||||
* and always create a timestamped backup before mutating. Plugins, MCP servers,
|
||||
* and the live settings.json files stay read-only because they are written
|
||||
* concurrently by the running Claude Code CLI.
|
||||
* Read paths cover every surface. Write paths cover: low-risk text-file
|
||||
* artifacts (skills, agents, commands, output styles, memory, per-project
|
||||
* auto-memory), the structured keybindings.json editor, settings.json layers
|
||||
* via a per-key merge (`PATCH /settings`, which is also how hooks are edited),
|
||||
* and plugin/marketplace management delegated to the `claude plugin …` CLI.
|
||||
* Every file write is preceded by a timestamped backup. MCP server config
|
||||
* stays read-only — it is written concurrently by the running CLI.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -19,6 +20,7 @@
|
||||
const { Router } = require("express");
|
||||
const cc = require("../lib/cc-discovery");
|
||||
const ccMutate = require("../lib/cc-mutate");
|
||||
const ccCli = require("../lib/cc-plugin-cli");
|
||||
const { broadcast } = require("../websocket");
|
||||
|
||||
const router = Router();
|
||||
@@ -41,6 +43,9 @@ const ERR_TO_STATUS = {
|
||||
ETOOLARGE: 413,
|
||||
EOUTOFROOT: 400,
|
||||
ENOTFOUND: 404,
|
||||
EREDACTED: 400,
|
||||
// The file on disk is unusable as-is; the caller must fix it by hand first.
|
||||
EBADJSON: 409,
|
||||
};
|
||||
|
||||
function mutateError(res, err) {
|
||||
@@ -192,6 +197,141 @@ router.delete("/file", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Plugins / marketplaces (via the `claude` CLI) ──────────────────────
|
||||
//
|
||||
// These shell out to `claude plugin …` with an argv array — never a shell
|
||||
// string — because the CLI owns the manifest + cache tree that plugin state
|
||||
// lives in. Identifiers travel in the body, not the URL, so a key like
|
||||
// `ccam@sg` needs no encoding (same reason `DELETE /file` takes a body).
|
||||
|
||||
const CLI_ERR_TO_STATUS = {
|
||||
EBADARG: 400,
|
||||
ECLINOTFOUND: 503,
|
||||
ETIMEOUT: 504,
|
||||
ECLIFAILED: 502,
|
||||
};
|
||||
|
||||
function cliError(res, err) {
|
||||
const status = CLI_ERR_TO_STATUS[err.code] || 500;
|
||||
return res
|
||||
.status(status)
|
||||
.json({ error: { code: err.code || "EINTERNAL", message: err.message } });
|
||||
}
|
||||
|
||||
/** Map a finished CLI run onto a response; a non-zero exit is a 422, not a 500. */
|
||||
function cliResult(res, result, changed) {
|
||||
if (!result.ok) {
|
||||
return res.status(422).json({
|
||||
error: {
|
||||
code: "ECLIEXIT",
|
||||
message: ccCli.cliMessage(result) || `claude exited with code ${result.code}`,
|
||||
},
|
||||
code: result.code,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
});
|
||||
}
|
||||
emitChanged(changed);
|
||||
res.json({ ok: true, stdout: result.stdout, stderr: result.stderr });
|
||||
}
|
||||
|
||||
router.get("/plugins/available", async (_req, res) => {
|
||||
try {
|
||||
res.json(await ccCli.listAvailable());
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/plugins/install", async (req, res) => {
|
||||
const { spec, scope } = req.body || {};
|
||||
try {
|
||||
const result = await ccCli.pluginInstall({ spec, scope });
|
||||
return cliResult(res, result, { action: "install", type: "plugin", name: spec });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const PLUGIN_ACTIONS = {
|
||||
enable: ccCli.pluginEnable,
|
||||
disable: ccCli.pluginDisable,
|
||||
update: ccCli.pluginUpdate,
|
||||
uninstall: ccCli.pluginUninstall,
|
||||
};
|
||||
|
||||
router.post("/plugins/action", async (req, res) => {
|
||||
const { key, action } = req.body || {};
|
||||
const run = PLUGIN_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADACTION",
|
||||
message: `action must be one of ${Object.keys(PLUGIN_ACTIONS).join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run({ spec: key });
|
||||
return cliResult(res, result, { action, type: "plugin", name: key });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/marketplaces/add", async (req, res) => {
|
||||
const { source, scope } = req.body || {};
|
||||
try {
|
||||
const result = await ccCli.marketplaceAdd({ source, scope });
|
||||
return cliResult(res, result, { action: "add", type: "marketplace", name: source });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
const MARKETPLACE_ACTIONS = {
|
||||
remove: ccCli.marketplaceRemove,
|
||||
update: ccCli.marketplaceUpdate,
|
||||
};
|
||||
|
||||
router.post("/marketplaces/action", async (req, res) => {
|
||||
const { name, action } = req.body || {};
|
||||
const run = MARKETPLACE_ACTIONS[action];
|
||||
if (!run) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
code: "EBADACTION",
|
||||
message: `action must be one of ${Object.keys(MARKETPLACE_ACTIONS).join(", ")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await run({ name });
|
||||
return cliResult(res, result, { action, type: "marketplace", name });
|
||||
} catch (err) {
|
||||
return cliError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/cc-config/settings — merge individual keys into one settings
|
||||
// layer. Body: { scope: "user"|"project"|"project-local", set?: {dotted: value},
|
||||
// unset?: ["dotted"] }. Hooks are edited through this same route as
|
||||
// `set: { "hooks.PreToolUse": [...] }` in settings.json's own nested shape.
|
||||
//
|
||||
// The project layers resolve against the SERVER's cwd, never `?cwd=`: a write
|
||||
// target chosen by the caller would let any request create a settings.json
|
||||
// anywhere on disk. Reads keep the override; writes do not.
|
||||
router.patch("/settings", (req, res) => {
|
||||
const { scope, set, unset } = req.body || {};
|
||||
try {
|
||||
const result = ccMutate.patchSettings({ scope, cwd: process.cwd(), set, unset });
|
||||
emitChanged({ action: "patch", type: "settings", name: scope, project: null });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
return mutateError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/backups", (req, res) => {
|
||||
const scope =
|
||||
req.query.scope === "user" || req.query.scope === "project" ? req.query.scope : undefined;
|
||||
|
||||
Reference in New Issue
Block a user