8546748759
Dependencies
- Security: body-parser and multer DoS advisories fixed (npm audit: 0).
- Server: Express 5 (query parser kept "extended", req.body defaults to {},
SPA catch-all /{*splat}, send-file helper restores Express 4's dot-folder
rule for sendFile/download, alerts ack route without inline regex),
better-sqlite3 13, js-yaml 5 (server/lib/yaml.js keeps js-yaml 4's default
schema), uuid dropped for crypto.randomUUID, node-pty beta.18, multer 2.5.
- Client: React 19, xterm 6 (WebGL renderer replaces the removed canvas one,
DOM fallback on context loss; Alt+arrow word moves re-mapped), lucide-react 1,
TypeScript 7, i18next/react-i18next/codemirror minors.
Workspace rename step 4
- `agenthub lanes` and `--lane` exit 1 with "renamed: use …"; client api.ts,
the CLI and the MCP server call /api/workspaces; /api/lanes stays mounted.
Server mode (S3): members manage their own Claude Code config
- server/lib/cc-target.js resolves whose config a /cc-config request touches
and gates the router by allowlist; /profiles* and /plugins/update-all stay
admin-only. Member writes go through write-as, CLI calls through sudo as the
member (never -y), reads through openSafe; cc_config_changed reaches only
its owner. Not verified live on an S3 install yet.
Other
- Chat: an HTML file is an artifact only when written whole and not a project
page (index.html, or under src/, public/, templates/, …).
- OpenAPI covers every /api route; openapi-coverage.test.js guards it.
- read-pool test keeps its event loop alive (its worker is unref'd).
- Docs: plan statuses, checklists, CLI.md, LANES.md, SERVER-MODE.md, API.md.
823 lines
29 KiB
JavaScript
823 lines
29 KiB
JavaScript
/**
|
|
* @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/<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 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
|
|
* 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.
|
|
*
|
|
* Whose config: every entry point takes `home` (a Claude dir; the host's
|
|
* getClaudeHome() when absent) and `noProject` (refuse the project layers —
|
|
* a member who named no workspace of theirs; never the dashboard's own
|
|
* folder). Every file operation goes through `ioFor(root)`: under S3 a root
|
|
* inside a member's home — their own config, or an admin's write into a
|
|
* member's workspace — is written, backed up, created and deleted AS that
|
|
* member through `write-as` (content on stdin), and read through run-as's
|
|
* guarded reader; the host never writes inside a member's home itself. Any
|
|
* other root is plain `fs`, exactly as before.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { getClaudeHome } = require("./claude-home");
|
|
const { isUnder, MAX_FILE_BYTES, memberPathOk } = 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/<slug> 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/<project>/memory/<name>.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");
|
|
}
|
|
|
|
/** The Claude dir a write is about: the caller's `home`, else the host's. */
|
|
const homeOf = (opts) => (opts && opts.home) || getClaudeHome();
|
|
|
|
/** No project layer for this caller (a member who named no workspace of theirs). */
|
|
function assertProject(opts) {
|
|
if (opts && opts.noProject) {
|
|
throw makeError("EBADSCOPE", "a project write needs ?cwd= naming one of your workspaces");
|
|
}
|
|
}
|
|
|
|
function rootForScope(scope, opts = {}) {
|
|
if (scope === "user") return homeOf(opts);
|
|
if (scope === "project") {
|
|
assertProject(opts);
|
|
return getProjectClaudeDir(opts.cwd);
|
|
}
|
|
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
|
}
|
|
|
|
function memoryPathForScope(scope, opts = {}) {
|
|
if (scope === "user") return path.join(homeOf(opts), "CLAUDE.md");
|
|
if (scope === "project") {
|
|
assertProject(opts);
|
|
return path.join(getProjectRoot(opts.cwd), "CLAUDE.md");
|
|
}
|
|
throw makeError("EBADSCOPE", `unknown scope: ${scope}`);
|
|
}
|
|
|
|
// ── File operations: as the host, or AS the member whose home it is ──────
|
|
|
|
/** Plain `fs`: the host's own config, as it always was. */
|
|
const HOST_IO = {
|
|
acct: null,
|
|
exists: (p) => fs.existsSync(p),
|
|
readText: (file) => fs.readFileSync(file, "utf8"),
|
|
mkdir: (dir) => fs.mkdirSync(dir, { recursive: true }),
|
|
write: (file, content) => atomicWriteFile(file, content),
|
|
backup: (args) => createBackup(args),
|
|
copyFile: (src, dst) => {
|
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
fs.copyFileSync(src, dst);
|
|
},
|
|
remove: (p) => fs.rmSync(p, { recursive: true, force: true }),
|
|
trustedRootReady: () => {},
|
|
};
|
|
|
|
/**
|
|
* S3: every operation AS `acct`, through `write-as` (content on stdin, never
|
|
* sudo's argv). The member can swap any name in their own home, and a write
|
|
* by the host would follow the swap — as the member, it can only do what the
|
|
* member could. Reads go through run-as's guarded reader (their own regular
|
|
* file, size capped).
|
|
*/
|
|
function memberIo(acct) {
|
|
const runAs = require("./run-as");
|
|
const lexists = (p) => {
|
|
try {
|
|
fs.lstatSync(p);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
const copy = (src, dst) => {
|
|
runAs.mkdirAs(acct, path.dirname(dst));
|
|
runAs.copyAs(acct, src, dst);
|
|
};
|
|
return {
|
|
acct,
|
|
exists: (p) => fs.existsSync(p),
|
|
readText: (file) => {
|
|
try {
|
|
return runAs.readFileSafe(file, "utf8", { max: MAX_FILE_BYTES });
|
|
} catch (err) {
|
|
if (err.code === "ETOOBIG") throw makeError("ETOOLARGE", `${file} is too large`);
|
|
if (err.code === "EFOREIGNFILE") {
|
|
throw makeError("EOUTOFROOT", `${file} is not a file of this account's`);
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
mkdir: (dir) => runAs.mkdirAs(acct, dir),
|
|
write: (file, content) => {
|
|
runAs.mkdirAs(acct, path.dirname(file));
|
|
runAs.writeAs(acct, file, "replace", content);
|
|
},
|
|
backup: ({ source, backupRoot, name }) => {
|
|
if (!lexists(source)) return null;
|
|
const dst = path.join(backupRoot, `${name}.${timestamp()}.bak`);
|
|
copy(source, dst);
|
|
return dst;
|
|
},
|
|
copyFile: copy,
|
|
remove: (p) => runAs.removeAs(acct, p),
|
|
// The host may not create a member's directory itself (assertNoSymlinkEscape
|
|
// would): the member does, and the check runs with `create: false`.
|
|
trustedRootReady: (root) => runAs.mkdirAs(acct, root),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* How to touch files under `root`: AS the member whose home it is (S3), else
|
|
* as the host. Throws EMEMBERGONE for a member's folder with no usable
|
|
* account — never "the host then".
|
|
*/
|
|
function ioFor(root) {
|
|
const acct = require("./run-as").accountForPath(path.resolve(root));
|
|
return acct ? memberIo(acct) : HOST_IO;
|
|
}
|
|
|
|
/** `assertNoSymlinkEscape` without the host ever creating a member's directory. */
|
|
function guardTarget(io, trustedRoot, target) {
|
|
io.trustedRootReady(trustedRoot);
|
|
assertNoSymlinkEscape(trustedRoot, target, {
|
|
label: "Claude",
|
|
create: io.acct ? false : undefined,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolve (and validate) the memory dir for a per-project file-based memory
|
|
* store: ~/.claude/projects/<project>/memory/. Rejects slugs that could
|
|
* traverse out of the projects root.
|
|
*/
|
|
function autoMemoryDir(project, opts = {}) {
|
|
if (typeof project !== "string" || !PROJECT_SLUG_RE.test(project) || project.includes("..")) {
|
|
throw makeError("EBADPROJECT", `invalid project slug: ${project}`);
|
|
}
|
|
const projectsRoot = path.join(homeOf(opts), "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: <abs path of file or skill dir>,
|
|
* filePath: <abs path of the actual .md file inside target>,
|
|
* containmentRoot: <abs path that must contain target> }
|
|
*/
|
|
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, opts);
|
|
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: `<projects>/<slug>/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(homeOf(opts), "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");
|
|
}
|
|
|
|
/**
|
|
* Always-on backup, routed to the bucket the (scope, type) pair backs up
|
|
* into. For files, copies to <bucket>/<name>.<ts>.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, io = HOST_IO }) {
|
|
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
|
|
// `<root>/cc-config-backups` sends the copied contents outside the root.
|
|
if (trustedRoot) guardTarget(io, trustedRoot, root);
|
|
return io.backup({ 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, home, noProject } = 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 opts = { cwd, home, noProject };
|
|
const r = resolveTarget(scope, type, name, { ...opts, project });
|
|
const io = ioFor(r.trustedRoot);
|
|
|
|
// 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.
|
|
guardTarget(io, r.trustedRoot, r.target);
|
|
|
|
const existedBefore = io.exists(r.filePath);
|
|
const backupPath = existedBefore
|
|
? backupTarget({
|
|
scope,
|
|
type,
|
|
target: r.kind === "dir" ? r.target : r.filePath,
|
|
opts,
|
|
trustedRoot: r.trustedRoot,
|
|
io,
|
|
})
|
|
: null;
|
|
|
|
if (r.kind === "dir") {
|
|
io.mkdir(r.target);
|
|
}
|
|
io.write(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, home, noProject } = args;
|
|
const opts = { cwd, home, noProject };
|
|
const r = resolveTarget(scope, type, name, { ...opts, project });
|
|
const io = ioFor(r.trustedRoot);
|
|
|
|
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.
|
|
guardTarget(io, r.trustedRoot, r.target);
|
|
|
|
if (!io.exists(r.target)) {
|
|
throw makeError("ENOTFOUND", `${type}/${name || "CLAUDE.md"} does not exist`);
|
|
}
|
|
|
|
const backupPath = backupTarget({
|
|
scope,
|
|
type,
|
|
target: r.target,
|
|
opts,
|
|
trustedRoot: r.trustedRoot,
|
|
io,
|
|
});
|
|
|
|
if (io.acct) io.remove(r.target);
|
|
else if (r.kind === "dir") HOST_IO.remove(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(opts) {
|
|
return path.join(homeOf(opts), "keybindings.json");
|
|
}
|
|
|
|
function keybindingsBackupRoot(opts) {
|
|
return path.join(homeOf(opts), "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, home } = 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({ home });
|
|
const io = ioFor(homeOf({ home }));
|
|
|
|
// Preserve any existing top-level metadata ($schema, $docs, unknown keys).
|
|
let base = {};
|
|
try {
|
|
const parsed = JSON.parse(io.readText(file));
|
|
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 = io.exists(file);
|
|
let backupPath = null;
|
|
if (existedBefore) {
|
|
const root = keybindingsBackupRoot({ home });
|
|
backupPath = path.join(root, `keybindings.json.${timestamp()}.bak`);
|
|
io.copyFile(file, backupPath);
|
|
}
|
|
|
|
io.write(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(homeOf(opts), "settings.json"),
|
|
project: (opts) => {
|
|
assertProject(opts);
|
|
return path.join(getProjectClaudeDir(opts.cwd), "settings.json");
|
|
},
|
|
"project-local": (opts) => {
|
|
assertProject(opts);
|
|
return 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, home, noProject } = args;
|
|
const opts = { cwd, home, noProject };
|
|
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 }));
|
|
|
|
const io = ioFor(path.dirname(file));
|
|
let existed = false;
|
|
let data = {};
|
|
let raw = null;
|
|
try {
|
|
raw = io.readText(file);
|
|
existed = true;
|
|
} catch (err) {
|
|
// Too big, or not this account's own file: never "fresh" — that would
|
|
// overwrite it.
|
|
if (err.code === "ETOOLARGE" || err.code === "EOUTOFROOT") throw err;
|
|
/* 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);
|
|
backupPath = path.join(root, `${path.basename(file)}.${timestamp()}.bak`);
|
|
io.copyFile(file, backupPath);
|
|
}
|
|
|
|
io.write(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"]).filter(
|
|
(s) => !(opts.noProject && s === "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 {
|
|
if (!memberPathOk(root)) continue;
|
|
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/<slug>/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(homeOf(opts), "projects");
|
|
if (!memberPathOk(projectsRoot)) throw new Error("not listable");
|
|
for (const proj of fs.readdirSync(projectsRoot)) {
|
|
const root = autoMemoryBackupRoot(path.join(projectsRoot, proj, "memory"));
|
|
let entries;
|
|
try {
|
|
if (!memberPathOk(root)) continue;
|
|
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,
|
|
};
|