agentboot 0.1.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 (78) hide show
  1. package/.github/ISSUE_TEMPLATE/persona-request.md +62 -0
  2. package/.github/ISSUE_TEMPLATE/quality-feedback.md +67 -0
  3. package/.github/workflows/cla.yml +25 -0
  4. package/.github/workflows/validate.yml +49 -0
  5. package/.idea/agentboot.iml +9 -0
  6. package/.idea/misc.xml +6 -0
  7. package/.idea/modules.xml +8 -0
  8. package/.idea/vcs.xml +6 -0
  9. package/CLA.md +98 -0
  10. package/CLAUDE.md +230 -0
  11. package/CONTRIBUTING.md +168 -0
  12. package/LICENSE +191 -0
  13. package/NOTICE +4 -0
  14. package/PERSONAS.md +156 -0
  15. package/README.md +172 -0
  16. package/agentboot.config.json +207 -0
  17. package/bin/agentboot.js +17 -0
  18. package/core/gotchas/README.md +35 -0
  19. package/core/instructions/baseline.instructions.md +133 -0
  20. package/core/instructions/security.instructions.md +186 -0
  21. package/core/personas/code-reviewer/SKILL.md +175 -0
  22. package/core/personas/code-reviewer/persona.config.json +11 -0
  23. package/core/personas/security-reviewer/SKILL.md +233 -0
  24. package/core/personas/security-reviewer/persona.config.json +11 -0
  25. package/core/personas/test-data-expert/SKILL.md +234 -0
  26. package/core/personas/test-data-expert/persona.config.json +10 -0
  27. package/core/personas/test-generator/SKILL.md +262 -0
  28. package/core/personas/test-generator/persona.config.json +10 -0
  29. package/core/traits/audit-trail.md +182 -0
  30. package/core/traits/confidence-signaling.md +172 -0
  31. package/core/traits/critical-thinking.md +129 -0
  32. package/core/traits/schema-awareness.md +132 -0
  33. package/core/traits/source-citation.md +174 -0
  34. package/core/traits/structured-output.md +199 -0
  35. package/docs/ci-cd-automation.md +548 -0
  36. package/docs/claude-code-reference/README.md +21 -0
  37. package/docs/claude-code-reference/agentboot-coverage.md +484 -0
  38. package/docs/claude-code-reference/feature-inventory.md +906 -0
  39. package/docs/cli-commands-audit.md +112 -0
  40. package/docs/cli-design.md +924 -0
  41. package/docs/concepts.md +1117 -0
  42. package/docs/config-schema-audit.md +121 -0
  43. package/docs/configuration.md +645 -0
  44. package/docs/delivery-methods.md +758 -0
  45. package/docs/developer-onboarding.md +342 -0
  46. package/docs/extending.md +448 -0
  47. package/docs/getting-started.md +298 -0
  48. package/docs/knowledge-layer.md +464 -0
  49. package/docs/marketplace.md +822 -0
  50. package/docs/org-connection.md +570 -0
  51. package/docs/plans/architecture.md +2429 -0
  52. package/docs/plans/design.md +2018 -0
  53. package/docs/plans/prd.md +1862 -0
  54. package/docs/plans/stack-rank.md +261 -0
  55. package/docs/plans/technical-spec.md +2755 -0
  56. package/docs/privacy-and-safety.md +807 -0
  57. package/docs/prompt-optimization.md +1071 -0
  58. package/docs/test-plan.md +972 -0
  59. package/docs/third-party-ecosystem.md +496 -0
  60. package/domains/compliance-template/README.md +173 -0
  61. package/domains/compliance-template/traits/compliance-aware.md +228 -0
  62. package/examples/enterprise/agentboot.config.json +184 -0
  63. package/examples/minimal/agentboot.config.json +46 -0
  64. package/package.json +63 -0
  65. package/repos.json +1 -0
  66. package/scripts/cli.ts +1069 -0
  67. package/scripts/compile.ts +1000 -0
  68. package/scripts/dev-sync.ts +149 -0
  69. package/scripts/lib/config.ts +137 -0
  70. package/scripts/lib/frontmatter.ts +61 -0
  71. package/scripts/sync.ts +687 -0
  72. package/scripts/validate.ts +421 -0
  73. package/tests/REGRESSION-PLAN.md +705 -0
  74. package/tests/TEST-PLAN.md +111 -0
  75. package/tests/cli.test.ts +705 -0
  76. package/tests/pipeline.test.ts +608 -0
  77. package/tests/validate.test.ts +278 -0
  78. package/tsconfig.json +62 -0
@@ -0,0 +1,705 @@
1
+ /**
2
+ * Tests for CLI commands and AB-2 compile features.
3
+ *
4
+ * Covers: AB-18 (context:fork), AB-33 (setup), AB-34/35 (add), AB-36 (doctor),
5
+ * AB-37 (status), AB-38 (lint), AB-45 (uninstall), AB-52 (gotchas),
6
+ * AB-55 (prompt style guide), AB-77 (welcome fragment), config command.
7
+ */
8
+
9
+ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
10
+ import { execSync } from "node:child_process";
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import os from "node:os";
15
+
16
+ const ROOT = path.resolve(__dirname, "..");
17
+ const TSX = path.join(ROOT, "node_modules", ".bin", "tsx");
18
+ const CLI = path.join(ROOT, "scripts", "cli.ts");
19
+
20
+ function run(args: string, cwd = ROOT): string {
21
+ return execSync(`${TSX} ${CLI} ${args}`, {
22
+ cwd,
23
+ env: { ...process.env, NODE_NO_WARNINGS: "1", FORCE_COLOR: "0" },
24
+ timeout: 30_000,
25
+ }).toString();
26
+ }
27
+
28
+ function runExpectFail(args: string, cwd = ROOT): string {
29
+ try {
30
+ run(args, cwd);
31
+ throw new Error("Expected command to fail but it succeeded");
32
+ } catch (err: any) {
33
+ if (err.message === "Expected command to fail but it succeeded") throw err;
34
+ const stdout = err.stdout?.toString() ?? "";
35
+ const stderr = err.stderr?.toString() ?? "";
36
+ return stdout + stderr || err.message;
37
+ }
38
+ }
39
+
40
+ // ===========================================================================
41
+ // AB-18: Skills with context:fork
42
+ // ===========================================================================
43
+
44
+ describe("AB-18: skill frontmatter with context:fork", () => {
45
+ const skillPath = path.join(ROOT, "dist", "claude", "core", "skills", "review-code", "SKILL.md");
46
+
47
+ it("skill frontmatter contains context: fork", () => {
48
+ const content = fs.readFileSync(skillPath, "utf-8");
49
+ expect(content).toMatch(/^---\n/);
50
+ expect(content).toContain("context: fork");
51
+ });
52
+
53
+ it("skill frontmatter contains agent: pointing to persona name", () => {
54
+ const content = fs.readFileSync(skillPath, "utf-8");
55
+ expect(content).toContain('agent: "code-reviewer"');
56
+ });
57
+
58
+ it("skill description is properly quoted in YAML", () => {
59
+ const content = fs.readFileSync(skillPath, "utf-8");
60
+ expect(content).toMatch(/description: ".*"/);
61
+ });
62
+
63
+ it("all 4 skills have context:fork and correct agent references", () => {
64
+ const skills = [
65
+ { dir: "review-code", agent: "code-reviewer" },
66
+ { dir: "review-security", agent: "security-reviewer" },
67
+ { dir: "gen-tests", agent: "test-generator" },
68
+ { dir: "gen-testdata", agent: "test-data-expert" },
69
+ ];
70
+ for (const skill of skills) {
71
+ const content = fs.readFileSync(
72
+ path.join(ROOT, "dist", "claude", "core", "skills", skill.dir, "SKILL.md"),
73
+ "utf-8",
74
+ );
75
+ expect(content, `${skill.dir} should have context: fork`).toContain("context: fork");
76
+ expect(content, `${skill.dir} should reference agent ${skill.agent}`).toContain(
77
+ `agent: "${skill.agent}"`,
78
+ );
79
+ }
80
+ });
81
+
82
+ it("skill frontmatter does NOT have double frontmatter blocks", () => {
83
+ const content = fs.readFileSync(skillPath, "utf-8");
84
+ // The file should start with exactly one frontmatter block (---\n...\n---)
85
+ // Check that there's only one frontmatter opening at the very start
86
+ const frontmatterOpens = content.match(/^---\n/gm);
87
+ // First line must be --- and there should be exactly one more --- that closes it
88
+ expect(content.startsWith("---\n")).toBe(true);
89
+ // After stripping the frontmatter, the remaining content should NOT start with ---
90
+ const afterFm = content.replace(/^---\n[\s\S]*?\n---\n*/, "");
91
+ expect(afterFm.startsWith("---")).toBe(false);
92
+ });
93
+
94
+ it("original SKILL.md frontmatter is stripped (not duplicated)", () => {
95
+ const content = fs.readFileSync(skillPath, "utf-8");
96
+ // The source SKILL.md has "name:" and "version:" in frontmatter — these should be stripped
97
+ expect(content).not.toMatch(/^name:/m);
98
+ expect(content).not.toMatch(/^version:/m);
99
+ });
100
+ });
101
+
102
+ // ===========================================================================
103
+ // AB-77: Welcome fragment in CLAUDE.md
104
+ // ===========================================================================
105
+
106
+ describe("AB-77: welcome fragment in CLAUDE.md", () => {
107
+ const claudeMdPath = path.join(ROOT, "dist", "claude", "core", "CLAUDE.md");
108
+
109
+ it("contains Available Personas section", () => {
110
+ const content = fs.readFileSync(claudeMdPath, "utf-8");
111
+ expect(content).toContain("## Available Personas");
112
+ });
113
+
114
+ it("lists all 4 personas with invocation commands", () => {
115
+ const content = fs.readFileSync(claudeMdPath, "utf-8");
116
+ expect(content).toContain("`/review-code`");
117
+ expect(content).toContain("`/review-security`");
118
+ expect(content).toContain("`/gen-tests`");
119
+ expect(content).toContain("`/gen-testdata`");
120
+ });
121
+
122
+ it("includes persona descriptions", () => {
123
+ const content = fs.readFileSync(claudeMdPath, "utf-8");
124
+ expect(content).toContain("Senior code reviewer");
125
+ });
126
+
127
+ it("instruction @imports do NOT have double .md extension", () => {
128
+ const content = fs.readFileSync(claudeMdPath, "utf-8");
129
+ expect(content).not.toContain(".md.md");
130
+ });
131
+ });
132
+
133
+ // ===========================================================================
134
+ // AB-52: Gotchas compilation
135
+ // ===========================================================================
136
+
137
+ describe("AB-52: gotchas compilation", () => {
138
+ // compile.ts resolves coreDir from its own ROOT, so we test gotchas
139
+ // by creating a gotcha in the actual project, building, then cleaning up.
140
+ const gotchaPath = path.join(ROOT, "core", "gotchas", "test-gotcha.md");
141
+
142
+ afterEach(() => {
143
+ if (fs.existsSync(gotchaPath)) fs.unlinkSync(gotchaPath);
144
+ });
145
+
146
+ it("compiles gotcha files from core/gotchas/ to dist/claude/core/rules/", () => {
147
+ // Create a test gotcha in the real project
148
+ fs.writeFileSync(
149
+ gotchaPath,
150
+ '---\npaths:\n - "**/*.lambda.ts"\n---\n\n# Test Gotcha\n\n- Cold start penalty\n',
151
+ );
152
+
153
+ // Rebuild
154
+ run("build");
155
+
156
+ // Gotcha should appear in claude rules
157
+ const rulesDir = path.join(ROOT, "dist", "claude", "core", "rules");
158
+ expect(fs.existsSync(path.join(rulesDir, "test-gotcha.md"))).toBe(true);
159
+ const content = fs.readFileSync(path.join(rulesDir, "test-gotcha.md"), "utf-8");
160
+ expect(content).toContain("Cold start penalty");
161
+
162
+ // Gotcha should appear in skill gotchas
163
+ const skillGotchasDir = path.join(ROOT, "dist", "skill", "core", "gotchas");
164
+ expect(fs.existsSync(path.join(skillGotchasDir, "test-gotcha.md"))).toBe(true);
165
+
166
+ // README should NOT be compiled
167
+ expect(fs.existsSync(path.join(rulesDir, "README.md"))).toBe(false);
168
+
169
+ // Clean up dist gotcha files
170
+ fs.unlinkSync(path.join(rulesDir, "test-gotcha.md"));
171
+ if (fs.existsSync(path.join(skillGotchasDir, "test-gotcha.md"))) {
172
+ fs.unlinkSync(path.join(skillGotchasDir, "test-gotcha.md"));
173
+ }
174
+ });
175
+ });
176
+
177
+ // ===========================================================================
178
+ // AB-33: setup command
179
+ // ===========================================================================
180
+
181
+ describe("AB-33: setup command", () => {
182
+ let tempDir: string;
183
+
184
+ beforeEach(() => {
185
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-setup-"));
186
+ });
187
+
188
+ afterEach(() => {
189
+ fs.rmSync(tempDir, { recursive: true, force: true });
190
+ });
191
+
192
+ it("scaffolds agentboot.config.json in a fresh directory", () => {
193
+ run("setup --skip-detect", tempDir);
194
+ const configPath = path.join(tempDir, "agentboot.config.json");
195
+ expect(fs.existsSync(configPath)).toBe(true);
196
+ const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
197
+ expect(config.org).toBeDefined();
198
+ expect(config.personas.enabled).toContain("code-reviewer");
199
+ expect(config.traits.enabled).toContain("critical-thinking");
200
+ });
201
+
202
+ it("scaffolds repos.json", () => {
203
+ run("setup --skip-detect", tempDir);
204
+ expect(fs.existsSync(path.join(tempDir, "repos.json"))).toBe(true);
205
+ const repos = JSON.parse(fs.readFileSync(path.join(tempDir, "repos.json"), "utf-8"));
206
+ expect(repos).toEqual([]);
207
+ });
208
+
209
+ it("creates core directory structure", () => {
210
+ run("setup --skip-detect", tempDir);
211
+ expect(fs.existsSync(path.join(tempDir, "core", "personas"))).toBe(true);
212
+ expect(fs.existsSync(path.join(tempDir, "core", "traits"))).toBe(true);
213
+ expect(fs.existsSync(path.join(tempDir, "core", "instructions"))).toBe(true);
214
+ expect(fs.existsSync(path.join(tempDir, "core", "gotchas"))).toBe(true);
215
+ });
216
+
217
+ it("does not overwrite existing agentboot.config.json", () => {
218
+ fs.writeFileSync(path.join(tempDir, "agentboot.config.json"), '{"org": "existing"}');
219
+ const output = run("setup", tempDir);
220
+ expect(output).toContain("already exists");
221
+ // Config should not be overwritten
222
+ const config = JSON.parse(fs.readFileSync(path.join(tempDir, "agentboot.config.json"), "utf-8"));
223
+ expect(config.org).toBe("existing");
224
+ });
225
+
226
+ it("generates valid JSON config (parseable, no syntax errors)", () => {
227
+ run("setup --skip-detect", tempDir);
228
+ const raw = fs.readFileSync(path.join(tempDir, "agentboot.config.json"), "utf-8");
229
+ expect(() => JSON.parse(raw)).not.toThrow();
230
+ });
231
+ });
232
+
233
+ // ===========================================================================
234
+ // AB-34/35/55: add command
235
+ // ===========================================================================
236
+
237
+ describe("AB-34/35/55: add command", () => {
238
+ let tempDir: string;
239
+
240
+ beforeEach(() => {
241
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-add-"));
242
+ // Create the core directories the add command expects
243
+ fs.mkdirSync(path.join(tempDir, "core", "personas"), { recursive: true });
244
+ fs.mkdirSync(path.join(tempDir, "core", "traits"), { recursive: true });
245
+ fs.mkdirSync(path.join(tempDir, "core", "gotchas"), { recursive: true });
246
+ });
247
+
248
+ afterEach(() => {
249
+ fs.rmSync(tempDir, { recursive: true, force: true });
250
+ });
251
+
252
+ it("scaffolds a persona with SKILL.md and persona.config.json", () => {
253
+ run("add persona my-reviewer", tempDir);
254
+ const personaDir = path.join(tempDir, "core", "personas", "my-reviewer");
255
+ expect(fs.existsSync(path.join(personaDir, "SKILL.md"))).toBe(true);
256
+ expect(fs.existsSync(path.join(personaDir, "persona.config.json"))).toBe(true);
257
+ });
258
+
259
+ it("persona SKILL.md has trait injection markers", () => {
260
+ run("add persona my-reviewer", tempDir);
261
+ const content = fs.readFileSync(
262
+ path.join(tempDir, "core", "personas", "my-reviewer", "SKILL.md"),
263
+ "utf-8",
264
+ );
265
+ expect(content).toContain("<!-- traits:start -->");
266
+ expect(content).toContain("<!-- traits:end -->");
267
+ });
268
+
269
+ it("persona SKILL.md has required frontmatter", () => {
270
+ run("add persona my-reviewer", tempDir);
271
+ const content = fs.readFileSync(
272
+ path.join(tempDir, "core", "personas", "my-reviewer", "SKILL.md"),
273
+ "utf-8",
274
+ );
275
+ expect(content).toMatch(/^---\n/);
276
+ expect(content).toContain("name: my-reviewer");
277
+ expect(content).toContain("description:");
278
+ });
279
+
280
+ it("persona SKILL.md has style guide sections (AB-55)", () => {
281
+ run("add persona my-reviewer", tempDir);
282
+ const content = fs.readFileSync(
283
+ path.join(tempDir, "core", "personas", "my-reviewer", "SKILL.md"),
284
+ "utf-8",
285
+ );
286
+ expect(content).toContain("## Identity");
287
+ expect(content).toContain("## Setup");
288
+ expect(content).toContain("## Rules");
289
+ expect(content).toContain("## Output Format");
290
+ expect(content).toContain("## What Not To Do");
291
+ // Style guide comments
292
+ expect(content).toContain("imperative voice");
293
+ expect(content).toContain("20 rules maximum");
294
+ });
295
+
296
+ it("persona.config.json is valid JSON with required fields", () => {
297
+ run("add persona my-reviewer", tempDir);
298
+ const config = JSON.parse(
299
+ fs.readFileSync(path.join(tempDir, "core", "personas", "my-reviewer", "persona.config.json"), "utf-8"),
300
+ );
301
+ expect(config.name).toBe("My Reviewer");
302
+ expect(config.description).toBeDefined();
303
+ expect(config.invocation).toBe("/my-reviewer");
304
+ expect(Array.isArray(config.traits)).toBe(true);
305
+ });
306
+
307
+ it("rejects duplicate persona names", () => {
308
+ run("add persona my-reviewer", tempDir);
309
+ const output = runExpectFail("add persona my-reviewer", tempDir);
310
+ expect(output).toContain("already exists");
311
+ });
312
+
313
+ it("rejects invalid names (uppercase, special chars, leading digit)", () => {
314
+ expect(() => run("add persona MyReviewer", tempDir)).toThrow();
315
+ expect(() => run("add persona 1bad", tempDir)).toThrow();
316
+ expect(() => run("add persona has_underscore", tempDir)).toThrow();
317
+ });
318
+
319
+ it("scaffolds a trait with correct sections", () => {
320
+ run("add trait error-handling", tempDir);
321
+ const traitPath = path.join(tempDir, "core", "traits", "error-handling.md");
322
+ expect(fs.existsSync(traitPath)).toBe(true);
323
+ const content = fs.readFileSync(traitPath, "utf-8");
324
+ expect(content).toContain("# Error Handling");
325
+ expect(content).toContain("## When to Apply");
326
+ expect(content).toContain("## What to Do");
327
+ expect(content).toContain("## What Not to Do");
328
+ });
329
+
330
+ it("scaffolds a gotcha with paths frontmatter", () => {
331
+ run("add gotcha lambda-cold-starts", tempDir);
332
+ const gotchaPath = path.join(tempDir, "core", "gotchas", "lambda-cold-starts.md");
333
+ expect(fs.existsSync(gotchaPath)).toBe(true);
334
+ const content = fs.readFileSync(gotchaPath, "utf-8");
335
+ expect(content).toMatch(/^---\n/);
336
+ expect(content).toContain("paths:");
337
+ expect(content).toContain("# Lambda Cold Starts");
338
+ });
339
+
340
+ it("rejects unknown type", () => {
341
+ const output = runExpectFail("add widget foo", tempDir);
342
+ expect(output).toContain("Unknown type");
343
+ });
344
+
345
+ it("capitalizes hyphenated names correctly", () => {
346
+ run("add persona multi-word-name", tempDir);
347
+ const config = JSON.parse(
348
+ fs.readFileSync(path.join(tempDir, "core", "personas", "multi-word-name", "persona.config.json"), "utf-8"),
349
+ );
350
+ expect(config.name).toBe("Multi Word Name");
351
+ });
352
+ });
353
+
354
+ // ===========================================================================
355
+ // AB-36: doctor command
356
+ // ===========================================================================
357
+
358
+ describe("AB-36: doctor command", () => {
359
+ it("passes all checks on the project root", () => {
360
+ const output = run("doctor");
361
+ expect(output).toContain("All checks passed");
362
+ });
363
+
364
+ it("detects missing config", () => {
365
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-doc-"));
366
+ try {
367
+ const output = runExpectFail(`doctor --config ${path.join(tempDir, "nonexistent.json")}`, tempDir);
368
+ expect(output).toContain("not found");
369
+ } finally {
370
+ fs.rmSync(tempDir, { recursive: true, force: true });
371
+ }
372
+ });
373
+
374
+ it("reports exit code 0 on success", () => {
375
+ // Should not throw
376
+ run("doctor");
377
+ });
378
+ });
379
+
380
+ // ===========================================================================
381
+ // AB-37: status command
382
+ // ===========================================================================
383
+
384
+ describe("AB-37: status command", () => {
385
+ it("shows org and persona info", () => {
386
+ const output = run("status");
387
+ expect(output).toContain("Your Organization");
388
+ expect(output).toContain("code-reviewer");
389
+ expect(output).toContain("4 enabled");
390
+ });
391
+
392
+ it("--format json produces valid JSON", () => {
393
+ const output = run("status --format json");
394
+ const status = JSON.parse(output);
395
+ expect(status.org).toBe("your-org");
396
+ expect(status.personas).toContain("code-reviewer");
397
+ expect(Array.isArray(status.repos)).toBe(true);
398
+ });
399
+
400
+ it("shows 'No repos' when repos.json is empty", () => {
401
+ const output = run("status");
402
+ expect(output).toContain("No repos");
403
+ });
404
+ });
405
+
406
+ // ===========================================================================
407
+ // AB-38: lint command
408
+ // ===========================================================================
409
+
410
+ describe("AB-38: lint command", () => {
411
+ it("reports trait-too-long for current traits", () => {
412
+ const output = run("lint");
413
+ expect(output).toContain("trait-too-long");
414
+ });
415
+
416
+ it("--severity error hides warnings", () => {
417
+ const output = run("lint --severity error");
418
+ // Should only show errors or pass with no issues
419
+ expect(output).not.toContain("WARN");
420
+ });
421
+
422
+ it("--severity info shows warnings (info threshold includes all)", () => {
423
+ const output = run("lint --severity info");
424
+ // With info threshold, warnings are still shown
425
+ expect(output).toContain("WARN");
426
+ });
427
+
428
+ it("--format json produces valid JSON output", () => {
429
+ const output = run("lint --format json --severity info");
430
+ const findings = JSON.parse(output);
431
+ expect(Array.isArray(findings)).toBe(true);
432
+ for (const f of findings) {
433
+ expect(f.rule).toBeDefined();
434
+ expect(f.severity).toMatch(/^(info|warn|error)$/);
435
+ expect(f.file).toBeDefined();
436
+ expect(f.message).toBeDefined();
437
+ }
438
+ });
439
+
440
+ it("--persona filters to specific persona", () => {
441
+ const output = run("lint --format json --severity info --persona code-reviewer");
442
+ const findings = JSON.parse(output);
443
+ // All findings should be for code-reviewer persona or general (traits)
444
+ for (const f of findings) {
445
+ const isPersonaFile = f.file.includes("code-reviewer");
446
+ const isTraitFile = f.file.startsWith("core/traits/");
447
+ expect(isPersonaFile || isTraitFile, `Unexpected file in filtered output: ${f.file}`).toBe(true);
448
+ }
449
+ });
450
+
451
+ it("detects vague language in a test persona", () => {
452
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-lint-"));
453
+ const personaDir = path.join(tempDir, "core", "personas", "vague-persona");
454
+ fs.mkdirSync(personaDir, { recursive: true });
455
+ fs.writeFileSync(
456
+ path.join(personaDir, "SKILL.md"),
457
+ '---\nname: vague\ndescription: test\n---\n\nBe thorough when reviewing.\nTry to check if possible.\n',
458
+ );
459
+ fs.writeFileSync(
460
+ path.join(tempDir, "agentboot.config.json"),
461
+ JSON.stringify({
462
+ org: "test",
463
+ personas: { enabled: ["vague-persona"] },
464
+ traits: { enabled: [] },
465
+ }),
466
+ );
467
+
468
+ try {
469
+ const output = run(`lint --config ${path.join(tempDir, "agentboot.config.json")} --format json --severity warn`, tempDir);
470
+ const findings = JSON.parse(output);
471
+ const vagueFindings = findings.filter((f: any) => f.rule === "vague-instruction");
472
+ expect(vagueFindings.length).toBeGreaterThanOrEqual(2);
473
+ } finally {
474
+ fs.rmSync(tempDir, { recursive: true, force: true });
475
+ }
476
+ });
477
+
478
+ it("detects secrets in persona files", () => {
479
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-lint-secret-"));
480
+ const personaDir = path.join(tempDir, "core", "personas", "secret-persona");
481
+ fs.mkdirSync(personaDir, { recursive: true });
482
+ fs.writeFileSync(
483
+ path.join(personaDir, "SKILL.md"),
484
+ '---\nname: secret\ndescription: test\n---\n\nUse api key sk-abcdefghijklmnopqrstuvwxyz1234 for testing.\n',
485
+ );
486
+ fs.writeFileSync(
487
+ path.join(tempDir, "agentboot.config.json"),
488
+ JSON.stringify({
489
+ org: "test",
490
+ personas: { enabled: ["secret-persona"] },
491
+ traits: { enabled: [] },
492
+ }),
493
+ );
494
+
495
+ try {
496
+ const output = runExpectFail(
497
+ `lint --config ${path.join(tempDir, "agentboot.config.json")} --format json`,
498
+ tempDir,
499
+ );
500
+ const findings = JSON.parse(output);
501
+ expect(findings.some((f: any) => f.rule === "credential-in-prompt")).toBe(true);
502
+ } finally {
503
+ fs.rmSync(tempDir, { recursive: true, force: true });
504
+ }
505
+ });
506
+ });
507
+
508
+ // ===========================================================================
509
+ // AB-45: uninstall command
510
+ // ===========================================================================
511
+
512
+ describe("AB-45: uninstall command", () => {
513
+ let syncTarget: string;
514
+ let originalRepos: string;
515
+
516
+ beforeAll(() => {
517
+ // Build + sync to a temp target to have something to uninstall
518
+ originalRepos = fs.readFileSync(path.join(ROOT, "repos.json"), "utf-8");
519
+ syncTarget = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-uninstall-"));
520
+ fs.writeFileSync(
521
+ path.join(ROOT, "repos.json"),
522
+ JSON.stringify([{ path: syncTarget, label: "uninstall-test", platform: "claude" }]),
523
+ );
524
+ run("sync");
525
+ });
526
+
527
+ afterAll(() => {
528
+ fs.writeFileSync(path.join(ROOT, "repos.json"), originalRepos);
529
+ if (syncTarget) fs.rmSync(syncTarget, { recursive: true, force: true });
530
+ });
531
+
532
+ it("dry-run lists files without removing them", () => {
533
+ const output = run(`uninstall --repo ${syncTarget} --dry-run`);
534
+ expect(output).toContain("would remove");
535
+ // Files should still exist
536
+ expect(fs.existsSync(path.join(syncTarget, ".claude", "skills", "review-code", "SKILL.md"))).toBe(true);
537
+ });
538
+
539
+ it("uninstall removes synced files with matching hashes", () => {
540
+ // Verify manifest exists before uninstall
541
+ const manifestPath = path.join(syncTarget, ".claude", ".agentboot-manifest.json");
542
+ expect(fs.existsSync(manifestPath)).toBe(true);
543
+
544
+ const output = run(`uninstall --repo ${syncTarget}`);
545
+ expect(output).toContain("removed");
546
+
547
+ // Skills should be gone
548
+ expect(fs.existsSync(path.join(syncTarget, ".claude", "skills", "review-code", "SKILL.md"))).toBe(false);
549
+ // Manifest should be gone
550
+ expect(fs.existsSync(manifestPath)).toBe(false);
551
+ });
552
+
553
+ it("skips modified files (hash mismatch)", () => {
554
+ // Re-sync to have fresh files
555
+ fs.writeFileSync(
556
+ path.join(ROOT, "repos.json"),
557
+ JSON.stringify([{ path: syncTarget, label: "uninstall-test", platform: "claude" }]),
558
+ );
559
+ run("sync");
560
+
561
+ // Modify one file
562
+ const skillPath = path.join(syncTarget, ".claude", "skills", "review-code", "SKILL.md");
563
+ fs.appendFileSync(skillPath, "\n<!-- manually modified -->\n");
564
+
565
+ const output = run(`uninstall --repo ${syncTarget}`);
566
+ expect(output).toContain("modified");
567
+ // Modified file should still exist
568
+ expect(fs.existsSync(skillPath)).toBe(true);
569
+ });
570
+
571
+ it("rejects path traversal in manifest entries", () => {
572
+ // Create a malicious manifest
573
+ const maliciousTarget = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-traversal-"));
574
+ const claudeDir = path.join(maliciousTarget, ".claude");
575
+ fs.mkdirSync(claudeDir, { recursive: true });
576
+
577
+ // Create a canary file outside .claude/ that should NOT be deleted
578
+ const canaryPath = path.join(maliciousTarget, "important-file.txt");
579
+ fs.writeFileSync(canaryPath, "do not delete");
580
+
581
+ // Write manifest with traversal attempt
582
+ const canaryContent = fs.readFileSync(canaryPath);
583
+ const canaryHash = createHash("sha256").update(canaryContent).digest("hex");
584
+ fs.writeFileSync(
585
+ path.join(claudeDir, ".agentboot-manifest.json"),
586
+ JSON.stringify({
587
+ managed_by: "agentboot",
588
+ version: "0.1.0",
589
+ synced_at: new Date().toISOString(),
590
+ files: [{ path: "../important-file.txt", hash: canaryHash }],
591
+ }),
592
+ );
593
+
594
+ try {
595
+ const output = run(`uninstall --repo ${maliciousTarget}`);
596
+ expect(output).toContain("rejected");
597
+ // Canary file should still exist
598
+ expect(fs.existsSync(canaryPath)).toBe(true);
599
+ } finally {
600
+ fs.rmSync(maliciousTarget, { recursive: true, force: true });
601
+ }
602
+ });
603
+
604
+ it("handles no manifest gracefully", () => {
605
+ const emptyTarget = fs.mkdtempSync(path.join(os.tmpdir(), "agentboot-empty-"));
606
+ try {
607
+ const output = run(`uninstall --repo ${emptyTarget}`);
608
+ expect(output).toContain("No .agentboot-manifest.json found");
609
+ } finally {
610
+ fs.rmSync(emptyTarget, { recursive: true, force: true });
611
+ }
612
+ });
613
+ });
614
+
615
+ // ===========================================================================
616
+ // Config command
617
+ // ===========================================================================
618
+
619
+ describe("config command", () => {
620
+ it("reads a top-level key", () => {
621
+ const output = run("config org");
622
+ expect(output.trim()).toBe("your-org");
623
+ });
624
+
625
+ it("reads a nested key with dot notation", () => {
626
+ const output = run("config personas.enabled");
627
+ const parsed = JSON.parse(output);
628
+ expect(parsed).toContain("code-reviewer");
629
+ });
630
+
631
+ it("errors on nonexistent key", () => {
632
+ const output = runExpectFail("config nonexistent.key");
633
+ expect(output).toContain("Key not found");
634
+ });
635
+
636
+ it("config mutation exits non-zero", () => {
637
+ expect(() => run("config org newvalue")).toThrow();
638
+ });
639
+ });
640
+
641
+ // ===========================================================================
642
+ // YAML frontmatter safety
643
+ // ===========================================================================
644
+
645
+ describe("YAML frontmatter safety", () => {
646
+ it("all compiled skill descriptions are quoted in YAML frontmatter", () => {
647
+ // Verify existing compiled output has quoted descriptions
648
+ const skills = ["review-code", "review-security", "gen-tests", "gen-testdata"];
649
+ for (const skill of skills) {
650
+ const content = fs.readFileSync(
651
+ path.join(ROOT, "dist", "claude", "core", "skills", skill, "SKILL.md"),
652
+ "utf-8",
653
+ );
654
+ expect(content, `${skill} description should be quoted`).toMatch(/description: ".*"/);
655
+ expect(content, `${skill} agent should be quoted`).toMatch(/agent: ".*"/);
656
+ }
657
+ });
658
+
659
+ it("all compiled agent names and descriptions are quoted", () => {
660
+ const agents = ["code-reviewer", "security-reviewer", "test-generator", "test-data-expert"];
661
+ for (const agent of agents) {
662
+ const content = fs.readFileSync(
663
+ path.join(ROOT, "dist", "claude", "core", "agents", `${agent}.md`),
664
+ "utf-8",
665
+ );
666
+ expect(content, `${agent} name should be quoted`).toMatch(/name: ".*"/);
667
+ expect(content, `${agent} description should be quoted`).toMatch(/description: ".*"/);
668
+ }
669
+ });
670
+
671
+ it("description containing special YAML characters is safely escaped", () => {
672
+ // The security-reviewer description contains an em-dash (—) which is safe but worth verifying
673
+ const content = fs.readFileSync(
674
+ path.join(ROOT, "dist", "claude", "core", "skills", "review-security", "SKILL.md"),
675
+ "utf-8",
676
+ );
677
+ // Description should be quoted and contain the full text
678
+ expect(content).toContain('description: "Adversarial security reviewer');
679
+ });
680
+ });
681
+
682
+ // ===========================================================================
683
+ // CLI global behavior
684
+ // ===========================================================================
685
+
686
+ describe("CLI global behavior", () => {
687
+ it("--version outputs the version from package.json", () => {
688
+ const output = run("--version");
689
+ expect(output.trim()).toMatch(/^\d+\.\d+\.\d+$/);
690
+ });
691
+
692
+ it("--help lists all commands", () => {
693
+ const output = run("--help");
694
+ expect(output).toContain("build");
695
+ expect(output).toContain("validate");
696
+ expect(output).toContain("sync");
697
+ expect(output).toContain("setup");
698
+ expect(output).toContain("add");
699
+ expect(output).toContain("doctor");
700
+ expect(output).toContain("status");
701
+ expect(output).toContain("lint");
702
+ expect(output).toContain("uninstall");
703
+ expect(output).toContain("config");
704
+ });
705
+ });