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.
- package/README.md +49 -2
- package/bin/skillrepo.mjs +8 -0
- package/package.json +10 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init.mjs +45 -6
- package/src/commands/push.mjs +187 -0
- package/src/commands/uninstall.mjs +12 -1
- package/src/commands/update.mjs +97 -16
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +186 -2
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/http.mjs +169 -11
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/skill-walk.mjs +97 -0
- package/src/test/commands/init.test.mjs +281 -0
- package/src/test/commands/push.test.mjs +289 -0
- package/src/test/commands/update.test.mjs +135 -0
- package/src/test/dispatcher.test.mjs +10 -2
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/mock-server.mjs +92 -10
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/http.test.mjs +242 -1
- package/src/test/lib/skill-walk.test.mjs +127 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
|
@@ -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 —
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
chunks.push(chunk);
|
|
144
158
|
});
|
|
145
159
|
req.on("end", () => {
|
|
146
|
-
|
|
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(
|
|
164
|
+
lastPostBody = JSON.parse(rawBodyText);
|
|
149
165
|
} catch {
|
|
150
|
-
lastPostBody =
|
|
166
|
+
lastPostBody = rawBodyText; // preserve as string for tests
|
|
151
167
|
}
|
|
152
168
|
}
|
|
153
|
-
handleRequest(req, res,
|
|
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
|
-
// ──
|
|
193
|
-
|
|
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
|
+
});
|