skillrepo 3.2.0 → 4.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 (53) hide show
  1. package/README.md +137 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Integration tests for the cohort SessionStart hook framework (#1240).
3
+ *
4
+ * Differs from the per-shape merger unit tests in that we exercise
5
+ * the FULL installer-through-uninstaller round trip across all four
6
+ * cohort vendors against a real temp filesystem, in a multi-tool
7
+ * merge surface where the agent-hooks framework is one of N writers
8
+ * extending the same JSON files.
9
+ *
10
+ * Five scenarios per vendor:
11
+ *
12
+ * 1. Fresh install → file at the right path, correct shape
13
+ * 2. Idempotent re-install → no duplicate entries, action="unchanged"
14
+ * 3. Multi-tool surface preservation → install with pre-existing
15
+ * "other tool" entries → SkillRepo entry adjacent, others intact
16
+ * 4. Round-trip via the public dispatcher API → install via
17
+ * `installAgentHookFor`, uninstall via batch remover
18
+ * 5. Repeated uninstall → idempotent (skipped/unchanged after the
19
+ * first remove)
20
+ *
21
+ * No network, no mock server, no spawned binary — pure filesystem +
22
+ * dispatcher composition.
23
+ */
24
+
25
+ import { describe, it, beforeEach, afterEach } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import {
28
+ mkdtempSync,
29
+ mkdirSync,
30
+ rmSync,
31
+ readFileSync,
32
+ writeFileSync,
33
+ existsSync,
34
+ } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { tmpdir, homedir } from "node:os";
37
+
38
+ import {
39
+ installAgentHookFor,
40
+ uninstallAgentHookFor,
41
+ } from "../../lib/agent-hook-merge.mjs";
42
+ import { removeAllAgentHooks } from "../../lib/removers/agent-hooks.mjs";
43
+ import {
44
+ AGENT_HOOK_COMMAND,
45
+ AGENT_HOOK_FINGERPRINT,
46
+ } from "../../lib/artifact-registry.mjs";
47
+ import { getAgentByKey } from "../../lib/agent-registry.mjs";
48
+ import {
49
+ captureHome,
50
+ setSandboxHome,
51
+ restoreHome,
52
+ assertHomeIsolated,
53
+ } from "../helpers/sandbox-home.mjs";
54
+
55
+ let sandbox;
56
+ let originalHomeEnv;
57
+
58
+ function setup() {
59
+ sandbox = mkdtempSync(join(tmpdir(), "cli-agent-hooks-int-"));
60
+ mkdirSync(join(sandbox, "home"), { recursive: true });
61
+ originalHomeEnv = captureHome();
62
+ setSandboxHome(join(sandbox, "home"));
63
+ assertHomeIsolated(tmpdir(), "agent-hooks integration tests");
64
+ }
65
+
66
+ function teardown() {
67
+ restoreHome(originalHomeEnv);
68
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
69
+ }
70
+
71
+ const COHORT_VENDORS = ["cursor", "gemini", "codex", "copilot"];
72
+
73
+ /**
74
+ * Per-vendor pre-existing "other tool" content. Mirrors realistic
75
+ * shapes the cohort installer has to coexist with:
76
+ * - Cursor: 1Password / Snyk / Apiiro flat sessionStart entries
77
+ * - Gemini: theme + a UserPromptSubmit hook the user wrote
78
+ * - Codex: a different pre-installed claude-shape SessionStart hook
79
+ * - Copilot: per-tool file (no merge concerns) — fresh install only
80
+ */
81
+ const PRE_EXISTING = {
82
+ cursor: {
83
+ version: 1,
84
+ hooks: {
85
+ sessionStart: [
86
+ { command: "1password-agent-helper" },
87
+ { command: "snyk auth-check" },
88
+ ],
89
+ beforePromptSubmit: [{ command: "apiiro-scan" }],
90
+ },
91
+ },
92
+ gemini: {
93
+ theme: "dark",
94
+ hooks: {
95
+ SessionStart: [
96
+ { hooks: [{ type: "command", command: "user-script.sh" }] },
97
+ ],
98
+ UserPromptSubmit: [
99
+ { hooks: [{ type: "command", command: "another.sh" }] },
100
+ ],
101
+ },
102
+ },
103
+ codex: {
104
+ hooks: {
105
+ SessionStart: [
106
+ { hooks: [{ type: "command", command: "user-codex-hook.sh" }] },
107
+ ],
108
+ },
109
+ },
110
+ copilot: null, // per-tool file — no merge concerns
111
+ };
112
+
113
+ /**
114
+ * Find the SkillRepo entry's `command` field in the parsed config,
115
+ * shape-aware. Returns null if not present.
116
+ */
117
+ function findSkillrepoCommand(parsed, vendorKey) {
118
+ const entry = getAgentByKey(vendorKey);
119
+ const eventName = entry.agentHook.eventName;
120
+ const arr = parsed?.hooks?.[eventName];
121
+ if (!Array.isArray(arr)) return null;
122
+ if (entry.agentHook.shape === "cursor-shape") {
123
+ const found = arr.find(
124
+ (h) => typeof h?.command === "string" && h.command.includes(AGENT_HOOK_FINGERPRINT),
125
+ );
126
+ return found?.command ?? null;
127
+ }
128
+ // claude-shape
129
+ for (const group of arr) {
130
+ if (!Array.isArray(group?.hooks)) continue;
131
+ for (const h of group.hooks) {
132
+ if (typeof h?.command === "string" && h.command.includes(AGENT_HOOK_FINGERPRINT)) {
133
+ return h.command;
134
+ }
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // ──────────────────────────────────────────────────────────────────
141
+
142
+ describe("agent-hooks integration: end-to-end per-vendor round trip", () => {
143
+ beforeEach(setup);
144
+ afterEach(teardown);
145
+
146
+ for (const vendorKey of COHORT_VENDORS) {
147
+ it(`${vendorKey}: 1) fresh install writes correct shape`, () => {
148
+ const r = installAgentHookFor(vendorKey);
149
+ assert.equal(r.action, "installed");
150
+
151
+ const entry = getAgentByKey(vendorKey);
152
+ const filePath = entry.agentHook.pathFn();
153
+ assert.ok(existsSync(filePath), `${vendorKey} hook file must exist`);
154
+
155
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
156
+ const cmd = findSkillrepoCommand(parsed, vendorKey);
157
+ assert.equal(
158
+ cmd,
159
+ AGENT_HOOK_COMMAND,
160
+ `${vendorKey} command must equal AGENT_HOOK_COMMAND`,
161
+ );
162
+ });
163
+
164
+ it(`${vendorKey}: 2) re-install is idempotent (action: "unchanged", no duplicates)`, () => {
165
+ installAgentHookFor(vendorKey);
166
+ const r2 = installAgentHookFor(vendorKey);
167
+ assert.equal(r2.action, "unchanged");
168
+
169
+ const entry = getAgentByKey(vendorKey);
170
+ const filePath = entry.agentHook.pathFn();
171
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
172
+
173
+ // Count SkillRepo entries — must be exactly 1
174
+ const eventName = entry.agentHook.eventName;
175
+ let count = 0;
176
+ const arr = parsed?.hooks?.[eventName] ?? [];
177
+ if (entry.agentHook.shape === "cursor-shape") {
178
+ count = arr.filter(
179
+ (h) => typeof h?.command === "string" && h.command.includes(AGENT_HOOK_FINGERPRINT),
180
+ ).length;
181
+ } else {
182
+ for (const group of arr) {
183
+ if (!Array.isArray(group?.hooks)) continue;
184
+ count += group.hooks.filter(
185
+ (h) =>
186
+ typeof h?.command === "string" &&
187
+ h.command.includes(AGENT_HOOK_FINGERPRINT),
188
+ ).length;
189
+ }
190
+ }
191
+ assert.equal(count, 1, `exactly one SkillRepo hook entry for ${vendorKey}`);
192
+ });
193
+
194
+ it(`${vendorKey}: 3) preserves pre-existing other-tool entries (multi-tool merge surface)`, () => {
195
+ const entry = getAgentByKey(vendorKey);
196
+ const filePath = entry.agentHook.pathFn();
197
+ const pre = PRE_EXISTING[vendorKey];
198
+ if (pre !== null) {
199
+ mkdirSync(join(filePath, ".."), { recursive: true });
200
+ writeFileSync(filePath, JSON.stringify(pre, null, 2));
201
+ }
202
+
203
+ installAgentHookFor(vendorKey);
204
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
205
+
206
+ if (vendorKey === "cursor") {
207
+ // Other tools' sessionStart entries preserved
208
+ assert.deepEqual(
209
+ parsed.hooks.sessionStart
210
+ .map((h) => h.command)
211
+ .filter((c) => c !== AGENT_HOOK_COMMAND),
212
+ ["1password-agent-helper", "snyk auth-check"],
213
+ );
214
+ // Different-event array untouched
215
+ assert.deepEqual(parsed.hooks.beforePromptSubmit, [
216
+ { command: "apiiro-scan" },
217
+ ]);
218
+ } else if (vendorKey === "gemini") {
219
+ // Top-level user setting preserved
220
+ assert.equal(parsed.theme, "dark");
221
+ // Different-event hook untouched
222
+ assert.equal(
223
+ parsed.hooks.UserPromptSubmit[0].hooks[0].command,
224
+ "another.sh",
225
+ );
226
+ // SessionStart has the user's group AND the SkillRepo group
227
+ const cmds = parsed.hooks.SessionStart.flatMap((g) =>
228
+ (g.hooks ?? []).map((h) => h.command),
229
+ );
230
+ assert.ok(cmds.includes("user-script.sh"));
231
+ assert.ok(cmds.includes(AGENT_HOOK_COMMAND));
232
+ } else if (vendorKey === "codex") {
233
+ const cmds = parsed.hooks.SessionStart.flatMap((g) =>
234
+ (g.hooks ?? []).map((h) => h.command),
235
+ );
236
+ assert.ok(cmds.includes("user-codex-hook.sh"));
237
+ assert.ok(cmds.includes(AGENT_HOOK_COMMAND));
238
+ } else if (vendorKey === "copilot") {
239
+ // Per-tool file: no pre-existing content. Just sanity-check
240
+ // the SkillRepo entry is present.
241
+ const cmd = findSkillrepoCommand(parsed, vendorKey);
242
+ assert.equal(cmd, AGENT_HOOK_COMMAND);
243
+ }
244
+ });
245
+
246
+ it(`${vendorKey}: 4) round-trip via batch remover removes only SkillRepo`, () => {
247
+ const entry = getAgentByKey(vendorKey);
248
+ const filePath = entry.agentHook.pathFn();
249
+ const pre = PRE_EXISTING[vendorKey];
250
+ if (pre !== null) {
251
+ mkdirSync(join(filePath, ".."), { recursive: true });
252
+ writeFileSync(filePath, JSON.stringify(pre, null, 2));
253
+ }
254
+
255
+ installAgentHookFor(vendorKey);
256
+ const removed = removeAllAgentHooks();
257
+ const myResult = removed.find((r) => r.id === `agent-hook-${vendorKey}`);
258
+ assert.equal(myResult.action, "removed");
259
+
260
+ // File still has the OTHER tools' entries (where applicable)
261
+ if (existsSync(filePath)) {
262
+ const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
263
+ const cmd = findSkillrepoCommand(parsed, vendorKey);
264
+ assert.equal(cmd, null, `SkillRepo command must be gone for ${vendorKey}`);
265
+ }
266
+ });
267
+
268
+ it(`${vendorKey}: 5) repeated uninstall is idempotent`, () => {
269
+ installAgentHookFor(vendorKey);
270
+ uninstallAgentHookFor(vendorKey);
271
+ const r2 = uninstallAgentHookFor(vendorKey);
272
+ assert.ok(
273
+ r2.action === "skipped" || r2.action === "unchanged",
274
+ `expected skipped|unchanged on second uninstall, got ${r2.action}`,
275
+ );
276
+ });
277
+ }
278
+ });
279
+
280
+ describe("agent-hooks integration: multi-vendor batch operations", () => {
281
+ beforeEach(setup);
282
+ afterEach(teardown);
283
+
284
+ it("installing all four vendors in one pass produces four files", () => {
285
+ for (const v of COHORT_VENDORS) installAgentHookFor(v);
286
+ assert.ok(existsSync(join(homedir(), ".cursor", "hooks.json")));
287
+ assert.ok(existsSync(join(homedir(), ".gemini", "settings.json")));
288
+ assert.ok(existsSync(join(homedir(), ".codex", "hooks.json")));
289
+ assert.ok(
290
+ existsSync(join(homedir(), ".copilot", "hooks", "skillrepo-update.json")),
291
+ );
292
+ });
293
+
294
+ it("removeAllAgentHooks tears down every installed cohort vendor in one pass", () => {
295
+ for (const v of COHORT_VENDORS) installAgentHookFor(v);
296
+ const results = removeAllAgentHooks();
297
+ const removedCount = results.filter((r) => r.action === "removed").length;
298
+ assert.equal(removedCount, 4);
299
+ });
300
+
301
+ it("cross-vendor state isolation: installing for one vendor does NOT touch any other vendor's file (#1239 QA)", () => {
302
+ // INTENT: a user runs `skillrepo init --agent cursor`, then later
303
+ // re-runs with `--agent gemini`. The second run must not corrupt,
304
+ // remove, or otherwise touch the file the first run wrote. The
305
+ // dispatcher's per-vendor scoping is correct today; this test
306
+ // locks the contract so a future widening of the eligible filter
307
+ // or fan-out logic doesn't silently regress it.
308
+ installAgentHookFor("cursor");
309
+ const cursorPath = join(homedir(), ".cursor", "hooks.json");
310
+ const cursorBefore = readFileSync(cursorPath, "utf-8");
311
+
312
+ // Now install Gemini only — Cursor's file must be byte-identical
313
+ // afterward.
314
+ installAgentHookFor("gemini");
315
+ assert.equal(
316
+ readFileSync(cursorPath, "utf-8"),
317
+ cursorBefore,
318
+ "Cursor file must NOT change when installing for Gemini",
319
+ );
320
+
321
+ // Inverse: install Codex now, ensure Gemini's file is also
322
+ // byte-stable.
323
+ const geminiPath = join(homedir(), ".gemini", "settings.json");
324
+ const geminiBefore = readFileSync(geminiPath, "utf-8");
325
+ installAgentHookFor("codex");
326
+ assert.equal(
327
+ readFileSync(geminiPath, "utf-8"),
328
+ geminiBefore,
329
+ "Gemini file must NOT change when installing for Codex",
330
+ );
331
+
332
+ // Uninstall one vendor; the other two must survive intact.
333
+ uninstallAgentHookFor("cursor");
334
+ assert.equal(
335
+ readFileSync(geminiPath, "utf-8"),
336
+ geminiBefore,
337
+ "Gemini file must NOT change when uninstalling Cursor",
338
+ );
339
+ });
340
+ });
@@ -108,9 +108,12 @@ describe("file-write.mjs integration — round-trip", () => {
108
108
  }
109
109
  });
110
110
 
111
- it("writes to global dir under HOME with --global", () => {
111
+ it("writes to global dir under HOME with --global --agent claudeCode", () => {
112
112
  const skill = multiFileSkill();
113
- const result = writeSkillDir(skill, { global: true });
113
+ const result = writeSkillDir(skill, {
114
+ global: true,
115
+ vendors: ["claudeCode"],
116
+ });
114
117
  assert.equal(result.written.length, 1);
115
118
  const dir = result.written[0];
116
119
  assert.ok(dir.startsWith(process.env.HOME), "should write under HOME");
@@ -190,13 +193,13 @@ describe("file-write.mjs integration — orphan recovery", () => {
190
193
 
191
194
  it("cleanupOrphans removes injected .tmp/ (with live siblings) and .old/ across all roots", () => {
192
195
  // Inject orphans in claudeProject root, claudeGlobal root, and
193
- // projectFallback root. .tmp/ entries need a live sibling so the
196
+ // agentsProject root. .tmp/ entries need a live sibling so the
194
197
  // safety invariant doesn't preserve them as recoverable.
195
198
  const claudeProjectRoot = join(process.cwd(), ".claude", "skills");
196
- const fallbackRoot = join(process.cwd(), "skills");
199
+ const agentsRoot = join(process.cwd(), ".agents", "skills");
197
200
  const globalRoot = join(process.env.HOME, ".claude", "skills");
198
201
  mkdirSync(claudeProjectRoot, { recursive: true });
199
- mkdirSync(fallbackRoot, { recursive: true });
202
+ mkdirSync(agentsRoot, { recursive: true });
200
203
  mkdirSync(globalRoot, { recursive: true });
201
204
 
202
205
  // ghost-1: live sibling + .tmp + .old (.tmp gets cleaned because live exists)
@@ -205,8 +208,8 @@ describe("file-write.mjs integration — orphan recovery", () => {
205
208
  mkdirSync(join(claudeProjectRoot, "ghost-1.old"));
206
209
  writeFileSync(join(claudeProjectRoot, "ghost-1.tmp", "garbage.txt"), "leftover");
207
210
 
208
- // ghost-2: just a .old/ in the fallback root (.old has no invariant)
209
- mkdirSync(join(fallbackRoot, "ghost-2.old"));
211
+ // ghost-2: just a .old/ in the agents cohort root (.old has no invariant)
212
+ mkdirSync(join(agentsRoot, "ghost-2.old"));
210
213
 
211
214
  // ghost-3: live sibling + .tmp in the global root
212
215
  mkdirSync(join(globalRoot, "ghost-3"));
@@ -217,7 +220,7 @@ describe("file-write.mjs integration — orphan recovery", () => {
217
220
 
218
221
  assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.tmp")));
219
222
  assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.old")));
220
- assert.ok(!existsSync(join(fallbackRoot, "ghost-2.old")));
223
+ assert.ok(!existsSync(join(agentsRoot, "ghost-2.old")));
221
224
  assert.ok(!existsSync(join(globalRoot, "ghost-3.tmp")));
222
225
  // Live siblings preserved
223
226
  assert.ok(existsSync(join(claudeProjectRoot, "ghost-1")));
@@ -272,14 +275,32 @@ describe("file-write.mjs integration — remove + .gitignore", () => {
272
275
  }
273
276
  });
274
277
 
275
- it("write to fallback creates .gitignore entry; remove does not delete it", () => {
278
+ it("write to .agents/skills/ creates .gitignore entry; remove does not delete it", () => {
276
279
  writeSkillDir(multiFileSkill(), { vendors: ["cursor"] });
277
280
  const giBefore = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
278
- assert.match(giBefore, /\/skills\//);
281
+ assert.match(giBefore, /\.agents\/skills\//);
279
282
 
280
283
  removeSkillDir("pdf-helper", { vendors: ["cursor"] });
281
284
  const giAfter = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
282
285
  // remove should not touch .gitignore
283
286
  assert.equal(giAfter, giBefore);
284
287
  });
288
+
289
+ it("cohort dedupe end-to-end: vendors=[cursor, windsurf] writes ONE skill", () => {
290
+ const skill = multiFileSkill();
291
+ const result = writeSkillDir(skill, { vendors: ["cursor", "windsurf"] });
292
+ assert.equal(
293
+ result.written.length,
294
+ 1,
295
+ "cohort vendors must collapse to a single write",
296
+ );
297
+ const dir = result.written[0];
298
+ assert.ok(
299
+ dir.includes(join(".agents", "skills", "pdf-helper")),
300
+ `expected dir under .agents/skills/, got ${dir}`,
301
+ );
302
+ for (const file of skill.files) {
303
+ assert.ok(existsSync(join(dir, file.path)));
304
+ }
305
+ });
285
306
  });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Unit tests for `lib/agent-hook-merge.mjs` (#1240).
3
+ *
4
+ * The dispatcher's job is small but load-bearing: route a vendor key
5
+ * to the right per-shape merger, and aggregate per-vendor results
6
+ * for fan-out without letting one failure abort siblings.
7
+ */
8
+
9
+ import { describe, it, beforeEach, afterEach } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import {
12
+ mkdtempSync,
13
+ mkdirSync,
14
+ rmSync,
15
+ readFileSync,
16
+ existsSync,
17
+ } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { tmpdir, homedir } from "node:os";
20
+
21
+ import {
22
+ installAgentHookFor,
23
+ uninstallAgentHookFor,
24
+ installAgentHooksForVendors,
25
+ } from "../../lib/agent-hook-merge.mjs";
26
+ import { AGENT_HOOK_COMMAND } from "../../lib/artifact-registry.mjs";
27
+ import {
28
+ captureHome,
29
+ setSandboxHome,
30
+ restoreHome,
31
+ assertHomeIsolated,
32
+ } from "../helpers/sandbox-home.mjs";
33
+
34
+ let sandbox;
35
+ let originalHomeEnv;
36
+
37
+ function setup() {
38
+ sandbox = mkdtempSync(join(tmpdir(), "cli-agent-hook-dispatch-"));
39
+ mkdirSync(join(sandbox, "home"), { recursive: true });
40
+ originalHomeEnv = captureHome();
41
+ setSandboxHome(join(sandbox, "home"));
42
+ assertHomeIsolated(tmpdir(), "agent-hook dispatcher tests");
43
+ }
44
+
45
+ function teardown() {
46
+ restoreHome(originalHomeEnv);
47
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
48
+ }
49
+
50
+ // ──────────────────────────────────────────────────────────────────
51
+
52
+ describe("installAgentHookFor — single vendor dispatch", () => {
53
+ beforeEach(setup);
54
+ afterEach(teardown);
55
+
56
+ it("routes cursor to cursor-shape merger (writes ~/.cursor/hooks.json)", () => {
57
+ const r = installAgentHookFor("cursor");
58
+ assert.equal(r.action, "installed");
59
+ assert.equal(r.path, "~/.cursor/hooks.json");
60
+ assert.equal(r.command, AGENT_HOOK_COMMAND);
61
+ assert.ok(existsSync(join(homedir(), ".cursor", "hooks.json")));
62
+ });
63
+
64
+ it("routes gemini/codex/copilot to claude-shape merger (each writes its own file)", () => {
65
+ for (const vendorKey of ["gemini", "codex", "copilot"]) {
66
+ const r = installAgentHookFor(vendorKey);
67
+ assert.equal(r.action, "installed", `expected install for ${vendorKey}`);
68
+ }
69
+ assert.ok(existsSync(join(homedir(), ".gemini", "settings.json")));
70
+ assert.ok(existsSync(join(homedir(), ".codex", "hooks.json")));
71
+ assert.ok(
72
+ existsSync(join(homedir(), ".copilot", "hooks", "skillrepo-update.json")),
73
+ );
74
+ });
75
+
76
+ it("rejects vendors with null agentHook (claudeCode, windsurf, cline)", () => {
77
+ for (const vendorKey of ["claudeCode", "windsurf", "cline"]) {
78
+ assert.throws(
79
+ () => installAgentHookFor(vendorKey),
80
+ /no agentHook spec/,
81
+ `${vendorKey} should not have an agentHook spec`,
82
+ );
83
+ }
84
+ });
85
+
86
+ it("rejects unknown vendor keys", () => {
87
+ assert.throws(() => installAgentHookFor("doesnotexist"), /unknown agent/);
88
+ });
89
+ });
90
+
91
+ describe("uninstallAgentHookFor", () => {
92
+ beforeEach(setup);
93
+ afterEach(teardown);
94
+
95
+ it("routes by shape and removes the hook (round trip with installAgentHookFor)", () => {
96
+ installAgentHookFor("gemini");
97
+ const r = uninstallAgentHookFor("gemini");
98
+ assert.equal(r.action, "removed");
99
+ });
100
+
101
+ it("dryRun returns 'would-remove' without writing", () => {
102
+ installAgentHookFor("cursor");
103
+ const filePath = join(homedir(), ".cursor", "hooks.json");
104
+ const before = readFileSync(filePath, "utf-8");
105
+ const r = uninstallAgentHookFor("cursor", { dryRun: true });
106
+ assert.equal(r.action, "would-remove");
107
+ assert.equal(readFileSync(filePath, "utf-8"), before);
108
+ });
109
+ });
110
+
111
+ describe("installAgentHooksForVendors — fan-out + failure isolation", () => {
112
+ beforeEach(setup);
113
+ afterEach(teardown);
114
+
115
+ it("installs hooks for every cohort vendor in the input list", () => {
116
+ const results = installAgentHooksForVendors({
117
+ vendors: ["cursor", "gemini", "codex", "copilot"],
118
+ });
119
+ assert.equal(results.length, 4);
120
+ for (const r of results) {
121
+ assert.equal(r.action, "installed", `expected install for ${r.vendorKey}`);
122
+ }
123
+ });
124
+
125
+ it("silently skips vendors with null agentHook (claudeCode, windsurf, cline)", () => {
126
+ // Mixed input — claudeCode + cursor in one call. The cohort
127
+ // installer must NOT install for claudeCode (it has its own
128
+ // mechanism), only for cursor.
129
+ const results = installAgentHooksForVendors({
130
+ vendors: ["claudeCode", "windsurf", "cline", "cursor"],
131
+ });
132
+ assert.equal(results.length, 1);
133
+ assert.equal(results[0].vendorKey, "cursor");
134
+ assert.equal(results[0].action, "installed");
135
+ });
136
+
137
+ it("dedupes the input list", () => {
138
+ const results = installAgentHooksForVendors({
139
+ vendors: ["gemini", "gemini", "gemini"],
140
+ });
141
+ assert.equal(results.length, 1);
142
+ });
143
+
144
+ it("surfaces unknown vendor keys as failed (does not silently skip — typos must be visible)", () => {
145
+ const results = installAgentHooksForVendors({
146
+ vendors: ["cursor", "fake-vendor"],
147
+ });
148
+ const fakeResult = results.find((r) => r.vendorKey === "fake-vendor");
149
+ assert.equal(fakeResult.action, "failed");
150
+ assert.match(fakeResult.reason, /Unknown agent key/);
151
+ // Cursor's entry still installed despite the sibling failure
152
+ const cursorResult = results.find((r) => r.vendorKey === "cursor");
153
+ assert.equal(cursorResult.action, "installed");
154
+ });
155
+
156
+ it("rejects non-array input loudly (catches dispatcher bugs at the boundary)", () => {
157
+ assert.throws(
158
+ () => installAgentHooksForVendors({ vendors: "cursor" }),
159
+ /must be an array/,
160
+ );
161
+ });
162
+
163
+ it("returns idempotent 'unchanged' when re-run after a successful install", () => {
164
+ installAgentHooksForVendors({ vendors: ["cursor", "gemini"] });
165
+ const second = installAgentHooksForVendors({
166
+ vendors: ["cursor", "gemini"],
167
+ });
168
+ for (const r of second) {
169
+ assert.equal(r.action, "unchanged");
170
+ }
171
+ });
172
+ });