@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.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- 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
|
+
}
|