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