@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
@@ -8,38 +8,12 @@
8
8
  */
9
9
 
10
10
  import { join } from "node:path";
11
+ import { Command } from "commander";
11
12
  import { loadConfig } from "../config.ts";
12
13
  import { GroupError, ValidationError } from "../errors.ts";
13
14
  import { createTrackerClient, resolveBackend, type TrackerClient } from "../tracker/factory.ts";
14
15
  import type { TaskGroup, TaskGroupProgress } from "../types.ts";
15
16
 
16
- /** Boolean flags that do NOT consume the next arg. */
17
- const BOOLEAN_FLAGS = new Set(["--json", "--help", "-h"]);
18
-
19
- /**
20
- * Extract positional arguments, skipping flag-value pairs.
21
- */
22
- function getPositionalArgs(args: string[]): string[] {
23
- const positional: string[] = [];
24
- let i = 0;
25
- while (i < args.length) {
26
- const arg = args[i];
27
- if (arg?.startsWith("-")) {
28
- if (BOOLEAN_FLAGS.has(arg)) {
29
- i += 1;
30
- } else {
31
- i += 2;
32
- }
33
- } else {
34
- if (arg !== undefined) {
35
- positional.push(arg);
36
- }
37
- i += 1;
38
- }
39
- }
40
- return positional;
41
- }
42
-
43
17
  /**
44
18
  * Resolve the groups.json path from the project root.
45
19
  */
@@ -322,174 +296,167 @@ function printGroupProgress(progress: TaskGroupProgress): void {
322
296
  }
323
297
  }
324
298
 
325
- const GROUP_HELP = `overstory group -- Manage task groups for batch coordination
326
-
327
- Usage: overstory group <subcommand> [args...]
328
-
329
- Subcommands:
330
- create '<name>' <id1> [id2...] Create a new task group
331
- status [group-id] Show progress for one or all groups
332
- add <group-id> <id1> [id2...] Add issues to a group
333
- remove <group-id> <id1> [id2...] Remove issues from a group
334
- list List all groups (summary)
335
-
336
- Options:
337
- --json Output as JSON
338
- --skip-validation Skip beads issue validation (for offline use)
339
- --help, -h Show this help`;
340
-
341
299
  /**
342
- * Entry point for `overstory group <subcommand>`.
300
+ * Create the Commander command for `overstory group`.
343
301
  */
344
- export async function groupCommand(args: string[]): Promise<void> {
345
- if (args.includes("--help") || args.includes("-h") || args.length === 0) {
346
- process.stdout.write(`${GROUP_HELP}\n`);
347
- return;
348
- }
349
-
350
- const subcommand = args[0];
351
- const subArgs = args.slice(1);
352
- const json = subArgs.includes("--json");
353
- const skipValidation = subArgs.includes("--skip-validation");
354
-
355
- const config = await loadConfig(process.cwd());
356
- const projectRoot = config.project.root;
357
- const resolvedBackend = await resolveBackend(config.taskTracker.backend, projectRoot);
358
- const tracker = createTrackerClient(resolvedBackend, projectRoot);
359
-
360
- switch (subcommand) {
361
- case "create": {
362
- const positional = getPositionalArgs(subArgs);
363
- const name = positional[0];
364
- if (!name || name.trim().length === 0) {
365
- throw new ValidationError(
366
- "Group name is required: overstory group create '<name>' <id1> [id2...]",
367
- { field: "name" },
302
+ export function createGroupCommand(): Command {
303
+ const cmd = new Command("group").description("Manage task groups for batch coordination");
304
+
305
+ cmd
306
+ .command("create")
307
+ .description("Create a new task group")
308
+ .argument("<name>", "Group name")
309
+ .argument("<ids...>", "Issue IDs to include")
310
+ .option("--json", "Output as JSON")
311
+ .option("--skip-validation", "Skip beads issue validation (for offline use)")
312
+ .action(
313
+ async (name: string, ids: string[], opts: { json?: boolean; skipValidation?: boolean }) => {
314
+ const config = await loadConfig(process.cwd());
315
+ const projectRoot = config.project.root;
316
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, projectRoot);
317
+ const tracker = createTrackerClient(resolvedBackend, projectRoot);
318
+
319
+ const group = await createGroup(
320
+ projectRoot,
321
+ name,
322
+ ids,
323
+ opts.skipValidation ?? false,
324
+ tracker,
368
325
  );
369
- }
370
- const issueIds = positional.slice(1);
371
- if (issueIds.length === 0) {
372
- throw new ValidationError(
373
- "At least one issue ID is required: overstory group create '<name>' <id1> [id2...]",
374
- { field: "issueIds" },
375
- );
376
- }
377
- const group = await createGroup(projectRoot, name, issueIds, skipValidation, tracker);
378
- if (json) {
379
- process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
380
- } else {
381
- process.stdout.write(`Created group "${group.name}" (${group.id})\n`);
382
- process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
383
- }
384
- break;
385
- }
386
-
387
- case "status": {
388
- const positional = getPositionalArgs(subArgs);
389
- const groupId = positional[0];
390
- const groups = await loadGroups(projectRoot);
391
-
392
- if (groupId) {
393
- const group = groups.find((g) => g.id === groupId);
394
- if (!group) {
395
- throw new GroupError(`Group "${groupId}" not found`, { groupId });
396
- }
397
- const progress = await getGroupProgress(projectRoot, group, groups, tracker);
398
- if (json) {
399
- process.stdout.write(`${JSON.stringify(progress, null, "\t")}\n`);
326
+ if (opts.json) {
327
+ process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
400
328
  } else {
401
- printGroupProgress(progress);
329
+ process.stdout.write(`Created group "${group.name}" (${group.id})\n`);
330
+ process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
402
331
  }
403
- } else {
404
- const activeGroups = groups.filter((g) => g.status === "active");
405
- if (activeGroups.length === 0) {
332
+ },
333
+ );
334
+
335
+ cmd
336
+ .command("status")
337
+ .description("Show progress for one or all groups")
338
+ .argument("[group-id]", "Group ID (optional, shows all if omitted)")
339
+ .option("--json", "Output as JSON")
340
+ .option("--skip-validation", "Skip beads issue validation (for offline use)")
341
+ .action(
342
+ async (groupId: string | undefined, opts: { json?: boolean; skipValidation?: boolean }) => {
343
+ const config = await loadConfig(process.cwd());
344
+ const projectRoot = config.project.root;
345
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, projectRoot);
346
+ const tracker = createTrackerClient(resolvedBackend, projectRoot);
347
+ const json = opts.json ?? false;
348
+
349
+ const groups = await loadGroups(projectRoot);
350
+
351
+ if (groupId) {
352
+ const group = groups.find((g) => g.id === groupId);
353
+ if (!group) {
354
+ throw new GroupError(`Group "${groupId}" not found`, { groupId });
355
+ }
356
+ const progress = await getGroupProgress(projectRoot, group, groups, tracker);
406
357
  if (json) {
407
- process.stdout.write("[]\n");
358
+ process.stdout.write(`${JSON.stringify(progress, null, "\t")}\n`);
408
359
  } else {
409
- process.stdout.write("No active groups\n");
360
+ printGroupProgress(progress);
410
361
  }
411
- break;
412
- }
413
- const progressList: TaskGroupProgress[] = [];
414
- for (const group of activeGroups) {
415
- const progress = await getGroupProgress(projectRoot, group, groups, tracker);
416
- progressList.push(progress);
417
- }
418
- if (json) {
419
- process.stdout.write(`${JSON.stringify(progressList, null, "\t")}\n`);
420
362
  } else {
421
- for (const progress of progressList) {
422
- printGroupProgress(progress);
423
- process.stdout.write("\n");
363
+ const activeGroups = groups.filter((g) => g.status === "active");
364
+ if (activeGroups.length === 0) {
365
+ if (json) {
366
+ process.stdout.write("[]\n");
367
+ } else {
368
+ process.stdout.write("No active groups\n");
369
+ }
370
+ return;
371
+ }
372
+ const progressList: TaskGroupProgress[] = [];
373
+ for (const group of activeGroups) {
374
+ const progress = await getGroupProgress(projectRoot, group, groups, tracker);
375
+ progressList.push(progress);
376
+ }
377
+ if (json) {
378
+ process.stdout.write(`${JSON.stringify(progressList, null, "\t")}\n`);
379
+ } else {
380
+ for (const progress of progressList) {
381
+ printGroupProgress(progress);
382
+ process.stdout.write("\n");
383
+ }
424
384
  }
425
385
  }
426
- }
427
- break;
428
- }
429
-
430
- case "add": {
431
- const positional = getPositionalArgs(subArgs);
432
- const groupId = positional[0];
433
- if (!groupId || groupId.trim().length === 0) {
434
- throw new ValidationError(
435
- "Group ID is required: overstory group add <group-id> <id1> [id2...]",
436
- { field: "groupId" },
386
+ },
387
+ );
388
+
389
+ cmd
390
+ .command("add")
391
+ .description("Add issues to a group")
392
+ .argument("<group-id>", "Group ID")
393
+ .argument("<ids...>", "Issue IDs to add")
394
+ .option("--json", "Output as JSON")
395
+ .option("--skip-validation", "Skip beads issue validation (for offline use)")
396
+ .action(
397
+ async (
398
+ groupId: string,
399
+ ids: string[],
400
+ opts: { json?: boolean; skipValidation?: boolean },
401
+ ) => {
402
+ const config = await loadConfig(process.cwd());
403
+ const projectRoot = config.project.root;
404
+ const resolvedBackend = await resolveBackend(config.taskTracker.backend, projectRoot);
405
+ const tracker = createTrackerClient(resolvedBackend, projectRoot);
406
+
407
+ const group = await addToGroup(
408
+ projectRoot,
409
+ groupId,
410
+ ids,
411
+ opts.skipValidation ?? false,
412
+ tracker,
437
413
  );
438
- }
439
- const issueIds = positional.slice(1);
440
- if (issueIds.length === 0) {
441
- throw new ValidationError(
442
- "At least one issue ID is required: overstory group add <group-id> <id1> [id2...]",
443
- { field: "issueIds" },
444
- );
445
- }
446
- const group = await addToGroup(projectRoot, groupId, issueIds, skipValidation, tracker);
447
- if (json) {
414
+ if (opts.json) {
415
+ process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
416
+ } else {
417
+ process.stdout.write(`Added ${ids.length} issue(s) to "${group.name}"\n`);
418
+ process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
419
+ }
420
+ },
421
+ );
422
+
423
+ cmd
424
+ .command("remove")
425
+ .description("Remove issues from a group")
426
+ .argument("<group-id>", "Group ID")
427
+ .argument("<ids...>", "Issue IDs to remove")
428
+ .option("--json", "Output as JSON")
429
+ .action(async (groupId: string, ids: string[], opts: { json?: boolean }) => {
430
+ const config = await loadConfig(process.cwd());
431
+ const projectRoot = config.project.root;
432
+
433
+ const group = await removeFromGroup(projectRoot, groupId, ids);
434
+ if (opts.json) {
448
435
  process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
449
436
  } else {
450
- process.stdout.write(`Added ${issueIds.length} issue(s) to "${group.name}"\n`);
437
+ process.stdout.write(`Removed ${ids.length} issue(s) from "${group.name}"\n`);
451
438
  process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
452
439
  }
453
- break;
454
- }
440
+ });
455
441
 
456
- case "remove": {
457
- const positional = getPositionalArgs(subArgs);
458
- const groupId = positional[0];
459
- if (!groupId || groupId.trim().length === 0) {
460
- throw new ValidationError(
461
- "Group ID is required: overstory group remove <group-id> <id1> [id2...]",
462
- { field: "groupId" },
463
- );
464
- }
465
- const issueIds = positional.slice(1);
466
- if (issueIds.length === 0) {
467
- throw new ValidationError(
468
- "At least one issue ID is required: overstory group remove <group-id> <id1> [id2...]",
469
- { field: "issueIds" },
470
- );
471
- }
472
- const group = await removeFromGroup(projectRoot, groupId, issueIds);
473
- if (json) {
474
- process.stdout.write(`${JSON.stringify(group, null, "\t")}\n`);
475
- } else {
476
- process.stdout.write(`Removed ${issueIds.length} issue(s) from "${group.name}"\n`);
477
- process.stdout.write(` Members: ${group.memberIssueIds.join(", ")}\n`);
478
- }
479
- break;
480
- }
442
+ cmd
443
+ .command("list")
444
+ .description("List all groups (summary)")
445
+ .option("--json", "Output as JSON")
446
+ .action(async (opts: { json?: boolean }) => {
447
+ const config = await loadConfig(process.cwd());
448
+ const projectRoot = config.project.root;
481
449
 
482
- case "list": {
483
450
  const groups = await loadGroups(projectRoot);
484
451
  if (groups.length === 0) {
485
- if (json) {
452
+ if (opts.json) {
486
453
  process.stdout.write("[]\n");
487
454
  } else {
488
455
  process.stdout.write("No groups\n");
489
456
  }
490
- break;
457
+ return;
491
458
  }
492
- if (json) {
459
+ if (opts.json) {
493
460
  process.stdout.write(`${JSON.stringify(groups, null, "\t")}\n`);
494
461
  } else {
495
462
  for (const group of groups) {
@@ -499,13 +466,36 @@ export async function groupCommand(args: string[]): Promise<void> {
499
466
  );
500
467
  }
501
468
  }
502
- break;
503
- }
469
+ });
504
470
 
505
- default:
506
- throw new ValidationError(
507
- `Unknown group subcommand: ${subcommand}. Run 'overstory group --help' for usage.`,
508
- { field: "subcommand", value: subcommand },
509
- );
471
+ return cmd;
472
+ }
473
+
474
+ /**
475
+ * Entry point for `overstory group <subcommand>`.
476
+ */
477
+ export async function groupCommand(args: string[]): Promise<void> {
478
+ const cmd = createGroupCommand();
479
+ cmd.exitOverride();
480
+
481
+ if (args.length === 0) {
482
+ process.stdout.write(cmd.helpInformation());
483
+ return;
484
+ }
485
+
486
+ try {
487
+ await cmd.parseAsync(args, { from: "user" });
488
+ } catch (err: unknown) {
489
+ if (err && typeof err === "object" && "code" in err) {
490
+ const code = (err as { code: string }).code;
491
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
492
+ return;
493
+ }
494
+ if (code === "commander.unknownCommand") {
495
+ const message = err instanceof Error ? err.message : String(err);
496
+ throw new ValidationError(message, { field: "subcommand" });
497
+ }
498
+ }
499
+ throw err;
510
500
  }
511
501
  }
@@ -75,7 +75,7 @@ afterEach(async () => {
75
75
  describe("hooksCommand help", () => {
76
76
  test("--help outputs help text", async () => {
77
77
  const output = await captureStdout(() => hooksCommand(["--help"]));
78
- expect(output).toContain("overstory hooks");
78
+ expect(output).toContain("hooks");
79
79
  expect(output).toContain("install");
80
80
  expect(output).toContain("uninstall");
81
81
  expect(output).toContain("status");
@@ -83,7 +83,7 @@ describe("hooksCommand help", () => {
83
83
 
84
84
  test("empty args outputs help text", async () => {
85
85
  const output = await captureStdout(() => hooksCommand([]));
86
- expect(output).toContain("overstory hooks");
86
+ expect(output).toContain("hooks");
87
87
  });
88
88
 
89
89
  test("unknown subcommand throws ValidationError", async () => {
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { mkdir, unlink } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
+ import { Command } from "commander";
15
16
  import { loadConfig } from "../config.ts";
16
17
  import { ValidationError } from "../errors.ts";
17
18
 
@@ -63,23 +64,6 @@ export function mergeHooksByEventType(
63
64
  return merged;
64
65
  }
65
66
 
66
- const HOOKS_HELP = `overstory hooks — Manage orchestrator hooks
67
-
68
- Usage: overstory hooks <subcommand>
69
-
70
- Subcommands:
71
- install Install orchestrator hooks to .claude/settings.local.json
72
- uninstall Remove orchestrator hooks from .claude/settings.local.json
73
- status Check if hooks are installed
74
-
75
- Options:
76
- --force Overwrite existing hooks in .claude/settings.local.json
77
- --json Output as JSON
78
- --help, -h Show this help
79
-
80
- Hooks source: .overstory/hooks.json (generated by overstory init)
81
- Hooks target: .claude/settings.local.json (read by Claude Code)`;
82
-
83
67
  /**
84
68
  * Install orchestrator hooks from .overstory/hooks.json to .claude/settings.local.json.
85
69
  *
@@ -87,8 +71,7 @@ Hooks target: .claude/settings.local.json (read by Claude Code)`;
87
71
  * .claude/settings.local.json where Claude Code discovers it. Preserves any
88
72
  * existing non-hooks keys in the target file.
89
73
  */
90
- async function installHooks(args: string[]): Promise<void> {
91
- const force = args.includes("--force");
74
+ async function installHooks(force: boolean): Promise<void> {
92
75
  const cwd = process.cwd();
93
76
  const config = await loadConfig(cwd);
94
77
  const projectRoot = config.project.root;
@@ -147,7 +130,7 @@ async function installHooks(args: string[]): Promise<void> {
147
130
  * If hooks were the only content, removes the file entirely.
148
131
  * Otherwise, preserves other keys and only removes the hooks key.
149
132
  */
150
- async function uninstallHooks(_args: string[]): Promise<void> {
133
+ async function uninstallHooks(): Promise<void> {
151
134
  const cwd = process.cwd();
152
135
  const config = await loadConfig(cwd);
153
136
  const projectRoot = config.project.root;
@@ -188,8 +171,7 @@ async function uninstallHooks(_args: string[]): Promise<void> {
188
171
  /**
189
172
  * Show hooks installation status.
190
173
  */
191
- async function statusHooks(args: string[]): Promise<void> {
192
- const json = args.includes("--json");
174
+ async function statusHooks(json: boolean): Promise<void> {
193
175
  const cwd = process.cwd();
194
176
  const config = await loadConfig(cwd);
195
177
  const projectRoot = config.project.root;
@@ -222,32 +204,60 @@ async function statusHooks(args: string[]): Promise<void> {
222
204
  }
223
205
  }
224
206
 
207
+ export function createHooksCommand(): Command {
208
+ const cmd = new Command("hooks").description("Manage orchestrator hooks");
209
+
210
+ cmd
211
+ .command("install")
212
+ .description("Install orchestrator hooks to .claude/settings.local.json")
213
+ .option("--force", "Overwrite existing hooks")
214
+ .action(async (opts: { force?: boolean }) => {
215
+ await installHooks(opts.force ?? false);
216
+ });
217
+
218
+ cmd
219
+ .command("uninstall")
220
+ .description("Remove orchestrator hooks from .claude/settings.local.json")
221
+ .action(async () => {
222
+ await uninstallHooks();
223
+ });
224
+
225
+ cmd
226
+ .command("status")
227
+ .description("Check if hooks are installed")
228
+ .option("--json", "Output as JSON")
229
+ .action(async (opts: { json?: boolean }) => {
230
+ await statusHooks(opts.json ?? false);
231
+ });
232
+
233
+ return cmd;
234
+ }
235
+
225
236
  /**
226
237
  * Entry point for `overstory hooks <subcommand>`.
227
238
  */
228
239
  export async function hooksCommand(args: string[]): Promise<void> {
229
- if (args.includes("--help") || args.includes("-h") || args.length === 0) {
230
- process.stdout.write(`${HOOKS_HELP}\n`);
240
+ const cmd = createHooksCommand();
241
+ cmd.exitOverride();
242
+
243
+ if (args.length === 0) {
244
+ process.stdout.write(cmd.helpInformation());
231
245
  return;
232
246
  }
233
247
 
234
- const subcommand = args[0];
235
- const subArgs = args.slice(1);
236
-
237
- switch (subcommand) {
238
- case "install":
239
- await installHooks(subArgs);
240
- break;
241
- case "uninstall":
242
- await uninstallHooks(subArgs);
243
- break;
244
- case "status":
245
- await statusHooks(subArgs);
246
- break;
247
- default:
248
- throw new ValidationError(
249
- `Unknown hooks subcommand: ${subcommand}. Run 'overstory hooks --help' for usage.`,
250
- { field: "subcommand", value: subcommand },
251
- );
248
+ try {
249
+ await cmd.parseAsync(args, { from: "user" });
250
+ } catch (err: unknown) {
251
+ if (err && typeof err === "object" && "code" in err) {
252
+ const code = (err as { code: string }).code;
253
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
254
+ return;
255
+ }
256
+ if (code === "commander.unknownCommand") {
257
+ const message = err instanceof Error ? err.message : String(err);
258
+ throw new ValidationError(message, { field: "subcommand" });
259
+ }
260
+ }
261
+ throw err;
252
262
  }
253
263
  }