@katyella/legio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. package/templates/overlay.md.tmpl +79 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Tests for legio hooks install/uninstall/status command.
3
+ *
4
+ * Uses real temp directories and real filesystem (no mocks needed).
5
+ * Each test gets an isolated temp directory with minimal .legio/
6
+ * and .claude/ scaffolding.
7
+ */
8
+
9
+ import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
12
+ import { ValidationError } from "../errors.ts";
13
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
14
+ import { hooksCommand } from "./hooks.ts";
15
+
16
+ let tempDir: string;
17
+ const originalCwd = process.cwd();
18
+
19
+ /** Orchestrator hooks content for .legio/hooks.json. */
20
+ const SAMPLE_HOOKS = {
21
+ hooks: {
22
+ SessionStart: [
23
+ {
24
+ matcher: "",
25
+ hooks: [{ type: "command", command: "legio prime --agent orchestrator" }],
26
+ },
27
+ ],
28
+ Stop: [
29
+ {
30
+ matcher: "",
31
+ hooks: [{ type: "command", command: "legio log session-end --agent orchestrator" }],
32
+ },
33
+ ],
34
+ },
35
+ };
36
+
37
+ /** Capture stdout.write output during a function call. */
38
+ async function captureStdout(fn: () => Promise<void>): Promise<string> {
39
+ const chunks: string[] = [];
40
+ const originalWrite = process.stdout.write;
41
+ process.stdout.write = ((chunk: string) => {
42
+ chunks.push(chunk);
43
+ return true;
44
+ }) as typeof process.stdout.write;
45
+ try {
46
+ await fn();
47
+ } finally {
48
+ process.stdout.write = originalWrite;
49
+ }
50
+ return chunks.join("");
51
+ }
52
+
53
+ beforeEach(async () => {
54
+ process.chdir(originalCwd);
55
+ tempDir = await realpath(await createTempGitRepo());
56
+
57
+ // Create minimal .legio/ with config.yaml
58
+ const legioDir = join(tempDir, ".legio");
59
+ await mkdir(legioDir, { recursive: true });
60
+ await writeFile(
61
+ join(legioDir, "config.yaml"),
62
+ ["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
63
+ "\n",
64
+ ),
65
+ );
66
+
67
+ process.chdir(tempDir);
68
+ });
69
+
70
+ afterEach(async () => {
71
+ process.chdir(originalCwd);
72
+ await cleanupTempDir(tempDir);
73
+ });
74
+
75
+ describe("hooksCommand help", () => {
76
+ test("--help outputs help text", async () => {
77
+ const output = await captureStdout(() => hooksCommand(["--help"]));
78
+ expect(output).toContain("legio hooks");
79
+ expect(output).toContain("install");
80
+ expect(output).toContain("uninstall");
81
+ expect(output).toContain("status");
82
+ });
83
+
84
+ test("empty args outputs help text", async () => {
85
+ const output = await captureStdout(() => hooksCommand([]));
86
+ expect(output).toContain("legio hooks");
87
+ });
88
+
89
+ test("unknown subcommand throws ValidationError", async () => {
90
+ await expect(hooksCommand(["frobnicate"])).rejects.toThrow(ValidationError);
91
+ });
92
+ });
93
+
94
+ describe("hooks install", () => {
95
+ test("installs hooks from .legio/hooks.json to .claude/settings.local.json", async () => {
96
+ // Write source hooks
97
+ await writeFile(
98
+ join(tempDir, ".legio", "hooks.json"),
99
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
100
+ );
101
+
102
+ await captureStdout(() => hooksCommand(["install"]));
103
+
104
+ // Verify target file was created
105
+ const targetPath = join(tempDir, ".claude", "settings.local.json");
106
+ const content = await readFile(targetPath, "utf-8");
107
+ const parsed = JSON.parse(content) as Record<string, unknown>;
108
+ expect(parsed.hooks).toBeDefined();
109
+ expect(content).toContain("legio prime");
110
+ });
111
+
112
+ test("preserves existing non-hooks keys in settings.local.json", async () => {
113
+ await writeFile(
114
+ join(tempDir, ".legio", "hooks.json"),
115
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
116
+ );
117
+
118
+ // Write existing settings.local.json with non-hooks content
119
+ const claudeDir = join(tempDir, ".claude");
120
+ await mkdir(claudeDir, { recursive: true });
121
+ await writeFile(
122
+ join(claudeDir, "settings.local.json"),
123
+ `${JSON.stringify({ env: { SOME_VAR: "1" } }, null, "\t")}\n`,
124
+ );
125
+
126
+ await captureStdout(() => hooksCommand(["install"]));
127
+
128
+ const content = await readFile(join(claudeDir, "settings.local.json"), "utf-8");
129
+ const parsed = JSON.parse(content) as Record<string, unknown>;
130
+ expect(parsed.hooks).toBeDefined();
131
+ expect(parsed.env).toEqual({ SOME_VAR: "1" });
132
+ });
133
+
134
+ test("warns when hooks already exist without --force", async () => {
135
+ await writeFile(
136
+ join(tempDir, ".legio", "hooks.json"),
137
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
138
+ );
139
+
140
+ const claudeDir = join(tempDir, ".claude");
141
+ await mkdir(claudeDir, { recursive: true });
142
+ await writeFile(
143
+ join(claudeDir, "settings.local.json"),
144
+ `${JSON.stringify({ hooks: { old: "hooks" } }, null, "\t")}\n`,
145
+ );
146
+
147
+ const output = await captureStdout(() => hooksCommand(["install"]));
148
+ expect(output).toContain("already present");
149
+ expect(output).toContain("--force");
150
+
151
+ // Verify hooks were NOT overwritten
152
+ const content = await readFile(join(claudeDir, "settings.local.json"), "utf-8");
153
+ expect(content).toContain("old");
154
+ });
155
+
156
+ test("--force overwrites existing hooks", async () => {
157
+ await writeFile(
158
+ join(tempDir, ".legio", "hooks.json"),
159
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
160
+ );
161
+
162
+ const claudeDir = join(tempDir, ".claude");
163
+ await mkdir(claudeDir, { recursive: true });
164
+ await writeFile(
165
+ join(claudeDir, "settings.local.json"),
166
+ `${JSON.stringify({ hooks: { old: "hooks" } }, null, "\t")}\n`,
167
+ );
168
+
169
+ await captureStdout(() => hooksCommand(["install", "--force"]));
170
+
171
+ const content = await readFile(join(claudeDir, "settings.local.json"), "utf-8");
172
+ expect(content).not.toContain("old");
173
+ expect(content).toContain("legio prime");
174
+ });
175
+
176
+ test("throws when .legio/hooks.json does not exist", async () => {
177
+ await expect(hooksCommand(["install"])).rejects.toThrow(ValidationError);
178
+ });
179
+
180
+ test("writes JSON with trailing newline", async () => {
181
+ await writeFile(
182
+ join(tempDir, ".legio", "hooks.json"),
183
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
184
+ );
185
+
186
+ await captureStdout(() => hooksCommand(["install"]));
187
+
188
+ const content = await readFile(join(tempDir, ".claude", "settings.local.json"), "utf-8");
189
+ expect(content.endsWith("\n")).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe("hooks uninstall", () => {
194
+ test("removes hooks-only settings.local.json file entirely", async () => {
195
+ const claudeDir = join(tempDir, ".claude");
196
+ await mkdir(claudeDir, { recursive: true });
197
+ await writeFile(
198
+ join(claudeDir, "settings.local.json"),
199
+ `${JSON.stringify({ hooks: { some: "hooks" } }, null, "\t")}\n`,
200
+ );
201
+
202
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
203
+ expect(output).toContain("Removed");
204
+
205
+ const exists = await access(join(claudeDir, "settings.local.json"))
206
+ .then(() => true)
207
+ .catch(() => false);
208
+ expect(exists).toBe(false);
209
+ });
210
+
211
+ test("preserves non-hooks keys when uninstalling", async () => {
212
+ const claudeDir = join(tempDir, ".claude");
213
+ await mkdir(claudeDir, { recursive: true });
214
+ await writeFile(
215
+ join(claudeDir, "settings.local.json"),
216
+ `${JSON.stringify({ hooks: { some: "hooks" }, env: { KEY: "val" } }, null, "\t")}\n`,
217
+ );
218
+
219
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
220
+ expect(output).toContain("preserved other settings");
221
+
222
+ const content = await readFile(join(claudeDir, "settings.local.json"), "utf-8");
223
+ const parsed = JSON.parse(content) as Record<string, unknown>;
224
+ expect(parsed.hooks).toBeUndefined();
225
+ expect(parsed.env).toEqual({ KEY: "val" });
226
+ });
227
+
228
+ test("handles missing settings.local.json gracefully", async () => {
229
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
230
+ expect(output).toContain("nothing to uninstall");
231
+ });
232
+
233
+ test("handles settings.local.json with no hooks key", async () => {
234
+ const claudeDir = join(tempDir, ".claude");
235
+ await mkdir(claudeDir, { recursive: true });
236
+ await writeFile(
237
+ join(claudeDir, "settings.local.json"),
238
+ `${JSON.stringify({ env: { KEY: "val" } }, null, "\t")}\n`,
239
+ );
240
+
241
+ const output = await captureStdout(() => hooksCommand(["uninstall"]));
242
+ expect(output).toContain("No hooks found");
243
+ });
244
+ });
245
+
246
+ describe("hooks status", () => {
247
+ test("reports source missing when .legio/hooks.json does not exist", async () => {
248
+ const output = await captureStdout(() => hooksCommand(["status"]));
249
+ expect(output).toContain("missing");
250
+ });
251
+
252
+ test("reports installed:false when no hooks in .claude/", async () => {
253
+ await writeFile(
254
+ join(tempDir, ".legio", "hooks.json"),
255
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
256
+ );
257
+
258
+ const output = await captureStdout(() => hooksCommand(["status"]));
259
+ expect(output).toContain("present");
260
+ expect(output).toContain("no");
261
+ expect(output).toContain("legio hooks install");
262
+ });
263
+
264
+ test("reports installed:true when hooks present in .claude/", async () => {
265
+ await writeFile(
266
+ join(tempDir, ".legio", "hooks.json"),
267
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
268
+ );
269
+
270
+ const claudeDir = join(tempDir, ".claude");
271
+ await mkdir(claudeDir, { recursive: true });
272
+ await writeFile(
273
+ join(claudeDir, "settings.local.json"),
274
+ `${JSON.stringify({ hooks: {} }, null, "\t")}\n`,
275
+ );
276
+
277
+ const output = await captureStdout(() => hooksCommand(["status"]));
278
+ expect(output).toContain("yes");
279
+ });
280
+
281
+ test("--json outputs correct fields", async () => {
282
+ await writeFile(
283
+ join(tempDir, ".legio", "hooks.json"),
284
+ `${JSON.stringify(SAMPLE_HOOKS, null, "\t")}\n`,
285
+ );
286
+
287
+ const output = await captureStdout(() => hooksCommand(["status", "--json"]));
288
+ const parsed = JSON.parse(output) as Record<string, unknown>;
289
+ expect(parsed.sourceExists).toBe(true);
290
+ expect(parsed.installed).toBe(false);
291
+ });
292
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * CLI command: legio hooks install|uninstall|status
3
+ *
4
+ * Manages orchestrator hooks in .claude/settings.local.json.
5
+ * Hooks are sourced from .legio/hooks.json (generated by legio init).
6
+ *
7
+ * This keeps the canonical hook configuration in .legio/ while placing
8
+ * a minimal copy in .claude/ only when the user explicitly opts in.
9
+ * Running `legio init` alone does NOT modify .claude/ — the user must
10
+ * run `legio hooks install` as a separate step.
11
+ */
12
+
13
+ import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { loadConfig } from "../config.ts";
16
+ import { ValidationError } from "../errors.ts";
17
+
18
+ const HOOKS_HELP = `legio hooks — Manage orchestrator hooks
19
+
20
+ Usage: legio hooks <subcommand>
21
+
22
+ Subcommands:
23
+ install Install orchestrator hooks to .claude/settings.local.json
24
+ uninstall Remove orchestrator hooks from .claude/settings.local.json
25
+ status Check if hooks are installed
26
+
27
+ Options:
28
+ --force Overwrite existing hooks in .claude/settings.local.json
29
+ --json Output as JSON
30
+ --help, -h Show this help
31
+
32
+ Hooks source: .legio/hooks.json (generated by legio init)
33
+ Hooks target: .claude/settings.local.json (read by Claude Code)`;
34
+
35
+ /**
36
+ * Check if a file exists using access().
37
+ */
38
+ async function fileExists(path: string): Promise<boolean> {
39
+ try {
40
+ await access(path);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Install orchestrator hooks from .legio/hooks.json to .claude/settings.local.json.
49
+ *
50
+ * Reads the canonical hook config from .legio/hooks.json and writes it to
51
+ * .claude/settings.local.json where Claude Code discovers it. Preserves any
52
+ * existing non-hooks keys in the target file.
53
+ */
54
+ async function installHooks(args: string[]): Promise<void> {
55
+ const force = args.includes("--force");
56
+ const cwd = process.cwd();
57
+ const config = await loadConfig(cwd);
58
+ const projectRoot = config.project.root;
59
+
60
+ // Read source hooks from .legio/hooks.json
61
+ const sourcePath = join(projectRoot, ".legio", "hooks.json");
62
+ if (!(await fileExists(sourcePath))) {
63
+ throw new ValidationError("No hooks.json found in .legio/. Run 'legio init' first.", {
64
+ field: "source",
65
+ });
66
+ }
67
+
68
+ const sourceContent = await readFile(sourcePath, "utf-8");
69
+ const sourceHooks = JSON.parse(sourceContent) as Record<string, unknown>;
70
+
71
+ // Check target .claude/settings.local.json
72
+ const targetDir = join(projectRoot, ".claude");
73
+ const targetPath = join(targetDir, "settings.local.json");
74
+
75
+ let targetConfig: Record<string, unknown> = {};
76
+ if (await fileExists(targetPath)) {
77
+ const existingContent = await readFile(targetPath, "utf-8");
78
+ const existing = JSON.parse(existingContent) as Record<string, unknown>;
79
+
80
+ if (existing.hooks && !force) {
81
+ process.stdout.write(
82
+ "Hooks already present in .claude/settings.local.json\nUse --force to overwrite.\n",
83
+ );
84
+ return;
85
+ }
86
+
87
+ // Preserve non-hooks keys (e.g., env settings)
88
+ targetConfig = existing;
89
+ }
90
+
91
+ // Merge: set hooks from source, preserve other keys
92
+ targetConfig.hooks = sourceHooks.hooks;
93
+
94
+ // Write
95
+ await mkdir(targetDir, { recursive: true });
96
+ await writeFile(targetPath, `${JSON.stringify(targetConfig, null, "\t")}\n`);
97
+
98
+ process.stdout.write("\u2713 Installed orchestrator hooks to .claude/settings.local.json\n");
99
+ process.stdout.write(" Source: .legio/hooks.json\n");
100
+ }
101
+
102
+ /**
103
+ * Remove orchestrator hooks from .claude/settings.local.json.
104
+ *
105
+ * If hooks were the only content, removes the file entirely.
106
+ * Otherwise, preserves other keys and only removes the hooks key.
107
+ */
108
+ async function uninstallHooks(_args: string[]): Promise<void> {
109
+ const cwd = process.cwd();
110
+ const config = await loadConfig(cwd);
111
+ const projectRoot = config.project.root;
112
+
113
+ const targetPath = join(projectRoot, ".claude", "settings.local.json");
114
+
115
+ if (!(await fileExists(targetPath))) {
116
+ process.stdout.write("No .claude/settings.local.json found \u2014 nothing to uninstall.\n");
117
+ return;
118
+ }
119
+
120
+ const content = await readFile(targetPath, "utf-8");
121
+ const parsed = JSON.parse(content) as Record<string, unknown>;
122
+
123
+ if (!parsed.hooks) {
124
+ process.stdout.write(
125
+ "No hooks found in .claude/settings.local.json \u2014 nothing to uninstall.\n",
126
+ );
127
+ return;
128
+ }
129
+
130
+ // Separate hooks from other settings
131
+ const { hooks: _hooks, ...rest } = parsed;
132
+
133
+ const remainingKeys = Object.keys(rest);
134
+ if (remainingKeys.length === 0) {
135
+ await unlink(targetPath);
136
+ process.stdout.write("\u2713 Removed .claude/settings.local.json (was hooks-only)\n");
137
+ } else {
138
+ await writeFile(targetPath, `${JSON.stringify(rest, null, "\t")}\n`);
139
+ process.stdout.write(
140
+ "\u2713 Removed hooks from .claude/settings.local.json (preserved other settings)\n",
141
+ );
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Show hooks installation status.
147
+ */
148
+ async function statusHooks(args: string[]): Promise<void> {
149
+ const json = args.includes("--json");
150
+ const cwd = process.cwd();
151
+ const config = await loadConfig(cwd);
152
+ const projectRoot = config.project.root;
153
+
154
+ const sourcePath = join(projectRoot, ".legio", "hooks.json");
155
+ const targetPath = join(projectRoot, ".claude", "settings.local.json");
156
+
157
+ const sourceExists = await fileExists(sourcePath);
158
+ const targetExists = await fileExists(targetPath);
159
+
160
+ let installed = false;
161
+ if (targetExists) {
162
+ const content = await readFile(targetPath, "utf-8");
163
+ const parsed = JSON.parse(content) as Record<string, unknown>;
164
+ installed = !!parsed.hooks;
165
+ }
166
+
167
+ if (json) {
168
+ process.stdout.write(`${JSON.stringify({ sourceExists, installed })}\n`);
169
+ } else {
170
+ process.stdout.write(
171
+ `Hooks source (.legio/hooks.json): ${sourceExists ? "present" : "missing"}\n`,
172
+ );
173
+ process.stdout.write(
174
+ `Hooks installed (.claude/settings.local.json): ${installed ? "yes" : "no"}\n`,
175
+ );
176
+ if (!installed && sourceExists) {
177
+ process.stdout.write(`\nRun 'legio hooks install' to install.\n`);
178
+ }
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Entry point for `legio hooks <subcommand>`.
184
+ */
185
+ export async function hooksCommand(args: string[]): Promise<void> {
186
+ if (args.includes("--help") || args.includes("-h") || args.length === 0) {
187
+ process.stdout.write(`${HOOKS_HELP}\n`);
188
+ return;
189
+ }
190
+
191
+ const subcommand = args[0];
192
+ const subArgs = args.slice(1);
193
+
194
+ switch (subcommand) {
195
+ case "install":
196
+ await installHooks(subArgs);
197
+ break;
198
+ case "uninstall":
199
+ await uninstallHooks(subArgs);
200
+ break;
201
+ case "status":
202
+ await statusHooks(subArgs);
203
+ break;
204
+ default:
205
+ throw new ValidationError(
206
+ `Unknown hooks subcommand: ${subcommand}. Run 'legio hooks --help' for usage.`,
207
+ { field: "subcommand", value: subcommand },
208
+ );
209
+ }
210
+ }