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.
- package/README.md +137 -27
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- 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/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/sync.test.mjs +157 -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
- package/src/lib/detect-ides.mjs +0 -44
- 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, {
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
209
|
-
mkdirSync(join(
|
|
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(
|
|
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
|
|
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,
|
|
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
|
+
});
|