@os-eco/overstory-cli 0.7.4 → 0.7.6

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.
@@ -2,15 +2,22 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { readdir } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { cleanupTempDir, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
5
- import { initCommand, OVERSTORY_GITIGNORE, OVERSTORY_README } from "./init.ts";
5
+ import type { Spawner } from "./init.ts";
6
+ import { initCommand, OVERSTORY_GITIGNORE, OVERSTORY_README, resolveToolSet } from "./init.ts";
6
7
 
7
8
  /**
8
9
  * Tests for `overstory init` -- agent definition deployment.
9
10
  *
10
11
  * Uses real temp git repos. Suppresses stdout to keep test output clean.
11
12
  * process.cwd() is saved/restored because initCommand uses it to find the project root.
13
+ *
14
+ * Tests that don't exercise ecosystem bootstrap pass a no-op spawner via _spawner
15
+ * so they don't require ml/sd/cn CLIs to be installed (they aren't available in CI).
12
16
  */
13
17
 
18
+ /** No-op spawner that treats all ecosystem tools as "not installed". */
19
+ const noopSpawner: Spawner = async () => ({ exitCode: 1, stdout: "", stderr: "not found" });
20
+
14
21
  const AGENT_DEF_FILES = [
15
22
  "scout.md",
16
23
  "builder.md",
@@ -46,7 +53,7 @@ describe("initCommand: agent-defs deployment", () => {
46
53
  });
47
54
 
48
55
  test("creates .overstory/agent-defs/ with all 7 agent definition files (supervisor deprecated)", async () => {
49
- await initCommand({});
56
+ await initCommand({ _spawner: noopSpawner });
50
57
 
51
58
  const agentDefsDir = join(tempDir, ".overstory", "agent-defs");
52
59
  const files = await readdir(agentDefsDir);
@@ -56,7 +63,7 @@ describe("initCommand: agent-defs deployment", () => {
56
63
  });
57
64
 
58
65
  test("copied files match source content", async () => {
59
- await initCommand({});
66
+ await initCommand({ _spawner: noopSpawner });
60
67
 
61
68
  for (const fileName of AGENT_DEF_FILES) {
62
69
  const sourcePath = join(SOURCE_AGENTS_DIR, fileName);
@@ -71,7 +78,7 @@ describe("initCommand: agent-defs deployment", () => {
71
78
 
72
79
  test("--force reinit overwrites existing agent def files", async () => {
73
80
  // First init
74
- await initCommand({});
81
+ await initCommand({ _spawner: noopSpawner });
75
82
 
76
83
  // Tamper with one of the deployed files
77
84
  const tamperPath = join(tempDir, ".overstory", "agent-defs", "scout.md");
@@ -82,7 +89,7 @@ describe("initCommand: agent-defs deployment", () => {
82
89
  expect(tampered).toBe("# tampered content\n");
83
90
 
84
91
  // Reinit with --force
85
- await initCommand({ force: true });
92
+ await initCommand({ force: true, _spawner: noopSpawner });
86
93
 
87
94
  // Verify the file was overwritten with the original source
88
95
  const sourceContent = await Bun.file(join(SOURCE_AGENTS_DIR, "scout.md")).text();
@@ -91,7 +98,7 @@ describe("initCommand: agent-defs deployment", () => {
91
98
  });
92
99
 
93
100
  test("Stop hook includes mulch learn command", async () => {
94
- await initCommand({});
101
+ await initCommand({ _spawner: noopSpawner });
95
102
 
96
103
  const hooksPath = join(tempDir, ".overstory", "hooks.json");
97
104
  const content = await Bun.file(hooksPath).text();
@@ -104,7 +111,7 @@ describe("initCommand: agent-defs deployment", () => {
104
111
  });
105
112
 
106
113
  test("PostToolUse hooks include Bash-matched mulch diff hook", async () => {
107
- await initCommand({});
114
+ await initCommand({ _spawner: noopSpawner });
108
115
 
109
116
  const hooksPath = join(tempDir, ".overstory", "hooks.json");
110
117
  const content = await Bun.file(hooksPath).text();
@@ -146,7 +153,7 @@ describe("initCommand: .overstory/.gitignore", () => {
146
153
  });
147
154
 
148
155
  test("creates .overstory/.gitignore with wildcard+whitelist model", async () => {
149
- await initCommand({});
156
+ await initCommand({ _spawner: noopSpawner });
150
157
 
151
158
  const gitignorePath = join(tempDir, ".overstory", ".gitignore");
152
159
  const content = await Bun.file(gitignorePath).text();
@@ -166,7 +173,7 @@ describe("initCommand: .overstory/.gitignore", () => {
166
173
 
167
174
  test("gitignore is always written when init completes", async () => {
168
175
  // Init should write gitignore
169
- await initCommand({});
176
+ await initCommand({ _spawner: noopSpawner });
170
177
 
171
178
  const gitignorePath = join(tempDir, ".overstory", ".gitignore");
172
179
  const content = await Bun.file(gitignorePath).text();
@@ -181,7 +188,7 @@ describe("initCommand: .overstory/.gitignore", () => {
181
188
 
182
189
  test("--force reinit overwrites stale .overstory/.gitignore", async () => {
183
190
  // First init
184
- await initCommand({});
191
+ await initCommand({ _spawner: noopSpawner });
185
192
 
186
193
  const gitignorePath = join(tempDir, ".overstory", ".gitignore");
187
194
 
@@ -194,7 +201,7 @@ describe("initCommand: .overstory/.gitignore", () => {
194
201
  expect(tampered).not.toContain("!.gitignore\n");
195
202
 
196
203
  // Reinit with --force
197
- await initCommand({ force: true });
204
+ await initCommand({ force: true, _spawner: noopSpawner });
198
205
 
199
206
  // Verify the file was overwritten with the new wildcard+whitelist format
200
207
  const restored = await Bun.file(gitignorePath).text();
@@ -205,7 +212,7 @@ describe("initCommand: .overstory/.gitignore", () => {
205
212
 
206
213
  test("subsequent init without --force does not overwrite gitignore", async () => {
207
214
  // First init
208
- await initCommand({});
215
+ await initCommand({ _spawner: noopSpawner });
209
216
 
210
217
  const gitignorePath = join(tempDir, ".overstory", ".gitignore");
211
218
 
@@ -217,7 +224,7 @@ describe("initCommand: .overstory/.gitignore", () => {
217
224
  expect(tampered).toBe("# custom content\n");
218
225
 
219
226
  // Second init without --force should return early (not overwrite)
220
- await initCommand({});
227
+ await initCommand({ _spawner: noopSpawner });
221
228
 
222
229
  // Verify the file was NOT overwritten (early return prevented it)
223
230
  const afterSecondInit = await Bun.file(gitignorePath).text();
@@ -247,7 +254,7 @@ describe("initCommand: .overstory/README.md", () => {
247
254
  });
248
255
 
249
256
  test("creates .overstory/README.md with expected content", async () => {
250
- await initCommand({});
257
+ await initCommand({ _spawner: noopSpawner });
251
258
 
252
259
  const readmePath = join(tempDir, ".overstory", "README.md");
253
260
  const exists = await Bun.file(readmePath).exists();
@@ -263,7 +270,7 @@ describe("initCommand: .overstory/README.md", () => {
263
270
 
264
271
  test("--force reinit overwrites README.md", async () => {
265
272
  // First init
266
- await initCommand({});
273
+ await initCommand({ _spawner: noopSpawner });
267
274
 
268
275
  const readmePath = join(tempDir, ".overstory", "README.md");
269
276
 
@@ -273,7 +280,7 @@ describe("initCommand: .overstory/README.md", () => {
273
280
  expect(tampered).toBe("# tampered\n");
274
281
 
275
282
  // Reinit with --force
276
- await initCommand({ force: true });
283
+ await initCommand({ force: true, _spawner: noopSpawner });
277
284
 
278
285
  // Verify restored to canonical content
279
286
  const restored = await Bun.file(readmePath).text();
@@ -282,7 +289,7 @@ describe("initCommand: .overstory/README.md", () => {
282
289
 
283
290
  test("subsequent init without --force does not overwrite README.md", async () => {
284
291
  // First init
285
- await initCommand({});
292
+ await initCommand({ _spawner: noopSpawner });
286
293
 
287
294
  const readmePath = join(tempDir, ".overstory", "README.md");
288
295
 
@@ -292,7 +299,7 @@ describe("initCommand: .overstory/README.md", () => {
292
299
  expect(tampered).toBe("# custom content\n");
293
300
 
294
301
  // Second init without --force returns early
295
- await initCommand({});
302
+ await initCommand({ _spawner: noopSpawner });
296
303
 
297
304
  // Verify tampered content preserved (early return)
298
305
  const afterSecondInit = await Bun.file(readmePath).text();
@@ -328,7 +335,7 @@ describe("initCommand: canonical branch detection", () => {
328
335
  // Switch to a non-standard branch name
329
336
  await runGitInDir(tempDir, ["switch", "-c", "trunk"]);
330
337
 
331
- await initCommand({});
338
+ await initCommand({ _spawner: noopSpawner });
332
339
 
333
340
  const configPath = join(tempDir, ".overstory", "config.yaml");
334
341
  const content = await Bun.file(configPath).text();
@@ -337,7 +344,7 @@ describe("initCommand: canonical branch detection", () => {
337
344
 
338
345
  test("standard branch names (main) still work as canonicalBranch", async () => {
339
346
  // createTempGitRepo defaults to main branch
340
- await initCommand({});
347
+ await initCommand({ _spawner: noopSpawner });
341
348
 
342
349
  const configPath = join(tempDir, ".overstory", "config.yaml");
343
350
  const content = await Bun.file(configPath).text();
@@ -368,14 +375,14 @@ describe("initCommand: --yes flag", () => {
368
375
 
369
376
  test("--yes reinitializes when .overstory/ already exists", async () => {
370
377
  // First init
371
- await initCommand({});
378
+ await initCommand({ _spawner: noopSpawner });
372
379
 
373
380
  // Tamper with config to verify reinit happens
374
381
  const configPath = join(tempDir, ".overstory", "config.yaml");
375
382
  await Bun.write(configPath, "# tampered\n");
376
383
 
377
384
  // Second init with --yes should reinitialize (not return early)
378
- await initCommand({ yes: true });
385
+ await initCommand({ yes: true, _spawner: noopSpawner });
379
386
 
380
387
  // Verify config was regenerated (not the tampered content)
381
388
  const content = await Bun.file(configPath).text();
@@ -384,7 +391,7 @@ describe("initCommand: --yes flag", () => {
384
391
  });
385
392
 
386
393
  test("--yes works on fresh project (no .overstory/ yet)", async () => {
387
- await initCommand({ yes: true });
394
+ await initCommand({ yes: true, _spawner: noopSpawner });
388
395
 
389
396
  const configPath = join(tempDir, ".overstory", "config.yaml");
390
397
  const exists = await Bun.file(configPath).exists();
@@ -396,14 +403,14 @@ describe("initCommand: --yes flag", () => {
396
403
 
397
404
  test("--yes overwrites agent-defs on reinit", async () => {
398
405
  // First init
399
- await initCommand({});
406
+ await initCommand({ _spawner: noopSpawner });
400
407
 
401
408
  // Tamper with an agent def
402
409
  const scoutPath = join(tempDir, ".overstory", "agent-defs", "scout.md");
403
410
  await Bun.write(scoutPath, "TAMPERED CONTENT");
404
411
 
405
412
  // Reinit with --yes should overwrite
406
- await initCommand({ yes: true });
413
+ await initCommand({ yes: true, _spawner: noopSpawner });
407
414
 
408
415
  const restored = await Bun.file(scoutPath).text();
409
416
  expect(restored).not.toBe("TAMPERED CONTENT");
@@ -432,7 +439,7 @@ describe("initCommand: --name flag", () => {
432
439
  });
433
440
 
434
441
  test("--name overrides auto-detected project name", async () => {
435
- await initCommand({ name: "custom-project" });
442
+ await initCommand({ name: "custom-project", _spawner: noopSpawner });
436
443
 
437
444
  const configPath = join(tempDir, ".overstory", "config.yaml");
438
445
  const content = await Bun.file(configPath).text();
@@ -440,7 +447,7 @@ describe("initCommand: --name flag", () => {
440
447
  });
441
448
 
442
449
  test("--name combined with --yes works for fully non-interactive init", async () => {
443
- await initCommand({ yes: true, name: "scripted-project" });
450
+ await initCommand({ yes: true, name: "scripted-project", _spawner: noopSpawner });
444
451
 
445
452
  const configPath = join(tempDir, ".overstory", "config.yaml");
446
453
  const content = await Bun.file(configPath).text();
@@ -448,3 +455,335 @@ describe("initCommand: --name flag", () => {
448
455
  expect(content).toContain("# Overstory configuration");
449
456
  });
450
457
  });
458
+
459
+ // ---- Ecosystem Bootstrap Tests ----
460
+
461
+ /**
462
+ * Build a Spawner that returns preset responses keyed by "arg0 arg1 ..." prefix.
463
+ * Records all calls for assertion.
464
+ */
465
+ function createMockSpawner(
466
+ responses: Record<string, { exitCode: number; stdout: string; stderr: string }>,
467
+ ): {
468
+ spawner: Spawner;
469
+ calls: string[][];
470
+ } {
471
+ const calls: string[][] = [];
472
+ const spawner: Spawner = async (args) => {
473
+ calls.push(args);
474
+ const key = args.join(" ");
475
+ // Longest prefix match
476
+ let bestMatch = "";
477
+ let bestResponse = { exitCode: 1, stdout: "", stderr: "not found" };
478
+ for (const [pattern, response] of Object.entries(responses)) {
479
+ if (key.startsWith(pattern) && pattern.length > bestMatch.length) {
480
+ bestMatch = pattern;
481
+ bestResponse = response;
482
+ }
483
+ }
484
+ return bestResponse;
485
+ };
486
+ return { spawner, calls };
487
+ }
488
+
489
+ describe("resolveToolSet", () => {
490
+ test("default (no opts) returns all three tools in order", () => {
491
+ const tools = resolveToolSet({});
492
+ expect(tools.map((t) => t.name)).toEqual(["mulch", "seeds", "canopy"]);
493
+ });
494
+
495
+ test("--skip-mulch removes mulch", () => {
496
+ const tools = resolveToolSet({ skipMulch: true });
497
+ expect(tools.map((t) => t.name)).toEqual(["seeds", "canopy"]);
498
+ });
499
+
500
+ test("--skip-seeds removes seeds", () => {
501
+ const tools = resolveToolSet({ skipSeeds: true });
502
+ expect(tools.map((t) => t.name)).toEqual(["mulch", "canopy"]);
503
+ });
504
+
505
+ test("--skip-canopy removes canopy", () => {
506
+ const tools = resolveToolSet({ skipCanopy: true });
507
+ expect(tools.map((t) => t.name)).toEqual(["mulch", "seeds"]);
508
+ });
509
+
510
+ test("multiple skip flags combine", () => {
511
+ const tools = resolveToolSet({ skipMulch: true, skipSeeds: true });
512
+ expect(tools.map((t) => t.name)).toEqual(["canopy"]);
513
+ });
514
+
515
+ test("--tools overrides to specific tools", () => {
516
+ const tools = resolveToolSet({ tools: "mulch,seeds" });
517
+ expect(tools.map((t) => t.name)).toEqual(["mulch", "seeds"]);
518
+ });
519
+
520
+ test("--tools single tool", () => {
521
+ const tools = resolveToolSet({ tools: "canopy" });
522
+ expect(tools.map((t) => t.name)).toEqual(["canopy"]);
523
+ });
524
+
525
+ test("--tools with unknown name filters it out", () => {
526
+ const tools = resolveToolSet({ tools: "mulch,unknown" });
527
+ expect(tools.map((t) => t.name)).toEqual(["mulch"]);
528
+ });
529
+
530
+ test("--tools overrides skip flags", () => {
531
+ // --tools takes precedence over --skip-* flags
532
+ const tools = resolveToolSet({ tools: "mulch", skipMulch: true });
533
+ expect(tools.map((t) => t.name)).toEqual(["mulch"]);
534
+ });
535
+
536
+ test("all skip flags returns empty array", () => {
537
+ const tools = resolveToolSet({ skipMulch: true, skipSeeds: true, skipCanopy: true });
538
+ expect(tools).toHaveLength(0);
539
+ });
540
+ });
541
+
542
+ describe("initCommand: ecosystem bootstrap", () => {
543
+ let tempDir: string;
544
+ let originalCwd: string;
545
+ let originalWrite: typeof process.stdout.write;
546
+
547
+ beforeEach(async () => {
548
+ tempDir = await createTempGitRepo();
549
+ originalCwd = process.cwd();
550
+ process.chdir(tempDir);
551
+ originalWrite = process.stdout.write;
552
+ process.stdout.write = (() => true) as typeof process.stdout.write;
553
+ });
554
+
555
+ afterEach(async () => {
556
+ process.chdir(originalCwd);
557
+ process.stdout.write = originalWrite;
558
+ await cleanupTempDir(tempDir);
559
+ });
560
+
561
+ test("all tools installed and init succeeds → status initialized", async () => {
562
+ const { spawner, calls } = createMockSpawner({
563
+ "ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
564
+ "ml init": { exitCode: 0, stdout: "initialized", stderr: "" },
565
+ "ml onboard": { exitCode: 0, stdout: "appended", stderr: "" },
566
+ "sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
567
+ "sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
568
+ "sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
569
+ "cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
570
+ "cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
571
+ "cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
572
+ });
573
+
574
+ await initCommand({ _spawner: spawner });
575
+
576
+ // All three init commands were called
577
+ expect(calls).toContainEqual(["ml", "init"]);
578
+ expect(calls).toContainEqual(["sd", "init"]);
579
+ expect(calls).toContainEqual(["cn", "init"]);
580
+
581
+ // All three onboard commands were called
582
+ expect(calls).toContainEqual(["ml", "onboard"]);
583
+ expect(calls).toContainEqual(["sd", "onboard"]);
584
+ expect(calls).toContainEqual(["cn", "onboard"]);
585
+ });
586
+
587
+ test("tool not installed → init and onboard not called", async () => {
588
+ const { spawner, calls } = createMockSpawner({
589
+ "ml --version": { exitCode: 1, stdout: "", stderr: "command not found" },
590
+ "sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
591
+ "sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
592
+ "sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
593
+ "cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
594
+ "cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
595
+ "cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
596
+ });
597
+
598
+ await initCommand({ _spawner: spawner });
599
+
600
+ // mulch init should NOT have been called
601
+ expect(calls).not.toContainEqual(["ml", "init"]);
602
+ // seeds and canopy should still be called
603
+ expect(calls).toContainEqual(["sd", "init"]);
604
+ expect(calls).toContainEqual(["cn", "init"]);
605
+ });
606
+
607
+ test("tool init non-zero + dir exists → already_initialized", async () => {
608
+ // Create .mulch/ directory to simulate existing mulch init
609
+ const { mkdir } = await import("node:fs/promises");
610
+ await mkdir(join(tempDir, ".mulch"), { recursive: true });
611
+
612
+ const { spawner } = createMockSpawner({
613
+ "ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
614
+ "ml init": { exitCode: 1, stdout: "", stderr: "already initialized" },
615
+ "ml onboard": { exitCode: 0, stdout: "appended", stderr: "" },
616
+ "sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
617
+ "sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
618
+ "sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
619
+ "cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
620
+ "cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
621
+ "cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
622
+ });
623
+
624
+ // Should not throw — already_initialized is not an error
625
+ await initCommand({ _spawner: spawner });
626
+ });
627
+
628
+ test("--skip-onboard skips onboard calls", async () => {
629
+ const { spawner, calls } = createMockSpawner({
630
+ "ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
631
+ "ml init": { exitCode: 0, stdout: "initialized", stderr: "" },
632
+ "sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
633
+ "sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
634
+ "cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
635
+ "cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
636
+ });
637
+
638
+ await initCommand({ skipOnboard: true, _spawner: spawner });
639
+
640
+ expect(calls).not.toContainEqual(["ml", "onboard"]);
641
+ expect(calls).not.toContainEqual(["sd", "onboard"]);
642
+ expect(calls).not.toContainEqual(["cn", "onboard"]);
643
+ });
644
+
645
+ test("--skip-mulch skips mulch entirely", async () => {
646
+ const { spawner, calls } = createMockSpawner({
647
+ "sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
648
+ "sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
649
+ "sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
650
+ "cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
651
+ "cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
652
+ "cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
653
+ });
654
+
655
+ await initCommand({ skipMulch: true, _spawner: spawner });
656
+
657
+ expect(calls.filter((c) => c[0] === "ml")).toHaveLength(0);
658
+ });
659
+
660
+ test("--json outputs JSON envelope with tools and onboard status", async () => {
661
+ const { spawner } = createMockSpawner({
662
+ "ml --version": { exitCode: 0, stdout: "0.6.3", stderr: "" },
663
+ "ml init": { exitCode: 0, stdout: "initialized", stderr: "" },
664
+ "ml onboard": { exitCode: 0, stdout: "appended", stderr: "" },
665
+ "sd --version": { exitCode: 0, stdout: "0.2.4", stderr: "" },
666
+ "sd init": { exitCode: 0, stdout: "initialized", stderr: "" },
667
+ "sd onboard": { exitCode: 0, stdout: "appended", stderr: "" },
668
+ "cn --version": { exitCode: 0, stdout: "0.2.0", stderr: "" },
669
+ "cn init": { exitCode: 0, stdout: "initialized", stderr: "" },
670
+ "cn onboard": { exitCode: 0, stdout: "appended", stderr: "" },
671
+ });
672
+
673
+ let capturedOutput = "";
674
+ const restoreWrite = process.stdout.write;
675
+ process.stdout.write = ((chunk: unknown) => {
676
+ capturedOutput += String(chunk);
677
+ return true;
678
+ }) as typeof process.stdout.write;
679
+
680
+ await initCommand({ json: true, _spawner: spawner });
681
+
682
+ process.stdout.write = restoreWrite;
683
+
684
+ // Find the JSON line (last line with JSON content)
685
+ const jsonLine = capturedOutput.split("\n").find((line) => line.startsWith('{"success":'));
686
+
687
+ expect(jsonLine).toBeDefined();
688
+ const parsed = JSON.parse(jsonLine ?? "{}") as Record<string, unknown>;
689
+ expect(parsed.success).toBe(true);
690
+ expect(parsed.command).toBe("init");
691
+ expect(parsed.tools).toBeDefined();
692
+ expect(parsed.onboard).toBeDefined();
693
+ expect(typeof parsed.gitattributes).toBe("boolean");
694
+
695
+ const tools = parsed.tools as Record<string, { status: string }>;
696
+ expect(tools.overstory?.status).toBe("initialized");
697
+ expect(tools.mulch?.status).toBe("initialized");
698
+ expect(tools.seeds?.status).toBe("initialized");
699
+ expect(tools.canopy?.status).toBe("initialized");
700
+ });
701
+ });
702
+
703
+ describe("initCommand: .gitattributes setup", () => {
704
+ let tempDir: string;
705
+ let originalCwd: string;
706
+ let originalWrite: typeof process.stdout.write;
707
+
708
+ beforeEach(async () => {
709
+ tempDir = await createTempGitRepo();
710
+ originalCwd = process.cwd();
711
+ process.chdir(tempDir);
712
+ originalWrite = process.stdout.write;
713
+ process.stdout.write = (() => true) as typeof process.stdout.write;
714
+ });
715
+
716
+ afterEach(async () => {
717
+ process.chdir(originalCwd);
718
+ process.stdout.write = originalWrite;
719
+ await cleanupTempDir(tempDir);
720
+ });
721
+
722
+ test("creates .gitattributes with merge=union entries", async () => {
723
+ // Use a spawner that skips all ecosystem tools so only gitattributes step runs
724
+ const { spawner } = createMockSpawner({});
725
+ await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
726
+
727
+ const gitattrsPath = join(tempDir, ".gitattributes");
728
+ const exists = await Bun.file(gitattrsPath).exists();
729
+ expect(exists).toBe(true);
730
+
731
+ const content = await Bun.file(gitattrsPath).text();
732
+ expect(content).toContain(".mulch/expertise/*.jsonl merge=union");
733
+ expect(content).toContain(".seeds/issues.jsonl merge=union");
734
+ });
735
+
736
+ test("does not duplicate entries on reinit with --force", async () => {
737
+ const { spawner } = createMockSpawner({});
738
+
739
+ // First init
740
+ await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
741
+
742
+ // Second init with --force
743
+ await initCommand({
744
+ force: true,
745
+ skipMulch: true,
746
+ skipSeeds: true,
747
+ skipCanopy: true,
748
+ _spawner: spawner,
749
+ });
750
+
751
+ const gitattrsPath = join(tempDir, ".gitattributes");
752
+ const content = await Bun.file(gitattrsPath).text();
753
+
754
+ // Count occurrences — should be exactly one each
755
+ const mulchCount = (content.match(/\.mulch\/expertise\/\*\.jsonl merge=union/g) ?? []).length;
756
+ const seedsCount = (content.match(/\.seeds\/issues\.jsonl merge=union/g) ?? []).length;
757
+ expect(mulchCount).toBe(1);
758
+ expect(seedsCount).toBe(1);
759
+ });
760
+
761
+ test("preserves existing .gitattributes content", async () => {
762
+ // Pre-create .gitattributes with existing content
763
+ const existingContent = "*.lock binary\n*.png binary\n";
764
+ await Bun.write(join(tempDir, ".gitattributes"), existingContent);
765
+
766
+ const { spawner } = createMockSpawner({});
767
+ await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
768
+
769
+ const content = await Bun.file(join(tempDir, ".gitattributes")).text();
770
+ expect(content).toContain("*.lock binary");
771
+ expect(content).toContain("*.png binary");
772
+ expect(content).toContain(".mulch/expertise/*.jsonl merge=union");
773
+ expect(content).toContain(".seeds/issues.jsonl merge=union");
774
+ });
775
+
776
+ test("no-op when entries already present", async () => {
777
+ // Pre-create .gitattributes with the entries already
778
+ const existingContent =
779
+ ".mulch/expertise/*.jsonl merge=union\n.seeds/issues.jsonl merge=union\n";
780
+ await Bun.write(join(tempDir, ".gitattributes"), existingContent);
781
+
782
+ const { spawner } = createMockSpawner({});
783
+ await initCommand({ skipMulch: true, skipSeeds: true, skipCanopy: true, _spawner: spawner });
784
+
785
+ const content = await Bun.file(join(tempDir, ".gitattributes")).text();
786
+ // Content should be unchanged
787
+ expect(content).toBe(existingContent);
788
+ });
789
+ });