@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.
- package/dist/agents/checks.d.ts +35 -0
- package/dist/agents/checks.d.ts.map +1 -0
- package/dist/agents/discover.d.ts +86 -0
- package/dist/agents/discover.d.ts.map +1 -0
- package/dist/agents/importer.d.ts +46 -0
- package/dist/agents/importer.d.ts.map +1 -0
- package/dist/agents/index.d.ts +14 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/types.d.ts +196 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/audit/catalog.d.ts +4 -1
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/report.d.ts +8 -0
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/cdk/advise.d.ts +29 -0
- package/dist/cdk/advise.d.ts.map +1 -0
- package/dist/cdk/assembly.d.ts +38 -0
- package/dist/cdk/assembly.d.ts.map +1 -0
- package/dist/cdk/graph.d.ts +68 -0
- package/dist/cdk/graph.d.ts.map +1 -0
- package/dist/cdk/tier-map.d.ts +44 -0
- package/dist/cdk/tier-map.d.ts.map +1 -0
- package/dist/cdk/types.d.ts +114 -0
- package/dist/cdk/types.d.ts.map +1 -0
- package/dist/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +48 -6
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts +64 -0
- package/dist/cli/commands/import-agents.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
- package/dist/cli/handlers/carve.d.ts +5 -4
- package/dist/cli/handlers/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +10 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +15 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/identity.d.ts +196 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +52 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +17 -63
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve-provider.d.ts +142 -0
- package/dist/terraform/carve-provider.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +36 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/emit-source.d.ts +25 -0
- package/dist/terraform/emit-source.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +11 -1
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/providers/aws.d.ts +19 -0
- package/dist/terraform/providers/aws.d.ts.map +1 -0
- package/dist/terraform/providers/gcp.d.ts +41 -0
- package/dist/terraform/providers/gcp.d.ts.map +1 -0
- package/dist/terraform/providers/index.d.ts +15 -0
- package/dist/terraform/providers/index.d.ts.map +1 -0
- package/dist/terraform/providers/kubernetes.d.ts +29 -0
- package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +70 -4
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +38 -26
- package/dist/terraform/tier-map.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +6 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/agents/checks.test.ts +228 -0
- package/src/agents/checks.ts +429 -0
- package/src/agents/discover.test.ts +310 -0
- package/src/agents/discover.ts +939 -0
- package/src/agents/importer.ts +49 -0
- package/src/agents/index.ts +29 -0
- package/src/agents/types.ts +207 -0
- package/src/audit/catalog.ts +90 -1
- package/src/audit/report.ts +9 -1
- package/src/audit/rules-doc.ts +6 -0
- package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
- package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
- package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
- package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
- package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
- package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
- package/src/cdk/advise.test.ts +208 -0
- package/src/cdk/advise.ts +44 -0
- package/src/cdk/assembly.ts +133 -0
- package/src/cdk/graph.test.ts +206 -0
- package/src/cdk/graph.ts +525 -0
- package/src/cdk/tier-map.ts +71 -0
- package/src/cdk/types.ts +115 -0
- package/src/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-apply.ts +20 -4
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +24 -2
- package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
- package/src/cli/commands/carve-emit-provider.test.ts +207 -0
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +55 -28
- package/src/cli/commands/carve.ts +139 -36
- package/src/cli/commands/import-agents.test.ts +208 -0
- package/src/cli/commands/import-agents.ts +196 -0
- package/src/cli/handlers/carve-emit.ts +8 -1
- package/src/cli/handlers/carve.ts +8 -7
- package/src/cli/handlers/lifecycle.test.ts +187 -1
- package/src/cli/handlers/lifecycle.ts +125 -1
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +29 -5
- package/src/cli/registry.ts +15 -0
- package/src/identity.test.ts +199 -0
- package/src/identity.ts +346 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +65 -0
- package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
- package/src/terraform/adopt-state.test.ts +131 -0
- package/src/terraform/adopt-state.ts +22 -167
- package/src/terraform/aws-resources.test.ts +55 -16
- package/src/terraform/aws-resources.ts +1 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/carve-provider.test.ts +155 -0
- package/src/terraform/carve-provider.ts +237 -0
- package/src/terraform/carve.test.ts +55 -1
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/emit-source.ts +39 -0
- package/src/terraform/graduate.test.ts +37 -0
- package/src/terraform/graduate.ts +55 -7
- package/src/terraform/graph.ts +3 -3
- package/src/terraform/providers/aws.ts +169 -0
- package/src/terraform/providers/gcp.test.ts +228 -0
- package/src/terraform/providers/gcp.ts +329 -0
- package/src/terraform/providers/index.ts +21 -0
- package/src/terraform/providers/kubernetes.ts +224 -0
- package/src/terraform/score.ts +111 -25
- package/src/terraform/tier-map.ts +55 -90
- package/src/terraform/types.ts +6 -0
- package/src/yaml.test.ts +54 -0
- 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
|
+
}
|