@os-eco/overstory-cli 0.6.1 → 0.6.5

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 (110) hide show
  1. package/README.md +8 -7
  2. package/package.json +12 -4
  3. package/src/agents/checkpoint.test.ts +2 -2
  4. package/src/agents/hooks-deployer.test.ts +131 -16
  5. package/src/agents/hooks-deployer.ts +33 -1
  6. package/src/agents/identity.test.ts +27 -27
  7. package/src/agents/identity.ts +10 -10
  8. package/src/agents/lifecycle.test.ts +6 -6
  9. package/src/agents/lifecycle.ts +2 -2
  10. package/src/agents/manifest.test.ts +86 -0
  11. package/src/agents/overlay.test.ts +9 -9
  12. package/src/agents/overlay.ts +4 -4
  13. package/src/commands/agents.test.ts +8 -8
  14. package/src/commands/agents.ts +62 -91
  15. package/src/commands/clean.test.ts +36 -51
  16. package/src/commands/clean.ts +28 -49
  17. package/src/commands/completions.ts +14 -0
  18. package/src/commands/coordinator.test.ts +133 -26
  19. package/src/commands/coordinator.ts +101 -64
  20. package/src/commands/costs.test.ts +47 -47
  21. package/src/commands/costs.ts +96 -75
  22. package/src/commands/dashboard.test.ts +2 -2
  23. package/src/commands/dashboard.ts +75 -95
  24. package/src/commands/doctor.test.ts +2 -2
  25. package/src/commands/doctor.ts +92 -79
  26. package/src/commands/errors.test.ts +2 -2
  27. package/src/commands/errors.ts +56 -50
  28. package/src/commands/feed.test.ts +2 -2
  29. package/src/commands/feed.ts +86 -83
  30. package/src/commands/group.ts +167 -177
  31. package/src/commands/hooks.test.ts +2 -2
  32. package/src/commands/hooks.ts +52 -42
  33. package/src/commands/init.test.ts +19 -19
  34. package/src/commands/init.ts +7 -16
  35. package/src/commands/inspect.test.ts +18 -18
  36. package/src/commands/inspect.ts +55 -58
  37. package/src/commands/log.test.ts +26 -31
  38. package/src/commands/log.ts +97 -91
  39. package/src/commands/logs.test.ts +1 -1
  40. package/src/commands/logs.ts +101 -104
  41. package/src/commands/mail.test.ts +5 -5
  42. package/src/commands/mail.ts +157 -169
  43. package/src/commands/merge.test.ts +28 -66
  44. package/src/commands/merge.ts +21 -51
  45. package/src/commands/metrics.test.ts +8 -8
  46. package/src/commands/metrics.ts +34 -35
  47. package/src/commands/monitor.test.ts +3 -3
  48. package/src/commands/monitor.ts +57 -62
  49. package/src/commands/nudge.test.ts +1 -1
  50. package/src/commands/nudge.ts +41 -89
  51. package/src/commands/prime.test.ts +19 -51
  52. package/src/commands/prime.ts +13 -50
  53. package/src/commands/replay.test.ts +2 -2
  54. package/src/commands/replay.ts +79 -86
  55. package/src/commands/run.test.ts +1 -1
  56. package/src/commands/run.ts +97 -77
  57. package/src/commands/sling.test.ts +201 -5
  58. package/src/commands/sling.ts +37 -64
  59. package/src/commands/spec.test.ts +14 -40
  60. package/src/commands/spec.ts +32 -101
  61. package/src/commands/status.test.ts +97 -1
  62. package/src/commands/status.ts +63 -58
  63. package/src/commands/stop.test.ts +22 -40
  64. package/src/commands/stop.ts +18 -33
  65. package/src/commands/supervisor.test.ts +12 -14
  66. package/src/commands/supervisor.ts +144 -165
  67. package/src/commands/trace.test.ts +15 -15
  68. package/src/commands/trace.ts +59 -82
  69. package/src/commands/watch.test.ts +2 -2
  70. package/src/commands/watch.ts +38 -45
  71. package/src/commands/worktree.test.ts +213 -37
  72. package/src/commands/worktree.ts +110 -55
  73. package/src/config.test.ts +96 -0
  74. package/src/doctor/consistency.test.ts +14 -14
  75. package/src/doctor/databases.test.ts +22 -2
  76. package/src/doctor/databases.ts +16 -0
  77. package/src/doctor/dependencies.test.ts +55 -1
  78. package/src/doctor/dependencies.ts +113 -18
  79. package/src/doctor/merge-queue.test.ts +4 -4
  80. package/src/e2e/init-sling-lifecycle.test.ts +8 -8
  81. package/src/errors.ts +1 -1
  82. package/src/index.ts +223 -213
  83. package/src/logging/color.test.ts +74 -91
  84. package/src/logging/color.ts +52 -46
  85. package/src/logging/reporter.test.ts +10 -10
  86. package/src/logging/reporter.ts +6 -5
  87. package/src/mail/broadcast.test.ts +1 -1
  88. package/src/mail/client.test.ts +6 -6
  89. package/src/mail/store.test.ts +3 -3
  90. package/src/merge/queue.test.ts +73 -7
  91. package/src/merge/queue.ts +17 -2
  92. package/src/merge/resolver.test.ts +159 -7
  93. package/src/merge/resolver.ts +46 -2
  94. package/src/metrics/store.test.ts +44 -44
  95. package/src/metrics/store.ts +2 -2
  96. package/src/metrics/summary.test.ts +35 -35
  97. package/src/mulch/client.test.ts +1 -1
  98. package/src/schema-consistency.test.ts +239 -0
  99. package/src/sessions/compat.test.ts +3 -3
  100. package/src/sessions/compat.ts +2 -2
  101. package/src/sessions/store.test.ts +41 -4
  102. package/src/sessions/store.ts +13 -2
  103. package/src/types.ts +14 -14
  104. package/src/watchdog/daemon.test.ts +10 -10
  105. package/src/watchdog/daemon.ts +1 -1
  106. package/src/watchdog/health.test.ts +1 -1
  107. package/src/worktree/manager.test.ts +20 -20
  108. package/src/worktree/manager.ts +120 -4
  109. package/src/worktree/tmux.test.ts +98 -9
  110. package/src/worktree/tmux.ts +18 -0
@@ -10,10 +10,12 @@
10
10
  */
11
11
 
12
12
  import { existsSync } from "node:fs";
13
- import { join } from "node:path";
13
+ import { join, resolve } from "node:path";
14
+ import { Command } from "commander";
14
15
  import { loadConfig } from "../config.ts";
15
16
  import { ValidationError } from "../errors.ts";
16
- import { color } from "../logging/color.ts";
17
+ import type { ColorFn } from "../logging/color.ts";
18
+ import { color, noColor, visibleLength } from "../logging/color.ts";
17
19
  import { createMailStore, type MailStore } from "../mail/store.ts";
18
20
  import { createMergeQueue, type MergeQueue } from "../merge/queue.ts";
19
21
  import { createMetricsStore, type MetricsStore } from "../metrics/store.ts";
@@ -23,6 +25,9 @@ import type { MailMessage } from "../types.ts";
23
25
  import { evaluateHealth } from "../watchdog/health.ts";
24
26
  import { getCachedTmuxSessions, getCachedWorktrees, type StatusData } from "./status.ts";
25
27
 
28
+ const pkgPath = resolve(import.meta.dir, "../../package.json");
29
+ const PKG_VERSION: string = JSON.parse(await Bun.file(pkgPath).text()).version ?? "unknown";
30
+
26
31
  /**
27
32
  * Terminal control codes (cursor movement, screen clearing).
28
33
  * These are not colors, so they stay separate from the color module.
@@ -49,17 +54,6 @@ const BOX = {
49
54
  cross: "┼",
50
55
  };
51
56
 
52
- /**
53
- * Parse a named flag value from args.
54
- */
55
- function getFlag(args: string[], flag: string): string | undefined {
56
- const idx = args.indexOf(flag);
57
- if (idx === -1 || idx + 1 >= args.length) {
58
- return undefined;
59
- }
60
- return args[idx + 1];
61
- }
62
-
63
57
  /**
64
58
  * Format a duration in ms to a human-readable string.
65
59
  */
@@ -408,21 +402,20 @@ async function loadDashboardData(
408
402
  * Render the header bar (line 1).
409
403
  */
410
404
  function renderHeader(width: number, interval: number, currentRunId?: string | null): string {
411
- const left = `${color.bold}overstory dashboard v0.2.0${color.reset}`;
405
+ const left = color.bold(`overstory dashboard v${PKG_VERSION}`);
412
406
  const now = new Date().toLocaleTimeString();
413
407
  const scope = currentRunId ? ` [run: ${currentRunId.slice(0, 8)}]` : " [all runs]";
414
408
  const right = `${now}${scope} | refresh: ${interval}ms`;
415
- const leftStripped = "overstory dashboard v0.2.0"; // for length calculation
416
- const padding = width - leftStripped.length - right.length;
409
+ const padding = width - visibleLength(left) - right.length;
417
410
  const line = left + " ".repeat(Math.max(0, padding)) + right;
418
411
  const separator = horizontalLine(width, BOX.topLeft, BOX.horizontal, BOX.topRight);
419
412
  return `${line}\n${separator}`;
420
413
  }
421
414
 
422
415
  /**
423
- * Get color for agent state.
416
+ * Get color function for agent state.
424
417
  */
425
- function getStateColor(state: string): string {
418
+ function getStateColor(state: string): ColorFn {
426
419
  switch (state) {
427
420
  case "working":
428
421
  return color.green;
@@ -435,7 +428,7 @@ function getStateColor(state: string): string {
435
428
  case "completed":
436
429
  return color.cyan;
437
430
  default:
438
- return color.white;
431
+ return noColor;
439
432
  }
440
433
  }
441
434
 
@@ -472,14 +465,12 @@ function renderAgentPanel(
472
465
  let output = "";
473
466
 
474
467
  // Panel header
475
- const headerLine = `${BOX.vertical} ${color.bold}Agents${color.reset} (${data.status.agents.length})`;
476
- const headerPadding = " ".repeat(
477
- Math.max(0, width - headerLine.length - 1 + color.bold.length + color.reset.length),
478
- );
468
+ const headerLine = `${BOX.vertical} ${color.bold("Agents")} (${data.status.agents.length})`;
469
+ const headerPadding = " ".repeat(Math.max(0, width - visibleLength(headerLine) - 1));
479
470
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
480
471
 
481
472
  // Column headers
482
- const colHeaders = `${BOX.vertical} St Name Capability State Bead ID Duration Tmux ${BOX.vertical}`;
473
+ const colHeaders = `${BOX.vertical} St Name Capability State Task ID Duration Tmux ${BOX.vertical}`;
483
474
  output += `${CURSOR.cursorTo(startRow + 1, 1)}${colHeaders}\n`;
484
475
 
485
476
  // Separator
@@ -509,7 +500,7 @@ function renderAgentPanel(
509
500
  const name = pad(truncate(agent.agentName, 15), 15);
510
501
  const capability = pad(truncate(agent.capability, 12), 12);
511
502
  const state = pad(agent.state, 10);
512
- const beadId = pad(truncate(agent.beadId, 16), 16);
503
+ const taskId = pad(truncate(agent.taskId, 16), 16);
513
504
  const endTime =
514
505
  agent.state === "completed" || agent.state === "zombie"
515
506
  ? new Date(agent.lastActivity).getTime()
@@ -517,9 +508,9 @@ function renderAgentPanel(
517
508
  const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
518
509
  const durationPadded = pad(duration, 9);
519
510
  const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
520
- const tmuxDot = tmuxAlive ? `${color.green}●${color.reset}` : `${color.red}○${color.reset}`;
511
+ const tmuxDot = tmuxAlive ? color.green("●") : color.red("○");
521
512
 
522
- const line = `${BOX.vertical} ${stateColor}${icon}${color.reset} ${name} ${capability} ${stateColor}${state}${color.reset} ${beadId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
513
+ const line = `${BOX.vertical} ${stateColor(icon)} ${name} ${capability} ${stateColor(state)} ${taskId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
523
514
  output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${line}\n`;
524
515
  }
525
516
 
@@ -537,20 +528,20 @@ function renderAgentPanel(
537
528
  }
538
529
 
539
530
  /**
540
- * Get color for mail priority.
531
+ * Get color function for mail priority.
541
532
  */
542
- function getPriorityColor(priority: string): string {
533
+ function getPriorityColor(priority: string): ColorFn {
543
534
  switch (priority) {
544
535
  case "urgent":
545
536
  return color.red;
546
537
  case "high":
547
538
  return color.yellow;
548
539
  case "normal":
549
- return color.white;
540
+ return noColor;
550
541
  case "low":
551
542
  return color.dim;
552
543
  default:
553
- return color.white;
544
+ return noColor;
554
545
  }
555
546
  }
556
547
 
@@ -568,10 +559,8 @@ function renderMailPanel(
568
559
  let output = "";
569
560
 
570
561
  const unreadCount = data.status.unreadMailCount;
571
- const headerLine = `${BOX.vertical} ${color.bold}Mail${color.reset} (${unreadCount} unread)`;
572
- const headerPadding = " ".repeat(
573
- Math.max(0, panelWidth - headerLine.length - 1 + color.bold.length + color.reset.length),
574
- );
562
+ const headerLine = `${BOX.vertical} ${color.bold("Mail")} (${unreadCount} unread)`;
563
+ const headerPadding = " ".repeat(Math.max(0, panelWidth - visibleLength(headerLine) - 1));
575
564
  output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
576
565
 
577
566
  const separator = horizontalLine(panelWidth, BOX.tee, BOX.horizontal, BOX.cross);
@@ -584,26 +573,16 @@ function renderMailPanel(
584
573
  const msg = messages[i];
585
574
  if (!msg) continue;
586
575
 
587
- const priorityColor = getPriorityColor(msg.priority);
576
+ const priorityColorFn = getPriorityColor(msg.priority);
588
577
  const priority = msg.priority === "normal" ? "" : `[${msg.priority}] `;
589
578
  const from = truncate(msg.from, 12);
590
579
  const to = truncate(msg.to, 12);
591
580
  const subject = truncate(msg.subject, panelWidth - 40);
592
581
  const time = timeAgo(msg.createdAt);
593
582
 
594
- const line = `${BOX.vertical} ${priorityColor}${priority}${color.reset}${from} → ${to}: ${subject} (${time})`;
595
- const padding = " ".repeat(
596
- Math.max(
597
- 0,
598
- panelWidth -
599
- line.length -
600
- 1 +
601
- priorityColor.length +
602
- color.reset.length +
603
- priorityColor.length +
604
- color.reset.length,
605
- ),
606
- );
583
+ const coloredPriority = priority ? priorityColorFn(priority) : "";
584
+ const line = `${BOX.vertical} ${coloredPriority}${from} → ${to}: ${subject} (${time})`;
585
+ const padding = " ".repeat(Math.max(0, panelWidth - visibleLength(line) - 1));
607
586
  output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${line}${padding}${BOX.vertical}\n`;
608
587
  }
609
588
 
@@ -617,9 +596,9 @@ function renderMailPanel(
617
596
  }
618
597
 
619
598
  /**
620
- * Get color for merge queue status.
599
+ * Get color function for merge queue status.
621
600
  */
622
- function getMergeStatusColor(status: string): string {
601
+ function getMergeStatusColor(status: string): ColorFn {
623
602
  switch (status) {
624
603
  case "pending":
625
604
  return color.yellow;
@@ -630,7 +609,7 @@ function getMergeStatusColor(status: string): string {
630
609
  case "merged":
631
610
  return color.green;
632
611
  default:
633
- return color.white;
612
+ return noColor;
634
613
  }
635
614
  }
636
615
 
@@ -648,10 +627,8 @@ function renderMergeQueuePanel(
648
627
  const panelWidth = width - startCol + 1;
649
628
  let output = "";
650
629
 
651
- const headerLine = `${BOX.vertical} ${color.bold}Merge Queue${color.reset} (${data.mergeQueue.length})`;
652
- const headerPadding = " ".repeat(
653
- Math.max(0, panelWidth - headerLine.length - 1 + color.bold.length + color.reset.length),
654
- );
630
+ const headerLine = `${BOX.vertical} ${color.bold("Merge Queue")} (${data.mergeQueue.length})`;
631
+ const headerPadding = " ".repeat(Math.max(0, panelWidth - visibleLength(headerLine) - 1));
655
632
  output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${BOX.vertical}\n`;
656
633
 
657
634
  const separator = horizontalLine(panelWidth, BOX.cross, BOX.horizontal, BOX.teeRight);
@@ -664,15 +641,13 @@ function renderMergeQueuePanel(
664
641
  const entry = entries[i];
665
642
  if (!entry) continue;
666
643
 
667
- const statusColor = getMergeStatusColor(entry.status);
644
+ const statusColorFn = getMergeStatusColor(entry.status);
668
645
  const status = pad(entry.status, 10);
669
646
  const agent = truncate(entry.agentName, 15);
670
647
  const branch = truncate(entry.branchName, panelWidth - 30);
671
648
 
672
- const line = `${BOX.vertical} ${statusColor}${status}${color.reset} ${agent} ${branch}`;
673
- const padding = " ".repeat(
674
- Math.max(0, panelWidth - line.length - 1 + statusColor.length + color.reset.length),
675
- );
649
+ const line = `${BOX.vertical} ${statusColorFn(status)} ${agent} ${branch}`;
650
+ const padding = " ".repeat(Math.max(0, panelWidth - visibleLength(line) - 1));
676
651
  output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${line}${padding}${BOX.vertical}\n`;
677
652
  }
678
653
 
@@ -699,10 +674,8 @@ function renderMetricsPanel(
699
674
  const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
700
675
  output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
701
676
 
702
- const headerLine = `${BOX.vertical} ${color.bold}Metrics${color.reset}`;
703
- const headerPadding = " ".repeat(
704
- Math.max(0, width - headerLine.length - 1 + color.bold.length + color.reset.length),
705
- );
677
+ const headerLine = `${BOX.vertical} ${color.bold("Metrics")}`;
678
+ const headerPadding = " ".repeat(Math.max(0, width - visibleLength(headerLine) - 1));
706
679
  output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
707
680
 
708
681
  const totalSessions = data.metrics.totalSessions;
@@ -756,38 +729,15 @@ function renderDashboard(data: DashboardData, interval: number): void {
756
729
  process.stdout.write(output);
757
730
  }
758
731
 
759
- /**
760
- * Entry point for `overstory dashboard [--interval <ms>] [--all]`.
761
- */
762
- const DASHBOARD_HELP = `overstory dashboard — Live TUI dashboard for agent monitoring
763
-
764
- Usage: overstory dashboard [--interval <ms>] [--all]
765
-
766
- Options:
767
- --interval <ms> Poll interval in milliseconds (default: 2000, min: 500)
768
- --all Show data from all runs (default: current run only)
769
- --help, -h Show this help
770
-
771
- Dashboard panels:
772
- - Agent panel: Active agents with status, capability, bead ID, duration
773
- - Mail panel: Recent messages with priority and time
774
- - Merge queue: Pending/merging/conflict entries
775
- - Metrics: Session counts, avg duration, by-capability breakdown
776
-
777
- By default the dashboard scopes all panels to the current run (current-run.txt).
778
- Use --all to see data across all runs.
779
-
780
- Press Ctrl+C to exit.`;
781
-
782
- export async function dashboardCommand(args: string[]): Promise<void> {
783
- if (args.includes("--help") || args.includes("-h")) {
784
- process.stdout.write(`${DASHBOARD_HELP}\n`);
785
- return;
786
- }
732
+ interface DashboardOpts {
733
+ interval?: string;
734
+ all?: boolean;
735
+ }
787
736
 
788
- const intervalStr = getFlag(args, "--interval");
737
+ async function executeDashboard(opts: DashboardOpts): Promise<void> {
738
+ const intervalStr = opts.interval;
789
739
  const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 2000;
790
- const showAll = args.includes("--all");
740
+ const showAll = opts.all ?? false;
791
741
 
792
742
  if (Number.isNaN(interval) || interval < 500) {
793
743
  throw new ValidationError("--interval must be a number >= 500 (milliseconds)", {
@@ -836,3 +786,33 @@ export async function dashboardCommand(args: string[]): Promise<void> {
836
786
  await Bun.sleep(interval);
837
787
  }
838
788
  }
789
+
790
+ export function createDashboardCommand(): Command {
791
+ return new Command("dashboard")
792
+ .description("Live TUI dashboard for agent monitoring (Ctrl+C to stop)")
793
+ .option("--interval <ms>", "Poll interval in milliseconds (default: 2000, min: 500)")
794
+ .option("--all", "Show data from all runs (default: current run only)")
795
+ .action(async (opts: DashboardOpts) => {
796
+ await executeDashboard(opts);
797
+ });
798
+ }
799
+
800
+ export async function dashboardCommand(args: string[]): Promise<void> {
801
+ const cmd = createDashboardCommand();
802
+ cmd.exitOverride();
803
+ try {
804
+ await cmd.parseAsync(args, { from: "user" });
805
+ } catch (err: unknown) {
806
+ if (err && typeof err === "object" && "code" in err) {
807
+ const code = (err as { code: string }).code;
808
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
809
+ return;
810
+ }
811
+ if (code.startsWith("commander.")) {
812
+ const message = err instanceof Error ? err.message : String(err);
813
+ throw new ValidationError(message, { field: "args" });
814
+ }
815
+ }
816
+ throw err;
817
+ }
818
+ }
@@ -61,7 +61,7 @@ describe("doctorCommand", () => {
61
61
  await doctorCommand(["--help"], { checkRunners: [] });
62
62
  const out = output();
63
63
 
64
- expect(out).toContain("overstory doctor");
64
+ expect(out).toContain("doctor");
65
65
  expect(out).toContain("Run health checks");
66
66
  expect(out).toContain("--json");
67
67
  expect(out).toContain("--verbose");
@@ -72,7 +72,7 @@ describe("doctorCommand", () => {
72
72
  await doctorCommand(["-h"], { checkRunners: [] });
73
73
  const out = output();
74
74
 
75
- expect(out).toContain("overstory doctor");
75
+ expect(out).toContain("doctor");
76
76
  expect(out).toContain("--help");
77
77
  });
78
78
  });
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { join } from "node:path";
8
+ import { Command } from "commander";
8
9
  import { loadConfig } from "../config.ts";
9
10
  import { checkAgents } from "../doctor/agents.ts";
10
11
  import { checkConfig } from "../doctor/config-check.ts";
@@ -32,18 +33,6 @@ const ALL_CHECKS: Array<{ category: DoctorCategory; fn: DoctorCheckFn }> = [
32
33
  { category: "version", fn: checkVersion },
33
34
  ];
34
35
 
35
- function hasFlag(args: string[], flag: string): boolean {
36
- return args.includes(flag);
37
- }
38
-
39
- function getFlag(args: string[], flag: string): string | undefined {
40
- const idx = args.indexOf(flag);
41
- if (idx === -1 || idx + 1 >= args.length) {
42
- return undefined;
43
- }
44
- return args[idx + 1];
45
- }
46
-
47
36
  /**
48
37
  * Format human-readable output for doctor checks.
49
38
  */
@@ -54,7 +43,7 @@ function printHumanReadable(
54
43
  ): void {
55
44
  const w = process.stdout.write.bind(process.stdout);
56
45
 
57
- w(`${color.bold}Overstory Doctor${color.reset}\n`);
46
+ w(`${color.bold("Overstory Doctor")}\n`);
58
47
  w("================\n\n");
59
48
 
60
49
  // Group checks by category
@@ -75,10 +64,10 @@ function printHumanReadable(
75
64
  continue; // Skip empty categories unless verbose
76
65
  }
77
66
 
78
- w(`${color.bold}[${category}]${color.reset}\n`);
67
+ w(`${color.bold(`[${category}]`)}\n`);
79
68
 
80
69
  if (categoryChecks.length === 0) {
81
- w(` ${color.dim}No checks${color.reset}\n`);
70
+ w(` ${color.dim("No checks")}\n`);
82
71
  } else {
83
72
  for (const check of categoryChecks) {
84
73
  // Skip passing checks unless verbose
@@ -88,17 +77,17 @@ function printHumanReadable(
88
77
 
89
78
  const icon =
90
79
  check.status === "pass"
91
- ? `${color.green}✔${color.reset}`
80
+ ? color.green("✔")
92
81
  : check.status === "warn"
93
- ? `${color.yellow}⚠${color.reset}`
94
- : `${color.red}✘${color.reset}`;
82
+ ? color.yellow("⚠")
83
+ : color.red("✘");
95
84
 
96
85
  w(` ${icon} ${check.message}\n`);
97
86
 
98
87
  // Print details if present
99
88
  if (check.details && check.details.length > 0) {
100
89
  for (const detail of check.details) {
101
- w(` ${color.dim}→ ${detail}${color.reset}\n`);
90
+ w(` ${color.dim(`→ ${detail}`)}\n`);
102
91
  }
103
92
  }
104
93
  }
@@ -113,7 +102,7 @@ function printHumanReadable(
113
102
  const fail = checks.filter((c) => c.status === "fail").length;
114
103
 
115
104
  w(
116
- `${color.bold}Summary:${color.reset} ${color.green}${pass} passed${color.reset}, ${color.yellow}${warn} warning${warn === 1 ? "" : "s"}${color.reset}, ${color.red}${fail} failure${fail === 1 ? "" : "s"}${color.reset}\n`,
105
+ `${color.bold("Summary:")} ${color.green(`${pass} passed`)}, ${color.yellow(`${warn} warning${warn === 1 ? "" : "s"}`)}, ${color.red(`${fail} failure${fail === 1 ? "" : "s"}`)}\n`,
117
106
  );
118
107
  }
119
108
 
@@ -133,24 +122,76 @@ function printJSON(checks: DoctorCheck[]): void {
133
122
  process.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
134
123
  }
135
124
 
136
- const DOCTOR_HELP = `overstory doctor -- Run health checks on overstory subsystems
137
-
138
- Usage: overstory doctor [options]
139
-
140
- Options:
141
- --json Output as JSON
142
- --verbose Show passing checks (default: only problems)
143
- --category <name> Run only one category
144
- --help, -h Show this help
145
-
146
- Categories: dependencies, structure, config, databases, consistency, agents, merge, logs, version`;
147
-
148
125
  /** Options for dependency injection in doctorCommand. */
149
126
  export interface DoctorCommandOptions {
150
127
  /** Override the check runners (defaults to ALL_CHECKS). Pass [] to skip all checks. */
151
128
  checkRunners?: Array<{ category: DoctorCategory; fn: DoctorCheckFn }>;
152
129
  }
153
130
 
131
+ /**
132
+ * Create the Commander command for `overstory doctor`.
133
+ */
134
+ export function createDoctorCommand(options?: DoctorCommandOptions): Command {
135
+ return new Command("doctor")
136
+ .description("Run health checks on overstory setup")
137
+ .option("--json", "Output as JSON")
138
+ .option("--verbose", "Show passing checks (default: only problems)")
139
+ .option("--category <name>", "Run only one category")
140
+ .addHelpText(
141
+ "after",
142
+ "\nCategories: dependencies, structure, config, databases, consistency, agents, merge, logs, version",
143
+ )
144
+ .action(async (opts: { json?: boolean; verbose?: boolean; category?: string }) => {
145
+ const json = opts.json ?? false;
146
+ const verbose = opts.verbose ?? false;
147
+ const categoryFilter = opts.category;
148
+
149
+ // Validate category filter if provided
150
+ if (categoryFilter !== undefined) {
151
+ const validCategories = ALL_CHECKS.map((c) => c.category);
152
+ if (!validCategories.includes(categoryFilter as DoctorCategory)) {
153
+ throw new ValidationError(
154
+ `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
155
+ {
156
+ field: "category",
157
+ value: categoryFilter,
158
+ },
159
+ );
160
+ }
161
+ }
162
+
163
+ const cwd = process.cwd();
164
+ const config = await loadConfig(cwd);
165
+ const overstoryDir = join(config.project.root, ".overstory");
166
+
167
+ // Filter checks by category if specified
168
+ const allChecks = options?.checkRunners ?? ALL_CHECKS;
169
+ const checksToRun = categoryFilter
170
+ ? allChecks.filter((c) => c.category === categoryFilter)
171
+ : allChecks;
172
+
173
+ // Run all checks sequentially
174
+ const results: DoctorCheck[] = [];
175
+ for (const { fn } of checksToRun) {
176
+ const checkResults = await fn(config, overstoryDir);
177
+ results.push(...checkResults);
178
+ }
179
+
180
+ // Output results
181
+ if (json) {
182
+ printJSON(results);
183
+ } else {
184
+ printHumanReadable(results, verbose, allChecks);
185
+ }
186
+
187
+ // Set exit code if any check failed
188
+ const hasFailures = results.some((c) => c.status === "fail");
189
+ if (hasFailures) {
190
+ process.exitCode = 1;
191
+ }
192
+ });
193
+ }
194
+
154
195
  /**
155
196
  * Entry point for `overstory doctor [--json] [--verbose] [--category <name>]`.
156
197
  *
@@ -160,54 +201,26 @@ export async function doctorCommand(
160
201
  args: string[],
161
202
  options?: DoctorCommandOptions,
162
203
  ): Promise<number | undefined> {
163
- if (hasFlag(args, "--help") || hasFlag(args, "-h")) {
164
- process.stdout.write(`${DOCTOR_HELP}\n`);
165
- return;
166
- }
167
-
168
- const json = hasFlag(args, "--json");
169
- const verbose = hasFlag(args, "--verbose");
170
- const categoryFilter = getFlag(args, "--category");
171
-
172
- // Validate category filter if provided
173
- if (categoryFilter !== undefined) {
174
- const validCategories = ALL_CHECKS.map((c) => c.category);
175
- if (!validCategories.includes(categoryFilter as DoctorCategory)) {
176
- throw new ValidationError(
177
- `Invalid category: ${categoryFilter}. Valid categories: ${validCategories.join(", ")}`,
178
- {
179
- field: "category",
180
- value: categoryFilter,
181
- },
182
- );
204
+ const cmd = createDoctorCommand(options);
205
+ cmd.exitOverride();
206
+
207
+ const prevExitCode = process.exitCode as number | undefined;
208
+ process.exitCode = undefined;
209
+
210
+ try {
211
+ await cmd.parseAsync(args, { from: "user" });
212
+ } catch (err: unknown) {
213
+ process.exitCode = prevExitCode;
214
+ if (err && typeof err === "object" && "code" in err) {
215
+ const code = (err as { code: string }).code;
216
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
217
+ return undefined;
218
+ }
183
219
  }
220
+ throw err;
184
221
  }
185
222
 
186
- const cwd = process.cwd();
187
- const config = await loadConfig(cwd);
188
- const overstoryDir = join(config.project.root, ".overstory");
189
-
190
- // Filter checks by category if specified
191
- const allChecks = options?.checkRunners ?? ALL_CHECKS;
192
- const checksToRun = categoryFilter
193
- ? allChecks.filter((c) => c.category === categoryFilter)
194
- : allChecks;
195
-
196
- // Run all checks sequentially
197
- const results: DoctorCheck[] = [];
198
- for (const { fn } of checksToRun) {
199
- const checkResults = await fn(config, overstoryDir);
200
- results.push(...checkResults);
201
- }
202
-
203
- // Output results
204
- if (json) {
205
- printJSON(results);
206
- } else {
207
- printHumanReadable(results, verbose, allChecks);
208
- }
209
-
210
- // Return exit code if any check failed
211
- const hasFailures = results.some((c) => c.status === "fail");
212
- return hasFailures ? 1 : undefined;
223
+ const exitCode = process.exitCode === 1 ? 1 : undefined;
224
+ process.exitCode = prevExitCode;
225
+ return exitCode;
213
226
  }
@@ -78,7 +78,7 @@ describe("errorsCommand", () => {
78
78
  await errorsCommand(["--help"]);
79
79
  const out = output();
80
80
 
81
- expect(out).toContain("overstory errors");
81
+ expect(out).toContain("errors");
82
82
  expect(out).toContain("--agent");
83
83
  expect(out).toContain("--run");
84
84
  expect(out).toContain("--json");
@@ -91,7 +91,7 @@ describe("errorsCommand", () => {
91
91
  await errorsCommand(["-h"]);
92
92
  const out = output();
93
93
 
94
- expect(out).toContain("overstory errors");
94
+ expect(out).toContain("errors");
95
95
  });
96
96
  });
97
97