opencode-swarm-plugin 0.59.5 → 0.60.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 (44) hide show
  1. package/README.md +14 -1
  2. package/bin/commands/doctor.test.ts +622 -0
  3. package/bin/commands/doctor.ts +658 -0
  4. package/bin/commands/status.test.ts +506 -0
  5. package/bin/commands/status.ts +520 -0
  6. package/bin/commands/tree.ts +39 -3
  7. package/bin/swarm.ts +19 -3
  8. package/claude-plugin/dist/index.js +290 -47
  9. package/claude-plugin/dist/schemas/cell.d.ts +2 -0
  10. package/claude-plugin/dist/schemas/cell.d.ts.map +1 -1
  11. package/claude-plugin/dist/utils/git-commit-info.d.ts +10 -0
  12. package/claude-plugin/dist/utils/git-commit-info.d.ts.map +1 -0
  13. package/claude-plugin/dist/utils/tree-renderer.d.ts +69 -13
  14. package/claude-plugin/dist/utils/tree-renderer.d.ts.map +1 -1
  15. package/dist/bin/swarm.js +1883 -386
  16. package/dist/dashboard.d.ts.map +1 -1
  17. package/dist/hive.d.ts +8 -0
  18. package/dist/hive.d.ts.map +1 -1
  19. package/dist/hive.js +6 -4
  20. package/dist/index.d.ts +10 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +62 -16
  23. package/dist/marketplace/index.js +290 -47
  24. package/dist/plugin.js +61 -15
  25. package/dist/replay-tools.d.ts +5 -1
  26. package/dist/replay-tools.d.ts.map +1 -1
  27. package/dist/schemas/cell.d.ts +2 -0
  28. package/dist/schemas/cell.d.ts.map +1 -1
  29. package/dist/skills.d.ts +4 -0
  30. package/dist/skills.d.ts.map +1 -1
  31. package/dist/storage.d.ts +7 -0
  32. package/dist/storage.d.ts.map +1 -1
  33. package/dist/swarm-orchestrate.d.ts +12 -0
  34. package/dist/swarm-orchestrate.d.ts.map +1 -1
  35. package/dist/swarm-prompts.js +61 -15
  36. package/dist/swarm.d.ts +6 -0
  37. package/dist/swarm.d.ts.map +1 -1
  38. package/dist/test-utils/msw-server.d.ts +21 -0
  39. package/dist/test-utils/msw-server.d.ts.map +1 -0
  40. package/dist/utils/git-commit-info.d.ts +10 -0
  41. package/dist/utils/git-commit-info.d.ts.map +1 -0
  42. package/dist/utils/tree-renderer.d.ts +69 -13
  43. package/dist/utils/tree-renderer.d.ts.map +1 -1
  44. package/package.json +3 -2
package/README.md CHANGED
@@ -16,15 +16,28 @@
16
16
  ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
17
17
  ```
18
18
 
19
+ ## Prerequisites
20
+
21
+ **Bun is required.** The CLI uses Bun-specific APIs and won't run with Node.js alone.
22
+
23
+ ```bash
24
+ # Install Bun (if you don't have it)
25
+ curl -fsSL https://bun.sh/install | bash
26
+ ```
27
+
28
+ See [bun.sh](https://bun.sh) for other installation methods (Homebrew, npm, etc.).
29
+
19
30
  ## Quickstart (<2 minutes)
20
31
 
21
32
  ### 1. Install
22
33
 
23
34
  ```bash
24
- npm install -g opencode-swarm-plugin@latest
35
+ bun install -g opencode-swarm-plugin@latest
25
36
  swarm setup
26
37
  ```
27
38
 
39
+ > **Note:** You can also use `npm install -g`, but Bun must be installed to run the CLI.
40
+
28
41
  ### Claude Code Plugin (Marketplace)
29
42
 
30
43
  ```text
@@ -0,0 +1,622 @@
1
+ /**
2
+ * @fileoverview Tests for enhanced doctor command
3
+ *
4
+ * Tests each health check individually and the combined doctor runner.
5
+ * Uses in-memory libSQL database for isolation.
6
+ */
7
+
8
+ import { describe, expect, test, beforeAll, beforeEach, afterAll } from "bun:test";
9
+ import { createInMemorySwarmMailLibSQL, createHiveAdapter } from "swarm-mail";
10
+ import type { SwarmMailAdapter } from "swarm-mail";
11
+ import type { DatabaseAdapter } from "swarm-mail";
12
+ import {
13
+ checkDbIntegrity,
14
+ checkOrphanedCells,
15
+ checkDependencyCycles,
16
+ checkStaleReservations,
17
+ checkZombieBlocked,
18
+ checkGhostWorkers,
19
+ detectCycles,
20
+ runDoctor,
21
+ formatDoctorReport,
22
+ parseDoctorArgs,
23
+ } from "./doctor.js";
24
+
25
+ // ============================================================================
26
+ // Test Helpers
27
+ // ============================================================================
28
+
29
+ async function createTestDb(testId: string): Promise<{
30
+ swarmMail: SwarmMailAdapter;
31
+ db: DatabaseAdapter;
32
+ projectPath: string;
33
+ }> {
34
+ const swarmMail = await createInMemorySwarmMailLibSQL(testId);
35
+ const db = await swarmMail.getDatabase();
36
+ const projectPath = `/tmp/test-doctor-${testId}`;
37
+
38
+ // Initialize hive schema
39
+ const adapter = createHiveAdapter(db, projectPath);
40
+ await adapter.runMigrations();
41
+
42
+ // Also run swarm-mail migrations (for reservations table)
43
+ await swarmMail.runMigrations();
44
+
45
+ return { swarmMail, db, projectPath };
46
+ }
47
+
48
+ // ============================================================================
49
+ // checkDbIntegrity
50
+ // ============================================================================
51
+
52
+ describe("checkDbIntegrity", () => {
53
+ let swarmMail: SwarmMailAdapter;
54
+ let db: DatabaseAdapter;
55
+
56
+ beforeAll(async () => {
57
+ const setup = await createTestDb("integrity");
58
+ swarmMail = setup.swarmMail;
59
+ db = setup.db;
60
+ });
61
+
62
+ afterAll(async () => {
63
+ await swarmMail.close();
64
+ });
65
+
66
+ test("passes on healthy database", async () => {
67
+ const result = await checkDbIntegrity(db);
68
+ expect(result.status).toBe("pass");
69
+ expect(result.message).toBe("OK");
70
+ });
71
+ });
72
+
73
+ // ============================================================================
74
+ // checkOrphanedCells
75
+ // ============================================================================
76
+
77
+ describe("checkOrphanedCells", () => {
78
+ let swarmMail: SwarmMailAdapter;
79
+ let db: DatabaseAdapter;
80
+ let projectPath: string;
81
+
82
+ beforeAll(async () => {
83
+ const setup = await createTestDb("orphans");
84
+ swarmMail = setup.swarmMail;
85
+ db = setup.db;
86
+ projectPath = setup.projectPath;
87
+ });
88
+
89
+ afterAll(async () => {
90
+ await swarmMail.close();
91
+ });
92
+
93
+ test("passes with no orphans", async () => {
94
+ const adapter = createHiveAdapter(db, projectPath);
95
+
96
+ // Create epic and child — valid reference
97
+ const epic = await adapter.createCell(projectPath, {
98
+ title: "Valid Epic",
99
+ type: "epic",
100
+ priority: 0,
101
+ });
102
+
103
+ await adapter.createCell(projectPath, {
104
+ title: "Valid Child",
105
+ type: "task",
106
+ priority: 1,
107
+ parent_id: epic.id,
108
+ });
109
+
110
+ const result = await checkOrphanedCells(db);
111
+ expect(result.status).toBe("pass");
112
+ });
113
+
114
+ test("detects orphaned cells", async () => {
115
+ // Disable foreign key checks to insert orphan
116
+ await db.exec("PRAGMA foreign_keys = OFF");
117
+ await db.query(
118
+ `INSERT INTO beads (id, project_key, type, status, title, priority, parent_id, created_at, updated_at)
119
+ VALUES (?, ?, 'task', 'open', 'Orphan Cell', 1, 'nonexistent-parent', ?, ?)`,
120
+ ["orphan-test-1", projectPath, Date.now(), Date.now()]
121
+ );
122
+ await db.exec("PRAGMA foreign_keys = ON");
123
+
124
+ const result = await checkOrphanedCells(db);
125
+ expect(result.status).toBe("fail");
126
+ expect(result.message).toContain("orphaned");
127
+ expect(result.fixable).toBe(true);
128
+ expect(result.details).toBeDefined();
129
+ expect(result.details!.length).toBeGreaterThan(0);
130
+ });
131
+
132
+ test("fixes orphaned cells with --fix", async () => {
133
+ const result = await checkOrphanedCells(db, { fix: true });
134
+ expect(result.fixed).toBeGreaterThan(0);
135
+
136
+ // Verify fix — no more orphans
137
+ const verify = await checkOrphanedCells(db);
138
+ expect(verify.status).toBe("pass");
139
+ });
140
+ });
141
+
142
+ // ============================================================================
143
+ // checkDependencyCycles
144
+ // ============================================================================
145
+
146
+ describe("checkDependencyCycles", () => {
147
+ let swarmMail: SwarmMailAdapter;
148
+ let db: DatabaseAdapter;
149
+ let projectPath: string;
150
+
151
+ beforeAll(async () => {
152
+ const setup = await createTestDb("cycles");
153
+ swarmMail = setup.swarmMail;
154
+ db = setup.db;
155
+ projectPath = setup.projectPath;
156
+ });
157
+
158
+ afterAll(async () => {
159
+ await swarmMail.close();
160
+ });
161
+
162
+ test("passes with no dependencies", async () => {
163
+ const result = await checkDependencyCycles(db);
164
+ expect(result.status).toBe("pass");
165
+ });
166
+
167
+ test("passes with acyclic dependencies", async () => {
168
+ const adapter = createHiveAdapter(db, projectPath);
169
+
170
+ const cellA = await adapter.createCell(projectPath, {
171
+ title: "Cell A", type: "task", priority: 1,
172
+ });
173
+ const cellB = await adapter.createCell(projectPath, {
174
+ title: "Cell B", type: "task", priority: 2,
175
+ });
176
+ const cellC = await adapter.createCell(projectPath, {
177
+ title: "Cell C", type: "task", priority: 3,
178
+ });
179
+
180
+ // A → B → C (no cycle)
181
+ await adapter.addDependency(projectPath, cellA.id, cellB.id, "blocks");
182
+ await adapter.addDependency(projectPath, cellB.id, cellC.id, "blocks");
183
+
184
+ const result = await checkDependencyCycles(db);
185
+ expect(result.status).toBe("pass");
186
+ });
187
+
188
+ test("detects circular dependencies", async () => {
189
+ const adapter = createHiveAdapter(db, projectPath);
190
+
191
+ const cellX = await adapter.createCell(projectPath, {
192
+ title: "Cycle X", type: "task", priority: 1,
193
+ });
194
+ const cellY = await adapter.createCell(projectPath, {
195
+ title: "Cycle Y", type: "task", priority: 2,
196
+ });
197
+ const cellZ = await adapter.createCell(projectPath, {
198
+ title: "Cycle Z", type: "task", priority: 3,
199
+ });
200
+
201
+ // The adapter prevents cycles, so we insert the cyclic dependency directly via SQL
202
+ // First create two valid deps via adapter
203
+ await adapter.addDependency(projectPath, cellX.id, cellY.id, "blocks");
204
+ await adapter.addDependency(projectPath, cellY.id, cellZ.id, "blocks");
205
+
206
+ // Now force the cycle-closing edge directly in SQL
207
+ const now = Date.now();
208
+ await db.query(
209
+ `INSERT INTO bead_dependencies (cell_id, depends_on_id, relationship, created_at)
210
+ VALUES (?, ?, 'blocks', ?)`,
211
+ [cellZ.id, cellX.id, now]
212
+ );
213
+
214
+ const result = await checkDependencyCycles(db);
215
+ expect(result.status).toBe("fail");
216
+ expect(result.message).toContain("cycle");
217
+ expect(result.fixable).toBe(false);
218
+ });
219
+
220
+ test("reports cycle is not fixable", async () => {
221
+ // The cycle from previous test should still be present
222
+ const result = await checkDependencyCycles(db);
223
+ expect(result.status).toBe("fail");
224
+ expect(result.fixable).toBe(false);
225
+ });
226
+ });
227
+
228
+ // ============================================================================
229
+ // detectCycles (unit test for the DFS algorithm)
230
+ // ============================================================================
231
+
232
+ describe("detectCycles", () => {
233
+ test("empty graph has no cycles", () => {
234
+ const graph = new Map<string, string[]>();
235
+ expect(detectCycles(graph)).toEqual([]);
236
+ });
237
+
238
+ test("linear graph has no cycles", () => {
239
+ const graph = new Map<string, string[]>([
240
+ ["A", ["B"]],
241
+ ["B", ["C"]],
242
+ ]);
243
+ expect(detectCycles(graph)).toEqual([]);
244
+ });
245
+
246
+ test("self-loop is a cycle", () => {
247
+ const graph = new Map<string, string[]>([
248
+ ["A", ["A"]],
249
+ ]);
250
+ const cycles = detectCycles(graph);
251
+ expect(cycles.length).toBe(1);
252
+ // The cycle should contain A
253
+ expect(cycles[0]).toContain("A");
254
+ });
255
+
256
+ test("two-node cycle", () => {
257
+ const graph = new Map<string, string[]>([
258
+ ["A", ["B"]],
259
+ ["B", ["A"]],
260
+ ]);
261
+ const cycles = detectCycles(graph);
262
+ expect(cycles.length).toBeGreaterThanOrEqual(1);
263
+ });
264
+
265
+ test("three-node cycle", () => {
266
+ const graph = new Map<string, string[]>([
267
+ ["A", ["B"]],
268
+ ["B", ["C"]],
269
+ ["C", ["A"]],
270
+ ]);
271
+ const cycles = detectCycles(graph);
272
+ expect(cycles.length).toBeGreaterThanOrEqual(1);
273
+ });
274
+
275
+ test("diamond shape (no cycle)", () => {
276
+ const graph = new Map<string, string[]>([
277
+ ["A", ["B", "C"]],
278
+ ["B", ["D"]],
279
+ ["C", ["D"]],
280
+ ]);
281
+ expect(detectCycles(graph)).toEqual([]);
282
+ });
283
+ });
284
+
285
+ // ============================================================================
286
+ // checkStaleReservations
287
+ // ============================================================================
288
+
289
+ describe("checkStaleReservations", () => {
290
+ let swarmMail: SwarmMailAdapter;
291
+ let db: DatabaseAdapter;
292
+ let projectPath: string;
293
+
294
+ beforeAll(async () => {
295
+ const setup = await createTestDb("reservations");
296
+ swarmMail = setup.swarmMail;
297
+ db = setup.db;
298
+ projectPath = setup.projectPath;
299
+ });
300
+
301
+ afterAll(async () => {
302
+ await swarmMail.close();
303
+ });
304
+
305
+ test("passes with no reservations", async () => {
306
+ const result = await checkStaleReservations(db);
307
+ expect(result.status).toBe("pass");
308
+ });
309
+
310
+ test("detects stale reservations", async () => {
311
+ const pastTime = Date.now() - 3600_000; // 1 hour ago
312
+
313
+ // Insert an expired reservation directly
314
+ await db.query(
315
+ `INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, created_at, expires_at)
316
+ VALUES (?, 'test-agent', 'src/foo.ts', 1, ?, ?)`,
317
+ [projectPath, pastTime - 60000, pastTime]
318
+ );
319
+
320
+ const result = await checkStaleReservations(db);
321
+ expect(result.status).toBe("fail");
322
+ expect(result.message).toContain("stale");
323
+ expect(result.fixable).toBe(true);
324
+ });
325
+
326
+ test("fixes stale reservations with --fix", async () => {
327
+ const result = await checkStaleReservations(db, { fix: true });
328
+ expect(result.fixed).toBeGreaterThan(0);
329
+
330
+ // Verify fix — no more stale reservations
331
+ const verify = await checkStaleReservations(db);
332
+ expect(verify.status).toBe("pass");
333
+ });
334
+
335
+ test("ignores active reservations", async () => {
336
+ const futureTime = Date.now() + 3600_000; // 1 hour from now
337
+
338
+ await db.query(
339
+ `INSERT INTO reservations (project_key, agent_name, path_pattern, exclusive, created_at, expires_at)
340
+ VALUES (?, 'active-agent', 'src/bar.ts', 1, ?, ?)`,
341
+ [projectPath, Date.now(), futureTime]
342
+ );
343
+
344
+ const result = await checkStaleReservations(db);
345
+ expect(result.status).toBe("pass");
346
+ });
347
+ });
348
+
349
+ // ============================================================================
350
+ // checkZombieBlocked
351
+ // ============================================================================
352
+
353
+ describe("checkZombieBlocked", () => {
354
+ let swarmMail: SwarmMailAdapter;
355
+ let db: DatabaseAdapter;
356
+ let projectPath: string;
357
+
358
+ beforeAll(async () => {
359
+ const setup = await createTestDb("zombies");
360
+ swarmMail = setup.swarmMail;
361
+ db = setup.db;
362
+ projectPath = setup.projectPath;
363
+ });
364
+
365
+ afterAll(async () => {
366
+ await swarmMail.close();
367
+ });
368
+
369
+ test("passes with no blocked cells", async () => {
370
+ const result = await checkZombieBlocked(db);
371
+ expect(result.status).toBe("pass");
372
+ });
373
+
374
+ test("detects zombie blocked cells", async () => {
375
+ const adapter = createHiveAdapter(db, projectPath);
376
+
377
+ // Create a blocker cell and close it
378
+ const blocker = await adapter.createCell(projectPath, {
379
+ title: "Blocker Task", type: "task", priority: 1,
380
+ });
381
+ await adapter.closeCell(projectPath, blocker.id, "Done");
382
+
383
+ // Create a cell that depends on the blocker
384
+ const blocked = await adapter.createCell(projectPath, {
385
+ title: "Zombie Blocked Task", type: "task", priority: 2,
386
+ });
387
+
388
+ // Set it to blocked status
389
+ await adapter.changeCellStatus(projectPath, blocked.id, "blocked");
390
+
391
+ // Add dependency
392
+ await adapter.addDependency(projectPath, blocked.id, blocker.id, "blocks");
393
+
394
+ const result = await checkZombieBlocked(db);
395
+ expect(result.status).toBe("fail");
396
+ expect(result.message).toContain("should be unblocked");
397
+ expect(result.fixable).toBe(true);
398
+ });
399
+
400
+ test("fixes zombie blocked cells with --fix", async () => {
401
+ const result = await checkZombieBlocked(db, { fix: true });
402
+ expect(result.fixed).toBeGreaterThan(0);
403
+
404
+ // Verify fix — no more zombies
405
+ const verify = await checkZombieBlocked(db);
406
+ expect(verify.status).toBe("pass");
407
+ });
408
+ });
409
+
410
+ // ============================================================================
411
+ // checkGhostWorkers
412
+ // ============================================================================
413
+
414
+ describe("checkGhostWorkers", () => {
415
+ let swarmMail: SwarmMailAdapter;
416
+ let db: DatabaseAdapter;
417
+ let projectPath: string;
418
+
419
+ beforeAll(async () => {
420
+ const setup = await createTestDb("ghosts");
421
+ swarmMail = setup.swarmMail;
422
+ db = setup.db;
423
+ projectPath = setup.projectPath;
424
+ });
425
+
426
+ afterAll(async () => {
427
+ await swarmMail.close();
428
+ });
429
+
430
+ test("passes with no in-progress cells", async () => {
431
+ const result = await checkGhostWorkers(db);
432
+ expect(result.status).toBe("pass");
433
+ });
434
+
435
+ test("detects ghost in-progress cells with inactive agents", async () => {
436
+ const adapter = createHiveAdapter(db, projectPath);
437
+
438
+ // Create a cell and set it to in_progress
439
+ const cell = await adapter.createCell(projectPath, {
440
+ title: "Ghost Task", type: "task", priority: 1, assignee: "ghost-agent",
441
+ });
442
+ await adapter.changeCellStatus(projectPath, cell.id, "in_progress");
443
+
444
+ // Insert an agent with stale last_active_at
445
+ const staleTime = Date.now() - 2 * 60 * 60 * 1000; // 2 hours ago
446
+ await db.query(
447
+ `INSERT OR REPLACE INTO agents (project_key, name, registered_at, last_active_at)
448
+ VALUES (?, 'ghost-agent', ?, ?)`,
449
+ [projectPath, staleTime, staleTime]
450
+ );
451
+
452
+ // Use a very short cutoff to make sure it detects it
453
+ const result = await checkGhostWorkers(db, 1000); // 1 second cutoff
454
+ expect(result.status).toBe("warn");
455
+ expect(result.message).toContain("in-progress");
456
+ expect(result.fixable).toBe(false);
457
+ });
458
+ });
459
+
460
+ // ============================================================================
461
+ // runDoctor (combined)
462
+ // ============================================================================
463
+
464
+ describe("runDoctor", () => {
465
+ let swarmMail: SwarmMailAdapter;
466
+ let db: DatabaseAdapter;
467
+
468
+ beforeAll(async () => {
469
+ const setup = await createTestDb("combined");
470
+ swarmMail = setup.swarmMail;
471
+ db = setup.db;
472
+ });
473
+
474
+ afterAll(async () => {
475
+ await swarmMail.close();
476
+ });
477
+
478
+ test("runs all checks and returns report", async () => {
479
+ const report = await runDoctor(db);
480
+
481
+ expect(report.checks.length).toBe(6);
482
+ expect(report.timestamp).toBeTruthy();
483
+ expect(report.passed).toBeGreaterThanOrEqual(0);
484
+ expect(report.passed + report.failed + report.warned).toBe(6);
485
+ });
486
+
487
+ test("reports all passing on clean database", async () => {
488
+ const report = await runDoctor(db);
489
+
490
+ // On a fresh database, most checks should pass
491
+ expect(report.passed).toBeGreaterThanOrEqual(4);
492
+ });
493
+
494
+ test("supports --fix option", async () => {
495
+ const report = await runDoctor(db, { fix: true });
496
+ expect(report.checks.length).toBe(6);
497
+ });
498
+ });
499
+
500
+ // ============================================================================
501
+ // formatDoctorReport
502
+ // ============================================================================
503
+
504
+ describe("formatDoctorReport", () => {
505
+ test("formats passing report", () => {
506
+ const report = {
507
+ checks: [
508
+ { name: "Database integrity", status: "pass" as const, message: "OK" },
509
+ { name: "Cell references", status: "pass" as const, message: "OK" },
510
+ ],
511
+ passed: 2,
512
+ failed: 0,
513
+ warned: 0,
514
+ fixed: 0,
515
+ timestamp: new Date().toISOString(),
516
+ };
517
+
518
+ const output = formatDoctorReport(report);
519
+ expect(output).toContain("Swarm Doctor");
520
+ expect(output).toContain("Database integrity");
521
+ expect(output).toContain("2/2 checks passed");
522
+ });
523
+
524
+ test("formats failing report with fix suggestion", () => {
525
+ const report = {
526
+ checks: [
527
+ { name: "Database integrity", status: "pass" as const, message: "OK" },
528
+ {
529
+ name: "Zombie blocked",
530
+ status: "fail" as const,
531
+ message: "2 cells should be unblocked",
532
+ fixable: true,
533
+ details: ['cell-1 ("Task 1")', 'cell-2 ("Task 2")'],
534
+ },
535
+ ],
536
+ passed: 1,
537
+ failed: 1,
538
+ warned: 0,
539
+ fixed: 0,
540
+ timestamp: new Date().toISOString(),
541
+ };
542
+
543
+ const output = formatDoctorReport(report);
544
+ expect(output).toContain("1/2 checks passed");
545
+ expect(output).toContain("1 issue(s) found");
546
+ expect(output).toContain("--fix");
547
+ });
548
+
549
+ test("formats report after fix", () => {
550
+ const report = {
551
+ checks: [
552
+ {
553
+ name: "Zombie blocked",
554
+ status: "warn" as const,
555
+ message: "Unblocked 2 zombie cell(s)",
556
+ fixed: 2,
557
+ },
558
+ ],
559
+ passed: 0,
560
+ failed: 0,
561
+ warned: 1,
562
+ fixed: 2,
563
+ timestamp: new Date().toISOString(),
564
+ };
565
+
566
+ const output = formatDoctorReport(report, { fix: true });
567
+ expect(output).toContain("2 item(s) fixed");
568
+ expect(output).not.toContain("Run with --fix");
569
+ });
570
+
571
+ test("truncates long detail lists", () => {
572
+ const details = Array.from({ length: 10 }, (_, i) => `detail-${i}`);
573
+ const report = {
574
+ checks: [
575
+ {
576
+ name: "Test check",
577
+ status: "fail" as const,
578
+ message: "10 issues",
579
+ details,
580
+ },
581
+ ],
582
+ passed: 0,
583
+ failed: 1,
584
+ warned: 0,
585
+ fixed: 0,
586
+ timestamp: new Date().toISOString(),
587
+ };
588
+
589
+ const output = formatDoctorReport(report);
590
+ expect(output).toContain("... and 5 more");
591
+ });
592
+ });
593
+
594
+ // ============================================================================
595
+ // parseDoctorArgs
596
+ // ============================================================================
597
+
598
+ describe("parseDoctorArgs", () => {
599
+ test("parses --fix", () => {
600
+ const opts = parseDoctorArgs(["--fix"]);
601
+ expect(opts.fix).toBe(true);
602
+ expect(opts.json).toBeFalsy();
603
+ });
604
+
605
+ test("parses --json", () => {
606
+ const opts = parseDoctorArgs(["--json"]);
607
+ expect(opts.json).toBe(true);
608
+ expect(opts.fix).toBeFalsy();
609
+ });
610
+
611
+ test("parses both flags", () => {
612
+ const opts = parseDoctorArgs(["--fix", "--json"]);
613
+ expect(opts.fix).toBe(true);
614
+ expect(opts.json).toBe(true);
615
+ });
616
+
617
+ test("returns defaults for no args", () => {
618
+ const opts = parseDoctorArgs([]);
619
+ expect(opts.fix).toBe(false);
620
+ expect(opts.json).toBe(false);
621
+ });
622
+ });