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.
Files changed (131) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +9 -5
  3. package/dist/core/agent-edit.d.ts +6 -0
  4. package/dist/{server → core}/agent-edit.js +21 -22
  5. package/dist/core/backups.d.ts +19 -0
  6. package/dist/{server → core}/backups.js +37 -45
  7. package/dist/core/core.d.ts +96 -0
  8. package/dist/core/core.js +57 -0
  9. package/dist/core/index.d.ts +22 -0
  10. package/dist/core/index.js +38 -0
  11. package/dist/core/mcp-edit.d.ts +25 -0
  12. package/dist/{server → core}/mcp-edit.js +116 -95
  13. package/dist/core/mutations.d.ts +20 -0
  14. package/dist/{server → core}/mutations.js +8 -15
  15. package/dist/core/open.d.ts +31 -0
  16. package/dist/{server → core}/open.js +39 -12
  17. package/dist/core/parse-edit.d.ts +12 -0
  18. package/dist/core/parse-edit.js +64 -0
  19. package/dist/core/reference.d.ts +3 -0
  20. package/dist/core/reference.js +11 -0
  21. package/dist/core/scan/compare.d.ts +7 -0
  22. package/dist/{server → core}/scan/compare.js +5 -3
  23. package/dist/core/scan/context.d.ts +13 -0
  24. package/dist/{server → core}/scan/context.js +58 -32
  25. package/dist/core/scan/data.d.ts +8 -0
  26. package/dist/core/scan/data.js +18 -0
  27. package/dist/core/scan/env.d.ts +26 -0
  28. package/dist/{server → core}/scan/env.js +10 -3
  29. package/dist/core/scan/files.d.ts +68 -0
  30. package/dist/core/scan/files.js +270 -0
  31. package/dist/core/scan/git.d.ts +14 -0
  32. package/dist/core/scan/git.js +98 -0
  33. package/dist/core/scan/index.d.ts +5 -0
  34. package/dist/{server → core}/scan/index.js +115 -81
  35. package/dist/core/scan/instructions.d.ts +54 -0
  36. package/dist/{server → core}/scan/instructions.js +141 -73
  37. package/dist/core/scan/inventory.d.ts +38 -0
  38. package/dist/{server → core}/scan/inventory.js +134 -115
  39. package/dist/core/scan/mcp.d.ts +28 -0
  40. package/dist/{server → core}/scan/mcp.js +151 -94
  41. package/dist/core/scan/memory.d.ts +8 -0
  42. package/dist/{server → core}/scan/memory.js +8 -8
  43. package/dist/core/scan/path-test.d.ts +9 -0
  44. package/dist/core/scan/path-test.js +156 -0
  45. package/dist/core/scan/permissions.d.ts +31 -0
  46. package/dist/{server → core}/scan/permissions.js +6 -9
  47. package/dist/core/scan/plugins.d.ts +39 -0
  48. package/dist/{server → core}/scan/plugins.js +36 -27
  49. package/dist/core/scan/projects.d.ts +9 -0
  50. package/dist/{server → core}/scan/projects.js +5 -4
  51. package/dist/core/scan/sandbox.d.ts +14 -0
  52. package/dist/{server → core}/scan/sandbox.js +87 -59
  53. package/dist/core/scan/settings.d.ts +35 -0
  54. package/dist/{server → core}/scan/settings.js +88 -92
  55. package/dist/core/scan/tools.d.ts +10 -0
  56. package/dist/{server → core}/scan/tools.js +28 -20
  57. package/dist/core/scan/usage.d.ts +25 -0
  58. package/dist/{server → core}/scan/usage.js +117 -92
  59. package/dist/core/schema.d.ts +8 -0
  60. package/dist/core/schema.js +67 -0
  61. package/dist/core/secrets.d.ts +44 -0
  62. package/dist/core/secrets.js +187 -0
  63. package/dist/core/settings-edit.d.ts +9 -0
  64. package/dist/core/settings-edit.js +216 -0
  65. package/dist/core/watch.d.ts +45 -0
  66. package/dist/{server → core}/watch.js +129 -20
  67. package/dist/core/write.d.ts +119 -0
  68. package/dist/{server → core}/write.js +63 -8
  69. package/dist/server/args.d.ts +32 -0
  70. package/dist/server/args.js +106 -0
  71. package/dist/server/cli.d.ts +2 -0
  72. package/dist/server/cli.js +58 -83
  73. package/dist/server/commands.d.ts +8 -0
  74. package/dist/server/commands.js +39 -0
  75. package/dist/server/server.d.ts +12 -0
  76. package/dist/server/server.js +80 -195
  77. package/dist/server/start.d.ts +13 -0
  78. package/dist/server/start.js +21 -0
  79. package/dist/shared/compare.d.ts +25 -0
  80. package/dist/shared/memory.d.ts +2 -0
  81. package/dist/shared/requests.d.ts +104 -0
  82. package/dist/shared/requests.js +73 -0
  83. package/dist/shared/scopes.d.ts +10 -0
  84. package/dist/shared/settings-catalog.json +2199 -0
  85. package/dist/shared/types.d.ts +530 -0
  86. package/dist/shared/types.js +11 -0
  87. package/dist/ui/assets/index-CH1HoC38.js +60 -0
  88. package/dist/ui/index.html +1 -1
  89. package/dist/view/diff.d.ts +16 -0
  90. package/dist/view/diff.js +85 -0
  91. package/dist/view/format.d.ts +17 -0
  92. package/dist/view/format.js +47 -0
  93. package/dist/view/index.d.ts +11 -0
  94. package/dist/view/index.js +13 -0
  95. package/dist/view/lines.d.ts +9 -0
  96. package/dist/view/lines.js +12 -0
  97. package/dist/view/path-test.d.ts +3 -0
  98. package/dist/view/path-test.js +7 -0
  99. package/dist/view/sections/catalog.d.ts +9 -0
  100. package/dist/view/sections/catalog.js +47 -0
  101. package/dist/view/sections/extensions.d.ts +1 -0
  102. package/dist/view/sections/extensions.js +48 -0
  103. package/dist/view/sections/hooks.d.ts +1 -0
  104. package/dist/view/sections/hooks.js +32 -0
  105. package/dist/view/sections/index.d.ts +6 -0
  106. package/dist/view/sections/index.js +27 -0
  107. package/dist/view/sections/instructions.d.ts +6 -0
  108. package/dist/view/sections/instructions.js +38 -0
  109. package/dist/view/sections/mcp.d.ts +3 -0
  110. package/dist/view/sections/mcp.js +38 -0
  111. package/dist/view/sections/memory.d.ts +1 -0
  112. package/dist/view/sections/memory.js +31 -0
  113. package/dist/view/sections/permissions.d.ts +17 -0
  114. package/dist/view/sections/permissions.js +87 -0
  115. package/dist/view/sections/settings.d.ts +1 -0
  116. package/dist/view/sections/settings.js +33 -0
  117. package/dist/view/sections/tools.d.ts +6 -0
  118. package/dist/view/sections/tools.js +57 -0
  119. package/dist/view/sections/types.d.ts +123 -0
  120. package/dist/view/sections/types.js +10 -0
  121. package/dist/view/settings.d.ts +12 -0
  122. package/dist/view/settings.js +31 -0
  123. package/dist/view/usage.d.ts +8 -0
  124. package/dist/view/usage.js +18 -0
  125. package/package.json +13 -2
  126. package/dist/server/scan/files.js +0 -478
  127. package/dist/server/scan/path-test.js +0 -121
  128. package/dist/server/schema.js +0 -15
  129. package/dist/server/secrets.js +0 -63
  130. package/dist/server/settings-edit.js +0 -221
  131. package/dist/ui/assets/index-XqJwI3k9.js +0 -60
@@ -1,12 +1,14 @@
1
1
  import { join } from "node:path";
2
+ import { Compile } from "typebox/compile";
2
3
  import {} from "../../shared/types.js";
4
+ import { redactSecrets, shownUrl } from "../secrets.js";
3
5
  import { isConfigHome } from "./env.js";
4
- import { isRecord, readJsonObject, redactSecrets, shownUrl, stringField, stringsIn, wildcard, } from "./files.js";
6
+ import { readJsonOrReport, stringField, stringsIn, wildcard } from "./files.js";
7
+ import { isRecord } from "./data.js";
5
8
  import { manifestConfigs } from "./plugins.js";
6
9
  import serverSchema from "../../shared/mcp-server.schema.json" with { type: "json" };
7
- import { ajv, schemaErrors } from "../schema.js";
8
- import { readManagedSettings } from "./settings.js";
9
- const validateServer = ajv.compile(serverSchema);
10
+ import { schemaErrors } from "../schema.js";
11
+ const validateServer = Compile(serverSchema);
10
12
  /**
11
13
  * What makes a server definition one Claude Code cannot start: a missing command or url, a type it
12
14
  * does not know, or a field the docs define holding the wrong kind of value (docs: mcp). It checks
@@ -29,13 +31,20 @@ export function serverProblems(definition) {
29
31
  problems.push(`A ${type} server needs a "url".`);
30
32
  return [...problems, ...schemaErrors(validateServer, definition, "")];
31
33
  }
34
+ /** What the page shows a server connects to: its URL, or its command line with values hidden. */
35
+ function targetOf(url, command, definition) {
36
+ if (url)
37
+ return shownUrl(url);
38
+ if (!command)
39
+ return "";
40
+ return [command[0], ...stringsIn(definition.args)].join(" ");
41
+ }
32
42
  function define(servers, scope, sourcePath, organisation = false) {
33
43
  if (!isRecord(servers))
34
44
  return [];
35
- return Object.entries(servers)
36
- .filter(([, config]) => isRecord(config))
37
- .map(([name, config]) => {
38
- const c = config;
45
+ return Object.entries(servers).flatMap(([name, c]) => {
46
+ if (!isRecord(c))
47
+ return [];
39
48
  const type = stringField(c, "type");
40
49
  const url = stringField(c, "url") || null;
41
50
  const command = stringField(c, "command")
@@ -47,11 +56,7 @@ function define(servers, scope, sourcePath, organisation = false) {
47
56
  scope,
48
57
  // An entry without a type is read as stdio, even one with a url (docs: mcp).
49
58
  transport: type || "stdio",
50
- target: url
51
- ? shownUrl(url)
52
- : command
53
- ? [command[0], ...stringsIn(definition.args)].join(" ")
54
- : "",
59
+ target: targetOf(url, command, definition),
55
60
  sourcePath,
56
61
  status: "active",
57
62
  definition,
@@ -63,12 +68,14 @@ function define(servers, scope, sourcePath, organisation = false) {
63
68
  }
64
69
  // A ${VAR} in an organisation entry means it still has to pass the allowlist.
65
70
  const usesVariables = JSON.stringify(c).includes("${");
66
- return {
67
- server,
68
- command: type && type !== "stdio" ? null : command,
69
- url: type ? url : null,
70
- organisation: organisation && !usesVariables,
71
- };
71
+ return [
72
+ {
73
+ server,
74
+ command: type && type !== "stdio" ? null : command,
75
+ url: type ? url : null,
76
+ organisation: organisation && !usesVariables,
77
+ },
78
+ ];
72
79
  });
73
80
  }
74
81
  /** Control and invisible formatting characters, such as zero-width spaces and bidi overrides. */
@@ -108,6 +115,8 @@ function managedEntryProblem(name, config) {
108
115
  }
109
116
  /** managedMcpServers from managed settings, with entries Claude Code drops marked invalid. */
110
117
  function defineManaged(servers, sourcePath) {
118
+ if (!isRecord(servers))
119
+ return [];
111
120
  const defined = define(servers, "managed", sourcePath, true);
112
121
  for (const d of defined) {
113
122
  const problem = managedEntryProblem(d.server.name, servers[d.server.name]);
@@ -143,6 +152,10 @@ function policyEntries(value) {
143
152
  typeof e.serverUrl === "string"));
144
153
  }
145
154
  /** A `serverUrl` pattern (docs: managed-mcp#how-serverurl-entries-match). */
155
+ const DEFAULT_PORTS = new Map([
156
+ ["https", "443"],
157
+ ["http", "80"],
158
+ ]);
146
159
  function urlMatches(pattern, url) {
147
160
  const p = /^([^:/]+):\/\/([^/:]+)(?::([^/]*))?(\/.*)?$/.exec(pattern);
148
161
  let u;
@@ -160,7 +173,7 @@ function urlMatches(pattern, url) {
160
173
  return false;
161
174
  if (!wildcard(host.replace(/\.$/, ""), "i").test(u.hostname.replace(/\.$/, "")))
162
175
  return false;
163
- const actualPort = u.port || (scheme_ === "https" ? "443" : scheme_ === "http" ? "80" : "");
176
+ const actualPort = u.port || (DEFAULT_PORTS.get(scheme_) ?? "");
164
177
  // No port: a full hostname means the default port only, a wildcard hostname any port.
165
178
  if (port !== undefined) {
166
179
  if (!wildcard(port).test(actualPort))
@@ -191,64 +204,132 @@ function allowed(list, d) {
191
204
  * server is one that is configured and allowed, not one that is known to work.
192
205
  */
193
206
  export async function readMcpServers(input, plugins) {
194
- const { env, projectDir, layers, claudeJson, trust, profile } = input;
207
+ const { env, layers, claudeJson, trust, profile, problems } = input;
195
208
  const projects = isRecord(claudeJson?.projects) ? claudeJson.projects : {};
196
209
  // Local servers live in the entry under the trust key, even from a subdirectory: claude mcp add
197
210
  // --scope local writes there and claude mcp list reads only that entry, ignoring one for the
198
211
  // subdirectory (observed with v2.1.296; the docs do not say).
199
212
  const entry = projects[trust.root];
200
213
  const project = isRecord(entry) ? entry : {};
201
- const managed = layers.find((l) => l.scope === "managed");
202
- // Checked as written: the layer has header values hidden, which would hide a ${VAR} in one.
203
- const managedServers = (await readManagedSettings(env)).data?.managedMcpServers;
204
214
  const managedFile = join(env.managedDir, "managed-mcp.json");
205
- const managedJson = await readJsonObject(env, managedFile);
206
- const mcpJson = join(projectDir, ".mcp.json");
207
- const shared = await readJsonObject(env, mcpJson);
215
+ const managedJson = await readJsonOrReport(env, managedFile, problems);
208
216
  const defined = [
209
- ...(managedJson.ok ? define(managedJson.data.mcpServers, "managed", managedFile, true) : []),
210
- ...defineManaged(managedServers, managed?.path ?? ""),
217
+ ...(await configuredServers(input, project, managedFile, managedJson)),
218
+ ...(await pluginServers(env, plugins, problems)),
219
+ ];
220
+ // A managed-mcp.json that does not parse is reported; ccdesk cannot tell which servers it lists,
221
+ // so it does not show it taking control.
222
+ const exclusive = managedJson !== null;
223
+ const policy = policyOf(layers);
224
+ const disabledHere = stringsIn(project.disabledMcpServers);
225
+ const answersFor = approvalAnswers(input, project);
226
+ const byName = new Map();
227
+ const enabledEndpoints = new Map();
228
+ for (const d of defined) {
229
+ const { server } = d;
230
+ if (server.status === "invalid")
231
+ continue;
232
+ if (exclusive && !d.organisation && server.scope !== "managed") {
233
+ set(server, "blocked", "managed-mcp.json takes exclusive control of MCP servers");
234
+ continue;
235
+ }
236
+ // Scopes dedupe by name; plugins by endpoint against the enabled servers above them.
237
+ const earlier = server.scope === "plugin" ? enabledEndpoints.get(endpoint(d)) : byName.get(server.name);
238
+ if (earlier) {
239
+ set(server, "shadowed", `The ${earlier.server.scope} definition of ${earlier.server.name} is used instead`);
240
+ continue;
241
+ }
242
+ if (server.scope !== "plugin")
243
+ byName.set(server.name, d);
244
+ const blocked = policy.blocks(d);
245
+ if (blocked)
246
+ set(server, "blocked", blocked);
247
+ else if (disabledHere.includes(server.name))
248
+ set(server, "disabled", "Turned off for this project in /mcp");
249
+ else if (server.scope === "project")
250
+ approve(server, answersFor(server.name), profile);
251
+ if (server.status === "active" || server.status === "pending")
252
+ enabledEndpoints.set(endpoint(d), d);
253
+ }
254
+ return defined.map((d) => d.server);
255
+ }
256
+ function set(server, status, note) {
257
+ server.status = status;
258
+ server.note = note;
259
+ }
260
+ /** The servers your settings, ~/.claude.json and the project define, in precedence order. */
261
+ async function configuredServers(input, project, managedFile, managedJson) {
262
+ const { env, projectDir, layers, claudeJson, problems } = input;
263
+ const managed = layers.find((l) => l.scope === "managed");
264
+ const mcpJson = join(projectDir, ".mcp.json");
265
+ const shared = await readJsonOrReport(env, mcpJson, problems);
266
+ return [
267
+ ...(managedJson ? define(managedJson.mcpServers, "managed", managedFile, true) : []),
268
+ // Checked as written: the layer has header values hidden, which would hide a ${VAR} in one.
269
+ ...defineManaged(input.managed?.managedMcpServers, managed?.path ?? ""),
211
270
  ...define(project.mcpServers, "local", env.claudeJson),
212
- ...(shared.ok ? define(shared.data.mcpServers, "project", mcpJson) : []),
271
+ ...(shared ? define(shared.mcpServers, "project", mcpJson) : []),
213
272
  ...define(claudeJson?.mcpServers, "user", env.claudeJson),
214
273
  ];
215
- for (const plugin of plugins) {
274
+ }
275
+ /** The servers each enabled plugin brings, from its .mcp.json and its manifest. */
276
+ async function pluginServers(env, plugins, problems) {
277
+ const defined = [];
278
+ for (const loaded of plugins) {
279
+ const { plugin } = loaded;
216
280
  const path = join(plugin.installPath, ".mcp.json");
217
- const file = await readJsonObject(env, path);
281
+ const file = await readJsonOrReport(env, path, problems);
218
282
  // A server name declared later in the same plugin replaces an earlier one.
219
283
  const own = new Map();
220
284
  const add = (list) => {
221
285
  for (const d of list)
222
286
  own.set(d.server.name, d);
223
287
  };
224
- if (file.ok)
225
- add(define(file.data.mcpServers ?? file.data, "plugin", path));
226
- for (const part of await manifestConfigs(env, plugin, "mcpServers"))
288
+ if (file)
289
+ add(define(file.mcpServers ?? file, "plugin", path));
290
+ for (const part of await manifestConfigs(env, loaded, "mcpServers", problems))
227
291
  add(define(part.value, "plugin", part.path));
228
292
  for (const d of own.values())
229
293
  d.server.plugin = plugin.id;
230
294
  defined.push(...own.values());
231
295
  }
232
- const exclusive = managedJson.ok;
296
+ return defined;
297
+ }
298
+ /** deniedMcpServers and allowedMcpServers across the settings: why a server is blocked, if it is. */
299
+ function policyOf(layers) {
233
300
  const denied = layers.flatMap((l) => policyEntries(l.data?.deniedMcpServers));
234
- const managedOnly = managed?.data?.allowManagedMcpServersOnly === true;
301
+ const managedOnly = layers.find((l) => l.scope === "managed")?.data?.allowManagedMcpServersOnly === true;
235
302
  const allowLayers = layers.filter((l) => !managedOnly || l.scope === "managed");
236
303
  const allowSet = allowLayers.some((l) => Array.isArray(l.data?.allowedMcpServers));
237
304
  const allowList = allowLayers.flatMap((l) => policyEntries(l.data?.allowedMcpServers));
238
- const disabledHere = stringsIn(project.disabledMcpServers);
239
- // A cloned repository cannot approve its own servers: project and local approvals count once the
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.
305
+ return {
306
+ blocks: (d) => {
307
+ if (denied.some((e) => matches(e, d)))
308
+ return "Blocked by deniedMcpServers";
309
+ if (allowSet && !d.organisation && !allowed(allowList, d))
310
+ return managedOnly ? "Not in the managed allowedMcpServers" : "Not in allowedMcpServers";
311
+ return null;
312
+ },
313
+ };
314
+ }
315
+ /**
316
+ * The answers about a .mcp.json server, each with whether it counts here. A cloned repository
317
+ * cannot approve its own servers: project and local approvals count once the folder is trusted,
318
+ * local ones in your configuration home right away (docs: mcp#project-server-approvals-and-workspace-trust).
319
+ * A disabledMcpjsonServers entry in any file rejects the server. Your answers to the .mcp.json
320
+ * prompt sit in the project entry: claude mcp list counts an enableAllProjectMcpServers there, and
321
+ * claude mcp reset-project-choices clears it (v2.1.296).
322
+ */
323
+ function approvalAnswers(input, project) {
324
+ const { env, projectDir, layers, trust } = input;
242
325
  const approvalCounts = (scope) => (scope !== "project" && scope !== "local") ||
243
326
  trust.trusted ||
244
327
  (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
328
  const sources = [
248
329
  ...layers.flatMap((l) => (l.data ? [{ source: l.scope, path: l.path, data: l.data }] : [])),
249
330
  { source: "claude-json", path: env.claudeJson, data: project },
250
331
  ];
251
- const answersFor = (name) => sources.flatMap(({ source, path, data }) => {
332
+ return (name) => sources.flatMap(({ source, path, data }) => {
252
333
  const counts = source === "claude-json" || approvalCounts(source);
253
334
  const out = [];
254
335
  if (data.enableAllProjectMcpServers === true)
@@ -259,55 +340,31 @@ export async function readMcpServers(input, plugins) {
259
340
  out.push({ key: "disabledMcpjsonServers", source, path, counts: true });
260
341
  return out;
261
342
  });
262
- const from = (a) => a.source === "claude-json" ? "your answer in ~/.claude.json" : `${a.source} settings`;
263
- const set = (server, status, note) => {
264
- server.status = status;
265
- server.note = note;
266
- };
267
- const byName = new Map();
268
- const enabledEndpoints = new Map();
269
- for (const d of defined) {
270
- const { server } = d;
271
- if (server.status === "invalid")
272
- continue;
273
- if (exclusive && !d.organisation && server.scope !== "managed") {
274
- set(server, "blocked", "managed-mcp.json takes exclusive control of MCP servers");
275
- continue;
276
- }
277
- // Scopes dedupe by name; plugins by endpoint against the enabled servers above them.
278
- const earlier = server.scope === "plugin" ? enabledEndpoints.get(endpoint(d)) : byName.get(server.name);
279
- if (earlier) {
280
- set(server, "shadowed", `The ${earlier.server.scope} definition of ${earlier.server.name} is used instead`);
281
- continue;
282
- }
283
- if (server.scope !== "plugin")
284
- byName.set(server.name, d);
285
- if (denied.some((e) => matches(e, d)))
286
- set(server, "blocked", "Blocked by deniedMcpServers");
287
- else if (allowSet && !d.organisation && !allowed(allowList, d))
288
- set(server, "blocked", managedOnly ? "Not in the managed allowedMcpServers" : "Not in allowedMcpServers");
289
- else if (disabledHere.includes(server.name))
290
- set(server, "disabled", "Turned off for this project in /mcp");
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
- }
309
- if (server.status === "active" || server.status === "pending")
310
- enabledEndpoints.set(endpoint(d), d);
343
+ }
344
+ /** Where an approval answer was given, as the page names it. */
345
+ function answerSource(a) {
346
+ return a.source === "claude-json" ? "your answer in ~/.claude.json" : `${a.source} settings`;
347
+ }
348
+ /** Marks a .mcp.json server rejected, waiting for approval, or left active by its answers. */
349
+ function approve(server, answers, profile) {
350
+ if (answers.length > 0)
351
+ server.approvals = answers;
352
+ const rejection = answers.find((a) => a.key === "disabledMcpjsonServers");
353
+ const waiting = answers.find((a) => a.key !== "disabledMcpjsonServers" && !a.counts);
354
+ const approved = answers.some((a) => a.key !== "disabledMcpjsonServers" && a.counts);
355
+ if (rejection) {
356
+ set(server, "rejected", `Rejected by disabledMcpjsonServers in ${answerSource(rejection)}`);
357
+ return;
311
358
  }
312
- return defined.map((d) => d.server);
359
+ if (approved)
360
+ return;
361
+ // claude -p connects them without asking, approved or not (docs: permissions#what-runs-before-you-trust-a-folder).
362
+ if (profile === "print") {
363
+ server.note =
364
+ "Not approved, but claude -p connects .mcp.json servers without asking; the SDK does when settingSources includes project";
365
+ return;
366
+ }
367
+ set(server, "pending", waiting
368
+ ? `Claude Code asks before starting it: ${waiting.key} in ${answerSource(waiting)} counts once you trust this folder`
369
+ : "Claude Code asks before starting servers from .mcp.json: answer its prompt, or list the server in enabledMcpjsonServers in your user settings");
313
370
  }
@@ -0,0 +1,8 @@
1
+ import { type MemoryStore, type Problem } from "../../shared/types.ts";
2
+ import { type ScanEnv } from "./env.ts";
3
+ /** The memory files in `dir`; a file that is there but cannot be read goes into `problems`. */
4
+ export declare function readMemoryStore(env: ScanEnv, dir: string, projectPath: string | null, problems?: Problem[]): Promise<MemoryStore | null>;
5
+ /** Memory files an index line links to that are not in the store. */
6
+ export declare function deadLinks(line: string, files: string[]): string[];
7
+ /** Whether MEMORY.md text points at a memory file, as a Markdown link or a [[name]] link. */
8
+ export declare function indexMentions(index: string, file: string): boolean;
@@ -1,33 +1,33 @@
1
1
  import { join } from "node:path";
2
2
  import {} from "../../shared/types.js";
3
3
  import {} from "./env.js";
4
- import { isDir, listDir, parseFrontmatter, readTextFile, revisionOf, shortHash, stringField, } from "./files.js";
4
+ import { isDir, listDir, readTextFile, revisionOf, shortHash, stringField } from "./files.js";
5
+ import { isRecord, parseFrontmatter } from "./data.js";
5
6
  const INDEX_FILE = "MEMORY.md";
6
7
  /** Claude Code reads only this much of MEMORY.md at session start (docs: memory#auto-memory). */
7
8
  const INDEX_LINE_LIMIT = 200;
8
9
  const INDEX_BYTE_LIMIT = 25 * 1024;
9
10
  const TYPES = ["user", "feedback", "project", "reference"];
10
- export async function readMemoryStore(env, dir, projectPath) {
11
+ /** The memory files in `dir`; a file that is there but cannot be read goes into `problems`. */
12
+ export async function readMemoryStore(env, dir, projectPath, problems) {
11
13
  if (!(await isDir(dir)))
12
14
  return null;
13
- const indexFile = await readTextFile(env, join(dir, INDEX_FILE));
15
+ const indexFile = await readTextFile(env, join(dir, INDEX_FILE), problems);
14
16
  const files = (await listDir(dir)).filter((f) => f.endsWith(".md") && f !== INDEX_FILE);
15
17
  const memories = [];
16
18
  for (const file of files) {
17
- const text = await readTextFile(env, join(dir, file));
19
+ const text = await readTextFile(env, join(dir, file), problems);
18
20
  if (!text)
19
21
  continue;
20
22
  const { data, body } = parseFrontmatter(text.text);
21
- const metadata = typeof data.metadata === "object" && data.metadata !== null
22
- ? data.metadata
23
- : {};
23
+ const metadata = isRecord(data.metadata) ? data.metadata : {};
24
24
  const rawType = stringField(metadata, "type") || stringField(data, "type");
25
25
  memories.push({
26
26
  file,
27
27
  path: text.path,
28
28
  name: stringField(data, "name") || file.replace(/\.md$/, ""),
29
29
  description: stringField(data, "description"),
30
- type: TYPES.includes(rawType) ? rawType : "unknown",
30
+ type: TYPES.find((t) => t === rawType) ?? "unknown",
31
31
  body,
32
32
  mtime: text.mtime,
33
33
  bytes: text.bytes,
@@ -0,0 +1,9 @@
1
+ import { type DirectoryView, type PathTest } from "../../shared/types.ts";
2
+ import { type ScanEnv } from "./env.ts";
3
+ /**
4
+ * What Claude Code adds to context once Claude reads or edits `file` in a session started in `dir`:
5
+ * path-scoped rules, skills with matching `paths`, and CLAUDE.md files in the folders between.
6
+ */
7
+ export declare function testPath(env: ScanEnv, dir: string, file: string,
8
+ /** The view of the start directory; a server passes one that reuses a current scan. */
9
+ viewOf?: (cwd: string) => Promise<DirectoryView>): Promise<PathTest>;
@@ -0,0 +1,156 @@
1
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
2
+ import {} from "../../shared/types.js";
3
+ import {} from "./env.js";
4
+ import { isFile, isWithin, listDir, MAX_TEXT_BYTES, readTextFile, resolvedPath, stringsIn, toStringList, walkMarkdown, } from "./files.js";
5
+ import { parseFrontmatter } from "./data.js";
6
+ import { scanDirectory } from "./index.js";
7
+ import { AGENTS_MD_NAMES, agentsMdLoads, CLAUDE_MD_NAMES, isExcluded, linkedRuleBlock, ruleTarget, safeMatch, } from "./instructions.js";
8
+ import { applySkillOverrides, readSkill } from "./inventory.js";
9
+ /** Globs are written with forward slashes on every platform. */
10
+ function slashed(path) {
11
+ return path.split(sep).join("/");
12
+ }
13
+ /** An invalid pattern matches nothing; the others still count (docs: memory#path-specific-rules). */
14
+ function firstMatch(globs, path) {
15
+ return globs.find((glob) => safeMatch(path, glob));
16
+ }
17
+ /**
18
+ * What Claude Code adds to context once Claude reads or edits `file` in a session started in `dir`:
19
+ * path-scoped rules, skills with matching `paths`, and CLAUDE.md files in the folders between.
20
+ */
21
+ export async function testPath(env, dir, file,
22
+ /** The view of the start directory; a server passes one that reuses a current scan. */
23
+ viewOf = (cwd) => scanDirectory(env, cwd)) {
24
+ const cwd = resolve(dir);
25
+ const target = isAbsolute(file) ? resolve(file) : resolve(cwd, file);
26
+ if (target === cwd || !isWithin(cwd, target))
27
+ return { file: target, inside: false, exists: false, matches: [] };
28
+ const view = await viewOf(cwd);
29
+ const projectRoot = view.gitRoot ?? cwd;
30
+ const test = {
31
+ env,
32
+ cwd,
33
+ target,
34
+ realCwd: await resolvedPath(cwd),
35
+ approved: view.trust.externalImports === "approved",
36
+ mode: view.agentsMdMode,
37
+ excludes: stringsIn(view.effective.find((e) => e.key === "claudeMdExcludes")?.value),
38
+ overrides: view.effective.find((e) => e.key === "skillOverrides")?.value,
39
+ startSkills: new Set(view.inventory.skills.filter((s) => s.scope !== "plugin").map((s) => s.name)),
40
+ };
41
+ const matches = startRuleMatches(view, target, projectRoot);
42
+ // Folders below the start directory load their own instructions, rules and skills on demand.
43
+ const between = [];
44
+ for (let d = dirname(target); d !== cwd && isWithin(cwd, d); d = dirname(d))
45
+ between.unshift(d);
46
+ for (const folder of between)
47
+ matches.push(...(await nestedInstructions(test, folder)), ...(await nestedRules(test, folder)), ...(await nestedSkills(test, folder)));
48
+ matches.push(...startSkillMatches(view, target, projectRoot));
49
+ return { file: target, inside: true, exists: await isFile(target), matches };
50
+ }
51
+ /** Path rules Claude Code would use at all: not excluded, not waiting for approval. */
52
+ function startRuleMatches(view, target, projectRoot) {
53
+ const matches = [];
54
+ for (const rule of view.instructions) {
55
+ if (!rule.paths || rule.status !== "on-match")
56
+ continue;
57
+ // A project rule's globs are relative to the folder holding its .claude; others to the project.
58
+ const base = rule.scope === "project" || rule.scope === "local" ? rule.dir : projectRoot;
59
+ const glob = firstMatch(rule.paths, slashed(relative(base, target)));
60
+ if (glob)
61
+ matches.push({ kind: "rule", path: rule.path, scope: rule.scope, glob });
62
+ }
63
+ return matches;
64
+ }
65
+ /** A folder's CLAUDE.md, CLAUDE.local.md and AGENTS.md files that load on demand. */
66
+ async function nestedInstructions(test, folder) {
67
+ const { env, excludes, mode } = test;
68
+ const matches = [];
69
+ /** Whether the file is there; an excluded one or one over the limit is there but not loaded. */
70
+ const nested = async (name, scope) => {
71
+ const path = join(folder, name);
72
+ const text = await readTextFile(env, path);
73
+ if (!text)
74
+ return false;
75
+ if (!isExcluded(excludes, path, text.realPath) && text.bytes <= MAX_TEXT_BYTES)
76
+ matches.push({ kind: "nested", path, scope });
77
+ return true;
78
+ };
79
+ // managed-only leaves out files at launch, but a subfolder's CLAUDE.md and rules still load
80
+ // on demand (docs: memory#choose-which-instruction-files-load).
81
+ let hasClaudeMd = false;
82
+ for (const name of CLAUDE_MD_NAMES)
83
+ if (await nested(name, "project"))
84
+ hasClaudeMd = true;
85
+ if (mode !== "managed-only" && (await nested("CLAUDE.local.md", "local")))
86
+ hasClaudeMd = true;
87
+ if (agentsMdLoads(mode, hasClaudeMd))
88
+ for (const name of AGENTS_MD_NAMES)
89
+ await nested(name, "project");
90
+ return matches;
91
+ }
92
+ /** A folder's rules that load for the file: ones without paths, and ones whose paths match it. */
93
+ async function nestedRules(test, folder) {
94
+ const { env, target, realCwd, approved, excludes } = test;
95
+ const matches = [];
96
+ for (const path of await walkMarkdown(join(folder, ".claude", "rules"))) {
97
+ const text = await readTextFile(env, path);
98
+ if (!text || text.bytes > MAX_TEXT_BYTES)
99
+ continue;
100
+ // As at startup: excluded by its target's path too, and one linked from outside the project
101
+ // waits for your approval, then loads only without paths.
102
+ const linked = await ruleTarget(text, folder, realCwd);
103
+ if (isExcluded(excludes, path, linked))
104
+ continue;
105
+ const globs = toStringList(parseFrontmatter(text.text).data.paths);
106
+ if (linkedRuleBlock(linked, realCwd, approved, globs.length > 0))
107
+ continue;
108
+ const glob = globs.length === 0 ? undefined : firstMatch(globs, slashed(relative(folder, target)));
109
+ if (globs.length === 0 || glob)
110
+ matches.push({ kind: "nested", path, scope: "project", ...(glob ? { glob } : {}) });
111
+ }
112
+ return matches;
113
+ }
114
+ /**
115
+ * A folder's skills Claude may pick itself: not off, user-invocable-only or
116
+ * disable-model-invocation; one with paths only for a matching file, its globs read from its
117
+ * folder like a rule's.
118
+ */
119
+ async function nestedSkills(test, folder) {
120
+ const { env, cwd, target, overrides, startSkills } = test;
121
+ const matches = [];
122
+ for (const name of await listDir(join(folder, ".claude", "skills"))) {
123
+ const skill = await readSkill(env, join(folder, ".claude", "skills", name), "project");
124
+ if (!skill)
125
+ continue;
126
+ applySkillOverrides([skill], overrides);
127
+ if (skill.listed === false)
128
+ continue;
129
+ const glob = skill.paths && firstMatch(skill.paths, slashed(relative(folder, target)));
130
+ if (skill.paths && !glob)
131
+ continue;
132
+ // A nested skill whose folder name another skill has is called by its folder as well, as
133
+ // apps/web:deploy (docs: skills#how-a-skill-gets-its-command-name).
134
+ matches.push({
135
+ kind: "skill",
136
+ path: skill.path,
137
+ scope: "project",
138
+ name: startSkills.has(name) ? `${slashed(relative(cwd, folder))}:${name}` : skill.name,
139
+ ...(glob ? { glob } : {}),
140
+ });
141
+ }
142
+ return matches;
143
+ }
144
+ /** Skills Claude can pick on its own; one with disable-model-invocation only runs when you call it. */
145
+ function startSkillMatches(view, target, projectRoot) {
146
+ return view.inventory.skills.flatMap((skill) => {
147
+ if (!skill.paths || skill.listed === false || skill.shadowedBy)
148
+ return [];
149
+ const glob = firstMatch(skill.paths, slashed(relative(projectRoot, target)));
150
+ if (!glob)
151
+ return [];
152
+ return [
153
+ { kind: "skill", path: skill.path, scope: skill.scope, glob, name: skill.id ?? skill.name },
154
+ ];
155
+ });
156
+ }
@@ -0,0 +1,31 @@
1
+ import { type Permission, type PermissionList, type Scope, type SessionProfile, type SettingsLayer, type Trust } from "../../shared/types.ts";
2
+ /**
3
+ * Whether a file's trust-gated settings (permission grants, autoMemoryDirectory) wait for
4
+ * the workspace trust dialog. Claude Code runs git to tell a private local file from a committed one
5
+ * only once the folder or a parent is trusted, or in claude -p and the SDK, which count as accepted;
6
+ * until then the local file waits too, except in your configuration home. A committed local file
7
+ * waits like project settings, which -p and the SDK never apply in a folder you have not trusted
8
+ * (docs: permissions#when-your-local-settings-file-needs-trust, #what-runs-before-you-trust-a-folder).
9
+ */
10
+ export declare function trustWaits({ trust, inConfigHome, localFromRepository, profile, }: Options): (scope: Scope) => boolean;
11
+ /**
12
+ * An MCP rule with a parameter, which Claude Code skips when it reads a settings file; such a rule
13
+ * works only from --disallowedTools (docs: permissions#match-by-input-parameter).
14
+ */
15
+ export declare function isSkippedMcpRule(rule: string): boolean;
16
+ /** Claude Code checks every file's rules together: deny, then ask, then allow (docs: permissions). */
17
+ export declare const CHECK_ORDER: PermissionList[];
18
+ export interface Options {
19
+ trust: Trust;
20
+ /** Claude Code started in your configuration home, where the local file applies without the trust step. */
21
+ inConfigHome: boolean;
22
+ /** The local settings file is tracked in git, so Claude Code treats it like repository settings. */
23
+ localFromRepository: boolean;
24
+ profile: SessionProfile;
25
+ }
26
+ /**
27
+ * Every permission entry from every settings file, each marked with whether Claude Code applies it
28
+ * (docs: permissions#project-allow-rules-and-workspace-trust, settings-reference#permissions-defaultmode,
29
+ * #allowmanagedpermissionrulesonly).
30
+ */
31
+ export declare function permissionsOf(layers: SettingsLayer[], options: Options): Permission[];
@@ -1,6 +1,7 @@
1
1
  import { HIGHEST_FIRST } from "../../shared/scopes.js";
2
2
  import {} from "../../shared/types.js";
3
- import { isRecord, stringsIn } from "./files.js";
3
+ import { stringsIn } from "./files.js";
4
+ import { isRecord } from "./data.js";
4
5
  import { ignoredModeReason } from "./settings.js";
5
6
  /**
6
7
  * Whether a file's trust-gated settings (permission grants, autoMemoryDirectory) wait for
@@ -17,7 +18,7 @@ export function trustWaits({ trust, inConfigHome, localFromRepository, profile,
17
18
  }
18
19
  /**
19
20
  * An MCP rule with a parameter, which Claude Code skips when it reads a settings file; such a rule
20
- * works only from --disallowedTools (docs: permissions#match-specific-tool-uses).
21
+ * works only from --disallowedTools (docs: permissions#match-by-input-parameter).
21
22
  */
22
23
  export function isSkippedMcpRule(rule) {
23
24
  return /^mcp__[^(]*\(/.test(rule);
@@ -30,13 +31,9 @@ export const CHECK_ORDER = ["deny", "ask", "allow"];
30
31
  * #allowmanagedpermissionrulesonly).
31
32
  */
32
33
  export function permissionsOf(layers, options) {
33
- const sources = HIGHEST_FIRST.flatMap((scope) => layers
34
- .filter((l) => l.scope === scope && isRecord(l.data?.permissions))
35
- .map((l) => ({
36
- scope,
37
- path: l.path,
38
- permissions: l.data?.permissions,
39
- })));
34
+ const sources = HIGHEST_FIRST.flatMap((scope) => layers.flatMap((l) => l.scope === scope && isRecord(l.data?.permissions)
35
+ ? [{ scope, path: l.path, permissions: l.data.permissions }]
36
+ : []));
40
37
  const managedOnly = layers.some((l) => l.scope === "managed" && l.data?.allowManagedPermissionRulesOnly === true);
41
38
  // Grants from repository files wait for the workspace trust dialog; deny and ask apply at once.
42
39
  const waitsForTrust = trustWaits(options);