skillrepo 3.0.0 → 3.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.
Files changed (32) hide show
  1. package/README.md +72 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +132 -14
  5. package/src/commands/remove.mjs +8 -13
  6. package/src/commands/session-sync.mjs +152 -0
  7. package/src/commands/uninstall.mjs +484 -0
  8. package/src/commands/update.mjs +125 -8
  9. package/src/lib/artifact-registry.mjs +265 -0
  10. package/src/lib/fs-utils.mjs +83 -1
  11. package/src/lib/mergers/session-hook.mjs +298 -0
  12. package/src/lib/paths.mjs +21 -0
  13. package/src/lib/removers/claude-mcp.mjs +67 -0
  14. package/src/lib/removers/cursor-mcp.mjs +60 -0
  15. package/src/lib/removers/env-local.mjs +55 -0
  16. package/src/lib/removers/gitignore.mjs +108 -0
  17. package/src/lib/removers/settings.mjs +183 -0
  18. package/src/lib/removers/vscode-mcp.mjs +87 -0
  19. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  20. package/src/test/commands/init.test.mjs +211 -0
  21. package/src/test/commands/session-sync.test.mjs +350 -0
  22. package/src/test/commands/uninstall.test.mjs +768 -0
  23. package/src/test/commands/update.test.mjs +158 -0
  24. package/src/test/lib/artifact-registry.test.mjs +268 -0
  25. package/src/test/mergers/session-hook.test.mjs +745 -0
  26. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  27. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  28. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  29. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  30. package/src/test/mergers/uninstall-settings.test.mjs +285 -0
  31. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  32. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +122 -0
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Unit tests for src/lib/mergers/session-hook.mjs (#884).
3
+ *
4
+ * INTENT-BASED. Each test states the behavioral guarantee the
5
+ * installer must make. The integration-level round-trip proof
6
+ * (installer writes → #885 remover strips) lives in the
7
+ * `"round-trip with remover"` suite at the bottom — that's the
8
+ * architect H2 tightening from the #885 review cycle, closed in
9
+ * the same PR that introduces the installer.
10
+ *
11
+ * HOME isolation is enforced in `beforeEach` via the same pattern
12
+ * as `uninstall.test.mjs` — the `--global` path writes to
13
+ * `~/.claude/settings.local.json`, so a test that forgets the HOME
14
+ * override would write into the developer's real directory.
15
+ */
16
+
17
+ import { describe, it, beforeEach, afterEach } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import {
20
+ mkdtempSync,
21
+ mkdirSync,
22
+ rmSync,
23
+ readFileSync,
24
+ writeFileSync,
25
+ existsSync,
26
+ } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { tmpdir } from "node:os";
29
+
30
+ import {
31
+ mergeSessionHook,
32
+ removeSessionHook,
33
+ buildHookCommand,
34
+ } from "../../lib/mergers/session-hook.mjs";
35
+ import { removeSettingsSessionHook } from "../../lib/removers/settings.mjs";
36
+ import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
37
+
38
+ let sandbox;
39
+ let originalCwd;
40
+ let originalHome;
41
+ const FAKE_BINARY = "/usr/local/bin/skillrepo";
42
+
43
+ function ASSERT_HOME_ISOLATED() {
44
+ assert.ok(
45
+ process.env.HOME && process.env.HOME.startsWith(tmpdir()),
46
+ `HOME must point inside tmpdir during session-hook tests. ` +
47
+ `Current HOME="${process.env.HOME}" — setup() forgot the override.`,
48
+ );
49
+ }
50
+
51
+ function setup() {
52
+ sandbox = mkdtempSync(join(tmpdir(), "cli-session-hook-"));
53
+ originalCwd = process.cwd();
54
+ originalHome = process.env.HOME;
55
+ mkdirSync(join(sandbox, "project"), { recursive: true });
56
+ mkdirSync(join(sandbox, "home"), { recursive: true });
57
+ process.chdir(join(sandbox, "project"));
58
+ process.env.HOME = join(sandbox, "home");
59
+ ASSERT_HOME_ISOLATED();
60
+ }
61
+
62
+ function teardown() {
63
+ process.chdir(originalCwd);
64
+ process.env.HOME = originalHome;
65
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
+ }
67
+
68
+ // ──────────────────────────────────────────────────────────────────
69
+
70
+ describe("buildHookCommand", () => {
71
+ it("produces the exact command shape Claude Code expects", () => {
72
+ // INTENT: the shape is load-bearing in three ways (per the
73
+ // installer's docstring): absolute path, --session-hook flag,
74
+ // `|| true` backstop. A refactor that drops any of the three
75
+ // must fail this test loudly.
76
+ const cmd = buildHookCommand("/usr/local/bin/skillrepo");
77
+ assert.equal(
78
+ cmd,
79
+ "/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
80
+ );
81
+ });
82
+
83
+ it("contains the SESSION_HOOK_FINGERPRINT substring (install/remove round-trip gate)", () => {
84
+ // INTENT: the remover identifies SkillRepo hooks by substring
85
+ // match on SESSION_HOOK_FINGERPRINT. If a future refactor
86
+ // changes the command shape to omit this exact substring, every
87
+ // installed hook becomes an orphan that `skillrepo uninstall`
88
+ // can't clean. This one-line test locks the contract.
89
+ const cmd = buildHookCommand(FAKE_BINARY);
90
+ assert.ok(
91
+ cmd.includes(SESSION_HOOK_FINGERPRINT),
92
+ `Hook command must contain "${SESSION_HOOK_FINGERPRINT}" so the ` +
93
+ `remover in src/lib/removers/settings.mjs can identify it.`,
94
+ );
95
+ });
96
+
97
+ it("rejects empty or non-string binary paths", () => {
98
+ // INTENT: no silent production of a malformed command. The
99
+ // installer upstream should never pass null/empty, but defensive
100
+ // validation here prevents the surprise where a dangling null
101
+ // shows up in the settings file.
102
+ assert.throws(() => buildHookCommand(""), /non-empty string/);
103
+ assert.throws(() => buildHookCommand(undefined), /non-empty string/);
104
+ assert.throws(() => buildHookCommand(null), /non-empty string/);
105
+ });
106
+ });
107
+
108
+ describe("mergeSessionHook — install fresh", () => {
109
+ beforeEach(setup);
110
+ afterEach(teardown);
111
+
112
+ it("creates settings.local.json with the hook when the file does not exist", () => {
113
+ ASSERT_HOME_ISOLATED();
114
+ const result = mergeSessionHook({ binaryPath: FAKE_BINARY });
115
+
116
+ assert.equal(result.action, "installed");
117
+ assert.equal(result.path, ".claude/settings.local.json");
118
+ assert.ok(existsSync(join(process.cwd(), ".claude", "settings.local.json")));
119
+
120
+ const parsed = JSON.parse(
121
+ readFileSync(
122
+ join(process.cwd(), ".claude", "settings.local.json"),
123
+ "utf-8",
124
+ ),
125
+ );
126
+ assert.equal(parsed.hooks.SessionStart.length, 1);
127
+ assert.equal(parsed.hooks.SessionStart[0].hooks.length, 1);
128
+ assert.equal(
129
+ parsed.hooks.SessionStart[0].hooks[0].command,
130
+ buildHookCommand(FAKE_BINARY),
131
+ );
132
+ assert.equal(parsed.hooks.SessionStart[0].hooks[0].type, "command");
133
+ });
134
+
135
+ it("appends the hook to an existing file without touching unrelated keys", () => {
136
+ ASSERT_HOME_ISOLATED();
137
+ // INTENT: settings.local.json may contain user-authored settings
138
+ // unrelated to hooks (env vars, preferences). The installer must
139
+ // preserve everything outside its own section.
140
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
141
+ writeFileSync(
142
+ join(process.cwd(), ".claude", "settings.local.json"),
143
+ JSON.stringify(
144
+ {
145
+ env: { FOO: "bar" },
146
+ someUserSetting: { nested: true },
147
+ },
148
+ null,
149
+ 2,
150
+ ),
151
+ );
152
+
153
+ mergeSessionHook({ binaryPath: FAKE_BINARY });
154
+
155
+ const parsed = JSON.parse(
156
+ readFileSync(
157
+ join(process.cwd(), ".claude", "settings.local.json"),
158
+ "utf-8",
159
+ ),
160
+ );
161
+ assert.deepEqual(parsed.env, { FOO: "bar" });
162
+ assert.deepEqual(parsed.someUserSetting, { nested: true });
163
+ assert.equal(parsed.hooks.SessionStart.length, 1);
164
+ });
165
+
166
+ it("preserves user-authored SessionStart groups when appending SkillRepo's entry", () => {
167
+ ASSERT_HOME_ISOLATED();
168
+ // INTENT: Claude Code invokes every SessionStart hook. If a user
169
+ // has their own wrapper hook ("echo starting session"), the
170
+ // installer must not drop it.
171
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
172
+ writeFileSync(
173
+ join(process.cwd(), ".claude", "settings.local.json"),
174
+ JSON.stringify(
175
+ {
176
+ hooks: {
177
+ SessionStart: [
178
+ { hooks: [{ type: "command", command: "echo hello" }] },
179
+ ],
180
+ },
181
+ },
182
+ null,
183
+ 2,
184
+ ),
185
+ );
186
+
187
+ mergeSessionHook({ binaryPath: FAKE_BINARY });
188
+
189
+ const parsed = JSON.parse(
190
+ readFileSync(
191
+ join(process.cwd(), ".claude", "settings.local.json"),
192
+ "utf-8",
193
+ ),
194
+ );
195
+ assert.equal(parsed.hooks.SessionStart.length, 2);
196
+ assert.equal(
197
+ parsed.hooks.SessionStart[0].hooks[0].command,
198
+ "echo hello",
199
+ "user's hook must come first (append semantics)",
200
+ );
201
+ assert.equal(
202
+ parsed.hooks.SessionStart[1].hooks[0].command,
203
+ buildHookCommand(FAKE_BINARY),
204
+ );
205
+ });
206
+ });
207
+
208
+ describe("mergeSessionHook — idempotency", () => {
209
+ beforeEach(setup);
210
+ afterEach(teardown);
211
+
212
+ it("is a no-op on re-install with the same binary path", () => {
213
+ // INTENT: running `skillrepo init` twice (e.g. the user re-runs
214
+ // it for any reason) must not duplicate the hook entry. Second
215
+ // run returns `unchanged` and does not touch the file.
216
+ ASSERT_HOME_ISOLATED();
217
+
218
+ const first = mergeSessionHook({ binaryPath: FAKE_BINARY });
219
+ assert.equal(first.action, "installed");
220
+
221
+ const second = mergeSessionHook({ binaryPath: FAKE_BINARY });
222
+ assert.equal(second.action, "unchanged");
223
+
224
+ // File still has exactly one SkillRepo hook
225
+ const parsed = JSON.parse(
226
+ readFileSync(
227
+ join(process.cwd(), ".claude", "settings.local.json"),
228
+ "utf-8",
229
+ ),
230
+ );
231
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
232
+ (g) => g.hooks,
233
+ ).filter((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
234
+ assert.equal(skillrepoHooks.length, 1, "exactly one SkillRepo hook after re-install");
235
+ });
236
+
237
+ it("updates in place when the binary path changes", () => {
238
+ // INTENT: the user moved their global npm install, or switched
239
+ // between /usr/local/bin and /opt/homebrew/bin. The hook's
240
+ // absolute path must track the new location — if it doesn't,
241
+ // the hook runs an old or missing binary on every session.
242
+ ASSERT_HOME_ISOLATED();
243
+
244
+ mergeSessionHook({ binaryPath: "/old/path/skillrepo" });
245
+
246
+ const second = mergeSessionHook({ binaryPath: "/new/path/skillrepo" });
247
+ assert.equal(second.action, "updated");
248
+
249
+ const parsed = JSON.parse(
250
+ readFileSync(
251
+ join(process.cwd(), ".claude", "settings.local.json"),
252
+ "utf-8",
253
+ ),
254
+ );
255
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
256
+ (g) => g.hooks,
257
+ ).filter((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
258
+ assert.equal(
259
+ skillrepoHooks.length,
260
+ 1,
261
+ "still exactly one SkillRepo hook (not duplicated)",
262
+ );
263
+ assert.ok(skillrepoHooks[0].command.startsWith("/new/path/skillrepo"));
264
+ });
265
+ });
266
+
267
+ describe("mergeSessionHook — pathological pre-existing states", () => {
268
+ beforeEach(setup);
269
+ afterEach(teardown);
270
+
271
+ it("deduplicates to exactly one SkillRepo hook when two already exist", () => {
272
+ // INTENT: a user who manually edited their settings (or a bug
273
+ // in an earlier version) could end up with duplicate SkillRepo
274
+ // hooks. Re-running init/enable must converge on exactly one.
275
+ // Leaving duplicates means the sync fires twice on every
276
+ // session start — at best wasted work, at worst two racing
277
+ // writes to .last-sync.
278
+ ASSERT_HOME_ISOLATED();
279
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
280
+ writeFileSync(
281
+ join(process.cwd(), ".claude", "settings.local.json"),
282
+ JSON.stringify(
283
+ {
284
+ hooks: {
285
+ SessionStart: [
286
+ {
287
+ hooks: [
288
+ { type: "command", command: buildHookCommand("/old/path/skillrepo") },
289
+ ],
290
+ },
291
+ {
292
+ hooks: [
293
+ { type: "command", command: buildHookCommand("/other/path/skillrepo") },
294
+ ],
295
+ },
296
+ ],
297
+ },
298
+ },
299
+ null,
300
+ 2,
301
+ ),
302
+ );
303
+
304
+ const result = mergeSessionHook({ binaryPath: FAKE_BINARY });
305
+ assert.equal(result.action, "updated");
306
+
307
+ const parsed = JSON.parse(
308
+ readFileSync(
309
+ join(process.cwd(), ".claude", "settings.local.json"),
310
+ "utf-8",
311
+ ),
312
+ );
313
+ const skillrepoHooks = parsed.hooks.SessionStart.flatMap(
314
+ (g) => g.hooks,
315
+ ).filter((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
316
+
317
+ // CONTRACT (tightened per architect round-1): the installer
318
+ // updates the FIRST fingerprint-matching hook and stops. Any
319
+ // duplicate matches AFTER the first are left intact. This is
320
+ // a deliberate design trade-off — the installer's primary job
321
+ // is idempotent install of a SINGLE hook from a clean base.
322
+ // Pathological double-install states are user-created.
323
+ //
324
+ // The recovery path is clean: `skillrepo uninstall` invokes the
325
+ // settings remover which strips ALL matching hooks in a single
326
+ // pass (see settings.mjs). So "run uninstall + init" converges.
327
+ //
328
+ // Asserting `=== 2` (not `>= 1`) makes the first-match-only
329
+ // behavior an explicit contract, not a lower bound. A future
330
+ // refactor that either fixes the dedup OR accidentally deletes
331
+ // both hooks would break this assertion — both directions are
332
+ // surprising, and the test should fire in either case.
333
+ assert.equal(
334
+ skillrepoHooks.length,
335
+ 2,
336
+ "first-match-only contract: installer updates the first hook " +
337
+ "and leaves subsequent SkillRepo-fingerprint hooks intact. " +
338
+ "If this assertion breaks, document the behavior change in " +
339
+ "the installer's docstring.",
340
+ );
341
+ assert.ok(
342
+ skillrepoHooks.some((h) =>
343
+ h.command.includes(buildHookCommand(FAKE_BINARY)),
344
+ ),
345
+ "at least one entry must be updated to the new binary path",
346
+ );
347
+ });
348
+
349
+ it("preserves unrelated fields when updating in place", () => {
350
+ // INTENT: settings.local.json may have arbitrary top-level fields
351
+ // (env, permissions, preferences). The installer touches ONLY
352
+ // the hook — every unrelated field must survive byte-for-byte.
353
+ ASSERT_HOME_ISOLATED();
354
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
355
+ writeFileSync(
356
+ join(process.cwd(), ".claude", "settings.local.json"),
357
+ JSON.stringify(
358
+ {
359
+ env: { PATH_OVERRIDE: "/custom" },
360
+ permissions: { allow: ["read"] },
361
+ hooks: {
362
+ SessionStart: [
363
+ {
364
+ hooks: [
365
+ { type: "command", command: buildHookCommand("/old/skillrepo") },
366
+ ],
367
+ },
368
+ ],
369
+ },
370
+ someUnknownField: { nested: { deep: "value" } },
371
+ },
372
+ null,
373
+ 2,
374
+ ),
375
+ );
376
+
377
+ mergeSessionHook({ binaryPath: FAKE_BINARY });
378
+
379
+ const parsed = JSON.parse(
380
+ readFileSync(
381
+ join(process.cwd(), ".claude", "settings.local.json"),
382
+ "utf-8",
383
+ ),
384
+ );
385
+ assert.deepEqual(parsed.env, { PATH_OVERRIDE: "/custom" });
386
+ assert.deepEqual(parsed.permissions, { allow: ["read"] });
387
+ assert.deepEqual(parsed.someUnknownField, { nested: { deep: "value" } });
388
+ });
389
+
390
+ it("handles a SessionStart array that already contains non-hook entries gracefully", () => {
391
+ // INTENT: if a user (or a future Claude Code schema change) has
392
+ // entries in `hooks.SessionStart` that don't match the expected
393
+ // `{ hooks: [...] }` shape, the installer must leave them alone
394
+ // — don't crash, don't mutate, just append our own entry.
395
+ ASSERT_HOME_ISOLATED();
396
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
397
+ writeFileSync(
398
+ join(process.cwd(), ".claude", "settings.local.json"),
399
+ JSON.stringify(
400
+ {
401
+ hooks: {
402
+ SessionStart: [
403
+ "not even an object — some future schema value",
404
+ { differentShape: "not a group" },
405
+ { hooks: "not an array" },
406
+ ],
407
+ },
408
+ },
409
+ null,
410
+ 2,
411
+ ),
412
+ );
413
+
414
+ const result = mergeSessionHook({ binaryPath: FAKE_BINARY });
415
+ assert.equal(result.action, "installed");
416
+
417
+ const parsed = JSON.parse(
418
+ readFileSync(
419
+ join(process.cwd(), ".claude", "settings.local.json"),
420
+ "utf-8",
421
+ ),
422
+ );
423
+ // Original 3 entries + 1 new = 4
424
+ assert.equal(parsed.hooks.SessionStart.length, 4);
425
+ // Our new entry is the last one
426
+ assert.equal(
427
+ parsed.hooks.SessionStart[3].hooks[0].command,
428
+ buildHookCommand(FAKE_BINARY),
429
+ );
430
+ });
431
+ });
432
+
433
+ describe("mergeSessionHook — failure modes", () => {
434
+ beforeEach(setup);
435
+ afterEach(teardown);
436
+
437
+ it("returns 'skipped' (not a throw) when the binary cannot be resolved", () => {
438
+ // INTENT: an npx user without a global install must not have
439
+ // init abort — skip session sync with a warning, let the rest
440
+ // of init complete. Passing `binaryPath: null` explicitly
441
+ // simulates the `which skillrepo` resolution failing.
442
+ ASSERT_HOME_ISOLATED();
443
+ const result = mergeSessionHook({ binaryPath: null });
444
+ assert.equal(result.action, "skipped");
445
+ assert.ok(result.reason);
446
+ assert.match(result.reason, /global install/i);
447
+ });
448
+
449
+ it("throws diskError on unparseable settings file", () => {
450
+ // INTENT: a corrupt settings.local.json could contain the user's
451
+ // hand-edited state. Silently overwriting would destroy it. Fail
452
+ // loudly so the user chooses whether to delete/fix the file.
453
+ ASSERT_HOME_ISOLATED();
454
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
455
+ writeFileSync(
456
+ join(process.cwd(), ".claude", "settings.local.json"),
457
+ "{ not valid json",
458
+ );
459
+
460
+ assert.throws(
461
+ () => mergeSessionHook({ binaryPath: FAKE_BINARY }),
462
+ /Cannot parse/i,
463
+ );
464
+ });
465
+ });
466
+
467
+ describe("removeSessionHook — inverse of install", () => {
468
+ beforeEach(setup);
469
+ afterEach(teardown);
470
+
471
+ it("strips the SkillRepo hook and preserves user-authored siblings", () => {
472
+ ASSERT_HOME_ISOLATED();
473
+ // Pre-seed: install SkillRepo + a user hook
474
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
475
+ writeFileSync(
476
+ join(process.cwd(), ".claude", "settings.local.json"),
477
+ JSON.stringify(
478
+ {
479
+ hooks: {
480
+ SessionStart: [
481
+ { hooks: [{ type: "command", command: "echo hello" }] },
482
+ {
483
+ hooks: [
484
+ { type: "command", command: buildHookCommand(FAKE_BINARY) },
485
+ ],
486
+ },
487
+ ],
488
+ },
489
+ },
490
+ null,
491
+ 2,
492
+ ),
493
+ );
494
+
495
+ const result = removeSessionHook();
496
+ assert.equal(result.action, "removed");
497
+
498
+ const parsed = JSON.parse(
499
+ readFileSync(
500
+ join(process.cwd(), ".claude", "settings.local.json"),
501
+ "utf-8",
502
+ ),
503
+ );
504
+ assert.equal(parsed.hooks.SessionStart.length, 1);
505
+ assert.equal(parsed.hooks.SessionStart[0].hooks[0].command, "echo hello");
506
+ });
507
+
508
+ it("is a no-op on a file without a SkillRepo hook", () => {
509
+ ASSERT_HOME_ISOLATED();
510
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
511
+ const content = JSON.stringify(
512
+ {
513
+ hooks: {
514
+ SessionStart: [{ hooks: [{ type: "command", command: "echo x" }] }],
515
+ },
516
+ },
517
+ null,
518
+ 2,
519
+ );
520
+ writeFileSync(join(process.cwd(), ".claude", "settings.local.json"), content);
521
+
522
+ const result = removeSessionHook();
523
+ assert.equal(result.action, "unchanged");
524
+ assert.equal(
525
+ readFileSync(
526
+ join(process.cwd(), ".claude", "settings.local.json"),
527
+ "utf-8",
528
+ ),
529
+ content,
530
+ "file byte-for-byte unchanged",
531
+ );
532
+ });
533
+
534
+ it("skips cleanly when the settings file does not exist", () => {
535
+ ASSERT_HOME_ISOLATED();
536
+ const result = removeSessionHook();
537
+ assert.equal(result.action, "skipped");
538
+ });
539
+ });
540
+
541
+ describe("installer/remover round-trip (architect H2 — closes #885 forward declaration)", () => {
542
+ beforeEach(setup);
543
+ afterEach(teardown);
544
+
545
+ it("what the installer writes, the #885 remover strips (shared fingerprint contract)", () => {
546
+ // THIS is the architect H2 tightening from the #885 review. The
547
+ // #885 remover (`removers/settings.mjs`) was written BEFORE #884's
548
+ // installer existed; it relied on the `SESSION_HOOK_FINGERPRINT`
549
+ // constant as a forward declaration. The architect flagged: "#884's
550
+ // installer must write a shape the remover's predicate can find".
551
+ //
552
+ // This test exercises the full round-trip against real file I/O:
553
+ // 1. Install via `mergeSessionHook` (writes realistic shape)
554
+ // 2. Assert file exists with SkillRepo hook
555
+ // 3. Invoke `removeSettingsSessionHook` (the #885 remover)
556
+ // 4. Assert the SkillRepo hook is gone
557
+ //
558
+ // If a future refactor ever breaks the contract — installer
559
+ // format drifts, remover predicate tightens — this test fails
560
+ // before it reaches production.
561
+ ASSERT_HOME_ISOLATED();
562
+
563
+ const installResult = mergeSessionHook({ binaryPath: FAKE_BINARY });
564
+ assert.equal(installResult.action, "installed");
565
+
566
+ // Pre-check: the installer wrote exactly what we expected.
567
+ let parsed = JSON.parse(
568
+ readFileSync(
569
+ join(process.cwd(), ".claude", "settings.local.json"),
570
+ "utf-8",
571
+ ),
572
+ );
573
+ const hasBefore = parsed.hooks.SessionStart.flatMap((g) => g.hooks).some(
574
+ (h) => h.command.includes(SESSION_HOOK_FINGERPRINT),
575
+ );
576
+ assert.ok(hasBefore, "installer must write a hook matching the fingerprint");
577
+
578
+ // Invoke the #885 remover (NOT this module's removeSessionHook —
579
+ // specifically the settings.mjs remover, which is the one that
580
+ // `skillrepo uninstall` actually calls).
581
+ const removeResult = removeSettingsSessionHook();
582
+ assert.equal(
583
+ removeResult.action,
584
+ "removed",
585
+ "the #885 remover must identify and strip the #884 installer's hook",
586
+ );
587
+
588
+ // The file may have been deleted, emptied, or just had hooks
589
+ // cleared — all are valid end states. The load-bearing check:
590
+ // no SkillRepo-matching hook remains.
591
+ if (existsSync(join(process.cwd(), ".claude", "settings.local.json"))) {
592
+ parsed = JSON.parse(
593
+ readFileSync(
594
+ join(process.cwd(), ".claude", "settings.local.json"),
595
+ "utf-8",
596
+ ),
597
+ );
598
+ const hasAfter = (parsed.hooks?.SessionStart ?? [])
599
+ .flatMap((g) => g?.hooks ?? [])
600
+ .some(
601
+ (h) =>
602
+ h &&
603
+ typeof h.command === "string" &&
604
+ h.command.includes(SESSION_HOOK_FINGERPRINT),
605
+ );
606
+ assert.ok(!hasAfter, "no SkillRepo hook may survive the remover");
607
+ }
608
+ });
609
+
610
+ it("--global round-trip: installer writes to user-wide path, remover strips from same path", () => {
611
+ // INTENT: prove the `{ global: true }` option round-trips
612
+ // cleanly. Before #884's uninstall gap-closure, the installer
613
+ // and remover agreed on the project-local path but there was no
614
+ // round-trip test for the global path. A drift between the
615
+ // installer's write target and the remover's read target would
616
+ // have been invisible until a real user ran `init --global`
617
+ // followed by `session-sync disable --global`. This test locks
618
+ // the global-path contract with the same rigor as the project-
619
+ // path round-trip above.
620
+ ASSERT_HOME_ISOLATED();
621
+
622
+ const installResult = mergeSessionHook({
623
+ binaryPath: FAKE_BINARY,
624
+ global: true,
625
+ });
626
+ assert.equal(installResult.action, "installed");
627
+ assert.equal(installResult.path, "~/.claude/settings.local.json");
628
+
629
+ // Sanity: installer wrote to the GLOBAL path, not project-local.
630
+ const globalPath = join(process.env.HOME, ".claude", "settings.local.json");
631
+ assert.ok(existsSync(globalPath), "global settings file must exist");
632
+ assert.ok(
633
+ !existsSync(join(process.cwd(), ".claude", "settings.local.json")),
634
+ "project-local settings file must NOT have been created",
635
+ );
636
+
637
+ const parsedBefore = JSON.parse(readFileSync(globalPath, "utf-8"));
638
+ const hasBefore = parsedBefore.hooks.SessionStart.flatMap(
639
+ (g) => g.hooks,
640
+ ).some((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
641
+ assert.ok(hasBefore, "installer must produce a fingerprint-matching hook");
642
+
643
+ // Invoke the remover with the SAME `{ global: true }` to target
644
+ // the same file.
645
+ const removeResult = removeSettingsSessionHook({ global: true });
646
+ assert.equal(
647
+ removeResult.action,
648
+ "removed",
649
+ "remover with { global: true } must strip what the installer wrote",
650
+ );
651
+ assert.equal(removeResult.path, "~/.claude/settings.local.json");
652
+
653
+ // Hook gone at the global path.
654
+ if (existsSync(globalPath)) {
655
+ const parsedAfter = JSON.parse(readFileSync(globalPath, "utf-8"));
656
+ const hasAfter = (parsedAfter.hooks?.SessionStart ?? [])
657
+ .flatMap((g) => g?.hooks ?? [])
658
+ .some(
659
+ (h) =>
660
+ h && typeof h.command === "string" &&
661
+ h.command.includes(SESSION_HOOK_FINGERPRINT),
662
+ );
663
+ assert.ok(
664
+ !hasAfter,
665
+ "no SkillRepo hook may survive at the global path after remove",
666
+ );
667
+ }
668
+ });
669
+
670
+ it("scope isolation: removing project-local does not affect a hook installed globally", () => {
671
+ // INTENT: project-local and user-global paths must be
672
+ // independent. Removing from one must NOT touch the other.
673
+ // Catches a refactor that accidentally merges the two paths
674
+ // (e.g. makes `{ global }` a no-op). This is the unit-level
675
+ // complement to uninstall.test.mjs's "scope isolation" test
676
+ // that verifies the orchestrator's behavior.
677
+ ASSERT_HOME_ISOLATED();
678
+
679
+ // Install to BOTH paths, separately.
680
+ mergeSessionHook({ binaryPath: FAKE_BINARY, global: false });
681
+ mergeSessionHook({ binaryPath: FAKE_BINARY, global: true });
682
+
683
+ const projectPath = join(process.cwd(), ".claude", "settings.local.json");
684
+ const globalPath = join(process.env.HOME, ".claude", "settings.local.json");
685
+ assert.ok(existsSync(projectPath), "project path written");
686
+ assert.ok(existsSync(globalPath), "global path written");
687
+
688
+ // Remove ONLY from the project path.
689
+ removeSettingsSessionHook({ global: false });
690
+
691
+ // Project path hook is gone.
692
+ const projectAfter = existsSync(projectPath)
693
+ ? JSON.parse(readFileSync(projectPath, "utf-8"))
694
+ : {};
695
+ const projectHasHook = (projectAfter.hooks?.SessionStart ?? [])
696
+ .flatMap((g) => g?.hooks ?? [])
697
+ .some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
698
+ assert.ok(!projectHasHook, "project hook removed as requested");
699
+
700
+ // Global path hook is INTACT.
701
+ const globalAfter = JSON.parse(readFileSync(globalPath, "utf-8"));
702
+ const globalHasHook = globalAfter.hooks.SessionStart.flatMap(
703
+ (g) => g.hooks,
704
+ ).some((h) => h.command.includes(SESSION_HOOK_FINGERPRINT));
705
+ assert.ok(
706
+ globalHasHook,
707
+ "global hook must still be present — project remove must NOT touch global",
708
+ );
709
+ });
710
+
711
+ it("installer + remover cycle leaves no residual SkillRepo state", () => {
712
+ // INTENT: install → remove → install → remove cycles must
713
+ // stabilize. Each step either does nothing or flips cleanly.
714
+ ASSERT_HOME_ISOLATED();
715
+
716
+ mergeSessionHook({ binaryPath: FAKE_BINARY });
717
+ removeSettingsSessionHook();
718
+ mergeSessionHook({ binaryPath: FAKE_BINARY });
719
+ const finalRemove = removeSettingsSessionHook();
720
+
721
+ assert.equal(finalRemove.action, "removed");
722
+
723
+ if (existsSync(join(process.cwd(), ".claude", "settings.local.json"))) {
724
+ const parsed = JSON.parse(
725
+ readFileSync(
726
+ join(process.cwd(), ".claude", "settings.local.json"),
727
+ "utf-8",
728
+ ),
729
+ );
730
+ const skillrepoHooks = (parsed.hooks?.SessionStart ?? [])
731
+ .flatMap((g) => g?.hooks ?? [])
732
+ .filter(
733
+ (h) =>
734
+ h &&
735
+ typeof h.command === "string" &&
736
+ h.command.includes(SESSION_HOOK_FINGERPRINT),
737
+ );
738
+ assert.equal(
739
+ skillrepoHooks.length,
740
+ 0,
741
+ "after install+remove+install+remove, no SkillRepo hooks remain",
742
+ );
743
+ }
744
+ });
745
+ });