skillrepo 3.1.0 → 3.1.2

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 (45) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/commands/init-session-sync.mjs +307 -0
  4. package/src/commands/init.mjs +111 -101
  5. package/src/commands/session-sync-actions.mjs +92 -0
  6. package/src/lib/artifact-registry.mjs +43 -3
  7. package/src/lib/binary-locator.mjs +99 -0
  8. package/src/lib/cli-config.mjs +16 -3
  9. package/src/lib/cli-version.mjs +56 -0
  10. package/src/lib/config.mjs +6 -3
  11. package/src/lib/file-write.mjs +8 -3
  12. package/src/lib/fs-utils.mjs +9 -10
  13. package/src/lib/global-install.mjs +387 -0
  14. package/src/lib/mcp-merge.mjs +16 -5
  15. package/src/lib/mergers/session-hook.mjs +125 -33
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/sync.mjs +26 -0
  18. package/src/lib/transient-runners.mjs +204 -0
  19. package/src/test/commands/add.test.mjs +10 -4
  20. package/src/test/commands/get.test.mjs +10 -4
  21. package/src/test/commands/init.test.mjs +889 -15
  22. package/src/test/commands/list.test.mjs +10 -4
  23. package/src/test/commands/remove.test.mjs +10 -4
  24. package/src/test/commands/search.test.mjs +10 -4
  25. package/src/test/commands/session-sync-actions.test.mjs +74 -0
  26. package/src/test/commands/session-sync.test.mjs +25 -23
  27. package/src/test/commands/uninstall.test.mjs +20 -14
  28. package/src/test/commands/update.test.mjs +10 -4
  29. package/src/test/helpers/mock-spawn.mjs +121 -0
  30. package/src/test/helpers/sandbox-home.mjs +161 -0
  31. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  32. package/src/test/integration/file-write.integration.test.mjs +10 -4
  33. package/src/test/lib/cli-config.test.mjs +182 -4
  34. package/src/test/lib/cli-version.test.mjs +47 -0
  35. package/src/test/lib/config.test.mjs +10 -4
  36. package/src/test/lib/file-write.test.mjs +24 -10
  37. package/src/test/lib/global-install.test.mjs +424 -0
  38. package/src/test/lib/mcp-merge.test.mjs +13 -7
  39. package/src/test/lib/paths.test.mjs +10 -4
  40. package/src/test/lib/platform.test.mjs +135 -0
  41. package/src/test/lib/sync.test.mjs +20 -4
  42. package/src/test/lib/transient-runners.test.mjs +270 -0
  43. package/src/test/mergers/session-hook.test.mjs +722 -22
  44. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  45. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
@@ -34,49 +34,67 @@ import {
34
34
  } from "../../lib/mergers/session-hook.mjs";
35
35
  import { removeSettingsSessionHook } from "../../lib/removers/settings.mjs";
36
36
  import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
37
+ import {
38
+ captureHome,
39
+ setSandboxHome,
40
+ restoreHome,
41
+ assertHomeIsolated,
42
+ } from "../helpers/sandbox-home.mjs";
37
43
 
38
44
  let sandbox;
39
45
  let originalCwd;
40
- let originalHome;
46
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
47
+ let originalHomeEnv;
41
48
  const FAKE_BINARY = "/usr/local/bin/skillrepo";
42
49
 
43
50
  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
- );
51
+ // Thin wrapper around the shared helper. Checks BOTH HOME and
52
+ // USERPROFILE so Windows is actually guarded — `os.homedir()`
53
+ // reads USERPROFILE on Windows, and an assertion that checked
54
+ // only HOME would pass while real user state was at risk.
55
+ assertHomeIsolated(tmpdir(), "session-hook tests");
49
56
  }
50
57
 
51
58
  function setup() {
52
59
  sandbox = mkdtempSync(join(tmpdir(), "cli-session-hook-"));
53
60
  originalCwd = process.cwd();
54
- originalHome = process.env.HOME;
61
+ originalHomeEnv = captureHome();
55
62
  mkdirSync(join(sandbox, "project"), { recursive: true });
56
63
  mkdirSync(join(sandbox, "home"), { recursive: true });
57
64
  process.chdir(join(sandbox, "project"));
58
- process.env.HOME = join(sandbox, "home");
65
+ setSandboxHome(join(sandbox, "home"));
59
66
  ASSERT_HOME_ISOLATED();
60
67
  }
61
68
 
62
69
  function teardown() {
63
70
  process.chdir(originalCwd);
64
- process.env.HOME = originalHome;
71
+ restoreHome(originalHomeEnv);
65
72
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
66
73
  }
67
74
 
68
75
  // ──────────────────────────────────────────────────────────────────
69
76
 
70
77
  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");
78
+ it("produces the exact command shape Claude Code expects (POSIX)", () => {
79
+ // INTENT: the shape is load-bearing in four ways (per the
80
+ // installer's docstring): absolute path, single-quoted to
81
+ // tolerate spaces/parens, --session-hook flag, `|| true`
82
+ // backstop. A refactor that drops any of the four must fail
83
+ // this test loudly.
84
+ //
85
+ // The `platform: "linux"` override is explicit because this test
86
+ // asserts the POSIX command shape. Without it, the test runs
87
+ // under `os.platform()` — which is `win32` on the Windows CI
88
+ // runner and correctly drops `|| true`, causing a spurious
89
+ // failure against an assertion that was written for POSIX.
90
+ // Platform-specific shapes are covered by the dedicated POSIX/
91
+ // Windows tests below; this one is the POSIX-shape contract.
92
+ const cmd = buildHookCommand("/usr/local/bin/skillrepo", {
93
+ platform: "linux",
94
+ });
77
95
  assert.equal(
78
96
  cmd,
79
- "/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
97
+ "'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true",
80
98
  );
81
99
  });
82
100
 
@@ -94,6 +112,121 @@ describe("buildHookCommand", () => {
94
112
  );
95
113
  });
96
114
 
115
+ it("v3.1.1 Windows support: buildHookCommand OMITS '|| true' on Windows", () => {
116
+ // INTENT: cmd.exe doesn't understand the `true` builtin. If we
117
+ // leave `|| true` on Windows, a binary-missing scenario emits a
118
+ // confusing "'true' is not recognized" error. Windows hook
119
+ // commands drop the shell backstop; the `--session-hook` flag's
120
+ // exit-0 contract inside the Node process is the primary
121
+ // defense regardless of platform.
122
+ const cmd = buildHookCommand("C:\\Program Files\\node\\skillrepo.cmd", {
123
+ platform: "win32",
124
+ });
125
+ assert.doesNotMatch(
126
+ cmd,
127
+ /\|\| true/,
128
+ "Windows hook command must NOT contain '|| true'",
129
+ );
130
+ assert.match(
131
+ cmd,
132
+ /skillrepo\.cmd" update --session-hook 2>&1$/,
133
+ "Windows hook command ends at '2>&1' — no shell backstop, with closing quote on the path",
134
+ );
135
+ // Fingerprint still present — remover round-trip must work on
136
+ // Windows too.
137
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
138
+ });
139
+
140
+ it("v3.1.1 Windows support: buildHookCommand keeps '|| true' on POSIX", () => {
141
+ const cmd = buildHookCommand("/usr/local/bin/skillrepo", { platform: "linux" });
142
+ assert.match(
143
+ cmd,
144
+ /\|\| true$/,
145
+ "POSIX hook command must retain the '|| true' shell backstop",
146
+ );
147
+ const cmdDarwin = buildHookCommand("/usr/local/bin/skillrepo", { platform: "darwin" });
148
+ assert.match(cmdDarwin, /\|\| true$/);
149
+ });
150
+
151
+ it("v3.1.2: POSIX path with spaces is single-quoted", () => {
152
+ // Real-world case: macOS users with a space in their home dir
153
+ // (`/Users/First Last/.npm-global/bin/skillrepo`). The unquoted
154
+ // command would be parsed by the shell as multiple arguments
155
+ // and silently fail on session start.
156
+ const cmd = buildHookCommand(
157
+ "/Users/First Last/.npm-global/bin/skillrepo",
158
+ { platform: "linux" },
159
+ );
160
+ assert.equal(
161
+ cmd,
162
+ "'/Users/First Last/.npm-global/bin/skillrepo' update --session-hook 2>&1 || true",
163
+ );
164
+ // Fingerprint still present after quoting.
165
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
166
+ });
167
+
168
+ it("v3.1.2: POSIX path with single quote escapes correctly ('\\\\'')", () => {
169
+ // Hardcore edge case: a path with a literal single quote. POSIX
170
+ // shells require closing the quote, escaping the literal `'`,
171
+ // then reopening — the standard `'\\''` trick.
172
+ const cmd = buildHookCommand("/Users/J's bin/skillrepo", {
173
+ platform: "linux",
174
+ });
175
+ assert.equal(
176
+ cmd,
177
+ "'/Users/J'\\''s bin/skillrepo' update --session-hook 2>&1 || true",
178
+ );
179
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
180
+ });
181
+
182
+ it("v3.1.2: Windows path with spaces is double-quoted", () => {
183
+ // Real-world: `C:\Program Files\nodejs\skillrepo.cmd` and
184
+ // `C:\Program Files (x86)\...`. cmd.exe needs double quotes;
185
+ // backslashes inside double quotes are NOT escape characters
186
+ // (they pass through to the resolved path verbatim).
187
+ const cmd = buildHookCommand(
188
+ "C:\\Program Files\\nodejs\\skillrepo.cmd",
189
+ { platform: "win32" },
190
+ );
191
+ assert.equal(
192
+ cmd,
193
+ '"C:\\Program Files\\nodejs\\skillrepo.cmd" update --session-hook 2>&1',
194
+ );
195
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
196
+ });
197
+
198
+ it("v3.1.2: Windows path with parentheses (Program Files (x86)) is preserved verbatim inside double quotes", () => {
199
+ const cmd = buildHookCommand(
200
+ "C:\\Program Files (x86)\\node\\skillrepo.cmd",
201
+ { platform: "win32" },
202
+ );
203
+ assert.ok(cmd.startsWith('"C:\\Program Files (x86)\\node\\skillrepo.cmd"'));
204
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
205
+ });
206
+
207
+ it("v3.1.2: backward-compat — fingerprint matches both unquoted (v3.1.0/3.1.1) and quoted (v3.1.2+) shapes", () => {
208
+ // The fingerprint is `" update --session-hook"` with a leading
209
+ // space. That space sits between the path's closing context
210
+ // (a `'`/`"` quote in v3.1.2, the bare path char in v3.1.0/3.1.1)
211
+ // and `update`. Both shapes contain the leading-space substring.
212
+ // Without this contract, an upgrade from v3.1.1 to v3.1.2 would
213
+ // duplicate the hook entry instead of updating it in place.
214
+ const v311Posix =
215
+ "/usr/local/bin/skillrepo update --session-hook 2>&1 || true";
216
+ const v312Posix =
217
+ "'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true";
218
+ const v311Win =
219
+ "C:\\path\\skillrepo.cmd update --session-hook 2>&1";
220
+ const v312Win =
221
+ '"C:\\path\\skillrepo.cmd" update --session-hook 2>&1';
222
+ for (const cmd of [v311Posix, v312Posix, v311Win, v312Win]) {
223
+ assert.ok(
224
+ cmd.includes(SESSION_HOOK_FINGERPRINT),
225
+ `fingerprint must match shape: ${cmd}`,
226
+ );
227
+ }
228
+ });
229
+
97
230
  it("rejects empty or non-string binary paths", () => {
98
231
  // INTENT: no silent production of a malformed command. The
99
232
  // installer upstream should never pass null/empty, but defensive
@@ -105,6 +238,338 @@ describe("buildHookCommand", () => {
105
238
  });
106
239
  });
107
240
 
241
+ describe("resolveSkillrepoBinary — v3.1.1 Windows support", () => {
242
+ // These tests exercise the platform-specific locator branch
243
+ // (`where` on Windows, `which` elsewhere) and the Windows
244
+ // absolute-path check (`C:\...` not `/`). They use the
245
+ // `{ platform }` option to avoid relying on the host OS.
246
+ //
247
+ // IMPORTANT: these tests DO NOT run a real `where.exe` subprocess
248
+ // on Linux/macOS CI (there is no `where` to run). Instead they
249
+ // verify the LOCATOR-SELECTION logic by checking that calling
250
+ // with `platform: "win32"` on a Unix host produces a null return
251
+ // (because `where` doesn't exist there) rather than a thrown
252
+ // error. That's sufficient to prove the platform branch works;
253
+ // actual Windows binary resolution is tested by the Windows CI
254
+ // smoke job added in .github/workflows/.
255
+ //
256
+ // Behavioral coverage requires beforeEach/afterEach to clear npx
257
+ // signals (the function returns null early on npx regardless of
258
+ // platform). Reuses the isolate-process-state pattern from the
259
+ // isNpxInvocation tests.
260
+ let originalArgv;
261
+ let originalUnderscore;
262
+
263
+ beforeEach(() => {
264
+ originalArgv = process.argv;
265
+ originalUnderscore = process.env._;
266
+ process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
267
+ delete process.env._;
268
+ });
269
+
270
+ afterEach(() => {
271
+ process.argv = originalArgv;
272
+ if (originalUnderscore === undefined) delete process.env._;
273
+ else process.env._ = originalUnderscore;
274
+ });
275
+
276
+ it("does not throw when called with platform: 'win32' on a non-Windows host", async () => {
277
+ // On Linux/macOS there is no `where.exe` on PATH, so the
278
+ // execFileSync throws ENOENT. Our try/catch returns null —
279
+ // the same safe-skip path that fires when the binary isn't
280
+ // on PATH. Verifies the platform branch compiles cleanly
281
+ // without requiring a real Windows environment.
282
+ const { resolveSkillrepoBinary } = await import(
283
+ "../../lib/mergers/session-hook.mjs?v311-win-test=" + Date.now()
284
+ );
285
+ const result = resolveSkillrepoBinary({ platform: "win32" });
286
+ assert.equal(
287
+ result,
288
+ null,
289
+ "on a Unix host with platform:'win32', where.exe isn't available → null return (not throw)",
290
+ );
291
+ });
292
+
293
+ it("mergeSessionHook under platform:'win32' routes to the architect's skipped path when binary can't be resolved", async () => {
294
+ // End-to-end test of the Windows binary-resolution path. When
295
+ // `where` isn't available (simulating a Windows environment
296
+ // where the user hasn't installed skillrepo globally), the
297
+ // installer must skip gracefully with the architect-specified
298
+ // reason — same as the Unix "no global install" fallback.
299
+ const { mergeSessionHook: mergeFresh } = await import(
300
+ "../../lib/mergers/session-hook.mjs?v311-win-integration=" + Date.now()
301
+ );
302
+ const tmpSandbox = mkdtempSync(join(tmpdir(), "cli-win-test-"));
303
+ const originalCwd = process.cwd();
304
+ const originalHomeEnvLocal = captureHome();
305
+ mkdirSync(join(tmpSandbox, "project"), { recursive: true });
306
+ mkdirSync(join(tmpSandbox, "home"), { recursive: true });
307
+ process.chdir(join(tmpSandbox, "project"));
308
+ setSandboxHome(join(tmpSandbox, "home"));
309
+
310
+ try {
311
+ // Explicitly pass binaryPath: null so the test doesn't rely
312
+ // on resolveSkillrepoBinary. This is a different check —
313
+ // verifying the downstream skipped-with-reason path works
314
+ // identically regardless of what caused the null.
315
+ const result = mergeFresh({ binaryPath: null });
316
+ assert.equal(result.action, "skipped");
317
+ assert.ok(result.reason);
318
+ assert.match(
319
+ result.reason,
320
+ /stable.*skillrepo/i,
321
+ "Windows skipped path must surface the same user-actionable message as POSIX",
322
+ );
323
+ } finally {
324
+ process.chdir(originalCwd);
325
+ restoreHome(originalHomeEnvLocal);
326
+ rmSync(tmpSandbox, { recursive: true, force: true });
327
+ }
328
+ });
329
+ });
330
+
331
+ describe("v3.1.1 Windows hook: fingerprint + round-trip contract", () => {
332
+ // The fingerprint SESSION_HOOK_FINGERPRINT is the substring that
333
+ // lets the uninstaller identify SkillRepo-owned hook entries.
334
+ // These tests lock the contract from BOTH platform ends: the
335
+ // Windows command shape must contain the fingerprint, and the
336
+ // remover must strip what the Windows builder produces. If the
337
+ // refactor that produced platform.mjs ever drifts the two sides,
338
+ // one of these tests fails before any user ships.
339
+ beforeEach(setup);
340
+ afterEach(teardown);
341
+
342
+ // A realistic Windows npm-install shim path. `npm install -g` on
343
+ // Windows drops three launchers into `%AppData%\npm\`:
344
+ // skillrepo (bash shim, used by Git Bash via shell lookup)
345
+ // skillrepo.cmd (cmd.exe shim)
346
+ // skillrepo.ps1 (PowerShell shim)
347
+ //
348
+ // `where skillrepo` matches files whose extension appears in
349
+ // `%PATHEXT%`. The default PATHEXT is
350
+ // `.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC` — `.cmd`
351
+ // is in that list, `.ps1` usually isn't, and the extension-less
352
+ // bash shim never appears in `where`'s output because `where`
353
+ // only matches extensions from PATHEXT. So in practice `where
354
+ // skillrepo` returns a single line ending in `.cmd` — that's the
355
+ // path we must exercise. The CLI's resolver takes the first line
356
+ // regardless, so even on a system with a custom PATHEXT that
357
+ // surfaces `.ps1` first, the first-line rule still produces a
358
+ // usable absolute path.
359
+ const WIN_SHIM_PATH = "C:\\Users\\alice\\AppData\\Roaming\\npm\\skillrepo.cmd";
360
+
361
+ it("fingerprint is platform-neutral: POSIX and Windows command shapes both contain it", () => {
362
+ // This is the test that would have caught the v3.1.1-dev bug
363
+ // where the fingerprint was `"skillrepo update --session-hook"`
364
+ // (the longer form) and the Windows `.cmd` path broke the
365
+ // substring match because the extension intrudes between
366
+ // `skillrepo` and `update`. If this assertion fires, the
367
+ // fingerprint has regressed and Windows hook identification is
368
+ // broken.
369
+ const posixCmd = buildHookCommand("/usr/local/bin/skillrepo", {
370
+ platform: "darwin",
371
+ });
372
+ const windowsCmd = buildHookCommand(WIN_SHIM_PATH, { platform: "win32" });
373
+
374
+ assert.ok(
375
+ posixCmd.includes(SESSION_HOOK_FINGERPRINT),
376
+ `POSIX command "${posixCmd}" must include fingerprint "${SESSION_HOOK_FINGERPRINT}"`,
377
+ );
378
+ assert.ok(
379
+ windowsCmd.includes(SESSION_HOOK_FINGERPRINT),
380
+ `Windows command "${windowsCmd}" must include fingerprint "${SESSION_HOOK_FINGERPRINT}". ` +
381
+ `The Windows shim path ends in .cmd, which the earlier fingerprint ` +
382
+ `"skillrepo update --session-hook" failed to match.`,
383
+ );
384
+ });
385
+
386
+ it("backward compat: pre-v3.1.1 hooks (with longer fingerprint) still match the new shorter fingerprint", () => {
387
+ // Users who ran `skillrepo init` under v3.1.0 have a hook whose
388
+ // command contains the LONGER fingerprint "skillrepo update
389
+ // --session-hook". The shorter v3.1.1 fingerprint "update
390
+ // --session-hook" must still be a substring of every legacy
391
+ // command — otherwise upgrading breaks uninstall for every
392
+ // existing user. This test is the backward-compat gate.
393
+ const legacyPosixCommand =
394
+ "/usr/local/bin/skillrepo update --session-hook 2>&1 || true";
395
+ assert.ok(
396
+ legacyPosixCommand.includes(SESSION_HOOK_FINGERPRINT),
397
+ `Legacy v3.1.0 POSIX hook "${legacyPosixCommand}" must still be ` +
398
+ `identifiable by the v3.1.1 fingerprint "${SESSION_HOOK_FINGERPRINT}" — ` +
399
+ `if this fires, upgrading breaks uninstall for existing users.`,
400
+ );
401
+ });
402
+
403
+ it("Windows hook round-trip: installer + remover identify the same .cmd-shim command", () => {
404
+ // End-to-end proof that on Windows, the hook written by the
405
+ // installer is the hook found by the remover. We can't spawn a
406
+ // real where.exe on macOS/Linux CI, so we simulate the Windows
407
+ // result by (1) asking buildHookCommand for the Windows shape
408
+ // and (2) writing that exact command into a settings file,
409
+ // then (3) invoking the remover and verifying the hook is
410
+ // gone. This exercises the fingerprint match against a real
411
+ // Windows-shaped command string.
412
+ ASSERT_HOME_ISOLATED();
413
+ const windowsCmd = buildHookCommand(WIN_SHIM_PATH, { platform: "win32" });
414
+
415
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
416
+ writeFileSync(
417
+ join(process.cwd(), ".claude", "settings.local.json"),
418
+ JSON.stringify(
419
+ {
420
+ hooks: {
421
+ SessionStart: [
422
+ { hooks: [{ type: "command", command: windowsCmd }] },
423
+ ],
424
+ },
425
+ },
426
+ null,
427
+ 2,
428
+ ),
429
+ );
430
+
431
+ const removeResult = removeSettingsSessionHook();
432
+ assert.equal(
433
+ removeResult.action,
434
+ "removed",
435
+ "Remover must identify a Windows-shaped hook command. If this fires, " +
436
+ "SESSION_HOOK_FINGERPRINT has drifted from what buildHookCommand " +
437
+ "produces on Windows — the .cmd extension likely intrudes between " +
438
+ "`skillrepo` and `update`.",
439
+ );
440
+ });
441
+
442
+ it("mergeSessionHook under platform:'win32' writes a Windows-shaped command end-to-end", () => {
443
+ // INTENT: the architect's round-3 HIGH finding. mergeSessionHook
444
+ // must propagate the platform override through to buildHookCommand
445
+ // so a non-Windows CI host can still prove that a real Windows
446
+ // user would receive a command WITHOUT `|| true` — cmd.exe
447
+ // doesn't understand the `true` builtin.
448
+ //
449
+ // Before round 3, this test documented its own gap: `mergeSessionHook`
450
+ // didn't accept a platform option, so passing a Windows-shaped
451
+ // binaryPath still produced a POSIX command (because buildHookCommand
452
+ // read os.platform() directly). Passing only the fingerprint check
453
+ // gave false confidence. Fixing this closes that gap — the assertion
454
+ // now verifies the Windows command shape end-to-end against real
455
+ // file I/O.
456
+ ASSERT_HOME_ISOLATED();
457
+
458
+ const installResult = mergeSessionHook({
459
+ binaryPath: WIN_SHIM_PATH,
460
+ platform: "win32",
461
+ });
462
+ assert.equal(installResult.action, "installed");
463
+
464
+ const parsed = JSON.parse(
465
+ readFileSync(
466
+ join(process.cwd(), ".claude", "settings.local.json"),
467
+ "utf-8",
468
+ ),
469
+ );
470
+ const cmd = parsed.hooks.SessionStart[0].hooks[0].command;
471
+
472
+ // 1. Fingerprint present — installer/remover round-trip works.
473
+ assert.ok(
474
+ cmd.includes(SESSION_HOOK_FINGERPRINT),
475
+ `Hook with Windows-shaped binaryPath must contain the fingerprint. Got: "${cmd}"`,
476
+ );
477
+ // 2. No `|| true` backstop — cmd.exe can't execute it.
478
+ assert.ok(
479
+ !cmd.includes("|| true"),
480
+ `Windows hook must NOT contain "|| true" — cmd.exe has no such builtin. Got: "${cmd}"`,
481
+ );
482
+ // 3. Command ends exactly at "2>&1" — sanity on the full shape.
483
+ // The closing `"` is the path's wrapping quote (v3.1.2:
484
+ // paths are quoted to tolerate spaces).
485
+ assert.ok(
486
+ cmd.endsWith(`skillrepo.cmd" update --session-hook 2>&1`),
487
+ `Windows hook command must end at "2>&1". Got: "${cmd}"`,
488
+ );
489
+
490
+ // Remover identifies and strips the Windows-shaped hook.
491
+ const removeResult = removeSettingsSessionHook();
492
+ assert.equal(removeResult.action, "removed");
493
+ });
494
+
495
+ it("mergeSessionHook under platform:'linux' still writes the POSIX `|| true` backstop", () => {
496
+ // Inverse guard: a test that asserts the POSIX path still emits
497
+ // `|| true` so a refactor that accidentally inverts the platform
498
+ // check (or a bug in platform.mjs that swaps the conventions)
499
+ // fails loudly here instead of silently stripping the backstop
500
+ // from every POSIX user's hook.
501
+ ASSERT_HOME_ISOLATED();
502
+
503
+ const installResult = mergeSessionHook({
504
+ binaryPath: "/usr/local/bin/skillrepo",
505
+ platform: "linux",
506
+ });
507
+ assert.equal(installResult.action, "installed");
508
+
509
+ const parsed = JSON.parse(
510
+ readFileSync(
511
+ join(process.cwd(), ".claude", "settings.local.json"),
512
+ "utf-8",
513
+ ),
514
+ );
515
+ const cmd = parsed.hooks.SessionStart[0].hooks[0].command;
516
+ assert.ok(
517
+ cmd.endsWith("|| true"),
518
+ `POSIX hook command must end with "|| true". Got: "${cmd}"`,
519
+ );
520
+ });
521
+
522
+ it("buildHookCommand contract: Windows command shape has NO `|| true` suffix", () => {
523
+ // Explicit contract: on Windows, cmd.exe doesn't know `true`,
524
+ // so the shell backstop MUST be omitted. A regression that
525
+ // adds `|| true` back to the Windows command would cause every
526
+ // session with a missing binary to emit "'true' is not
527
+ // recognized as an internal or external command". The
528
+ // --session-hook exit-0 contract in the Node process is the
529
+ // only defense on Windows, by design.
530
+ const windowsCmd = buildHookCommand(WIN_SHIM_PATH, { platform: "win32" });
531
+ assert.ok(
532
+ !windowsCmd.includes("|| true"),
533
+ `Windows hook command must not contain "|| true" — cmd.exe has no such builtin. ` +
534
+ `Got: "${windowsCmd}"`,
535
+ );
536
+ assert.ok(
537
+ windowsCmd.endsWith("2>&1"),
538
+ `Windows command must end at "2>&1" — no backstop appended. Got: "${windowsCmd}"`,
539
+ );
540
+ });
541
+
542
+ it("the fingerprint is specific enough that innocuous user hooks do NOT match it", () => {
543
+ // Guard against false-positives on the shorter fingerprint.
544
+ // User-authored SessionStart hooks shouldn't happen to match
545
+ // `update --session-hook` by accident. Enumerate a few
546
+ // plausible user commands and verify they don't trip the
547
+ // predicate. If a new one starts matching, the fingerprint is
548
+ // too loose and needs tightening.
549
+ const plausibleUserCommands = [
550
+ "echo 'session started'",
551
+ "/usr/local/bin/claude-sync update",
552
+ "git status",
553
+ "npm update --save",
554
+ "brew update --quiet",
555
+ "some-other-tool --session-start",
556
+ // The closest plausible miss: a different CLI whose flags
557
+ // happen to include `--session-hook`. This is so specific
558
+ // (two-flag combination with a space between) that inventing
559
+ // a real conflicting command is hard, but document the limit.
560
+ "other-tool --session-begin",
561
+ ];
562
+ for (const userCmd of plausibleUserCommands) {
563
+ assert.ok(
564
+ !userCmd.includes(SESSION_HOOK_FINGERPRINT),
565
+ `User command "${userCmd}" must NOT match fingerprint ` +
566
+ `"${SESSION_HOOK_FINGERPRINT}" — false-positive means uninstall ` +
567
+ `would delete user-authored hooks.`,
568
+ );
569
+ }
570
+ });
571
+ });
572
+
108
573
  describe("mergeSessionHook — install fresh", () => {
109
574
  beforeEach(setup);
110
575
  afterEach(teardown);
@@ -260,7 +725,13 @@ describe("mergeSessionHook — idempotency", () => {
260
725
  1,
261
726
  "still exactly one SkillRepo hook (not duplicated)",
262
727
  );
263
- assert.ok(skillrepoHooks[0].command.startsWith("/new/path/skillrepo"));
728
+ // Path is shell-quoted (v3.1.2: see buildHookCommand docstring)
729
+ // so the absolute path appears INSIDE the command rather than
730
+ // at position 0.
731
+ assert.ok(
732
+ skillrepoHooks[0].command.includes("/new/path/skillrepo"),
733
+ `updated hook must use new path, got: ${skillrepoHooks[0].command}`,
734
+ );
264
735
  });
265
736
  });
266
737
 
@@ -435,15 +906,65 @@ describe("mergeSessionHook — failure modes", () => {
435
906
  afterEach(teardown);
436
907
 
437
908
  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.
909
+ // INTENT: a caller passing `binaryPath: null` (e.g. session-sync
910
+ // enable under npx) must not have the action throw. Skip
911
+ // gracefully with an actionable reason. Init bypasses this path
912
+ // in v3.1.2 by passing the post-auto-install absolute path
913
+ // explicitly via `binaryPath`.
442
914
  ASSERT_HOME_ISOLATED();
443
915
  const result = mergeSessionHook({ binaryPath: null });
444
916
  assert.equal(result.action, "skipped");
445
917
  assert.ok(result.reason);
446
- assert.match(result.reason, /global install/i);
918
+ // The remediation hint must mention `npm install -g` so the user
919
+ // has a copy-pasteable next step.
920
+ assert.match(result.reason, /npm install -g/);
921
+ });
922
+
923
+ it("v3.1.1 fix: returns 'skipped' under npx invocation even when `which skillrepo` would succeed", async () => {
924
+ // Regression guard for the v3.1.0 shipped bug: `which skillrepo`
925
+ // finds the transient npx cache path and returns it as if it
926
+ // were a stable install location. The hook command baked in at
927
+ // install time then points at `~/.npm/_npx/<hash>/.../skillrepo`
928
+ // which disappears on npm cache eviction or version bump.
929
+ //
930
+ // This test exercises the FIX path: `resolveSkillrepoBinary` now
931
+ // calls `isNpxInvocation()` BEFORE `which`, and returns null if
932
+ // npx is detected. That null propagates to mergeSessionHook,
933
+ // which returns `skipped` with the architect's intended reason.
934
+ ASSERT_HOME_ISOLATED();
935
+
936
+ // Simulate the npx invocation via process.argv[1]. The real
937
+ // invocation would have the npx-cache path as argv[1] and npx
938
+ // would put its bin dir on PATH — but simulating argv alone is
939
+ // sufficient to exercise the isNpxInvocation check.
940
+ const originalArgv = process.argv;
941
+ process.argv = [
942
+ "/usr/local/bin/node",
943
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
944
+ ];
945
+
946
+ try {
947
+ // Call WITHOUT passing binaryPath explicitly — forces the
948
+ // resolveSkillrepoBinary() path to run, which must detect npx
949
+ // and return null.
950
+ const { mergeSessionHook: mergeFresh } = await import(
951
+ "../../lib/mergers/session-hook.mjs?v311-npx-test=" + Date.now()
952
+ );
953
+ const result = mergeFresh();
954
+ assert.equal(
955
+ result.action,
956
+ "skipped",
957
+ "npx invocation must skip session-sync install",
958
+ );
959
+ assert.ok(result.reason, "skipped action must carry a reason");
960
+ assert.match(
961
+ result.reason,
962
+ /stable.*skillrepo/i,
963
+ "reason must mention the stable-binary requirement",
964
+ );
965
+ } finally {
966
+ process.argv = originalArgv;
967
+ }
447
968
  });
448
969
 
449
970
  it("throws diskError on unparseable settings file", () => {
@@ -462,6 +983,185 @@ describe("mergeSessionHook — failure modes", () => {
462
983
  /Cannot parse/i,
463
984
  );
464
985
  });
986
+
987
+ it("v3.1.2 bypass: explicit binaryPath under npx → 'updated' when prior _npx-cache hook exists", async () => {
988
+ // QA gap fix: the bypass-via-binaryPath contract must work
989
+ // for ALL three success states (installed/updated/unchanged),
990
+ // not just "installed" (the empty-disk case). This test
991
+ // exercises "updated" — pre-seed a hook with an _npx cache
992
+ // path command, then call merge with an explicit non-npx
993
+ // binaryPath, assert action is "updated" and the new path
994
+ // replaced the cache path.
995
+ ASSERT_HOME_ISOLATED();
996
+
997
+ const originalArgv = process.argv;
998
+ process.argv = [
999
+ "/usr/local/bin/node",
1000
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
1001
+ ];
1002
+
1003
+ try {
1004
+ // Pre-seed a v3.1.0-style hook with an _npx cache path baked
1005
+ // in (the bug v3.1.1 was trying to prevent).
1006
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
1007
+ const STALE_NPX_PATH =
1008
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo";
1009
+ const stalePath = join(
1010
+ process.cwd(),
1011
+ ".claude",
1012
+ "settings.local.json",
1013
+ );
1014
+ writeFileSync(
1015
+ stalePath,
1016
+ JSON.stringify(
1017
+ {
1018
+ hooks: {
1019
+ SessionStart: [
1020
+ {
1021
+ hooks: [
1022
+ {
1023
+ type: "command",
1024
+ command: `${STALE_NPX_PATH} update --session-hook 2>&1 || true`,
1025
+ },
1026
+ ],
1027
+ },
1028
+ ],
1029
+ },
1030
+ },
1031
+ null,
1032
+ 2,
1033
+ ),
1034
+ );
1035
+
1036
+ const { mergeSessionHook: mergeFresh } = await import(
1037
+ "../../lib/mergers/session-hook.mjs?v312-updated-test=" + Date.now()
1038
+ );
1039
+ const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
1040
+ const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
1041
+
1042
+ assert.equal(
1043
+ result.action,
1044
+ "updated",
1045
+ "stale _npx hook with new binaryPath must be 'updated', not 'installed' or 'skipped'",
1046
+ );
1047
+ // The on-disk file must reflect the new path, not the cache.
1048
+ const written = JSON.parse(readFileSync(stalePath, "utf-8"));
1049
+ const cmd = written.hooks.SessionStart[0].hooks[0].command;
1050
+ assert.ok(
1051
+ cmd.includes(POST_INSTALL_PATH),
1052
+ `updated hook must use new path, got: ${cmd}`,
1053
+ );
1054
+ assert.ok(
1055
+ !cmd.includes("_npx"),
1056
+ "updated hook must NOT leak the prior _npx cache path",
1057
+ );
1058
+ } finally {
1059
+ process.argv = originalArgv;
1060
+ }
1061
+ });
1062
+
1063
+ it("v3.1.2 bypass: explicit binaryPath under npx → 'unchanged' when same hook already present", async () => {
1064
+ // The third success state — repeated init with the same
1065
+ // global. Idempotency: no file write, action is "unchanged".
1066
+ ASSERT_HOME_ISOLATED();
1067
+
1068
+ const originalArgv = process.argv;
1069
+ process.argv = [
1070
+ "/usr/local/bin/node",
1071
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
1072
+ ];
1073
+
1074
+ try {
1075
+ const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
1076
+ const { buildHookCommand: buildFresh, mergeSessionHook: mergeFresh } =
1077
+ await import(
1078
+ "../../lib/mergers/session-hook.mjs?v312-unchanged-test=" + Date.now()
1079
+ );
1080
+ const expectedCmd = buildFresh(POST_INSTALL_PATH);
1081
+
1082
+ // Pre-seed the EXACT command we'd write — idempotent re-run.
1083
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
1084
+ writeFileSync(
1085
+ join(process.cwd(), ".claude", "settings.local.json"),
1086
+ JSON.stringify(
1087
+ {
1088
+ hooks: {
1089
+ SessionStart: [
1090
+ {
1091
+ hooks: [{ type: "command", command: expectedCmd }],
1092
+ },
1093
+ ],
1094
+ },
1095
+ },
1096
+ null,
1097
+ 2,
1098
+ ),
1099
+ );
1100
+
1101
+ const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
1102
+ assert.equal(
1103
+ result.action,
1104
+ "unchanged",
1105
+ "identical hook with same binaryPath must be 'unchanged'",
1106
+ );
1107
+ assert.equal(result.command, expectedCmd);
1108
+ } finally {
1109
+ process.argv = originalArgv;
1110
+ }
1111
+ });
1112
+
1113
+ it("v3.1.2: explicit binaryPath bypasses the isNpxInvocation guard", async () => {
1114
+ // INTENT: init's v3.1.2 auto-install flow runs `npm install -g
1115
+ // skillrepo` itself under npx, then calls mergeSessionHook with
1116
+ // the resulting absolute path passed explicitly via `binaryPath`.
1117
+ // The internal `resolveSkillrepoBinary` early-returns under npx —
1118
+ // but when the caller already has the binary path, that guard
1119
+ // must NOT block the install. The `binaryPath` parameter is the
1120
+ // bypass mechanism.
1121
+ //
1122
+ // This test is the lock-in for the v3.1.2 contract: explicit
1123
+ // `binaryPath` short-circuits the npx detection. If a future
1124
+ // refactor moves the npx guard into mergeSessionHook itself
1125
+ // (rather than resolveSkillrepoBinary), this test fails — and
1126
+ // it should.
1127
+ ASSERT_HOME_ISOLATED();
1128
+
1129
+ const originalArgv = process.argv;
1130
+ process.argv = [
1131
+ "/usr/local/bin/node",
1132
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
1133
+ ];
1134
+
1135
+ try {
1136
+ const { mergeSessionHook: mergeFresh, buildHookCommand: buildFresh } =
1137
+ await import(
1138
+ "../../lib/mergers/session-hook.mjs?v312-bypass-test=" + Date.now()
1139
+ );
1140
+ // Pass an absolute, stable path explicitly — the kind init's
1141
+ // auto-install flow obtains from `resolveGlobalBinary()` after
1142
+ // a successful `npm install -g`.
1143
+ const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
1144
+ const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
1145
+ assert.equal(
1146
+ result.action,
1147
+ "installed",
1148
+ "explicit binaryPath under npx must succeed, not skip",
1149
+ );
1150
+ // The hook command must contain the explicit path (not the
1151
+ // _npx cache path).
1152
+ assert.equal(result.command, buildFresh(POST_INSTALL_PATH));
1153
+ assert.ok(
1154
+ result.command.includes(POST_INSTALL_PATH),
1155
+ "hook command must use the explicit path verbatim",
1156
+ );
1157
+ assert.ok(
1158
+ !result.command.includes("_npx"),
1159
+ "hook command must NOT leak the _npx cache path",
1160
+ );
1161
+ } finally {
1162
+ process.argv = originalArgv;
1163
+ }
1164
+ });
465
1165
  });
466
1166
 
467
1167
  describe("removeSessionHook — inverse of install", () => {