ccdesk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -2
  4. package/dist/server/agent-edit.js +77 -0
  5. package/dist/server/backups.js +303 -0
  6. package/dist/server/cli.js +105 -0
  7. package/dist/server/mcp-edit.js +263 -0
  8. package/dist/server/mutations.js +193 -0
  9. package/dist/server/open.js +139 -0
  10. package/dist/server/scan/compare.js +34 -0
  11. package/dist/server/scan/context.js +140 -0
  12. package/dist/server/scan/env.js +28 -0
  13. package/dist/server/scan/files.js +478 -0
  14. package/dist/server/scan/index.js +280 -0
  15. package/dist/server/scan/instructions.js +289 -0
  16. package/dist/server/scan/inventory.js +452 -0
  17. package/dist/server/scan/mcp.js +313 -0
  18. package/dist/server/scan/memory.js +90 -0
  19. package/dist/server/scan/path-test.js +121 -0
  20. package/dist/server/scan/permissions.js +101 -0
  21. package/dist/server/scan/plugins.js +124 -0
  22. package/dist/server/scan/projects.js +101 -0
  23. package/dist/server/scan/sandbox.js +161 -0
  24. package/dist/server/scan/settings.js +330 -0
  25. package/dist/server/scan/tools.js +111 -0
  26. package/dist/server/scan/usage.js +514 -0
  27. package/dist/server/schema.js +15 -0
  28. package/dist/server/secrets.js +63 -0
  29. package/dist/server/server.js +352 -0
  30. package/dist/server/settings-edit.js +221 -0
  31. package/dist/server/watch.js +214 -0
  32. package/dist/server/write.js +233 -0
  33. package/dist/shared/builtin-tools.json +235 -0
  34. package/dist/shared/compare.js +1 -0
  35. package/dist/shared/hook-events.json +35 -0
  36. package/dist/shared/mcp-server.schema.json +25 -0
  37. package/dist/shared/memory.js +4 -0
  38. package/dist/shared/scopes.js +20 -0
  39. package/dist/shared/settings-scopes.json +208 -0
  40. package/dist/shared/settings.schema.json +4623 -0
  41. package/dist/shared/types.js +2 -0
  42. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  43. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  44. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  45. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  46. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  47. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  48. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  49. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  50. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  51. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  52. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  53. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  54. package/dist/ui/index.html +15 -0
  55. package/dist/ui/logo.png +0 -0
  56. package/package.json +86 -4
@@ -0,0 +1,352 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { createServer } from "node:http";
3
+ import { extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
+ import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
5
+ import { EVERY_FILE, PERMISSION_LISTS } from "../shared/scopes.js";
6
+ import { SESSION_PROFILES, } from "../shared/types.js";
7
+ import { listBackups, previewRestore, restoreBackup } from "./backups.js";
8
+ import { previewAgentEdit, saveAgentEdit } from "./agent-edit.js";
9
+ import { previewMcpEdit, runClaude, saveMcpEdit } from "./mcp-edit.js";
10
+ import { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.js";
11
+ import { openInEditor } from "./open.js";
12
+ import { compareSettings } from "./scan/compare.js";
13
+ import {} from "./scan/env.js";
14
+ import { isCredentials, isRecord } from "./scan/files.js";
15
+ import { scanOverview } from "./scan/index.js";
16
+ import { CountStopped, readUsage } from "./scan/usage.js";
17
+ import { testPath } from "./scan/path-test.js";
18
+ import { previewHookEdit, previewPermissionEdit, previewSettingsEdit, saveHookEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
19
+ import { Watcher } from "./watch.js";
20
+ import { RefusedError } from "./write.js";
21
+ const MIME = {
22
+ ".html": "text/html; charset=utf-8",
23
+ ".js": "text/javascript; charset=utf-8",
24
+ ".css": "text/css; charset=utf-8",
25
+ ".svg": "image/svg+xml",
26
+ ".woff2": "font/woff2",
27
+ ".png": "image/png",
28
+ ".json": "application/json",
29
+ };
30
+ const SECURITY_HEADERS = {
31
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
32
+ "X-Content-Type-Options": "nosniff",
33
+ "Referrer-Policy": "no-referrer",
34
+ "Cache-Control": "no-store",
35
+ };
36
+ /** A request ccdesk will not take, answered with `status`. */
37
+ class RequestError extends Error {
38
+ status;
39
+ constructor(status, message) {
40
+ super(message);
41
+ this.status = status;
42
+ }
43
+ }
44
+ export function createCcdeskServer(options) {
45
+ const watcher = new Watcher(options.env);
46
+ const table = routes(options, watcher);
47
+ const server = createServer((req, res) => {
48
+ handle(options, table, server, req, res).catch((error) => {
49
+ const [status, message] = failure(error);
50
+ send(res, status, { error: message });
51
+ });
52
+ });
53
+ server.on("close", () => watcher.close());
54
+ return server;
55
+ }
56
+ /** The status and message an API request that failed is answered with. */
57
+ function failure(error) {
58
+ if (error instanceof RequestError)
59
+ return [error.status, error.message];
60
+ // A change ccdesk refuses on purpose.
61
+ if (error instanceof RefusedError)
62
+ return [409, error.message];
63
+ if (error instanceof CountStopped)
64
+ return [503, error.message];
65
+ return [500, error instanceof Error ? error.message : String(error)];
66
+ }
67
+ /** The query parameter `name`; a missing one is a 400. */
68
+ function param(url, name, message = `${name} is required`) {
69
+ const value = url.searchParams.get(name);
70
+ if (!value)
71
+ throw new RequestError(400, message);
72
+ return value;
73
+ }
74
+ /** Aborts when the page goes away or asks for something else. */
75
+ function stopOnClose(res) {
76
+ const stop = new AbortController();
77
+ res.once("close", () => stop.abort());
78
+ return stop.signal;
79
+ }
80
+ function routes(options, watcher) {
81
+ const { env, launchDir } = options;
82
+ const run = options.runClaude ?? runClaude;
83
+ const revision = (body) => stringFields(body, ["revision"]).revision;
84
+ const get = (path, action) => ({ method: "GET", path, action });
85
+ const post = (path, action) => ({ method: "POST", path, action });
86
+ return [
87
+ get("/api/overview", () => scanOverview(env, launchDir)),
88
+ get("/api/directory", ({ url }) => {
89
+ const profile = url.searchParams.get("profile") ?? "interactive";
90
+ if (!SESSION_PROFILES.includes(profile))
91
+ throw new RequestError(400, `profile is one of ${SESSION_PROFILES.join(", ")}`);
92
+ return watcher.scan(param(url, "path"), profile);
93
+ }),
94
+ post("/api/usage", ({ body, res }) => {
95
+ const { path } = stringFields(body, ["path"]);
96
+ const rules = body.rules;
97
+ if (!Array.isArray(rules))
98
+ throw new RequestError(400, "rules must be a list");
99
+ const inEffect = rules.map((r) => {
100
+ const { list, rule } = stringFields(r, ["list", "rule"]);
101
+ if (!PERMISSION_LISTS.includes(list))
102
+ throw new RequestError(400, "a rule needs a list: allow, ask or deny");
103
+ return { list: list, rule };
104
+ });
105
+ return readUsage(env, path, inEffect, { signal: stopOnClose(res) });
106
+ }),
107
+ post("/api/memory/preview", async ({ body }) => {
108
+ const { action, path } = stringFields(body, ["action", "path"]);
109
+ if (!isMemoryAction(action))
110
+ throw new RequestError(400, "unknown memory action");
111
+ return previewMemoryChange(env, action, path);
112
+ }),
113
+ post("/api/memory/change", async ({ body }) => {
114
+ const { action, path, revision } = stringFields(body, ["action", "path", "revision"]);
115
+ if (!isMemoryAction(action))
116
+ throw new RequestError(400, "unknown memory action");
117
+ return changeMemory(env, action, path, revision);
118
+ }),
119
+ post("/api/open", async ({ body }) => {
120
+ const { path } = stringFields(body, ["path"]);
121
+ if (!isAbsolute(path))
122
+ throw new RequestError(400, "an absolute path is required");
123
+ const info = await stat(path).catch(() => null);
124
+ if (!info?.isFile() && !info?.isDirectory())
125
+ throw new RefusedError("That file or folder no longer exists.");
126
+ if (await isCredentials(env, path))
127
+ throw new RefusedError("ccdesk does not open credentials.");
128
+ return { editor: await (options.openFile ?? openInEditor)(path) };
129
+ }),
130
+ post("/api/settings/preview", async ({ body }) => previewSettingsEdit(env, settingsEditFrom(body))),
131
+ post("/api/settings/save", async ({ body }) => saveSettingsEdit(env, settingsEditFrom(body), revision(body))),
132
+ post("/api/permissions/preview", async ({ body }) => previewPermissionEdit(env, permissionEditFrom(body))),
133
+ post("/api/permissions/save", async ({ body }) => savePermissionEdit(env, permissionEditFrom(body), revision(body))),
134
+ post("/api/hooks/preview", async ({ body }) => previewHookEdit(env, hookEditFrom(body))),
135
+ post("/api/hooks/save", async ({ body }) => saveHookEdit(env, hookEditFrom(body), revision(body))),
136
+ post("/api/agents/preview", async ({ body }) => previewAgentEdit(env, agentEditFrom(body))),
137
+ post("/api/agents/save", async ({ body }) => saveAgentEdit(env, agentEditFrom(body), revision(body))),
138
+ post("/api/mcp/preview", async ({ body }) => previewMcpEdit(env, mcpEditFrom(body), run)),
139
+ post("/api/mcp/save", async ({ body }) => saveMcpEdit(env, mcpEditFrom(body), revision(body), run)),
140
+ get("/api/watch", async ({ url, res }) => {
141
+ const dir = url.searchParams.get("dir");
142
+ const since = Number(url.searchParams.get("since") ?? "0");
143
+ if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
144
+ throw new RequestError(400, "dir is required");
145
+ return watcher.wait(resolve(dir), since, stopOnClose(res));
146
+ }),
147
+ get("/api/compare", async ({ url }) => {
148
+ const a = url.searchParams.get("a");
149
+ const b = url.searchParams.get("b");
150
+ if (!a || !b || !isAbsolute(a) || !isAbsolute(b))
151
+ throw new RequestError(400, "a and b must be absolute paths");
152
+ return compareSettings(env, a, b);
153
+ }),
154
+ get("/api/path-test", async ({ url }) => {
155
+ const dir = url.searchParams.get("dir");
156
+ const file = url.searchParams.get("file");
157
+ if (!dir || !isAbsolute(dir) || !file)
158
+ throw new RequestError(400, "dir and file are required");
159
+ return testPath(env, dir, file, (cwd) => watcher.view(cwd));
160
+ }),
161
+ get("/api/backups", async ({ url }) => {
162
+ const before = url.searchParams.get("before") ?? undefined;
163
+ const limit = Number(url.searchParams.get("limit") ?? "50");
164
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
165
+ throw new RequestError(400, "limit must be 1 to 200");
166
+ return listBackups(env, { ...(before ? { before } : {}), limit });
167
+ }),
168
+ post("/api/backups/preview", async ({ body }) => previewRestore(env, stringFields(body, ["id"]).id)),
169
+ post("/api/backups/restore", async ({ body }) => {
170
+ const { id, revision } = stringFields(body, ["id", "revision"]);
171
+ return restoreBackup(env, id, revision);
172
+ }),
173
+ ];
174
+ }
175
+ /**
176
+ * Every request passes the same checks before its route runs: the Host header, then for the API
177
+ * the token, the Origin of anything but a GET, and a POST's body. Failures are answered by
178
+ * `failure`.
179
+ */
180
+ async function handle(options, table, server, req, res) {
181
+ const address = server.address();
182
+ const port = typeof address === "object" && address ? address.port : 0;
183
+ // Reject DNS-rebinding requests: only our own loopback origin may talk to this server.
184
+ const allowedHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
185
+ if (!allowedHosts.includes(req.headers.host ?? ""))
186
+ return send(res, 421, { error: "Unexpected host" });
187
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
188
+ if (!url.pathname.startsWith("/api/"))
189
+ return serveStatic(options.uiDir, url.pathname, res);
190
+ if (req.headers["x-ccdesk-token"] !== options.token)
191
+ return send(res, 401, { error: "Missing or wrong token" });
192
+ const origin = req.headers.origin;
193
+ if (req.method !== "GET" &&
194
+ origin !== undefined &&
195
+ !allowedHosts.some((h) => origin === `http://${h}`)) {
196
+ return send(res, 403, { error: "Cross-origin request refused" });
197
+ }
198
+ const route = table.find((r) => r.method === req.method && r.path === url.pathname);
199
+ if (!route)
200
+ return send(res, 404, { error: "Not found" });
201
+ const body = route.method === "POST" ? await readBody(req) : undefined;
202
+ return send(res, 200, await route.action({ url, body, res }));
203
+ }
204
+ /** The named string fields of a JSON body; anything else is a 400. */
205
+ function stringFields(body, names) {
206
+ if (!isRecord(body) || names.some((n) => typeof body[n] !== "string"))
207
+ throw new RequestError(400, `${names.join(", ")} ${names.length === 1 ? "is" : "are"} required`);
208
+ return body;
209
+ }
210
+ function settingsEditFrom(body) {
211
+ const { dir, scope, key } = stringFields(body, ["dir", "scope", "key"]);
212
+ if (!EVERY_FILE.includes(scope))
213
+ throw new RequestError(400, "unknown scope");
214
+ const value = body.value;
215
+ return { dir, scope: scope, key, ...(value === undefined ? {} : { value }) };
216
+ }
217
+ function permissionEditFrom(body) {
218
+ const { dir, file } = stringFields(body, ["dir", "file"]);
219
+ const rule = (value) => {
220
+ if (value === undefined)
221
+ return undefined;
222
+ const { list, rule } = stringFields(value, ["list", "rule"]);
223
+ if (!PERMISSION_LISTS.includes(list) || rule.trim() === "")
224
+ throw new RequestError(400, "a rule needs a list (allow, ask or deny) and text");
225
+ return { list: list, rule };
226
+ };
227
+ const { remove, add } = body;
228
+ const edit = { dir, file };
229
+ const removed = rule(remove);
230
+ const added = rule(add);
231
+ if (removed)
232
+ edit.remove = removed;
233
+ if (added)
234
+ edit.add = added;
235
+ if (!removed && !added)
236
+ throw new RequestError(400, "remove or add is required");
237
+ return edit;
238
+ }
239
+ function hookEditFrom(body) {
240
+ const { dir, file } = stringFields(body, ["dir", "file"]);
241
+ const { remove, add } = body;
242
+ const edit = { dir, file };
243
+ if (remove !== undefined) {
244
+ const { event } = stringFields(remove, ["event"]);
245
+ const { at, definition } = remove;
246
+ const index = (n) => (Number.isInteger(n) && n >= 0 ? n : -1);
247
+ const group = isRecord(at) ? index(at.group) : -1;
248
+ const hook = isRecord(at) ? index(at.index) : -1;
249
+ if (group < 0 || hook < 0)
250
+ throw new RequestError(400, "remove needs the hook's place");
251
+ edit.remove = { event, at: { group, index: hook }, definition };
252
+ }
253
+ if (add !== undefined) {
254
+ const { event } = stringFields(add, ["event"]);
255
+ if (!HOOK_EVENTS.includes(event))
256
+ throw new RequestError(400, `${event} is not a hook event Claude Code knows`);
257
+ const { matcher, definition } = add;
258
+ if (matcher !== undefined && typeof matcher !== "string")
259
+ throw new RequestError(400, "matcher must be text");
260
+ if (!isRecord(definition))
261
+ throw new RequestError(400, "a hook must be a JSON object");
262
+ edit.add = { event, ...(matcher ? { matcher } : {}), definition };
263
+ }
264
+ if (!edit.remove && !edit.add)
265
+ throw new RequestError(400, "remove or add is required");
266
+ return edit;
267
+ }
268
+ function agentEditFrom(body) {
269
+ const { dir } = stringFields(body, ["dir"]);
270
+ const { path, create, text } = body;
271
+ if (text !== null && typeof text !== "string")
272
+ throw new RequestError(400, "text must be the agent file, or null to delete it");
273
+ const edit = { dir, text };
274
+ if (typeof path === "string")
275
+ edit.path = path;
276
+ else if (create !== undefined) {
277
+ const { scope, file } = stringFields(create, ["scope", "file"]);
278
+ if (scope !== "user" && scope !== "project")
279
+ throw new RequestError(400, "a new agent goes in user or project scope");
280
+ if (text === null)
281
+ throw new RequestError(400, "a new agent needs its text");
282
+ edit.create = { scope, file };
283
+ }
284
+ else
285
+ throw new RequestError(400, "path or create is required");
286
+ return edit;
287
+ }
288
+ function mcpEditFrom(body) {
289
+ const { dir, scope } = stringFields(body, ["dir", "scope"]);
290
+ if (scope !== "user" && scope !== "local" && scope !== "project")
291
+ throw new RequestError(400, "scope must be user, local or project");
292
+ const { remove, add } = body;
293
+ const edit = { dir, scope };
294
+ if (remove !== undefined) {
295
+ const { name } = stringFields(remove, ["name"]);
296
+ edit.remove = { name, definition: remove.definition };
297
+ }
298
+ if (add !== undefined) {
299
+ const { name } = stringFields(add, ["name"]);
300
+ const { definition } = add;
301
+ if (!isRecord(definition))
302
+ throw new RequestError(400, "a server must be a JSON object");
303
+ edit.add = { name, definition };
304
+ }
305
+ if (!edit.remove && !edit.add)
306
+ throw new RequestError(400, "remove or add is required");
307
+ return edit;
308
+ }
309
+ const MAX_BODY = 64 * 1024;
310
+ async function readBody(req) {
311
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
312
+ throw new RequestError(415, "Send JSON");
313
+ const chunks = [];
314
+ let size = 0;
315
+ for await (const chunk of req) {
316
+ size += chunk.length;
317
+ if (size > MAX_BODY)
318
+ throw new RequestError(413, "Request too large");
319
+ chunks.push(chunk);
320
+ }
321
+ try {
322
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
323
+ }
324
+ catch {
325
+ throw new RequestError(400, "Not valid JSON");
326
+ }
327
+ }
328
+ async function serveStatic(uiDir, pathname, res) {
329
+ if (!uiDir)
330
+ return send(res, 404, { error: "UI is served by the Vite dev server" });
331
+ let decoded;
332
+ try {
333
+ decoded = decodeURIComponent(pathname);
334
+ }
335
+ catch {
336
+ return send(res, 400, { error: "Bad request" });
337
+ }
338
+ const rel = normalize(decoded).replace(/^[/\\]+/, "");
339
+ const target = join(uiDir, rel === "" ? "index.html" : rel);
340
+ if (!target.startsWith(uiDir + sep) && target !== uiDir)
341
+ return send(res, 403, { error: "Forbidden" });
342
+ const file = await readFile(target).catch(() => null);
343
+ // Unknown paths fall back to the app shell so client-side routes reload correctly.
344
+ const body = file ?? (await readFile(join(uiDir, "index.html")));
345
+ const type = file ? (MIME[extname(target)] ?? "application/octet-stream") : MIME[".html"];
346
+ res.writeHead(200, { ...SECURITY_HEADERS, "Content-Type": type });
347
+ res.end(body);
348
+ }
349
+ function send(res, status, body) {
350
+ res.writeHead(status, { ...SECURITY_HEADERS, "Content-Type": "application/json" });
351
+ res.end(JSON.stringify(body));
352
+ }
@@ -0,0 +1,221 @@
1
+ import { stat } from "node:fs/promises";
2
+ import {} from "ajv";
3
+ import { allowedScopes, ignoredReason } from "../shared/scopes.js";
4
+ import schema from "../shared/settings.schema.json" with { type: "json" };
5
+ import {} from "../shared/types.js";
6
+ import {} from "./scan/env.js";
7
+ import { isRecord, jsonSyntaxError, redactSecrets, revisionOf } from "./scan/files.js";
8
+ import { isSkippedMcpRule } from "./scan/permissions.js";
9
+ import { settingsFiles } from "./scan/settings.js";
10
+ import { ajv, schemaErrors } from "./schema.js";
11
+ import { hasHiddenValues, shown } from "./secrets.js";
12
+ import { RefusedError, readIfExists, recordChange, serially, writeAtomic } from "./write.js";
13
+ const properties = schema.definitions.ClaudeCodeSettings.properties;
14
+ const validators = new Map();
15
+ function validatorFor(key) {
16
+ if (!Object.hasOwn(properties, key))
17
+ return null;
18
+ let validate = validators.get(key);
19
+ if (!validate) {
20
+ validate = ajv.compile(properties[key]);
21
+ validators.set(key, validate);
22
+ }
23
+ return validate;
24
+ }
25
+ async function editableFile(env, dir, pick, missing = "That is not a settings file Claude Code reads here.") {
26
+ const file = (await settingsFiles(env, dir)).find(pick);
27
+ if (!file)
28
+ throw new RefusedError(missing);
29
+ if (file.scope === "managed")
30
+ throw new RefusedError("Managed settings are set by your organisation.");
31
+ if (file.scope !== "user" && !(await stat(dir).catch(() => null))?.isDirectory())
32
+ throw new RefusedError(`${dir} is not a directory on this machine.`);
33
+ return file;
34
+ }
35
+ async function readSettings(env, path) {
36
+ const raw = await readIfExists(env, path);
37
+ if (raw === null || raw.trim() === "")
38
+ return { raw, data: {} };
39
+ let parsed;
40
+ try {
41
+ parsed = JSON.parse(raw);
42
+ }
43
+ catch (error) {
44
+ throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
45
+ }
46
+ if (!isRecord(parsed))
47
+ throw new RefusedError(`${path} does not hold a JSON object.`);
48
+ return { raw, data: parsed };
49
+ }
50
+ /**
51
+ * Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely.
52
+ * `checksHiddenValues` is for a key whose edit checks the part it changes itself, so values it
53
+ * leaves alone are written back exactly as they were.
54
+ */
55
+ async function planKey(env, file, key, value, checksHiddenValues = false) {
56
+ if (!allowedScopes(key).includes(file.scope))
57
+ throw new RefusedError(`${ignoredReason(key)}, so a ${file.scope} value would do nothing.`);
58
+ const validate = validatorFor(key);
59
+ if (!validate)
60
+ throw new RefusedError(`${key} is not in Claude Code's settings schema (${schema.$comment}).`);
61
+ const { path } = file;
62
+ const { raw, data } = await readSettings(env, path);
63
+ const nextValue = value(data);
64
+ // ccdesk never shows hidden values, so it must not write them either.
65
+ if (!checksHiddenValues &&
66
+ (hasHiddenValues({ [key]: data[key] }) || hasHiddenValues({ [key]: nextValue })))
67
+ throw new RefusedError(`${key} holds hidden values; edit it in the file instead.`);
68
+ if (raw !== null && JSON.stringify(nextValue) === JSON.stringify(data[key]))
69
+ throw new RefusedError("This file already says that; there is nothing to change.");
70
+ const before = raw === null ? null : shown(data);
71
+ // The diff is rendered from the parsed file, so a file in another layout is reported as
72
+ // reformatted instead of diffing its raw text.
73
+ const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
74
+ const base = { path, before, revision: revisionOf(raw), reformats };
75
+ const errors = nextValue === undefined ? [] : schemaErrors(validate, nextValue, key);
76
+ if (errors.length > 0)
77
+ return {
78
+ path,
79
+ scope: file.scope,
80
+ raw,
81
+ next: null,
82
+ preview: { ...base, after: before ?? "", errors },
83
+ };
84
+ const next = { ...data };
85
+ if (nextValue === undefined)
86
+ delete next[key];
87
+ else
88
+ next[key] = nextValue;
89
+ return {
90
+ path,
91
+ scope: file.scope,
92
+ raw,
93
+ next: `${JSON.stringify(next, null, 2)}\n`,
94
+ preview: { ...base, after: shown(next), errors: [] },
95
+ };
96
+ }
97
+ async function planSettingsEdit(env, edit) {
98
+ const file = await editableFile(env, edit.dir, (f) => f.scope === edit.scope && !f.legacy, edit.scope === "project"
99
+ ? "In your home directory this file is your user settings; edit it as User."
100
+ : undefined);
101
+ return planKey(env, file, edit.key, () => edit.value);
102
+ }
103
+ /** One rule taken out of its list and/or added to another, applied to the file as it is on disk. */
104
+ async function planPermissionEdit(env, edit) {
105
+ const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
106
+ return planKey(env, file, "permissions", (data) => {
107
+ const permissions = isRecord(data.permissions) ? { ...data.permissions } : {};
108
+ const { remove, add } = edit;
109
+ if (remove) {
110
+ const list = Array.isArray(permissions[remove.list])
111
+ ? permissions[remove.list]
112
+ : [];
113
+ if (!list.includes(remove.rule))
114
+ throw new RefusedError(`${remove.rule} is no longer in ${remove.list} in this file.`);
115
+ const kept = list.filter((r) => r !== remove.rule);
116
+ if (kept.length > 0)
117
+ permissions[remove.list] = kept;
118
+ else
119
+ delete permissions[remove.list];
120
+ }
121
+ if (add) {
122
+ if (isSkippedMcpRule(add.rule))
123
+ throw new RefusedError("Claude Code skips an MCP rule with parentheses in a settings file. To match a parameter of an MCP tool, pass a deny rule with --disallowedTools.");
124
+ const list = Array.isArray(permissions[add.list]) ? permissions[add.list] : [];
125
+ if (!list.includes(add.rule))
126
+ permissions[add.list] = [...list, add.rule];
127
+ }
128
+ return Object.keys(permissions).length > 0 ? permissions : undefined;
129
+ });
130
+ }
131
+ /** The matcher a group is written with; a missing or empty one matches everything. */
132
+ function matcherOf(group) {
133
+ return isRecord(group) && typeof group.matcher === "string" ? group.matcher : "";
134
+ }
135
+ /** One hook taken out, replaced or added, applied to the file as it is on disk. */
136
+ async function planHookEdit(env, edit) {
137
+ const file = await editableFile(env, edit.dir, (f) => f.path === edit.file);
138
+ return planKey(env, file, "hooks", (data) => {
139
+ const hooks = isRecord(data.hooks)
140
+ ? structuredClone(data.hooks)
141
+ : {};
142
+ const groupsOf = (event) => {
143
+ const groups = hooks[event];
144
+ return Array.isArray(groups) ? groups : [];
145
+ };
146
+ const { remove, add } = edit;
147
+ if (remove) {
148
+ const groups = groupsOf(remove.event);
149
+ const group = groups[remove.at.group];
150
+ const list = isRecord(group) && Array.isArray(group.hooks) ? group.hooks : null;
151
+ const hook = list?.[remove.at.index];
152
+ if (!list || JSON.stringify(redactSecrets(hook)) !== JSON.stringify(remove.definition))
153
+ throw new RefusedError("This hook changed in the file since ccdesk read it; review it again.");
154
+ // The page saw this hook with its header values hidden, so a changed copy would lose them.
155
+ if (add && hasHiddenValues(hook))
156
+ throw new RefusedError("This hook sends header values ccdesk does not show; edit it in the file, or remove it and add it again.");
157
+ const matcher = add?.matcher ?? "";
158
+ // A hook alone in its group keeps the group when only its matcher changes, unless another
159
+ // group already has that matcher.
160
+ const inPlace = add?.event === remove.event &&
161
+ (matcher === matcherOf(group) ||
162
+ (list.length === 1 && !groups.some((g) => g !== group && matcherOf(g) === matcher)));
163
+ if (add && inPlace && isRecord(group)) {
164
+ list[remove.at.index] = add.definition;
165
+ if (matcher)
166
+ group.matcher = matcher;
167
+ else
168
+ delete group.matcher;
169
+ return hooks;
170
+ }
171
+ list.splice(remove.at.index, 1);
172
+ if (list.length === 0)
173
+ groups.splice(remove.at.group, 1);
174
+ if (groups.length === 0 && add?.event !== remove.event)
175
+ delete hooks[remove.event];
176
+ }
177
+ if (add) {
178
+ if (hasHiddenValues(add.definition))
179
+ throw new RefusedError("ccdesk does not write header values it would then hide; add this hook in the file instead.");
180
+ const groups = groupsOf(add.event);
181
+ const matcher = add.matcher ?? "";
182
+ const group = groups.find((g) => matcherOf(g) === matcher && isRecord(g) && Array.isArray(g.hooks));
183
+ if (isRecord(group) && Array.isArray(group.hooks))
184
+ group.hooks.push(add.definition);
185
+ else
186
+ groups.push({ ...(matcher ? { matcher } : {}), hooks: [add.definition] });
187
+ hooks[add.event] = groups;
188
+ }
189
+ return Object.keys(hooks).length > 0 ? hooks : undefined;
190
+ }, true);
191
+ }
192
+ /** Writes a planned edit, but only if the file is still the revision the preview was made from. */
193
+ async function commit(env, plan, expectedRevision) {
194
+ if (plan.next === null)
195
+ throw new RefusedError(plan.preview.errors.join("; "));
196
+ if (plan.preview.revision !== expectedRevision)
197
+ throw new RefusedError("The file changed since you reviewed this edit; review it again.");
198
+ const next = plan.next;
199
+ return recordChange(env, { kind: "settings", target: plan.path, before: plan.raw }, async () => {
200
+ await writeAtomic(env, plan.path, next, expectedRevision);
201
+ return { target: plan.path, revision: revisionOf(next) };
202
+ });
203
+ }
204
+ export async function previewSettingsEdit(env, edit) {
205
+ return (await planSettingsEdit(env, edit)).preview;
206
+ }
207
+ export function saveSettingsEdit(env, edit, expectedRevision) {
208
+ return serially(async () => commit(env, await planSettingsEdit(env, edit), expectedRevision));
209
+ }
210
+ export async function previewPermissionEdit(env, edit) {
211
+ return (await planPermissionEdit(env, edit)).preview;
212
+ }
213
+ export function savePermissionEdit(env, edit, expectedRevision) {
214
+ return serially(async () => commit(env, await planPermissionEdit(env, edit), expectedRevision));
215
+ }
216
+ export async function previewHookEdit(env, edit) {
217
+ return (await planHookEdit(env, edit)).preview;
218
+ }
219
+ export function saveHookEdit(env, edit, expectedRevision) {
220
+ return serially(async () => commit(env, await planHookEdit(env, edit), expectedRevision));
221
+ }