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.
- package/CHANGELOG.md +75 -0
- package/README.md +84 -67
- package/dist/server/agent-edit.js +77 -0
- package/dist/server/backups.js +129 -52
- package/dist/server/cli.js +21 -8
- package/dist/server/mcp-edit.js +263 -0
- package/dist/server/mutations.js +8 -3
- package/dist/server/scan/compare.js +34 -0
- package/dist/server/scan/context.js +6 -5
- package/dist/server/scan/env.js +8 -3
- package/dist/server/scan/files.js +118 -30
- package/dist/server/scan/index.js +29 -15
- package/dist/server/scan/instructions.js +11 -7
- package/dist/server/scan/inventory.js +134 -42
- package/dist/server/scan/mcp.js +88 -30
- package/dist/server/scan/memory.js +8 -9
- package/dist/server/scan/path-test.js +32 -10
- package/dist/server/scan/permissions.js +22 -6
- package/dist/server/scan/plugins.js +18 -11
- package/dist/server/scan/projects.js +7 -6
- package/dist/server/scan/sandbox.js +161 -0
- package/dist/server/scan/settings.js +33 -20
- package/dist/server/scan/tools.js +7 -3
- package/dist/server/scan/usage.js +119 -46
- package/dist/server/schema.js +15 -0
- package/dist/server/secrets.js +63 -0
- package/dist/server/server.js +222 -128
- package/dist/server/settings-edit.js +90 -21
- package/dist/server/watch.js +52 -10
- package/dist/server/write.js +58 -20
- package/dist/shared/compare.js +1 -0
- package/dist/shared/hook-events.json +35 -0
- package/dist/shared/mcp-server.schema.json +25 -0
- package/dist/shared/memory.js +4 -0
- package/dist/shared/scopes.js +3 -1
- package/dist/shared/types.js +1 -1
- package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
- package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
- package/dist/ui/assets/index-BRgGlpjd.css +1 -0
- package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- package/dist/ui/index.html +2 -2
- package/package.json +10 -4
- package/dist/ui/assets/index-BM4K7hSO.css +0 -1
- package/dist/ui/assets/index-CN3vkVjv.js +0 -45
package/dist/server/backups.js
CHANGED
|
@@ -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,
|
|
7
|
-
import {
|
|
8
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
170
|
-
const before =
|
|
171
|
-
const after =
|
|
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
|
|
241
|
+
...(index && index.missing.length > 0
|
|
242
|
+
? { indexLines: index.missing.map((line) => shownText(line) ?? line) }
|
|
243
|
+
: {}),
|
|
182
244
|
};
|
|
183
245
|
}
|
|
184
|
-
/**
|
|
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
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
await
|
|
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
|
|
297
|
+
if (created && (await removeExactly(target, revisionOf(saved))))
|
|
298
|
+
await forgetChange(env, created.id);
|
|
222
299
|
throw error;
|
|
223
300
|
}
|
|
224
301
|
return { target };
|
package/dist/server/cli.js
CHANGED
|
@@ -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
|
|
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>
|
|
14
|
-
--no-open
|
|
15
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/server/mutations.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|