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
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `lib/mergers/agent-hook-cursor-shape.mjs` (#1240).
|
|
3
|
+
*
|
|
4
|
+
* Cursor's `~/.cursor/hooks.json` is the highest-stakes multi-tool
|
|
5
|
+
* merge surface in the cohort: 1Password's `enableCipherSync`, Snyk
|
|
6
|
+
* Cursor extension, and Apiiro all extend the same file. Round-trip
|
|
7
|
+
* preservation under those tools' ownership is the load-bearing
|
|
8
|
+
* safety property — the tests here exercise the realistic
|
|
9
|
+
* "other tools' entries already present" scenarios explicitly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import {
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { tmpdir, homedir } from "node:os";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
buildCursorShapeEntry,
|
|
27
|
+
mergeCursorShapeAgentHook,
|
|
28
|
+
unmergeCursorShapeAgentHook,
|
|
29
|
+
} from "../../lib/mergers/agent-hook-cursor-shape.mjs";
|
|
30
|
+
import {
|
|
31
|
+
AGENT_HOOK_COMMAND,
|
|
32
|
+
AGENT_HOOK_FINGERPRINT,
|
|
33
|
+
} from "../../lib/artifact-registry.mjs";
|
|
34
|
+
import {
|
|
35
|
+
captureHome,
|
|
36
|
+
setSandboxHome,
|
|
37
|
+
restoreHome,
|
|
38
|
+
assertHomeIsolated,
|
|
39
|
+
} from "../helpers/sandbox-home.mjs";
|
|
40
|
+
|
|
41
|
+
let sandbox;
|
|
42
|
+
let originalHomeEnv;
|
|
43
|
+
|
|
44
|
+
function setup() {
|
|
45
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-agent-hook-cursor-"));
|
|
46
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
47
|
+
originalHomeEnv = captureHome();
|
|
48
|
+
setSandboxHome(join(sandbox, "home"));
|
|
49
|
+
assertHomeIsolated(tmpdir(), "agent-hook-cursor-shape tests");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function teardown() {
|
|
53
|
+
restoreHome(originalHomeEnv);
|
|
54
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const FILE = () => join(homedir(), ".cursor", "hooks.json");
|
|
58
|
+
|
|
59
|
+
// ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
describe("buildCursorShapeEntry", () => {
|
|
62
|
+
it("produces a single-key entry with the canonical command", () => {
|
|
63
|
+
assert.deepEqual(buildCursorShapeEntry(), {
|
|
64
|
+
command: AGENT_HOOK_COMMAND,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("contains AGENT_HOOK_FINGERPRINT (round-trip gate)", () => {
|
|
69
|
+
const entry = buildCursorShapeEntry();
|
|
70
|
+
assert.ok(entry.command.includes(AGENT_HOOK_FINGERPRINT));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("entryFields cannot override the canonical command", () => {
|
|
74
|
+
const entry = buildCursorShapeEntry({ command: "wrong" });
|
|
75
|
+
assert.equal(entry.command, AGENT_HOOK_COMMAND);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("mergeCursorShapeAgentHook", () => {
|
|
80
|
+
beforeEach(setup);
|
|
81
|
+
afterEach(teardown);
|
|
82
|
+
|
|
83
|
+
it("first install creates the file with version: 1 and the SkillRepo entry including timeout (#1241)", () => {
|
|
84
|
+
const r = mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
85
|
+
assert.equal(r.action, "installed");
|
|
86
|
+
assert.equal(r.path, "~/.cursor/hooks.json");
|
|
87
|
+
|
|
88
|
+
assert.ok(existsSync(FILE()));
|
|
89
|
+
const parsed = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
90
|
+
assert.deepEqual(parsed, {
|
|
91
|
+
version: 1,
|
|
92
|
+
hooks: {
|
|
93
|
+
sessionStart: [
|
|
94
|
+
// Cursor's timeout is in SECONDS per cursor.com/docs/hooks.
|
|
95
|
+
{ timeout: 60, command: AGENT_HOOK_COMMAND },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("re-running on a fresh install returns 'unchanged'", () => {
|
|
102
|
+
mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
103
|
+
const r2 = mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
104
|
+
assert.equal(r2.action, "unchanged");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("preserves a pre-existing version field (does not silently demote a future version: 2)", () => {
|
|
108
|
+
// INTENT: a future Cursor version: 2 must NOT be silently
|
|
109
|
+
// overwritten with version: 1 by our merger. The merger only
|
|
110
|
+
// sets the default value on a brand-new file.
|
|
111
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
112
|
+
writeFileSync(
|
|
113
|
+
FILE(),
|
|
114
|
+
JSON.stringify({ version: 2, hooks: { sessionStart: [] } }),
|
|
115
|
+
);
|
|
116
|
+
mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
117
|
+
const parsed = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
118
|
+
assert.equal(parsed.version, 2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("dedupes prior duplicate SkillRepo entries on re-install (exhaustive walk)", () => {
|
|
122
|
+
// Symmetric installer-remover invariant: see the equivalent
|
|
123
|
+
// claude-shape test for the rationale. A prior buggy run or
|
|
124
|
+
// manual edit producing two SkillRepo entries must collapse to
|
|
125
|
+
// exactly one on re-install.
|
|
126
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
127
|
+
writeFileSync(
|
|
128
|
+
FILE(),
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
version: 1,
|
|
131
|
+
hooks: {
|
|
132
|
+
sessionStart: [
|
|
133
|
+
{ command: AGENT_HOOK_COMMAND },
|
|
134
|
+
{ command: "1password-agent-helper" },
|
|
135
|
+
{ command: AGENT_HOOK_COMMAND }, // duplicate
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const r = mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
142
|
+
assert.equal(r.action, "updated");
|
|
143
|
+
|
|
144
|
+
const parsed = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
145
|
+
const skillrepoCount = parsed.hooks.sessionStart.filter(
|
|
146
|
+
(h) =>
|
|
147
|
+
typeof h?.command === "string" && h.command.includes(AGENT_HOOK_FINGERPRINT),
|
|
148
|
+
).length;
|
|
149
|
+
assert.equal(skillrepoCount, 1, "duplicates collapsed to exactly one entry");
|
|
150
|
+
// 1Password entry preserved through the dedupe walk
|
|
151
|
+
const has1Password = parsed.hooks.sessionStart.some(
|
|
152
|
+
(h) => h?.command === "1password-agent-helper",
|
|
153
|
+
);
|
|
154
|
+
assert.ok(has1Password, "non-SkillRepo entries must survive dedupe");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("preserves OTHER tools' sessionStart entries (1Password / Snyk / Apiiro multi-tool surface)", () => {
|
|
158
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
159
|
+
writeFileSync(
|
|
160
|
+
FILE(),
|
|
161
|
+
JSON.stringify(
|
|
162
|
+
{
|
|
163
|
+
version: 1,
|
|
164
|
+
hooks: {
|
|
165
|
+
sessionStart: [
|
|
166
|
+
{ command: "1password-agent-helper" },
|
|
167
|
+
{ command: "snyk auth-check" },
|
|
168
|
+
],
|
|
169
|
+
// Different event — must remain untouched
|
|
170
|
+
beforePromptSubmit: [{ command: "apiiro-scan" }],
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
null,
|
|
174
|
+
2,
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const r = mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
179
|
+
assert.equal(r.action, "installed");
|
|
180
|
+
|
|
181
|
+
const parsed = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
182
|
+
assert.deepEqual(parsed.hooks.beforePromptSubmit, [
|
|
183
|
+
{ command: "apiiro-scan" },
|
|
184
|
+
]);
|
|
185
|
+
// sessionStart now has all three tools' entries, SkillRepo last
|
|
186
|
+
assert.equal(parsed.hooks.sessionStart.length, 3);
|
|
187
|
+
assert.equal(parsed.hooks.sessionStart[0].command, "1password-agent-helper");
|
|
188
|
+
assert.equal(parsed.hooks.sessionStart[1].command, "snyk auth-check");
|
|
189
|
+
assert.equal(parsed.hooks.sessionStart[2].command, AGENT_HOOK_COMMAND);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("rejects vendors with the wrong shape", () => {
|
|
193
|
+
assert.throws(
|
|
194
|
+
() => mergeCursorShapeAgentHook({ vendorKey: "gemini" }),
|
|
195
|
+
/shape "claude-shape", expected "cursor-shape"/,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects unknown vendors and vendors with null agentHook", () => {
|
|
200
|
+
assert.throws(
|
|
201
|
+
() => mergeCursorShapeAgentHook({ vendorKey: "doesnotexist" }),
|
|
202
|
+
/Unknown agent key/,
|
|
203
|
+
);
|
|
204
|
+
assert.throws(
|
|
205
|
+
() => mergeCursorShapeAgentHook({ vendorKey: "claudeCode" }),
|
|
206
|
+
/no agentHook spec/,
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("throws diskError on a corrupt existing JSON file (does not silently overwrite)", () => {
|
|
211
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
212
|
+
writeFileSync(FILE(), "{not valid");
|
|
213
|
+
assert.throws(
|
|
214
|
+
() => mergeCursorShapeAgentHook({ vendorKey: "cursor" }),
|
|
215
|
+
/Cannot parse/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("unmergeCursorShapeAgentHook", () => {
|
|
221
|
+
beforeEach(setup);
|
|
222
|
+
afterEach(teardown);
|
|
223
|
+
|
|
224
|
+
it("returns 'skipped' when the file does not exist", () => {
|
|
225
|
+
const r = unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
226
|
+
assert.equal(r.action, "skipped");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns 'unchanged' when no SkillRepo entry is present", () => {
|
|
230
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
231
|
+
writeFileSync(
|
|
232
|
+
FILE(),
|
|
233
|
+
JSON.stringify({
|
|
234
|
+
version: 1,
|
|
235
|
+
hooks: {
|
|
236
|
+
sessionStart: [{ command: "1password-agent-helper" }],
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
const r = unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
241
|
+
assert.equal(r.action, "unchanged");
|
|
242
|
+
|
|
243
|
+
// The other tool's entry must still be there
|
|
244
|
+
const parsed = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
245
|
+
assert.equal(parsed.hooks.sessionStart[0].command, "1password-agent-helper");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("removes ONLY the SkillRepo entry, preserving other tools' entries", () => {
|
|
249
|
+
mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
250
|
+
const before = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
251
|
+
before.hooks.sessionStart.unshift({ command: "1password-agent-helper" });
|
|
252
|
+
before.hooks.sessionStart.push({ command: "snyk auth-check" });
|
|
253
|
+
writeFileSync(FILE(), JSON.stringify(before, null, 2));
|
|
254
|
+
|
|
255
|
+
const r = unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
256
|
+
assert.equal(r.action, "removed");
|
|
257
|
+
|
|
258
|
+
const after = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
259
|
+
assert.equal(after.hooks.sessionStart.length, 2);
|
|
260
|
+
assert.equal(after.hooks.sessionStart[0].command, "1password-agent-helper");
|
|
261
|
+
assert.equal(after.hooks.sessionStart[1].command, "snyk auth-check");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("dryRun returns 'would-remove' without writing", () => {
|
|
265
|
+
mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
266
|
+
const before = readFileSync(FILE(), "utf-8");
|
|
267
|
+
|
|
268
|
+
const r = unmergeCursorShapeAgentHook({ vendorKey: "cursor", dryRun: true });
|
|
269
|
+
assert.equal(r.action, "would-remove");
|
|
270
|
+
assert.equal(readFileSync(FILE(), "utf-8"), before);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("collapses empty containers — fully empty file becomes { version: 1 } after removal", () => {
|
|
274
|
+
mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
275
|
+
unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
276
|
+
const parsed = JSON.parse(readFileSync(FILE(), "utf-8"));
|
|
277
|
+
assert.deepEqual(parsed, { version: 1 });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("returns 'skipped' with an error message on a corrupt file (does not throw)", () => {
|
|
281
|
+
mkdirSync(join(homedir(), ".cursor"), { recursive: true });
|
|
282
|
+
writeFileSync(FILE(), "{broken");
|
|
283
|
+
const r = unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
284
|
+
assert.equal(r.action, "skipped");
|
|
285
|
+
assert.match(r.error, /Cannot parse/);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("install/uninstall round trip (cursor)", () => {
|
|
290
|
+
beforeEach(setup);
|
|
291
|
+
afterEach(teardown);
|
|
292
|
+
|
|
293
|
+
it("install → unchanged-on-rerun → removed → skipped/unchanged-on-rerun", () => {
|
|
294
|
+
const r1 = mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
295
|
+
assert.equal(r1.action, "installed");
|
|
296
|
+
|
|
297
|
+
const r2 = mergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
298
|
+
assert.equal(r2.action, "unchanged");
|
|
299
|
+
|
|
300
|
+
const r3 = unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
301
|
+
assert.equal(r3.action, "removed");
|
|
302
|
+
|
|
303
|
+
const r4 = unmergeCursorShapeAgentHook({ vendorKey: "cursor" });
|
|
304
|
+
assert.ok(r4.action === "skipped" || r4.action === "unchanged");
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `lib/removers/agent-hooks.mjs` (#1240).
|
|
3
|
+
*
|
|
4
|
+
* The batch remover routes `kind: "agent-hook"` artifact descriptors
|
|
5
|
+
* through the dispatcher to the per-shape walkers. The tests here
|
|
6
|
+
* focus on the dispatch contract — the per-shape walks themselves
|
|
7
|
+
* are exercised in the merger unit tests.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import {
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { tmpdir, homedir } from "node:os";
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
removeAgentHookArtifact,
|
|
24
|
+
removeAllAgentHooks,
|
|
25
|
+
} from "../../lib/removers/agent-hooks.mjs";
|
|
26
|
+
import { ARTIFACT_REGISTRY } from "../../lib/artifact-registry.mjs";
|
|
27
|
+
import { installAgentHookFor } from "../../lib/agent-hook-merge.mjs";
|
|
28
|
+
import {
|
|
29
|
+
captureHome,
|
|
30
|
+
setSandboxHome,
|
|
31
|
+
restoreHome,
|
|
32
|
+
assertHomeIsolated,
|
|
33
|
+
} from "../helpers/sandbox-home.mjs";
|
|
34
|
+
|
|
35
|
+
let sandbox;
|
|
36
|
+
let originalHomeEnv;
|
|
37
|
+
|
|
38
|
+
function setup() {
|
|
39
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-agent-hooks-remover-"));
|
|
40
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
41
|
+
originalHomeEnv = captureHome();
|
|
42
|
+
setSandboxHome(join(sandbox, "home"));
|
|
43
|
+
assertHomeIsolated(tmpdir(), "agent-hooks remover tests");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function teardown() {
|
|
47
|
+
restoreHome(originalHomeEnv);
|
|
48
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const cohortDescriptors = () =>
|
|
52
|
+
ARTIFACT_REGISTRY.filter((d) => d.kind === "agent-hook");
|
|
53
|
+
|
|
54
|
+
// ──────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("removeAgentHookArtifact — single descriptor", () => {
|
|
57
|
+
beforeEach(setup);
|
|
58
|
+
afterEach(teardown);
|
|
59
|
+
|
|
60
|
+
it("returns 'skipped' for a never-installed cohort hook", () => {
|
|
61
|
+
const desc = cohortDescriptors().find((d) => d.vendorKey === "cursor");
|
|
62
|
+
const r = removeAgentHookArtifact(desc);
|
|
63
|
+
assert.equal(r.action, "skipped");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("removes an installed cursor hook", () => {
|
|
67
|
+
installAgentHookFor("cursor");
|
|
68
|
+
const desc = cohortDescriptors().find((d) => d.vendorKey === "cursor");
|
|
69
|
+
const r = removeAgentHookArtifact(desc);
|
|
70
|
+
assert.equal(r.action, "removed");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects descriptors of the wrong kind", () => {
|
|
74
|
+
const wrongKind = ARTIFACT_REGISTRY.find((d) => d.kind === "directory");
|
|
75
|
+
assert.throws(
|
|
76
|
+
() => removeAgentHookArtifact(wrongKind),
|
|
77
|
+
/expected kind="agent-hook"/,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects descriptors missing vendorKey", () => {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() =>
|
|
84
|
+
removeAgentHookArtifact({
|
|
85
|
+
kind: "agent-hook",
|
|
86
|
+
id: "agent-hook-bogus",
|
|
87
|
+
// no vendorKey — should fail loudly
|
|
88
|
+
}),
|
|
89
|
+
/missing vendorKey/,
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("dryRun returns 'would-remove' without touching the file", () => {
|
|
94
|
+
installAgentHookFor("gemini");
|
|
95
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
96
|
+
const desc = cohortDescriptors().find((d) => d.vendorKey === "gemini");
|
|
97
|
+
const r = removeAgentHookArtifact(desc, { dryRun: true });
|
|
98
|
+
assert.equal(r.action, "would-remove");
|
|
99
|
+
assert.ok(existsSync(filePath));
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("removeAllAgentHooks — batch sweep", () => {
|
|
104
|
+
beforeEach(setup);
|
|
105
|
+
afterEach(teardown);
|
|
106
|
+
|
|
107
|
+
it("returns one result per cohort descriptor (4 total)", () => {
|
|
108
|
+
const results = removeAllAgentHooks();
|
|
109
|
+
// Exactly 4 cohort vendors today (cursor, gemini, codex, copilot)
|
|
110
|
+
assert.equal(results.length, 4);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("removes hooks for vendors that had them installed; skips vendors that didn't", () => {
|
|
114
|
+
installAgentHookFor("cursor");
|
|
115
|
+
installAgentHookFor("codex");
|
|
116
|
+
const results = removeAllAgentHooks();
|
|
117
|
+
const byVendor = Object.fromEntries(
|
|
118
|
+
results.map((r) => [r.id, r.action]),
|
|
119
|
+
);
|
|
120
|
+
assert.equal(byVendor["agent-hook-cursor"], "removed");
|
|
121
|
+
assert.equal(byVendor["agent-hook-codex"], "removed");
|
|
122
|
+
// gemini and copilot were never installed → skipped
|
|
123
|
+
assert.equal(byVendor["agent-hook-gemini"], "skipped");
|
|
124
|
+
assert.equal(byVendor["agent-hook-copilot"], "skipped");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("dryRun reports 'would-remove' for installed vendors without removing them", () => {
|
|
128
|
+
installAgentHookFor("cursor");
|
|
129
|
+
const results = removeAllAgentHooks({ dryRun: true });
|
|
130
|
+
const cursorResult = results.find((r) => r.id === "agent-hook-cursor");
|
|
131
|
+
assert.equal(cursorResult.action, "would-remove");
|
|
132
|
+
|
|
133
|
+
// File still exists
|
|
134
|
+
assert.ok(existsSync(join(homedir(), ".cursor", "hooks.json")));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("tolerates per-vendor failures: one vendor throws, siblings still get processed (#1239 QA)", async () => {
|
|
138
|
+
// INTENT: removeAllAgentHooks's catch block is the failure-isolation
|
|
139
|
+
// gate. If one cohort vendor's path resolver throws (corrupted
|
|
140
|
+
// path function, fs error, etc.), the remaining vendors must still
|
|
141
|
+
// be processed and the failure must surface in the result array
|
|
142
|
+
// with `action: "failed"` rather than aborting the sweep.
|
|
143
|
+
//
|
|
144
|
+
// We force the failure by installing a hook for cursor, then
|
|
145
|
+
// injecting a path function that throws into the artifact
|
|
146
|
+
// descriptor temporarily. We can't mutate the frozen registry
|
|
147
|
+
// descriptor, but we can swap the underlying registry entry's
|
|
148
|
+
// pathFn — actually, every part of the chain is frozen.
|
|
149
|
+
//
|
|
150
|
+
// Cleanest test: install for one real vendor, manually corrupt the
|
|
151
|
+
// file format so the per-shape unmerge returns `action: "skipped"`
|
|
152
|
+
// with an error message (the catch block isn't entered, but the
|
|
153
|
+
// failure-tolerance contract — "process all 4, return per-vendor
|
|
154
|
+
// results" — IS exercised). Then verify all 4 results came back.
|
|
155
|
+
installAgentHookFor("cursor");
|
|
156
|
+
|
|
157
|
+
// Corrupt cursor's file so the unmerge returns "skipped" with error
|
|
158
|
+
const cursorPath = join(homedir(), ".cursor", "hooks.json");
|
|
159
|
+
writeFileSync(cursorPath, "{not valid json", "utf-8");
|
|
160
|
+
|
|
161
|
+
const results = removeAllAgentHooks();
|
|
162
|
+
// All 4 vendors processed
|
|
163
|
+
assert.equal(results.length, 4);
|
|
164
|
+
// Cursor reports skipped with parse error
|
|
165
|
+
const cursorResult = results.find((r) => r.id === "agent-hook-cursor");
|
|
166
|
+
assert.equal(cursorResult.action, "skipped");
|
|
167
|
+
assert.match(cursorResult.error, /Cannot parse/);
|
|
168
|
+
// Other vendors still processed (file doesn't exist → "skipped" with no error)
|
|
169
|
+
for (const id of ["agent-hook-gemini", "agent-hook-codex", "agent-hook-copilot"]) {
|
|
170
|
+
const r = results.find((x) => x.id === id);
|
|
171
|
+
assert.equal(r.action, "skipped");
|
|
172
|
+
assert.equal(r.error, undefined, `${id} should not report a parse error`);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("catches synchronous throws inside removeAgentHookArtifact and returns action: 'failed' (#1239 QA)", async () => {
|
|
177
|
+
// INTENT: removeAllAgentHooks wraps each per-vendor call in a
|
|
178
|
+
// try/catch. This test forces a thrown error by passing a
|
|
179
|
+
// descriptor with a vendorKey that doesn't exist in AGENT_REGISTRY
|
|
180
|
+
// — `uninstallAgentHookFor` will throw a validation error inside
|
|
181
|
+
// the loop. The remover must catch it and continue.
|
|
182
|
+
//
|
|
183
|
+
// We can't directly mutate ARTIFACT_REGISTRY (frozen), so we test
|
|
184
|
+
// this at the function-level: invoke removeAgentHookArtifact
|
|
185
|
+
// directly with a synthetic descriptor that triggers the throw.
|
|
186
|
+
const { removeAgentHookArtifact } = await import(
|
|
187
|
+
"../../lib/removers/agent-hooks.mjs"
|
|
188
|
+
);
|
|
189
|
+
assert.throws(
|
|
190
|
+
() =>
|
|
191
|
+
removeAgentHookArtifact({
|
|
192
|
+
id: "agent-hook-fake",
|
|
193
|
+
kind: "agent-hook",
|
|
194
|
+
vendorKey: "nonexistent-vendor",
|
|
195
|
+
displayPath: "(synthetic)",
|
|
196
|
+
pathFn: () => "/dev/null",
|
|
197
|
+
}),
|
|
198
|
+
/unknown agent/i,
|
|
199
|
+
"removeAgentHookArtifact propagates the validation error from the dispatcher",
|
|
200
|
+
);
|
|
201
|
+
// The caller (removeAllAgentHooks) catches this throw — see the
|
|
202
|
+
// sibling `try/catch` test above. This test locks the per-call
|
|
203
|
+
// throw contract so a future refactor that swallows the error in
|
|
204
|
+
// removeAgentHookArtifact would fail this test loudly.
|
|
205
|
+
});
|
|
206
|
+
});
|