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:
2026-08-25 15:58:03 +07:00
parent 03a178a718
commit 008ebf1c5c
12 changed files with 3054 additions and 118 deletions
+184 -3
View File
@@ -5,10 +5,17 @@
* skills, subagents, slash commands, output styles, CLAUDE.md memory, and
* per-project file-based memory (~/.claude/projects/<slug>/memory/*.md).
*
* settings.json layers (user / project / project-local) are also writable, but
* ONLY through `patchSettings`'s per-key merge — never as a whole-blob
* overwrite. Hooks are edited through that same path (`hooks.<Event>`).
*
* Hard constraints (do not relax without a follow-up review):
* - Plugins, MCP servers, hooks-in-settings, and settings.json files are
* NEVER touched here. Those have concurrent-write races with the live
* Claude Code CLI and need different handling.
* - Plugins and MCP servers are NEVER touched here. Plugin state is written
* by the `claude` CLI (see cc-plugin-cli.js); MCP config stays read-only.
* - A settings write re-reads the file unredacted and merges the named keys
* only. Writing back what `readSettings` returned would replace a real
* secret with the literal `<redacted>`, so any payload carrying that
* marker is refused.
* - Every write/delete creates a timestamped backup BEFORE the mutation.
* Backups land under <root>/cc-config-backups/<type>/, well outside the
* directories Claude Code scans, so a deleted skill cannot reappear as
@@ -439,6 +446,179 @@ function writeKeybindings(args = {}) {
return { ok: true, file, target: file, backupPath, created: !existedBefore };
}
// ── settings.json (key-merge only) ─────────────────────────────────────
//
// The three settings layers Claude Code reads. `project-local` is the
// dashboard's name for `settings.local.json`; the plugin CLI calls the same
// layer `local` (see cc-plugin-cli.js) — the two vocabularies are separate on
// purpose and never share a validator.
const SETTINGS_SCOPES = {
user: (opts) => path.join(getClaudeHome(), "settings.json"),
project: (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.json"),
"project-local": (opts) => path.join(getProjectClaudeDir(opts.cwd), "settings.local.json"),
};
/** A single dotted-path segment. `$` is allowed for `$schema`-style metadata. */
const SETTINGS_SEGMENT_RE = /^[A-Za-z0-9_$][A-Za-z0-9._$-]*$/;
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
function settingsFileFor(scope, opts) {
const resolver = SETTINGS_SCOPES[scope];
if (!resolver) throw makeError("EBADSCOPE", `unknown settings scope: ${scope}`);
return resolver(opts);
}
function settingsBackupRoot(scope, opts) {
// Beside the file being edited, in the dotted dir Claude Code never scans —
// same convention memory backups use.
return path.join(path.dirname(settingsFileFor(scope, opts)), ".cc-config-backups", "settings");
}
/** Split `"permissions.allow"` into validated segments. */
function parseSettingsPath(dotted) {
if (typeof dotted !== "string" || !dotted.trim()) {
throw makeError("EBADNAME", "settings key path must be a non-empty string");
}
const parts = dotted.split(".");
for (const seg of parts) {
if (!seg || !SETTINGS_SEGMENT_RE.test(seg) || FORBIDDEN_SEGMENTS.has(seg)) {
throw makeError("EBADNAME", `invalid settings key segment: ${seg}`);
}
}
return parts;
}
/**
* Guard against the redaction trap: `readSettings` replaces secret-looking
* string values with the literal `<redacted>` before the UI ever sees them, so
* echoing a value read from the UI back into the file would overwrite a real
* credential with that placeholder. Any payload carrying the marker is
* refused outright rather than partially applied.
*/
function assertNoRedactionMarker(value) {
let json;
try {
json = JSON.stringify(value);
} catch {
throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
}
if (json === undefined) throw makeError("EBADCONTENT", "settings value is not JSON-serializable");
if (json.includes("<redacted>")) {
throw makeError(
"EREDACTED",
"value contains the <redacted> placeholder — edit that key in the file directly instead"
);
}
}
function setDeep(obj, parts, value) {
let node = obj;
for (let i = 0; i < parts.length - 1; i += 1) {
const key = parts[i];
const next = node[key];
if (!next || typeof next !== "object" || Array.isArray(next)) node[key] = {};
node = node[key];
}
node[parts[parts.length - 1]] = value;
}
function unsetDeep(obj, parts) {
let node = obj;
for (let i = 0; i < parts.length - 1; i += 1) {
const next = node[parts[i]];
if (!next || typeof next !== "object") return false;
node = next;
}
const last = parts[parts.length - 1];
if (!Object.prototype.hasOwnProperty.call(node, last)) return false;
delete node[last];
return true;
}
/**
* Merge individual keys into a settings.json layer. This is the ONLY write
* path to a settings file, and it is deliberately a per-key merge: the file is
* re-read (unredacted) here, so keys the dashboard never showed — including
* secret-valued ones — survive byte-for-byte.
*
* A file that exists but does not parse as JSON is an error, never a
* clobber: overwriting a hand-edited file the CLI still owns would lose data.
*
* @param {{scope:string, cwd?:string, set?:Record<string,unknown>, unset?:string[]}} args
* @returns {{path:string, backupPath:string|null, created:boolean, changed:string[]}}
*/
function patchSettings(args) {
const { scope, cwd, set, unset } = args;
const opts = { cwd };
const file = settingsFileFor(scope, opts);
const setEntries =
set && typeof set === "object" && !Array.isArray(set) ? Object.entries(set) : [];
const unsetPaths = Array.isArray(unset) ? unset : [];
if (setEntries.length === 0 && unsetPaths.length === 0) {
throw makeError("EBADCONTENT", "nothing to change: provide `set` and/or `unset`");
}
// Validate everything before touching disk — a partial apply is worse than
// a rejected request.
const parsedSet = setEntries.map(([key, value]) => {
assertNoRedactionMarker(value);
return { parts: parseSettingsPath(key), key, value };
});
const parsedUnset = unsetPaths.map((key) => ({ parts: parseSettingsPath(key), key }));
let existed = false;
let data = {};
let raw = null;
try {
raw = fs.readFileSync(file, "utf8");
existed = true;
} catch {
/* fresh file */
}
if (existed) {
if (Buffer.byteLength(raw, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", "settings file is too large to edit safely");
}
try {
data = raw.trim() ? JSON.parse(raw) : {};
} catch {
throw makeError("EBADJSON", `${file} is not valid JSON — fix it by hand before editing here`);
}
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw makeError("EBADJSON", `${file} does not contain a JSON object`);
}
}
const changed = [];
for (const { parts, key, value } of parsedSet) {
setDeep(data, parts, value);
changed.push(key);
}
for (const { parts, key } of parsedUnset) {
if (unsetDeep(data, parts)) changed.push(key);
}
const output = `${JSON.stringify(data, null, 2)}\n`;
// The same ceiling the read path enforces: a value pasted into the editor
// must not produce a file the explorer can no longer open. Checked BEFORE
// the backup, so a rejected write leaves no artifacts at all.
if (Buffer.byteLength(output, "utf8") > MAX_FILE_BYTES) {
throw makeError("ETOOLARGE", "the resulting settings file would be too large");
}
let backupPath = null;
if (existed) {
const root = settingsBackupRoot(scope, opts);
fs.mkdirSync(root, { recursive: true });
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
fs.copyFileSync(file, backupPath);
}
atomicWriteFile(file, output);
return { path: file, backupPath, created: !existed, changed };
}
/**
* List backups for either all types or a specific (scope, type) bucket.
* Returns [{ scope, type, name, backupPath, mtime, size }].
@@ -528,6 +708,7 @@ module.exports = {
writeArtifact,
deleteArtifact,
writeKeybindings,
patchSettings,
listBackups,
resolveTarget, // exported for tests
TYPES,