ccdesk 0.2.1 → 0.2.3

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 +54 -0
  2. package/README.md +9 -5
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +21 -22
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +37 -45
  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 +25 -0
  12. package/dist/{server → core}/mcp-edit.js +116 -95
  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 +31 -0
  16. package/dist/{server → core}/open.js +39 -12
  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 +5 -3
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +58 -32
  25. package/dist/core/scan/data.d.ts +8 -0
  26. package/dist/core/scan/data.js +18 -0
  27. package/dist/core/scan/env.d.ts +26 -0
  28. package/dist/{server → core}/scan/env.js +10 -3
  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 +14 -0
  32. package/dist/core/scan/git.js +98 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +115 -81
  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 +151 -94
  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 +6 -9
  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 +5 -4
  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 +35 -0
  54. package/dist/{server → core}/scan/settings.js +88 -92
  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 +25 -0
  58. package/dist/{server → core}/scan/usage.js +117 -92
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +44 -0
  62. package/dist/core/secrets.js +187 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/core/settings-edit.js +216 -0
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +129 -20
  67. package/dist/core/write.d.ts +119 -0
  68. package/dist/{server → core}/write.js +63 -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 +2 -0
  81. package/dist/shared/requests.d.ts +104 -0
  82. package/dist/shared/requests.js +73 -0
  83. package/dist/shared/scopes.d.ts +10 -0
  84. package/dist/shared/settings-catalog.json +2199 -0
  85. package/dist/shared/types.d.ts +530 -0
  86. package/dist/shared/types.js +11 -0
  87. package/dist/ui/assets/index-CH1HoC38.js +60 -0
  88. package/dist/ui/index.html +1 -1
  89. package/dist/view/diff.d.ts +16 -0
  90. package/dist/view/diff.js +85 -0
  91. package/dist/view/format.d.ts +17 -0
  92. package/dist/view/format.js +47 -0
  93. package/dist/view/index.d.ts +11 -0
  94. package/dist/view/index.js +13 -0
  95. package/dist/view/lines.d.ts +9 -0
  96. package/dist/view/lines.js +12 -0
  97. package/dist/view/path-test.d.ts +3 -0
  98. package/dist/view/path-test.js +7 -0
  99. package/dist/view/sections/catalog.d.ts +9 -0
  100. package/dist/view/sections/catalog.js +47 -0
  101. package/dist/view/sections/extensions.d.ts +1 -0
  102. package/dist/view/sections/extensions.js +48 -0
  103. package/dist/view/sections/hooks.d.ts +1 -0
  104. package/dist/view/sections/hooks.js +32 -0
  105. package/dist/view/sections/index.d.ts +6 -0
  106. package/dist/view/sections/index.js +27 -0
  107. package/dist/view/sections/instructions.d.ts +6 -0
  108. package/dist/view/sections/instructions.js +38 -0
  109. package/dist/view/sections/mcp.d.ts +3 -0
  110. package/dist/view/sections/mcp.js +38 -0
  111. package/dist/view/sections/memory.d.ts +1 -0
  112. package/dist/view/sections/memory.js +31 -0
  113. package/dist/view/sections/permissions.d.ts +17 -0
  114. package/dist/view/sections/permissions.js +87 -0
  115. package/dist/view/sections/settings.d.ts +1 -0
  116. package/dist/view/sections/settings.js +33 -0
  117. package/dist/view/sections/tools.d.ts +6 -0
  118. package/dist/view/sections/tools.js +57 -0
  119. package/dist/view/sections/types.d.ts +123 -0
  120. package/dist/view/sections/types.js +10 -0
  121. package/dist/view/settings.d.ts +12 -0
  122. package/dist/view/settings.js +31 -0
  123. package/dist/view/usage.d.ts +8 -0
  124. package/dist/view/usage.js +18 -0
  125. package/package.json +13 -2
  126. package/dist/server/scan/files.js +0 -478
  127. package/dist/server/scan/path-test.js +0 -121
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/server/settings-edit.js +0 -221
  131. package/dist/ui/assets/index-XqJwI3k9.js +0 -60
@@ -3,10 +3,12 @@ import { access, stat } from "node:fs/promises";
3
3
  import { delimiter, join, resolve } from "node:path";
4
4
  import {} from "../shared/types.js";
5
5
  import {} from "./scan/env.js";
6
- import { gitRoot, isRecord, jsonSyntaxError, mainWorktreeRoot, redactSecrets, revisionOf, } from "./scan/files.js";
6
+ import { revisionOf } from "./scan/files.js";
7
+ import { isRecord } from "./scan/data.js";
8
+ import { gitRoot, mainWorktreeRoot, trustKey } from "./scan/git.js";
7
9
  import { serverProblems } from "./scan/mcp.js";
8
- import { hasHiddenValues, secretTexts, shown } from "./secrets.js";
9
- import { RefusedError, readIfExists, recordChange, recordClaudeMcpChange, serially, writeAtomic, } from "./write.js";
10
+ import { hasHiddenValues, jsonSyntaxError, redactSecrets, secretTexts, shown } from "./secrets.js";
11
+ import { RefusedError, readIfExists, readJsonObject, recordChange, recordClaudeMcpChange, saveReviewed, serially, writeAtomic, } from "./write.js";
10
12
  const exists = (path) => access(path).then(() => true, () => false);
11
13
  /**
12
14
  * The program to run for `claude`. On Windows, npm installs Claude Code as a claude.cmd that starts
@@ -14,7 +16,7 @@ const exists = (path) => access(path).then(() => true, () => false);
14
16
  * JSON as shell syntax, so ccdesk runs that claude.exe itself. A claude.exe earlier on PATH, as the
15
17
  * native installer puts there, comes first, as it would in a terminal.
16
18
  */
17
- export async function claudeProgram(path = process.env.PATH ?? "", platform = process.platform) {
19
+ export async function claudeProgram(path, platform) {
18
20
  if (platform !== "win32")
19
21
  return "claude";
20
22
  // Windows reads a PATH entry in quotes as the folder inside them.
@@ -44,24 +46,35 @@ function secretsIn(args) {
44
46
  }
45
47
  });
46
48
  }
47
- export const runClaude = async (args, cwd) => {
48
- const program = await claudeProgram();
49
- return new Promise((done, fail) => {
50
- execFile(program, args, { cwd, timeout: 30_000 }, (error, stdout, stderr) => {
51
- if (error?.code === "ENOENT")
52
- return fail(new RefusedError("ccdesk changes your user and local MCP servers through the claude command, which is not on PATH."));
53
- // Node's own message repeats the command, with the server's env and header values in it.
54
- if (error) {
55
- // claude's own message may quote the definition it was given.
56
- const said = secretsIn(args).reduce((text, secret) => text.replaceAll(secret, "•••"), stderr);
57
- const reason = said.trim() ||
58
- (error.killed ? "it took longer than 30 seconds" : `it exited with code ${error.code}`);
59
- return fail(new RefusedError(`claude ${args[1]} failed: ${reason}`));
60
- }
61
- done(stdout);
49
+ /** Runs the claude on `host`'s PATH, with `host`'s environment. */
50
+ export function claudeRunner(host) {
51
+ return async (args, cwd, configDir) => {
52
+ const program = await claudeProgram(host.processEnv.PATH ?? "", host.platform);
53
+ // Set only for a chosen profile: Claude Code also moves .claude.json into a CLAUDE_CONFIG_DIR.
54
+ const env = configDir === undefined
55
+ ? host.processEnv
56
+ : { ...host.processEnv, CLAUDE_CONFIG_DIR: configDir };
57
+ return new Promise((done, fail) => {
58
+ execFile(program, args, { cwd, env, timeout: 30_000 }, (error, stdout, stderr) => {
59
+ if (error?.code === "ENOENT")
60
+ return fail(new RefusedError("ccdesk changes your user and local MCP servers through the claude command, which is not on PATH."));
61
+ // Node's own message repeats the command, with the server's env and header values in it.
62
+ if (error) {
63
+ // claude's own message may quote the definition it was given.
64
+ const said = secretsIn(args).reduce((text, secret) => text.replaceAll(secret, "•••"), stderr);
65
+ const reason = said.trim() ||
66
+ (error.killed ? "it took longer than 30 seconds" : `it exited with code ${error.code}`);
67
+ return fail(new RefusedError(`claude ${args[1]} failed: ${reason}`));
68
+ }
69
+ done(stdout);
70
+ });
62
71
  });
63
- });
64
- };
72
+ };
73
+ }
74
+ /** claudeRunner for this process's PATH and environment, read on each run; use a core's instead. */
75
+ export const runClaude = (args, cwd, configDir) =>
76
+ // biome-ignore lint/style/noProcessEnv: the documented default of the free runClaude; a core passes its own.
77
+ claudeRunner({ platform: process.platform, processEnv: process.env })(args, cwd, configDir);
65
78
  /** Quotes a word for a POSIX shell, so a command shown to you can be pasted as it is. */
66
79
  function quoted(word) {
67
80
  return `'${word.replaceAll("'", "'\\''")}'`;
@@ -107,20 +120,7 @@ async function planProject(env, edit) {
107
120
  if (!(await stat(dir).catch(() => null))?.isDirectory())
108
121
  throw new RefusedError(`${dir} is not a directory on this machine.`);
109
122
  const path = join(dir, ".mcp.json");
110
- const raw = await readIfExists(env, path);
111
- let data = {};
112
- if (raw !== null && raw.trim() !== "") {
113
- let parsed;
114
- try {
115
- parsed = JSON.parse(raw);
116
- }
117
- catch (error) {
118
- throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
119
- }
120
- if (!isRecord(parsed))
121
- throw new RefusedError(`${path} does not hold a JSON object.`);
122
- data = parsed;
123
- }
123
+ const { raw, data, reformats } = await readJsonObject(env, path);
124
124
  const servers = isRecord(data.mcpServers) ? data.mcpServers : {};
125
125
  checked(servers, edit);
126
126
  const next = { ...data, mcpServers: edited(servers, edit) };
@@ -133,7 +133,7 @@ async function planProject(env, edit) {
133
133
  before,
134
134
  after: errors.length > 0 ? (before ?? "") : shown(next),
135
135
  revision: revisionOf(raw),
136
- reformats: raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`,
136
+ reformats,
137
137
  errors,
138
138
  },
139
139
  save: () => recordChange(env, { kind: "mcp", target: path, before: raw }, async () => {
@@ -143,11 +143,12 @@ async function planProject(env, edit) {
143
143
  };
144
144
  }
145
145
  /**
146
- * A user or local server, which lives in ~/.claude.json: ccdesk runs Claude Code's own
147
- * `claude mcp` commands rather than write that file, which Claude Code rewrites all the time.
146
+ * The user or local servers an edit changes in ~/.claude.json, and the folder claude runs in for
147
+ * them. Local servers are kept per project, under the key the scan reads them from; Claude Code
148
+ * files them there from any folder below. User servers do not depend on the folder, so claude runs
149
+ * in your home directory for them.
148
150
  */
149
- async function planClaudeJson(env, edit, run) {
150
- const dir = resolve(edit.dir);
151
+ async function claudeJsonServers(env, edit) {
151
152
  const raw = await readIfExists(env, env.claudeJson);
152
153
  let data = {};
153
154
  try {
@@ -158,21 +159,21 @@ async function planClaudeJson(env, edit, run) {
158
159
  catch (error) {
159
160
  throw new RefusedError(`${env.claudeJson} is ${jsonSyntaxError(error)}; fix it by hand first.`);
160
161
  }
161
- // Local servers are kept per project, under the key the scan reads them from: the main
162
- // checkout's root, or this folder outside git. Claude Code files them there from any folder below.
163
- const projects = isRecord(data.projects) ? data.projects : {};
164
- const root = (await mainWorktreeRoot(dir)) ?? (await gitRoot(dir)) ?? dir;
165
- // User servers do not depend on the folder, so claude runs in your home directory for them.
166
- const cwd = edit.scope === "user" ? env.home : root;
162
+ const dir = resolve(edit.dir);
163
+ const cwd = edit.scope === "user"
164
+ ? env.home
165
+ : trustKey(dir, await mainWorktreeRoot(dir), await gitRoot(dir));
167
166
  if (!(await stat(cwd).catch(() => null))?.isDirectory())
168
167
  throw new RefusedError(`${cwd} is not a directory on this machine.`);
168
+ const projects = isRecord(data.projects) ? data.projects : {};
169
169
  const holder = edit.scope === "user" ? data : projects[cwd];
170
170
  const servers = isRecord(holder) && isRecord(holder.mcpServers) ? holder.mcpServers : {};
171
- checked(servers, edit);
172
- const { remove, add } = edit;
173
- const errors = add ? serverProblems(add.definition) : [];
174
- // Names follow "--", so one such as --help is a name, not an option.
175
- const addArgs = (name, definition) => [
171
+ return { cwd, servers };
172
+ }
173
+ /** The `claude mcp` arguments for an edit; names follow "--", so one such as --help is a name. */
174
+ const claudeArgs = {
175
+ remove: (edit, name) => ["mcp", "remove", "--scope", edit.scope, "--", name],
176
+ add: (edit, name, definition) => [
176
177
  "mcp",
177
178
  "add-json",
178
179
  "--scope",
@@ -180,25 +181,76 @@ async function planClaudeJson(env, edit, run) {
180
181
  "--",
181
182
  name,
182
183
  JSON.stringify(definition),
183
- ];
184
- const addCommand = (name, definition) => `claude mcp add-json --scope ${edit.scope} -- ${quoted(name)} ${quoted(JSON.stringify(definition))}`;
184
+ ],
185
+ };
186
+ /**
187
+ * `claude` with these arguments as you would type it in bash, zsh or Git Bash: the server's name
188
+ * and definition after "--" are quoted, and the profile ccdesk reads is named, so pasting the line
189
+ * changes the same servers. With `cwd`, it starts by going to the folder claude ran in.
190
+ */
191
+ function commandLine(env, args, cwd) {
192
+ const names = args.indexOf("--") + 1;
193
+ const claude = env.configDir === undefined ? "claude" : `CLAUDE_CONFIG_DIR=${quoted(env.configDir)} claude`;
194
+ const line = [claude, ...args.slice(0, names), ...args.slice(names).map(quoted)].join(" ");
195
+ return cwd === undefined ? line : `cd ${quoted(cwd)} && ${line}`;
196
+ }
197
+ /**
198
+ * A user or local server, which lives in ~/.claude.json: ccdesk runs Claude Code's own
199
+ * `claude mcp` commands rather than write that file, which Claude Code rewrites all the time.
200
+ */
201
+ async function planClaudeJson(env, edit, run) {
202
+ const { cwd, servers } = await claudeJsonServers(env, edit);
203
+ checked(servers, edit);
204
+ const { remove, add } = edit;
205
+ const errors = add ? serverProblems(add.definition) : [];
185
206
  const before = Object.keys(servers).length > 0 ? shown(servers) : null;
207
+ // A local server's commands to paste go to the folder it belongs to first.
208
+ const from = edit.scope === "local" ? cwd : undefined;
186
209
  // Recent changes lists the edit with the servers it touched, values hidden as the page shows
187
- // them, and the commands that put them back from the folder claude ran in.
210
+ // them, and the commands that put them back.
188
211
  const record = () => {
189
212
  const names = [remove?.name, add?.name].filter((n) => n !== undefined);
190
213
  const touched = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => names.includes(name)));
191
- const at = edit.scope === "local" ? `cd ${quoted(cwd)} && ` : "";
192
214
  return recordClaudeMcpChange(env, {
193
215
  target: env.claudeJson,
194
216
  shownBefore: shown(touched(servers)),
195
217
  shownAfter: shown(touched(edited(servers, edit))),
196
218
  undoCommands: [
197
- ...(add ? [`${at}claude mcp remove --scope ${edit.scope} -- ${quoted(add.name)}`] : []),
198
- ...(remove ? [`${at}${addCommand(remove.name, redactSecrets(servers[remove.name]))}`] : []),
219
+ ...(add ? [commandLine(env, claudeArgs.remove(edit, add.name), from)] : []),
220
+ ...(remove
221
+ ? [
222
+ commandLine(env, claudeArgs.add(edit, remove.name, redactSecrets(servers[remove.name])), from),
223
+ ]
224
+ : []),
199
225
  ],
200
226
  });
201
227
  };
228
+ const save = async () => {
229
+ if (remove)
230
+ await run(claudeArgs.remove(edit, remove.name), cwd, env.configDir);
231
+ // The change is made by then; a record that cannot be written only leaves it unlisted.
232
+ if (!add) {
233
+ await record().catch(() => undefined);
234
+ return { target: env.claudeJson };
235
+ }
236
+ try {
237
+ await run(claudeArgs.add(edit, add.name, add.definition), cwd, env.configDir);
238
+ }
239
+ catch (error) {
240
+ if (!remove)
241
+ throw error;
242
+ // A replaced server goes back as it was, so a refused add does not lose it. When that fails
243
+ // too, the error gives the command that puts it back; a replaced server has no hidden values
244
+ // (see checked), so the command shows nothing the page does not.
245
+ const putBack = claudeArgs.add(edit, remove.name, servers[remove.name]);
246
+ await run(putBack, cwd, env.configDir).catch(() => {
247
+ throw new RefusedError(`ccdesk removed ${remove.name}, then could not add it back. In bash, zsh or Git Bash, put it back with: ${commandLine(env, putBack, from)} (${error instanceof Error ? error.message : String(error)})`);
248
+ });
249
+ throw error;
250
+ }
251
+ await record().catch(() => undefined);
252
+ return { target: env.claudeJson };
253
+ };
202
254
  return {
203
255
  preview: {
204
256
  path: env.claudeJson,
@@ -211,37 +263,13 @@ async function planClaudeJson(env, edit, run) {
211
263
  errors,
212
264
  // Shown with secrets hidden; the command that runs has the values you typed.
213
265
  commands: [
214
- ...(remove ? [`claude mcp remove --scope ${edit.scope} -- ${quoted(remove.name)}`] : []),
215
- ...(add ? [addCommand(add.name, redactSecrets(add.definition))] : []),
266
+ ...(remove ? [commandLine(env, claudeArgs.remove(edit, remove.name))] : []),
267
+ ...(add
268
+ ? [commandLine(env, claudeArgs.add(edit, add.name, redactSecrets(add.definition)))]
269
+ : []),
216
270
  ],
217
271
  },
218
- save: async () => {
219
- if (remove)
220
- await run(["mcp", "remove", "--scope", edit.scope, "--", remove.name], cwd);
221
- // The change is made by then; a record that cannot be written only leaves it unlisted.
222
- if (!add) {
223
- await record().catch(() => undefined);
224
- return { target: env.claudeJson };
225
- }
226
- try {
227
- await run(addArgs(add.name, add.definition), cwd);
228
- }
229
- catch (error) {
230
- if (!remove)
231
- throw error;
232
- // A replaced server goes back as it was, so a refused add does not lose it. When that
233
- // fails too, the error gives the command that puts it back, from the folder a local server
234
- // belongs to; a replaced server has no hidden values (see checked), so the command shows
235
- // nothing the page does not.
236
- const original = servers[remove.name];
237
- await run(addArgs(remove.name, original), cwd).catch(() => {
238
- throw new RefusedError(`ccdesk removed ${remove.name}, then could not add it back. In bash, zsh or Git Bash, put it back with: ${edit.scope === "local" ? `cd ${quoted(cwd)} && ` : ""}${addCommand(remove.name, original)} (${error instanceof Error ? error.message : String(error)})`);
239
- });
240
- throw error;
241
- }
242
- await record().catch(() => undefined);
243
- return { target: env.claudeJson };
244
- },
272
+ save,
245
273
  };
246
274
  }
247
275
  function plan(env, edit, run) {
@@ -251,13 +279,6 @@ export async function previewMcpEdit(env, edit, run) {
251
279
  return (await plan(env, edit, run)).preview;
252
280
  }
253
281
  /** Makes a reviewed MCP edit, only while the servers are still as reviewed. */
254
- export function saveMcpEdit(env, edit, expectedRevision, run) {
255
- return serially(async () => {
256
- const planned = await plan(env, edit, run);
257
- if (planned.preview.errors.length > 0)
258
- throw new RefusedError(planned.preview.errors.join(" "));
259
- if (planned.preview.revision !== expectedRevision)
260
- throw new RefusedError("The servers changed since you reviewed this edit; review it again.");
261
- return planned.save();
262
- });
282
+ export function saveMcpEdit(env, edit, expectedRevision, run, queue = serially) {
283
+ return saveReviewed(queue, () => plan(env, edit, run), expectedRevision, "The servers changed since you reviewed this edit; review it again.");
263
284
  }
@@ -0,0 +1,20 @@
1
+ import { type Change, type MemoryAction, type MemoryChangePreview } from "../shared/types.ts";
2
+ import { type ScanEnv } from "./scan/env.ts";
3
+ import { type Serially } from "./write.ts";
4
+ /** A real `<config>/projects/<store>/memory` folder, after following links. */
5
+ export declare function assertMemoryDir(env: ScanEnv, dir: string): Promise<string>;
6
+ /**
7
+ * A memory folder's MEMORY.md. When it is a link, the file it leads to must be in the same folder,
8
+ * so changing the index can never rewrite a file elsewhere.
9
+ */
10
+ export declare function indexPathIn(store: string): Promise<string>;
11
+ /** One fingerprint for two files, so a change can require both as they were reviewed. */
12
+ export declare function revisionOfBoth(first: string | null, second: string | null): string;
13
+ export declare function isMemoryAction(value: string): value is MemoryAction;
14
+ /** What a memory action would change, for review. */
15
+ export declare function previewMemoryChange(env: ScanEnv, action: MemoryAction, path: string): Promise<MemoryChangePreview>;
16
+ /**
17
+ * Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
18
+ * action runs only while everything its review was worked out from is unchanged.
19
+ */
20
+ export declare function changeMemory(env: ScanEnv, action: MemoryAction, path: string, expectedRevision: string, queue?: Serially): Promise<Change>;
@@ -2,9 +2,11 @@ import { access, constants, readdir, realpath, stat } from "node:fs/promises";
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import {} from "../shared/types.js";
4
4
  import {} from "./scan/env.js";
5
- import { isWithin, parseFrontmatter, revisionOf, shownText, stringField } from "./scan/files.js";
5
+ import { isWithin, revisionOf, stringField } from "./scan/files.js";
6
+ import { parseFrontmatter } from "./scan/data.js";
7
+ import { shownText } from "./secrets.js";
6
8
  import { deadLinks, indexMentions } from "./scan/memory.js";
7
- import { RefusedError, readIfExists, recordChange, removeExactly, serially, writeAtomic, } from "./write.js";
9
+ import { RefusedError, readIfExists, recordChange, removeExactly, saveReviewed, serially, writeAtomic, } from "./write.js";
8
10
  async function projectsRoot(env) {
9
11
  return realpath(join(env.claudeDir, "projects")).catch(() => {
10
12
  throw new RefusedError("There is no auto memory folder.");
@@ -47,16 +49,7 @@ async function assertMemoryFile(env, path) {
47
49
  export function revisionOfBoth(first, second) {
48
50
  return `${revisionOf(first)}+${revisionOf(second)}`;
49
51
  }
50
- const reviewAgain = () => new RefusedError("The memory or MEMORY.md changed since you reviewed this; review it again.");
51
- /** Runs a plan only while everything it was worked out from is as reviewed. */
52
- function saveReviewed(plan, expectedRevision) {
53
- return serially(async () => {
54
- const planned = await plan();
55
- if (planned.preview.revision !== expectedRevision)
56
- throw reviewAgain();
57
- return planned.save();
58
- });
59
- }
52
+ const REVIEW_AGAIN = "The memory or MEMORY.md changed since you reviewed this; review it again.";
60
53
  /** MEMORY.md's lines without the empty ones a trailing newline leaves. */
61
54
  function indexLines(index) {
62
55
  const lines = (index ?? "").split("\n");
@@ -103,7 +96,7 @@ async function planDelete(env, path) {
103
96
  });
104
97
  }
105
98
  if (!removed)
106
- throw reviewAgain();
99
+ throw new RefusedError(REVIEW_AGAIN);
107
100
  // The lines are kept as they were in the backup; the page gets them as ccdesk shows text.
108
101
  return {
109
102
  target: real,
@@ -188,6 +181,6 @@ export async function previewMemoryChange(env, action, path) {
188
181
  * Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
189
182
  * action runs only while everything its review was worked out from is unchanged.
190
183
  */
191
- export function changeMemory(env, action, path, expectedRevision) {
192
- return saveReviewed(() => PLANS[action](env, path), expectedRevision);
184
+ export function changeMemory(env, action, path, expectedRevision, queue = serially) {
185
+ return saveReviewed(queue, () => PLANS[action](env, path), expectedRevision, REVIEW_AGAIN);
193
186
  }
@@ -0,0 +1,31 @@
1
+ import { type ScanEnv } from "./scan/env.ts";
2
+ type Command = [string, string[]];
3
+ /** The machine an editor is looked for and started on. */
4
+ type Host = Pick<ScanEnv, "platform" | "processEnv">;
5
+ /**
6
+ * The commands to try, in order: `CCDESK_EDITOR` alone when set; else `VISUAL` or `EDITOR` when
7
+ * they name a graphical editor, then graphical editors found on PATH, then the system's opener.
8
+ * The system opener comes last because on Linux it often picks a terminal editor or nothing for
9
+ * JSON and Markdown.
10
+ */
11
+ export declare function editorCommands(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, found?: (name: string) => boolean): Command[];
12
+ /**
13
+ * How to start `command` on `path`. On Windows a .cmd or .bat file (VS Code's `code`) or a bare
14
+ * name that may resolve to one cannot be started directly, so it goes through cmd.exe, quoted.
15
+ */
16
+ export declare function spawnPlan(command: string, args: string[], path: string, platform: NodeJS.Platform): {
17
+ file: string;
18
+ args: string[];
19
+ verbatim: boolean;
20
+ };
21
+ /**
22
+ * Opens `url` in the default browser on `host`; resolves null once the opener started, else why it
23
+ * failed. On Windows `start` is a cmd.exe builtin, so cmd.exe is the command.
24
+ */
25
+ export declare function openBrowser(url: string, host: Host): Promise<string | null>;
26
+ /**
27
+ * Opens a file or folder in the first editor that starts on `host`; resolves with the command
28
+ * used. Without `host` it uses this process's platform and environment; use a core's instead.
29
+ */
30
+ export declare function openInEditor(path: string, host?: Host): Promise<string>;
31
+ export {};
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { accessSync, constants } from "node:fs";
3
3
  import { delimiter, join, posix, win32 } from "node:path";
4
+ import {} from "./scan/env.js";
5
+ import { RefusedError } from "./write.js";
4
6
  /** Editors that need a terminal; started from a background server they would have none. */
5
7
  const TERMINAL_EDITORS = new Set([
6
8
  "vi",
@@ -72,11 +74,7 @@ export function editorCommands(env, platform, found = (name) => onPath(env, plat
72
74
  for (const editor of GUI_EDITORS)
73
75
  if (found(editor))
74
76
  out.push([editor, []]);
75
- out.push(platform === "darwin"
76
- ? ["open", []]
77
- : platform === "win32"
78
- ? ["explorer", []]
79
- : ["xdg-open", []]);
77
+ out.push(fileOpener(platform));
80
78
  return out.filter((c, i) => out.findIndex((o) => o[0] === c[0]) === i);
81
79
  }
82
80
  /** Splits an editor setting into words, keeping quoted parts such as "C:\Program Files\…" whole. */
@@ -98,16 +96,17 @@ export function spawnPlan(command, args, path, platform) {
98
96
  return { file: "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
99
97
  }
100
98
  /** Starts one command; resolves null once it is running or exited cleanly, else why it failed. */
101
- function launch([command, args], path) {
99
+ function launch([command, args], path, host) {
102
100
  return new Promise((resolve) => {
103
101
  let plan;
104
102
  try {
105
- plan = spawnPlan(command, args, path, process.platform);
103
+ plan = spawnPlan(command, args, path, host.platform);
106
104
  }
107
105
  catch (error) {
108
106
  return resolve(error.message);
109
107
  }
110
108
  const child = spawn(plan.file, plan.args, {
109
+ env: host.processEnv,
111
110
  detached: true,
112
111
  stdio: "ignore",
113
112
  windowsVerbatimArguments: plan.verbatim,
@@ -126,14 +125,42 @@ function launch([command, args], path) {
126
125
  });
127
126
  });
128
127
  }
129
- /** Opens a file or folder in the first editor that starts; resolves with the command used. */
130
- export async function openInEditor(path) {
128
+ /**
129
+ * Opens `url` in the default browser on `host`; resolves null once the opener started, else why it
130
+ * failed. On Windows `start` is a cmd.exe builtin, so cmd.exe is the command.
131
+ */
132
+ export function openBrowser(url, host) {
133
+ return launch(browserOpener(host.platform), url, host);
134
+ }
135
+ /** The system's own way to open a file in the app it belongs to. */
136
+ function fileOpener(platform) {
137
+ if (platform === "darwin")
138
+ return ["open", []];
139
+ if (platform === "win32")
140
+ return ["explorer", []];
141
+ return ["xdg-open", []];
142
+ }
143
+ /** The system's own way to open a URL in the default browser. */
144
+ function browserOpener(platform) {
145
+ if (platform === "darwin")
146
+ return ["open", []];
147
+ if (platform === "win32")
148
+ return ["cmd.exe", ["/c", "start", ""]];
149
+ return ["xdg-open", []];
150
+ }
151
+ /**
152
+ * Opens a file or folder in the first editor that starts on `host`; resolves with the command
153
+ * used. Without `host` it uses this process's platform and environment; use a core's instead.
154
+ */
155
+ export async function openInEditor(path,
156
+ // biome-ignore lint/style/noProcessEnv: the documented default of the free openInEditor; a core passes its own.
157
+ host = { platform: process.platform, processEnv: process.env }) {
131
158
  const failures = [];
132
- for (const command of editorCommands(process.env, process.platform)) {
133
- const failure = await launch(command, path);
159
+ for (const command of editorCommands(host.processEnv, host.platform)) {
160
+ const failure = await launch(command, path, host);
134
161
  if (failure === null)
135
162
  return command[0];
136
163
  failures.push(failure);
137
164
  }
138
- throw new Error(`Could not open it (${failures.join("; ")}). Set CCDESK_EDITOR to your editor.`);
165
+ throw new RefusedError(`Could not open it (${failures.join("; ")}). Set CCDESK_EDITOR to your editor.`);
139
166
  }
@@ -0,0 +1,12 @@
1
+ import { type AgentEdit, type HookEdit, type McpEdit, type PermissionEdit, type SettingsEdit } from "../shared/types.ts";
2
+ /**
3
+ * An edit that is not well formed, such as an unknown scope or a missing field: the caller sent
4
+ * something no review could have produced. The edit modules trust the edits these parsers return.
5
+ */
6
+ export declare class InvalidEditError extends Error {
7
+ }
8
+ export declare function parseSettingsEdit(body: unknown): SettingsEdit;
9
+ export declare function parsePermissionEdit(body: unknown): PermissionEdit;
10
+ export declare function parseHookEdit(body: unknown): HookEdit;
11
+ export declare function parseAgentEdit(body: unknown): AgentEdit;
12
+ export declare function parseMcpEdit(body: unknown): McpEdit;
@@ -0,0 +1,64 @@
1
+ import { Compile } from "typebox/compile";
2
+ import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
3
+ import { AgentEditBody, HookEditBody, McpEditBody, PermissionEditBody, SettingsEditBody, } from "../shared/requests.js";
4
+ import {} from "../shared/types.js";
5
+ import { isRecord } from "./scan/data.js";
6
+ import { firstError } from "./schema.js";
7
+ /**
8
+ * An edit that is not well formed, such as an unknown scope or a missing field: the caller sent
9
+ * something no review could have produced. The edit modules trust the edits these parsers return.
10
+ */
11
+ export class InvalidEditError extends Error {
12
+ }
13
+ const settingsEdit = Compile(SettingsEditBody);
14
+ const permissionEdit = Compile(PermissionEditBody);
15
+ const hookEdit = Compile(HookEditBody);
16
+ const agentEdit = Compile(AgentEditBody);
17
+ const mcpEdit = Compile(McpEditBody);
18
+ export function parseSettingsEdit(body) {
19
+ if (!settingsEdit.Check(body))
20
+ throw new InvalidEditError(firstError(settingsEdit, body));
21
+ return body;
22
+ }
23
+ export function parsePermissionEdit(body) {
24
+ if (!permissionEdit.Check(body))
25
+ throw new InvalidEditError(firstError(permissionEdit, body));
26
+ if (!body.remove && !body.add)
27
+ throw new InvalidEditError("remove or add is required");
28
+ if (body.remove?.rule.trim() === "" || body.add?.rule.trim() === "")
29
+ throw new InvalidEditError("a rule needs a list (allow, ask or deny) and text");
30
+ return body;
31
+ }
32
+ export function parseHookEdit(body) {
33
+ if (!hookEdit.Check(body))
34
+ throw new InvalidEditError(firstError(hookEdit, body));
35
+ if (!body.remove && !body.add)
36
+ throw new InvalidEditError("remove or add is required");
37
+ if (body.add) {
38
+ if (!HOOK_EVENTS.includes(body.add.event))
39
+ throw new InvalidEditError(`${body.add.event} is not a hook event Claude Code knows`);
40
+ if (!isRecord(body.add.definition))
41
+ throw new InvalidEditError("a hook must be a JSON object");
42
+ }
43
+ return body;
44
+ }
45
+ export function parseAgentEdit(body) {
46
+ if (!agentEdit.Check(body))
47
+ throw new InvalidEditError(firstError(agentEdit, body));
48
+ if (body.path !== undefined)
49
+ return body;
50
+ if (!body.create)
51
+ throw new InvalidEditError("path or create is required");
52
+ if (body.text === null)
53
+ throw new InvalidEditError("a new agent needs its text");
54
+ return body;
55
+ }
56
+ export function parseMcpEdit(body) {
57
+ if (!mcpEdit.Check(body))
58
+ throw new InvalidEditError(firstError(mcpEdit, body));
59
+ if (!body.remove && !body.add)
60
+ throw new InvalidEditError("remove or add is required");
61
+ if (body.add && !isRecord(body.add.definition))
62
+ throw new InvalidEditError("a server must be a JSON object");
63
+ return body;
64
+ }
@@ -0,0 +1,3 @@
1
+ import { type Reference } from "../shared/types.ts";
2
+ /** Claude Code's settings data a front end edits against, from the files generated into src/shared. */
3
+ export declare const REFERENCE: Reference;
@@ -0,0 +1,11 @@
1
+ import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
2
+ import catalog from "../shared/settings-catalog.json" with { type: "json" };
3
+ import schema from "../shared/settings.schema.json" with { type: "json" };
4
+ import {} from "../shared/types.js";
5
+ /** Claude Code's settings data a front end edits against, from the files generated into src/shared. */
6
+ export const REFERENCE = {
7
+ schemaSource: schema.$comment,
8
+ settings: schema.definitions.ClaudeCodeSettings.properties,
9
+ catalog: Object.entries(catalog.keys).map(([key, entry]) => ({ key, ...entry })),
10
+ hookEvents: HOOK_EVENTS,
11
+ };
@@ -0,0 +1,7 @@
1
+ import { type SettingsComparison } from "../../shared/compare.ts";
2
+ import { type ScanEnv } from "./env.ts";
3
+ /**
4
+ * The effective settings of two directories side by side. Values are compared as ccdesk shows
5
+ * them, so hidden values stay hidden and two of them count as the same.
6
+ */
7
+ export declare function compareSettings(env: ScanEnv, a: string, b: string): Promise<SettingsComparison>;
@@ -1,10 +1,11 @@
1
1
  import { resolve } from "node:path";
2
+ import { isDeepStrictEqual } from "node:util";
2
3
  import {} from "../../shared/compare.js";
3
4
  import {} from "./env.js";
4
- import { effectiveSettings, readSettingsLayers } from "./settings.js";
5
+ import { effectiveSettings, readManagedSettings, readSettingsLayers } from "./settings.js";
5
6
  /** Each key Claude Code applies in `dir`, with its value and the files it comes from. */
6
7
  async function valuesIn(env, dir) {
7
- const layers = await readSettingsLayers(env, dir);
8
+ const layers = await readSettingsLayers(env, dir, await readManagedSettings(env));
8
9
  const values = new Map();
9
10
  for (const setting of effectiveSettings(layers)) {
10
11
  // A key every file sets where Claude Code does not read it applies nowhere.
@@ -28,7 +29,8 @@ export async function compareSettings(env, a, b) {
28
29
  const settings = keys.map((key) => {
29
30
  const x = left.get(key) ?? null;
30
31
  const y = right.get(key) ?? null;
31
- return { key, a: x, b: y, same: JSON.stringify(x?.value) === JSON.stringify(y?.value) };
32
+ // The order keys are written in does not change a setting.
33
+ return { key, a: x, b: y, same: isDeepStrictEqual(x?.value, y?.value) };
32
34
  });
33
35
  return { a: resolve(a), b: resolve(b), settings };
34
36
  }
@@ -0,0 +1,13 @@
1
+ import { type ContextBlock, type EffectiveSetting, type Instruction, type Inventory, type MemoryStore } from "../../shared/types.ts";
2
+ import { type ScanEnv } from "./env.ts";
3
+ interface ContextInput {
4
+ env: ScanEnv;
5
+ projectRoot: string;
6
+ instructions: Instruction[];
7
+ memory: MemoryStore | null;
8
+ inventory: Inventory;
9
+ effective: EffectiveSetting[];
10
+ }
11
+ /** What Claude Code puts in context at session start in this directory, in load order. */
12
+ export declare function buildContext(input: ContextInput): Promise<ContextBlock[]>;
13
+ export {};