ccdesk 0.2.2 → 0.2.4

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 (131) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +7 -4
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +15 -21
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +64 -48
  7. package/dist/core/core.d.ts +96 -0
  8. package/dist/core/core.js +57 -0
  9. package/dist/core/index.d.ts +22 -0
  10. package/dist/core/index.js +38 -0
  11. package/dist/core/mcp-edit.d.ts +23 -0
  12. package/dist/{server → core}/mcp-edit.js +121 -100
  13. package/dist/core/mutations.d.ts +20 -0
  14. package/dist/{server → core}/mutations.js +8 -15
  15. package/dist/core/open.d.ts +28 -0
  16. package/dist/{server → core}/open.js +38 -13
  17. package/dist/core/parse-edit.d.ts +12 -0
  18. package/dist/core/parse-edit.js +64 -0
  19. package/dist/core/reference.d.ts +3 -0
  20. package/dist/core/reference.js +11 -0
  21. package/dist/core/scan/compare.d.ts +7 -0
  22. package/dist/{server → core}/scan/compare.js +2 -2
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +55 -32
  25. package/dist/core/scan/data.d.ts +14 -0
  26. package/dist/core/scan/data.js +26 -0
  27. package/dist/core/scan/env.d.ts +28 -0
  28. package/dist/{server → core}/scan/env.js +15 -4
  29. package/dist/core/scan/files.d.ts +68 -0
  30. package/dist/core/scan/files.js +270 -0
  31. package/dist/core/scan/git.d.ts +20 -0
  32. package/dist/core/scan/git.js +106 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +119 -82
  35. package/dist/core/scan/instructions.d.ts +54 -0
  36. package/dist/{server → core}/scan/instructions.js +141 -73
  37. package/dist/core/scan/inventory.d.ts +38 -0
  38. package/dist/{server → core}/scan/inventory.js +134 -115
  39. package/dist/core/scan/mcp.d.ts +28 -0
  40. package/dist/{server → core}/scan/mcp.js +153 -95
  41. package/dist/core/scan/memory.d.ts +8 -0
  42. package/dist/{server → core}/scan/memory.js +8 -8
  43. package/dist/core/scan/path-test.d.ts +9 -0
  44. package/dist/core/scan/path-test.js +156 -0
  45. package/dist/core/scan/permissions.d.ts +31 -0
  46. package/dist/{server → core}/scan/permissions.js +5 -8
  47. package/dist/core/scan/plugins.d.ts +39 -0
  48. package/dist/{server → core}/scan/plugins.js +36 -27
  49. package/dist/core/scan/projects.d.ts +9 -0
  50. package/dist/{server → core}/scan/projects.js +7 -5
  51. package/dist/core/scan/sandbox.d.ts +14 -0
  52. package/dist/{server → core}/scan/sandbox.js +87 -59
  53. package/dist/core/scan/settings.d.ts +37 -0
  54. package/dist/{server → core}/scan/settings.js +98 -93
  55. package/dist/core/scan/tools.d.ts +10 -0
  56. package/dist/{server → core}/scan/tools.js +28 -20
  57. package/dist/core/scan/usage.d.ts +19 -0
  58. package/dist/{server → core}/scan/usage.js +122 -97
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +45 -0
  62. package/dist/core/secrets.js +207 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/{server → core}/settings-edit.js +91 -112
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +54 -17
  67. package/dist/core/write.d.ts +117 -0
  68. package/dist/{server → core}/write.js +61 -8
  69. package/dist/server/args.d.ts +32 -0
  70. package/dist/server/args.js +106 -0
  71. package/dist/server/cli.d.ts +2 -0
  72. package/dist/server/cli.js +58 -83
  73. package/dist/server/commands.d.ts +8 -0
  74. package/dist/server/commands.js +39 -0
  75. package/dist/server/server.d.ts +12 -0
  76. package/dist/server/server.js +80 -195
  77. package/dist/server/start.d.ts +13 -0
  78. package/dist/server/start.js +21 -0
  79. package/dist/shared/compare.d.ts +25 -0
  80. package/dist/shared/memory.d.ts +6 -0
  81. package/dist/shared/memory.js +18 -2
  82. package/dist/shared/requests.d.ts +104 -0
  83. package/dist/shared/requests.js +73 -0
  84. package/dist/shared/scopes.d.ts +10 -0
  85. package/dist/shared/settings-catalog.json +2199 -0
  86. package/dist/shared/types.d.ts +535 -0
  87. package/dist/shared/types.js +11 -0
  88. package/dist/ui/assets/index-C9qqEI5Z.js +60 -0
  89. package/dist/ui/index.html +1 -1
  90. package/dist/view/diff.d.ts +16 -0
  91. package/dist/view/diff.js +85 -0
  92. package/dist/view/format.d.ts +17 -0
  93. package/dist/view/format.js +47 -0
  94. package/dist/view/index.d.ts +11 -0
  95. package/dist/view/index.js +13 -0
  96. package/dist/view/lines.d.ts +9 -0
  97. package/dist/view/lines.js +12 -0
  98. package/dist/view/path-test.d.ts +3 -0
  99. package/dist/view/path-test.js +7 -0
  100. package/dist/view/sections/catalog.d.ts +9 -0
  101. package/dist/view/sections/catalog.js +47 -0
  102. package/dist/view/sections/extensions.d.ts +1 -0
  103. package/dist/view/sections/extensions.js +48 -0
  104. package/dist/view/sections/hooks.d.ts +1 -0
  105. package/dist/view/sections/hooks.js +32 -0
  106. package/dist/view/sections/index.d.ts +6 -0
  107. package/dist/view/sections/index.js +27 -0
  108. package/dist/view/sections/instructions.d.ts +6 -0
  109. package/dist/view/sections/instructions.js +38 -0
  110. package/dist/view/sections/mcp.d.ts +3 -0
  111. package/dist/view/sections/mcp.js +38 -0
  112. package/dist/view/sections/memory.d.ts +1 -0
  113. package/dist/view/sections/memory.js +31 -0
  114. package/dist/view/sections/permissions.d.ts +17 -0
  115. package/dist/view/sections/permissions.js +87 -0
  116. package/dist/view/sections/settings.d.ts +1 -0
  117. package/dist/view/sections/settings.js +33 -0
  118. package/dist/view/sections/tools.d.ts +6 -0
  119. package/dist/view/sections/tools.js +57 -0
  120. package/dist/view/sections/types.d.ts +123 -0
  121. package/dist/view/sections/types.js +10 -0
  122. package/dist/view/settings.d.ts +12 -0
  123. package/dist/view/settings.js +31 -0
  124. package/dist/view/usage.d.ts +8 -0
  125. package/dist/view/usage.js +18 -0
  126. package/package.json +13 -2
  127. package/dist/server/scan/files.js +0 -480
  128. package/dist/server/scan/path-test.js +0 -136
  129. package/dist/server/schema.js +0 -15
  130. package/dist/server/secrets.js +0 -63
  131. package/dist/ui/assets/index-Dg9dxngR.js +0 -60
@@ -1,25 +1,21 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { readFile } 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 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";
4
+ import {} from "typebox";
5
+ import { Compile } from "typebox/compile";
6
+ import {} from "../core/core.js";
7
+ import { BackupBody, MemoryBody, OpenBody, RevisionBody, UsageBody } from "../shared/requests.js";
8
+ import { SESSION_PROFILES } from "../shared/types.js";
9
+ import { isMemoryAction } from "../core/mutations.js";
10
+ import { InvalidEditError, parseAgentEdit, parseHookEdit, parseMcpEdit, parsePermissionEdit, parseSettingsEdit, } from "../core/parse-edit.js";
11
+ import { REFERENCE } from "../core/reference.js";
12
+ import { CountStopped } from "../core/scan/usage.js";
13
+ import { firstError } from "../core/schema.js";
14
+ import { Watcher } from "../core/watch.js";
15
+ import { RefusedError } from "../core/write.js";
16
+ const HTML = "text/html; charset=utf-8";
21
17
  const MIME = {
22
- ".html": "text/html; charset=utf-8",
18
+ ".html": HTML,
23
19
  ".js": "text/javascript; charset=utf-8",
24
20
  ".css": "text/css; charset=utf-8",
25
21
  ".svg": "image/svg+xml",
@@ -42,8 +38,8 @@ class RequestError extends Error {
42
38
  }
43
39
  }
44
40
  export function createCcdeskServer(options) {
45
- const watcher = new Watcher(options.env);
46
- const table = routes(options, watcher);
41
+ const watcher = new Watcher(options.core.env);
42
+ const table = routes(options.core, watcher, options.launchDir);
47
43
  const server = createServer((req, res) => {
48
44
  handle(options, table, server, req, res).catch((error) => {
49
45
  const [status, message] = failure(error);
@@ -57,6 +53,8 @@ export function createCcdeskServer(options) {
57
53
  function failure(error) {
58
54
  if (error instanceof RequestError)
59
55
  return [error.status, error.message];
56
+ if (error instanceof InvalidEditError)
57
+ return [400, error.message];
60
58
  // A change ccdesk refuses on purpose.
61
59
  if (error instanceof RefusedError)
62
60
  return [409, error.message];
@@ -65,111 +63,101 @@ function failure(error) {
65
63
  return [500, error instanceof Error ? error.message : String(error)];
66
64
  }
67
65
  /** The query parameter `name`; a missing one is a 400. */
68
- function param(url, name, message = `${name} is required`) {
66
+ function param(url, name) {
69
67
  const value = url.searchParams.get(name);
70
68
  if (!value)
71
- throw new RequestError(400, message);
69
+ throw new RequestError(400, `${name} is required`);
72
70
  return value;
73
71
  }
72
+ /** The query parameter `name`, which must be an absolute path. */
73
+ function absoluteParam(url, name) {
74
+ const value = param(url, name);
75
+ if (!isAbsolute(value))
76
+ throw new RequestError(400, `${name} must be an absolute path`);
77
+ return value;
78
+ }
79
+ const revisionBody = Compile(RevisionBody);
80
+ const usageBody = Compile(UsageBody);
81
+ const memoryBody = Compile(MemoryBody);
82
+ const openBody = Compile(OpenBody);
83
+ const backupBody = Compile(BackupBody);
84
+ /** A POST body `validator` accepts; anything else is a 400 naming what is wrong. */
85
+ function checked(validator, body) {
86
+ if (!validator.Check(body))
87
+ throw new RequestError(400, firstError(validator, body));
88
+ return body;
89
+ }
74
90
  /** Aborts when the page goes away or asks for something else. */
75
91
  function stopOnClose(res) {
76
92
  const stop = new AbortController();
77
93
  res.once("close", () => stop.abort());
78
94
  return stop.signal;
79
95
  }
80
- function routes(options, watcher) {
81
- const { env, launchDir } = options;
82
- const run = options.runClaude ?? runClaude;
83
- const revision = (body) => stringFields(body, ["revision"]).revision;
96
+ function routes(core, watcher, launchDir) {
97
+ const revision = (body) => checked(revisionBody, body).revision;
84
98
  const get = (path, action) => ({ method: "GET", path, action });
85
99
  const post = (path, action) => ({ method: "POST", path, action });
86
100
  return [
87
- get("/api/overview", () => scanOverview(env, launchDir)),
101
+ get("/api/overview", () => core.scanOverview(launchDir)),
102
+ // Sent instead of bundled with the page: the catalog and schema are most of its size.
103
+ get("/api/reference", async () => REFERENCE),
88
104
  get("/api/directory", ({ url }) => {
89
- const profile = url.searchParams.get("profile") ?? "interactive";
90
- if (!SESSION_PROFILES.includes(profile))
105
+ const asked = url.searchParams.get("profile") ?? "interactive";
106
+ const profile = SESSION_PROFILES.find((p) => p === asked);
107
+ if (!profile)
91
108
  throw new RequestError(400, `profile is one of ${SESSION_PROFILES.join(", ")}`);
92
- return watcher.scan(param(url, "path"), profile);
109
+ return watcher.scan(absoluteParam(url, "path"), profile);
93
110
  }),
94
111
  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) });
112
+ const { path, rules } = checked(usageBody, body);
113
+ return core.readUsage(path, rules, { signal: stopOnClose(res) });
106
114
  }),
107
115
  post("/api/memory/preview", async ({ body }) => {
108
- const { action, path } = stringFields(body, ["action", "path"]);
116
+ const { action, path } = checked(memoryBody, body);
109
117
  if (!isMemoryAction(action))
110
118
  throw new RequestError(400, "unknown memory action");
111
- return previewMemoryChange(env, action, path);
119
+ return core.previewMemoryChange(action, path);
112
120
  }),
113
121
  post("/api/memory/change", async ({ body }) => {
114
- const { action, path, revision } = stringFields(body, ["action", "path", "revision"]);
122
+ const { action, path } = checked(memoryBody, body);
115
123
  if (!isMemoryAction(action))
116
124
  throw new RequestError(400, "unknown memory action");
117
- return changeMemory(env, action, path, revision);
125
+ return core.changeMemory(action, path, revision(body));
118
126
  }),
119
127
  post("/api/open", async ({ body }) => {
120
- const { path } = stringFields(body, ["path"]);
128
+ const { path } = checked(openBody, body);
121
129
  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) };
130
+ throw new RequestError(400, "path must be an absolute path");
131
+ return { editor: await core.openFile(path) };
129
132
  }),
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)),
133
+ post("/api/settings/preview", async ({ body }) => core.previewSettingsEdit(parseSettingsEdit(body))),
134
+ post("/api/settings/save", async ({ body }) => core.saveSettingsEdit(parseSettingsEdit(body), revision(body))),
135
+ post("/api/permissions/preview", async ({ body }) => core.previewPermissionEdit(parsePermissionEdit(body))),
136
+ post("/api/permissions/save", async ({ body }) => core.savePermissionEdit(parsePermissionEdit(body), revision(body))),
137
+ post("/api/hooks/preview", async ({ body }) => core.previewHookEdit(parseHookEdit(body))),
138
+ post("/api/hooks/save", async ({ body }) => core.saveHookEdit(parseHookEdit(body), revision(body))),
139
+ post("/api/agents/preview", async ({ body }) => core.previewAgentEdit(parseAgentEdit(body))),
140
+ post("/api/agents/save", async ({ body }) => core.saveAgentEdit(parseAgentEdit(body), revision(body))),
141
+ post("/api/mcp/preview", async ({ body }) => core.previewMcpEdit(parseMcpEdit(body))),
142
+ post("/api/mcp/save", async ({ body }) => core.saveMcpEdit(parseMcpEdit(body), revision(body))),
140
143
  get("/api/watch", async ({ url, res }) => {
141
- const dir = url.searchParams.get("dir");
144
+ const dir = absoluteParam(url, "dir");
142
145
  const since = Number(url.searchParams.get("since") ?? "0");
143
- if (!dir || !isAbsolute(dir) || !Number.isInteger(since))
144
- throw new RequestError(400, "dir is required");
146
+ if (!Number.isInteger(since))
147
+ throw new RequestError(400, "since must be a whole number");
145
148
  return watcher.wait(resolve(dir), since, stopOnClose(res));
146
149
  }),
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
- }),
150
+ get("/api/compare", async ({ url }) => core.compareSettings(absoluteParam(url, "a"), absoluteParam(url, "b"))),
151
+ get("/api/path-test", async ({ url }) => core.testPath(absoluteParam(url, "dir"), param(url, "file"), (cwd) => watcher.view(cwd))),
161
152
  get("/api/backups", async ({ url }) => {
162
153
  const before = url.searchParams.get("before") ?? undefined;
163
154
  const limit = Number(url.searchParams.get("limit") ?? "50");
164
155
  if (!Number.isInteger(limit) || limit < 1 || limit > 200)
165
156
  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);
157
+ return core.listBackups({ ...(before ? { before } : {}), limit });
172
158
  }),
159
+ post("/api/backups/preview", async ({ body }) => core.previewRestore(checked(backupBody, body).id)),
160
+ post("/api/backups/restore", async ({ body }) => core.restoreBackup(checked(backupBody, body).id, revision(body))),
173
161
  ];
174
162
  }
175
163
  /**
@@ -201,111 +189,6 @@ async function handle(options, table, server, req, res) {
201
189
  const body = route.method === "POST" ? await readBody(req) : undefined;
202
190
  return send(res, 200, await route.action({ url, body, res }));
203
191
  }
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
192
  const MAX_BODY = 64 * 1024;
310
193
  async function readBody(req) {
311
194
  if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
@@ -313,10 +196,12 @@ async function readBody(req) {
313
196
  const chunks = [];
314
197
  let size = 0;
315
198
  for await (const chunk of req) {
316
- size += chunk.length;
199
+ // A request without setEncoding yields Buffers.
200
+ const piece = chunk;
201
+ size += piece.length;
317
202
  if (size > MAX_BODY)
318
203
  throw new RequestError(413, "Request too large");
319
- chunks.push(chunk);
204
+ chunks.push(piece);
320
205
  }
321
206
  try {
322
207
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
@@ -342,7 +227,7 @@ async function serveStatic(uiDir, pathname, res) {
342
227
  const file = await readFile(target).catch(() => null);
343
228
  // Unknown paths fall back to the app shell so client-side routes reload correctly.
344
229
  const body = file ?? (await readFile(join(uiDir, "index.html")));
345
- const type = file ? (MIME[extname(target)] ?? "application/octet-stream") : MIME[".html"];
230
+ const type = file ? (MIME[extname(target)] ?? "application/octet-stream") : HTML;
346
231
  res.writeHead(200, { ...SECURITY_HEADERS, "Content-Type": type });
347
232
  res.end(body);
348
233
  }
@@ -0,0 +1,13 @@
1
+ import { type ServerOptions } from "./server.ts";
2
+ export interface StartOptions extends ServerOptions {
3
+ /** 0 picks a free port. */
4
+ port: number;
5
+ }
6
+ export interface RunningServer {
7
+ /** The page's address on 127.0.0.1, with the token in its fragment. */
8
+ url: string;
9
+ /** Stops listening and drops open connections, such as a browser tab's. */
10
+ close: () => Promise<void>;
11
+ }
12
+ /** Serves `options.core` on 127.0.0.1; rejects when it cannot listen, such as on a port in use. */
13
+ export declare function startServer(options: StartOptions): Promise<RunningServer>;
@@ -0,0 +1,21 @@
1
+ import {} from "node:net";
2
+ import { createCcdeskServer } from "./server.js";
3
+ /** Serves `options.core` on 127.0.0.1; rejects when it cannot listen, such as on a port in use. */
4
+ export function startServer(options) {
5
+ const server = createCcdeskServer(options);
6
+ return new Promise((resolve, reject) => {
7
+ server.once("error", reject);
8
+ server.listen(options.port, "127.0.0.1", () => {
9
+ server.off("error", reject);
10
+ const { port } = server.address();
11
+ resolve({
12
+ url: `http://127.0.0.1:${port}/#token=${options.token}`,
13
+ close: () => new Promise((closed, failed) => {
14
+ server.close((error) => (error ? failed(error) : closed()));
15
+ // An open browser tab keeps a connection alive, which would hold close() open.
16
+ server.closeAllConnections();
17
+ }),
18
+ });
19
+ });
20
+ });
21
+ }
@@ -0,0 +1,25 @@
1
+ import { type Scope } from "./types.ts";
2
+ /** The value a key has in one directory, and the settings files it comes from. */
3
+ export interface ComparedValue {
4
+ /** As ccdesk shows it, with hidden values as •••. */
5
+ value: unknown;
6
+ /** The files that decide it, lowest precedence first; several when lists or objects are combined. */
7
+ sources: Array<{
8
+ scope: Scope;
9
+ path: string;
10
+ }>;
11
+ }
12
+ export interface ComparedSetting {
13
+ key: string;
14
+ /** Null where no settings file Claude Code reads there sets the key. */
15
+ a: ComparedValue | null;
16
+ b: ComparedValue | null;
17
+ /** Whether both have the same value as ccdesk shows it; two hidden values count as the same. */
18
+ same: boolean;
19
+ }
20
+ /** The effective settings of two directories, key by key. */
21
+ export interface SettingsComparison {
22
+ a: string;
23
+ b: string;
24
+ settings: ComparedSetting[];
25
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Claude Code names a project's store after its path with every non-alphanumeric character as "-".
3
+ * A name over 200 characters is cut there and ends with "-" and a hash of the whole path, so deep
4
+ * paths that share a start get different stores (observed in the SDK 0.3.296; the docs do not say).
5
+ */
6
+ export declare function storeDirName(projectPath: string): string;
@@ -1,4 +1,20 @@
1
- /** Claude Code names a project's store after its path with every non-alphanumeric character as "-". */
1
+ /** Longest store name Claude Code keeps as it is; a longer one is cut and given a hash of the path. */
2
+ const MAX_STORE_NAME = 200;
3
+ /**
4
+ * Claude Code names a project's store after its path with every non-alphanumeric character as "-".
5
+ * A name over 200 characters is cut there and ends with "-" and a hash of the whole path, so deep
6
+ * paths that share a start get different stores (observed in the SDK 0.3.296; the docs do not say).
7
+ */
2
8
  export function storeDirName(projectPath) {
3
- return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
9
+ const name = projectPath.replace(/[^a-zA-Z0-9]/g, "-");
10
+ if (name.length <= MAX_STORE_NAME)
11
+ return name;
12
+ return `${name.slice(0, MAX_STORE_NAME)}-${Math.abs(pathHash(projectPath)).toString(36)}`;
13
+ }
14
+ /** Java's 32-bit string hash, over UTF-16 code units, as Claude Code computes it. */
15
+ function pathHash(text) {
16
+ let hash = 0;
17
+ for (let i = 0; i < text.length; i++)
18
+ hash = ((hash << 5) - hash + text.charCodeAt(i)) | 0;
19
+ return hash;
4
20
  }
@@ -0,0 +1,104 @@
1
+ import { type Static, Type } from "typebox";
2
+ /** Set one top-level key in one settings file; `value` undefined removes the key. */
3
+ export declare const SettingsEditBody: Type.TObject<{
4
+ dir: Type.TString;
5
+ scope: Type.TEnum<import("./types.ts").Scope[]>;
6
+ key: Type.TString;
7
+ value: Type.TOptional<Type.TUnknown>;
8
+ }>;
9
+ export type SettingsEdit = Static<typeof SettingsEditBody>;
10
+ /** Take one permission rule out of its list and/or add one, in one settings file. */
11
+ export declare const PermissionEditBody: Type.TObject<{
12
+ dir: Type.TString;
13
+ file: Type.TString;
14
+ remove: Type.TOptional<Type.TObject<{
15
+ list: Type.TEnum<import("./types.ts").PermissionList[]>;
16
+ rule: Type.TString;
17
+ }>>;
18
+ add: Type.TOptional<Type.TObject<{
19
+ list: Type.TEnum<import("./types.ts").PermissionList[]>;
20
+ rule: Type.TString;
21
+ }>>;
22
+ }>;
23
+ export type PermissionEdit = Static<typeof PermissionEditBody>;
24
+ /**
25
+ * One hook change to a settings file. With both, the hook is replaced in place when it stays in
26
+ * the same event and matcher, and moved otherwise.
27
+ */
28
+ export declare const HookEditBody: Type.TObject<{
29
+ dir: Type.TString;
30
+ file: Type.TString;
31
+ /** The hook to take out, as the scan showed it; refused when the file no longer has it there. */
32
+ remove: Type.TOptional<Type.TObject<{
33
+ event: Type.TString;
34
+ at: Type.TObject<{
35
+ group: Type.TInteger;
36
+ index: Type.TInteger;
37
+ }>;
38
+ definition: Type.TUnknown;
39
+ }>>;
40
+ /** The hook to write: the matcher group it goes in, and the hook itself. */
41
+ add: Type.TOptional<Type.TObject<{
42
+ event: Type.TString;
43
+ /** Empty or absent for a group without a matcher, which matches everything. */
44
+ matcher: Type.TOptional<Type.TString>;
45
+ definition: Type.TUnknown;
46
+ }>>;
47
+ }>;
48
+ export type HookEdit = Static<typeof HookEditBody>;
49
+ /** One subagent file change: a new agent, a changed one, or one deleted. */
50
+ export declare const AgentEditBody: Type.TObject<{
51
+ dir: Type.TString;
52
+ /** An agent the scan shows here; absent when creating one. */
53
+ path: Type.TOptional<Type.TString>;
54
+ /** Where a new agent goes, and its file name without .md. */
55
+ create: Type.TOptional<Type.TObject<{
56
+ scope: Type.TEnum<["user", "project"]>;
57
+ file: Type.TString;
58
+ }>>;
59
+ /** The whole file; null deletes the agent. */
60
+ text: Type.TUnion<[Type.TString, Type.TNull]>;
61
+ }>;
62
+ export type AgentEdit = Static<typeof AgentEditBody>;
63
+ /**
64
+ * One MCP server change. Project servers are changed in .mcp.json; user and local ones through
65
+ * `claude mcp`. With both, the server is replaced, keeping its place in .mcp.json.
66
+ */
67
+ export declare const McpEditBody: Type.TObject<{
68
+ dir: Type.TString;
69
+ scope: Type.TEnum<["user", "local", "project"]>;
70
+ /** The server to take out, as the scan showed it; refused when it changed since. */
71
+ remove: Type.TOptional<Type.TObject<{
72
+ name: Type.TString;
73
+ definition: Type.TUnknown;
74
+ }>>;
75
+ add: Type.TOptional<Type.TObject<{
76
+ name: Type.TString;
77
+ definition: Type.TUnknown;
78
+ }>>;
79
+ }>;
80
+ export type McpEdit = Static<typeof McpEditBody>;
81
+ /** A save's or restore's revision: the one its preview returned. */
82
+ export declare const RevisionBody: Type.TObject<{
83
+ revision: Type.TString;
84
+ }>;
85
+ /** Count which permission rules the transcripts under `path` used. */
86
+ export declare const UsageBody: Type.TObject<{
87
+ path: Type.TString;
88
+ rules: Type.TArray<Type.TObject<{
89
+ list: Type.TEnum<import("./types.ts").PermissionList[]>;
90
+ rule: Type.TString;
91
+ }>>;
92
+ }>;
93
+ /** Preview a memory change; changing it also sends the revision. */
94
+ export declare const MemoryBody: Type.TObject<{
95
+ action: Type.TString;
96
+ path: Type.TString;
97
+ }>;
98
+ export declare const OpenBody: Type.TObject<{
99
+ path: Type.TString;
100
+ }>;
101
+ /** Preview restoring a backup; restoring it also sends the revision. */
102
+ export declare const BackupBody: Type.TObject<{
103
+ id: Type.TString;
104
+ }>;
@@ -0,0 +1,73 @@
1
+ // What each API request sends, as JSON Schema. The server checks bodies against these, and each
2
+ // type is read from its schema. The page imports only the types, re-exported from types.ts, so
3
+ // TypeBox stays out of its bundle.
4
+ import { Type } from "typebox";
5
+ import { EVERY_FILE, PERMISSION_LISTS } from "./scopes.js";
6
+ /** Set one top-level key in one settings file; `value` undefined removes the key. */
7
+ export const SettingsEditBody = Type.Object({
8
+ dir: Type.String(),
9
+ scope: Type.Enum(EVERY_FILE),
10
+ key: Type.String(),
11
+ value: Type.Optional(Type.Unknown()),
12
+ });
13
+ /** One permission rule. */
14
+ const Rule = Type.Object({ list: Type.Enum(PERMISSION_LISTS), rule: Type.String() });
15
+ /** Take one permission rule out of its list and/or add one, in one settings file. */
16
+ export const PermissionEditBody = Type.Object({
17
+ dir: Type.String(),
18
+ file: Type.String(),
19
+ remove: Type.Optional(Rule),
20
+ add: Type.Optional(Rule),
21
+ });
22
+ const place = Type.Integer({ minimum: 0 });
23
+ /**
24
+ * One hook change to a settings file. With both, the hook is replaced in place when it stays in
25
+ * the same event and matcher, and moved otherwise.
26
+ */
27
+ export const HookEditBody = Type.Object({
28
+ dir: Type.String(),
29
+ file: Type.String(),
30
+ /** The hook to take out, as the scan showed it; refused when the file no longer has it there. */
31
+ remove: Type.Optional(Type.Object({
32
+ event: Type.String(),
33
+ at: Type.Object({ group: place, index: place }),
34
+ definition: Type.Unknown(),
35
+ })),
36
+ /** The hook to write: the matcher group it goes in, and the hook itself. */
37
+ add: Type.Optional(Type.Object({
38
+ event: Type.String(),
39
+ /** Empty or absent for a group without a matcher, which matches everything. */
40
+ matcher: Type.Optional(Type.String()),
41
+ definition: Type.Unknown(),
42
+ })),
43
+ });
44
+ /** One subagent file change: a new agent, a changed one, or one deleted. */
45
+ export const AgentEditBody = Type.Object({
46
+ dir: Type.String(),
47
+ /** An agent the scan shows here; absent when creating one. */
48
+ path: Type.Optional(Type.String()),
49
+ /** Where a new agent goes, and its file name without .md. */
50
+ create: Type.Optional(Type.Object({ scope: Type.Enum(["user", "project"]), file: Type.String() })),
51
+ /** The whole file; null deletes the agent. */
52
+ text: Type.Union([Type.String(), Type.Null()]),
53
+ });
54
+ /**
55
+ * One MCP server change. Project servers are changed in .mcp.json; user and local ones through
56
+ * `claude mcp`. With both, the server is replaced, keeping its place in .mcp.json.
57
+ */
58
+ export const McpEditBody = Type.Object({
59
+ dir: Type.String(),
60
+ scope: Type.Enum(["user", "local", "project"]),
61
+ /** The server to take out, as the scan showed it; refused when it changed since. */
62
+ remove: Type.Optional(Type.Object({ name: Type.String(), definition: Type.Unknown() })),
63
+ add: Type.Optional(Type.Object({ name: Type.String(), definition: Type.Unknown() })),
64
+ });
65
+ /** A save's or restore's revision: the one its preview returned. */
66
+ export const RevisionBody = Type.Object({ revision: Type.String() });
67
+ /** Count which permission rules the transcripts under `path` used. */
68
+ export const UsageBody = Type.Object({ path: Type.String(), rules: Type.Array(Rule) });
69
+ /** Preview a memory change; changing it also sends the revision. */
70
+ export const MemoryBody = Type.Object({ action: Type.String(), path: Type.String() });
71
+ export const OpenBody = Type.Object({ path: Type.String() });
72
+ /** Preview restoring a backup; restoring it also sends the revision. */
73
+ export const BackupBody = Type.Object({ id: Type.String() });
@@ -0,0 +1,10 @@
1
+ import { type PermissionList, type Scope } from "./types.ts";
2
+ export declare const EVERY_FILE: Scope[];
3
+ /** Settings files by precedence: managed beats local beats project beats user. */
4
+ export declare const HIGHEST_FIRST: Scope[];
5
+ /** The settings files ccdesk writes to; managed settings belong to your organisation. */
6
+ export declare const EDITABLE_SCOPES: Scope[];
7
+ /** The permission lists, in the order ccdesk shows them. */
8
+ export declare const PERMISSION_LISTS: PermissionList[];
9
+ export declare function allowedScopes(key: string): Scope[];
10
+ export declare function ignoredReason(key: string): string;