@intentius/chant 0.52.1 → 0.53.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 (149) hide show
  1. package/dist/agents/checks.d.ts +35 -0
  2. package/dist/agents/checks.d.ts.map +1 -0
  3. package/dist/agents/discover.d.ts +86 -0
  4. package/dist/agents/discover.d.ts.map +1 -0
  5. package/dist/agents/importer.d.ts +46 -0
  6. package/dist/agents/importer.d.ts.map +1 -0
  7. package/dist/agents/index.d.ts +14 -0
  8. package/dist/agents/index.d.ts.map +1 -0
  9. package/dist/agents/types.d.ts +196 -0
  10. package/dist/agents/types.d.ts.map +1 -0
  11. package/dist/audit/catalog.d.ts +4 -1
  12. package/dist/audit/catalog.d.ts.map +1 -1
  13. package/dist/audit/report.d.ts +8 -0
  14. package/dist/audit/report.d.ts.map +1 -1
  15. package/dist/audit/rules-doc.d.ts.map +1 -1
  16. package/dist/cdk/advise.d.ts +29 -0
  17. package/dist/cdk/advise.d.ts.map +1 -0
  18. package/dist/cdk/assembly.d.ts +38 -0
  19. package/dist/cdk/assembly.d.ts.map +1 -0
  20. package/dist/cdk/graph.d.ts +68 -0
  21. package/dist/cdk/graph.d.ts.map +1 -0
  22. package/dist/cdk/tier-map.d.ts +44 -0
  23. package/dist/cdk/tier-map.d.ts.map +1 -0
  24. package/dist/cdk/types.d.ts +114 -0
  25. package/dist/cdk/types.d.ts.map +1 -0
  26. package/dist/cli/commands/audit-agents.d.ts +83 -0
  27. package/dist/cli/commands/audit-agents.d.ts.map +1 -0
  28. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  29. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  30. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  31. package/dist/cli/commands/carve.d.ts +48 -6
  32. package/dist/cli/commands/carve.d.ts.map +1 -1
  33. package/dist/cli/commands/import-agents.d.ts +64 -0
  34. package/dist/cli/commands/import-agents.d.ts.map +1 -0
  35. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  36. package/dist/cli/handlers/carve.d.ts +5 -4
  37. package/dist/cli/handlers/carve.d.ts.map +1 -1
  38. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  39. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  40. package/dist/cli/handlers/misc.d.ts.map +1 -1
  41. package/dist/cli/main.d.ts.map +1 -1
  42. package/dist/cli/registry.d.ts +15 -0
  43. package/dist/cli/registry.d.ts.map +1 -1
  44. package/dist/identity.d.ts +196 -0
  45. package/dist/identity.d.ts.map +1 -0
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/lexicon.d.ts +52 -0
  49. package/dist/lexicon.d.ts.map +1 -1
  50. package/dist/terraform/adopt-state.d.ts +17 -63
  51. package/dist/terraform/adopt-state.d.ts.map +1 -1
  52. package/dist/terraform/aws-resources.d.ts +1 -1
  53. package/dist/terraform/bridge.d.ts.map +1 -1
  54. package/dist/terraform/carve-provider.d.ts +142 -0
  55. package/dist/terraform/carve-provider.d.ts.map +1 -0
  56. package/dist/terraform/carve.d.ts +36 -3
  57. package/dist/terraform/carve.d.ts.map +1 -1
  58. package/dist/terraform/emit-source.d.ts +25 -0
  59. package/dist/terraform/emit-source.d.ts.map +1 -0
  60. package/dist/terraform/graduate.d.ts +11 -1
  61. package/dist/terraform/graduate.d.ts.map +1 -1
  62. package/dist/terraform/providers/aws.d.ts +19 -0
  63. package/dist/terraform/providers/aws.d.ts.map +1 -0
  64. package/dist/terraform/providers/gcp.d.ts +41 -0
  65. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  66. package/dist/terraform/providers/index.d.ts +15 -0
  67. package/dist/terraform/providers/index.d.ts.map +1 -0
  68. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  69. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  70. package/dist/terraform/score.d.ts +70 -4
  71. package/dist/terraform/score.d.ts.map +1 -1
  72. package/dist/terraform/tier-map.d.ts +38 -26
  73. package/dist/terraform/tier-map.d.ts.map +1 -1
  74. package/dist/terraform/types.d.ts +6 -0
  75. package/dist/terraform/types.d.ts.map +1 -1
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +6 -1
  78. package/src/agents/checks.test.ts +228 -0
  79. package/src/agents/checks.ts +429 -0
  80. package/src/agents/discover.test.ts +310 -0
  81. package/src/agents/discover.ts +939 -0
  82. package/src/agents/importer.ts +49 -0
  83. package/src/agents/index.ts +29 -0
  84. package/src/agents/types.ts +207 -0
  85. package/src/audit/catalog.ts +90 -1
  86. package/src/audit/report.ts +9 -1
  87. package/src/audit/rules-doc.ts +6 -0
  88. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  89. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  90. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  91. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  92. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  93. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  94. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  95. package/src/cdk/advise.test.ts +208 -0
  96. package/src/cdk/advise.ts +44 -0
  97. package/src/cdk/assembly.ts +133 -0
  98. package/src/cdk/graph.test.ts +206 -0
  99. package/src/cdk/graph.ts +525 -0
  100. package/src/cdk/tier-map.ts +71 -0
  101. package/src/cdk/types.ts +115 -0
  102. package/src/cli/commands/audit-agents.test.ts +260 -0
  103. package/src/cli/commands/audit-agents.ts +387 -0
  104. package/src/cli/commands/carve-apply.ts +20 -4
  105. package/src/cli/commands/carve-bridge.test.ts +30 -0
  106. package/src/cli/commands/carve-bridge.ts +24 -2
  107. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  108. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  109. package/src/cli/commands/carve-emit.test.ts +72 -1
  110. package/src/cli/commands/carve-emit.ts +55 -28
  111. package/src/cli/commands/carve.ts +139 -36
  112. package/src/cli/commands/import-agents.test.ts +208 -0
  113. package/src/cli/commands/import-agents.ts +196 -0
  114. package/src/cli/handlers/carve-emit.ts +8 -1
  115. package/src/cli/handlers/carve.ts +8 -7
  116. package/src/cli/handlers/lifecycle.test.ts +187 -1
  117. package/src/cli/handlers/lifecycle.ts +125 -1
  118. package/src/cli/handlers/misc.ts +111 -0
  119. package/src/cli/main.ts +29 -5
  120. package/src/cli/registry.ts +15 -0
  121. package/src/identity.test.ts +199 -0
  122. package/src/identity.ts +346 -0
  123. package/src/index.ts +1 -0
  124. package/src/lexicon.ts +65 -0
  125. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  126. package/src/terraform/adopt-state.test.ts +131 -0
  127. package/src/terraform/adopt-state.ts +22 -167
  128. package/src/terraform/aws-resources.test.ts +55 -16
  129. package/src/terraform/aws-resources.ts +1 -1
  130. package/src/terraform/bridge.test.ts +12 -0
  131. package/src/terraform/bridge.ts +4 -1
  132. package/src/terraform/carve-provider.test.ts +155 -0
  133. package/src/terraform/carve-provider.ts +237 -0
  134. package/src/terraform/carve.test.ts +55 -1
  135. package/src/terraform/carve.ts +0 -0
  136. package/src/terraform/emit-source.ts +39 -0
  137. package/src/terraform/graduate.test.ts +37 -0
  138. package/src/terraform/graduate.ts +55 -7
  139. package/src/terraform/graph.ts +3 -3
  140. package/src/terraform/providers/aws.ts +169 -0
  141. package/src/terraform/providers/gcp.test.ts +228 -0
  142. package/src/terraform/providers/gcp.ts +329 -0
  143. package/src/terraform/providers/index.ts +21 -0
  144. package/src/terraform/providers/kubernetes.ts +224 -0
  145. package/src/terraform/score.ts +111 -25
  146. package/src/terraform/tier-map.ts +55 -90
  147. package/src/terraform/types.ts +6 -0
  148. package/src/yaml.test.ts +54 -0
  149. package/src/yaml.ts +24 -3
@@ -0,0 +1,939 @@
1
+ /**
2
+ * Find every agent configuration on this machine and normalize it.
3
+ *
4
+ * The scan is location-driven, not walk-driven. Agent config does not hide in
5
+ * arbitrary places: each harness publishes a small, fixed set of paths it
6
+ * reads, so this module probes that list rather than crawling the filesystem.
7
+ * That keeps a full three-scope scan to a few dozen `stat` calls, and — more
8
+ * importantly — makes "we looked and it wasn't there" a fact the report can
9
+ * state (`AgentScanResult.probed`) instead of an absence of evidence.
10
+ *
11
+ * Two shapes recur across harnesses and are handled once here:
12
+ *
13
+ * - **A config is many files.** Claude Code merges `settings.json`,
14
+ * `settings.local.json`, `mcp.json`, and `~/.claude.json` into one
15
+ * effective configuration; codex puts the equivalent in one TOML file.
16
+ * Both land as a single {@link AgentConfigSite} with `sources` listing
17
+ * every file that contributed.
18
+ * - **MCP servers are declared in more than one place per scope.** Claude
19
+ * Code alone accepts them in `~/.claude/mcp.json`, `~/.claude.json`'s
20
+ * top-level `mcpServers`, and per-project entries under `~/.claude.json`'s
21
+ * `projects` map. `mergeMcp` collects all of them and keeps the *first*
22
+ * declaration of a name, matching the harness's own precedence.
23
+ *
24
+ * Nothing here judges what it finds — `checks.ts` owns that. Discovery's only
25
+ * editorial act is dropping sites with no content at all, so an empty
26
+ * `~/.gemini` directory doesn't become a resource.
27
+ */
28
+
29
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
30
+ import { homedir } from "os";
31
+ import { basename, join, resolve } from "path";
32
+ import { parseTOML } from "../toml";
33
+ import { parseYAML } from "../yaml";
34
+ import type {
35
+ AgentConfigSite,
36
+ AgentRuntime,
37
+ AgentScanResult,
38
+ AgentScope,
39
+ CommandDecl,
40
+ InstructionFile,
41
+ McpServerDecl,
42
+ McpTransport,
43
+ PermissionConfig,
44
+ PluginDecl,
45
+ SkillDecl,
46
+ SkillOrigin,
47
+ SubagentDecl,
48
+ } from "./types";
49
+ import { AGENT_RUNTIMES, AGENT_SCOPES } from "./types";
50
+
51
+ /** Skip pathological files rather than pulling them into memory — mirrors the audit walk's cap. */
52
+ const MAX_FILE_BYTES = 4 * 1024 * 1024;
53
+ /** `~/.claude.json` legitimately reaches hundreds of KB (it holds per-project history), so it gets its own, larger cap. */
54
+ const MAX_STATE_FILE_BYTES = 32 * 1024 * 1024;
55
+ /** Bound the per-directory listing so a stray huge skills/ tree can't stall a scan. */
56
+ const MAX_DIR_ENTRIES = 500;
57
+
58
+ export interface ScanOptions {
59
+ /** Scopes to probe. Defaults to all three. */
60
+ scopes?: readonly AgentScope[];
61
+ /** Harnesses to probe. Defaults to all five. */
62
+ runtimes?: readonly AgentRuntime[];
63
+ /** Home directory. Injectable so tests can scan a fixture tree instead of the real machine. */
64
+ home?: string;
65
+ /** Platform, which decides where system-scope policy lives. Injectable for the same reason. */
66
+ platform?: NodeJS.Platform;
67
+ /** Directories to treat as project scope. Defaults to `[process.cwd()]`. */
68
+ projectRoots?: string[];
69
+ }
70
+
71
+ /**
72
+ * Machine-wide policy locations, by platform. Claude Code is the only harness
73
+ * of the five that defines a system scope today; the others are user-scoped
74
+ * only, which is itself worth reporting.
75
+ */
76
+ export function systemSettingsPaths(platform: NodeJS.Platform): string[] {
77
+ if (platform === "darwin") return ["/Library/Application Support/ClaudeCode/managed-settings.json"];
78
+ if (platform === "win32") return ["C:\\ProgramData\\ClaudeCode\\managed-settings.json"];
79
+ return ["/etc/claude-code/managed-settings.json"];
80
+ }
81
+
82
+ // ── Reading primitives ───────────────────────────────────────────────
83
+ //
84
+ // Every read goes through the recorder so the scan can report what it probed
85
+ // and what it failed on. A parse failure is never fatal: a machine with one
86
+ // corrupt settings file should still get a report about the other twelve.
87
+
88
+ /** Accumulates the provenance a scan reports alongside its sites. */
89
+ class Recorder {
90
+ readonly probed: string[] = [];
91
+ readonly unreadable: Array<{ path: string; reason: string }> = [];
92
+ /**
93
+ * Per-site MCP declarations before first-wins merging, keyed by
94
+ * {@link declKey} rather than site id.
95
+ *
96
+ * Site ids are not yet unique at discovery time — `uniquifySiteIds` runs
97
+ * after every site is collected, because it needs to see the collisions.
98
+ * Keying this by identity-from-the-start means a collision can't cause one
99
+ * site's declarations to overwrite another's before the rename happens.
100
+ */
101
+ readonly declarations: Record<string, McpServerDecl[]> = {};
102
+ /** Memo for files read once per scanned root — see {@link cachedJson}. */
103
+ private readonly jsonCache = new Map<string, Record<string, unknown> | undefined>();
104
+
105
+ /** Note that a path was looked for. Returns whether it exists. */
106
+ probe(path: string): boolean {
107
+ this.probed.push(path);
108
+ return existsSync(path);
109
+ }
110
+
111
+ fail(path: string, err: unknown): void {
112
+ this.unreadable.push({ path, reason: err instanceof Error ? err.message : String(err) });
113
+ }
114
+
115
+ text(path: string, maxBytes = MAX_FILE_BYTES): string | undefined {
116
+ if (!this.probe(path)) return undefined;
117
+ try {
118
+ const size = statSync(path).size;
119
+ if (size > maxBytes) {
120
+ this.fail(path, `file is ${size} bytes, over the ${maxBytes}-byte scan cap`);
121
+ return undefined;
122
+ }
123
+ return readFileSync(path, "utf-8");
124
+ } catch (err) {
125
+ this.fail(path, err);
126
+ return undefined;
127
+ }
128
+ }
129
+
130
+ json(path: string, maxBytes = MAX_FILE_BYTES): Record<string, unknown> | undefined {
131
+ const raw = this.text(path, maxBytes);
132
+ if (raw === undefined) return undefined;
133
+ try {
134
+ const parsed: unknown = JSON.parse(raw);
135
+ return isRecord(parsed) ? parsed : undefined;
136
+ } catch (err) {
137
+ this.fail(path, err);
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * `json`, memoized by path.
144
+ *
145
+ * For `~/.claude.json` specifically: it is read once per project root (it
146
+ * holds the per-project MCP servers), it is routinely hundreds of KB, and
147
+ * `--all-projects` scans dozens of roots in one pass. Re-parsing it per root
148
+ * turned a fast scan into a slow one for no benefit — the file cannot change
149
+ * mid-scan.
150
+ */
151
+ cachedJson(path: string, maxBytes = MAX_FILE_BYTES): Record<string, unknown> | undefined {
152
+ if (this.jsonCache.has(path)) return this.jsonCache.get(path);
153
+ const parsed = this.json(path, maxBytes);
154
+ this.jsonCache.set(path, parsed);
155
+ return parsed;
156
+ }
157
+
158
+ toml(path: string): Record<string, unknown> | undefined {
159
+ const raw = this.text(path);
160
+ if (raw === undefined) return undefined;
161
+ try {
162
+ const parsed: unknown = parseTOML(raw);
163
+ return isRecord(parsed) ? parsed : undefined;
164
+ } catch (err) {
165
+ this.fail(path, err);
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ /** Subdirectory names under `dir`, or `[]` if it isn't a readable directory. */
171
+ dirs(dir: string): string[] {
172
+ if (!this.probe(dir)) return [];
173
+ try {
174
+ return readdirSync(dir, { withFileTypes: true })
175
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
176
+ .slice(0, MAX_DIR_ENTRIES)
177
+ .map((e) => e.name)
178
+ .sort();
179
+ } catch (err) {
180
+ this.fail(dir, err);
181
+ return [];
182
+ }
183
+ }
184
+
185
+ /** Names of files under `dir` matching `ext`, or `[]`. */
186
+ files(dir: string, ext: string): string[] {
187
+ if (!this.probe(dir)) return [];
188
+ try {
189
+ return readdirSync(dir, { withFileTypes: true })
190
+ .filter((e) => e.isFile() && e.name.endsWith(ext))
191
+ .slice(0, MAX_DIR_ENTRIES)
192
+ .map((e) => e.name)
193
+ .sort();
194
+ } catch (err) {
195
+ this.fail(dir, err);
196
+ return [];
197
+ }
198
+ }
199
+ }
200
+
201
+ function isRecord(v: unknown): v is Record<string, unknown> {
202
+ return typeof v === "object" && v !== null && !Array.isArray(v);
203
+ }
204
+
205
+ function asStringMap(v: unknown): Record<string, string> {
206
+ if (!isRecord(v)) return {};
207
+ const out: Record<string, string> = {};
208
+ for (const [k, val] of Object.entries(v)) {
209
+ if (typeof val === "string") out[k] = val;
210
+ else if (typeof val === "number" || typeof val === "boolean") out[k] = String(val);
211
+ }
212
+ return out;
213
+ }
214
+
215
+ function asStringArray(v: unknown): string[] | undefined {
216
+ if (!Array.isArray(v)) return undefined;
217
+ const out = v.filter((x): x is string => typeof x === "string");
218
+ return out.length > 0 ? out : undefined;
219
+ }
220
+
221
+ /** Parse a markdown file's `---` YAML frontmatter. Returns `{}` when there is none. */
222
+ export function frontmatter(markdown: string): Record<string, unknown> {
223
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown);
224
+ if (!match) return {};
225
+ try {
226
+ const parsed = parseYAML(match[1]);
227
+ return isRecord(parsed) ? parsed : {};
228
+ } catch {
229
+ // Malformed frontmatter just means we don't get a description — the skill
230
+ // itself is still real and still gets reported.
231
+ return {};
232
+ }
233
+ }
234
+
235
+ function str(v: unknown): string | undefined {
236
+ return typeof v === "string" && v.length > 0 ? v : undefined;
237
+ }
238
+
239
+ // ── MCP normalization ────────────────────────────────────────────────
240
+
241
+ /**
242
+ * Normalize one harness's `mcpServers` / `mcp_servers` map.
243
+ *
244
+ * Both dialects agree on the substance — a name mapping to either a `command`
245
+ * + `args` (stdio) or a `url` (http/sse) — so one normalizer serves Claude
246
+ * Code's JSON and codex's TOML. Keys this model names are lifted out; whatever
247
+ * remains is preserved in `extra` so a check can read a header or timeout
248
+ * without this function having to know about it.
249
+ */
250
+ export function normalizeMcpServers(raw: unknown, source: string): McpServerDecl[] {
251
+ if (!isRecord(raw)) return [];
252
+ const servers: McpServerDecl[] = [];
253
+ for (const [name, value] of Object.entries(raw)) {
254
+ if (!isRecord(value)) continue;
255
+ const command = str(value.command);
256
+ const url = str(value.url) ?? str(value.endpoint);
257
+ const declared = str(value.type) ?? str(value.transport);
258
+
259
+ let transport: McpTransport;
260
+ if (declared === "sse") transport = "sse";
261
+ else if (declared === "http" || declared === "streamable-http") transport = "http";
262
+ else if (command) transport = "stdio";
263
+ else if (url) transport = url.includes("/sse") ? "sse" : "http";
264
+ else transport = "unknown";
265
+
266
+ const extra: Record<string, unknown> = {};
267
+ for (const [k, v] of Object.entries(value)) {
268
+ if (["command", "args", "url", "endpoint", "env", "type", "transport"].includes(k)) continue;
269
+ extra[k] = v;
270
+ }
271
+
272
+ servers.push({
273
+ name,
274
+ transport,
275
+ source,
276
+ command,
277
+ args: asStringArray(value.args),
278
+ url,
279
+ env: isRecord(value.env) ? asStringMap(value.env) : undefined,
280
+ extra: Object.keys(extra).length > 0 ? extra : undefined,
281
+ });
282
+ }
283
+ // Sorted by name so a site's server list is stable regardless of the order
284
+ // the source file happened to declare them in. Generated chant code is diffed
285
+ // between runs, so ordering that tracks file layout would produce churn.
286
+ return servers.sort((a, b) => (a.name < b.name ? -1 : 1));
287
+ }
288
+
289
+ /**
290
+ * Combine MCP declarations from several files, first-wins per name.
291
+ *
292
+ * First-wins matches how the harnesses resolve a duplicate: the
293
+ * highest-precedence file is passed first by the callers below, and a
294
+ * later-scanned file redeclaring the same server name does not override it.
295
+ * The shadowed copy is not dropped silently — AGT007 reports it.
296
+ */
297
+ function mergeMcp(...groups: McpServerDecl[][]): McpServerDecl[] {
298
+ const byName = new Map<string, McpServerDecl>();
299
+ for (const group of groups) {
300
+ for (const server of group) {
301
+ if (!byName.has(server.name)) byName.set(server.name, server);
302
+ }
303
+ }
304
+ return [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
305
+ }
306
+
307
+ /** Every MCP declaration seen, including ones `mergeMcp` shadowed. Feeds the shadowing check. */
308
+ function allMcp(...groups: McpServerDecl[][]): McpServerDecl[] {
309
+ return groups.flat();
310
+ }
311
+
312
+ // ── Shared readers ───────────────────────────────────────────────────
313
+
314
+ /**
315
+ * Read a `skills/` tree laid out as `<dir>/<name>/SKILL.md` — the layout
316
+ * Claude Code and codex both use.
317
+ */
318
+ function readSkillTree(rec: Recorder, dir: string, origin: SkillOrigin): SkillDecl[] {
319
+ const skills: SkillDecl[] = [];
320
+ for (const name of rec.dirs(dir)) {
321
+ const path = join(dir, name, "SKILL.md");
322
+ const content = rec.text(path);
323
+ if (content === undefined) continue;
324
+ const fm = frontmatter(content);
325
+ skills.push({
326
+ name: str(fm.name) ?? name,
327
+ origin,
328
+ path,
329
+ content,
330
+ description: str(fm.description),
331
+ });
332
+ }
333
+ return skills;
334
+ }
335
+
336
+ /** Read `<dir>/*.md` agent definitions. */
337
+ function readSubagents(rec: Recorder, dir: string): SubagentDecl[] {
338
+ const agents: SubagentDecl[] = [];
339
+ for (const file of rec.files(dir, ".md")) {
340
+ const path = join(dir, file);
341
+ const content = rec.text(path);
342
+ if (content === undefined) continue;
343
+ const fm = frontmatter(content);
344
+ agents.push({
345
+ name: str(fm.name) ?? basename(file, ".md"),
346
+ path,
347
+ description: str(fm.description),
348
+ tools: str(fm.tools),
349
+ model: str(fm.model),
350
+ });
351
+ }
352
+ return agents;
353
+ }
354
+
355
+ /** Read `<dir>/*.md` slash-command definitions. */
356
+ function readCommands(rec: Recorder, dir: string): CommandDecl[] {
357
+ const commands: CommandDecl[] = [];
358
+ for (const file of rec.files(dir, ".md")) {
359
+ const path = join(dir, file);
360
+ const content = rec.text(path);
361
+ if (content === undefined) continue;
362
+ commands.push({
363
+ name: basename(file, ".md"),
364
+ path,
365
+ description: str(frontmatter(content).description),
366
+ });
367
+ }
368
+ return commands;
369
+ }
370
+
371
+ /** Read an instruction file if present. */
372
+ function readInstructions(rec: Recorder, ...paths: string[]): InstructionFile[] {
373
+ const out: InstructionFile[] = [];
374
+ for (const path of paths) {
375
+ const content = rec.text(path);
376
+ if (content === undefined || content.trim() === "") continue;
377
+ out.push({ path, content, bytes: Buffer.byteLength(content, "utf-8") });
378
+ }
379
+ return out;
380
+ }
381
+
382
+ /** Normalize a settings `permissions` block. */
383
+ function readPermissions(settings: Record<string, unknown>): PermissionConfig | undefined {
384
+ const raw = settings.permissions;
385
+ const bypasses = settings.skipDangerousModePermissionPrompt === true || settings.bypassPermissions === true;
386
+ if (!isRecord(raw)) return bypasses ? { bypassesPrompts: true } : undefined;
387
+ return {
388
+ allow: asStringArray(raw.allow),
389
+ deny: asStringArray(raw.deny),
390
+ ask: asStringArray(raw.ask),
391
+ defaultMode: str(raw.defaultMode),
392
+ bypassesPrompts: bypasses || undefined,
393
+ };
394
+ }
395
+
396
+ /** Drop a site that would carry no information. */
397
+ function hasContent(site: AgentConfigSite): boolean {
398
+ return (
399
+ site.instructions.length > 0 ||
400
+ site.mcpServers.length > 0 ||
401
+ site.skills.length > 0 ||
402
+ site.subagents.length > 0 ||
403
+ site.commands.length > 0 ||
404
+ site.plugins.length > 0 ||
405
+ Object.keys(site.env).length > 0 ||
406
+ site.permissions !== undefined ||
407
+ site.model !== undefined
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Stable identity for a site during discovery, independent of its (not yet
413
+ * unique) id. Scope, runtime, and absolute root together identify exactly one
414
+ * site by construction.
415
+ */
416
+ function declKey(site: Pick<AgentConfigSite, "scope" | "runtime" | "root">): string {
417
+ return `${site.scope}|${site.runtime}|${resolve(site.root)}`;
418
+ }
419
+
420
+ /** Slugify one path segment. */
421
+ function slugSegment(segment: string): string {
422
+ return segment.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
423
+ }
424
+
425
+ /**
426
+ * Slugify the last `depth` segments of a path into the project half of a site
427
+ * id. Depth 1 is the directory name; deeper values disambiguate two projects
428
+ * that share one (see {@link uniquifySiteIds}).
429
+ */
430
+ function projectSlug(path: string, depth = 1): string {
431
+ const segments = resolve(path).split(/[\\/]/).filter(Boolean);
432
+ const slug = segments.slice(-depth).map(slugSegment).filter(Boolean).join("-");
433
+ return slug || "root";
434
+ }
435
+
436
+ /** Slugify a directory name into the project half of a site id. */
437
+ function slug(path: string): string {
438
+ return projectSlug(path, 1);
439
+ }
440
+
441
+ /**
442
+ * Make every site id unique within a scan.
443
+ *
444
+ * Site ids are derived from the directory name, which is unique enough when
445
+ * scanning one project and demonstrably not when scanning all of them — a
446
+ * machine here had `dev/intentius/behold` and `dev/jhgaylor/behold`, plus two
447
+ * `guild` checkouts. Left alone, those collide into one id, which silently
448
+ * corrupts the per-site declaration lookup and emits duplicate `export const`
449
+ * identifiers in generated TypeScript.
450
+ *
451
+ * Colliding ids are qualified with as many parent directory segments as it
452
+ * takes (`...-intentius-behold` vs `...-jhgaylor-behold`), falling back to a
453
+ * numeric suffix in the pathological case. Ids that were already unique are
454
+ * left exactly as they were, so the common single-project output is unchanged.
455
+ */
456
+ function uniquifySiteIds(sites: AgentConfigSite[]): void {
457
+ const counts = new Map<string, number>();
458
+ for (const site of sites) counts.set(site.id, (counts.get(site.id) ?? 0) + 1);
459
+ if ([...counts.values()].every((n) => n === 1)) return;
460
+
461
+ const taken = new Set(sites.filter((s) => counts.get(s.id) === 1).map((s) => s.id));
462
+
463
+ for (const site of sites) {
464
+ if (counts.get(site.id) === 1) continue;
465
+ const prefix = `${site.scope}-${site.runtime}`;
466
+
467
+ let chosen: string | undefined;
468
+ for (let depth = 2; depth <= 6; depth++) {
469
+ const candidate = `${prefix}-${projectSlug(site.root, depth)}`;
470
+ if (!taken.has(candidate)) {
471
+ chosen = candidate;
472
+ break;
473
+ }
474
+ }
475
+ if (chosen === undefined) {
476
+ let n = 2;
477
+ while (taken.has(`${site.id}-${n}`)) n++;
478
+ chosen = `${site.id}-${n}`;
479
+ }
480
+
481
+ site.id = chosen;
482
+ taken.add(chosen);
483
+ }
484
+ }
485
+
486
+ // ── Per-harness discovery ────────────────────────────────────────────
487
+
488
+ /**
489
+ * Claude Code — the richest surface of the five, and the only one with a
490
+ * system scope.
491
+ *
492
+ * At user scope the effective config is spread over five files plus two
493
+ * directory trees, and MCP servers can come from three of them; at project
494
+ * scope the same shape repeats under `.claude/`, with the wrinkle that
495
+ * `~/.claude.json`'s `projects` map holds per-project MCP servers that live in
496
+ * the *home* directory while governing a *project* root. That last one is easy
497
+ * to miss by hand, which is exactly why it's worth scanning for.
498
+ */
499
+ function discoverClaude(rec: Recorder, scope: AgentScope, root: string, home: string, platform: NodeJS.Platform): AgentConfigSite | undefined {
500
+ const id = scope === "project" ? `project-claude-${slug(root)}` : `${scope}-claude`;
501
+ const sources: string[] = [];
502
+ const track = <T>(path: string, value: T | undefined): T | undefined => {
503
+ if (value !== undefined) sources.push(path);
504
+ return value;
505
+ };
506
+
507
+ if (scope === "system") {
508
+ // System scope is policy only: a managed-settings.json, no skills or memory.
509
+ for (const path of systemSettingsPaths(platform)) {
510
+ const settings = rec.json(path);
511
+ if (settings === undefined) continue;
512
+ sources.push(path);
513
+ const site: AgentConfigSite = {
514
+ id,
515
+ scope,
516
+ runtime: "claude",
517
+ root: "/",
518
+ sources,
519
+ instructions: [],
520
+ mcpServers: normalizeMcpServers(settings.mcpServers, path),
521
+ skills: [],
522
+ subagents: [],
523
+ commands: [],
524
+ plugins: [],
525
+ env: asStringMap(settings.env),
526
+ permissions: readPermissions(settings),
527
+ model: str(settings.model),
528
+ settings,
529
+ };
530
+ return hasContent(site) ? site : undefined;
531
+ }
532
+ return undefined;
533
+ }
534
+
535
+ const dir = scope === "user" ? join(root, ".claude") : join(root, ".claude");
536
+
537
+ const settingsPath = join(dir, "settings.json");
538
+ const localPath = join(dir, "settings.local.json");
539
+ const mcpPath = scope === "user" ? join(dir, "mcp.json") : join(root, ".mcp.json");
540
+
541
+ const settings = track(settingsPath, rec.json(settingsPath)) ?? {};
542
+ const local = track(localPath, rec.json(localPath)) ?? {};
543
+ const mcpFile = track(mcpPath, rec.json(mcpPath)) ?? {};
544
+
545
+ // `~/.claude.json` is the harness's own state file. It carries a top-level
546
+ // `mcpServers` at user scope, and a `projects` map whose entries hold
547
+ // per-project `mcpServers` — config that governs a project root but is
548
+ // stored in the home directory.
549
+ const statePath = join(home, ".claude.json");
550
+ const state = rec.cachedJson(statePath, MAX_STATE_FILE_BYTES) ?? {};
551
+ let stateMcp: McpServerDecl[] = [];
552
+ if (scope === "user") {
553
+ stateMcp = normalizeMcpServers(state.mcpServers, statePath);
554
+ } else {
555
+ const projects = isRecord(state.projects) ? state.projects : {};
556
+ const entry = projects[resolve(root)];
557
+ if (isRecord(entry)) stateMcp = normalizeMcpServers(entry.mcpServers, statePath);
558
+ }
559
+ if (stateMcp.length > 0) sources.push(statePath);
560
+
561
+ // settings.local.json wins over settings.json, which is why it merges second.
562
+ const merged: Record<string, unknown> = { ...settings, ...local };
563
+
564
+ const instructions =
565
+ scope === "user"
566
+ ? readInstructions(rec, join(root, "CLAUDE.md"), join(dir, "CLAUDE.md"))
567
+ : readInstructions(rec, join(root, "CLAUDE.md"), join(root, "AGENTS.md"), join(dir, "CLAUDE.md"));
568
+ sources.push(...instructions.map((i) => i.path));
569
+
570
+ const skills = readSkillTree(rec, join(dir, "skills"), "local");
571
+ const subagents = readSubagents(rec, join(dir, "agents"));
572
+ const commands = readCommands(rec, join(dir, "commands"));
573
+
574
+ const mcpFromFile = normalizeMcpServers(mcpFile.mcpServers, mcpPath);
575
+ const mcpFromSettings = normalizeMcpServers(merged.mcpServers, settingsPath);
576
+
577
+ const site: AgentConfigSite = {
578
+ id,
579
+ scope,
580
+ runtime: "claude",
581
+ root: resolve(root),
582
+ sources: [...new Set(sources)],
583
+ instructions,
584
+ mcpServers: mergeMcp(mcpFromFile, mcpFromSettings, stateMcp),
585
+ skills: [...skills, ...(scope === "user" ? readClaudePlugins(rec, dir, merged) : [])],
586
+ subagents,
587
+ commands,
588
+ plugins: scope === "user" ? readClaudePluginRegistry(rec, dir, merged) : [],
589
+ env: asStringMap(merged.env),
590
+ permissions: readPermissions(merged),
591
+ model: str(merged.model),
592
+ settings: merged,
593
+ };
594
+ // Record every declaration (not just the merge winners) for AGT007.
595
+ rec.declarations[declKey(site)] = allMcp(mcpFromFile, mcpFromSettings, stateMcp);
596
+ return hasContent(site) ? site : undefined;
597
+ }
598
+
599
+ /** Skills that arrive via an installed plugin, which the user never wrote and may not have read. */
600
+ function readClaudePlugins(rec: Recorder, dir: string, settings: Record<string, unknown>): SkillDecl[] {
601
+ const skills: SkillDecl[] = [];
602
+ const marketplaces = rec.json(join(dir, "plugins", "known_marketplaces.json")) ?? {};
603
+ for (const [name, entry] of Object.entries(marketplaces)) {
604
+ if (!isRecord(entry)) continue;
605
+ const location = str(entry.installLocation);
606
+ if (!location) continue;
607
+ for (const skill of readSkillTree(rec, join(location, "skills"), "marketplace")) {
608
+ skills.push({ ...skill, source: marketplaceSource(entry) });
609
+ }
610
+ }
611
+ void settings;
612
+ return skills;
613
+ }
614
+
615
+ /** The installed-plugin registry, including which marketplace each came from and whether it is pinned. */
616
+ function readClaudePluginRegistry(rec: Recorder, dir: string, settings: Record<string, unknown>): PluginDecl[] {
617
+ const plugins: PluginDecl[] = [];
618
+ const enabled = isRecord(settings.enabledPlugins) ? settings.enabledPlugins : {};
619
+ const installed = rec.json(join(dir, "plugins", "installed_plugins.json")) ?? {};
620
+ const entries = isRecord(installed.plugins) ? installed.plugins : {};
621
+ for (const [name, entry] of Object.entries(entries)) {
622
+ plugins.push({
623
+ name,
624
+ marketplace: isRecord(entry) ? str(entry.marketplace) ?? marketplaceSource(entry) : undefined,
625
+ ref: isRecord(entry) ? str(entry.version) ?? str(entry.ref) ?? str(entry.commit) : undefined,
626
+ enabled: enabled[name] !== false,
627
+ remote: isRecord(entry) ? !isLocalSource(entry) : true,
628
+ });
629
+ }
630
+
631
+ // A known marketplace with no installed plugin is still a configured remote
632
+ // the harness will fetch from, so it is worth reporting.
633
+ const marketplaces = rec.json(join(dir, "plugins", "known_marketplaces.json")) ?? {};
634
+ for (const [name, entry] of Object.entries(marketplaces)) {
635
+ if (plugins.some((p) => p.name === name)) continue;
636
+ if (!isRecord(entry)) continue;
637
+ plugins.push({ name, marketplace: marketplaceSource(entry), ref: str(entry.ref), enabled: true, remote: !isLocalSource(entry) });
638
+ }
639
+ return plugins;
640
+ }
641
+
642
+ /** True when a marketplace/plugin entry points at a local directory rather than a remote the user doesn't control. */
643
+ function isLocalSource(entry: Record<string, unknown>): boolean {
644
+ const source = isRecord(entry.source) ? entry.source : entry;
645
+ const kind = str(source.source_type) ?? str(source.source) ?? str(source.type);
646
+ if (kind === "local" || kind === "file" || kind === "directory") return true;
647
+ // A bare absolute path with no repo/url is a vendored directory.
648
+ const location = str(source.path) ?? str(source.source);
649
+ return location !== undefined && location.startsWith("/") && !str(source.repo) && !str(source.url);
650
+ }
651
+
652
+ /** Render a marketplace `source` block (`{source: "github", repo: "owner/name"}`) as a source string. */
653
+ function marketplaceSource(entry: Record<string, unknown>): string | undefined {
654
+ const source = isRecord(entry.source) ? entry.source : entry;
655
+ const repo = str(source.repo);
656
+ if (repo) return repo;
657
+ return str(source.url) ?? str(source.source);
658
+ }
659
+
660
+ /**
661
+ * Codex — one TOML file holds what Claude Code spreads across five, with
662
+ * `[mcp_servers.<name>]` tables in place of a `mcpServers` object.
663
+ */
664
+ function discoverCodex(rec: Recorder, scope: AgentScope, root: string): AgentConfigSite | undefined {
665
+ if (scope === "system") return undefined;
666
+ const id = scope === "project" ? `project-codex-${slug(root)}` : "user-codex";
667
+ const dir = scope === "user" ? join(root, ".codex") : join(root, ".codex");
668
+ const configPath = join(dir, "config.toml");
669
+ const sources: string[] = [];
670
+
671
+ const config = rec.toml(configPath);
672
+ if (config !== undefined) sources.push(configPath);
673
+ const settings = config ?? {};
674
+
675
+ const instructions =
676
+ scope === "user"
677
+ ? readInstructions(rec, join(dir, "AGENTS.md"), join(root, "AGENTS.md"))
678
+ : readInstructions(rec, join(root, "AGENTS.md"));
679
+ sources.push(...instructions.map((i) => i.path));
680
+
681
+ // `.rules` files are codex's standing-instruction dialect alongside AGENTS.md.
682
+ for (const file of rec.files(join(dir, "rules"), ".rules")) {
683
+ const path = join(dir, "rules", file);
684
+ const content = rec.text(path);
685
+ if (content === undefined || content.trim() === "") continue;
686
+ instructions.push({ path, content, bytes: Buffer.byteLength(content, "utf-8") });
687
+ sources.push(path);
688
+ }
689
+
690
+ const marketplaces = isRecord(settings.marketplaces) ? settings.marketplaces : {};
691
+ const plugins: PluginDecl[] = Object.entries(marketplaces).map(([name, entry]) => ({
692
+ name,
693
+ marketplace: isRecord(entry) ? str(entry.source) : undefined,
694
+ ref: isRecord(entry) ? str(entry.ref) : undefined,
695
+ enabled: true,
696
+ // codex records `source_type = "local"` for the marketplaces it bundles.
697
+ remote: isRecord(entry) ? str(entry.source_type) !== "local" : true,
698
+ }));
699
+
700
+ const site: AgentConfigSite = {
701
+ id,
702
+ scope,
703
+ runtime: "codex",
704
+ root: resolve(root),
705
+ sources: [...new Set(sources)],
706
+ instructions,
707
+ mcpServers: normalizeMcpServers(settings.mcp_servers ?? settings.mcpServers, configPath),
708
+ skills: readSkillTree(rec, join(dir, "skills"), "local"),
709
+ subagents: [],
710
+ commands: readCommands(rec, join(dir, "prompts")),
711
+ plugins,
712
+ env: asStringMap(settings.env),
713
+ permissions: undefined,
714
+ model: str(settings.model),
715
+ settings,
716
+ };
717
+ return hasContent(site) ? site : undefined;
718
+ }
719
+
720
+ /** Gemini CLI — GEMINI.md plus a settings.json that can carry `mcpServers`. */
721
+ function discoverGemini(rec: Recorder, scope: AgentScope, root: string): AgentConfigSite | undefined {
722
+ if (scope === "system") return undefined;
723
+ const id = scope === "project" ? `project-gemini-${slug(root)}` : "user-gemini";
724
+ const dir = join(root, ".gemini");
725
+ const settingsPath = join(dir, "settings.json");
726
+ const sources: string[] = [];
727
+
728
+ const settings = rec.json(settingsPath);
729
+ if (settings !== undefined) sources.push(settingsPath);
730
+
731
+ const instructions = readInstructions(rec, join(root, "GEMINI.md"), join(dir, "GEMINI.md"));
732
+ sources.push(...instructions.map((i) => i.path));
733
+
734
+ const site: AgentConfigSite = {
735
+ id,
736
+ scope,
737
+ runtime: "gemini",
738
+ root: resolve(root),
739
+ sources: [...new Set(sources)],
740
+ instructions,
741
+ mcpServers: normalizeMcpServers(settings?.mcpServers, settingsPath),
742
+ skills: [],
743
+ subagents: [],
744
+ commands: [],
745
+ plugins: [],
746
+ env: asStringMap(settings?.env),
747
+ permissions: undefined,
748
+ model: str(settings?.model),
749
+ settings: settings ?? {},
750
+ };
751
+ return hasContent(site) ? site : undefined;
752
+ }
753
+
754
+ /** opencode — XDG-style config with an `mcp` block. */
755
+ function discoverOpencode(rec: Recorder, scope: AgentScope, root: string): AgentConfigSite | undefined {
756
+ if (scope === "system") return undefined;
757
+ const id = scope === "project" ? `project-opencode-${slug(root)}` : "user-opencode";
758
+ const configPath = scope === "user" ? join(root, ".config", "opencode", "opencode.json") : join(root, "opencode.json");
759
+ const sources: string[] = [];
760
+
761
+ const settings = rec.json(configPath);
762
+ if (settings !== undefined) sources.push(configPath);
763
+
764
+ const instructions =
765
+ scope === "user"
766
+ ? readInstructions(rec, join(root, ".config", "opencode", "AGENTS.md"))
767
+ : readInstructions(rec, join(root, "AGENTS.md"));
768
+ sources.push(...instructions.map((i) => i.path));
769
+
770
+ const site: AgentConfigSite = {
771
+ id,
772
+ scope,
773
+ runtime: "opencode",
774
+ root: resolve(root),
775
+ sources: [...new Set(sources)],
776
+ instructions,
777
+ // opencode names the block `mcp`; the entry shape matches the others.
778
+ mcpServers: normalizeMcpServers(settings?.mcp ?? settings?.mcpServers, configPath),
779
+ skills: [],
780
+ subagents: [],
781
+ commands: [],
782
+ plugins: [],
783
+ env: asStringMap(settings?.env),
784
+ permissions: undefined,
785
+ model: str(settings?.model),
786
+ settings: settings ?? {},
787
+ };
788
+ return hasContent(site) ? site : undefined;
789
+ }
790
+
791
+ /**
792
+ * Cursor — `.cursorrules` / `.cursor/rules/*.mdc` for instructions plus
793
+ * `mcp.json` for tool servers. Discovered and audited like the rest; it simply
794
+ * has no fountain `runtime` value to re-express onto.
795
+ */
796
+ function discoverCursor(rec: Recorder, scope: AgentScope, root: string): AgentConfigSite | undefined {
797
+ if (scope === "system") return undefined;
798
+ const id = scope === "project" ? `project-cursor-${slug(root)}` : "user-cursor";
799
+ const dir = join(root, ".cursor");
800
+ const mcpPath = join(dir, "mcp.json");
801
+ const sources: string[] = [];
802
+
803
+ const mcpFile = rec.json(mcpPath);
804
+ if (mcpFile !== undefined) sources.push(mcpPath);
805
+
806
+ const instructions = readInstructions(rec, join(root, ".cursorrules"));
807
+ sources.push(...instructions.map((i) => i.path));
808
+
809
+ // `.cursor/rules/*.mdc` is the newer, per-rule-file dialect.
810
+ for (const file of rec.files(join(dir, "rules"), ".mdc")) {
811
+ const path = join(dir, "rules", file);
812
+ const content = rec.text(path);
813
+ if (content === undefined || content.trim() === "") continue;
814
+ instructions.push({ path, content, bytes: Buffer.byteLength(content, "utf-8") });
815
+ sources.push(path);
816
+ }
817
+
818
+ const site: AgentConfigSite = {
819
+ id,
820
+ scope,
821
+ runtime: "cursor",
822
+ root: resolve(root),
823
+ sources: [...new Set(sources)],
824
+ instructions,
825
+ mcpServers: normalizeMcpServers(mcpFile?.mcpServers, mcpPath),
826
+ skills: [],
827
+ subagents: [],
828
+ commands: [],
829
+ plugins: [],
830
+ env: {},
831
+ permissions: undefined,
832
+ model: undefined,
833
+ settings: mcpFile ?? {},
834
+ };
835
+ return hasContent(site) ? site : undefined;
836
+ }
837
+
838
+ // ── Entry point ──────────────────────────────────────────────────────
839
+
840
+ type Discoverer = (rec: Recorder, scope: AgentScope, root: string, home: string, platform: NodeJS.Platform) => AgentConfigSite | undefined;
841
+
842
+ const DISCOVERERS: Record<AgentRuntime, Discoverer> = {
843
+ claude: discoverClaude,
844
+ codex: (rec, scope, root) => discoverCodex(rec, scope, root),
845
+ gemini: (rec, scope, root) => discoverGemini(rec, scope, root),
846
+ opencode: (rec, scope, root) => discoverOpencode(rec, scope, root),
847
+ cursor: (rec, scope, root) => discoverCursor(rec, scope, root),
848
+ };
849
+
850
+ /**
851
+ * Scan the machine and return every agent configuration found.
852
+ *
853
+ * Sites are ordered system → user → project, matching the order the harnesses
854
+ * merge them, so a reader scanning the report sees broad policy before the
855
+ * narrow overrides that modify it.
856
+ */
857
+ export function scanAgentConfigs(opts: ScanOptions = {}): AgentScanResult {
858
+ const home = opts.home ?? homedir();
859
+ const platform = opts.platform ?? process.platform;
860
+ const scopes = opts.scopes ?? AGENT_SCOPES;
861
+ const runtimes = opts.runtimes ?? AGENT_RUNTIMES;
862
+ const projectRoots = opts.projectRoots ?? [process.cwd()];
863
+
864
+ const rec = new Recorder();
865
+ const sites: AgentConfigSite[] = [];
866
+
867
+ for (const scope of AGENT_SCOPES) {
868
+ if (!scopes.includes(scope)) continue;
869
+ const roots = scope === "project" ? projectRoots : [scope === "system" ? "/" : home];
870
+ for (const root of roots) {
871
+ for (const runtime of AGENT_RUNTIMES) {
872
+ if (!runtimes.includes(runtime)) continue;
873
+ const site = DISCOVERERS[runtime](rec, scope, root, home, platform);
874
+ if (site) sites.push(site);
875
+ }
876
+ }
877
+ }
878
+
879
+ // Ids must be unique before declarations are keyed by them — see
880
+ // `uniquifySiteIds` for why a same-named directory in two parents collides.
881
+ uniquifySiteIds(sites);
882
+
883
+ const declarations: Record<string, McpServerDecl[]> = {};
884
+ for (const site of sites) {
885
+ const raw = rec.declarations[declKey(site)];
886
+ if (raw) declarations[site.id] = raw;
887
+ }
888
+
889
+ return {
890
+ sites,
891
+ probed: [...new Set(rec.probed)].sort(),
892
+ unreadable: rec.unreadable,
893
+ declarations,
894
+ };
895
+ }
896
+
897
+ /**
898
+ * Every project root the harness has registered on this machine, from
899
+ * `~/.claude.json`'s `projects` map.
900
+ *
901
+ * This is what makes `--all-projects` possible without crawling the disk: the
902
+ * harness already keeps the list of every project it has been opened in.
903
+ *
904
+ * Roots whose directory no longer exists are dropped. A deleted project is not
905
+ * something a user can act on, and counting it would make the "N projects not
906
+ * scanned" note overstate the gap — on a machine with 77 registered projects,
907
+ * 12 of them were stale.
908
+ */
909
+ export function registeredProjectRoots(home: string): string[] {
910
+ const statePath = join(home, ".claude.json");
911
+ if (!existsSync(statePath)) return [];
912
+ try {
913
+ if (statSync(statePath).size > MAX_STATE_FILE_BYTES) return [];
914
+ const state: unknown = JSON.parse(readFileSync(statePath, "utf-8"));
915
+ if (!isRecord(state) || !isRecord(state.projects)) return [];
916
+ return Object.keys(state.projects)
917
+ .map((p) => resolve(p))
918
+ .filter((p) => {
919
+ try {
920
+ return statSync(p).isDirectory();
921
+ } catch {
922
+ return false;
923
+ }
924
+ })
925
+ .sort();
926
+ } catch {
927
+ return [];
928
+ }
929
+ }
930
+
931
+ /**
932
+ * Count registered project roots this scan did not visit. A user with dozens of
933
+ * registered projects should be told that a cwd-scoped scan saw one of them,
934
+ * rather than being left to assume it saw all.
935
+ */
936
+ export function unscannedProjectCount(home: string, scannedRoots: string[]): number {
937
+ const scanned = new Set(scannedRoots.map((r) => resolve(r)));
938
+ return registeredProjectRoots(home).filter((p) => !scanned.has(p)).length;
939
+ }