ccdesk 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +84 -67
  3. package/dist/server/agent-edit.js +77 -0
  4. package/dist/server/backups.js +129 -52
  5. package/dist/server/cli.js +21 -8
  6. package/dist/server/mcp-edit.js +263 -0
  7. package/dist/server/mutations.js +8 -3
  8. package/dist/server/scan/compare.js +34 -0
  9. package/dist/server/scan/context.js +6 -5
  10. package/dist/server/scan/env.js +8 -3
  11. package/dist/server/scan/files.js +118 -30
  12. package/dist/server/scan/index.js +29 -15
  13. package/dist/server/scan/instructions.js +11 -7
  14. package/dist/server/scan/inventory.js +134 -42
  15. package/dist/server/scan/mcp.js +88 -30
  16. package/dist/server/scan/memory.js +8 -9
  17. package/dist/server/scan/path-test.js +32 -10
  18. package/dist/server/scan/permissions.js +22 -6
  19. package/dist/server/scan/plugins.js +18 -11
  20. package/dist/server/scan/projects.js +7 -6
  21. package/dist/server/scan/sandbox.js +161 -0
  22. package/dist/server/scan/settings.js +33 -20
  23. package/dist/server/scan/tools.js +7 -3
  24. package/dist/server/scan/usage.js +119 -46
  25. package/dist/server/schema.js +15 -0
  26. package/dist/server/secrets.js +63 -0
  27. package/dist/server/server.js +222 -128
  28. package/dist/server/settings-edit.js +90 -21
  29. package/dist/server/watch.js +52 -10
  30. package/dist/server/write.js +58 -20
  31. package/dist/shared/compare.js +1 -0
  32. package/dist/shared/hook-events.json +35 -0
  33. package/dist/shared/mcp-server.schema.json +25 -0
  34. package/dist/shared/memory.js +4 -0
  35. package/dist/shared/scopes.js +3 -1
  36. package/dist/shared/types.js +1 -1
  37. package/dist/ui/assets/hanken-grotesk-cyrillic-ext-wght-normal-D_UHSL_T.woff2 +0 -0
  38. package/dist/ui/assets/index-BQ3Lhx8z.js +60 -0
  39. package/dist/ui/assets/index-BRgGlpjd.css +1 -0
  40. package/dist/ui/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  41. package/dist/ui/index.html +2 -2
  42. package/package.json +10 -4
  43. package/dist/ui/assets/index-BM4K7hSO.css +0 -1
  44. 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
- /** A `.md` file as an agent, command or output style. */
14
- async function readEntry(path, name, scope, plugin) {
15
- const file = await readTextFile(path);
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") || 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 fallback = relative(base, path).replace(/\.md$/, "").split(/[/\\]/).join(separator);
54
- const entry = await readEntry(path, fallback, scope, plugin);
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, options.plugin);
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 skills = rootSkill ? await readSkills(root, "plugin", plugin.id) : [];
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", plugin.id)) ?? {
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 = [plugin.id.split("@")[0], ...subfolders.filter(Boolean), own].join(":");
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
- for (const command of commands)
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 files("output-styles")),
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") || stringField(h, "prompt") || stringField(h, "url"),
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)
@@ -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 ? shownUrl(url) : (command?.join(" ") ?? ""),
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
- if (!type && url) {
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
- export function endpoint(d) {
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
- const entry = [trust.root, projectDir].map((key) => projects[key]).find(isRecord);
166
- const project = entry ?? {};
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 directory right away (docs: mcp#project-server-approvals-and-workspace-trust).
207
- const approvalLayers = layers.filter((l) => (l.scope !== "project" && l.scope !== "local") ||
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
- (l.scope === "local" && projectDir === env.home));
210
- const approveAll = approvalLayers.some((l) => l.data?.enableAllProjectMcpServers === true);
211
- const approved = new Set([
212
- ...approvalLayers.flatMap((l) => stringsIn(l.data?.enabledMcpjsonServers)),
213
- ...stringsIn(project.enabledMcpjsonServers),
214
- ]);
215
- const rejected = new Set([
216
- ...layers.flatMap((l) => stringsIn(l.data?.disabledMcpjsonServers)),
217
- ...stringsIn(project.disabledMcpjsonServers),
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" && rejected.has(server.name))
248
- set(server, "rejected", "Rejected by disabledMcpjsonServers");
249
- else if (server.scope === "project" && !approveAll && !approved.has(server.name))
250
- set(server, "pending", "Claude Code asks before starting servers from .mcp.json");
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
- /** Claude Code names a project's store after its path with every non-alphanumeric character as "-". */
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
- const lines = file.text.split("\n");
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
- function indexMentions(index, file) {
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) {