ccdesk 0.1.0 → 0.2.1

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 (44) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-D5XqP5c_.css +1 -0
  39. package/dist/ui/assets/index-XqJwI3k9.js +60 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. package/dist/ui/assets/index-CN3vkVjv.js +0 -45
@@ -1,18 +1,23 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
2
  import { createServer } from "node:http";
3
3
  import { extname, isAbsolute, join, normalize, resolve, sep } from "node:path";
4
- import {} from "../shared/types.js";
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";
5
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";
6
10
  import { changeMemory, isMemoryAction, previewMemoryChange } from "./mutations.js";
7
11
  import { openInEditor } from "./open.js";
12
+ import { compareSettings } from "./scan/compare.js";
8
13
  import {} from "./scan/env.js";
9
- import { isRecord } from "./scan/files.js";
10
- import { scanDirectory, scanOverview } from "./scan/index.js";
11
- import { readUsage } from "./scan/usage.js";
14
+ import { isCredentials, isRecord } from "./scan/files.js";
15
+ import { scanOverview } from "./scan/index.js";
16
+ import { CountStopped, readUsage } from "./scan/usage.js";
12
17
  import { testPath } from "./scan/path-test.js";
13
- import { previewPermissionEdit, previewSettingsEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
18
+ import { previewHookEdit, previewPermissionEdit, previewSettingsEdit, saveHookEdit, savePermissionEdit, saveSettingsEdit, } from "./settings-edit.js";
14
19
  import { Watcher } from "./watch.js";
15
- import { isCredentials, RefusedError } from "./write.js";
20
+ import { RefusedError } from "./write.js";
16
21
  const MIME = {
17
22
  ".html": "text/html; charset=utf-8",
18
23
  ".js": "text/javascript; charset=utf-8",
@@ -38,17 +43,141 @@ class RequestError extends Error {
38
43
  }
39
44
  export function createCcdeskServer(options) {
40
45
  const watcher = new Watcher(options.env);
46
+ const table = routes(options, watcher);
41
47
  const server = createServer((req, res) => {
42
- handle(options, watcher, server, req, res).catch((error) => {
43
- if (error instanceof RequestError)
44
- return send(res, error.status, { error: error.message });
45
- send(res, 500, { error: error instanceof Error ? error.message : String(error) });
48
+ handle(options, table, server, req, res).catch((error) => {
49
+ const [status, message] = failure(error);
50
+ send(res, status, { error: message });
46
51
  });
47
52
  });
48
53
  server.on("close", () => watcher.close());
49
54
  return server;
50
55
  }
51
- async function handle(options, watcher, server, req, res) {
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) {
52
181
  const address = server.address();
53
182
  const port = typeof address === "object" && address ? address.port : 0;
54
183
  // Reject DNS-rebinding requests: only our own loopback origin may talk to this server.
@@ -66,123 +195,21 @@ async function handle(options, watcher, server, req, res) {
66
195
  !allowedHosts.some((h) => origin === `http://${h}`)) {
67
196
  return send(res, 403, { error: "Cross-origin request refused" });
68
197
  }
69
- const { env, launchDir } = options;
70
- if (req.method === "GET" && url.pathname === "/api/overview") {
71
- return send(res, 200, await scanOverview(env, launchDir));
72
- }
73
- if (req.method === "GET" && url.pathname === "/api/directory") {
74
- const path = url.searchParams.get("path");
75
- if (!path)
76
- return send(res, 400, { error: "path is required" });
77
- const view = await scanDirectory(env, path);
78
- watcher.track(view.path, view);
79
- return send(res, 200, view);
80
- }
81
- if (req.method === "POST" && url.pathname === "/api/usage") {
82
- const body = await readBody(req);
83
- const { path } = stringFields(body, ["path"]);
84
- const rules = body.rules;
85
- if (!Array.isArray(rules))
86
- return send(res, 400, { error: "rules must be a list" });
87
- return send(res, 200, await readUsage(env, path, rules.map((r) => {
88
- const { list, rule } = stringFields(r, ["list", "rule"]);
89
- if (!LISTS.includes(list))
90
- throw new RequestError(400, "a rule needs a list: allow, ask or deny");
91
- return { list: list, rule };
92
- })));
93
- }
94
- if (req.method === "POST" && url.pathname === "/api/memory/preview") {
95
- const { action, path } = await fields(req, ["action", "path"]);
96
- if (!isMemoryAction(action))
97
- return send(res, 400, { error: "unknown memory action" });
98
- return answer(res, () => previewMemoryChange(env, action, path));
99
- }
100
- if (req.method === "POST" && url.pathname === "/api/memory/change") {
101
- const { action, path, revision } = await fields(req, ["action", "path", "revision"]);
102
- if (!isMemoryAction(action))
103
- return send(res, 400, { error: "unknown memory action" });
104
- return answer(res, () => changeMemory(env, action, path, revision));
105
- }
106
- if (req.method === "POST" && url.pathname === "/api/open") {
107
- const { path } = await fields(req, ["path"]);
108
- if (!isAbsolute(path))
109
- return send(res, 400, { error: "an absolute path is required" });
110
- const info = await stat(path).catch(() => null);
111
- if (!info?.isFile() && !info?.isDirectory())
112
- return send(res, 409, { error: "That file or folder no longer exists." });
113
- if (await isCredentials(env, path))
114
- return send(res, 409, { error: "ccdesk does not open credentials." });
115
- const open = options.openFile ?? openInEditor;
116
- return answer(res, async () => ({ editor: await open(path) }));
117
- }
118
- if (req.method === "POST" && url.pathname === "/api/settings/preview") {
119
- const edit = settingsEditFrom(await readBody(req));
120
- return answer(res, () => previewSettingsEdit(env, edit));
121
- }
122
- if (req.method === "POST" && url.pathname === "/api/settings/save") {
123
- const body = await readBody(req);
124
- const edit = settingsEditFrom(body);
125
- const { revision } = stringFields(body, ["revision"]);
126
- return answer(res, () => saveSettingsEdit(env, edit, revision));
127
- }
128
- if (req.method === "POST" && url.pathname === "/api/permissions/preview") {
129
- const edit = permissionEditFrom(await readBody(req));
130
- return answer(res, () => previewPermissionEdit(env, edit));
131
- }
132
- if (req.method === "POST" && url.pathname === "/api/permissions/save") {
133
- const body = await readBody(req);
134
- const edit = permissionEditFrom(body);
135
- const { revision } = stringFields(body, ["revision"]);
136
- return answer(res, () => savePermissionEdit(env, edit, revision));
137
- }
138
- if (req.method === "GET" && url.pathname === "/api/watch") {
139
- const dir = url.searchParams.get("dir");
140
- const since = Number(url.searchParams.get("since") ?? "0");
141
- if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
142
- return send(res, 400, { error: "dir is required" });
143
- // Stop waiting when the page goes away or asks for another directory.
144
- const stop = new AbortController();
145
- res.once("close", () => stop.abort());
146
- return send(res, 200, await watcher.wait(resolve(dir), since, stop.signal));
147
- }
148
- if (req.method === "GET" && url.pathname === "/api/path-test") {
149
- const dir = url.searchParams.get("dir");
150
- const file = url.searchParams.get("file");
151
- if (!dir || !isAbsolute(dir) || !file)
152
- return send(res, 400, { error: "dir and file are required" });
153
- return answer(res, () => testPath(env, dir, file));
154
- }
155
- if (req.method === "GET" && url.pathname === "/api/backups") {
156
- const before = url.searchParams.get("before") ?? undefined;
157
- const limit = Number(url.searchParams.get("limit") ?? "50");
158
- if (!Number.isInteger(limit) || limit < 1 || limit > 200)
159
- return send(res, 400, { error: "limit must be 1 to 200" });
160
- return send(res, 200, await listBackups(env, { ...(before ? { before } : {}), limit }));
161
- }
162
- if (req.method === "POST" && url.pathname === "/api/backups/preview") {
163
- const { id } = await fields(req, ["id"]);
164
- return answer(res, () => previewRestore(env, id));
165
- }
166
- if (req.method === "POST" && url.pathname === "/api/backups/restore") {
167
- const { id, revision } = await fields(req, ["id", "revision"]);
168
- return answer(res, () => restoreBackup(env, id, revision));
169
- }
170
- return send(res, 404, { error: "Not found" });
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 }));
171
203
  }
172
- const SCOPES = ["managed", "user", "project", "local"];
173
- const LISTS = ["allow", "ask", "deny"];
174
204
  /** The named string fields of a JSON body; anything else is a 400. */
175
205
  function stringFields(body, names) {
176
206
  if (!isRecord(body) || names.some((n) => typeof body[n] !== "string"))
177
207
  throw new RequestError(400, `${names.join(", ")} ${names.length === 1 ? "is" : "are"} required`);
178
208
  return body;
179
209
  }
180
- async function fields(req, names) {
181
- return stringFields(await readBody(req), names);
182
- }
183
210
  function settingsEditFrom(body) {
184
211
  const { dir, scope, key } = stringFields(body, ["dir", "scope", "key"]);
185
- if (!SCOPES.includes(scope))
212
+ if (!EVERY_FILE.includes(scope))
186
213
  throw new RequestError(400, "unknown scope");
187
214
  const value = body.value;
188
215
  return { dir, scope: scope, key, ...(value === undefined ? {} : { value }) };
@@ -193,7 +220,7 @@ function permissionEditFrom(body) {
193
220
  if (value === undefined)
194
221
  return undefined;
195
222
  const { list, rule } = stringFields(value, ["list", "rule"]);
196
- if (!LISTS.includes(list) || rule.trim() === "")
223
+ if (!PERMISSION_LISTS.includes(list) || rule.trim() === "")
197
224
  throw new RequestError(400, "a rule needs a list (allow, ask or deny) and text");
198
225
  return { list: list, rule };
199
226
  };
@@ -209,15 +236,75 @@ function permissionEditFrom(body) {
209
236
  throw new RequestError(400, "remove or add is required");
210
237
  return edit;
211
238
  }
212
- async function answer(res, action) {
213
- try {
214
- send(res, 200, await action());
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 };
215
283
  }
216
- catch (error) {
217
- if (error instanceof RefusedError)
218
- return send(res, 409, { error: error.message });
219
- throw error;
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 };
220
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;
221
308
  }
222
309
  const MAX_BODY = 64 * 1024;
223
310
  async function readBody(req) {
@@ -241,7 +328,14 @@ async function readBody(req) {
241
328
  async function serveStatic(uiDir, pathname, res) {
242
329
  if (!uiDir)
243
330
  return send(res, 404, { error: "UI is served by the Vite dev server" });
244
- const rel = normalize(decodeURIComponent(pathname)).replace(/^[/\\]+/, "");
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(/^[/\\]+/, "");
245
339
  const target = join(uiDir, rel === "" ? "index.html" : rel);
246
340
  if (!target.startsWith(uiDir + sep) && target !== uiDir)
247
341
  return send(res, 403, { error: "Forbidden" });
@@ -1,14 +1,16 @@
1
1
  import { stat } from "node:fs/promises";
2
- import { Ajv } from "ajv";
2
+ import {} from "ajv";
3
3
  import { allowedScopes, ignoredReason } from "../shared/scopes.js";
4
4
  import schema from "../shared/settings.schema.json" with { type: "json" };
5
5
  import {} from "../shared/types.js";
6
6
  import {} from "./scan/env.js";
7
7
  import { isRecord, jsonSyntaxError, redactSecrets, revisionOf } from "./scan/files.js";
8
+ import { isSkippedMcpRule } from "./scan/permissions.js";
8
9
  import { settingsFiles } from "./scan/settings.js";
10
+ import { ajv, schemaErrors } from "./schema.js";
11
+ import { hasHiddenValues, shown } from "./secrets.js";
9
12
  import { RefusedError, readIfExists, recordChange, serially, writeAtomic } from "./write.js";
10
13
  const properties = schema.definitions.ClaudeCodeSettings.properties;
11
- const ajv = new Ajv({ allErrors: true, strict: false });
12
14
  const validators = new Map();
13
15
  function validatorFor(key) {
14
16
  if (!Object.hasOwn(properties, key))
@@ -20,12 +22,6 @@ function validatorFor(key) {
20
22
  }
21
23
  return validate;
22
24
  }
23
- function shown(data) {
24
- return `${JSON.stringify(redactSecrets(data), null, 2)}\n`;
25
- }
26
- function hasHiddenValues(value) {
27
- return JSON.stringify(redactSecrets(value)) !== JSON.stringify(value);
28
- }
29
25
  async function editableFile(env, dir, pick, missing = "That is not a settings file Claude Code reads here.") {
30
26
  const file = (await settingsFiles(env, dir)).find(pick);
31
27
  if (!file)
@@ -51,8 +47,12 @@ async function readSettings(env, path) {
51
47
  throw new RefusedError(`${path} does not hold a JSON object.`);
52
48
  return { raw, data: parsed };
53
49
  }
54
- /** Works out the file change that sets or removes one key, refusing what ccdesk cannot do safely. */
55
- async function planKey(env, file, key, value) {
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
56
  if (!allowedScopes(key).includes(file.scope))
57
57
  throw new RefusedError(`${ignoredReason(key)}, so a ${file.scope} value would do nothing.`);
58
58
  const validate = validatorFor(key);
@@ -61,20 +61,19 @@ async function planKey(env, file, key, value) {
61
61
  const { path } = file;
62
62
  const { raw, data } = await readSettings(env, path);
63
63
  const nextValue = value(data);
64
- // ccdesk never shows env or header values, so it must not write them either.
65
- if (hasHiddenValues({ [key]: data[key] }) || hasHiddenValues({ [key]: nextValue }))
64
+ // ccdesk never shows hidden values, so it must not write them either.
65
+ if (!checksHiddenValues &&
66
+ (hasHiddenValues({ [key]: data[key] }) || hasHiddenValues({ [key]: nextValue })))
66
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.");
67
70
  const before = raw === null ? null : shown(data);
68
71
  // The diff is rendered from the parsed file, so a file in another layout is reported as
69
72
  // reformatted instead of diffing its raw text.
70
73
  const reformats = raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`;
71
74
  const base = { path, before, revision: revisionOf(raw), reformats };
72
- const next = { ...data };
73
- if (nextValue === undefined) {
74
- delete next[key];
75
- }
76
- else if (!validate(nextValue)) {
77
- const errors = (validate.errors ?? []).map((e) => `${key}${e.instancePath.replaceAll("/", ".")} ${e.message ?? "is invalid"}`);
75
+ const errors = nextValue === undefined ? [] : schemaErrors(validate, nextValue, key);
76
+ if (errors.length > 0)
78
77
  return {
79
78
  path,
80
79
  scope: file.scope,
@@ -82,10 +81,11 @@ async function planKey(env, file, key, value) {
82
81
  next: null,
83
82
  preview: { ...base, after: before ?? "", errors },
84
83
  };
85
- }
86
- else {
84
+ const next = { ...data };
85
+ if (nextValue === undefined)
86
+ delete next[key];
87
+ else
87
88
  next[key] = nextValue;
88
- }
89
89
  return {
90
90
  path,
91
91
  scope: file.scope,
@@ -119,6 +119,8 @@ async function planPermissionEdit(env, edit) {
119
119
  delete permissions[remove.list];
120
120
  }
121
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.");
122
124
  const list = Array.isArray(permissions[add.list]) ? permissions[add.list] : [];
123
125
  if (!list.includes(add.rule))
124
126
  permissions[add.list] = [...list, add.rule];
@@ -126,6 +128,67 @@ async function planPermissionEdit(env, edit) {
126
128
  return Object.keys(permissions).length > 0 ? permissions : undefined;
127
129
  });
128
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
+ }
129
192
  /** Writes a planned edit, but only if the file is still the revision the preview was made from. */
130
193
  async function commit(env, plan, expectedRevision) {
131
194
  if (plan.next === null)
@@ -150,3 +213,9 @@ export async function previewPermissionEdit(env, edit) {
150
213
  export function savePermissionEdit(env, edit, expectedRevision) {
151
214
  return serially(async () => commit(env, await planPermissionEdit(env, edit), expectedRevision));
152
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
+ }