@intentius/chant 0.52.1 → 0.52.2
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/cli/commands/audit-agents.d.ts +83 -0
- package/dist/cli/commands/audit-agents.d.ts.map +1 -0
- 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/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/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/lexicon.d.ts +11 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +4 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/tier-map.d.ts +17 -0
- package/dist/terraform/tier-map.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/cli/commands/audit-agents.test.ts +260 -0
- package/src/cli/commands/audit-agents.ts +387 -0
- package/src/cli/commands/carve-bridge.test.ts +30 -0
- package/src/cli/commands/carve-bridge.ts +19 -1
- package/src/cli/commands/carve-emit.test.ts +72 -1
- package/src/cli/commands/carve-emit.ts +43 -20
- 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 +2 -0
- package/src/cli/handlers/misc.ts +111 -0
- package/src/cli/main.ts +17 -0
- package/src/cli/registry.ts +15 -0
- package/src/lexicon.ts +12 -0
- package/src/terraform/adopt-state.ts +7 -4
- package/src/terraform/aws-resources.test.ts +24 -1
- package/src/terraform/bridge.test.ts +12 -0
- package/src/terraform/bridge.ts +4 -1
- package/src/terraform/tier-map.ts +28 -1
- package/src/yaml.test.ts +54 -0
- package/src/yaml.ts +24 -3
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discovery is tested against a fixture home directory, never the real machine.
|
|
3
|
+
*
|
|
4
|
+
* That is not only for determinism: a test that read `~/.claude` would pass or
|
|
5
|
+
* fail based on the developer's own config, and would quietly stop covering the
|
|
6
|
+
* multi-file merge the moment someone deleted a settings file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
10
|
+
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { scanAgentConfigs, normalizeMcpServers, frontmatter, systemSettingsPaths, unscannedProjectCount, registeredProjectRoots } from "./discover";
|
|
14
|
+
|
|
15
|
+
let home: string;
|
|
16
|
+
let project: string;
|
|
17
|
+
|
|
18
|
+
function write(path: string, content: string): void {
|
|
19
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
20
|
+
writeFileSync(path, content, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writeIn(root: string, rel: string, content: string): void {
|
|
24
|
+
const full = join(root, rel);
|
|
25
|
+
mkdirSync(join(full, ".."), { recursive: true });
|
|
26
|
+
writeFileSync(full, content, "utf-8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
home = join(tmpdir(), `chant-agents-home-${Math.random().toString(36).slice(2)}`);
|
|
31
|
+
project = join(tmpdir(), `chant-agents-proj-${Math.random().toString(36).slice(2)}`);
|
|
32
|
+
mkdirSync(home, { recursive: true });
|
|
33
|
+
mkdirSync(project, { recursive: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
rmSync(project, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Scan the fixture tree only — `platform` pins the system path off the real machine's. */
|
|
42
|
+
function scan(opts: Parameters<typeof scanAgentConfigs>[0] = {}) {
|
|
43
|
+
return scanAgentConfigs({ home, projectRoots: [project], platform: "linux", ...opts });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("normalizeMcpServers", () => {
|
|
47
|
+
test("classifies stdio, http, and sse from the declaration's shape", () => {
|
|
48
|
+
const servers = normalizeMcpServers(
|
|
49
|
+
{
|
|
50
|
+
local: { command: "node", args: ["server.js"] },
|
|
51
|
+
remote: { url: "https://example.com/mcp" },
|
|
52
|
+
streamed: { url: "https://example.com/sse" },
|
|
53
|
+
declared: { url: "https://example.com/x", type: "sse" },
|
|
54
|
+
},
|
|
55
|
+
"/cfg.json",
|
|
56
|
+
);
|
|
57
|
+
// Sorted by name, not declaration order — see the sort in normalizeMcpServers.
|
|
58
|
+
expect(servers.map((s) => [s.name, s.transport])).toEqual([
|
|
59
|
+
["declared", "sse"],
|
|
60
|
+
["local", "stdio"],
|
|
61
|
+
["remote", "http"],
|
|
62
|
+
["streamed", "sse"],
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("keeps unmodeled keys in `extra` rather than dropping them", () => {
|
|
67
|
+
const [server] = normalizeMcpServers({ api: { url: "https://x/mcp", headers: { Authorization: "Bearer t" } } }, "/cfg.json");
|
|
68
|
+
// `headers` is where remote servers keep credentials — losing it here would
|
|
69
|
+
// blind both the secret check and the importer's redaction.
|
|
70
|
+
expect(server.extra).toEqual({ headers: { Authorization: "Bearer t" } });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("a non-object declaration is skipped, not crashed on", () => {
|
|
74
|
+
expect(normalizeMcpServers({ bad: "nope", good: { command: "x" } }, "/c")).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("frontmatter", () => {
|
|
79
|
+
test("parses YAML frontmatter", () => {
|
|
80
|
+
expect(frontmatter("---\nname: x\ndescription: y\n---\n\nbody")).toEqual({ name: "x", description: "y" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("returns {} for a file without frontmatter, and for malformed frontmatter", () => {
|
|
84
|
+
expect(frontmatter("# just a heading")).toEqual({});
|
|
85
|
+
expect(frontmatter("---\n: : :\n---\n")).toEqual({});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("systemSettingsPaths", () => {
|
|
90
|
+
test("is platform-specific", () => {
|
|
91
|
+
expect(systemSettingsPaths("darwin")[0]).toContain("/Library/Application Support");
|
|
92
|
+
expect(systemSettingsPaths("linux")[0]).toBe("/etc/claude-code/managed-settings.json");
|
|
93
|
+
expect(systemSettingsPaths("win32")[0]).toContain("ProgramData");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("scanAgentConfigs — claude user scope", () => {
|
|
98
|
+
test("merges settings, settings.local, mcp.json, and ~/.claude.json into one site", () => {
|
|
99
|
+
writeIn(home, "CLAUDE.md", "be brief");
|
|
100
|
+
writeIn(home, ".claude/settings.json", JSON.stringify({ model: "opus", env: { A: "1" } }));
|
|
101
|
+
writeIn(home, ".claude/settings.local.json", JSON.stringify({ model: "sonnet" }));
|
|
102
|
+
writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { a: { command: "x" } } }));
|
|
103
|
+
writeIn(home, ".claude.json", JSON.stringify({ mcpServers: { b: { url: "https://b/mcp" } } }));
|
|
104
|
+
|
|
105
|
+
const { sites } = scan({ scopes: ["user"], runtimes: ["claude"] });
|
|
106
|
+
expect(sites).toHaveLength(1);
|
|
107
|
+
const site = sites[0];
|
|
108
|
+
expect(site.id).toBe("user-claude");
|
|
109
|
+
// settings.local.json is merged last, so it wins.
|
|
110
|
+
expect(site.model).toBe("sonnet");
|
|
111
|
+
expect(site.env).toEqual({ A: "1" });
|
|
112
|
+
expect(site.mcpServers.map((s) => s.name)).toEqual(["a", "b"]);
|
|
113
|
+
expect(site.instructions.map((i) => i.content)).toEqual(["be brief"]);
|
|
114
|
+
expect(site.sources.length).toBeGreaterThanOrEqual(4);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("reads the skills tree, preferring the frontmatter name", () => {
|
|
118
|
+
writeIn(home, ".claude/skills/deploy/SKILL.md", "---\nname: deployer\ndescription: ships it\n---\n\nsteps");
|
|
119
|
+
const [site] = scan({ scopes: ["user"], runtimes: ["claude"] }).sites;
|
|
120
|
+
expect(site.skills).toEqual([
|
|
121
|
+
expect.objectContaining({ name: "deployer", origin: "local", description: "ships it" }),
|
|
122
|
+
]);
|
|
123
|
+
// The body is captured because inline skills are re-expressed by content.
|
|
124
|
+
expect(site.skills[0].content).toContain("steps");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("records every MCP declaration, including ones the first-wins merge shadowed", () => {
|
|
128
|
+
writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { dup: { command: "winner" } } }));
|
|
129
|
+
writeIn(home, ".claude.json", JSON.stringify({ mcpServers: { dup: { command: "loser" } } }));
|
|
130
|
+
|
|
131
|
+
const result = scan({ scopes: ["user"], runtimes: ["claude"] });
|
|
132
|
+
const site = result.sites[0];
|
|
133
|
+
expect(site.mcpServers).toHaveLength(1);
|
|
134
|
+
expect(site.mcpServers[0].command).toBe("winner");
|
|
135
|
+
// Both declarations survive in `declarations` so AGT007 can report the shadowing.
|
|
136
|
+
expect(result.declarations["user-claude"]).toHaveLength(2);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a config with nothing in it produces no site", () => {
|
|
140
|
+
expect(scan({ scopes: ["user"], runtimes: ["claude"] }).sites).toHaveLength(0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("scanAgentConfigs — project scope", () => {
|
|
145
|
+
test("reads .mcp.json and CLAUDE.md from the project root", () => {
|
|
146
|
+
writeIn(project, "CLAUDE.md", "project rules");
|
|
147
|
+
writeIn(project, ".mcp.json", JSON.stringify({ mcpServers: { p: { command: "x" } } }));
|
|
148
|
+
|
|
149
|
+
const [site] = scan({ scopes: ["project"], runtimes: ["claude"] }).sites;
|
|
150
|
+
expect(site.scope).toBe("project");
|
|
151
|
+
expect(site.id).toMatch(/^project-claude-/);
|
|
152
|
+
expect(site.mcpServers.map((s) => s.name)).toEqual(["p"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("picks up per-project MCP servers stored in the home directory's ~/.claude.json", () => {
|
|
156
|
+
// The easy-to-miss case: config that governs a project but lives in $HOME.
|
|
157
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [project]: { mcpServers: { hidden: { command: "x" } } } } }));
|
|
158
|
+
|
|
159
|
+
const [site] = scan({ scopes: ["project"], runtimes: ["claude"] }).sites;
|
|
160
|
+
expect(site.mcpServers.map((s) => s.name)).toEqual(["hidden"]);
|
|
161
|
+
expect(site.sources).toContain(join(home, ".claude.json"));
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("scanAgentConfigs — system scope", () => {
|
|
166
|
+
test("finds nothing when no managed policy is installed", () => {
|
|
167
|
+
expect(scan({ scopes: ["system"] }).sites).toHaveLength(0);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("scanAgentConfigs — codex", () => {
|
|
172
|
+
test("reads mcp_servers tables, model, and .rules files from config.toml", () => {
|
|
173
|
+
writeIn(
|
|
174
|
+
home,
|
|
175
|
+
".codex/config.toml",
|
|
176
|
+
[
|
|
177
|
+
'model = "gpt-5.5"',
|
|
178
|
+
"",
|
|
179
|
+
"[mcp_servers.remote]",
|
|
180
|
+
'url = "https://mcp.example.com/mcp"',
|
|
181
|
+
"",
|
|
182
|
+
"[mcp_servers.local]",
|
|
183
|
+
'command = "run-me"',
|
|
184
|
+
"",
|
|
185
|
+
"[mcp_servers.local.env]",
|
|
186
|
+
'TOKEN = "abc"',
|
|
187
|
+
"",
|
|
188
|
+
"[marketplaces.bundled]",
|
|
189
|
+
'source_type = "local"',
|
|
190
|
+
'source = "/opt/bundled"',
|
|
191
|
+
].join("\n"),
|
|
192
|
+
);
|
|
193
|
+
writeIn(home, ".codex/rules/default.rules", "always test");
|
|
194
|
+
|
|
195
|
+
const [site] = scan({ scopes: ["user"], runtimes: ["codex"] }).sites;
|
|
196
|
+
expect(site.runtime).toBe("codex");
|
|
197
|
+
expect(site.model).toBe("gpt-5.5");
|
|
198
|
+
expect(site.mcpServers.map((s) => s.name)).toEqual(["local", "remote"]);
|
|
199
|
+
expect(site.mcpServers.find((s) => s.name === "local")?.env).toEqual({ TOKEN: "abc" });
|
|
200
|
+
expect(site.instructions.some((i) => i.content === "always test")).toBe(true);
|
|
201
|
+
// A bundled local directory is not a remote that can drift upstream.
|
|
202
|
+
expect(site.plugins).toEqual([expect.objectContaining({ name: "bundled", remote: false })]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("scanAgentConfigs — resilience", () => {
|
|
207
|
+
test("an unparseable file is reported, and the rest of the scan still runs", () => {
|
|
208
|
+
writeIn(home, ".claude/settings.json", "{ not json");
|
|
209
|
+
writeIn(home, ".claude/mcp.json", JSON.stringify({ mcpServers: { ok: { command: "x" } } }));
|
|
210
|
+
|
|
211
|
+
const result = scan({ scopes: ["user"], runtimes: ["claude"] });
|
|
212
|
+
expect(result.unreadable.map((u) => u.path)).toContain(join(home, ".claude/settings.json"));
|
|
213
|
+
expect(result.sites[0].mcpServers.map((s) => s.name)).toEqual(["ok"]);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("probed locations are recorded so the report can say what it looked for", () => {
|
|
217
|
+
const result = scan({ scopes: ["user"], runtimes: ["claude"] });
|
|
218
|
+
expect(result.probed).toContain(join(home, ".claude", "settings.json"));
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("scanAgentConfigs — ordering", () => {
|
|
223
|
+
test("sites come back system → user → project, matching how harnesses merge them", () => {
|
|
224
|
+
writeIn(home, "CLAUDE.md", "user");
|
|
225
|
+
writeIn(project, "CLAUDE.md", "project");
|
|
226
|
+
const scopes = scan({ runtimes: ["claude"] }).sites.map((s) => s.scope);
|
|
227
|
+
expect(scopes).toEqual(["user", "project"]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("registeredProjectRoots", () => {
|
|
232
|
+
test("returns registered project directories, sorted", () => {
|
|
233
|
+
const a = join(project, "a");
|
|
234
|
+
const b = join(project, "b");
|
|
235
|
+
mkdirSync(a, { recursive: true });
|
|
236
|
+
mkdirSync(b, { recursive: true });
|
|
237
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [b]: {}, [a]: {} } }));
|
|
238
|
+
expect(registeredProjectRoots(home)).toEqual([a, b]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("drops registrations whose directory no longer exists", () => {
|
|
242
|
+
// A deleted project isn't something the user can act on, and counting it
|
|
243
|
+
// would make the "N not scanned" note overstate the gap.
|
|
244
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [project]: {}, "/definitely/not/here": {} } }));
|
|
245
|
+
expect(registeredProjectRoots(home)).toEqual([project]);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("is empty when there is no state file", () => {
|
|
249
|
+
expect(registeredProjectRoots(home)).toEqual([]);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("unscannedProjectCount", () => {
|
|
254
|
+
test("counts registered projects the scan did not visit", () => {
|
|
255
|
+
const a = join(project, "a");
|
|
256
|
+
const b = join(project, "b");
|
|
257
|
+
mkdirSync(a, { recursive: true });
|
|
258
|
+
mkdirSync(b, { recursive: true });
|
|
259
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [a]: {}, [b]: {}, [project]: {} } }));
|
|
260
|
+
expect(unscannedProjectCount(home, [project])).toBe(2);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("is zero once every registered project has been scanned", () => {
|
|
264
|
+
writeIn(home, ".claude.json", JSON.stringify({ projects: { [project]: {} } }));
|
|
265
|
+
expect(unscannedProjectCount(home, [project])).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("is zero when there is no state file", () => {
|
|
269
|
+
expect(unscannedProjectCount(home, [])).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("site id uniqueness across many project roots", () => {
|
|
274
|
+
/** Two projects that share a directory name under different parents. */
|
|
275
|
+
function twoBeholds() {
|
|
276
|
+
const one = join(project, "intentius", "behold");
|
|
277
|
+
const two = join(project, "jhgaylor", "behold");
|
|
278
|
+
mkdirSync(one, { recursive: true });
|
|
279
|
+
mkdirSync(two, { recursive: true });
|
|
280
|
+
writeIn(one, "CLAUDE.md", "one");
|
|
281
|
+
writeIn(two, "CLAUDE.md", "two");
|
|
282
|
+
return { one, two };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
test("qualifies colliding ids with parent directory segments", () => {
|
|
286
|
+
// Regression: `--all-projects` on a real machine hit this with two `behold`
|
|
287
|
+
// and two `guild` checkouts. Colliding ids emit duplicate `export const`
|
|
288
|
+
// identifiers in generated TypeScript.
|
|
289
|
+
const { one, two } = twoBeholds();
|
|
290
|
+
const { sites } = scanAgentConfigs({ home, projectRoots: [one, two], platform: "linux", scopes: ["project"], runtimes: ["claude"] });
|
|
291
|
+
expect(sites.map((s) => s.id).sort()).toEqual(["project-claude-intentius-behold", "project-claude-jhgaylor-behold"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("leaves already-unique ids untouched", () => {
|
|
295
|
+
writeIn(project, "CLAUDE.md", "x");
|
|
296
|
+
const { sites } = scanAgentConfigs({ home, projectRoots: [project], platform: "linux", scopes: ["project"], runtimes: ["claude"] });
|
|
297
|
+
expect(sites[0].id).toBe(`project-claude-${project.split("/").pop()!.toLowerCase()}`);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("declarations follow the renamed site rather than clobbering each other", () => {
|
|
301
|
+
const { one, two } = twoBeholds();
|
|
302
|
+
writeIn(one, ".mcp.json", JSON.stringify({ mcpServers: { only_in_one: { command: "a" } } }));
|
|
303
|
+
writeIn(two, ".mcp.json", JSON.stringify({ mcpServers: { only_in_two: { command: "b" } } }));
|
|
304
|
+
|
|
305
|
+
const result = scanAgentConfigs({ home, projectRoots: [one, two], platform: "linux", scopes: ["project"], runtimes: ["claude"] });
|
|
306
|
+
const names = (id: string) => (result.declarations[id] ?? []).map((d) => d.name);
|
|
307
|
+
expect(names("project-claude-intentius-behold")).toEqual(["only_in_one"]);
|
|
308
|
+
expect(names("project-claude-jhgaylor-behold")).toEqual(["only_in_two"]);
|
|
309
|
+
});
|
|
310
|
+
});
|