ccdesk 0.1.0 → 0.2.1
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 +86 -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-D5XqP5c_.css +1 -0
- package/dist/ui/assets/index-XqJwI3k9.js +60 -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
|
@@ -2,22 +2,25 @@ import { basename, dirname, join, relative } from "node:path";
|
|
|
2
2
|
import { HIGHEST_FIRST } from "../../shared/scopes.js";
|
|
3
3
|
import {} from "../../shared/types.js";
|
|
4
4
|
import {} from "./env.js";
|
|
5
|
-
import { isDir, isFile, isRecord, isWithin, listDir, parseFrontmatter, readJsonObject, readTextFile, resolvedPath, stringField, toStringList, } from "./files.js";
|
|
5
|
+
import { isDir, isFile, isRecord, isWithin, listDir, parseFrontmatter, readJsonObject, readTextFile, redactSecrets, resolvedPath, shownUrl, stringField, toStringList, } from "./files.js";
|
|
6
6
|
import { ancestorsRootFirst } from "./instructions.js";
|
|
7
7
|
import { readMcpServers } from "./mcp.js";
|
|
8
|
-
import { manifestCommandMap, manifestConfigs, manifestPaths, readPlugins } from "./plugins.js";
|
|
8
|
+
import { manifestCommandMap, manifestConfigs, manifestPaths, pluginNamespace, readPlugins, } from "./plugins.js";
|
|
9
9
|
import { effectiveSettings } from "./settings.js";
|
|
10
10
|
function isTrue(value) {
|
|
11
11
|
return value === true || (typeof value === "string" && /^(true|yes|on|1)$/i.test(value));
|
|
12
12
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
/**
|
|
14
|
+
* A `.md` file as an agent, command or output style. `fileName` is its name from its path, which a
|
|
15
|
+
* frontmatter `name` replaces, except for a command (docs: skills#how-a-skill-gets-its-command-name).
|
|
16
|
+
*/
|
|
17
|
+
async function readEntry(env, path, fileName, scope, { plugin, isCommand = false } = {}) {
|
|
18
|
+
const file = await readTextFile(env, path);
|
|
16
19
|
if (!file)
|
|
17
20
|
return null;
|
|
18
21
|
const { data } = parseFrontmatter(file.text);
|
|
19
22
|
const entry = {
|
|
20
|
-
name: stringField(data, "name") ||
|
|
23
|
+
name: (!isCommand && stringField(data, "name")) || fileName,
|
|
21
24
|
description: stringField(data, "description"),
|
|
22
25
|
path,
|
|
23
26
|
scope,
|
|
@@ -37,7 +40,7 @@ async function readEntry(path, name, scope, plugin) {
|
|
|
37
40
|
* `*.md` files in a folder and its subfolders. A file in a subfolder is named by its path, joined
|
|
38
41
|
* with `separator`: commands use ":" (`frontend/component.md` is `/frontend:component`).
|
|
39
42
|
*/
|
|
40
|
-
async function readFileEntries(dir, scope, { plugin, separator = "/" } = {}, base = dir, depth = 0) {
|
|
43
|
+
async function readFileEntries(env, dir, scope, { plugin, separator = "/" } = {}, base = dir, depth = 0) {
|
|
41
44
|
const out = [];
|
|
42
45
|
for (const name of await listDir(dir)) {
|
|
43
46
|
if (name.startsWith("."))
|
|
@@ -45,35 +48,42 @@ async function readFileEntries(dir, scope, { plugin, separator = "/" } = {}, bas
|
|
|
45
48
|
const path = join(dir, name);
|
|
46
49
|
if (await isDir(path)) {
|
|
47
50
|
if (depth < 4)
|
|
48
|
-
out.push(...(await readFileEntries(path, scope, { plugin, separator }, base, depth + 1)));
|
|
51
|
+
out.push(...(await readFileEntries(env, path, scope, { plugin, separator }, base, depth + 1)));
|
|
49
52
|
continue;
|
|
50
53
|
}
|
|
51
54
|
if (!name.endsWith(".md"))
|
|
52
55
|
continue;
|
|
53
|
-
const
|
|
54
|
-
|
|
56
|
+
const fileName = relative(base, path).replace(/\.md$/, "").split(/[/\\]/).join(separator);
|
|
57
|
+
// Commands are the entries joined with ":".
|
|
58
|
+
const entry = await readEntry(env, path, fileName, scope, {
|
|
59
|
+
plugin,
|
|
60
|
+
isCommand: separator === ":",
|
|
61
|
+
});
|
|
55
62
|
if (entry)
|
|
56
63
|
out.push(entry);
|
|
57
64
|
}
|
|
58
65
|
return out;
|
|
59
66
|
}
|
|
60
67
|
/** Files or folders of `.md` files. */
|
|
61
|
-
async function readPaths(paths, scope, options) {
|
|
68
|
+
async function readPaths(env, paths, scope, options) {
|
|
62
69
|
const out = [];
|
|
63
70
|
for (const path of paths) {
|
|
64
71
|
if (await isDir(path))
|
|
65
|
-
out.push(...(await readFileEntries(path, scope, options)));
|
|
72
|
+
out.push(...(await readFileEntries(env, path, scope, options)));
|
|
66
73
|
else if (path.endsWith(".md")) {
|
|
67
|
-
const entry = await readEntry(path, basename(path, ".md"), scope,
|
|
74
|
+
const entry = await readEntry(env, path, basename(path, ".md"), scope, {
|
|
75
|
+
plugin: options.plugin,
|
|
76
|
+
isCommand: options.separator === ":",
|
|
77
|
+
});
|
|
68
78
|
if (entry)
|
|
69
79
|
out.push(entry);
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
return out;
|
|
73
83
|
}
|
|
74
|
-
export async function readSkill(folder, scope, plugin) {
|
|
84
|
+
export async function readSkill(env, folder, scope, plugin) {
|
|
75
85
|
const path = join(folder, "SKILL.md");
|
|
76
|
-
const file = await readTextFile(path);
|
|
86
|
+
const file = await readTextFile(env, path);
|
|
77
87
|
if (!file)
|
|
78
88
|
return null;
|
|
79
89
|
const { data, body } = parseFrontmatter(file.text);
|
|
@@ -105,40 +115,51 @@ export async function readSkill(folder, scope, plugin) {
|
|
|
105
115
|
return entry;
|
|
106
116
|
}
|
|
107
117
|
/** `<name>/SKILL.md` folders, or one folder holding SKILL.md itself. */
|
|
108
|
-
async function readSkills(dir, scope, plugin) {
|
|
118
|
+
async function readSkills(env, dir, scope, plugin) {
|
|
109
119
|
if (plugin && (await isFile(join(dir, "SKILL.md")))) {
|
|
110
|
-
const skill = await readSkill(dir, scope, plugin);
|
|
120
|
+
const skill = await readSkill(env, dir, scope, plugin);
|
|
111
121
|
return skill ? [skill] : [];
|
|
112
122
|
}
|
|
113
123
|
const out = [];
|
|
114
124
|
for (const name of await listDir(dir)) {
|
|
115
125
|
if (name.startsWith(".") || (scope === "user" && name === "synced"))
|
|
116
126
|
continue;
|
|
117
|
-
const skill = await readSkill(join(dir, name), scope, plugin);
|
|
127
|
+
const skill = await readSkill(env, join(dir, name), scope, plugin);
|
|
118
128
|
if (skill)
|
|
119
129
|
out.push(skill);
|
|
120
130
|
}
|
|
121
131
|
return out;
|
|
122
132
|
}
|
|
123
133
|
/** A plugin's skills, agents, commands and output styles, from its folders and its manifest. */
|
|
124
|
-
async function pluginEntries(plugin) {
|
|
134
|
+
async function pluginEntries(env, plugin) {
|
|
125
135
|
const root = plugin.installPath;
|
|
126
136
|
const options = { plugin: plugin.id };
|
|
127
137
|
// `skills` adds to the default folder; the other keys replace theirs (docs: plugins-reference).
|
|
128
|
-
const extraSkills = await manifestPaths(plugin, "skills");
|
|
138
|
+
const extraSkills = await manifestPaths(env, plugin, "skills");
|
|
129
139
|
const skillDirs = [join(root, "skills"), ...(extraSkills ?? [])];
|
|
130
140
|
const rootSkill = extraSkills === null && !(await isDir(join(root, "skills")));
|
|
131
|
-
const
|
|
141
|
+
const namespace = await pluginNamespace(env, plugin);
|
|
142
|
+
const skills = [];
|
|
143
|
+
if (rootSkill)
|
|
144
|
+
for (const skill of await readSkills(env, root, "plugin", plugin.id)) {
|
|
145
|
+
// Without a name, a root SKILL.md is named after the plugin (docs: skills#how-a-skill-gets-its-command-name).
|
|
146
|
+
if (skill.folder === root && !stringField(parseFrontmatter(skill.text).data, "name"))
|
|
147
|
+
skill.name = namespace;
|
|
148
|
+
skills.push(skill);
|
|
149
|
+
}
|
|
132
150
|
for (const dir of skillDirs)
|
|
133
|
-
skills.push(...(await readSkills(dir, "plugin", plugin.id)));
|
|
134
|
-
const listed = async (key, folder, separator = "/") => readPaths((await manifestPaths(plugin, key)) ?? [join(root, folder)], "plugin", {
|
|
151
|
+
skills.push(...(await readSkills(env, dir, "plugin", plugin.id)));
|
|
152
|
+
const listed = async (key, folder, separator = "/") => readPaths(env, (await manifestPaths(env, plugin, key)) ?? [join(root, folder)], "plugin", {
|
|
135
153
|
...options,
|
|
136
154
|
separator,
|
|
137
155
|
});
|
|
138
156
|
const commands = await listed("commands", "commands", ":");
|
|
139
|
-
for (const c of await manifestCommandMap(plugin))
|
|
157
|
+
for (const c of await manifestCommandMap(env, plugin))
|
|
140
158
|
commands.push(c.text === null
|
|
141
|
-
? ((await readEntry(c.path, c.name, "plugin",
|
|
159
|
+
? ((await readEntry(env, c.path, c.name, "plugin", {
|
|
160
|
+
plugin: plugin.id,
|
|
161
|
+
isCommand: true,
|
|
162
|
+
})) ?? {
|
|
142
163
|
name: c.name,
|
|
143
164
|
description: c.description,
|
|
144
165
|
path: c.path,
|
|
@@ -154,14 +175,18 @@ async function pluginEntries(plugin) {
|
|
|
154
175
|
plugin: plugin.id,
|
|
155
176
|
text: c.text,
|
|
156
177
|
});
|
|
178
|
+
// A plugin's skills and commands are called as plugin-name:name; a name that already has the
|
|
179
|
+
// prefix keeps it once (docs: skills#how-a-skill-gets-its-command-name).
|
|
180
|
+
for (const entry of [...skills, ...commands])
|
|
181
|
+
entry.id = entry.name.startsWith(`${namespace}:`) ? entry.name : `${namespace}:${entry.name}`;
|
|
157
182
|
// A plugin subagent is named with the plugin and its subfolders (docs: sub-agents#choose-the-subagent-scope).
|
|
158
183
|
const agents = [];
|
|
159
|
-
for (const path of (await manifestPaths(plugin, "agents")) ?? [join(root, "agents")]) {
|
|
184
|
+
for (const path of (await manifestPaths(env, plugin, "agents")) ?? [join(root, "agents")]) {
|
|
160
185
|
const folder = (await isDir(path)) ? path : null;
|
|
161
|
-
for (const agent of await readPaths([path], "plugin", options)) {
|
|
186
|
+
for (const agent of await readPaths(env, [path], "plugin", options)) {
|
|
162
187
|
const subfolders = folder ? relative(folder, dirname(agent.path)).split(/[/\\]/) : [];
|
|
163
188
|
const own = agent.name.split("/").at(-1) ?? agent.name;
|
|
164
|
-
agent.id = [
|
|
189
|
+
agent.id = [namespace, ...subfolders.filter(Boolean), own].join(":");
|
|
165
190
|
agents.push(agent);
|
|
166
191
|
}
|
|
167
192
|
}
|
|
@@ -183,9 +208,26 @@ function projectFolders(env, projectDir, gitRoot, sub) {
|
|
|
183
208
|
.map((dir) => join(dir, ".claude", sub))
|
|
184
209
|
.filter((dir) => dir !== join(env.claudeDir, sub));
|
|
185
210
|
}
|
|
211
|
+
/**
|
|
212
|
+
* What Claude Code requires of a subagent file; it skips one that falls short, and treats one
|
|
213
|
+
* without a name as documentation (docs: sub-agents#subagent-files-claude-code-skips).
|
|
214
|
+
*/
|
|
215
|
+
export function agentProblems(text) {
|
|
216
|
+
const { data } = parseFrontmatter(text);
|
|
217
|
+
const name = stringField(data, "name");
|
|
218
|
+
const problems = [];
|
|
219
|
+
if (!name)
|
|
220
|
+
problems.push("The frontmatter needs a name.");
|
|
221
|
+
else if (name.length > 256 || name.includes(":") || name.startsWith("-"))
|
|
222
|
+
problems.push("A name has at most 256 characters, no ':' and does not start with '-'.");
|
|
223
|
+
if (!stringField(data, "description"))
|
|
224
|
+
problems.push("The frontmatter needs a description: when Claude should use this agent.");
|
|
225
|
+
return problems;
|
|
226
|
+
}
|
|
186
227
|
/**
|
|
187
228
|
* Subagents by priority: managed, then project folders closest to the start directory first, then
|
|
188
229
|
* yours. A name defined again lower down is marked as shadowed (docs: sub-agents#choose-the-subagent-scope).
|
|
230
|
+
* A file Claude Code skips takes no part in that.
|
|
189
231
|
*/
|
|
190
232
|
async function readAgents(env, projectDir, gitRoot) {
|
|
191
233
|
const sources = [
|
|
@@ -198,7 +240,13 @@ async function readAgents(env, projectDir, gitRoot) {
|
|
|
198
240
|
const agents = [];
|
|
199
241
|
const winners = new Map();
|
|
200
242
|
for (const [dir, scope] of sources)
|
|
201
|
-
for (const agent of await readFileEntries(dir, scope)) {
|
|
243
|
+
for (const agent of await readFileEntries(env, dir, scope)) {
|
|
244
|
+
const [problem] = agentProblems(agent.text);
|
|
245
|
+
if (problem) {
|
|
246
|
+
agent.skipped = problem;
|
|
247
|
+
agents.push(agent);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
202
250
|
const winner = winners.get(agent.name);
|
|
203
251
|
if (winner)
|
|
204
252
|
agent.shadowedBy = winner.path;
|
|
@@ -208,6 +256,24 @@ async function readAgents(env, projectDir, gitRoot) {
|
|
|
208
256
|
}
|
|
209
257
|
return agents;
|
|
210
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Output styles from the managed folder, every project folder from the start directory up to the
|
|
261
|
+
* repository root (the closest wins) and yours (docs: output-styles#create-a-custom-output-style).
|
|
262
|
+
* The docs order only the project folders; managed, project, then yours follows subagents.
|
|
263
|
+
*/
|
|
264
|
+
async function readOutputStyles(env, projectDir, gitRoot) {
|
|
265
|
+
const sources = [
|
|
266
|
+
[join(env.managedDir, ".claude", "output-styles"), "managed"],
|
|
267
|
+
...projectFolders(env, projectDir, gitRoot, "output-styles")
|
|
268
|
+
.reverse()
|
|
269
|
+
.map((dir) => [dir, "project"]),
|
|
270
|
+
[join(env.claudeDir, "output-styles"), "user"],
|
|
271
|
+
];
|
|
272
|
+
const styles = [];
|
|
273
|
+
for (const [dir, scope] of sources)
|
|
274
|
+
styles.push(...(await readFileEntries(env, dir, scope)));
|
|
275
|
+
return styles;
|
|
276
|
+
}
|
|
211
277
|
/**
|
|
212
278
|
* Applies skillOverrides to your skills and command files; plugin skills are not affected
|
|
213
279
|
* (docs: skills#override-skill-visibility-from-settings).
|
|
@@ -230,13 +296,13 @@ export async function readInventory(input) {
|
|
|
230
296
|
const { env, projectDir, gitRoot, layers } = input;
|
|
231
297
|
const plugins = await readPlugins(env, layers, projectDir, gitRoot);
|
|
232
298
|
const active = plugins.filter((p) => p.enabled);
|
|
233
|
-
const fromPlugins = await Promise.all(active.map(pluginEntries));
|
|
299
|
+
const fromPlugins = await Promise.all(active.map((plugin) => pluginEntries(env, plugin)));
|
|
234
300
|
const projectClaude = join(projectDir, ".claude");
|
|
235
301
|
const hasProjectDir = projectClaude !== env.claudeDir;
|
|
236
302
|
const files = async (sub, separator = "/") => [
|
|
237
|
-
...(await readFileEntries(join(env.claudeDir, sub), "user", { separator })),
|
|
303
|
+
...(await readFileEntries(env, join(env.claudeDir, sub), "user", { separator })),
|
|
238
304
|
...(hasProjectDir
|
|
239
|
-
? await readFileEntries(join(projectClaude, sub), "project", { separator })
|
|
305
|
+
? await readFileEntries(env, join(projectClaude, sub), "project", { separator })
|
|
240
306
|
: []),
|
|
241
307
|
];
|
|
242
308
|
// Project skills come from the start directory and every parent up to the repository root.
|
|
@@ -253,21 +319,34 @@ export async function readInventory(input) {
|
|
|
253
319
|
];
|
|
254
320
|
const skills = [];
|
|
255
321
|
const seenFolders = new Set();
|
|
322
|
+
// The same name in two of managed, yours and the project runs the first, in that order
|
|
323
|
+
// (docs: skills#resolve-skills-that-share-a-name).
|
|
324
|
+
const winners = new Map();
|
|
256
325
|
for (const [dir, scope] of skillDirs) {
|
|
257
|
-
for (const skill of await readSkills(dir, scope)) {
|
|
326
|
+
for (const skill of await readSkills(env, dir, scope)) {
|
|
258
327
|
// A folder that several locations link to loads once.
|
|
259
328
|
const real = skill.realPath ?? skill.folder ?? skill.path;
|
|
260
329
|
if (seenFolders.has(real))
|
|
261
330
|
continue;
|
|
262
331
|
seenFolders.add(real);
|
|
332
|
+
const winner = winners.get(skill.name);
|
|
333
|
+
if (winner && winner.scope !== skill.scope)
|
|
334
|
+
skill.shadowedBy = winner.path;
|
|
335
|
+
else if (!winner)
|
|
336
|
+
winners.set(skill.name, skill);
|
|
263
337
|
skills.push(skill);
|
|
264
338
|
}
|
|
265
339
|
}
|
|
266
340
|
// A command file is a skill too: one with disable-model-invocation is not listed to Claude.
|
|
267
341
|
const commands = await files("commands", ":");
|
|
268
|
-
|
|
342
|
+
// A skill of the same name runs instead of it (docs: skills#resolve-skills-that-share-a-name).
|
|
343
|
+
for (const command of commands) {
|
|
269
344
|
if (isTrue(parseFrontmatter(command.text).data["disable-model-invocation"]))
|
|
270
345
|
command.listed = false;
|
|
346
|
+
const skill = winners.get(command.name);
|
|
347
|
+
if (skill)
|
|
348
|
+
command.shadowedBy = skill.path;
|
|
349
|
+
}
|
|
271
350
|
const overrides = effectiveSettings(layers).find((s) => s.key === "skillOverrides")?.value;
|
|
272
351
|
applySkillOverrides([...skills, ...commands], overrides);
|
|
273
352
|
return {
|
|
@@ -278,15 +357,15 @@ export async function readInventory(input) {
|
|
|
278
357
|
skills: [...skills, ...fromPlugins.flatMap((p) => p.skills)],
|
|
279
358
|
commands: [...commands, ...fromPlugins.flatMap((p) => p.commands)],
|
|
280
359
|
outputStyles: [
|
|
281
|
-
...(await
|
|
360
|
+
...(await readOutputStyles(env, projectDir, gitRoot)),
|
|
282
361
|
...fromPlugins.flatMap((p) => p.outputStyles),
|
|
283
362
|
],
|
|
284
|
-
hooks: await readHooks(layers, active),
|
|
363
|
+
hooks: await readHooks(env, layers, active, input.trust, input.profile),
|
|
285
364
|
mcpServers: await readMcpServers(input, active),
|
|
286
365
|
plugins,
|
|
287
366
|
};
|
|
288
367
|
}
|
|
289
|
-
async function readHooks(layers, plugins) {
|
|
368
|
+
async function readHooks(env, layers, plugins, trust, profile) {
|
|
290
369
|
const out = [];
|
|
291
370
|
for (const layer of layers) {
|
|
292
371
|
if (layer.data)
|
|
@@ -294,10 +373,10 @@ async function readHooks(layers, plugins) {
|
|
|
294
373
|
}
|
|
295
374
|
for (const plugin of plugins) {
|
|
296
375
|
const path = join(plugin.installPath, "hooks", "hooks.json");
|
|
297
|
-
const file = await readJsonObject(path);
|
|
376
|
+
const file = await readJsonObject(env, path);
|
|
298
377
|
if (file.ok)
|
|
299
378
|
out.push(...hooksFrom(file.data.hooks, "plugin", path, plugin));
|
|
300
|
-
for (const part of await manifestConfigs(plugin, "hooks"))
|
|
379
|
+
for (const part of await manifestConfigs(env, plugin, "hooks"))
|
|
301
380
|
out.push(...hooksFrom(part.value, "plugin", part.path, plugin));
|
|
302
381
|
}
|
|
303
382
|
// docs: hooks#disable-or-remove-hooks, settings-reference#allowmanagedhooksonly
|
|
@@ -309,6 +388,11 @@ async function readHooks(layers, plugins) {
|
|
|
309
388
|
.find((l) => typeof l.data?.disableAllHooks === "boolean")?.data?.disableAllHooks === true;
|
|
310
389
|
const managedOnly = managed?.allowManagedHooksOnly === true;
|
|
311
390
|
const forcedByManaged = new Set(plugins.filter((p) => p.enabledIn === "managed").map((p) => p.id));
|
|
391
|
+
// An interactive session holds back hooks from every settings file, managed and yours included,
|
|
392
|
+
// until the folder or a parent covering it is trusted; -p and the SDK do not (docs:
|
|
393
|
+
// hooks#workspace-trust, permissions#what-runs-before-you-trust-a-folder). The docs say nothing
|
|
394
|
+
// of plugin hooks, so those are left as they are.
|
|
395
|
+
const heldBack = profile === "interactive" && !trust.trusted && !trust.inheritedFrom;
|
|
312
396
|
for (const hook of out) {
|
|
313
397
|
const organisation = hook.source === "managed" || (hook.plugin !== undefined && forcedByManaged.has(hook.plugin));
|
|
314
398
|
if (managedOff) {
|
|
@@ -323,6 +407,10 @@ async function readHooks(layers, plugins) {
|
|
|
323
407
|
hook.active = false;
|
|
324
408
|
hook.note = "Managed settings allow only managed hooks (allowManagedHooksOnly)";
|
|
325
409
|
}
|
|
410
|
+
else if (hook.source !== "plugin" && heldBack) {
|
|
411
|
+
hook.active = false;
|
|
412
|
+
hook.note = "Waits until you accept Claude Code's trust dialog for this folder";
|
|
413
|
+
}
|
|
326
414
|
}
|
|
327
415
|
return out;
|
|
328
416
|
}
|
|
@@ -333,19 +421,23 @@ function hooksFrom(config, source, sourcePath, plugin) {
|
|
|
333
421
|
for (const [event, groups] of Object.entries(config)) {
|
|
334
422
|
if (!Array.isArray(groups))
|
|
335
423
|
continue;
|
|
336
|
-
for (const group of groups) {
|
|
424
|
+
for (const [g, group] of groups.entries()) {
|
|
337
425
|
if (!isRecord(group) || !Array.isArray(group.hooks))
|
|
338
426
|
continue;
|
|
339
|
-
for (const h of group.hooks) {
|
|
427
|
+
for (const [index, h] of group.hooks.entries()) {
|
|
340
428
|
if (!isRecord(h))
|
|
341
429
|
continue;
|
|
342
430
|
const entry = {
|
|
343
431
|
event,
|
|
344
432
|
type: stringField(h, "type") || "command",
|
|
345
|
-
command: stringField(h, "command") ||
|
|
433
|
+
command: stringField(h, "command") ||
|
|
434
|
+
stringField(h, "prompt") ||
|
|
435
|
+
shownUrl(stringField(h, "url")),
|
|
346
436
|
source,
|
|
347
437
|
sourcePath,
|
|
348
438
|
active: true,
|
|
439
|
+
at: { group: g, index },
|
|
440
|
+
definition: redactSecrets(h),
|
|
349
441
|
};
|
|
350
442
|
const matcher = stringField(group, "matcher");
|
|
351
443
|
if (matcher)
|
package/dist/server/scan/mcp.js
CHANGED
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import {} from "../../shared/types.js";
|
|
3
|
-
import {} from "./env.js";
|
|
4
|
-
import { isRecord, readJsonObject, shownUrl, stringField, stringsIn, wildcard } from "./files.js";
|
|
3
|
+
import { isConfigHome } from "./env.js";
|
|
4
|
+
import { isRecord, readJsonObject, redactSecrets, shownUrl, stringField, stringsIn, wildcard, } from "./files.js";
|
|
5
5
|
import { manifestConfigs } from "./plugins.js";
|
|
6
|
+
import serverSchema from "../../shared/mcp-server.schema.json" with { type: "json" };
|
|
7
|
+
import { ajv, schemaErrors } from "../schema.js";
|
|
6
8
|
import { readManagedSettings } from "./settings.js";
|
|
9
|
+
const validateServer = ajv.compile(serverSchema);
|
|
10
|
+
/**
|
|
11
|
+
* What makes a server definition one Claude Code cannot start: a missing command or url, a type it
|
|
12
|
+
* does not know, or a field the docs define holding the wrong kind of value (docs: mcp). It checks
|
|
13
|
+
* the rest when it starts the server.
|
|
14
|
+
*/
|
|
15
|
+
export function serverProblems(definition) {
|
|
16
|
+
if (!isRecord(definition))
|
|
17
|
+
return ["A server must be a JSON object."];
|
|
18
|
+
const type = typeof definition.type === "string" ? definition.type : "";
|
|
19
|
+
if (type === "sdk")
|
|
20
|
+
return ['Only an SDK host application can register a "sdk" server; Claude Code skips it here.'];
|
|
21
|
+
const problems = [];
|
|
22
|
+
if (type === "" || type === "stdio") {
|
|
23
|
+
if (typeof definition.command !== "string" || definition.command.trim() === "")
|
|
24
|
+
problems.push(typeof definition.url === "string"
|
|
25
|
+
? 'A server with a url needs a "type", such as "http"; without one it is read as a stdio server.'
|
|
26
|
+
: 'A stdio server needs a "command".');
|
|
27
|
+
}
|
|
28
|
+
else if (typeof definition.url !== "string" || definition.url === "")
|
|
29
|
+
problems.push(`A ${type} server needs a "url".`);
|
|
30
|
+
return [...problems, ...schemaErrors(validateServer, definition, "")];
|
|
31
|
+
}
|
|
7
32
|
function define(servers, scope, sourcePath, organisation = false) {
|
|
8
33
|
if (!isRecord(servers))
|
|
9
34
|
return [];
|
|
@@ -16,19 +41,25 @@ function define(servers, scope, sourcePath, organisation = false) {
|
|
|
16
41
|
const command = stringField(c, "command")
|
|
17
42
|
? [stringField(c, "command"), ...stringsIn(c.args)]
|
|
18
43
|
: null;
|
|
44
|
+
const definition = redactSecrets(c);
|
|
19
45
|
const server = {
|
|
20
46
|
name,
|
|
21
47
|
scope,
|
|
22
48
|
// An entry without a type is read as stdio, even one with a url (docs: mcp).
|
|
23
49
|
transport: type || "stdio",
|
|
24
|
-
target: url
|
|
50
|
+
target: url
|
|
51
|
+
? shownUrl(url)
|
|
52
|
+
: command
|
|
53
|
+
? [command[0], ...stringsIn(definition.args)].join(" ")
|
|
54
|
+
: "",
|
|
25
55
|
sourcePath,
|
|
26
56
|
status: "active",
|
|
57
|
+
definition,
|
|
27
58
|
};
|
|
28
|
-
|
|
59
|
+
const [problem] = serverProblems(c);
|
|
60
|
+
if (problem) {
|
|
29
61
|
server.status = "invalid";
|
|
30
|
-
server.note =
|
|
31
|
-
'Has a url but no "type", so Claude Code reads it as a stdio server and it fails';
|
|
62
|
+
server.note = problem;
|
|
32
63
|
}
|
|
33
64
|
// A ${VAR} in an organisation entry means it still has to pass the allowlist.
|
|
34
65
|
const usesVariables = JSON.stringify(c).includes("${");
|
|
@@ -88,7 +119,7 @@ function defineManaged(servers, sourcePath) {
|
|
|
88
119
|
return defined;
|
|
89
120
|
}
|
|
90
121
|
/** Two URLs are one endpoint when they differ only in scheme or host case, a default port or a trailing slash. */
|
|
91
|
-
|
|
122
|
+
function endpoint(d) {
|
|
92
123
|
if (d.command)
|
|
93
124
|
return `command:${JSON.stringify(d.command)}`;
|
|
94
125
|
if (!d.url)
|
|
@@ -160,17 +191,20 @@ function allowed(list, d) {
|
|
|
160
191
|
* server is one that is configured and allowed, not one that is known to work.
|
|
161
192
|
*/
|
|
162
193
|
export async function readMcpServers(input, plugins) {
|
|
163
|
-
const { env, projectDir, layers, claudeJson, trust } = input;
|
|
194
|
+
const { env, projectDir, layers, claudeJson, trust, profile } = input;
|
|
164
195
|
const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
|
|
165
|
-
|
|
166
|
-
|
|
196
|
+
// Local servers live in the entry under the trust key, even from a subdirectory: claude mcp add
|
|
197
|
+
// --scope local writes there and claude mcp list reads only that entry, ignoring one for the
|
|
198
|
+
// subdirectory (observed with v2.1.296; the docs do not say).
|
|
199
|
+
const entry = projects[trust.root];
|
|
200
|
+
const project = isRecord(entry) ? entry : {};
|
|
167
201
|
const managed = layers.find((l) => l.scope === "managed");
|
|
168
202
|
// Checked as written: the layer has header values hidden, which would hide a ${VAR} in one.
|
|
169
203
|
const managedServers = (await readManagedSettings(env)).data?.managedMcpServers;
|
|
170
204
|
const managedFile = join(env.managedDir, "managed-mcp.json");
|
|
171
|
-
const managedJson = await readJsonObject(managedFile);
|
|
205
|
+
const managedJson = await readJsonObject(env, managedFile);
|
|
172
206
|
const mcpJson = join(projectDir, ".mcp.json");
|
|
173
|
-
const shared = await readJsonObject(mcpJson);
|
|
207
|
+
const shared = await readJsonObject(env, mcpJson);
|
|
174
208
|
const defined = [
|
|
175
209
|
...(managedJson.ok ? define(managedJson.data.mcpServers, "managed", managedFile, true) : []),
|
|
176
210
|
...defineManaged(managedServers, managed?.path ?? ""),
|
|
@@ -180,7 +214,7 @@ export async function readMcpServers(input, plugins) {
|
|
|
180
214
|
];
|
|
181
215
|
for (const plugin of plugins) {
|
|
182
216
|
const path = join(plugin.installPath, ".mcp.json");
|
|
183
|
-
const file = await readJsonObject(path);
|
|
217
|
+
const file = await readJsonObject(env, path);
|
|
184
218
|
// A server name declared later in the same plugin replaces an earlier one.
|
|
185
219
|
const own = new Map();
|
|
186
220
|
const add = (list) => {
|
|
@@ -189,7 +223,7 @@ export async function readMcpServers(input, plugins) {
|
|
|
189
223
|
};
|
|
190
224
|
if (file.ok)
|
|
191
225
|
add(define(file.data.mcpServers ?? file.data, "plugin", path));
|
|
192
|
-
for (const part of await manifestConfigs(plugin, "mcpServers"))
|
|
226
|
+
for (const part of await manifestConfigs(env, plugin, "mcpServers"))
|
|
193
227
|
add(define(part.value, "plugin", part.path));
|
|
194
228
|
for (const d of own.values())
|
|
195
229
|
d.server.plugin = plugin.id;
|
|
@@ -203,19 +237,29 @@ export async function readMcpServers(input, plugins) {
|
|
|
203
237
|
const allowList = allowLayers.flatMap((l) => policyEntries(l.data?.allowedMcpServers));
|
|
204
238
|
const disabledHere = stringsIn(project.disabledMcpServers);
|
|
205
239
|
// A cloned repository cannot approve its own servers: project and local approvals count once the
|
|
206
|
-
// folder is trusted, local ones in your home
|
|
207
|
-
|
|
240
|
+
// folder is trusted, local ones in your configuration home right away (docs: mcp#project-server-approvals-and-workspace-trust).
|
|
241
|
+
// A disabledMcpjsonServers entry in any file rejects the server.
|
|
242
|
+
const approvalCounts = (scope) => (scope !== "project" && scope !== "local") ||
|
|
208
243
|
trust.trusted ||
|
|
209
|
-
(
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
...
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
244
|
+
(scope === "local" && isConfigHome(env, projectDir));
|
|
245
|
+
// Your answers to the .mcp.json prompt sit in the project entry: claude mcp list counts an
|
|
246
|
+
// enableAllProjectMcpServers there, and claude mcp reset-project-choices clears it (v2.1.296).
|
|
247
|
+
const sources = [
|
|
248
|
+
...layers.flatMap((l) => (l.data ? [{ source: l.scope, path: l.path, data: l.data }] : [])),
|
|
249
|
+
{ source: "claude-json", path: env.claudeJson, data: project },
|
|
250
|
+
];
|
|
251
|
+
const answersFor = (name) => sources.flatMap(({ source, path, data }) => {
|
|
252
|
+
const counts = source === "claude-json" || approvalCounts(source);
|
|
253
|
+
const out = [];
|
|
254
|
+
if (data.enableAllProjectMcpServers === true)
|
|
255
|
+
out.push({ key: "enableAllProjectMcpServers", source, path, counts });
|
|
256
|
+
if (stringsIn(data.enabledMcpjsonServers).includes(name))
|
|
257
|
+
out.push({ key: "enabledMcpjsonServers", source, path, counts });
|
|
258
|
+
if (stringsIn(data.disabledMcpjsonServers).includes(name))
|
|
259
|
+
out.push({ key: "disabledMcpjsonServers", source, path, counts: true });
|
|
260
|
+
return out;
|
|
261
|
+
});
|
|
262
|
+
const from = (a) => a.source === "claude-json" ? "your answer in ~/.claude.json" : `${a.source} settings`;
|
|
219
263
|
const set = (server, status, note) => {
|
|
220
264
|
server.status = status;
|
|
221
265
|
server.note = note;
|
|
@@ -244,10 +288,24 @@ export async function readMcpServers(input, plugins) {
|
|
|
244
288
|
set(server, "blocked", managedOnly ? "Not in the managed allowedMcpServers" : "Not in allowedMcpServers");
|
|
245
289
|
else if (disabledHere.includes(server.name))
|
|
246
290
|
set(server, "disabled", "Turned off for this project in /mcp");
|
|
247
|
-
else if (server.scope === "project"
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
else if (server.scope === "project") {
|
|
292
|
+
const answers = answersFor(server.name);
|
|
293
|
+
if (answers.length > 0)
|
|
294
|
+
server.approvals = answers;
|
|
295
|
+
const rejection = answers.find((a) => a.key === "disabledMcpjsonServers");
|
|
296
|
+
const waiting = answers.find((a) => a.key !== "disabledMcpjsonServers" && !a.counts);
|
|
297
|
+
const approved = answers.some((a) => a.key !== "disabledMcpjsonServers" && a.counts);
|
|
298
|
+
if (rejection)
|
|
299
|
+
set(server, "rejected", `Rejected by disabledMcpjsonServers in ${from(rejection)}`);
|
|
300
|
+
// claude -p connects them without asking, approved or not (docs: permissions#what-runs-before-you-trust-a-folder).
|
|
301
|
+
else if (!approved && profile === "print")
|
|
302
|
+
server.note =
|
|
303
|
+
"Not approved, but claude -p connects .mcp.json servers without asking; the SDK does when settingSources includes project";
|
|
304
|
+
else if (!approved)
|
|
305
|
+
set(server, "pending", waiting
|
|
306
|
+
? `Claude Code asks before starting it: ${waiting.key} in ${from(waiting)} counts once you trust this folder`
|
|
307
|
+
: "Claude Code asks before starting servers from .mcp.json: answer its prompt, or list the server in enabledMcpjsonServers in your user settings");
|
|
308
|
+
}
|
|
251
309
|
if (server.status === "active" || server.status === "pending")
|
|
252
310
|
enabledEndpoints.set(endpoint(d), d);
|
|
253
311
|
}
|
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import {} from "../../shared/types.js";
|
|
3
|
+
import {} from "./env.js";
|
|
3
4
|
import { isDir, listDir, parseFrontmatter, readTextFile, revisionOf, shortHash, stringField, } from "./files.js";
|
|
4
5
|
const INDEX_FILE = "MEMORY.md";
|
|
5
6
|
/** Claude Code reads only this much of MEMORY.md at session start (docs: memory#auto-memory). */
|
|
6
7
|
const INDEX_LINE_LIMIT = 200;
|
|
7
8
|
const INDEX_BYTE_LIMIT = 25 * 1024;
|
|
8
9
|
const TYPES = ["user", "feedback", "project", "reference"];
|
|
9
|
-
|
|
10
|
-
export function storeDirName(projectPath) {
|
|
11
|
-
return projectPath.replace(/[^a-zA-Z0-9]/g, "-");
|
|
12
|
-
}
|
|
13
|
-
export async function readMemoryStore(dir, projectPath) {
|
|
10
|
+
export async function readMemoryStore(env, dir, projectPath) {
|
|
14
11
|
if (!(await isDir(dir)))
|
|
15
12
|
return null;
|
|
16
|
-
const indexFile = await readTextFile(join(dir, INDEX_FILE));
|
|
13
|
+
const indexFile = await readTextFile(env, join(dir, INDEX_FILE));
|
|
17
14
|
const files = (await listDir(dir)).filter((f) => f.endsWith(".md") && f !== INDEX_FILE);
|
|
18
15
|
const memories = [];
|
|
19
16
|
for (const file of files) {
|
|
20
|
-
const text = await readTextFile(join(dir, file));
|
|
17
|
+
const text = await readTextFile(env, join(dir, file));
|
|
21
18
|
if (!text)
|
|
22
19
|
continue;
|
|
23
20
|
const { data, body } = parseFrontmatter(text.text);
|
|
@@ -60,7 +57,8 @@ export async function readMemoryStore(dir, projectPath) {
|
|
|
60
57
|
};
|
|
61
58
|
}
|
|
62
59
|
function summarizeIndex(file, files) {
|
|
63
|
-
|
|
60
|
+
// A file's last newline ends its last line; it does not start another one.
|
|
61
|
+
const lines = file.text.replace(/\n$/, "").split("\n");
|
|
64
62
|
let loadedLines = 0;
|
|
65
63
|
let bytes = 0;
|
|
66
64
|
for (const line of lines) {
|
|
@@ -83,7 +81,8 @@ export function deadLinks(line, files) {
|
|
|
83
81
|
.map((m) => m[1] ?? "")
|
|
84
82
|
.filter((f) => !files.includes(f));
|
|
85
83
|
}
|
|
86
|
-
|
|
84
|
+
/** Whether MEMORY.md text points at a memory file, as a Markdown link or a [[name]] link. */
|
|
85
|
+
export function indexMentions(index, file) {
|
|
87
86
|
return index.includes(`(${file})`) || index.includes(`[[${file.replace(/\.md$/, "")}]]`);
|
|
88
87
|
}
|
|
89
88
|
function wikiLinks(body) {
|