skillrepo 4.0.0 → 4.2.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.
@@ -6,7 +6,8 @@
6
6
  * GET /api/v1/skill-content — legacy skill content
7
7
  * POST /api/v1/auth/validate — credential check (PR2)
8
8
  * GET /api/v1/library — library sync with ETag + since (PR2)
9
- * POST /api/v1/library — add to library (PR3a)
9
+ * POST /api/v1/library — multipart file-push (#1452)
10
+ * POST /api/v1/library/refs — add catalog skill to library (#1451)
10
11
  * DELETE /api/v1/library/[owner]/[name] — remove from library (PR3a)
11
12
  * GET /api/v1/skills/[owner]/[name] — single skill fetch (PR2)
12
13
  * GET /api/v1/skills/search — keyword search (PR2)
@@ -24,8 +25,9 @@
24
25
  * setEtag(etag) — what library responses include
25
26
  *
26
27
  * (PR3a additions)
27
- * setAddResponse(owner, name, resp) — per-skill response for POST /library
28
+ * setAddResponse(owner, name, resp) — per-skill response for POST /library/refs (#1451)
28
29
  * setRemoveResponse(owner, name, resp) — per-skill response for DELETE /library/<owner>/<name>
30
+ * setPushResponse(resp) — response for POST /library multipart push (#1452)
29
31
  * getLastPostBody() — inspect the most recent POST body (JSON-parsed)
30
32
  *
31
33
  * The slot APIs let unit/integration tests configure each scenario
@@ -85,6 +87,14 @@ export function createMockServer(initialPayload, options = {}) {
85
87
  let addResponses = new Map(); // key: "owner/name" or "*" → { status, body }
86
88
  let removeResponses = new Map(); // key: "owner/name" or "*" → { status, body }
87
89
 
90
+ // #1452 — POST /api/v1/library (multipart file-push) response slot.
91
+ // Tests set this with `setPushResponse({ status, body })`; when null,
92
+ // a default 201 LibraryPushResponse is served. The test server does
93
+ // not parse multipart bodies — verifying the wire format is the
94
+ // client's responsibility.
95
+ /** @type {{ status: number, body: any } | null} */
96
+ let pushResponse = null;
97
+
88
98
  // Most recent POST body (JSON-parsed) for test inspection.
89
99
  //
90
100
  // IMPORTANT:
@@ -138,19 +148,25 @@ export function createMockServer(initialPayload, options = {}) {
138
148
  // command sends a JSON body to POST /api/v1/library, so we
139
149
  // now collect chunks and re-dispatch once the stream ends.
140
150
  if (req.method === "POST" || req.method === "DELETE" || req.method === "PUT" || req.method === "PATCH") {
141
- let bodyChunks = "";
151
+ // Buffer as Buffer chunks, not string concatenation. JSON routes
152
+ // re-interpret as UTF-8; binary multipart bodies (#1452 push) keep
153
+ // their bytes intact. Concatenating `chunk.toString("utf-8")`
154
+ // would silently corrupt non-UTF-8 file content.
155
+ const chunks = [];
142
156
  req.on("data", (chunk) => {
143
- bodyChunks += chunk.toString("utf-8");
157
+ chunks.push(chunk);
144
158
  });
145
159
  req.on("end", () => {
146
- if (req.method === "POST" && bodyChunks.length > 0) {
160
+ const rawBody = Buffer.concat(chunks);
161
+ const rawBodyText = rawBody.toString("utf-8");
162
+ if (req.method === "POST" && rawBodyText.length > 0) {
147
163
  try {
148
- lastPostBody = JSON.parse(bodyChunks);
164
+ lastPostBody = JSON.parse(rawBodyText);
149
165
  } catch {
150
- lastPostBody = bodyChunks; // preserve as string for tests
166
+ lastPostBody = rawBodyText; // preserve as string for tests
151
167
  }
152
168
  }
153
- handleRequest(req, res, bodyChunks);
169
+ handleRequest(req, res, rawBodyText);
154
170
  });
155
171
  req.on("error", () => {
156
172
  // Connection dropped — nothing to do, client is gone
@@ -189,8 +205,11 @@ export function createMockServer(initialPayload, options = {}) {
189
205
  return;
190
206
  }
191
207
 
192
- // ── PR3a: POST /api/v1/library (add to library) ─────────────────
193
- if (url.pathname === "/api/v1/library" && req.method === "POST") {
208
+ // ── #1451: POST /api/v1/library/refs (add catalog skill) ───────
209
+ //
210
+ // Was at /api/v1/library until #1452 rebound that URL to multipart
211
+ // file-push. Same handler logic; only the URL moved.
212
+ if (url.pathname === "/api/v1/library/refs" && req.method === "POST") {
194
213
  if (!checkAuth(req, res)) return;
195
214
  let body;
196
215
  try {
@@ -233,6 +252,61 @@ export function createMockServer(initialPayload, options = {}) {
233
252
  return;
234
253
  }
235
254
 
255
+ // ── #1452: POST /api/v1/library (multipart file-push) ──────────
256
+ //
257
+ // Accepts multipart/form-data and returns a synthesized
258
+ // `LibraryPushResponse` shape. The handler does not actually
259
+ // parse the multipart body — it just inspects the Content-Type
260
+ // header to confirm the client sent multipart, and serves either
261
+ // a `pushResponse` slot value (set via `setPushResponse`) or a
262
+ // default 201 with `action: "created"` shape.
263
+ if (url.pathname === "/api/v1/library" && req.method === "POST") {
264
+ if (!checkAuth(req, res)) return;
265
+ const contentType = req.headers["content-type"] ?? "";
266
+ if (!contentType.includes("multipart/form-data")) {
267
+ res.writeHead(400, { "Content-Type": "application/json" });
268
+ res.end(
269
+ JSON.stringify({
270
+ error:
271
+ "Invalid multipart body. Set Content-Type: multipart/form-data.",
272
+ code: "invalid_multipart",
273
+ }),
274
+ );
275
+ return;
276
+ }
277
+ if (pushResponse) {
278
+ res.writeHead(pushResponse.status, {
279
+ "Content-Type": "application/json",
280
+ });
281
+ res.end(JSON.stringify(pushResponse.body));
282
+ return;
283
+ }
284
+ // Default: 201 with a synthesized SyncSkill response.
285
+ res.writeHead(201, {
286
+ "Content-Type": "application/json",
287
+ Location: "/api/v1/library/mock/test-skill",
288
+ });
289
+ res.end(
290
+ JSON.stringify({
291
+ action: "created",
292
+ bump: null,
293
+ skill: {
294
+ owner: "mock",
295
+ name: "test-skill",
296
+ version: "1.0",
297
+ description: "mock",
298
+ keywords: [],
299
+ updatedAt: new Date().toISOString(),
300
+ etag: '"mock/test-skill@0"',
301
+ contextSignals: null,
302
+ files: [],
303
+ filesIncomplete: false,
304
+ },
305
+ }),
306
+ );
307
+ return;
308
+ }
309
+
236
310
  // ── PR3a: DELETE /api/v1/library/[owner]/[name] (remove) ────────
237
311
  {
238
312
  const m = url.pathname.match(/^\/api\/v1\/library\/([^\/]+)\/([^\/]+)$/);
@@ -489,6 +563,14 @@ export function createMockServer(initialPayload, options = {}) {
489
563
  removeResponses = new Map();
490
564
  },
491
565
 
566
+ /**
567
+ * #1452 — register the next POST /api/v1/library (multipart file-push)
568
+ * response. Pass `null` to restore the default 201 LibraryPushResponse.
569
+ */
570
+ setPushResponse(response) {
571
+ pushResponse = response;
572
+ },
573
+
492
574
  /**
493
575
  * Return the most recent POST body the server received, or null
494
576
  * if no POST has been made yet. Body is JSON-parsed when
@@ -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
+ });
@@ -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
+ });