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
@@ -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, parseJson } from "./scan/data.js";
8
+ import { claudeJsonKey, 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, 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,26 +46,39 @@ function secretsIn(args) {
44
46
  }
45
47
  });
46
48
  }
47
- export const runClaude = async (args, cwd, configDir) => {
48
- const program = await claudeProgram();
49
- // Set only for a chosen profile: Claude Code also moves .claude.json into a CLAUDE_CONFIG_DIR.
50
- const env = configDir === undefined ? process.env : { ...process.env, CLAUDE_CONFIG_DIR: configDir };
51
- return new Promise((done, fail) => {
52
- execFile(program, args, { cwd, env, timeout: 30_000 }, (error, stdout, stderr) => {
53
- if (error?.code === "ENOENT")
54
- return fail(new RefusedError("ccdesk changes your user and local MCP servers through the claude command, which is not on PATH."));
55
- // Node's own message repeats the command, with the server's env and header values in it.
56
- if (error) {
57
- // claude's own message may quote the definition it was given.
58
- const said = secretsIn(args).reduce((text, secret) => text.replaceAll(secret, "•••"), stderr);
59
- const reason = said.trim() ||
60
- (error.killed ? "it took longer than 30 seconds" : `it exited with code ${error.code}`);
61
- return fail(new RefusedError(`claude ${args[1]} failed: ${reason}`));
62
- }
63
- done(stdout);
64
- });
65
- });
49
+ /**
50
+ * Why claude did not start. On Windows a claude.cmd that npm did not install, such as Scoop's,
51
+ * Volta's or pnpm's, is on PATH but cannot be run without a shell, so "not on PATH" would mislead.
52
+ */
53
+ const CLAUDE_NOT_FOUND = {
54
+ win32: "ccdesk changes your user and local MCP servers through the claude command and could not start it from PATH. On Windows it runs claude.exe, or the claude.exe inside npm's package when only npm's claude.cmd is there; another installer's claude.cmd or claude.ps1 cannot be started without a shell.",
55
+ other: "ccdesk changes your user and local MCP servers through the claude command, which is not on PATH.",
66
56
  };
57
+ /** Runs the claude on `host`'s PATH, with `host`'s environment. */
58
+ export function claudeRunner(host) {
59
+ return async (args, cwd, configDir) => {
60
+ const program = await claudeProgram(host.processEnv.PATH ?? "", host.platform);
61
+ // Set only for a chosen profile: Claude Code also moves .claude.json into a CLAUDE_CONFIG_DIR.
62
+ const env = configDir === undefined
63
+ ? host.processEnv
64
+ : { ...host.processEnv, CLAUDE_CONFIG_DIR: configDir };
65
+ return new Promise((done, fail) => {
66
+ execFile(program, args, { cwd, env, timeout: 30_000 }, (error, stdout, stderr) => {
67
+ if (error?.code === "ENOENT")
68
+ return fail(new RefusedError(CLAUDE_NOT_FOUND[host.platform === "win32" ? "win32" : "other"]));
69
+ // Node's own message repeats the command, with the server's env and header values in it.
70
+ if (error) {
71
+ // claude's own message may quote the definition it was given.
72
+ const said = secretsIn(args).reduce((text, secret) => text.replaceAll(secret, "•••"), stderr);
73
+ const reason = said.trim() ||
74
+ (error.killed ? "it took longer than 30 seconds" : `it exited with code ${error.code}`);
75
+ return fail(new RefusedError(`claude ${args[1]} failed: ${reason}`));
76
+ }
77
+ done(stdout);
78
+ });
79
+ });
80
+ };
81
+ }
67
82
  /** Quotes a word for a POSIX shell, so a command shown to you can be pasted as it is. */
68
83
  function quoted(word) {
69
84
  return `'${word.replaceAll("'", "'\\''")}'`;
@@ -109,20 +124,7 @@ async function planProject(env, edit) {
109
124
  if (!(await stat(dir).catch(() => null))?.isDirectory())
110
125
  throw new RefusedError(`${dir} is not a directory on this machine.`);
111
126
  const path = join(dir, ".mcp.json");
112
- const raw = await readIfExists(env, path);
113
- let data = {};
114
- if (raw !== null && raw.trim() !== "") {
115
- let parsed;
116
- try {
117
- parsed = JSON.parse(raw);
118
- }
119
- catch (error) {
120
- throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
121
- }
122
- if (!isRecord(parsed))
123
- throw new RefusedError(`${path} does not hold a JSON object.`);
124
- data = parsed;
125
- }
127
+ const { raw, data, reformats } = await readJsonObject(env, path);
126
128
  const servers = isRecord(data.mcpServers) ? data.mcpServers : {};
127
129
  checked(servers, edit);
128
130
  const next = { ...data, mcpServers: edited(servers, edit) };
@@ -135,7 +137,7 @@ async function planProject(env, edit) {
135
137
  before,
136
138
  after: errors.length > 0 ? (before ?? "") : shown(next),
137
139
  revision: revisionOf(raw),
138
- reformats: raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`,
140
+ reformats,
139
141
  errors,
140
142
  },
141
143
  save: () => recordChange(env, { kind: "mcp", target: path, before: raw }, async () => {
@@ -145,36 +147,37 @@ async function planProject(env, edit) {
145
147
  };
146
148
  }
147
149
  /**
148
- * A user or local server, which lives in ~/.claude.json: ccdesk runs Claude Code's own
149
- * `claude mcp` commands rather than write that file, which Claude Code rewrites all the time.
150
+ * The user or local servers an edit changes in ~/.claude.json, and the folder claude runs in for
151
+ * them. Local servers are kept per project, under the key the scan reads them from; Claude Code
152
+ * files them there from any folder below. User servers do not depend on the folder, so claude runs
153
+ * in your home directory for them.
150
154
  */
151
- async function planClaudeJson(env, edit, run) {
152
- const dir = resolve(edit.dir);
155
+ async function claudeJsonServers(env, edit) {
153
156
  const raw = await readIfExists(env, env.claudeJson);
154
157
  let data = {};
155
158
  try {
156
- const parsed = raw === null ? {} : JSON.parse(raw);
159
+ const parsed = raw === null ? {} : parseJson(raw);
157
160
  if (isRecord(parsed))
158
161
  data = parsed;
159
162
  }
160
163
  catch (error) {
161
164
  throw new RefusedError(`${env.claudeJson} is ${jsonSyntaxError(error)}; fix it by hand first.`);
162
165
  }
163
- // Local servers are kept per project, under the key the scan reads them from: the main
164
- // checkout's root, or this folder outside git. Claude Code files them there from any folder below.
165
- const projects = isRecord(data.projects) ? data.projects : {};
166
- const root = (await mainWorktreeRoot(dir)) ?? (await gitRoot(dir)) ?? dir;
167
- // User servers do not depend on the folder, so claude runs in your home directory for them.
168
- const cwd = edit.scope === "user" ? env.home : root;
166
+ const dir = resolve(edit.dir);
167
+ const cwd = edit.scope === "user"
168
+ ? env.home
169
+ : trustKey(dir, await mainWorktreeRoot(dir), await gitRoot(dir));
169
170
  if (!(await stat(cwd).catch(() => null))?.isDirectory())
170
171
  throw new RefusedError(`${cwd} is not a directory on this machine.`);
171
- const holder = edit.scope === "user" ? data : projects[cwd];
172
+ const projects = isRecord(data.projects) ? data.projects : {};
173
+ const holder = edit.scope === "user" ? data : projects[claudeJsonKey(env, cwd)];
172
174
  const servers = isRecord(holder) && isRecord(holder.mcpServers) ? holder.mcpServers : {};
173
- checked(servers, edit);
174
- const { remove, add } = edit;
175
- const errors = add ? serverProblems(add.definition) : [];
176
- // Names follow "--", so one such as --help is a name, not an option.
177
- const addArgs = (name, definition) => [
175
+ return { cwd, servers };
176
+ }
177
+ /** The `claude mcp` arguments for an edit; names follow "--", so one such as --help is a name. */
178
+ const claudeArgs = {
179
+ remove: (edit, name) => ["mcp", "remove", "--scope", edit.scope, "--", name],
180
+ add: (edit, name, definition) => [
178
181
  "mcp",
179
182
  "add-json",
180
183
  "--scope",
@@ -182,27 +185,76 @@ async function planClaudeJson(env, edit, run) {
182
185
  "--",
183
186
  name,
184
187
  JSON.stringify(definition),
185
- ];
186
- // The commands shown and recorded name the profile, so pasting one changes the same servers.
188
+ ],
189
+ };
190
+ /**
191
+ * `claude` with these arguments as you would type it in bash, zsh or Git Bash: the server's name
192
+ * and definition after "--" are quoted, and the profile ccdesk reads is named, so pasting the line
193
+ * changes the same servers. With `cwd`, it starts by going to the folder claude ran in.
194
+ */
195
+ function commandLine(env, args, cwd) {
196
+ const names = args.indexOf("--") + 1;
187
197
  const claude = env.configDir === undefined ? "claude" : `CLAUDE_CONFIG_DIR=${quoted(env.configDir)} claude`;
188
- const addCommand = (name, definition) => `${claude} mcp add-json --scope ${edit.scope} -- ${quoted(name)} ${quoted(JSON.stringify(definition))}`;
198
+ const line = [claude, ...args.slice(0, names), ...args.slice(names).map(quoted)].join(" ");
199
+ return cwd === undefined ? line : `cd ${quoted(cwd)} && ${line}`;
200
+ }
201
+ /**
202
+ * A user or local server, which lives in ~/.claude.json: ccdesk runs Claude Code's own
203
+ * `claude mcp` commands rather than write that file, which Claude Code rewrites all the time.
204
+ */
205
+ async function planClaudeJson(env, edit, run) {
206
+ const { cwd, servers } = await claudeJsonServers(env, edit);
207
+ checked(servers, edit);
208
+ const { remove, add } = edit;
209
+ const errors = add ? serverProblems(add.definition) : [];
189
210
  const before = Object.keys(servers).length > 0 ? shown(servers) : null;
211
+ // A local server's commands to paste go to the folder it belongs to first.
212
+ const from = edit.scope === "local" ? cwd : undefined;
190
213
  // Recent changes lists the edit with the servers it touched, values hidden as the page shows
191
- // them, and the commands that put them back from the folder claude ran in.
214
+ // them, and the commands that put them back.
192
215
  const record = () => {
193
216
  const names = [remove?.name, add?.name].filter((n) => n !== undefined);
194
217
  const touched = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => names.includes(name)));
195
- const at = edit.scope === "local" ? `cd ${quoted(cwd)} && ` : "";
196
218
  return recordClaudeMcpChange(env, {
197
219
  target: env.claudeJson,
198
220
  shownBefore: shown(touched(servers)),
199
221
  shownAfter: shown(touched(edited(servers, edit))),
200
222
  undoCommands: [
201
- ...(add ? [`${at}${claude} mcp remove --scope ${edit.scope} -- ${quoted(add.name)}`] : []),
202
- ...(remove ? [`${at}${addCommand(remove.name, redactSecrets(servers[remove.name]))}`] : []),
223
+ ...(add ? [commandLine(env, claudeArgs.remove(edit, add.name), from)] : []),
224
+ ...(remove
225
+ ? [
226
+ commandLine(env, claudeArgs.add(edit, remove.name, redactSecrets(servers[remove.name])), from),
227
+ ]
228
+ : []),
203
229
  ],
204
230
  });
205
231
  };
232
+ const save = async () => {
233
+ if (remove)
234
+ await run(claudeArgs.remove(edit, remove.name), cwd, env.configDir);
235
+ // The change is made by then; a record that cannot be written only leaves it unlisted.
236
+ if (!add) {
237
+ await record().catch(() => undefined);
238
+ return { target: env.claudeJson };
239
+ }
240
+ try {
241
+ await run(claudeArgs.add(edit, add.name, add.definition), cwd, env.configDir);
242
+ }
243
+ catch (error) {
244
+ if (!remove)
245
+ throw error;
246
+ // A replaced server goes back as it was, so a refused add does not lose it. When that fails
247
+ // too, the error gives the command that puts it back; a replaced server has no hidden values
248
+ // (see checked), so the command shows nothing the page does not.
249
+ const putBack = claudeArgs.add(edit, remove.name, servers[remove.name]);
250
+ await run(putBack, cwd, env.configDir).catch(() => {
251
+ 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)})`);
252
+ });
253
+ throw error;
254
+ }
255
+ await record().catch(() => undefined);
256
+ return { target: env.claudeJson };
257
+ };
206
258
  return {
207
259
  preview: {
208
260
  path: env.claudeJson,
@@ -215,37 +267,13 @@ async function planClaudeJson(env, edit, run) {
215
267
  errors,
216
268
  // Shown with secrets hidden; the command that runs has the values you typed.
217
269
  commands: [
218
- ...(remove ? [`${claude} mcp remove --scope ${edit.scope} -- ${quoted(remove.name)}`] : []),
219
- ...(add ? [addCommand(add.name, redactSecrets(add.definition))] : []),
270
+ ...(remove ? [commandLine(env, claudeArgs.remove(edit, remove.name))] : []),
271
+ ...(add
272
+ ? [commandLine(env, claudeArgs.add(edit, add.name, redactSecrets(add.definition)))]
273
+ : []),
220
274
  ],
221
275
  },
222
- save: async () => {
223
- if (remove)
224
- await run(["mcp", "remove", "--scope", edit.scope, "--", remove.name], cwd, env.configDir);
225
- // The change is made by then; a record that cannot be written only leaves it unlisted.
226
- if (!add) {
227
- await record().catch(() => undefined);
228
- return { target: env.claudeJson };
229
- }
230
- try {
231
- await run(addArgs(add.name, add.definition), cwd, env.configDir);
232
- }
233
- catch (error) {
234
- if (!remove)
235
- throw error;
236
- // A replaced server goes back as it was, so a refused add does not lose it. When that
237
- // fails too, the error gives the command that puts it back, from the folder a local server
238
- // belongs to; a replaced server has no hidden values (see checked), so the command shows
239
- // nothing the page does not.
240
- const original = servers[remove.name];
241
- await run(addArgs(remove.name, original), cwd, env.configDir).catch(() => {
242
- 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)})`);
243
- });
244
- throw error;
245
- }
246
- await record().catch(() => undefined);
247
- return { target: env.claudeJson };
248
- },
276
+ save,
249
277
  };
250
278
  }
251
279
  function plan(env, edit, run) {
@@ -255,13 +283,6 @@ export async function previewMcpEdit(env, edit, run) {
255
283
  return (await plan(env, edit, run)).preview;
256
284
  }
257
285
  /** Makes a reviewed MCP edit, only while the servers are still as reviewed. */
258
- export function saveMcpEdit(env, edit, expectedRevision, run) {
259
- return serially(async () => {
260
- const planned = await plan(env, edit, run);
261
- if (planned.preview.errors.length > 0)
262
- throw new RefusedError(planned.preview.errors.join(" "));
263
- if (planned.preview.revision !== expectedRevision)
264
- throw new RefusedError("The servers changed since you reviewed this edit; review it again.");
265
- return planned.save();
266
- });
286
+ export function saveMcpEdit(env, edit, expectedRevision, run, queue) {
287
+ return saveReviewed(queue, () => plan(env, edit, run), expectedRevision, "The servers changed since you reviewed this edit; review it again.");
267
288
  }
@@ -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, 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) {
185
+ return saveReviewed(queue, () => PLANS[action](env, path), expectedRevision, REVIEW_AGAIN);
193
186
  }
@@ -0,0 +1,28 @@
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
+ /** Opens a file or folder in the first editor that starts on `host`; resolves with the command used. */
27
+ export declare function openInEditor(path: string, host: Host): Promise<string>;
28
+ 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,
@@ -122,18 +121,44 @@ function launch([command, args], path) {
122
121
  });
123
122
  child.once("exit", (code) => {
124
123
  clearTimeout(timer);
125
- resolve(code === 0 ? null : `${command} exited with code ${code}`);
124
+ // explorer.exe hands the file to the running shell and exits with 1 even when that worked.
125
+ if (code === 0 || command === "explorer")
126
+ return resolve(null);
127
+ resolve(`${command} exited with code ${code}`);
126
128
  });
127
129
  });
128
130
  }
129
- /** Opens a file or folder in the first editor that starts; resolves with the command used. */
130
- export async function openInEditor(path) {
131
+ /**
132
+ * Opens `url` in the default browser on `host`; resolves null once the opener started, else why it
133
+ * failed. On Windows `start` is a cmd.exe builtin, so cmd.exe is the command.
134
+ */
135
+ export function openBrowser(url, host) {
136
+ return launch(browserOpener(host.platform), url, host);
137
+ }
138
+ /** The system's own way to open a file in the app it belongs to. */
139
+ function fileOpener(platform) {
140
+ if (platform === "darwin")
141
+ return ["open", []];
142
+ if (platform === "win32")
143
+ return ["explorer", []];
144
+ return ["xdg-open", []];
145
+ }
146
+ /** The system's own way to open a URL in the default browser. */
147
+ function browserOpener(platform) {
148
+ if (platform === "darwin")
149
+ return ["open", []];
150
+ if (platform === "win32")
151
+ return ["cmd.exe", ["/c", "start", ""]];
152
+ return ["xdg-open", []];
153
+ }
154
+ /** Opens a file or folder in the first editor that starts on `host`; resolves with the command used. */
155
+ export async function openInEditor(path, host) {
131
156
  const failures = [];
132
- for (const command of editorCommands(process.env, process.platform)) {
133
- const failure = await launch(command, path);
157
+ for (const command of editorCommands(host.processEnv, host.platform)) {
158
+ const failure = await launch(command, path, host);
134
159
  if (failure === null)
135
160
  return command[0];
136
161
  failures.push(failure);
137
162
  }
138
- throw new Error(`Could not open it (${failures.join("; ")}). Set CCDESK_EDITOR to your editor.`);
163
+ throw new RefusedError(`Could not open it (${failures.join("; ")}). Set CCDESK_EDITOR to your editor.`);
139
164
  }
@@ -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>;
@@ -2,10 +2,10 @@ import { resolve } from "node:path";
2
2
  import { isDeepStrictEqual } from "node:util";
3
3
  import {} from "../../shared/compare.js";
4
4
  import {} from "./env.js";
5
- import { effectiveSettings, readSettingsLayers } from "./settings.js";
5
+ import { effectiveSettings, readManagedSettings, readSettingsLayers } from "./settings.js";
6
6
  /** Each key Claude Code applies in `dir`, with its value and the files it comes from. */
7
7
  async function valuesIn(env, dir) {
8
- const layers = await readSettingsLayers(env, dir);
8
+ const layers = await readSettingsLayers(env, dir, await readManagedSettings(env));
9
9
  const values = new Map();
10
10
  for (const setting of effectiveSettings(layers)) {
11
11
  // A key every file sets where Claude Code does not read it applies nowhere.
@@ -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 {};