ccdesk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  39. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. package/dist/ui/assets/index-CN3vkVjv.js +0 -45
@@ -3,9 +3,18 @@ import { basename, dirname, join } from "node:path";
3
3
  import {} from "../shared/types.js";
4
4
  import { assertMemoryDir, indexPathIn, revisionOfBoth } from "./mutations.js";
5
5
  import {} from "./scan/env.js";
6
- import { isRecord, isWithin, redactSecrets, revisionOf, shownText, shownUrl, } from "./scan/files.js";
7
- import { backupRoot, MANIFEST, RefusedError, readIfExists, recordChange, removeExactly, SNAPSHOT, serially, writeAtomic, } from "./write.js";
8
- const KINDS = new Set(["settings", "memory-index", "memory-delete"]);
6
+ import { isRecord, isWithin, revisionOf, shownText } from "./scan/files.js";
7
+ import { hiddenValues, shown } from "./secrets.js";
8
+ import { backupRoot, forgetChange, MANIFEST, RefusedError, readIfExists, recordChange, removeExactly, SHOWN_AFTER, SNAPSHOT, StaleError, serially, writeAtomic, } from "./write.js";
9
+ const KINDS = new Set([
10
+ "settings",
11
+ "mcp",
12
+ "agent",
13
+ "memory-index",
14
+ "memory-delete",
15
+ "memory-restore",
16
+ "mcp-claude",
17
+ ]);
9
18
  function strings(value) {
10
19
  return Array.isArray(value) && value.every((v) => typeof v === "string");
11
20
  }
@@ -31,7 +40,8 @@ async function readManifest(env, id) {
31
40
  typeof data.createdAt !== "string" ||
32
41
  typeof data.before !== "string" ||
33
42
  typeof data.after !== "string" ||
34
- (data.removedIndexLines !== undefined && !strings(data.removedIndexLines)))
43
+ (data.removedIndexLines !== undefined && !strings(data.removedIndexLines)) ||
44
+ (data.undoCommands !== undefined && !strings(data.undoCommands)))
35
45
  return null;
36
46
  return data;
37
47
  }
@@ -65,46 +75,28 @@ function isSettingsFile(env, path) {
65
75
  return (["settings.json", "settings.local.json"].includes(basename(path)) &&
66
76
  basename(dirname(path)) === ".claude");
67
77
  }
68
- /**
69
- * Every value ccdesk shows hidden and never changes in a settings file: env and headers objects,
70
- * and URLs with a user, password or query.
71
- */
72
- function hiddenValues(text) {
73
- const found = [];
74
- const walk = (value) => {
75
- if (Array.isArray(value))
76
- value.forEach(walk);
77
- if (!isRecord(value))
78
- return;
79
- for (const [key, inner] of Object.entries(value)) {
80
- if ((key === "env" || key === "headers") && isRecord(inner))
81
- found.push(inner);
82
- else if (key === "url" && typeof inner === "string" && shownUrl(inner) !== inner)
83
- found.push(inner);
84
- else
85
- walk(inner);
86
- }
87
- };
88
- try {
89
- walk(text === null ? null : JSON.parse(text));
90
- }
91
- catch {
92
- return text ?? "";
93
- }
94
- return JSON.stringify(found);
78
+ /** A `.md` file in your agents folder or a project's `.claude/agents`, or a subfolder of one. */
79
+ function isAgentFile(env, path) {
80
+ if (!path.endsWith(".md"))
81
+ return false;
82
+ if (isWithin(join(env.claudeDir, "agents"), path))
83
+ return true;
84
+ const parts = path.split(/[/\\]/);
85
+ return parts.some((part, i) => part === ".claude" && parts[i + 1] === "agents" && i + 2 < parts.length);
95
86
  }
96
87
  /** Settings are shown parsed with env and header values hidden, as everywhere else in ccdesk. */
97
88
  function shownSettings(text) {
98
89
  if (text === null)
99
90
  return null;
100
91
  try {
101
- return `${JSON.stringify(redactSecrets(JSON.parse(text)), null, 2)}\n`;
92
+ return shown(JSON.parse(text));
102
93
  }
103
94
  catch {
104
95
  return "(not valid JSON; open the file to see it)\n";
105
96
  }
106
97
  }
107
- async function planRestore(env, id) {
98
+ /** A backup's manifest and its folder, which must be inside the backup folder. */
99
+ async function backupAt(env, id) {
108
100
  const manifest = await readManifest(env, id);
109
101
  if (!manifest)
110
102
  throw new RefusedError("That backup is gone or was not made by ccdesk.");
@@ -112,18 +104,51 @@ async function planRestore(env, id) {
112
104
  const dir = await realpath(join(root, id));
113
105
  if (!isWithin(root, dir) || dir === root)
114
106
  throw new RefusedError("Not a ccdesk backup.");
107
+ return { manifest, dir };
108
+ }
109
+ const MADE_BY_CLAUDE = "Claude Code made this change through claude mcp, so ccdesk cannot undo it. Run the commands below to put it back.";
110
+ /**
111
+ * What a change made through `claude mcp` did, as ccdesk recorded it: the servers before and
112
+ * after, values hidden, and the commands that put them back.
113
+ */
114
+ async function claudeMcpPreview(id, manifest, dir) {
115
+ const read = async (name, revision) => {
116
+ const text = await readFile(join(dir, name), "utf8").catch(() => null);
117
+ if (text === null || revisionOf(text) !== revision)
118
+ throw new RefusedError("The record of this change is missing or was changed.");
119
+ return text;
120
+ };
121
+ return {
122
+ id,
123
+ kind: manifest.kind,
124
+ target: manifest.target,
125
+ before: await read(SNAPSHOT, manifest.before),
126
+ after: await read(SHOWN_AFTER, manifest.after),
127
+ revision: manifest.after,
128
+ blocked: MADE_BY_CLAUDE,
129
+ undoCommands: manifest.undoCommands ?? [],
130
+ };
131
+ }
132
+ async function planRestore(env, id) {
133
+ const { manifest, dir } = await backupAt(env, id);
134
+ if (manifest.kind === "mcp-claude")
135
+ throw new RefusedError(MADE_BY_CLAUDE);
115
136
  let target = manifest.target;
116
137
  if (manifest.kind === "settings" && !isSettingsFile(env, target))
117
138
  throw new RefusedError("This backup does not point at a settings file.");
139
+ if (manifest.kind === "mcp" && basename(target) !== ".mcp.json")
140
+ throw new RefusedError("This backup does not point at a .mcp.json.");
141
+ if (manifest.kind === "agent" && !isAgentFile(env, target))
142
+ throw new RefusedError("This backup does not point at a subagent file.");
118
143
  if (manifest.kind === "memory-index" && basename(target) !== "MEMORY.md")
119
144
  throw new RefusedError("This backup does not point at a MEMORY.md.");
120
- if (manifest.kind !== "settings") {
145
+ if (manifest.kind !== "settings" && manifest.kind !== "mcp" && manifest.kind !== "agent") {
121
146
  const store = await assertMemoryDir(env, dirname(target));
122
147
  // The same confinement as any other MEMORY.md change: a link out of the folder is refused.
123
148
  target =
124
149
  manifest.kind === "memory-index" ? await indexPathIn(store) : join(store, basename(target));
125
150
  // A deleted memory comes back only as a topic file, as ccdesk deletes only those.
126
- if (manifest.kind === "memory-delete" &&
151
+ if (manifest.kind !== "memory-index" &&
127
152
  (!target.endsWith(".md") || basename(target) === "MEMORY.md"))
128
153
  throw new RefusedError("This backup does not point at a memory file.");
129
154
  }
@@ -140,6 +165,7 @@ async function planRestore(env, id) {
140
165
  manifest: { ...manifest, target },
141
166
  current: await readIfExists(env, target),
142
167
  saved,
168
+ linked: (await lstat(target).catch(() => null))?.isSymbolicLink() ?? false,
143
169
  };
144
170
  if (manifest.kind === "memory-delete") {
145
171
  const path = await indexPathIn(dirname(target));
@@ -153,22 +179,55 @@ async function planRestore(env, id) {
153
179
  function planRevision({ current, index }) {
154
180
  return index ? revisionOfBoth(current, index.text) : revisionOf(current);
155
181
  }
156
- function blockedReason({ manifest, current, saved }) {
157
- if (saved === null)
158
- return "ccdesk created this file, so there is no earlier version to put back. Change it in place instead.";
159
- if (manifest.kind === "settings" && hiddenValues(current) !== hiddenValues(saved))
160
- return "Hidden values in this file (env, headers or a URL's key) changed since the backup. ccdesk does not show or change those, so restore this one by hand.";
182
+ /**
183
+ * A delete that stopped halfway, after Claude rewrote the memory: the memory was never removed, so
184
+ * only the MEMORY.md lines the delete took out come back, and the memory stays as it is now.
185
+ */
186
+ function onlyIndexLines({ manifest, current, saved, index }) {
187
+ return (manifest.kind === "memory-delete" &&
188
+ manifest.after === "partial" &&
189
+ current !== null &&
190
+ current !== saved &&
191
+ (index?.missing.length ?? 0) > 0);
192
+ }
193
+ function blockedReason(plan) {
194
+ const { manifest, current, saved } = plan;
195
+ if (saved === null && manifest.kind === "memory-restore")
196
+ return "This put a deleted memory back. To undo it, delete the memory again.";
197
+ // Undoing a change that created its file removes the file, but only the one ccdesk wrote.
198
+ if (saved === null) {
199
+ if (manifest.after === "pending" || manifest.after === "partial")
200
+ return "This change stopped halfway, so ccdesk cannot tell what it wrote. Remove the file by hand if you need to.";
201
+ if (current === null)
202
+ return "The file ccdesk created is gone already.";
203
+ if (plan.linked || revisionOf(current) !== manifest.after)
204
+ return "The file changed after ccdesk created it, so ccdesk does not remove it. Change it in place instead.";
205
+ return undefined;
206
+ }
207
+ if (onlyIndexLines(plan))
208
+ return undefined;
209
+ // A file broken since the backup has no values ccdesk could keep; the backup is the way out of it,
210
+ // and the broken file is itself backed up before the restore replaces it.
211
+ const now = hiddenValues(current);
212
+ if ((manifest.kind === "settings" || manifest.kind === "mcp") &&
213
+ current !== null &&
214
+ now !== null &&
215
+ now !== hiddenValues(saved))
216
+ return "Hidden values in this file (env, headers, a URL's key or a key in args) changed since the backup. ccdesk does not show or change those, so restore this one by hand.";
161
217
  // A retry after a restore that wrote the memory but not MEMORY.md finds the memory already back.
162
218
  if (manifest.kind === "memory-delete" && current !== null && current !== saved)
163
219
  return "A memory with this file name exists again; delete or rename it first.";
164
220
  return undefined;
165
221
  }
166
222
  export async function previewRestore(env, id) {
223
+ const { manifest: recorded, dir } = await backupAt(env, id);
224
+ if (recorded.kind === "mcp-claude")
225
+ return claudeMcpPreview(id, recorded, dir);
167
226
  const plan = await planRestore(env, id);
168
227
  const { manifest, current, saved, index } = plan;
169
- const shown = manifest.kind === "settings" ? shownSettings : shownText;
170
- const before = shown(current);
171
- const after = shown(saved) ?? "";
228
+ const show = manifest.kind === "settings" || manifest.kind === "mcp" ? shownSettings : shownText;
229
+ const before = show(current);
230
+ const after = (onlyIndexLines(plan) ? before : show(saved)) ?? "";
172
231
  const blocked = blockedReason(plan);
173
232
  return {
174
233
  id,
@@ -177,21 +236,35 @@ export async function previewRestore(env, id) {
177
236
  before,
178
237
  after,
179
238
  revision: planRevision(plan),
239
+ ...(saved === null ? { removes: true } : {}),
180
240
  ...(blocked ? { blocked } : {}),
181
- ...(index && index.missing.length > 0 ? { indexLines: index.missing } : {}),
241
+ ...(index && index.missing.length > 0
242
+ ? { indexLines: index.missing.map((line) => shownText(line) ?? line) }
243
+ : {}),
182
244
  };
183
245
  }
184
- /** Puts a backed-up file back, if the file is still as it was when the restore was reviewed. */
246
+ /**
247
+ * Puts a backed-up file back, or removes a file ccdesk created, if the file is still as it was
248
+ * when the restore was reviewed. A removed file is backed up first, so it can be put back too.
249
+ */
185
250
  export function restoreBackup(env, id, expectedRevision) {
186
251
  return serially(async () => {
187
252
  const plan = await planRestore(env, id);
188
253
  const { manifest, current, saved, index } = plan;
189
254
  const blocked = blockedReason(plan);
190
- if (blocked || saved === null)
255
+ if (blocked)
191
256
  throw new RefusedError(blocked);
192
257
  if (planRevision(plan) !== expectedRevision)
193
258
  throw new RefusedError("The file changed since you reviewed this restore; review it again.");
194
259
  const { target } = manifest;
260
+ if (saved === null) {
261
+ await recordChange(env, { kind: manifest.kind, target, before: current }, async () => {
262
+ if (!(await removeExactly(target, revisionOf(current))))
263
+ throw new StaleError(`${target} changed while ccdesk was removing it; nothing was removed. Review it again.`);
264
+ return { revision: revisionOf(null) };
265
+ });
266
+ return { target };
267
+ }
195
268
  if (manifest.kind !== "memory-delete") {
196
269
  await recordChange(env, { kind: manifest.kind, target, before: current }, async () => {
197
270
  await writeAtomic(env, target, saved, revisionOf(current));
@@ -200,10 +273,14 @@ export function restoreBackup(env, id, expectedRevision) {
200
273
  return { target };
201
274
  }
202
275
  // A memory and its MEMORY.md lines come back together: when the index cannot be written, the
203
- // memory just put back is removed again, so a refused restore leaves both as they were.
204
- const created = current === null;
205
- if (created)
206
- await writeAtomic(env, target, saved, revisionOf(null));
276
+ // memory just put back is removed again, with its record, so a refused restore leaves both as
277
+ // they were.
278
+ const created = current === null
279
+ ? await recordChange(env, { kind: "memory-restore", target, before: null }, async () => {
280
+ await writeAtomic(env, target, saved, revisionOf(null));
281
+ return { revision: revisionOf(saved) };
282
+ })
283
+ : null;
207
284
  try {
208
285
  if (index && index.missing.length > 0) {
209
286
  const lines = (index.text ?? "").split("\n");
@@ -217,8 +294,8 @@ export function restoreBackup(env, id, expectedRevision) {
217
294
  }
218
295
  }
219
296
  catch (error) {
220
- if (created)
221
- await removeExactly(target, revisionOf(saved));
297
+ if (created && (await removeExactly(target, revisionOf(saved))))
298
+ await forgetChange(env, created.id);
222
299
  throw error;
223
300
  }
224
301
  return { target };
@@ -1,20 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomBytes } from "node:crypto";
4
+ import { statSync } from "node:fs";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { parseArgs } from "node:util";
6
7
  import { scanEnvFromProcess } from "./scan/env.js";
7
8
  import { createCcdeskServer } from "./server.js";
8
- const HELP = `ccdesk: see and manage what shapes Claude Code on this machine
9
+ const HELP = `ccdesk: see and change what Claude Code loads on this machine
9
10
 
10
11
  Usage: npx ccdesk [options]
11
12
 
12
13
  Options:
13
- --port <n> Port to listen on (default: a free port)
14
- --no-open Print the URL instead of opening a browser
15
- -h, --help Show this help
14
+ --port <n> Port to listen on (default: a free port)
15
+ --no-open Print the URL instead of opening a browser
16
+ --config-dir <path> Claude folder to read, as CLAUDE_CONFIG_DIR sets it for Claude Code
17
+ (wins over $CLAUDE_CONFIG_DIR)
18
+ -h, --help Show this help
16
19
 
17
- ccdesk reads ~/.claude (or $CLAUDE_CONFIG_DIR) and opens on the directory you run it from.
20
+ ccdesk reads ~/.claude (or --config-dir, or $CLAUDE_CONFIG_DIR) and opens on the directory you
21
+ run it from.
18
22
  It listens on 127.0.0.1 only.`;
19
23
  const DEV_PORT = 4319;
20
24
  const DEV_TOKEN = "dev";
@@ -23,6 +27,7 @@ function options() {
23
27
  return parseArgs({
24
28
  options: {
25
29
  port: { type: "string" },
30
+ "config-dir": { type: "string" },
26
31
  open: { type: "boolean", default: true },
27
32
  dev: { type: "boolean", default: false },
28
33
  help: { type: "boolean", short: "h", default: false },
@@ -48,9 +53,15 @@ function main() {
48
53
  process.exitCode = 2;
49
54
  return;
50
55
  }
56
+ const configDir = values["config-dir"];
57
+ if (configDir !== undefined && !statSync(configDir, { throwIfNoEntry: false })?.isDirectory()) {
58
+ console.error(`ccdesk: --config-dir ${configDir} is not a folder`);
59
+ process.exitCode = 2;
60
+ return;
61
+ }
51
62
  const token = dev ? DEV_TOKEN : randomBytes(18).toString("base64url");
52
63
  const server = createCcdeskServer({
53
- env: scanEnvFromProcess(),
64
+ env: scanEnvFromProcess(configDir),
54
65
  launchDir: process.cwd(),
55
66
  token,
56
67
  uiDir: dev ? null : fileURLToPath(new URL("../ui", import.meta.url)),
@@ -73,11 +84,13 @@ function main() {
73
84
  if (values.open)
74
85
  openBrowser(url);
75
86
  });
76
- process.once("SIGINT", () => {
87
+ const stop = () => {
77
88
  // An open browser tab keeps a connection alive, which would hold close() open.
78
89
  server.close(() => process.exit(0));
79
90
  server.closeAllConnections();
80
- });
91
+ };
92
+ process.once("SIGINT", stop);
93
+ process.once("SIGTERM", stop);
81
94
  }
82
95
  function openBrowser(url) {
83
96
  const [command, args] = process.platform === "darwin"
@@ -0,0 +1,263 @@
1
+ import { execFile } from "node:child_process";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { delimiter, join, resolve } from "node:path";
4
+ import {} from "../shared/types.js";
5
+ import {} from "./scan/env.js";
6
+ import { gitRoot, isRecord, jsonSyntaxError, mainWorktreeRoot, redactSecrets, revisionOf, } from "./scan/files.js";
7
+ 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
+ const exists = (path) => access(path).then(() => true, () => false);
11
+ /**
12
+ * The program to run for `claude`. On Windows, npm installs Claude Code as a claude.cmd that starts
13
+ * the package's claude.exe; Node cannot run a .cmd without a shell, which would read the server's
14
+ * JSON as shell syntax, so ccdesk runs that claude.exe itself. A claude.exe earlier on PATH, as the
15
+ * native installer puts there, comes first, as it would in a terminal.
16
+ */
17
+ export async function claudeProgram(path = process.env.PATH ?? "", platform = process.platform) {
18
+ if (platform !== "win32")
19
+ return "claude";
20
+ // Windows reads a PATH entry in quotes as the folder inside them.
21
+ for (const dir of path
22
+ .split(delimiter)
23
+ .map((d) => d.replace(/^"(.*)"$/, "$1"))
24
+ .filter(Boolean)) {
25
+ if (await exists(join(dir, "claude.exe")))
26
+ return join(dir, "claude.exe");
27
+ const fromNpm = join(dir, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe");
28
+ if ((await exists(join(dir, "claude.cmd"))) && (await exists(fromNpm)))
29
+ return fromNpm;
30
+ }
31
+ return "claude";
32
+ }
33
+ /** The secret values in a server definition passed to claude, which add-json takes as one JSON argument. */
34
+ function secretsIn(args) {
35
+ return args.flatMap((arg) => {
36
+ if (!arg.startsWith("{"))
37
+ return [];
38
+ try {
39
+ return secretTexts(JSON.parse(arg));
40
+ }
41
+ catch {
42
+ // Not JSON after all, so it holds no definition.
43
+ return [];
44
+ }
45
+ });
46
+ }
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);
62
+ });
63
+ });
64
+ };
65
+ /** Quotes a word for a POSIX shell, so a command shown to you can be pasted as it is. */
66
+ function quoted(word) {
67
+ return `'${word.replaceAll("'", "'\\''")}'`;
68
+ }
69
+ /**
70
+ * Checks what an edit asks for against the servers as they are now: the one to remove must still
71
+ * be as the scan showed it, and the one to add must not take another server's name.
72
+ */
73
+ function checked(servers, edit) {
74
+ const { remove, add } = edit;
75
+ if (remove) {
76
+ const now = servers[remove.name];
77
+ if (now === undefined ||
78
+ JSON.stringify(redactSecrets(now)) !== JSON.stringify(remove.definition))
79
+ throw new RefusedError(`${remove.name} changed since ccdesk read it; review it again.`);
80
+ // ccdesk never shows hidden values, so it cannot write them back either.
81
+ if (add && hasHiddenValues(now))
82
+ throw new RefusedError(`${remove.name} has values ccdesk does not show, in env, headers, its url or its args; edit it in the file, or remove it and add it again.`);
83
+ }
84
+ if (add) {
85
+ if (add.name.trim() === "" || /[\p{Cc}\p{Cf}\s]/u.test(add.name))
86
+ throw new RefusedError("A server name needs letters or digits and no spaces.");
87
+ if (add.name !== remove?.name && Object.hasOwn(servers, add.name))
88
+ throw new RefusedError(`There is already a server named ${add.name} here.`);
89
+ }
90
+ }
91
+ /** The servers after the edit, with a replaced server kept in its place. */
92
+ function edited(servers, edit) {
93
+ const { remove, add } = edit;
94
+ // Built with fromEntries: assigning a server named __proto__ would set the prototype instead.
95
+ const next = Object.entries(servers).flatMap(([name, config]) => {
96
+ if (name !== remove?.name)
97
+ return [[name, config]];
98
+ return add ? [[add.name, add.definition]] : [];
99
+ });
100
+ if (add && !remove)
101
+ next.push([add.name, add.definition]);
102
+ return Object.fromEntries(next);
103
+ }
104
+ /** A project server: ccdesk changes .mcp.json itself, backed up and listed in Recent changes. */
105
+ async function planProject(env, edit) {
106
+ const dir = resolve(edit.dir);
107
+ if (!(await stat(dir).catch(() => null))?.isDirectory())
108
+ throw new RefusedError(`${dir} is not a directory on this machine.`);
109
+ 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
+ }
124
+ const servers = isRecord(data.mcpServers) ? data.mcpServers : {};
125
+ checked(servers, edit);
126
+ const next = { ...data, mcpServers: edited(servers, edit) };
127
+ const text = `${JSON.stringify(next, null, 2)}\n`;
128
+ const errors = edit.add ? serverProblems(edit.add.definition) : [];
129
+ const before = raw === null ? null : shown(data);
130
+ return {
131
+ preview: {
132
+ path,
133
+ before,
134
+ after: errors.length > 0 ? (before ?? "") : shown(next),
135
+ revision: revisionOf(raw),
136
+ reformats: raw !== null && raw.trim() !== "" && raw !== `${JSON.stringify(data, null, 2)}\n`,
137
+ errors,
138
+ },
139
+ save: () => recordChange(env, { kind: "mcp", target: path, before: raw }, async () => {
140
+ await writeAtomic(env, path, text, revisionOf(raw));
141
+ return { target: path, revision: revisionOf(text) };
142
+ }),
143
+ };
144
+ }
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.
148
+ */
149
+ async function planClaudeJson(env, edit, run) {
150
+ const dir = resolve(edit.dir);
151
+ const raw = await readIfExists(env, env.claudeJson);
152
+ let data = {};
153
+ try {
154
+ const parsed = raw === null ? {} : JSON.parse(raw);
155
+ if (isRecord(parsed))
156
+ data = parsed;
157
+ }
158
+ catch (error) {
159
+ throw new RefusedError(`${env.claudeJson} is ${jsonSyntaxError(error)}; fix it by hand first.`);
160
+ }
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;
167
+ if (!(await stat(cwd).catch(() => null))?.isDirectory())
168
+ throw new RefusedError(`${cwd} is not a directory on this machine.`);
169
+ const holder = edit.scope === "user" ? data : projects[cwd];
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) => [
176
+ "mcp",
177
+ "add-json",
178
+ "--scope",
179
+ edit.scope,
180
+ "--",
181
+ name,
182
+ JSON.stringify(definition),
183
+ ];
184
+ const addCommand = (name, definition) => `claude mcp add-json --scope ${edit.scope} -- ${quoted(name)} ${quoted(JSON.stringify(definition))}`;
185
+ const before = Object.keys(servers).length > 0 ? shown(servers) : null;
186
+ // 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.
188
+ const record = () => {
189
+ const names = [remove?.name, add?.name].filter((n) => n !== undefined);
190
+ const touched = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => names.includes(name)));
191
+ const at = edit.scope === "local" ? `cd ${quoted(cwd)} && ` : "";
192
+ return recordClaudeMcpChange(env, {
193
+ target: env.claudeJson,
194
+ shownBefore: shown(touched(servers)),
195
+ shownAfter: shown(touched(edited(servers, edit))),
196
+ undoCommands: [
197
+ ...(add ? [`${at}claude mcp remove --scope ${edit.scope} -- ${quoted(add.name)}`] : []),
198
+ ...(remove ? [`${at}${addCommand(remove.name, redactSecrets(servers[remove.name]))}`] : []),
199
+ ],
200
+ });
201
+ };
202
+ return {
203
+ preview: {
204
+ path: env.claudeJson,
205
+ before,
206
+ after: errors.length > 0 ? (before ?? "") : shown(edited(servers, edit)),
207
+ // Of the servers as stored, so a changed hidden value, which the page never sees,
208
+ // still makes a reviewed edit stale.
209
+ revision: revisionOf(JSON.stringify(servers)),
210
+ reformats: false,
211
+ errors,
212
+ // Shown with secrets hidden; the command that runs has the values you typed.
213
+ commands: [
214
+ ...(remove ? [`claude mcp remove --scope ${edit.scope} -- ${quoted(remove.name)}`] : []),
215
+ ...(add ? [addCommand(add.name, redactSecrets(add.definition))] : []),
216
+ ],
217
+ },
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
+ },
245
+ };
246
+ }
247
+ function plan(env, edit, run) {
248
+ return edit.scope === "project" ? planProject(env, edit) : planClaudeJson(env, edit, run);
249
+ }
250
+ export async function previewMcpEdit(env, edit, run) {
251
+ return (await plan(env, edit, run)).preview;
252
+ }
253
+ /** 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
+ });
263
+ }
@@ -3,7 +3,7 @@ import { basename, dirname, join } from "node:path";
3
3
  import {} from "../shared/types.js";
4
4
  import {} from "./scan/env.js";
5
5
  import { isWithin, parseFrontmatter, revisionOf, shownText, stringField } from "./scan/files.js";
6
- import { deadLinks } from "./scan/memory.js";
6
+ import { deadLinks, indexMentions } from "./scan/memory.js";
7
7
  import { RefusedError, readIfExists, recordChange, removeExactly, serially, writeAtomic, } from "./write.js";
8
8
  async function projectsRoot(env) {
9
9
  return realpath(join(env.claudeDir, "projects")).catch(() => {
@@ -72,7 +72,7 @@ async function planDelete(env, path) {
72
72
  const index = await readIfExists(env, indexPath);
73
73
  const removedIndexLines = [];
74
74
  const kept = (index ?? "").split("\n").filter((line) => {
75
- if (!line.includes(`(${basename(real)})`))
75
+ if (!indexMentions(line, basename(real)))
76
76
  return true;
77
77
  removedIndexLines.push(line);
78
78
  return false;
@@ -104,7 +104,12 @@ async function planDelete(env, path) {
104
104
  }
105
105
  if (!removed)
106
106
  throw reviewAgain();
107
- return { target: real, revision: revisionOfBoth(null, next), removedIndexLines };
107
+ // The lines are kept as they were in the backup; the page gets them as ccdesk shows text.
108
+ return {
109
+ target: real,
110
+ revision: revisionOfBoth(null, next),
111
+ removedIndexLines: removedIndexLines.map((line) => shownText(line) ?? line),
112
+ };
108
113
  });
109
114
  };
110
115
  return {