ccdesk 0.0.0-stage → 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 (56) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -2
  4. package/dist/server/agent-edit.js +77 -0
  5. package/dist/server/backups.js +303 -0
  6. package/dist/server/cli.js +105 -0
  7. package/dist/server/mcp-edit.js +263 -0
  8. package/dist/server/mutations.js +193 -0
  9. package/dist/server/open.js +139 -0
  10. package/dist/server/scan/compare.js +34 -0
  11. package/dist/server/scan/context.js +140 -0
  12. package/dist/server/scan/env.js +28 -0
  13. package/dist/server/scan/files.js +478 -0
  14. package/dist/server/scan/index.js +280 -0
  15. package/dist/server/scan/instructions.js +289 -0
  16. package/dist/server/scan/inventory.js +452 -0
  17. package/dist/server/scan/mcp.js +313 -0
  18. package/dist/server/scan/memory.js +90 -0
  19. package/dist/server/scan/path-test.js +121 -0
  20. package/dist/server/scan/permissions.js +101 -0
  21. package/dist/server/scan/plugins.js +124 -0
  22. package/dist/server/scan/projects.js +101 -0
  23. package/dist/server/scan/sandbox.js +161 -0
  24. package/dist/server/scan/settings.js +330 -0
  25. package/dist/server/scan/tools.js +111 -0
  26. package/dist/server/scan/usage.js +514 -0
  27. package/dist/server/schema.js +15 -0
  28. package/dist/server/secrets.js +63 -0
  29. package/dist/server/server.js +352 -0
  30. package/dist/server/settings-edit.js +221 -0
  31. package/dist/server/watch.js +214 -0
  32. package/dist/server/write.js +233 -0
  33. package/dist/shared/builtin-tools.json +235 -0
  34. package/dist/shared/compare.js +1 -0
  35. package/dist/shared/hook-events.json +35 -0
  36. package/dist/shared/mcp-server.schema.json +25 -0
  37. package/dist/shared/memory.js +4 -0
  38. package/dist/shared/scopes.js +20 -0
  39. package/dist/shared/settings-scopes.json +208 -0
  40. package/dist/shared/settings.schema.json +4623 -0
  41. package/dist/shared/types.js +2 -0
  42. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  43. package/dist/ui/assets/hanken-grotesk-latin-ext-wght-normal-Dg-wlmqe.woff2 +0 -0
  44. package/dist/ui/assets/hanken-grotesk-latin-wght-normal-CaVRRdDk.woff2 +0 -0
  45. package/dist/ui/assets/hanken-grotesk-vietnamese-wght-normal-CHiFlh_0.woff2 +0 -0
  46. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  47. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  48. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  49. package/dist/ui/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  50. package/dist/ui/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  51. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  52. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  53. package/dist/ui/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  54. package/dist/ui/index.html +15 -0
  55. package/dist/ui/logo.png +0 -0
  56. package/package.json +86 -4
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
3
+ import { randomBytes } from "node:crypto";
4
+ import { statSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+ import { scanEnvFromProcess } from "./scan/env.js";
8
+ import { createCcdeskServer } from "./server.js";
9
+ const HELP = `ccdesk: see and change what Claude Code loads on this machine
10
+
11
+ Usage: npx ccdesk [options]
12
+
13
+ Options:
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
19
+
20
+ ccdesk reads ~/.claude (or --config-dir, or $CLAUDE_CONFIG_DIR) and opens on the directory you
21
+ run it from.
22
+ It listens on 127.0.0.1 only.`;
23
+ const DEV_PORT = 4319;
24
+ const DEV_TOKEN = "dev";
25
+ function options() {
26
+ try {
27
+ return parseArgs({
28
+ options: {
29
+ port: { type: "string" },
30
+ "config-dir": { type: "string" },
31
+ open: { type: "boolean", default: true },
32
+ dev: { type: "boolean", default: false },
33
+ help: { type: "boolean", short: "h", default: false },
34
+ },
35
+ allowNegative: true,
36
+ }).values;
37
+ }
38
+ catch (error) {
39
+ console.error(`ccdesk: ${error.message}\n\n${HELP}`);
40
+ process.exit(2);
41
+ }
42
+ }
43
+ function main() {
44
+ const values = options();
45
+ if (values.help) {
46
+ console.log(HELP);
47
+ return;
48
+ }
49
+ const dev = values.dev;
50
+ const port = values.port ? Number(values.port) : dev ? DEV_PORT : 0;
51
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
52
+ console.error(`ccdesk: invalid --port ${values.port}`);
53
+ process.exitCode = 2;
54
+ return;
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
+ }
62
+ const token = dev ? DEV_TOKEN : randomBytes(18).toString("base64url");
63
+ const server = createCcdeskServer({
64
+ env: scanEnvFromProcess(configDir),
65
+ launchDir: process.cwd(),
66
+ token,
67
+ uiDir: dev ? null : fileURLToPath(new URL("../ui", import.meta.url)),
68
+ });
69
+ server.on("error", (error) => {
70
+ console.error(error.code === "EADDRINUSE"
71
+ ? `ccdesk: port ${port} is already in use`
72
+ : `ccdesk: ${error.message}`);
73
+ process.exitCode = 1;
74
+ });
75
+ server.listen(port, "127.0.0.1", () => {
76
+ const address = server.address();
77
+ const actual = typeof address === "object" && address ? address.port : port;
78
+ if (dev) {
79
+ console.log(`ccdesk API on http://127.0.0.1:${actual} (dev token "${DEV_TOKEN}"); run \`pnpm dev:ui\` for the UI`);
80
+ return;
81
+ }
82
+ const url = `http://127.0.0.1:${actual}/#token=${token}`;
83
+ console.log(`ccdesk is running at ${url}\nPress Ctrl+C to stop.`);
84
+ if (values.open)
85
+ openBrowser(url);
86
+ });
87
+ const stop = () => {
88
+ // An open browser tab keeps a connection alive, which would hold close() open.
89
+ server.close(() => process.exit(0));
90
+ server.closeAllConnections();
91
+ };
92
+ process.once("SIGINT", stop);
93
+ process.once("SIGTERM", stop);
94
+ }
95
+ function openBrowser(url) {
96
+ const [command, args] = process.platform === "darwin"
97
+ ? ["open", [url]]
98
+ : process.platform === "win32"
99
+ ? ["cmd", ["/c", "start", "", url]]
100
+ : ["xdg-open", [url]];
101
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
102
+ child.on("error", () => console.log("Could not open a browser; open the URL above."));
103
+ child.unref();
104
+ }
105
+ main();
@@ -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
+ }
@@ -0,0 +1,193 @@
1
+ import { access, constants, readdir, realpath, stat } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import {} from "../shared/types.js";
4
+ import {} from "./scan/env.js";
5
+ import { isWithin, parseFrontmatter, revisionOf, shownText, stringField } from "./scan/files.js";
6
+ import { deadLinks, indexMentions } from "./scan/memory.js";
7
+ import { RefusedError, readIfExists, recordChange, removeExactly, serially, writeAtomic, } from "./write.js";
8
+ async function projectsRoot(env) {
9
+ return realpath(join(env.claudeDir, "projects")).catch(() => {
10
+ throw new RefusedError("There is no auto memory folder.");
11
+ });
12
+ }
13
+ /** A real `<config>/projects/<store>/memory` folder, after following links. */
14
+ export async function assertMemoryDir(env, dir) {
15
+ const real = await realpath(dir).catch(() => {
16
+ throw new RefusedError("That memory folder no longer exists.");
17
+ });
18
+ const root = await projectsRoot(env);
19
+ if (real === root || !isWithin(root, real) || basename(real) !== "memory")
20
+ throw new RefusedError("ccdesk only changes auto memory under ~/.claude/projects.");
21
+ return real;
22
+ }
23
+ /**
24
+ * A memory folder's MEMORY.md. When it is a link, the file it leads to must be in the same folder,
25
+ * so changing the index can never rewrite a file elsewhere.
26
+ */
27
+ export async function indexPathIn(store) {
28
+ const path = join(store, "MEMORY.md");
29
+ const real = await realpath(path).catch(() => path);
30
+ if (dirname(real) !== store)
31
+ throw new RefusedError("This MEMORY.md links outside its memory folder; ccdesk leaves it alone.");
32
+ return path;
33
+ }
34
+ /** A topic file inside a memory folder: the only kind of file ccdesk deletes. */
35
+ async function assertMemoryFile(env, path) {
36
+ const real = await realpath(path).catch(() => {
37
+ throw new RefusedError("That memory file no longer exists.");
38
+ });
39
+ await assertMemoryDir(env, dirname(real));
40
+ if (!real.endsWith(".md") || basename(real) === "MEMORY.md")
41
+ throw new RefusedError("MEMORY.md is the index Claude maintains; delete individual memories instead.");
42
+ if (!(await stat(real)).isFile())
43
+ throw new RefusedError("Not a file.");
44
+ return real;
45
+ }
46
+ /** One fingerprint for two files, so a change can require both as they were reviewed. */
47
+ export function revisionOfBoth(first, second) {
48
+ return `${revisionOf(first)}+${revisionOf(second)}`;
49
+ }
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
+ }
60
+ /** MEMORY.md's lines without the empty ones a trailing newline leaves. */
61
+ function indexLines(index) {
62
+ const lines = (index ?? "").split("\n");
63
+ while (lines.at(-1) === "")
64
+ lines.pop();
65
+ return lines;
66
+ }
67
+ /** Deletes a memory with the MEMORY.md lines that point at it. */
68
+ async function planDelete(env, path) {
69
+ const real = await assertMemoryFile(env, path);
70
+ const text = await readIfExists(env, real);
71
+ const indexPath = await indexPathIn(dirname(real));
72
+ const index = await readIfExists(env, indexPath);
73
+ const removedIndexLines = [];
74
+ const kept = (index ?? "").split("\n").filter((line) => {
75
+ if (!indexMentions(line, basename(real)))
76
+ return true;
77
+ removedIndexLines.push(line);
78
+ return false;
79
+ });
80
+ const next = removedIndexLines.length > 0 ? kept.join("\n") : index;
81
+ const revision = revisionOfBoth(text, index);
82
+ const save = async () => {
83
+ // A folder that refuses the delete would leave MEMORY.md changed with the memory still there.
84
+ await access(dirname(real), constants.W_OK);
85
+ // Nothing of the delete is left when the index is as reviewed and the memory is still there,
86
+ // even if Claude rewrote the memory meanwhile.
87
+ const unchanged = async () => revisionOf(await readIfExists(env, indexPath)) === revisionOf(index) &&
88
+ (await readIfExists(env, real)) !== null;
89
+ return recordChange(env, { kind: "memory-delete", target: real, before: text, removedIndexLines, unchanged }, async () => {
90
+ // The index goes first: if it changed meanwhile, nothing has been removed yet.
91
+ if (next !== index)
92
+ await writeAtomic(env, indexPath, next ?? "", revisionOf(index));
93
+ let removed = false;
94
+ try {
95
+ removed = await removeExactly(real, revisionOf(text));
96
+ }
97
+ finally {
98
+ // Claude may have rewritten the memory while the index was saved; then the index goes back.
99
+ if (!removed && next !== index)
100
+ await writeAtomic(env, indexPath, index ?? "", revisionOf(next)).catch((error) => {
101
+ // The index was already changed, so this is not a "nothing written" refusal.
102
+ throw new RefusedError(`MEMORY.md changed again, so ccdesk could not put its line back: ${error instanceof Error ? error.message : error}`);
103
+ });
104
+ }
105
+ if (!removed)
106
+ throw reviewAgain();
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
+ };
113
+ });
114
+ };
115
+ return {
116
+ preview: {
117
+ files: [
118
+ { path: real, before: text, after: null },
119
+ ...(next !== index ? [{ path: indexPath, before: index, after: next }] : []),
120
+ ],
121
+ revision,
122
+ },
123
+ save,
124
+ };
125
+ }
126
+ /** Rewrites MEMORY.md through `edit`, recorded in Recent changes. */
127
+ async function planIndex(env, store, edit,
128
+ /** Other text the new index was worked out from, bound into the revision. */
129
+ source) {
130
+ const path = await indexPathIn(store);
131
+ const before = await readIfExists(env, path);
132
+ const after = `${edit(indexLines(before)).join("\n")}\n`;
133
+ if (after === before)
134
+ throw new RefusedError("MEMORY.md already says that.");
135
+ const save = () => recordChange(env, { kind: "memory-index", target: path, before }, async () => {
136
+ await writeAtomic(env, path, after, revisionOf(before));
137
+ return { target: path, revision: revisionOf(after) };
138
+ });
139
+ return {
140
+ preview: { files: [{ path, before, after }], revision: revisionOfBoth(before, source) },
141
+ save,
142
+ };
143
+ }
144
+ /** One line of text: frontmatter can hold multi-line strings, an index line cannot. */
145
+ function oneLine(text) {
146
+ return text.replace(/\s+/g, " ").trim();
147
+ }
148
+ /** Adds a memory that MEMORY.md does not mention, in the `- [name](file) — description` form. */
149
+ async function planAdd(env, memoryPath) {
150
+ const real = await assertMemoryFile(env, memoryPath);
151
+ const text = await readIfExists(env, real);
152
+ const { data } = parseFrontmatter(text ?? "");
153
+ const name = oneLine(stringField(data, "name")) || basename(real, ".md");
154
+ const description = oneLine(stringField(data, "description"));
155
+ const line = `- [${name}](${basename(real)})${description ? ` — ${description}` : ""}`;
156
+ return planIndex(env, dirname(real), (lines) => [...lines, line], text);
157
+ }
158
+ /** Removes every MEMORY.md line that points at a memory file that no longer exists. */
159
+ async function planRemoveDead(env, indexPath) {
160
+ if (basename(indexPath) !== "MEMORY.md")
161
+ throw new RefusedError("Only a memory folder's MEMORY.md can be changed.");
162
+ const store = await assertMemoryDir(env, dirname(indexPath));
163
+ const files = (await readdir(store)).sort();
164
+ return planIndex(env, store, (lines) => lines.filter((line) => deadLinks(line, files).length === 0), files.join("\n"));
165
+ }
166
+ const PLANS = {
167
+ delete: planDelete,
168
+ add: planAdd,
169
+ "remove-dead": planRemoveDead,
170
+ };
171
+ export function isMemoryAction(value) {
172
+ return Object.hasOwn(PLANS, value);
173
+ }
174
+ /** What a memory action would change, for review. */
175
+ export async function previewMemoryChange(env, action, path) {
176
+ // Shown like any other text: a memory folder could hold a JSON file with secrets.
177
+ const { files, revision } = (await PLANS[action](env, path)).preview;
178
+ return {
179
+ files: files.map((f) => ({
180
+ path: f.path,
181
+ before: shownText(f.before),
182
+ after: shownText(f.after),
183
+ })),
184
+ revision,
185
+ };
186
+ }
187
+ /**
188
+ * Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
189
+ * action runs only while everything its review was worked out from is unchanged.
190
+ */
191
+ export function changeMemory(env, action, path, expectedRevision) {
192
+ return saveReviewed(() => PLANS[action](env, path), expectedRevision);
193
+ }