@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.
- package/README.md +8 -7
- package/package.json +12 -4
- package/src/agents/checkpoint.test.ts +2 -2
- package/src/agents/hooks-deployer.test.ts +131 -16
- package/src/agents/hooks-deployer.ts +33 -1
- package/src/agents/identity.test.ts +27 -27
- package/src/agents/identity.ts +10 -10
- package/src/agents/lifecycle.test.ts +6 -6
- package/src/agents/lifecycle.ts +2 -2
- package/src/agents/manifest.test.ts +86 -0
- package/src/agents/overlay.test.ts +9 -9
- package/src/agents/overlay.ts +4 -4
- package/src/commands/agents.test.ts +8 -8
- package/src/commands/agents.ts +62 -91
- package/src/commands/clean.test.ts +36 -51
- package/src/commands/clean.ts +28 -49
- package/src/commands/completions.ts +14 -0
- package/src/commands/coordinator.test.ts +133 -26
- package/src/commands/coordinator.ts +101 -64
- package/src/commands/costs.test.ts +47 -47
- package/src/commands/costs.ts +96 -75
- package/src/commands/dashboard.test.ts +2 -2
- package/src/commands/dashboard.ts +75 -95
- package/src/commands/doctor.test.ts +2 -2
- package/src/commands/doctor.ts +92 -79
- package/src/commands/errors.test.ts +2 -2
- package/src/commands/errors.ts +56 -50
- package/src/commands/feed.test.ts +2 -2
- package/src/commands/feed.ts +86 -83
- package/src/commands/group.ts +167 -177
- package/src/commands/hooks.test.ts +2 -2
- package/src/commands/hooks.ts +52 -42
- package/src/commands/init.test.ts +19 -19
- package/src/commands/init.ts +7 -16
- package/src/commands/inspect.test.ts +18 -18
- package/src/commands/inspect.ts +55 -58
- package/src/commands/log.test.ts +26 -31
- package/src/commands/log.ts +97 -91
- package/src/commands/logs.test.ts +1 -1
- package/src/commands/logs.ts +101 -104
- package/src/commands/mail.test.ts +5 -5
- package/src/commands/mail.ts +157 -169
- package/src/commands/merge.test.ts +28 -66
- package/src/commands/merge.ts +21 -51
- package/src/commands/metrics.test.ts +8 -8
- package/src/commands/metrics.ts +34 -35
- package/src/commands/monitor.test.ts +3 -3
- package/src/commands/monitor.ts +57 -62
- package/src/commands/nudge.test.ts +1 -1
- package/src/commands/nudge.ts +41 -89
- package/src/commands/prime.test.ts +19 -51
- package/src/commands/prime.ts +13 -50
- package/src/commands/replay.test.ts +2 -2
- package/src/commands/replay.ts +79 -86
- package/src/commands/run.test.ts +1 -1
- package/src/commands/run.ts +97 -77
- package/src/commands/sling.test.ts +201 -5
- package/src/commands/sling.ts +37 -64
- package/src/commands/spec.test.ts +14 -40
- package/src/commands/spec.ts +32 -101
- package/src/commands/status.test.ts +97 -1
- package/src/commands/status.ts +63 -58
- package/src/commands/stop.test.ts +22 -40
- package/src/commands/stop.ts +18 -33
- package/src/commands/supervisor.test.ts +12 -14
- package/src/commands/supervisor.ts +144 -165
- package/src/commands/trace.test.ts +15 -15
- package/src/commands/trace.ts +59 -82
- package/src/commands/watch.test.ts +2 -2
- package/src/commands/watch.ts +38 -45
- package/src/commands/worktree.test.ts +213 -37
- package/src/commands/worktree.ts +110 -55
- package/src/config.test.ts +96 -0
- package/src/doctor/consistency.test.ts +14 -14
- package/src/doctor/databases.test.ts +22 -2
- package/src/doctor/databases.ts +16 -0
- package/src/doctor/dependencies.test.ts +55 -1
- package/src/doctor/dependencies.ts +113 -18
- package/src/doctor/merge-queue.test.ts +4 -4
- package/src/e2e/init-sling-lifecycle.test.ts +8 -8
- package/src/errors.ts +1 -1
- package/src/index.ts +223 -213
- package/src/logging/color.test.ts +74 -91
- package/src/logging/color.ts +52 -46
- package/src/logging/reporter.test.ts +10 -10
- package/src/logging/reporter.ts +6 -5
- package/src/mail/broadcast.test.ts +1 -1
- package/src/mail/client.test.ts +6 -6
- package/src/mail/store.test.ts +3 -3
- package/src/merge/queue.test.ts +73 -7
- package/src/merge/queue.ts +17 -2
- package/src/merge/resolver.test.ts +159 -7
- package/src/merge/resolver.ts +46 -2
- package/src/metrics/store.test.ts +44 -44
- package/src/metrics/store.ts +2 -2
- package/src/metrics/summary.test.ts +35 -35
- package/src/mulch/client.test.ts +1 -1
- package/src/schema-consistency.test.ts +239 -0
- package/src/sessions/compat.test.ts +3 -3
- package/src/sessions/compat.ts +2 -2
- package/src/sessions/store.test.ts +41 -4
- package/src/sessions/store.ts +13 -2
- package/src/types.ts +14 -14
- package/src/watchdog/daemon.test.ts +10 -10
- package/src/watchdog/daemon.ts +1 -1
- package/src/watchdog/health.test.ts +1 -1
- package/src/worktree/manager.test.ts +20 -20
- package/src/worktree/manager.ts +120 -4
- package/src/worktree/tmux.test.ts +98 -9
- 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 {
|
|
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 =
|
|
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
|
|
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):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ?
|
|
511
|
+
const tmuxDot = tmuxAlive ? color.green("●") : color.red("○");
|
|
521
512
|
|
|
522
|
-
const line = `${BOX.vertical} ${stateColor
|
|
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):
|
|
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
|
|
540
|
+
return noColor;
|
|
550
541
|
case "low":
|
|
551
542
|
return color.dim;
|
|
552
543
|
default:
|
|
553
|
-
return
|
|
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
|
|
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
|
|
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
|
|
595
|
-
const
|
|
596
|
-
|
|
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):
|
|
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
|
|
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
|
|
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
|
|
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} ${
|
|
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
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
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 =
|
|
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("
|
|
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("
|
|
75
|
+
expect(out).toContain("doctor");
|
|
76
76
|
expect(out).toContain("--help");
|
|
77
77
|
});
|
|
78
78
|
});
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
|
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
|
|
67
|
+
w(`${color.bold(`[${category}]`)}\n`);
|
|
79
68
|
|
|
80
69
|
if (categoryChecks.length === 0) {
|
|
81
|
-
w(` ${color.dim
|
|
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
|
-
?
|
|
80
|
+
? color.green("✔")
|
|
92
81
|
: check.status === "warn"
|
|
93
|
-
?
|
|
94
|
-
:
|
|
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
|
|
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
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
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("
|
|
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("
|
|
94
|
+
expect(out).toContain("errors");
|
|
95
95
|
});
|
|
96
96
|
});
|
|
97
97
|
|