ccdesk 0.2.2 → 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 (130) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +6 -3
  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 +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 +115 -98
  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 +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 +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 +7 -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 +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 +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 -95
  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/{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 +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 -480
  127. package/dist/server/scan/path-test.js +0 -136
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. 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 } 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,26 +46,35 @@ 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);
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
+ });
64
71
  });
65
- });
66
- };
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);
67
78
  /** Quotes a word for a POSIX shell, so a command shown to you can be pasted as it is. */
68
79
  function quoted(word) {
69
80
  return `'${word.replaceAll("'", "'\\''")}'`;
@@ -109,20 +120,7 @@ async function planProject(env, edit) {
109
120
  if (!(await stat(dir).catch(() => null))?.isDirectory())
110
121
  throw new RefusedError(`${dir} is not a directory on this machine.`);
111
122
  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
- }
123
+ const { raw, data, reformats } = await readJsonObject(env, path);
126
124
  const servers = isRecord(data.mcpServers) ? data.mcpServers : {};
127
125
  checked(servers, edit);
128
126
  const next = { ...data, mcpServers: edited(servers, edit) };
@@ -135,7 +133,7 @@ async function planProject(env, edit) {
135
133
  before,
136
134
  after: errors.length > 0 ? (before ?? "") : shown(next),
137
135
  revision: revisionOf(raw),
138
- reformats: raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`,
136
+ reformats,
139
137
  errors,
140
138
  },
141
139
  save: () => recordChange(env, { kind: "mcp", target: path, before: raw }, async () => {
@@ -145,11 +143,12 @@ async function planProject(env, edit) {
145
143
  };
146
144
  }
147
145
  /**
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.
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.
150
150
  */
151
- async function planClaudeJson(env, edit, run) {
152
- const dir = resolve(edit.dir);
151
+ async function claudeJsonServers(env, edit) {
153
152
  const raw = await readIfExists(env, env.claudeJson);
154
153
  let data = {};
155
154
  try {
@@ -160,21 +159,21 @@ async function planClaudeJson(env, edit, run) {
160
159
  catch (error) {
161
160
  throw new RefusedError(`${env.claudeJson} is ${jsonSyntaxError(error)}; fix it by hand first.`);
162
161
  }
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;
162
+ const dir = resolve(edit.dir);
163
+ const cwd = edit.scope === "user"
164
+ ? env.home
165
+ : trustKey(dir, await mainWorktreeRoot(dir), await gitRoot(dir));
169
166
  if (!(await stat(cwd).catch(() => null))?.isDirectory())
170
167
  throw new RefusedError(`${cwd} is not a directory on this machine.`);
168
+ const projects = isRecord(data.projects) ? data.projects : {};
171
169
  const holder = edit.scope === "user" ? data : projects[cwd];
172
170
  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) => [
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) => [
178
177
  "mcp",
179
178
  "add-json",
180
179
  "--scope",
@@ -182,27 +181,76 @@ async function planClaudeJson(env, edit, run) {
182
181
  "--",
183
182
  name,
184
183
  JSON.stringify(definition),
185
- ];
186
- // The commands shown and recorded name the profile, so pasting one changes the same servers.
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;
187
193
  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))}`;
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) : [];
189
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;
190
209
  // 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.
210
+ // them, and the commands that put them back.
192
211
  const record = () => {
193
212
  const names = [remove?.name, add?.name].filter((n) => n !== undefined);
194
213
  const touched = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => names.includes(name)));
195
- const at = edit.scope === "local" ? `cd ${quoted(cwd)} && ` : "";
196
214
  return recordClaudeMcpChange(env, {
197
215
  target: env.claudeJson,
198
216
  shownBefore: shown(touched(servers)),
199
217
  shownAfter: shown(touched(edited(servers, edit))),
200
218
  undoCommands: [
201
- ...(add ? [`${at}${claude} mcp remove --scope ${edit.scope} -- ${quoted(add.name)}`] : []),
202
- ...(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
+ : []),
203
225
  ],
204
226
  });
205
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
+ };
206
254
  return {
207
255
  preview: {
208
256
  path: env.claudeJson,
@@ -215,37 +263,13 @@ async function planClaudeJson(env, edit, run) {
215
263
  errors,
216
264
  // Shown with secrets hidden; the command that runs has the values you typed.
217
265
  commands: [
218
- ...(remove ? [`${claude} mcp remove --scope ${edit.scope} -- ${quoted(remove.name)}`] : []),
219
- ...(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
+ : []),
220
270
  ],
221
271
  },
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
- },
272
+ save,
249
273
  };
250
274
  }
251
275
  function plan(env, edit, run) {
@@ -255,13 +279,6 @@ export async function previewMcpEdit(env, edit, run) {
255
279
  return (await plan(env, edit, run)).preview;
256
280
  }
257
281
  /** 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
- });
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.");
267
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>;
@@ -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 {};