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,518 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `lib/mergers/agent-hook-claude-shape.mjs` (#1240).
|
|
3
|
+
*
|
|
4
|
+
* INTENT-BASED. Each test states the behavioral guarantee the
|
|
5
|
+
* installer or uninstaller must make. Round-trip preservation under
|
|
6
|
+
* concurrent multi-tool merges is the load-bearing safety property
|
|
7
|
+
* (Gemini's settings.json, Codex's hooks.json, and the per-tool
|
|
8
|
+
* Copilot file are all surfaces other extensions extend).
|
|
9
|
+
*
|
|
10
|
+
* HOME isolation enforced in `beforeEach` via the shared sandbox-home
|
|
11
|
+
* helper — every cohort hook config lives under `os.homedir()`, so a
|
|
12
|
+
* test that forgets the override would write into the developer's
|
|
13
|
+
* real home.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import {
|
|
19
|
+
mkdtempSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
existsSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { tmpdir, homedir } from "node:os";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
buildClaudeShapeEntry,
|
|
31
|
+
mergeClaudeShapeAgentHook,
|
|
32
|
+
unmergeClaudeShapeAgentHook,
|
|
33
|
+
} from "../../lib/mergers/agent-hook-claude-shape.mjs";
|
|
34
|
+
import {
|
|
35
|
+
AGENT_HOOK_COMMAND,
|
|
36
|
+
AGENT_HOOK_FINGERPRINT,
|
|
37
|
+
} from "../../lib/artifact-registry.mjs";
|
|
38
|
+
import { getAgentByKey } from "../../lib/agent-registry.mjs";
|
|
39
|
+
import {
|
|
40
|
+
captureHome,
|
|
41
|
+
setSandboxHome,
|
|
42
|
+
restoreHome,
|
|
43
|
+
assertHomeIsolated,
|
|
44
|
+
} from "../helpers/sandbox-home.mjs";
|
|
45
|
+
|
|
46
|
+
let sandbox;
|
|
47
|
+
let originalHomeEnv;
|
|
48
|
+
|
|
49
|
+
function setup() {
|
|
50
|
+
sandbox = mkdtempSync(join(tmpdir(), "cli-agent-hook-claude-"));
|
|
51
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
52
|
+
originalHomeEnv = captureHome();
|
|
53
|
+
setSandboxHome(join(sandbox, "home"));
|
|
54
|
+
assertHomeIsolated(tmpdir(), "agent-hook-claude-shape tests");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function teardown() {
|
|
58
|
+
restoreHome(originalHomeEnv);
|
|
59
|
+
if (sandbox) rmSync(sandbox, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
describe("buildClaudeShapeEntry", () => {
|
|
65
|
+
it("merges entryFields with the canonical command (command always wins)", () => {
|
|
66
|
+
// INTENT: a registry author who accidentally sets `command:
|
|
67
|
+
// "wrong"` in entryFields cannot override the AGENT_HOOK_COMMAND.
|
|
68
|
+
// The explicit assignment in buildClaudeShapeEntry is the gate.
|
|
69
|
+
const entry = buildClaudeShapeEntry({
|
|
70
|
+
type: "command",
|
|
71
|
+
timeout: 60000,
|
|
72
|
+
command: "this-must-be-overridden",
|
|
73
|
+
});
|
|
74
|
+
assert.equal(entry.command, AGENT_HOOK_COMMAND);
|
|
75
|
+
assert.equal(entry.type, "command");
|
|
76
|
+
assert.equal(entry.timeout, 60000);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("works with no entryFields argument (Cursor never passes any)", () => {
|
|
80
|
+
const entry = buildClaudeShapeEntry();
|
|
81
|
+
assert.deepEqual(entry, { command: AGENT_HOOK_COMMAND });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("contains AGENT_HOOK_FINGERPRINT in the command (round-trip gate)", () => {
|
|
85
|
+
// INTENT: the remover identifies SkillRepo entries by fingerprint
|
|
86
|
+
// substring. If a future refactor changes the command without
|
|
87
|
+
// preserving the fingerprint, every installed hook becomes an
|
|
88
|
+
// orphan that uninstall can't clean.
|
|
89
|
+
const entry = buildClaudeShapeEntry({ type: "command" });
|
|
90
|
+
assert.ok(
|
|
91
|
+
entry.command.includes(AGENT_HOOK_FINGERPRINT),
|
|
92
|
+
`Hook command "${entry.command}" must contain AGENT_HOOK_FINGERPRINT "${AGENT_HOOK_FINGERPRINT}"`,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("mergeClaudeShapeAgentHook (gemini)", () => {
|
|
98
|
+
beforeEach(setup);
|
|
99
|
+
afterEach(teardown);
|
|
100
|
+
|
|
101
|
+
it("first install creates the file with claude-shape JSON including matcher and name (#1242)", () => {
|
|
102
|
+
const r = mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
103
|
+
assert.equal(r.action, "installed");
|
|
104
|
+
assert.equal(r.path, "~/.gemini/settings.json");
|
|
105
|
+
|
|
106
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
107
|
+
assert.ok(existsSync(filePath));
|
|
108
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
109
|
+
assert.deepEqual(parsed, {
|
|
110
|
+
hooks: {
|
|
111
|
+
SessionStart: [
|
|
112
|
+
{
|
|
113
|
+
// Gemini-specific group field: matcher "*" required for
|
|
114
|
+
// the hook to fire.
|
|
115
|
+
matcher: "*",
|
|
116
|
+
hooks: [
|
|
117
|
+
{
|
|
118
|
+
name: "skillrepo-update",
|
|
119
|
+
type: "command",
|
|
120
|
+
timeout: 60000,
|
|
121
|
+
command: AGENT_HOOK_COMMAND,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("re-running on a fresh install returns 'unchanged' (idempotent)", () => {
|
|
131
|
+
mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
132
|
+
const r2 = mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
133
|
+
assert.equal(r2.action, "unchanged");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("preserves user-authored OTHER hook entries (multi-tool merge surface)", () => {
|
|
137
|
+
// Real-world: Gemini's settings.json is also where users put
|
|
138
|
+
// their own hook configurations, including hooks for OTHER
|
|
139
|
+
// events. The merger must not touch anything outside its own
|
|
140
|
+
// SkillRepo entry.
|
|
141
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
142
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
143
|
+
writeFileSync(
|
|
144
|
+
filePath,
|
|
145
|
+
JSON.stringify(
|
|
146
|
+
{
|
|
147
|
+
// Top-level user setting unrelated to hooks
|
|
148
|
+
theme: "dark",
|
|
149
|
+
hooks: {
|
|
150
|
+
// Existing user-authored hook on the SAME event
|
|
151
|
+
SessionStart: [
|
|
152
|
+
{
|
|
153
|
+
hooks: [
|
|
154
|
+
{ type: "command", command: "user-script.sh" },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
// User-authored hook on a DIFFERENT event
|
|
159
|
+
UserPromptSubmit: [
|
|
160
|
+
{ hooks: [{ type: "command", command: "another-script.sh" }] },
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
null,
|
|
165
|
+
2,
|
|
166
|
+
),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const r = mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
170
|
+
assert.equal(r.action, "installed");
|
|
171
|
+
|
|
172
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
173
|
+
// Top-level user setting preserved
|
|
174
|
+
assert.equal(parsed.theme, "dark");
|
|
175
|
+
// Different-event hook untouched
|
|
176
|
+
assert.deepEqual(parsed.hooks.UserPromptSubmit, [
|
|
177
|
+
{ hooks: [{ type: "command", command: "another-script.sh" }] },
|
|
178
|
+
]);
|
|
179
|
+
// SessionStart now has BOTH the user's group AND the SkillRepo group
|
|
180
|
+
assert.equal(parsed.hooks.SessionStart.length, 2);
|
|
181
|
+
assert.equal(
|
|
182
|
+
parsed.hooks.SessionStart[0].hooks[0].command,
|
|
183
|
+
"user-script.sh",
|
|
184
|
+
);
|
|
185
|
+
// SkillRepo group includes Gemini's required `matcher: "*"` (#1242)
|
|
186
|
+
assert.equal(parsed.hooks.SessionStart[1].matcher, "*");
|
|
187
|
+
assert.equal(
|
|
188
|
+
parsed.hooks.SessionStart[1].hooks[0].command,
|
|
189
|
+
AGENT_HOOK_COMMAND,
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("dedupes prior duplicate SkillRepo entries on re-install (exhaustive walk)", () => {
|
|
194
|
+
// INTENT: the symmetric installer-remover invariant. The remover
|
|
195
|
+
// strips ALL fingerprint-matching entries; the installer must
|
|
196
|
+
// also handle ALL of them. If a prior buggy run (or a manual
|
|
197
|
+
// edit) left two SkillRepo entries, re-running install must
|
|
198
|
+
// collapse them to exactly one.
|
|
199
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
200
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
201
|
+
writeFileSync(
|
|
202
|
+
filePath,
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
hooks: {
|
|
205
|
+
SessionStart: [
|
|
206
|
+
{
|
|
207
|
+
hooks: [
|
|
208
|
+
{ type: "command", timeout: 60000, command: AGENT_HOOK_COMMAND },
|
|
209
|
+
// Duplicate from a prior buggy run, in the same group
|
|
210
|
+
{ type: "command", timeout: 30000, command: AGENT_HOOK_COMMAND },
|
|
211
|
+
],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
// Another duplicate, in a different group
|
|
215
|
+
hooks: [
|
|
216
|
+
{ type: "command", command: AGENT_HOOK_COMMAND },
|
|
217
|
+
],
|
|
218
|
+
},
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const r = mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
225
|
+
assert.equal(r.action, "updated");
|
|
226
|
+
|
|
227
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
228
|
+
// Walk all groups, count fingerprint-matching entries
|
|
229
|
+
let count = 0;
|
|
230
|
+
for (const group of parsed.hooks.SessionStart) {
|
|
231
|
+
for (const h of group.hooks ?? []) {
|
|
232
|
+
if (typeof h?.command === "string" && h.command.includes("skillrepo update --silent")) {
|
|
233
|
+
count += 1;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
assert.equal(count, 1, "exactly one SkillRepo entry must remain after dedupe");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("Phase-0-era upgrade path: adds matcher to a group missing it (#1242)", () => {
|
|
241
|
+
// INTENT: a hook installed under Phase 0 has the entry but NOT
|
|
242
|
+
// the group-level `matcher: "*"` field. On the first run after
|
|
243
|
+
// the Phase 2 upgrade, the merger must detect the missing field
|
|
244
|
+
// and add it — otherwise Gemini silently filters out the group
|
|
245
|
+
// and the hook never fires post-upgrade.
|
|
246
|
+
//
|
|
247
|
+
// Construct a Phase-0-shaped file (entry has Phase 0's
|
|
248
|
+
// `{ type, timeout, command }` only, no `name`; group has no
|
|
249
|
+
// `matcher`).
|
|
250
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
251
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
252
|
+
writeFileSync(
|
|
253
|
+
filePath,
|
|
254
|
+
JSON.stringify({
|
|
255
|
+
hooks: {
|
|
256
|
+
SessionStart: [
|
|
257
|
+
{
|
|
258
|
+
// No matcher — Phase 0 era
|
|
259
|
+
hooks: [
|
|
260
|
+
{
|
|
261
|
+
type: "command",
|
|
262
|
+
timeout: 60000,
|
|
263
|
+
command: AGENT_HOOK_COMMAND,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const r = mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
273
|
+
assert.equal(r.action, "updated");
|
|
274
|
+
|
|
275
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
276
|
+
// Two-sided assertion: BOTH the group-level field add AND the
|
|
277
|
+
// entry-level replacement must happen on the upgrade. A regression
|
|
278
|
+
// that drops one side (e.g. adds matcher but doesn't update the
|
|
279
|
+
// entry to the Phase-2 shape) would otherwise pass with only one
|
|
280
|
+
// assertion. QA round-2 (#1239) flagged this gap.
|
|
281
|
+
const group = parsed.hooks.SessionStart[0];
|
|
282
|
+
const entry = group.hooks[0];
|
|
283
|
+
// Group-level: matcher added by the upgrade-path loop
|
|
284
|
+
assert.equal(group.matcher, "*");
|
|
285
|
+
// Entry-level: full Phase-2 entry shape (name is the new field)
|
|
286
|
+
assert.equal(entry.name, "skillrepo-update");
|
|
287
|
+
assert.equal(entry.type, "command");
|
|
288
|
+
assert.equal(entry.timeout, 60000);
|
|
289
|
+
assert.equal(entry.command, AGENT_HOOK_COMMAND);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("updates the SkillRepo entry in place when the timeout changes", () => {
|
|
293
|
+
// Simulate a future registry update where Gemini's timeout
|
|
294
|
+
// moves from 60000 to 120000. Re-running install should REPLACE
|
|
295
|
+
// the existing entry rather than append a duplicate.
|
|
296
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
297
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
298
|
+
writeFileSync(
|
|
299
|
+
filePath,
|
|
300
|
+
JSON.stringify({
|
|
301
|
+
hooks: {
|
|
302
|
+
SessionStart: [
|
|
303
|
+
{
|
|
304
|
+
hooks: [
|
|
305
|
+
{
|
|
306
|
+
type: "command",
|
|
307
|
+
timeout: 30000, // OLD value
|
|
308
|
+
command: AGENT_HOOK_COMMAND,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
}),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
const r = mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
318
|
+
assert.equal(r.action, "updated");
|
|
319
|
+
|
|
320
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
321
|
+
assert.equal(parsed.hooks.SessionStart.length, 1);
|
|
322
|
+
assert.equal(parsed.hooks.SessionStart[0].hooks[0].timeout, 60000);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("rejects vendors with the wrong shape", () => {
|
|
326
|
+
// Calling claude-shape merger with cursor-shape vendor is a
|
|
327
|
+
// dispatcher bug; surface it.
|
|
328
|
+
assert.throws(
|
|
329
|
+
() => mergeClaudeShapeAgentHook({ vendorKey: "cursor" }),
|
|
330
|
+
/shape "cursor-shape", expected "claude-shape"/,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("rejects unknown vendor keys", () => {
|
|
335
|
+
assert.throws(
|
|
336
|
+
() => mergeClaudeShapeAgentHook({ vendorKey: "doesnotexist" }),
|
|
337
|
+
/Unknown agent key/,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("rejects vendors whose agentHook is null (e.g. claudeCode)", () => {
|
|
342
|
+
assert.throws(
|
|
343
|
+
() => mergeClaudeShapeAgentHook({ vendorKey: "claudeCode" }),
|
|
344
|
+
/no agentHook spec/,
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("throws diskError on a corrupt existing JSON file (does not silently overwrite)", () => {
|
|
349
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
350
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
351
|
+
writeFileSync(filePath, "{not valid json");
|
|
352
|
+
assert.throws(
|
|
353
|
+
() => mergeClaudeShapeAgentHook({ vendorKey: "gemini" }),
|
|
354
|
+
/Cannot parse/,
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
describe("mergeClaudeShapeAgentHook (codex / copilot)", () => {
|
|
360
|
+
beforeEach(setup);
|
|
361
|
+
afterEach(teardown);
|
|
362
|
+
|
|
363
|
+
it("codex installer writes to ~/.codex/hooks.json with timeout: 60 seconds (#1243)", () => {
|
|
364
|
+
// Codex's timeout is in SECONDS (distinct from Gemini's
|
|
365
|
+
// milliseconds). Verified against the Codex hooks source.
|
|
366
|
+
mergeClaudeShapeAgentHook({ vendorKey: "codex" });
|
|
367
|
+
const parsed = JSON.parse(
|
|
368
|
+
readFileSync(join(homedir(), ".codex", "hooks.json"), "utf-8"),
|
|
369
|
+
);
|
|
370
|
+
const inner = parsed.hooks.SessionStart[0].hooks[0];
|
|
371
|
+
assert.equal(inner.type, "command");
|
|
372
|
+
assert.equal(inner.timeout, 60);
|
|
373
|
+
assert.equal(inner.command, AGENT_HOOK_COMMAND);
|
|
374
|
+
// No groupFields for Codex — matcher is Gemini-specific
|
|
375
|
+
assert.equal(parsed.hooks.SessionStart[0].matcher, undefined);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("copilot installer writes to ~/.copilot/hooks/skillrepo-update.json with lowercase sessionStart event (#1244)", () => {
|
|
379
|
+
// Copilot's spec uses lowercase `sessionStart` — distinct from
|
|
380
|
+
// Gemini and Codex's uppercase. Path includes a nested `hooks/`
|
|
381
|
+
// directory; the installer must mkdirp the parents.
|
|
382
|
+
mergeClaudeShapeAgentHook({ vendorKey: "copilot" });
|
|
383
|
+
const filePath = join(
|
|
384
|
+
homedir(),
|
|
385
|
+
".copilot",
|
|
386
|
+
"hooks",
|
|
387
|
+
"skillrepo-update.json",
|
|
388
|
+
);
|
|
389
|
+
assert.ok(existsSync(filePath));
|
|
390
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
391
|
+
// Lowercase event name (Phase 4 fix)
|
|
392
|
+
assert.ok(Array.isArray(parsed.hooks.sessionStart));
|
|
393
|
+
assert.equal(parsed.hooks.SessionStart, undefined);
|
|
394
|
+
const inner = parsed.hooks.sessionStart[0].hooks[0];
|
|
395
|
+
assert.equal(inner.command, AGENT_HOOK_COMMAND);
|
|
396
|
+
assert.equal(inner.type, "command");
|
|
397
|
+
assert.equal(inner.timeout, 60);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// ──────────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
describe("unmergeClaudeShapeAgentHook", () => {
|
|
404
|
+
beforeEach(setup);
|
|
405
|
+
afterEach(teardown);
|
|
406
|
+
|
|
407
|
+
it("returns 'skipped' when the file does not exist", () => {
|
|
408
|
+
const r = unmergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
409
|
+
assert.equal(r.action, "skipped");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("returns 'unchanged' when the file exists but has no SkillRepo entry", () => {
|
|
413
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
414
|
+
writeFileSync(
|
|
415
|
+
join(homedir(), ".gemini", "settings.json"),
|
|
416
|
+
JSON.stringify({ theme: "dark", hooks: {} }),
|
|
417
|
+
);
|
|
418
|
+
const r = unmergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
419
|
+
assert.equal(r.action, "unchanged");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it("removes ONLY the SkillRepo entry, preserving everything else", () => {
|
|
423
|
+
// Multi-tool surface: install SkillRepo + a user hook on the same
|
|
424
|
+
// event, then verify the remover surgically extracts ours.
|
|
425
|
+
mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
426
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
427
|
+
const before = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
428
|
+
// Insert an unrelated entry
|
|
429
|
+
before.hooks.SessionStart.unshift({
|
|
430
|
+
hooks: [{ type: "command", command: "user-keep.sh" }],
|
|
431
|
+
});
|
|
432
|
+
before.theme = "dark";
|
|
433
|
+
writeFileSync(filePath, JSON.stringify(before, null, 2));
|
|
434
|
+
|
|
435
|
+
const r = unmergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
436
|
+
assert.equal(r.action, "removed");
|
|
437
|
+
|
|
438
|
+
const after = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
439
|
+
assert.equal(after.theme, "dark");
|
|
440
|
+
assert.equal(after.hooks.SessionStart.length, 1);
|
|
441
|
+
assert.equal(
|
|
442
|
+
after.hooks.SessionStart[0].hooks[0].command,
|
|
443
|
+
"user-keep.sh",
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("dryRun returns 'would-remove' without writing", () => {
|
|
448
|
+
mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
449
|
+
const filePath = join(homedir(), ".gemini", "settings.json");
|
|
450
|
+
const before = readFileSync(filePath, "utf-8");
|
|
451
|
+
|
|
452
|
+
const r = unmergeClaudeShapeAgentHook({ vendorKey: "gemini", dryRun: true });
|
|
453
|
+
assert.equal(r.action, "would-remove");
|
|
454
|
+
|
|
455
|
+
// File on disk must be unchanged
|
|
456
|
+
assert.equal(readFileSync(filePath, "utf-8"), before);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("collapses empty containers — fully empty file becomes {} after removal", () => {
|
|
460
|
+
mergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
461
|
+
unmergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
462
|
+
const parsed = JSON.parse(
|
|
463
|
+
readFileSync(join(homedir(), ".gemini", "settings.json"), "utf-8"),
|
|
464
|
+
);
|
|
465
|
+
// hooks.SessionStart is empty → key removed; hooks empty → key removed
|
|
466
|
+
assert.deepEqual(parsed, {});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("returns 'skipped' with an error message on a corrupt file (does not throw)", () => {
|
|
470
|
+
// Uninstall must be lenient — a corrupt file should not block
|
|
471
|
+
// the rest of the uninstall pass. Same contract as
|
|
472
|
+
// removeSettingsSessionHook.
|
|
473
|
+
mkdirSync(join(homedir(), ".gemini"), { recursive: true });
|
|
474
|
+
writeFileSync(join(homedir(), ".gemini", "settings.json"), "{not valid");
|
|
475
|
+
const r = unmergeClaudeShapeAgentHook({ vendorKey: "gemini" });
|
|
476
|
+
assert.equal(r.action, "skipped");
|
|
477
|
+
assert.match(r.error, /Cannot parse/);
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ──────────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
describe("install/uninstall round trip (gemini, codex, copilot)", () => {
|
|
484
|
+
beforeEach(setup);
|
|
485
|
+
afterEach(teardown);
|
|
486
|
+
|
|
487
|
+
for (const vendorKey of ["gemini", "codex", "copilot"]) {
|
|
488
|
+
it(`${vendorKey}: install → unchanged-on-rerun → removed → unchanged-on-rerun`, () => {
|
|
489
|
+
const entry = getAgentByKey(vendorKey);
|
|
490
|
+
assert.ok(entry.agentHook, `${vendorKey} must have an agentHook spec`);
|
|
491
|
+
|
|
492
|
+
// 1. Fresh install
|
|
493
|
+
const r1 = mergeClaudeShapeAgentHook({ vendorKey });
|
|
494
|
+
assert.equal(r1.action, "installed");
|
|
495
|
+
assert.ok(existsSync(entry.agentHook.pathFn()));
|
|
496
|
+
|
|
497
|
+
// 2. Idempotent re-run
|
|
498
|
+
const r2 = mergeClaudeShapeAgentHook({ vendorKey });
|
|
499
|
+
assert.equal(r2.action, "unchanged");
|
|
500
|
+
|
|
501
|
+
// 3. Remove
|
|
502
|
+
const r3 = unmergeClaudeShapeAgentHook({ vendorKey });
|
|
503
|
+
assert.equal(r3.action, "removed");
|
|
504
|
+
|
|
505
|
+
// 4. Idempotent un-rerun
|
|
506
|
+
const r4 = unmergeClaudeShapeAgentHook({ vendorKey });
|
|
507
|
+
// After removal, the file may or may not exist depending on
|
|
508
|
+
// whether the JSON walked down to {} — both are valid
|
|
509
|
+
// post-remove states. "skipped" (file gone) and "unchanged"
|
|
510
|
+
// (file exists, no SkillRepo entry) both signal "nothing
|
|
511
|
+
// more to do."
|
|
512
|
+
assert.ok(
|
|
513
|
+
r4.action === "skipped" || r4.action === "unchanged",
|
|
514
|
+
`expected skipped|unchanged after second uninstall, got ${r4.action}`,
|
|
515
|
+
);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
});
|