@os-eco/overstory-cli 0.6.1 → 0.6.4

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 (80) hide show
  1. package/README.md +7 -6
  2. package/package.json +12 -4
  3. package/src/agents/hooks-deployer.test.ts +94 -16
  4. package/src/agents/hooks-deployer.ts +18 -0
  5. package/src/agents/manifest.test.ts +86 -0
  6. package/src/commands/agents.test.ts +3 -3
  7. package/src/commands/agents.ts +59 -88
  8. package/src/commands/clean.test.ts +31 -46
  9. package/src/commands/clean.ts +28 -49
  10. package/src/commands/completions.ts +14 -0
  11. package/src/commands/coordinator.test.ts +131 -24
  12. package/src/commands/coordinator.ts +100 -63
  13. package/src/commands/costs.test.ts +2 -2
  14. package/src/commands/costs.ts +96 -75
  15. package/src/commands/dashboard.test.ts +2 -2
  16. package/src/commands/dashboard.ts +73 -93
  17. package/src/commands/doctor.test.ts +2 -2
  18. package/src/commands/doctor.ts +92 -79
  19. package/src/commands/errors.test.ts +2 -2
  20. package/src/commands/errors.ts +56 -50
  21. package/src/commands/feed.test.ts +2 -2
  22. package/src/commands/feed.ts +86 -83
  23. package/src/commands/group.ts +167 -177
  24. package/src/commands/hooks.test.ts +2 -2
  25. package/src/commands/hooks.ts +52 -42
  26. package/src/commands/init.test.ts +19 -19
  27. package/src/commands/init.ts +7 -16
  28. package/src/commands/inspect.test.ts +2 -2
  29. package/src/commands/inspect.ts +54 -57
  30. package/src/commands/log.test.ts +5 -10
  31. package/src/commands/log.ts +90 -84
  32. package/src/commands/logs.test.ts +1 -1
  33. package/src/commands/logs.ts +101 -104
  34. package/src/commands/mail.ts +157 -169
  35. package/src/commands/merge.test.ts +20 -58
  36. package/src/commands/merge.ts +13 -43
  37. package/src/commands/metrics.test.ts +2 -2
  38. package/src/commands/metrics.ts +33 -34
  39. package/src/commands/monitor.test.ts +3 -3
  40. package/src/commands/monitor.ts +56 -61
  41. package/src/commands/nudge.ts +41 -89
  42. package/src/commands/prime.test.ts +15 -47
  43. package/src/commands/prime.ts +7 -44
  44. package/src/commands/replay.test.ts +2 -2
  45. package/src/commands/replay.ts +79 -86
  46. package/src/commands/run.ts +97 -77
  47. package/src/commands/sling.test.ts +196 -0
  48. package/src/commands/sling.ts +24 -54
  49. package/src/commands/spec.test.ts +13 -39
  50. package/src/commands/spec.ts +30 -99
  51. package/src/commands/status.ts +46 -42
  52. package/src/commands/stop.test.ts +21 -39
  53. package/src/commands/stop.ts +18 -33
  54. package/src/commands/supervisor.test.ts +3 -5
  55. package/src/commands/supervisor.ts +136 -157
  56. package/src/commands/trace.test.ts +9 -9
  57. package/src/commands/trace.ts +54 -77
  58. package/src/commands/watch.test.ts +2 -2
  59. package/src/commands/watch.ts +38 -45
  60. package/src/commands/worktree.test.ts +8 -8
  61. package/src/commands/worktree.ts +63 -46
  62. package/src/config.test.ts +96 -0
  63. package/src/doctor/databases.test.ts +22 -2
  64. package/src/doctor/databases.ts +16 -0
  65. package/src/doctor/dependencies.test.ts +55 -1
  66. package/src/doctor/dependencies.ts +113 -18
  67. package/src/e2e/init-sling-lifecycle.test.ts +6 -6
  68. package/src/index.ts +223 -213
  69. package/src/logging/color.test.ts +74 -91
  70. package/src/logging/color.ts +52 -46
  71. package/src/logging/reporter.test.ts +10 -10
  72. package/src/logging/reporter.ts +6 -5
  73. package/src/merge/queue.test.ts +66 -0
  74. package/src/merge/queue.ts +15 -0
  75. package/src/schema-consistency.test.ts +239 -0
  76. package/src/sessions/compat.ts +1 -1
  77. package/src/sessions/store.test.ts +37 -0
  78. package/src/sessions/store.ts +11 -0
  79. package/src/worktree/tmux.test.ts +98 -9
  80. package/src/worktree/tmux.ts +18 -0
@@ -6,15 +6,17 @@
6
6
  */
7
7
 
8
8
  import { join } from "node:path";
9
+ import { Command } from "commander";
9
10
  import { loadConfig } from "../config.ts";
10
11
  import { ValidationError } from "../errors.ts";
11
12
  import { createEventStore } from "../events/store.ts";
13
+ import type { ColorFn } from "../logging/color.ts";
12
14
  import { color } from "../logging/color.ts";
13
15
  import { openSessionStore } from "../sessions/compat.ts";
14
16
  import type { EventType, StoredEvent } from "../types.ts";
15
17
 
16
18
  /** Labels and colors for each event type. */
17
- const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
19
+ const EVENT_LABELS: Record<EventType, { label: string; color: ColorFn }> = {
18
20
  tool_start: { label: "TOOL START", color: color.blue },
19
21
  tool_end: { label: "TOOL END ", color: color.blue },
20
22
  session_start: { label: "SESSION +", color: color.green },
@@ -26,21 +28,6 @@ const EVENT_LABELS: Record<EventType, { label: string; color: string }> = {
26
28
  custom: { label: "CUSTOM ", color: color.gray },
27
29
  };
28
30
 
29
- /**
30
- * Parse a named flag value from args.
31
- */
32
- function getFlag(args: string[], flag: string): string | undefined {
33
- const idx = args.indexOf(flag);
34
- if (idx === -1 || idx + 1 >= args.length) {
35
- return undefined;
36
- }
37
- return args[idx + 1];
38
- }
39
-
40
- function hasFlag(args: string[], flag: string): boolean {
41
- return args.includes(flag);
42
- }
43
-
44
31
  /**
45
32
  * Detect whether a target string looks like a bead ID.
46
33
  * Bead IDs follow the pattern: word-alphanumeric (e.g., "overstory-rj1k", "myproject-abc1").
@@ -142,15 +129,15 @@ function buildEventDetail(event: StoredEvent): string {
142
129
  function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime: boolean): void {
143
130
  const w = process.stdout.write.bind(process.stdout);
144
131
 
145
- w(`${color.bold}Timeline for ${agentName}${color.reset}\n`);
132
+ w(`${color.bold(`Timeline for ${agentName}`)}\n`);
146
133
  w(`${"=".repeat(70)}\n`);
147
134
 
148
135
  if (events.length === 0) {
149
- w(`${color.dim}No events found.${color.reset}\n`);
136
+ w(`${color.dim("No events found.")}\n`);
150
137
  return;
151
138
  }
152
139
 
153
- w(`${color.dim}${events.length} event${events.length === 1 ? "" : "s"}${color.reset}\n\n`);
140
+ w(`${color.dim(`${events.length} event${events.length === 1 ? "" : "s"}`)}\n\n`);
154
141
 
155
142
  let lastDate = "";
156
143
 
@@ -161,7 +148,7 @@ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime
161
148
  if (lastDate !== "") {
162
149
  w("\n");
163
150
  }
164
- w(`${color.dim}--- ${date} ---${color.reset}\n`);
151
+ w(`${color.dim(`--- ${date} ---`)}\n`);
165
152
  lastDate = date;
166
153
  }
167
154
 
@@ -174,74 +161,35 @@ function printTimeline(events: StoredEvent[], agentName: string, useAbsoluteTime
174
161
  color: color.gray,
175
162
  };
176
163
 
177
- const levelColor =
178
- event.level === "error" ? color.red : event.level === "warn" ? color.yellow : "";
179
- const levelReset = levelColor ? color.reset : "";
164
+ const levelColorFn =
165
+ event.level === "error" ? color.red : event.level === "warn" ? color.yellow : null;
166
+ const applyLevel = (text: string) => (levelColorFn ? levelColorFn(text) : text);
180
167
 
181
168
  const detail = buildEventDetail(event);
182
- const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
169
+ const detailSuffix = detail ? ` ${color.dim(detail)}` : "";
183
170
 
184
- const agentLabel =
185
- event.agentName !== agentName ? ` ${color.dim}[${event.agentName}]${color.reset}` : "";
171
+ const agentLabel = event.agentName !== agentName ? ` ${color.dim(`[${event.agentName}]`)}` : "";
186
172
 
187
173
  w(
188
- `${color.dim}${timeStr.padStart(10)}${color.reset} ` +
189
- `${levelColor}${eventInfo.color}${color.bold}${eventInfo.label}${color.reset}${levelReset}` +
174
+ `${color.dim(timeStr.padStart(10))} ` +
175
+ `${applyLevel(eventInfo.color(color.bold(eventInfo.label)))}` +
190
176
  `${agentLabel}${detailSuffix}\n`,
191
177
  );
192
178
  }
193
179
  }
194
180
 
195
- const TRACE_HELP = `overstory trace -- Show chronological timeline for an agent or bead
196
-
197
- Usage: overstory trace <target> [options]
198
-
199
- Arguments:
200
- <target> Agent name or bead ID
201
-
202
- Options:
203
- --json Output as JSON array of StoredEvent objects
204
- --since <timestamp> Start time filter (ISO 8601)
205
- --until <timestamp> End time filter (ISO 8601)
206
- --limit <n> Max events to show (default: 100)
207
- --help, -h Show this help`;
208
-
209
- /**
210
- * Entry point for `overstory trace <target> [--json] [--since] [--until] [--limit]`.
211
- */
212
- export async function traceCommand(args: string[]): Promise<void> {
213
- if (args.includes("--help") || args.includes("-h")) {
214
- process.stdout.write(`${TRACE_HELP}\n`);
215
- return;
216
- }
217
-
218
- // Extract positional target: first arg that is not a flag or flag value
219
- const flagsWithValues = new Set(["--since", "--until", "--limit"]);
220
- const booleanFlags = new Set(["--json", "--help", "-h"]);
221
- let target: string | undefined;
222
- for (let i = 0; i < args.length; i++) {
223
- const arg = args[i];
224
- if (arg === undefined) continue;
225
- if (booleanFlags.has(arg)) continue;
226
- if (flagsWithValues.has(arg)) {
227
- i++; // skip the value
228
- continue;
229
- }
230
- if (arg.startsWith("-")) continue;
231
- target = arg;
232
- break;
233
- }
234
-
235
- if (!target) {
236
- throw new ValidationError("Missing target. Usage: overstory trace <agent-name|bead-id>", {
237
- field: "target",
238
- });
239
- }
181
+ interface TraceOpts {
182
+ json?: boolean;
183
+ since?: string;
184
+ until?: string;
185
+ limit?: string;
186
+ }
240
187
 
241
- const json = hasFlag(args, "--json");
242
- const sinceStr = getFlag(args, "--since");
243
- const untilStr = getFlag(args, "--until");
244
- const limitStr = getFlag(args, "--limit");
188
+ async function executeTrace(target: string, opts: TraceOpts): Promise<void> {
189
+ const json = opts.json ?? false;
190
+ const sinceStr = opts.since;
191
+ const untilStr = opts.until;
192
+ const limitStr = opts.limit;
245
193
  const limit = limitStr ? Number.parseInt(limitStr, 10) : 100;
246
194
 
247
195
  if (Number.isNaN(limit) || limit < 1) {
@@ -323,3 +271,32 @@ export async function traceCommand(args: string[]): Promise<void> {
323
271
  eventStore.close();
324
272
  }
325
273
  }
274
+
275
+ export function createTraceCommand(): Command {
276
+ return new Command("trace")
277
+ .description("Chronological event timeline for agent/bead")
278
+ .argument("<target>", "Agent name or bead ID")
279
+ .option("--json", "Output as JSON array of StoredEvent objects")
280
+ .option("--since <timestamp>", "Start time filter (ISO 8601)")
281
+ .option("--until <timestamp>", "End time filter (ISO 8601)")
282
+ .option("--limit <n>", "Max events to show (default: 100)")
283
+ .action(async (target: string, opts: TraceOpts) => {
284
+ await executeTrace(target, opts);
285
+ });
286
+ }
287
+
288
+ export async function traceCommand(args: string[]): Promise<void> {
289
+ const cmd = createTraceCommand();
290
+ cmd.exitOverride();
291
+ try {
292
+ await cmd.parseAsync(args, { from: "user" });
293
+ } catch (err: unknown) {
294
+ if (err && typeof err === "object" && "code" in err) {
295
+ const code = (err as { code: string }).code;
296
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
297
+ return;
298
+ }
299
+ }
300
+ throw err;
301
+ }
302
+ }
@@ -81,7 +81,7 @@ describe("watchCommand", () => {
81
81
  await watchCommand(["--help"]);
82
82
  const out = output();
83
83
 
84
- expect(out).toContain("overstory watch");
84
+ expect(out).toContain("watch");
85
85
  expect(out).toContain("--interval");
86
86
  expect(out).toContain("--background");
87
87
  expect(out).toContain("Tier 0");
@@ -91,7 +91,7 @@ describe("watchCommand", () => {
91
91
  await watchCommand(["-h"]);
92
92
  const out = output();
93
93
 
94
- expect(out).toContain("overstory watch");
94
+ expect(out).toContain("watch");
95
95
  expect(out).toContain("Tier 0");
96
96
  });
97
97
 
@@ -7,27 +7,13 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
+ import { Command } from "commander";
10
11
  import { loadConfig } from "../config.ts";
11
12
  import { OverstoryError } from "../errors.ts";
12
13
  import type { HealthCheck } from "../types.ts";
13
14
  import { startDaemon } from "../watchdog/daemon.ts";
14
15
  import { isProcessRunning } from "../watchdog/health.ts";
15
16
 
16
- /**
17
- * Parse a named flag value from args.
18
- */
19
- function getFlag(args: string[], flag: string): string | undefined {
20
- const idx = args.indexOf(flag);
21
- if (idx === -1 || idx + 1 >= args.length) {
22
- return undefined;
23
- }
24
- return args[idx + 1];
25
- }
26
-
27
- function hasFlag(args: string[], flag: string): boolean {
28
- return args.includes(flag);
29
- }
30
-
31
17
  /**
32
18
  * Format a health check for display.
33
19
  */
@@ -126,44 +112,21 @@ async function resolveOverstoryBin(): Promise<string> {
126
112
  }
127
113
 
128
114
  /**
129
- * Entry point for `overstory watch [--interval <ms>] [--background]`.
115
+ * Core implementation for the watch command.
130
116
  */
131
- const WATCH_HELP = `overstory watch Start Tier 0 mechanical watchdog daemon
132
-
133
- Usage: overstory watch [--interval <ms>] [--background]
134
-
135
- Tier numbering:
136
- Tier 0 Mechanical daemon (heartbeat, tmux/pid liveness) — this command
137
- Tier 1 Triage agent (ephemeral AI analysis of stalled agents)
138
- Tier 2 Monitor agent (continuous patrol — not yet implemented)
139
- Tier 3 Supervisor monitors (per-project)
140
-
141
- Options:
142
- --interval <ms> Health check interval in milliseconds (default: from config)
143
- --background Daemonize (run in background)
144
- --help, -h Show this help`;
145
-
146
- export async function watchCommand(args: string[]): Promise<void> {
147
- if (args.includes("--help") || args.includes("-h")) {
148
- process.stdout.write(`${WATCH_HELP}\n`);
149
- return;
150
- }
151
-
152
- const intervalStr = getFlag(args, "--interval");
153
- const background = hasFlag(args, "--background");
154
-
117
+ async function runWatch(opts: { interval?: string; background?: boolean }): Promise<void> {
155
118
  const cwd = process.cwd();
156
119
  const config = await loadConfig(cwd);
157
120
 
158
- const intervalMs = intervalStr
159
- ? Number.parseInt(intervalStr, 10)
121
+ const intervalMs = opts.interval
122
+ ? Number.parseInt(opts.interval, 10)
160
123
  : config.watchdog.tier0IntervalMs;
161
124
 
162
125
  const staleThresholdMs = config.watchdog.staleThresholdMs;
163
126
  const zombieThresholdMs = config.watchdog.zombieThresholdMs;
164
127
  const pidFilePath = join(config.project.root, ".overstory", "watchdog.pid");
165
128
 
166
- if (background) {
129
+ if (opts.background) {
167
130
  // Check if a watchdog is already running
168
131
  const existingPid = await readPidFile(pidFilePath);
169
132
  if (existingPid !== null && isProcessRunning(existingPid)) {
@@ -182,8 +145,8 @@ export async function watchCommand(args: string[]): Promise<void> {
182
145
 
183
146
  // Build the args for the child process, forwarding --interval but not --background
184
147
  const childArgs: string[] = ["watch"];
185
- if (intervalStr) {
186
- childArgs.push("--interval", intervalStr);
148
+ if (opts.interval) {
149
+ childArgs.push("--interval", opts.interval);
187
150
  }
188
151
 
189
152
  // Resolve the overstory binary path
@@ -245,3 +208,33 @@ export async function watchCommand(args: string[]): Promise<void> {
245
208
  // Block forever
246
209
  await new Promise(() => {});
247
210
  }
211
+
212
+ export function createWatchCommand(): Command {
213
+ return new Command("watch")
214
+ .description("Start Tier 0 mechanical watchdog daemon")
215
+ .option("--interval <ms>", "Health check interval in milliseconds")
216
+ .option("--background", "Daemonize (run in background)")
217
+ .action(async (opts: { interval?: string; background?: boolean }) => {
218
+ await runWatch(opts);
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Entry point for `overstory watch [--interval <ms>] [--background]`.
224
+ */
225
+ export async function watchCommand(args: string[]): Promise<void> {
226
+ const cmd = createWatchCommand();
227
+ cmd.exitOverride();
228
+
229
+ try {
230
+ await cmd.parseAsync(args, { from: "user" });
231
+ } catch (err: unknown) {
232
+ if (err && typeof err === "object" && "code" in err) {
233
+ const code = (err as { code: string }).code;
234
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
235
+ return;
236
+ }
237
+ }
238
+ throw err;
239
+ }
240
+ }
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { existsSync, realpathSync } from "node:fs";
3
3
  import { mkdir } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
+ import { ValidationError } from "../errors.ts";
5
6
  import { createSessionStore } from "../sessions/store.ts";
6
7
  import { cleanupTempDir, commitFile, createTempGitRepo, runGitInDir } from "../test-helpers.ts";
7
8
  import type { AgentSession } from "../types.ts";
@@ -99,30 +100,29 @@ describe("worktreeCommand", () => {
99
100
  await worktreeCommand(["--help"]);
100
101
  const out = output();
101
102
 
102
- expect(out).toContain("overstory worktree");
103
+ expect(out).toContain("worktree");
103
104
  expect(out).toContain("list");
104
105
  expect(out).toContain("clean");
105
- expect(out).toContain("--json");
106
106
  });
107
107
 
108
108
  test("-h shows help text", async () => {
109
109
  await worktreeCommand(["-h"]);
110
110
  const out = output();
111
111
 
112
- expect(out).toContain("overstory worktree");
112
+ expect(out).toContain("worktree");
113
113
  expect(out).toContain("list");
114
114
  });
115
115
  });
116
116
 
117
117
  describe("validation", () => {
118
118
  test("unknown subcommand throws ValidationError", async () => {
119
- await expect(worktreeCommand(["unknown"])).rejects.toThrow(
120
- "Unknown worktree subcommand: unknown",
121
- );
119
+ await expect(worktreeCommand(["unknown"])).rejects.toThrow(ValidationError);
122
120
  });
123
121
 
124
- test("missing subcommand throws ValidationError with (none)", async () => {
125
- await expect(worktreeCommand([])).rejects.toThrow("Unknown worktree subcommand: (none)");
122
+ test("empty args shows help text", async () => {
123
+ await worktreeCommand([]);
124
+ const out = output();
125
+ expect(out).toContain("worktree");
126
126
  });
127
127
  });
128
128
 
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { join } from "node:path";
10
+ import { Command } from "commander";
10
11
  import { loadConfig } from "../config.ts";
11
12
  import { ValidationError } from "../errors.ts";
12
13
  import { createMailStore } from "../mail/store.ts";
@@ -15,10 +16,6 @@ import type { AgentSession } from "../types.ts";
15
16
  import { isBranchMerged, listWorktrees, removeWorktree } from "../worktree/manager.ts";
16
17
  import { isSessionAlive, killSession } from "../worktree/tmux.ts";
17
18
 
18
- function hasFlag(args: string[], flag: string): boolean {
19
- return args.includes(flag);
20
- }
21
-
22
19
  /**
23
20
  * Handle `overstory worktree list`.
24
21
  */
@@ -72,14 +69,12 @@ async function handleList(root: string, json: boolean): Promise<void> {
72
69
  * Handle `overstory worktree clean [--completed] [--all] [--force]`.
73
70
  */
74
71
  async function handleClean(
75
- args: string[],
72
+ opts: { all: boolean; force: boolean; completedOnly: boolean },
76
73
  root: string,
77
74
  json: boolean,
78
75
  canonicalBranch: string,
79
76
  ): Promise<void> {
80
- const all = hasFlag(args, "--all");
81
- const force = hasFlag(args, "--force");
82
- const completedOnly = hasFlag(args, "--completed") || !all;
77
+ const { force, completedOnly } = opts;
83
78
 
84
79
  const worktrees = await listWorktrees(root);
85
80
  const overstoryDir = join(root, ".overstory");
@@ -260,52 +255,74 @@ async function handleClean(
260
255
  }
261
256
  }
262
257
 
258
+ export function createWorktreeCommand(): Command {
259
+ const cmd = new Command("worktree").description("Manage agent worktrees");
260
+
261
+ cmd
262
+ .command("list")
263
+ .description("List worktrees with agent status")
264
+ .option("--json", "Output as JSON")
265
+ .action(async (opts: { json?: boolean }) => {
266
+ const cwd = process.cwd();
267
+ const config = await loadConfig(cwd);
268
+ await handleList(config.project.root, opts.json ?? false);
269
+ });
270
+
271
+ cmd
272
+ .command("clean")
273
+ .description("Remove completed worktrees")
274
+ .option("--completed", "Only finished agents (default)")
275
+ .option("--all", "Force remove all worktrees")
276
+ .option("--force", "Delete even if branches are unmerged")
277
+ .option("--json", "Output as JSON")
278
+ .action(
279
+ async (opts: { completed?: boolean; all?: boolean; force?: boolean; json?: boolean }) => {
280
+ const cwd = process.cwd();
281
+ const config = await loadConfig(cwd);
282
+ const all = opts.all ?? false;
283
+ await handleClean(
284
+ {
285
+ all,
286
+ force: opts.force ?? false,
287
+ completedOnly: opts.completed ?? !all,
288
+ },
289
+ config.project.root,
290
+ opts.json ?? false,
291
+ config.project.canonicalBranch,
292
+ );
293
+ },
294
+ );
295
+
296
+ return cmd;
297
+ }
298
+
263
299
  /**
264
300
  * Entry point for `overstory worktree <subcommand> [flags]`.
265
301
  *
266
302
  * Subcommands: list, clean.
267
303
  */
268
- const WORKTREE_HELP = `overstory worktree — Manage agent worktrees
269
-
270
- Usage: overstory worktree <subcommand> [flags]
271
-
272
- Subcommands:
273
- list List worktrees with agent status
274
- clean Remove completed worktrees
275
- [--completed] Only finished agents (default)
276
- [--all] Force remove all
277
- [--force] Delete even if branches are unmerged
278
-
279
- Options:
280
- --json Output as JSON
281
- --help, -h Show this help`;
282
-
283
304
  export async function worktreeCommand(args: string[]): Promise<void> {
284
- if (args.includes("--help") || args.includes("-h")) {
285
- process.stdout.write(`${WORKTREE_HELP}\n`);
305
+ const cmd = createWorktreeCommand();
306
+ cmd.exitOverride();
307
+
308
+ if (args.length === 0) {
309
+ process.stdout.write(cmd.helpInformation());
286
310
  return;
287
311
  }
288
312
 
289
- const subcommand = args[0];
290
- const subArgs = args.slice(1);
291
- const jsonFlag = hasFlag(args, "--json");
292
-
293
- const cwd = process.cwd();
294
- const config = await loadConfig(cwd);
295
- const root = config.project.root;
296
- const canonicalBranch = config.project.canonicalBranch;
297
-
298
- switch (subcommand) {
299
- case "list":
300
- await handleList(root, jsonFlag);
301
- break;
302
- case "clean":
303
- await handleClean(subArgs, root, jsonFlag, canonicalBranch);
304
- break;
305
- default:
306
- throw new ValidationError(
307
- `Unknown worktree subcommand: ${subcommand ?? "(none)"}. Use: list, clean`,
308
- { field: "subcommand" },
309
- );
313
+ try {
314
+ await cmd.parseAsync(args, { from: "user" });
315
+ } catch (err: unknown) {
316
+ if (err && typeof err === "object" && "code" in err) {
317
+ const code = (err as { code: string }).code;
318
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
319
+ return;
320
+ }
321
+ if (code === "commander.unknownCommand") {
322
+ const message = err instanceof Error ? err.message : String(err);
323
+ throw new ValidationError(message, { field: "subcommand" });
324
+ }
325
+ }
326
+ throw err;
310
327
  }
311
328
  }
@@ -265,6 +265,80 @@ providers:
265
265
  expect(config.providers.anthropic).toEqual({ type: "native" });
266
266
  });
267
267
 
268
+ test("multiple providers parsed correctly", async () => {
269
+ await ensureOverstoryDir();
270
+ await writeConfig(`
271
+ providers:
272
+ anthropic:
273
+ type: native
274
+ openrouter:
275
+ type: gateway
276
+ baseUrl: https://openrouter.ai/api/v1
277
+ authTokenEnv: OPENROUTER_API_KEY
278
+ litellm:
279
+ type: gateway
280
+ baseUrl: http://localhost:4000
281
+ authTokenEnv: LITELLM_API_KEY
282
+ `);
283
+ const config = await loadConfig(tempDir);
284
+ expect(Object.keys(config.providers).length).toBe(3);
285
+ expect(config.providers.anthropic).toEqual({ type: "native" });
286
+ expect(config.providers.openrouter).toEqual({
287
+ type: "gateway",
288
+ baseUrl: "https://openrouter.ai/api/v1",
289
+ authTokenEnv: "OPENROUTER_API_KEY",
290
+ });
291
+ expect(config.providers.litellm).toEqual({
292
+ type: "gateway",
293
+ baseUrl: "http://localhost:4000",
294
+ authTokenEnv: "LITELLM_API_KEY",
295
+ });
296
+ });
297
+
298
+ test("config.local.yaml adds new provider alongside config.yaml providers", async () => {
299
+ await ensureOverstoryDir();
300
+ await writeConfig(`
301
+ providers:
302
+ openrouter:
303
+ type: gateway
304
+ baseUrl: https://openrouter.ai/api/v1
305
+ authTokenEnv: OPENROUTER_API_KEY
306
+ `);
307
+ await Bun.write(
308
+ join(tempDir, ".overstory", "config.local.yaml"),
309
+ `providers:\n litellm:\n type: gateway\n baseUrl: http://localhost:4000\n authTokenEnv: LITELLM_API_KEY\n`,
310
+ );
311
+ const config = await loadConfig(tempDir);
312
+ // All three providers present: default anthropic + openrouter from config.yaml + litellm from config.local.yaml
313
+ expect(config.providers.anthropic).toEqual({ type: "native" });
314
+ expect(config.providers.openrouter).toEqual({
315
+ type: "gateway",
316
+ baseUrl: "https://openrouter.ai/api/v1",
317
+ authTokenEnv: "OPENROUTER_API_KEY",
318
+ });
319
+ expect(config.providers.litellm).toEqual({
320
+ type: "gateway",
321
+ baseUrl: "http://localhost:4000",
322
+ authTokenEnv: "LITELLM_API_KEY",
323
+ });
324
+ });
325
+
326
+ test("simple model strings still work without providers section", async () => {
327
+ await ensureOverstoryDir();
328
+ await writeConfig(`
329
+ models:
330
+ coordinator: sonnet
331
+ builder: opus
332
+ monitor: haiku
333
+ `);
334
+ const config = await loadConfig(tempDir);
335
+ expect(config.models.coordinator).toBe("sonnet");
336
+ expect(config.models.builder).toBe("opus");
337
+ expect(config.models.monitor).toBe("haiku");
338
+ // Default anthropic provider still present even without explicit providers section
339
+ expect(config.providers.anthropic).toEqual({ type: "native" });
340
+ });
341
+
268
342
  test("migrates deprecated watchdog tier1/tier2 keys to tier0/tier1", async () => {
269
343
  await ensureOverstoryDir();
270
344
  await writeConfig(`
@@ -556,6 +630,28 @@ models:
556
630
  await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
557
631
  });
558
632
 
633
+ test("rejects model ref with deeply nested slashes when provider unknown", async () => {
634
+ await writeConfig(`
635
+ models:
636
+ coordinator: unknown/openai/gpt-5.3/latest
637
+ `);
638
+ await expect(loadConfig(tempDir)).rejects.toThrow(ValidationError);
639
+ });
640
+
641
+ test("accepts model ref with deeply nested slashes when provider exists", async () => {
642
+ await writeConfig(`
643
+ providers:
644
+ openrouter:
645
+ type: gateway
646
+ baseUrl: https://openrouter.ai/api/v1
647
+ authTokenEnv: OPENROUTER_API_KEY
648
+ models:
649
+ coordinator: openrouter/openai/gpt-5.3/variant
650
+ `);
651
+ const config = await loadConfig(tempDir);
652
+ expect(config.models.coordinator).toBe("openrouter/openai/gpt-5.3/variant");
653
+ });
654
+
559
655
  test("rejects bare invalid model name", async () => {
560
656
  await writeConfig(`
561
657
  models: