ccdesk 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +7 -4
- package/dist/core/agent-edit.d.ts +6 -0
- package/dist/{server → core}/agent-edit.js +15 -21
- package/dist/core/backups.d.ts +19 -0
- package/dist/{server → core}/backups.js +64 -48
- package/dist/core/core.d.ts +96 -0
- package/dist/core/core.js +57 -0
- package/dist/core/index.d.ts +22 -0
- package/dist/core/index.js +38 -0
- package/dist/core/mcp-edit.d.ts +23 -0
- package/dist/{server → core}/mcp-edit.js +121 -100
- package/dist/core/mutations.d.ts +20 -0
- package/dist/{server → core}/mutations.js +8 -15
- package/dist/core/open.d.ts +28 -0
- package/dist/{server → core}/open.js +38 -13
- package/dist/core/parse-edit.d.ts +12 -0
- package/dist/core/parse-edit.js +64 -0
- package/dist/core/reference.d.ts +3 -0
- package/dist/core/reference.js +11 -0
- package/dist/core/scan/compare.d.ts +7 -0
- package/dist/{server → core}/scan/compare.js +2 -2
- package/dist/core/scan/context.d.ts +13 -0
- package/dist/{server → core}/scan/context.js +55 -32
- package/dist/core/scan/data.d.ts +14 -0
- package/dist/core/scan/data.js +26 -0
- package/dist/core/scan/env.d.ts +28 -0
- package/dist/{server → core}/scan/env.js +15 -4
- package/dist/core/scan/files.d.ts +68 -0
- package/dist/core/scan/files.js +270 -0
- package/dist/core/scan/git.d.ts +20 -0
- package/dist/core/scan/git.js +106 -0
- package/dist/core/scan/index.d.ts +5 -0
- package/dist/{server → core}/scan/index.js +119 -82
- package/dist/core/scan/instructions.d.ts +54 -0
- package/dist/{server → core}/scan/instructions.js +141 -73
- package/dist/core/scan/inventory.d.ts +38 -0
- package/dist/{server → core}/scan/inventory.js +134 -115
- package/dist/core/scan/mcp.d.ts +28 -0
- package/dist/{server → core}/scan/mcp.js +153 -95
- package/dist/core/scan/memory.d.ts +8 -0
- package/dist/{server → core}/scan/memory.js +8 -8
- package/dist/core/scan/path-test.d.ts +9 -0
- package/dist/core/scan/path-test.js +156 -0
- package/dist/core/scan/permissions.d.ts +31 -0
- package/dist/{server → core}/scan/permissions.js +5 -8
- package/dist/core/scan/plugins.d.ts +39 -0
- package/dist/{server → core}/scan/plugins.js +36 -27
- package/dist/core/scan/projects.d.ts +9 -0
- package/dist/{server → core}/scan/projects.js +7 -5
- package/dist/core/scan/sandbox.d.ts +14 -0
- package/dist/{server → core}/scan/sandbox.js +87 -59
- package/dist/core/scan/settings.d.ts +37 -0
- package/dist/{server → core}/scan/settings.js +98 -93
- package/dist/core/scan/tools.d.ts +10 -0
- package/dist/{server → core}/scan/tools.js +28 -20
- package/dist/core/scan/usage.d.ts +19 -0
- package/dist/{server → core}/scan/usage.js +122 -97
- package/dist/core/schema.d.ts +8 -0
- package/dist/core/schema.js +67 -0
- package/dist/core/secrets.d.ts +45 -0
- package/dist/core/secrets.js +207 -0
- package/dist/core/settings-edit.d.ts +9 -0
- package/dist/{server → core}/settings-edit.js +91 -112
- package/dist/core/watch.d.ts +45 -0
- package/dist/{server → core}/watch.js +54 -17
- package/dist/core/write.d.ts +117 -0
- package/dist/{server → core}/write.js +61 -8
- package/dist/server/args.d.ts +32 -0
- package/dist/server/args.js +106 -0
- package/dist/server/cli.d.ts +2 -0
- package/dist/server/cli.js +58 -83
- package/dist/server/commands.d.ts +8 -0
- package/dist/server/commands.js +39 -0
- package/dist/server/server.d.ts +12 -0
- package/dist/server/server.js +80 -195
- package/dist/server/start.d.ts +13 -0
- package/dist/server/start.js +21 -0
- package/dist/shared/compare.d.ts +25 -0
- package/dist/shared/memory.d.ts +6 -0
- package/dist/shared/memory.js +18 -2
- package/dist/shared/requests.d.ts +104 -0
- package/dist/shared/requests.js +73 -0
- package/dist/shared/scopes.d.ts +10 -0
- package/dist/shared/settings-catalog.json +2199 -0
- package/dist/shared/types.d.ts +535 -0
- package/dist/shared/types.js +11 -0
- package/dist/ui/assets/index-C9qqEI5Z.js +60 -0
- package/dist/ui/index.html +1 -1
- package/dist/view/diff.d.ts +16 -0
- package/dist/view/diff.js +85 -0
- package/dist/view/format.d.ts +17 -0
- package/dist/view/format.js +47 -0
- package/dist/view/index.d.ts +11 -0
- package/dist/view/index.js +13 -0
- package/dist/view/lines.d.ts +9 -0
- package/dist/view/lines.js +12 -0
- package/dist/view/path-test.d.ts +3 -0
- package/dist/view/path-test.js +7 -0
- package/dist/view/sections/catalog.d.ts +9 -0
- package/dist/view/sections/catalog.js +47 -0
- package/dist/view/sections/extensions.d.ts +1 -0
- package/dist/view/sections/extensions.js +48 -0
- package/dist/view/sections/hooks.d.ts +1 -0
- package/dist/view/sections/hooks.js +32 -0
- package/dist/view/sections/index.d.ts +6 -0
- package/dist/view/sections/index.js +27 -0
- package/dist/view/sections/instructions.d.ts +6 -0
- package/dist/view/sections/instructions.js +38 -0
- package/dist/view/sections/mcp.d.ts +3 -0
- package/dist/view/sections/mcp.js +38 -0
- package/dist/view/sections/memory.d.ts +1 -0
- package/dist/view/sections/memory.js +31 -0
- package/dist/view/sections/permissions.d.ts +17 -0
- package/dist/view/sections/permissions.js +87 -0
- package/dist/view/sections/settings.d.ts +1 -0
- package/dist/view/sections/settings.js +33 -0
- package/dist/view/sections/tools.d.ts +6 -0
- package/dist/view/sections/tools.js +57 -0
- package/dist/view/sections/types.d.ts +123 -0
- package/dist/view/sections/types.js +10 -0
- package/dist/view/settings.d.ts +12 -0
- package/dist/view/settings.js +31 -0
- package/dist/view/usage.d.ts +8 -0
- package/dist/view/usage.js +18 -0
- package/package.json +13 -2
- package/dist/server/scan/files.js +0 -480
- package/dist/server/scan/path-test.js +0 -136
- package/dist/server/schema.js +0 -15
- package/dist/server/secrets.js +0 -63
- package/dist/ui/assets/index-Dg9dxngR.js +0 -60
|
@@ -3,10 +3,12 @@ import { access, stat } from "node:fs/promises";
|
|
|
3
3
|
import { delimiter, join, resolve } from "node:path";
|
|
4
4
|
import {} from "../shared/types.js";
|
|
5
5
|
import {} from "./scan/env.js";
|
|
6
|
-
import {
|
|
6
|
+
import { revisionOf } from "./scan/files.js";
|
|
7
|
+
import { isRecord, parseJson } from "./scan/data.js";
|
|
8
|
+
import { claudeJsonKey, gitRoot, mainWorktreeRoot, trustKey } from "./scan/git.js";
|
|
7
9
|
import { serverProblems } from "./scan/mcp.js";
|
|
8
|
-
import { hasHiddenValues, secretTexts, shown } from "./secrets.js";
|
|
9
|
-
import { RefusedError, readIfExists, recordChange, recordClaudeMcpChange,
|
|
10
|
+
import { hasHiddenValues, jsonSyntaxError, redactSecrets, secretTexts, shown } from "./secrets.js";
|
|
11
|
+
import { RefusedError, readIfExists, readJsonObject, recordChange, recordClaudeMcpChange, saveReviewed, writeAtomic, } from "./write.js";
|
|
10
12
|
const exists = (path) => access(path).then(() => true, () => false);
|
|
11
13
|
/**
|
|
12
14
|
* The program to run for `claude`. On Windows, npm installs Claude Code as a claude.cmd that starts
|
|
@@ -14,7 +16,7 @@ const exists = (path) => access(path).then(() => true, () => false);
|
|
|
14
16
|
* JSON as shell syntax, so ccdesk runs that claude.exe itself. A claude.exe earlier on PATH, as the
|
|
15
17
|
* native installer puts there, comes first, as it would in a terminal.
|
|
16
18
|
*/
|
|
17
|
-
export async function claudeProgram(path
|
|
19
|
+
export async function claudeProgram(path, platform) {
|
|
18
20
|
if (platform !== "win32")
|
|
19
21
|
return "claude";
|
|
20
22
|
// Windows reads a PATH entry in quotes as the folder inside them.
|
|
@@ -44,26 +46,39 @@ function secretsIn(args) {
|
|
|
44
46
|
}
|
|
45
47
|
});
|
|
46
48
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return fail(new RefusedError("ccdesk changes your user and local MCP servers through the claude command, which is not on PATH."));
|
|
55
|
-
// Node's own message repeats the command, with the server's env and header values in it.
|
|
56
|
-
if (error) {
|
|
57
|
-
// claude's own message may quote the definition it was given.
|
|
58
|
-
const said = secretsIn(args).reduce((text, secret) => text.replaceAll(secret, "•••"), stderr);
|
|
59
|
-
const reason = said.trim() ||
|
|
60
|
-
(error.killed ? "it took longer than 30 seconds" : `it exited with code ${error.code}`);
|
|
61
|
-
return fail(new RefusedError(`claude ${args[1]} failed: ${reason}`));
|
|
62
|
-
}
|
|
63
|
-
done(stdout);
|
|
64
|
-
});
|
|
65
|
-
});
|
|
49
|
+
/**
|
|
50
|
+
* Why claude did not start. On Windows a claude.cmd that npm did not install, such as Scoop's,
|
|
51
|
+
* Volta's or pnpm's, is on PATH but cannot be run without a shell, so "not on PATH" would mislead.
|
|
52
|
+
*/
|
|
53
|
+
const CLAUDE_NOT_FOUND = {
|
|
54
|
+
win32: "ccdesk changes your user and local MCP servers through the claude command and could not start it from PATH. On Windows it runs claude.exe, or the claude.exe inside npm's package when only npm's claude.cmd is there; another installer's claude.cmd or claude.ps1 cannot be started without a shell.",
|
|
55
|
+
other: "ccdesk changes your user and local MCP servers through the claude command, which is not on PATH.",
|
|
66
56
|
};
|
|
57
|
+
/** Runs the claude on `host`'s PATH, with `host`'s environment. */
|
|
58
|
+
export function claudeRunner(host) {
|
|
59
|
+
return async (args, cwd, configDir) => {
|
|
60
|
+
const program = await claudeProgram(host.processEnv.PATH ?? "", host.platform);
|
|
61
|
+
// Set only for a chosen profile: Claude Code also moves .claude.json into a CLAUDE_CONFIG_DIR.
|
|
62
|
+
const env = configDir === undefined
|
|
63
|
+
? host.processEnv
|
|
64
|
+
: { ...host.processEnv, CLAUDE_CONFIG_DIR: configDir };
|
|
65
|
+
return new Promise((done, fail) => {
|
|
66
|
+
execFile(program, args, { cwd, env, timeout: 30_000 }, (error, stdout, stderr) => {
|
|
67
|
+
if (error?.code === "ENOENT")
|
|
68
|
+
return fail(new RefusedError(CLAUDE_NOT_FOUND[host.platform === "win32" ? "win32" : "other"]));
|
|
69
|
+
// Node's own message repeats the command, with the server's env and header values in it.
|
|
70
|
+
if (error) {
|
|
71
|
+
// claude's own message may quote the definition it was given.
|
|
72
|
+
const said = secretsIn(args).reduce((text, secret) => text.replaceAll(secret, "•••"), stderr);
|
|
73
|
+
const reason = said.trim() ||
|
|
74
|
+
(error.killed ? "it took longer than 30 seconds" : `it exited with code ${error.code}`);
|
|
75
|
+
return fail(new RefusedError(`claude ${args[1]} failed: ${reason}`));
|
|
76
|
+
}
|
|
77
|
+
done(stdout);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
}
|
|
67
82
|
/** Quotes a word for a POSIX shell, so a command shown to you can be pasted as it is. */
|
|
68
83
|
function quoted(word) {
|
|
69
84
|
return `'${word.replaceAll("'", "'\\''")}'`;
|
|
@@ -109,20 +124,7 @@ async function planProject(env, edit) {
|
|
|
109
124
|
if (!(await stat(dir).catch(() => null))?.isDirectory())
|
|
110
125
|
throw new RefusedError(`${dir} is not a directory on this machine.`);
|
|
111
126
|
const path = join(dir, ".mcp.json");
|
|
112
|
-
const raw = await
|
|
113
|
-
let data = {};
|
|
114
|
-
if (raw !== null && raw.trim() !== "") {
|
|
115
|
-
let parsed;
|
|
116
|
-
try {
|
|
117
|
-
parsed = JSON.parse(raw);
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
throw new RefusedError(`${path} is ${jsonSyntaxError(error)}; fix it by hand first.`);
|
|
121
|
-
}
|
|
122
|
-
if (!isRecord(parsed))
|
|
123
|
-
throw new RefusedError(`${path} does not hold a JSON object.`);
|
|
124
|
-
data = parsed;
|
|
125
|
-
}
|
|
127
|
+
const { raw, data, reformats } = await readJsonObject(env, path);
|
|
126
128
|
const servers = isRecord(data.mcpServers) ? data.mcpServers : {};
|
|
127
129
|
checked(servers, edit);
|
|
128
130
|
const next = { ...data, mcpServers: edited(servers, edit) };
|
|
@@ -135,7 +137,7 @@ async function planProject(env, edit) {
|
|
|
135
137
|
before,
|
|
136
138
|
after: errors.length > 0 ? (before ?? "") : shown(next),
|
|
137
139
|
revision: revisionOf(raw),
|
|
138
|
-
reformats
|
|
140
|
+
reformats,
|
|
139
141
|
errors,
|
|
140
142
|
},
|
|
141
143
|
save: () => recordChange(env, { kind: "mcp", target: path, before: raw }, async () => {
|
|
@@ -145,36 +147,37 @@ async function planProject(env, edit) {
|
|
|
145
147
|
};
|
|
146
148
|
}
|
|
147
149
|
/**
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
+
* The user or local servers an edit changes in ~/.claude.json, and the folder claude runs in for
|
|
151
|
+
* them. Local servers are kept per project, under the key the scan reads them from; Claude Code
|
|
152
|
+
* files them there from any folder below. User servers do not depend on the folder, so claude runs
|
|
153
|
+
* in your home directory for them.
|
|
150
154
|
*/
|
|
151
|
-
async function
|
|
152
|
-
const dir = resolve(edit.dir);
|
|
155
|
+
async function claudeJsonServers(env, edit) {
|
|
153
156
|
const raw = await readIfExists(env, env.claudeJson);
|
|
154
157
|
let data = {};
|
|
155
158
|
try {
|
|
156
|
-
const parsed = raw === null ? {} :
|
|
159
|
+
const parsed = raw === null ? {} : parseJson(raw);
|
|
157
160
|
if (isRecord(parsed))
|
|
158
161
|
data = parsed;
|
|
159
162
|
}
|
|
160
163
|
catch (error) {
|
|
161
164
|
throw new RefusedError(`${env.claudeJson} is ${jsonSyntaxError(error)}; fix it by hand first.`);
|
|
162
165
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// User servers do not depend on the folder, so claude runs in your home directory for them.
|
|
168
|
-
const cwd = edit.scope === "user" ? env.home : root;
|
|
166
|
+
const dir = resolve(edit.dir);
|
|
167
|
+
const cwd = edit.scope === "user"
|
|
168
|
+
? env.home
|
|
169
|
+
: trustKey(dir, await mainWorktreeRoot(dir), await gitRoot(dir));
|
|
169
170
|
if (!(await stat(cwd).catch(() => null))?.isDirectory())
|
|
170
171
|
throw new RefusedError(`${cwd} is not a directory on this machine.`);
|
|
171
|
-
const
|
|
172
|
+
const projects = isRecord(data.projects) ? data.projects : {};
|
|
173
|
+
const holder = edit.scope === "user" ? data : projects[claudeJsonKey(env, cwd)];
|
|
172
174
|
const servers = isRecord(holder) && isRecord(holder.mcpServers) ? holder.mcpServers : {};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
return { cwd, servers };
|
|
176
|
+
}
|
|
177
|
+
/** The `claude mcp` arguments for an edit; names follow "--", so one such as --help is a name. */
|
|
178
|
+
const claudeArgs = {
|
|
179
|
+
remove: (edit, name) => ["mcp", "remove", "--scope", edit.scope, "--", name],
|
|
180
|
+
add: (edit, name, definition) => [
|
|
178
181
|
"mcp",
|
|
179
182
|
"add-json",
|
|
180
183
|
"--scope",
|
|
@@ -182,27 +185,76 @@ async function planClaudeJson(env, edit, run) {
|
|
|
182
185
|
"--",
|
|
183
186
|
name,
|
|
184
187
|
JSON.stringify(definition),
|
|
185
|
-
]
|
|
186
|
-
|
|
188
|
+
],
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* `claude` with these arguments as you would type it in bash, zsh or Git Bash: the server's name
|
|
192
|
+
* and definition after "--" are quoted, and the profile ccdesk reads is named, so pasting the line
|
|
193
|
+
* changes the same servers. With `cwd`, it starts by going to the folder claude ran in.
|
|
194
|
+
*/
|
|
195
|
+
function commandLine(env, args, cwd) {
|
|
196
|
+
const names = args.indexOf("--") + 1;
|
|
187
197
|
const claude = env.configDir === undefined ? "claude" : `CLAUDE_CONFIG_DIR=${quoted(env.configDir)} claude`;
|
|
188
|
-
const
|
|
198
|
+
const line = [claude, ...args.slice(0, names), ...args.slice(names).map(quoted)].join(" ");
|
|
199
|
+
return cwd === undefined ? line : `cd ${quoted(cwd)} && ${line}`;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* A user or local server, which lives in ~/.claude.json: ccdesk runs Claude Code's own
|
|
203
|
+
* `claude mcp` commands rather than write that file, which Claude Code rewrites all the time.
|
|
204
|
+
*/
|
|
205
|
+
async function planClaudeJson(env, edit, run) {
|
|
206
|
+
const { cwd, servers } = await claudeJsonServers(env, edit);
|
|
207
|
+
checked(servers, edit);
|
|
208
|
+
const { remove, add } = edit;
|
|
209
|
+
const errors = add ? serverProblems(add.definition) : [];
|
|
189
210
|
const before = Object.keys(servers).length > 0 ? shown(servers) : null;
|
|
211
|
+
// A local server's commands to paste go to the folder it belongs to first.
|
|
212
|
+
const from = edit.scope === "local" ? cwd : undefined;
|
|
190
213
|
// Recent changes lists the edit with the servers it touched, values hidden as the page shows
|
|
191
|
-
// them, and the commands that put them back
|
|
214
|
+
// them, and the commands that put them back.
|
|
192
215
|
const record = () => {
|
|
193
216
|
const names = [remove?.name, add?.name].filter((n) => n !== undefined);
|
|
194
217
|
const touched = (all) => Object.fromEntries(Object.entries(all).filter(([name]) => names.includes(name)));
|
|
195
|
-
const at = edit.scope === "local" ? `cd ${quoted(cwd)} && ` : "";
|
|
196
218
|
return recordClaudeMcpChange(env, {
|
|
197
219
|
target: env.claudeJson,
|
|
198
220
|
shownBefore: shown(touched(servers)),
|
|
199
221
|
shownAfter: shown(touched(edited(servers, edit))),
|
|
200
222
|
undoCommands: [
|
|
201
|
-
...(add ? [
|
|
202
|
-
...(remove
|
|
223
|
+
...(add ? [commandLine(env, claudeArgs.remove(edit, add.name), from)] : []),
|
|
224
|
+
...(remove
|
|
225
|
+
? [
|
|
226
|
+
commandLine(env, claudeArgs.add(edit, remove.name, redactSecrets(servers[remove.name])), from),
|
|
227
|
+
]
|
|
228
|
+
: []),
|
|
203
229
|
],
|
|
204
230
|
});
|
|
205
231
|
};
|
|
232
|
+
const save = async () => {
|
|
233
|
+
if (remove)
|
|
234
|
+
await run(claudeArgs.remove(edit, remove.name), cwd, env.configDir);
|
|
235
|
+
// The change is made by then; a record that cannot be written only leaves it unlisted.
|
|
236
|
+
if (!add) {
|
|
237
|
+
await record().catch(() => undefined);
|
|
238
|
+
return { target: env.claudeJson };
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await run(claudeArgs.add(edit, add.name, add.definition), cwd, env.configDir);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (!remove)
|
|
245
|
+
throw error;
|
|
246
|
+
// A replaced server goes back as it was, so a refused add does not lose it. When that fails
|
|
247
|
+
// too, the error gives the command that puts it back; a replaced server has no hidden values
|
|
248
|
+
// (see checked), so the command shows nothing the page does not.
|
|
249
|
+
const putBack = claudeArgs.add(edit, remove.name, servers[remove.name]);
|
|
250
|
+
await run(putBack, cwd, env.configDir).catch(() => {
|
|
251
|
+
throw new RefusedError(`ccdesk removed ${remove.name}, then could not add it back. In bash, zsh or Git Bash, put it back with: ${commandLine(env, putBack, from)} (${error instanceof Error ? error.message : String(error)})`);
|
|
252
|
+
});
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
await record().catch(() => undefined);
|
|
256
|
+
return { target: env.claudeJson };
|
|
257
|
+
};
|
|
206
258
|
return {
|
|
207
259
|
preview: {
|
|
208
260
|
path: env.claudeJson,
|
|
@@ -215,37 +267,13 @@ async function planClaudeJson(env, edit, run) {
|
|
|
215
267
|
errors,
|
|
216
268
|
// Shown with secrets hidden; the command that runs has the values you typed.
|
|
217
269
|
commands: [
|
|
218
|
-
...(remove ? [
|
|
219
|
-
...(add
|
|
270
|
+
...(remove ? [commandLine(env, claudeArgs.remove(edit, remove.name))] : []),
|
|
271
|
+
...(add
|
|
272
|
+
? [commandLine(env, claudeArgs.add(edit, add.name, redactSecrets(add.definition)))]
|
|
273
|
+
: []),
|
|
220
274
|
],
|
|
221
275
|
},
|
|
222
|
-
save
|
|
223
|
-
if (remove)
|
|
224
|
-
await run(["mcp", "remove", "--scope", edit.scope, "--", remove.name], cwd, env.configDir);
|
|
225
|
-
// The change is made by then; a record that cannot be written only leaves it unlisted.
|
|
226
|
-
if (!add) {
|
|
227
|
-
await record().catch(() => undefined);
|
|
228
|
-
return { target: env.claudeJson };
|
|
229
|
-
}
|
|
230
|
-
try {
|
|
231
|
-
await run(addArgs(add.name, add.definition), cwd, env.configDir);
|
|
232
|
-
}
|
|
233
|
-
catch (error) {
|
|
234
|
-
if (!remove)
|
|
235
|
-
throw error;
|
|
236
|
-
// A replaced server goes back as it was, so a refused add does not lose it. When that
|
|
237
|
-
// fails too, the error gives the command that puts it back, from the folder a local server
|
|
238
|
-
// belongs to; a replaced server has no hidden values (see checked), so the command shows
|
|
239
|
-
// nothing the page does not.
|
|
240
|
-
const original = servers[remove.name];
|
|
241
|
-
await run(addArgs(remove.name, original), cwd, env.configDir).catch(() => {
|
|
242
|
-
throw new RefusedError(`ccdesk removed ${remove.name}, then could not add it back. In bash, zsh or Git Bash, put it back with: ${edit.scope === "local" ? `cd ${quoted(cwd)} && ` : ""}${addCommand(remove.name, original)} (${error instanceof Error ? error.message : String(error)})`);
|
|
243
|
-
});
|
|
244
|
-
throw error;
|
|
245
|
-
}
|
|
246
|
-
await record().catch(() => undefined);
|
|
247
|
-
return { target: env.claudeJson };
|
|
248
|
-
},
|
|
276
|
+
save,
|
|
249
277
|
};
|
|
250
278
|
}
|
|
251
279
|
function plan(env, edit, run) {
|
|
@@ -255,13 +283,6 @@ export async function previewMcpEdit(env, edit, run) {
|
|
|
255
283
|
return (await plan(env, edit, run)).preview;
|
|
256
284
|
}
|
|
257
285
|
/** Makes a reviewed MCP edit, only while the servers are still as reviewed. */
|
|
258
|
-
export function saveMcpEdit(env, edit, expectedRevision, run) {
|
|
259
|
-
return
|
|
260
|
-
const planned = await plan(env, edit, run);
|
|
261
|
-
if (planned.preview.errors.length > 0)
|
|
262
|
-
throw new RefusedError(planned.preview.errors.join(" "));
|
|
263
|
-
if (planned.preview.revision !== expectedRevision)
|
|
264
|
-
throw new RefusedError("The servers changed since you reviewed this edit; review it again.");
|
|
265
|
-
return planned.save();
|
|
266
|
-
});
|
|
286
|
+
export function saveMcpEdit(env, edit, expectedRevision, run, queue) {
|
|
287
|
+
return saveReviewed(queue, () => plan(env, edit, run), expectedRevision, "The servers changed since you reviewed this edit; review it again.");
|
|
267
288
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Change, type MemoryAction, type MemoryChangePreview } from "../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./scan/env.ts";
|
|
3
|
+
import { type Serially } from "./write.ts";
|
|
4
|
+
/** A real `<config>/projects/<store>/memory` folder, after following links. */
|
|
5
|
+
export declare function assertMemoryDir(env: ScanEnv, dir: string): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* A memory folder's MEMORY.md. When it is a link, the file it leads to must be in the same folder,
|
|
8
|
+
* so changing the index can never rewrite a file elsewhere.
|
|
9
|
+
*/
|
|
10
|
+
export declare function indexPathIn(store: string): Promise<string>;
|
|
11
|
+
/** One fingerprint for two files, so a change can require both as they were reviewed. */
|
|
12
|
+
export declare function revisionOfBoth(first: string | null, second: string | null): string;
|
|
13
|
+
export declare function isMemoryAction(value: string): value is MemoryAction;
|
|
14
|
+
/** What a memory action would change, for review. */
|
|
15
|
+
export declare function previewMemoryChange(env: ScanEnv, action: MemoryAction, path: string): Promise<MemoryChangePreview>;
|
|
16
|
+
/**
|
|
17
|
+
* Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
|
|
18
|
+
* action runs only while everything its review was worked out from is unchanged.
|
|
19
|
+
*/
|
|
20
|
+
export declare function changeMemory(env: ScanEnv, action: MemoryAction, path: string, expectedRevision: string, queue: Serially): Promise<Change>;
|
|
@@ -2,9 +2,11 @@ import { access, constants, readdir, realpath, stat } from "node:fs/promises";
|
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
3
|
import {} from "../shared/types.js";
|
|
4
4
|
import {} from "./scan/env.js";
|
|
5
|
-
import { isWithin,
|
|
5
|
+
import { isWithin, revisionOf, stringField } from "./scan/files.js";
|
|
6
|
+
import { parseFrontmatter } from "./scan/data.js";
|
|
7
|
+
import { shownText } from "./secrets.js";
|
|
6
8
|
import { deadLinks, indexMentions } from "./scan/memory.js";
|
|
7
|
-
import { RefusedError, readIfExists, recordChange, removeExactly,
|
|
9
|
+
import { RefusedError, readIfExists, recordChange, removeExactly, saveReviewed, writeAtomic, } from "./write.js";
|
|
8
10
|
async function projectsRoot(env) {
|
|
9
11
|
return realpath(join(env.claudeDir, "projects")).catch(() => {
|
|
10
12
|
throw new RefusedError("There is no auto memory folder.");
|
|
@@ -47,16 +49,7 @@ async function assertMemoryFile(env, path) {
|
|
|
47
49
|
export function revisionOfBoth(first, second) {
|
|
48
50
|
return `${revisionOf(first)}+${revisionOf(second)}`;
|
|
49
51
|
}
|
|
50
|
-
const
|
|
51
|
-
/** Runs a plan only while everything it was worked out from is as reviewed. */
|
|
52
|
-
function saveReviewed(plan, expectedRevision) {
|
|
53
|
-
return serially(async () => {
|
|
54
|
-
const planned = await plan();
|
|
55
|
-
if (planned.preview.revision !== expectedRevision)
|
|
56
|
-
throw reviewAgain();
|
|
57
|
-
return planned.save();
|
|
58
|
-
});
|
|
59
|
-
}
|
|
52
|
+
const REVIEW_AGAIN = "The memory or MEMORY.md changed since you reviewed this; review it again.";
|
|
60
53
|
/** MEMORY.md's lines without the empty ones a trailing newline leaves. */
|
|
61
54
|
function indexLines(index) {
|
|
62
55
|
const lines = (index ?? "").split("\n");
|
|
@@ -103,7 +96,7 @@ async function planDelete(env, path) {
|
|
|
103
96
|
});
|
|
104
97
|
}
|
|
105
98
|
if (!removed)
|
|
106
|
-
throw
|
|
99
|
+
throw new RefusedError(REVIEW_AGAIN);
|
|
107
100
|
// The lines are kept as they were in the backup; the page gets them as ccdesk shows text.
|
|
108
101
|
return {
|
|
109
102
|
target: real,
|
|
@@ -188,6 +181,6 @@ export async function previewMemoryChange(env, action, path) {
|
|
|
188
181
|
* Makes a reviewed memory action. `path` is the memory file, or MEMORY.md for remove-dead; the
|
|
189
182
|
* action runs only while everything its review was worked out from is unchanged.
|
|
190
183
|
*/
|
|
191
|
-
export function changeMemory(env, action, path, expectedRevision) {
|
|
192
|
-
return saveReviewed(() => PLANS[action](env, path), expectedRevision);
|
|
184
|
+
export function changeMemory(env, action, path, expectedRevision, queue) {
|
|
185
|
+
return saveReviewed(queue, () => PLANS[action](env, path), expectedRevision, REVIEW_AGAIN);
|
|
193
186
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ScanEnv } from "./scan/env.ts";
|
|
2
|
+
type Command = [string, string[]];
|
|
3
|
+
/** The machine an editor is looked for and started on. */
|
|
4
|
+
type Host = Pick<ScanEnv, "platform" | "processEnv">;
|
|
5
|
+
/**
|
|
6
|
+
* The commands to try, in order: `CCDESK_EDITOR` alone when set; else `VISUAL` or `EDITOR` when
|
|
7
|
+
* they name a graphical editor, then graphical editors found on PATH, then the system's opener.
|
|
8
|
+
* The system opener comes last because on Linux it often picks a terminal editor or nothing for
|
|
9
|
+
* JSON and Markdown.
|
|
10
|
+
*/
|
|
11
|
+
export declare function editorCommands(env: NodeJS.ProcessEnv, platform: NodeJS.Platform, found?: (name: string) => boolean): Command[];
|
|
12
|
+
/**
|
|
13
|
+
* How to start `command` on `path`. On Windows a .cmd or .bat file (VS Code's `code`) or a bare
|
|
14
|
+
* name that may resolve to one cannot be started directly, so it goes through cmd.exe, quoted.
|
|
15
|
+
*/
|
|
16
|
+
export declare function spawnPlan(command: string, args: string[], path: string, platform: NodeJS.Platform): {
|
|
17
|
+
file: string;
|
|
18
|
+
args: string[];
|
|
19
|
+
verbatim: boolean;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Opens `url` in the default browser on `host`; resolves null once the opener started, else why it
|
|
23
|
+
* failed. On Windows `start` is a cmd.exe builtin, so cmd.exe is the command.
|
|
24
|
+
*/
|
|
25
|
+
export declare function openBrowser(url: string, host: Host): Promise<string | null>;
|
|
26
|
+
/** Opens a file or folder in the first editor that starts on `host`; resolves with the command used. */
|
|
27
|
+
export declare function openInEditor(path: string, host: Host): Promise<string>;
|
|
28
|
+
export {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { accessSync, constants } from "node:fs";
|
|
3
3
|
import { delimiter, join, posix, win32 } from "node:path";
|
|
4
|
+
import {} from "./scan/env.js";
|
|
5
|
+
import { RefusedError } from "./write.js";
|
|
4
6
|
/** Editors that need a terminal; started from a background server they would have none. */
|
|
5
7
|
const TERMINAL_EDITORS = new Set([
|
|
6
8
|
"vi",
|
|
@@ -72,11 +74,7 @@ export function editorCommands(env, platform, found = (name) => onPath(env, plat
|
|
|
72
74
|
for (const editor of GUI_EDITORS)
|
|
73
75
|
if (found(editor))
|
|
74
76
|
out.push([editor, []]);
|
|
75
|
-
out.push(platform
|
|
76
|
-
? ["open", []]
|
|
77
|
-
: platform === "win32"
|
|
78
|
-
? ["explorer", []]
|
|
79
|
-
: ["xdg-open", []]);
|
|
77
|
+
out.push(fileOpener(platform));
|
|
80
78
|
return out.filter((c, i) => out.findIndex((o) => o[0] === c[0]) === i);
|
|
81
79
|
}
|
|
82
80
|
/** Splits an editor setting into words, keeping quoted parts such as "C:\Program Files\…" whole. */
|
|
@@ -98,16 +96,17 @@ export function spawnPlan(command, args, path, platform) {
|
|
|
98
96
|
return { file: "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
|
|
99
97
|
}
|
|
100
98
|
/** Starts one command; resolves null once it is running or exited cleanly, else why it failed. */
|
|
101
|
-
function launch([command, args], path) {
|
|
99
|
+
function launch([command, args], path, host) {
|
|
102
100
|
return new Promise((resolve) => {
|
|
103
101
|
let plan;
|
|
104
102
|
try {
|
|
105
|
-
plan = spawnPlan(command, args, path,
|
|
103
|
+
plan = spawnPlan(command, args, path, host.platform);
|
|
106
104
|
}
|
|
107
105
|
catch (error) {
|
|
108
106
|
return resolve(error.message);
|
|
109
107
|
}
|
|
110
108
|
const child = spawn(plan.file, plan.args, {
|
|
109
|
+
env: host.processEnv,
|
|
111
110
|
detached: true,
|
|
112
111
|
stdio: "ignore",
|
|
113
112
|
windowsVerbatimArguments: plan.verbatim,
|
|
@@ -122,18 +121,44 @@ function launch([command, args], path) {
|
|
|
122
121
|
});
|
|
123
122
|
child.once("exit", (code) => {
|
|
124
123
|
clearTimeout(timer);
|
|
125
|
-
|
|
124
|
+
// explorer.exe hands the file to the running shell and exits with 1 even when that worked.
|
|
125
|
+
if (code === 0 || command === "explorer")
|
|
126
|
+
return resolve(null);
|
|
127
|
+
resolve(`${command} exited with code ${code}`);
|
|
126
128
|
});
|
|
127
129
|
});
|
|
128
130
|
}
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Opens `url` in the default browser on `host`; resolves null once the opener started, else why it
|
|
133
|
+
* failed. On Windows `start` is a cmd.exe builtin, so cmd.exe is the command.
|
|
134
|
+
*/
|
|
135
|
+
export function openBrowser(url, host) {
|
|
136
|
+
return launch(browserOpener(host.platform), url, host);
|
|
137
|
+
}
|
|
138
|
+
/** The system's own way to open a file in the app it belongs to. */
|
|
139
|
+
function fileOpener(platform) {
|
|
140
|
+
if (platform === "darwin")
|
|
141
|
+
return ["open", []];
|
|
142
|
+
if (platform === "win32")
|
|
143
|
+
return ["explorer", []];
|
|
144
|
+
return ["xdg-open", []];
|
|
145
|
+
}
|
|
146
|
+
/** The system's own way to open a URL in the default browser. */
|
|
147
|
+
function browserOpener(platform) {
|
|
148
|
+
if (platform === "darwin")
|
|
149
|
+
return ["open", []];
|
|
150
|
+
if (platform === "win32")
|
|
151
|
+
return ["cmd.exe", ["/c", "start", ""]];
|
|
152
|
+
return ["xdg-open", []];
|
|
153
|
+
}
|
|
154
|
+
/** Opens a file or folder in the first editor that starts on `host`; resolves with the command used. */
|
|
155
|
+
export async function openInEditor(path, host) {
|
|
131
156
|
const failures = [];
|
|
132
|
-
for (const command of editorCommands(
|
|
133
|
-
const failure = await launch(command, path);
|
|
157
|
+
for (const command of editorCommands(host.processEnv, host.platform)) {
|
|
158
|
+
const failure = await launch(command, path, host);
|
|
134
159
|
if (failure === null)
|
|
135
160
|
return command[0];
|
|
136
161
|
failures.push(failure);
|
|
137
162
|
}
|
|
138
|
-
throw new
|
|
163
|
+
throw new RefusedError(`Could not open it (${failures.join("; ")}). Set CCDESK_EDITOR to your editor.`);
|
|
139
164
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type AgentEdit, type HookEdit, type McpEdit, type PermissionEdit, type SettingsEdit } from "../shared/types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* An edit that is not well formed, such as an unknown scope or a missing field: the caller sent
|
|
4
|
+
* something no review could have produced. The edit modules trust the edits these parsers return.
|
|
5
|
+
*/
|
|
6
|
+
export declare class InvalidEditError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export declare function parseSettingsEdit(body: unknown): SettingsEdit;
|
|
9
|
+
export declare function parsePermissionEdit(body: unknown): PermissionEdit;
|
|
10
|
+
export declare function parseHookEdit(body: unknown): HookEdit;
|
|
11
|
+
export declare function parseAgentEdit(body: unknown): AgentEdit;
|
|
12
|
+
export declare function parseMcpEdit(body: unknown): McpEdit;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Compile } from "typebox/compile";
|
|
2
|
+
import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
|
|
3
|
+
import { AgentEditBody, HookEditBody, McpEditBody, PermissionEditBody, SettingsEditBody, } from "../shared/requests.js";
|
|
4
|
+
import {} from "../shared/types.js";
|
|
5
|
+
import { isRecord } from "./scan/data.js";
|
|
6
|
+
import { firstError } from "./schema.js";
|
|
7
|
+
/**
|
|
8
|
+
* An edit that is not well formed, such as an unknown scope or a missing field: the caller sent
|
|
9
|
+
* something no review could have produced. The edit modules trust the edits these parsers return.
|
|
10
|
+
*/
|
|
11
|
+
export class InvalidEditError extends Error {
|
|
12
|
+
}
|
|
13
|
+
const settingsEdit = Compile(SettingsEditBody);
|
|
14
|
+
const permissionEdit = Compile(PermissionEditBody);
|
|
15
|
+
const hookEdit = Compile(HookEditBody);
|
|
16
|
+
const agentEdit = Compile(AgentEditBody);
|
|
17
|
+
const mcpEdit = Compile(McpEditBody);
|
|
18
|
+
export function parseSettingsEdit(body) {
|
|
19
|
+
if (!settingsEdit.Check(body))
|
|
20
|
+
throw new InvalidEditError(firstError(settingsEdit, body));
|
|
21
|
+
return body;
|
|
22
|
+
}
|
|
23
|
+
export function parsePermissionEdit(body) {
|
|
24
|
+
if (!permissionEdit.Check(body))
|
|
25
|
+
throw new InvalidEditError(firstError(permissionEdit, body));
|
|
26
|
+
if (!body.remove && !body.add)
|
|
27
|
+
throw new InvalidEditError("remove or add is required");
|
|
28
|
+
if (body.remove?.rule.trim() === "" || body.add?.rule.trim() === "")
|
|
29
|
+
throw new InvalidEditError("a rule needs a list (allow, ask or deny) and text");
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
export function parseHookEdit(body) {
|
|
33
|
+
if (!hookEdit.Check(body))
|
|
34
|
+
throw new InvalidEditError(firstError(hookEdit, body));
|
|
35
|
+
if (!body.remove && !body.add)
|
|
36
|
+
throw new InvalidEditError("remove or add is required");
|
|
37
|
+
if (body.add) {
|
|
38
|
+
if (!HOOK_EVENTS.includes(body.add.event))
|
|
39
|
+
throw new InvalidEditError(`${body.add.event} is not a hook event Claude Code knows`);
|
|
40
|
+
if (!isRecord(body.add.definition))
|
|
41
|
+
throw new InvalidEditError("a hook must be a JSON object");
|
|
42
|
+
}
|
|
43
|
+
return body;
|
|
44
|
+
}
|
|
45
|
+
export function parseAgentEdit(body) {
|
|
46
|
+
if (!agentEdit.Check(body))
|
|
47
|
+
throw new InvalidEditError(firstError(agentEdit, body));
|
|
48
|
+
if (body.path !== undefined)
|
|
49
|
+
return body;
|
|
50
|
+
if (!body.create)
|
|
51
|
+
throw new InvalidEditError("path or create is required");
|
|
52
|
+
if (body.text === null)
|
|
53
|
+
throw new InvalidEditError("a new agent needs its text");
|
|
54
|
+
return body;
|
|
55
|
+
}
|
|
56
|
+
export function parseMcpEdit(body) {
|
|
57
|
+
if (!mcpEdit.Check(body))
|
|
58
|
+
throw new InvalidEditError(firstError(mcpEdit, body));
|
|
59
|
+
if (!body.remove && !body.add)
|
|
60
|
+
throw new InvalidEditError("remove or add is required");
|
|
61
|
+
if (body.add && !isRecord(body.add.definition))
|
|
62
|
+
throw new InvalidEditError("a server must be a JSON object");
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import HOOK_EVENTS from "../shared/hook-events.json" with { type: "json" };
|
|
2
|
+
import catalog from "../shared/settings-catalog.json" with { type: "json" };
|
|
3
|
+
import schema from "../shared/settings.schema.json" with { type: "json" };
|
|
4
|
+
import {} from "../shared/types.js";
|
|
5
|
+
/** Claude Code's settings data a front end edits against, from the files generated into src/shared. */
|
|
6
|
+
export const REFERENCE = {
|
|
7
|
+
schemaSource: schema.$comment,
|
|
8
|
+
settings: schema.definitions.ClaudeCodeSettings.properties,
|
|
9
|
+
catalog: Object.entries(catalog.keys).map(([key, entry]) => ({ key, ...entry })),
|
|
10
|
+
hookEvents: HOOK_EVENTS,
|
|
11
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type SettingsComparison } from "../../shared/compare.ts";
|
|
2
|
+
import { type ScanEnv } from "./env.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The effective settings of two directories side by side. Values are compared as ccdesk shows
|
|
5
|
+
* them, so hidden values stay hidden and two of them count as the same.
|
|
6
|
+
*/
|
|
7
|
+
export declare function compareSettings(env: ScanEnv, a: string, b: string): Promise<SettingsComparison>;
|
|
@@ -2,10 +2,10 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
3
|
import {} from "../../shared/compare.js";
|
|
4
4
|
import {} from "./env.js";
|
|
5
|
-
import { effectiveSettings, readSettingsLayers } from "./settings.js";
|
|
5
|
+
import { effectiveSettings, readManagedSettings, readSettingsLayers } from "./settings.js";
|
|
6
6
|
/** Each key Claude Code applies in `dir`, with its value and the files it comes from. */
|
|
7
7
|
async function valuesIn(env, dir) {
|
|
8
|
-
const layers = await readSettingsLayers(env, dir);
|
|
8
|
+
const layers = await readSettingsLayers(env, dir, await readManagedSettings(env));
|
|
9
9
|
const values = new Map();
|
|
10
10
|
for (const setting of effectiveSettings(layers)) {
|
|
11
11
|
// A key every file sets where Claude Code does not read it applies nowhere.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ContextBlock, type EffectiveSetting, type Instruction, type Inventory, type MemoryStore } from "../../shared/types.ts";
|
|
2
|
+
import { type ScanEnv } from "./env.ts";
|
|
3
|
+
interface ContextInput {
|
|
4
|
+
env: ScanEnv;
|
|
5
|
+
projectRoot: string;
|
|
6
|
+
instructions: Instruction[];
|
|
7
|
+
memory: MemoryStore | null;
|
|
8
|
+
inventory: Inventory;
|
|
9
|
+
effective: EffectiveSetting[];
|
|
10
|
+
}
|
|
11
|
+
/** What Claude Code puts in context at session start in this directory, in load order. */
|
|
12
|
+
export declare function buildContext(input: ContextInput): Promise<ContextBlock[]>;
|
|
13
|
+
export {};
|