/** * @file cc-mutate.js * @description Mutation helpers for the Claude Config Explorer. Handles * create / overwrite / delete on the low-risk text-file surfaces only: * skills, subagents, slash commands, output styles, CLAUDE.md memory, and * per-project file-based memory (~/.claude/projects//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.`). * * Hard constraints (do not relax without a follow-up review): * - 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 ``, so any payload carrying that * marker is refused. * - Every write/delete creates a timestamped backup BEFORE the mutation. * Backups land under /cc-config-backups//, well outside the * directories Claude Code scans, so a deleted skill cannot reappear as * a backup-named skill. * - Writes are atomic via temp file + fs.renameSync. Tmp is removed on * any failure path. * - Names are validated against a strict allowlist regex; resolved paths * are double-checked to live under the expected root before any I/O. * @author Nguyễn Ngọc Trí Vĩ */ const fs = require("node:fs"); const path = require("node:path"); const { getClaudeHome } = require("./claude-home"); const { isUnder, MAX_FILE_BYTES } = require("./cc-discovery"); const { timestamp, createBackup, atomicWriteFile, assertNoSymlinkEscape, } = require("./config-write"); const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; // Auto-memory files are arbitrary flat *.md filenames inside a project's // memory dir; the project is the ~/.claude/projects/ dir name. const MEMORY_FILE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/i; // Project slugs are an absolute cwd with "/" → "-", so they begin with "-". // Allow alnum/_/- as the first char (never "." — blocks hidden/weird dirs); // traversal is additionally blocked by the !includes("..") + isUnder guards. const PROJECT_SLUG_RE = /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/; const TYPES = { skills: { kind: "dir", subdir: "skills", filename: "SKILL.md" }, agents: { kind: "file", subdir: "agents", ext: ".md" }, commands: { kind: "file", subdir: "commands", ext: ".md" }, "output-styles": { kind: "file", subdir: "output-styles", ext: ".md" }, memory: { kind: "memory" }, // CLAUDE.md at root, no `name` // Per-project file-based memory: ~/.claude/projects//memory/.md. // Keyed by (project, name); scope is irrelevant (always under CLAUDE_HOME). "auto-memory": { kind: "auto-memory" }, }; function getProjectRoot(cwd) { return path.resolve(cwd || process.cwd()); } function getProjectClaudeDir(cwd) { return path.join(getProjectRoot(cwd), ".claude"); } function rootForScope(scope, opts = {}) { if (scope === "user") return getClaudeHome(); if (scope === "project") return getProjectClaudeDir(opts.cwd); throw makeError("EBADSCOPE", `unknown scope: ${scope}`); } function memoryPathForScope(scope, opts = {}) { if (scope === "user") return path.join(getClaudeHome(), "CLAUDE.md"); if (scope === "project") return path.join(getProjectRoot(opts.cwd), "CLAUDE.md"); throw makeError("EBADSCOPE", `unknown scope: ${scope}`); } /** * Resolve (and validate) the memory dir for a per-project file-based memory * store: ~/.claude/projects//memory/. Rejects slugs that could * traverse out of the projects root. */ function autoMemoryDir(project) { if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) { throw makeError("EBADPROJECT", `invalid project slug: ${project}`); } const projectsRoot = path.join(getClaudeHome(), "projects"); const dir = path.join(projectsRoot, project, "memory"); if (!isUnder(projectsRoot, dir)) { throw makeError("EOUTOFROOT", "project escapes the projects root"); } return dir; } function makeError(code, message) { const err = new Error(message); err.code = code; return err; } /** * Resolve the on-disk target for a (scope, type, name) tuple AND the * containment root used for path-traversal checks. * * Returns: * { kind: "file" | "dir" | "memoryFile", * target: , * filePath: , * containmentRoot: } */ function resolveTarget(scope, type, name, opts = {}) { const spec = TYPES[type]; if (!spec) throw makeError("EBADTYPE", `unknown type: ${type}`); if (spec.kind === "memory") { const filePath = memoryPathForScope(scope, opts); // Memory's containment root is the parent dir (CLAUDE_HOME or project root). return { kind: "memoryFile", target: filePath, filePath, containmentRoot: path.dirname(filePath), trustedRoot: path.dirname(filePath), }; } if (spec.kind === "auto-memory") { const memDir = autoMemoryDir(opts.project); if (typeof name !== "string" || !MEMORY_FILE_RE.test(name) || name.includes("..")) { throw makeError("EBADNAME", `auto-memory name must be a flat *.md filename`); } const target = path.join(memDir, name); // The projects root, not memDir: `//memory` is itself // replaceable with a link, so it cannot be its own comparison base. return { kind: "file", target, filePath: target, containmentRoot: memDir, trustedRoot: path.join(getClaudeHome(), "projects"), }; } if (typeof name !== "string" || !NAME_RE.test(name)) { throw makeError("EBADNAME", `name must match ${NAME_RE}`); } const root = rootForScope(scope, opts); const subdirAbs = path.join(root, spec.subdir); if (spec.kind === "dir") { const target = path.join(subdirAbs, name); return { kind: "dir", target, filePath: path.join(target, spec.filename), containmentRoot: subdirAbs, trustedRoot: root, }; } // file const target = path.join(subdirAbs, name + spec.ext); return { kind: "file", target, filePath: target, containmentRoot: subdirAbs, trustedRoot: root, }; } function backupRoot(scope, type, opts = {}) { return path.join(rootForScope(scope, opts), "cc-config-backups", type); } function memoryBackupRoot(scope, opts = {}) { // Memory's "type" for backup bookkeeping is just "memory"; root sits beside // the file itself. const dir = path.dirname(memoryPathForScope(scope, opts)); return path.join(dir, ".cc-config-backups", "memory"); } function autoMemoryBackupRoot(memDir) { // Backups live in a dotted subdir of the memory dir. Claude Code only loads // *.md directly in the dir, so .bak files tucked under a subdir stay inert. return path.join(memDir, ".cc-config-backups", "auto-memory"); } function rmTreeSync(p) { fs.rmSync(p, { recursive: true, force: true }); } /** * Always-on backup, routed to the bucket the (scope, type) pair backs up * into. For files, copies to /..bak. For dirs (skills), * copies the whole tree. Returns the backup path (or null if there was * nothing to back up — e.g. brand-new file). */ function backupTarget({ scope, type, target, opts, trustedRoot }) { let root; if (type === "memory") root = memoryBackupRoot(scope, opts); else if (type === "auto-memory") root = autoMemoryBackupRoot(path.dirname(target)); else root = backupRoot(scope, type, opts); // The backup DESTINATION needs the same guard as the artifact: a link at // `/cc-config-backups` sends the copied contents outside the root. if (trustedRoot) assertNoSymlinkEscape(trustedRoot, root, { label: "Claude" }); return createBackup({ source: target, backupRoot: root, type, name: path.basename(target) }); } // ── Public API ───────────────────────────────────────────────────────── /** * Create or overwrite a single text artifact. Returns metadata including * the backup path (null if this was a fresh create). * * @param {{scope:string, type:string, name?:string, content:string, cwd?:string}} args */ function writeArtifact(args) { const { scope, type, name, content, cwd, project } = args; if (typeof content !== "string") throw makeError("EBADCONTENT", "content must be a string"); if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) { throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`); } const r = resolveTarget(scope, type, name, { cwd, project }); // Containment guard: even after our regex, double-check that the resolved // path actually lives under the expected root. Defends against quirks like // Windows drive letters or normalize-then-resolve mismatches. if (!isUnder(r.containmentRoot, r.target)) { throw makeError("EOUTOFROOT", "resolved path is outside containment root"); } // The lexical check above is defeated by a symlink at the target or at any // ancestor of it — see config-write.js. Resolve before writing. assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" }); const existedBefore = fs.existsSync(r.filePath); const backupPath = existedBefore ? backupTarget({ scope, type, target: r.kind === "dir" ? r.target : r.filePath, opts: { cwd }, trustedRoot: r.trustedRoot, }) : null; if (r.kind === "dir") { fs.mkdirSync(r.target, { recursive: true }); } atomicWriteFile(r.filePath, content); return { ok: true, file: r.filePath, target: r.target, backupPath, created: !existedBefore, }; } /** * Delete a single text artifact. Backup is mandatory and runs first; if * the backup fails, the original is left intact. */ function deleteArtifact(args) { const { scope, type, name, cwd, project } = args; const r = resolveTarget(scope, type, name, { cwd, project }); if (!isUnder(r.containmentRoot, r.target)) { throw makeError("EOUTOFROOT", "resolved path is outside containment root"); } // rmSync/unlinkSync only unlink the link itself, but the backup that runs // first copies THROUGH it — so the same guard has to cover delete. assertNoSymlinkEscape(r.trustedRoot, r.target, { label: "Claude" }); if (!fs.existsSync(r.target)) { throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`); } const backupPath = backupTarget({ scope, type, target: r.target, opts: { cwd }, trustedRoot: r.trustedRoot, }); if (r.kind === "dir") { rmTreeSync(r.target); } else { fs.unlinkSync(r.target); } return { ok: true, file: r.filePath, target: r.target, backupPath }; } // ── Keybindings (structured JSON edit) ───────────────────────────────── // // keybindings.json is a single user-scope JSON file (~/.claude/keybindings.json). // Unlike settings.json / ~/.claude.json it is not rewritten mid-session by the // live CLI, so a backup-before-write edit is safe. We read-modify-write: any // existing top-level keys ($schema, $docs, and anything we don't model) are // preserved and only the `bindings` array is replaced, so metadata is never // dropped. Backups land under CLAUDE_HOME/cc-config-backups/keybindings/. function keybindingsFile() { return path.join(getClaudeHome(), "keybindings.json"); } function keybindingsBackupRoot() { return path.join(getClaudeHome(), "cc-config-backups", "keybindings"); } // A keybinding key ("ctrl+t", "escape", "shift+ctrl+f") or action id // ("toggleTodos"). Bounded, non-empty, single-line printable text. function validKbString(s, max) { return typeof s === "string" && s.trim().length >= 1 && s.length <= max && !/[\r\n\t]/.test(s); } /** * Overwrite ~/.claude/keybindings.json from a structured list of groups. Each * group is `{ context, bindings: [{ key, action }] }`; on disk the bindings * become an object keyed by `key`. Validates shape, rejects duplicate contexts * and duplicate keys within a context, backs up the existing file first, then * writes atomically. Returns `{ ok, file, backupPath, created }`. * * @param {{ groups: Array<{context:string, bindings:Array<{key:string,action:string}>}> }} args */ function writeKeybindings(args = {}) { const { groups } = args; if (!Array.isArray(groups)) { throw makeError("EBADCONTENT", "groups must be an array"); } if (groups.length > 200) { throw makeError("ETOOLARGE", "too many keybinding contexts (max 200)"); } const outBindings = []; const seenContexts = new Set(); for (const g of groups) { if (!g || typeof g !== "object") { throw makeError("EBADCONTENT", "each group must be an object"); } const context = typeof g.context === "string" ? g.context.trim() : ""; if (!validKbString(context, 128)) { throw makeError("EBADCONTENT", "each group needs a non-empty context (<= 128 chars)"); } if (seenContexts.has(context)) { throw makeError("EBADCONTENT", `duplicate context: ${context}`); } seenContexts.add(context); const list = Array.isArray(g.bindings) ? g.bindings : []; if (list.length > 1000) { throw makeError("ETOOLARGE", `too many bindings in context ${context} (max 1000)`); } const map = {}; for (const b of list) { if (!b || typeof b !== "object") { throw makeError("EBADCONTENT", `each binding in context ${context} must be an object`); } const key = typeof b.key === "string" ? b.key.trim() : ""; const action = typeof b.action === "string" ? b.action.trim() : ""; if (!validKbString(key, 64)) { throw makeError("EBADCONTENT", `invalid key in context ${context}`); } if (!validKbString(action, 128)) { throw makeError("EBADCONTENT", `invalid action for key "${key}" in context ${context}`); } if (Object.prototype.hasOwnProperty.call(map, key)) { throw makeError("EBADCONTENT", `duplicate key "${key}" in context ${context}`); } map[key] = action; } outBindings.push({ context, bindings: map }); } const file = keybindingsFile(); // Preserve any existing top-level metadata ($schema, $docs, unknown keys). let base = {}; try { const parsed = JSON.parse(fs.readFileSync(file, "utf8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) base = parsed; } catch { base = {}; } const nextObj = { ...base, bindings: outBindings }; const content = JSON.stringify(nextObj, null, 2) + "\n"; if (Buffer.byteLength(content, "utf8") > MAX_FILE_BYTES) { throw makeError("ETOOLARGE", `content exceeds ${MAX_FILE_BYTES} bytes`); } const existedBefore = fs.existsSync(file); let backupPath = null; if (existedBefore) { const root = keybindingsBackupRoot(); fs.mkdirSync(root, { recursive: true }); backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`); fs.copyFileSync(file, backupPath); } atomicWriteFile(file, content); 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 `` 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("")) { throw makeError( "EREDACTED", "value contains the 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, 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"); } // A patch that changes nothing writes nothing — and therefore backs up // nothing. Re-applying the same AI Proxy profile to a lane is the common // case (every re-attach sends the same eight env keys), and an unconditional // backup turned that into a `.bak` per apply piling up inside the user's // project. `changed` still lists the keys the caller asked for: they ARE the // file's content now, the request was satisfied, there was just nothing to // move to get there. if (existed && output === raw) { return { path: file, backupPath: null, created: false, changed }; } 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 }]. */ function listBackups(opts = {}) { const out = []; const scopes = opts.scope ? [opts.scope] : ["user", "project"]; // auto-memory backups live per-project, not under a user/project root — they // are scanned separately below. const types = (opts.type ? [opts.type] : Object.keys(TYPES)).filter((t) => t !== "auto-memory"); for (const scope of scopes) { if (scope === "auto-memory") continue; for (const type of types) { const root = type === "memory" ? memoryBackupRoot(scope, opts) : backupRoot(scope, type, opts); let entries = []; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } for (const ent of entries) { const full = path.join(root, ent.name); let stat; try { stat = fs.statSync(full); } catch { continue; } out.push({ scope, type, name: ent.name, backupPath: full, isDir: ent.isDirectory(), mtime: stat.mtimeMs, size: ent.isDirectory() ? null : stat.size, }); } } } // Per-project auto-memory backups: ~/.claude/projects//memory/ // .cc-config-backups/auto-memory/. Best-effort — never throw. const wantAuto = (!opts.type || opts.type === "auto-memory") && (!opts.scope || opts.scope === "auto-memory"); if (wantAuto) { try { const projectsRoot = path.join(getClaudeHome(), "projects"); for (const proj of fs.readdirSync(projectsRoot)) { const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory")); let entries; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; } for (const ent of entries) { const full = path.join(root, ent.name); let stat; try { stat = fs.statSync(full); } catch { continue; } out.push({ scope: "auto-memory", project: proj, type: "auto-memory", name: ent.name, backupPath: full, isDir: ent.isDirectory(), mtime: stat.mtimeMs, size: ent.isDirectory() ? null : stat.size, }); } } } catch { /* ignore */ } } return out.sort((a, b) => b.mtime - a.mtime); } module.exports = { writeArtifact, deleteArtifact, writeKeybindings, patchSettings, listBackups, resolveTarget, // exported for tests TYPES, NAME_RE, };