skillrepo 4.7.0 → 4.8.1

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 (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/init.mjs +15 -5
  4. package/src/lib/http.mjs +12 -2
  5. package/src/lib/sync.mjs +124 -8
  6. package/src/test/commands/add.test.mjs +78 -1
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +133 -0
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/mock-server.mjs +19 -11
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +615 -0
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +434 -0
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -0,0 +1,724 @@
1
+ /**
2
+ * Unit tests for src/commands/init-session-sync.mjs (#894 / v3.1.2).
3
+ *
4
+ * Step 6 of `skillrepo init` owns the SessionStart-hook decision tree,
5
+ * the lowest-branch-coverage module in the CLI before these tests. The
6
+ * exported `installSessionSyncHook(...)` is directly unit-testable: it
7
+ * takes injected `deps` (`confirmFn`, `spawn`, `getCliVersion`) and a
8
+ * printer `p`, so we drive every branch WITHOUT running the full
9
+ * `runInit` orchestration.
10
+ *
11
+ * ## Decision tree under test
12
+ *
13
+ * 1. noSessionSync → OptedOut
14
+ * 2. !claudeTargeted → NotApplicable
15
+ * 3. non-npx, declined → Declined
16
+ * 4. non-npx, proceed → Installed/Updated/Unchanged
17
+ * 5. npx + existing global on PATH → use-existing-global
18
+ * 6. npx + no global, declined → Declined + manual steps
19
+ * 7. npx + no global, getCliVersion throw→ Skipped + manual steps
20
+ * 8. npx + no global, install fails → Skipped
21
+ * 9. npx + no global, install succeeds → Installed (auto-install)
22
+ *
23
+ * ## Environment control
24
+ *
25
+ * npx-mode is decided by `isTransientRunnerInvocation()`, which reads
26
+ * `process.env._` (suffix `/npx`) and `process.argv[1]` (cache
27
+ * substring). We force npx mode by setting `process.env._` to a value
28
+ * ending in `/npx`; non-npx mode by deleting `_` (the node:test
29
+ * runner's argv[1] contains no runner cache substring). We
30
+ * save/restore `_` and `PATH` in beforeEach/afterEach.
31
+ *
32
+ * Existing-global presence is decided by `resolveGlobalBinary()`,
33
+ * which scans `process.env.PATH`. A `skillrepo` shim on PATH makes it
34
+ * resolve; an empty/shim-free PATH makes it return null. HOME is
35
+ * isolated to a sandbox so any hook write lands in the sandbox.
36
+ */
37
+
38
+ import { describe, it, beforeEach, afterEach } from "node:test";
39
+ import assert from "node:assert/strict";
40
+ import {
41
+ mkdtempSync,
42
+ mkdirSync,
43
+ rmSync,
44
+ readFileSync,
45
+ writeFileSync,
46
+ existsSync,
47
+ } from "node:fs";
48
+ import { join } from "node:path";
49
+ import { tmpdir } from "node:os";
50
+
51
+ import { installSessionSyncHook } from "../../commands/init-session-sync.mjs";
52
+ import { SessionSyncAction } from "../../commands/session-sync-actions.mjs";
53
+ import { buildHookCommand } from "../../lib/mergers/session-hook.mjs";
54
+ import { SESSION_HOOK_FINGERPRINT } from "../../lib/artifact-registry.mjs";
55
+ import {
56
+ captureHome,
57
+ setSandboxHome,
58
+ restoreHome,
59
+ assertHomeIsolated,
60
+ } from "../helpers/sandbox-home.mjs";
61
+ import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
62
+
63
+ let sandbox;
64
+ let originalCwd;
65
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
66
+ let originalHomeEnv;
67
+ /** @type {string | undefined} */
68
+ let originalUnderscore;
69
+ /** @type {string | undefined} */
70
+ let originalPath;
71
+
72
+ /**
73
+ * Stub printer matching the `makePrinter` surface used by this module:
74
+ * only `success` and `warning` are called. Records every line so tests
75
+ * can assert on the user-facing copy (especially the manual-steps text
76
+ * emitted by `printManualSteps`).
77
+ */
78
+ function makeStubPrinter() {
79
+ const success = [];
80
+ const warning = [];
81
+ return {
82
+ success(msg) {
83
+ success.push(msg);
84
+ },
85
+ warning(msg) {
86
+ warning.push(msg);
87
+ },
88
+ /** All lines, regardless of channel, joined for substring asserts. */
89
+ all() {
90
+ return [...success, ...warning].join("\n");
91
+ },
92
+ _success: success,
93
+ _warning: warning,
94
+ };
95
+ }
96
+
97
+ /** Force npx (transient-runner) mode via process.env._. */
98
+ function forceNpxMode() {
99
+ process.env._ = "/usr/local/bin/npx";
100
+ }
101
+
102
+ /** Force non-npx mode by removing the `_` signal. */
103
+ function forceNonNpxMode() {
104
+ delete process.env._;
105
+ }
106
+
107
+ /**
108
+ * Clear `skillrepo` off PATH entirely so `resolveGlobalBinary()` and
109
+ * `mergeSessionHook`'s resolver both return null. We point PATH at an
110
+ * empty sandbox bin dir (kept absolute so the locator doesn't drop it
111
+ * as a relative entry).
112
+ */
113
+ function clearSkillrepoFromPath() {
114
+ const emptyBin = join(sandbox, "empty-bin");
115
+ mkdirSync(emptyBin, { recursive: true });
116
+ process.env.PATH = emptyBin;
117
+ }
118
+
119
+ function ASSERT_HOME_ISOLATED() {
120
+ assertHomeIsolated(tmpdir(), "init-session-sync tests");
121
+ }
122
+
123
+ function setup() {
124
+ sandbox = mkdtempSync(join(tmpdir(), "cli-init-session-sync-"));
125
+ mkdirSync(join(sandbox, "project"), { recursive: true });
126
+ mkdirSync(join(sandbox, "home"), { recursive: true });
127
+ originalCwd = process.cwd();
128
+ originalHomeEnv = captureHome();
129
+ originalUnderscore = process.env._;
130
+ originalPath = process.env.PATH;
131
+ process.chdir(join(sandbox, "project"));
132
+ setSandboxHome(join(sandbox, "home"));
133
+ ASSERT_HOME_ISOLATED();
134
+ }
135
+
136
+ function teardown() {
137
+ process.chdir(originalCwd);
138
+ // Restore PATH first (shim install/uninstall mutates it).
139
+ if (originalPath === undefined) delete process.env.PATH;
140
+ else process.env.PATH = originalPath;
141
+ if (originalUnderscore === undefined) delete process.env._;
142
+ else process.env._ = originalUnderscore;
143
+ restoreHome(originalHomeEnv);
144
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
145
+ }
146
+
147
+ /** Absolute path to the project-local settings file in the sandbox. */
148
+ function projectSettingsPath() {
149
+ return join(process.cwd(), ".claude", "settings.local.json");
150
+ }
151
+
152
+ /** Does the on-disk project settings file contain the SkillRepo hook? */
153
+ function projectHasHook() {
154
+ const p = projectSettingsPath();
155
+ if (!existsSync(p)) return false;
156
+ const parsed = JSON.parse(readFileSync(p, "utf-8"));
157
+ return (parsed.hooks?.SessionStart ?? [])
158
+ .flatMap((g) => g?.hooks ?? [])
159
+ .some((h) => h?.command?.includes(SESSION_HOOK_FINGERPRINT));
160
+ }
161
+
162
+ // ──────────────────────────────────────────────────────────────────
163
+ // 1. noSessionSync → OptedOut
164
+ // ──────────────────────────────────────────────────────────────────
165
+
166
+ describe("init-session-sync — opted out / not applicable", () => {
167
+ beforeEach(setup);
168
+ afterEach(teardown);
169
+
170
+ it("returns OptedOut and writes nothing when --no-session-sync", async () => {
171
+ const p = makeStubPrinter();
172
+ const r = await installSessionSyncHook({
173
+ noSessionSync: true,
174
+ claudeTargeted: true,
175
+ yes: true,
176
+ json: false,
177
+ global: false,
178
+ p,
179
+ });
180
+
181
+ assert.equal(r.action, SessionSyncAction.OptedOut);
182
+ assert.equal(r.path, null);
183
+ assert.equal(r.globalInstallActive, false);
184
+ assert.match(p.all(), /--no-session-sync/);
185
+ assert.equal(projectHasHook(), false, "must not write a hook");
186
+ });
187
+
188
+ it("returns NotApplicable when Claude Code is not a target", async () => {
189
+ const p = makeStubPrinter();
190
+ const r = await installSessionSyncHook({
191
+ noSessionSync: false,
192
+ claudeTargeted: false,
193
+ yes: true,
194
+ json: false,
195
+ global: false,
196
+ p,
197
+ });
198
+
199
+ assert.equal(r.action, SessionSyncAction.NotApplicable);
200
+ assert.equal(r.path, null);
201
+ assert.equal(r.globalInstallActive, false);
202
+ assert.match(p.all(), /Claude Code-specific/);
203
+ assert.equal(projectHasHook(), false);
204
+ });
205
+ });
206
+
207
+ // ──────────────────────────────────────────────────────────────────
208
+ // 3 + 4. Non-npx branch
209
+ // ──────────────────────────────────────────────────────────────────
210
+
211
+ describe("init-session-sync — non-npx branch", () => {
212
+ let shimHandle;
213
+
214
+ beforeEach(() => {
215
+ setup();
216
+ forceNonNpxMode();
217
+ });
218
+ afterEach(() => {
219
+ uninstallShim(shimHandle);
220
+ shimHandle = undefined;
221
+ teardown();
222
+ });
223
+
224
+ it("declined (prompt says no) → Declined, no write", async () => {
225
+ // No shim needed: we never reach the merge step.
226
+ clearSkillrepoFromPath();
227
+ const p = makeStubPrinter();
228
+ const r = await installSessionSyncHook({
229
+ noSessionSync: false,
230
+ claudeTargeted: true,
231
+ yes: false,
232
+ json: false,
233
+ global: false,
234
+ p,
235
+ deps: { confirmFn: () => false },
236
+ });
237
+
238
+ assert.equal(r.action, SessionSyncAction.Declined);
239
+ assert.equal(r.path, null);
240
+ assert.equal(r.globalInstallActive, false);
241
+ assert.match(p.all(), /session-sync enable/);
242
+ assert.equal(projectHasHook(), false);
243
+ });
244
+
245
+ it("proceed via --yes → Installed (fresh hook on disk)", async () => {
246
+ // Shim on PATH so mergeSessionHook's resolver finds a binary.
247
+ shimHandle = installShim(join(sandbox, "home"));
248
+ const p = makeStubPrinter();
249
+ const r = await installSessionSyncHook({
250
+ noSessionSync: false,
251
+ claudeTargeted: true,
252
+ yes: true,
253
+ json: false,
254
+ global: false,
255
+ p,
256
+ });
257
+
258
+ assert.equal(r.action, SessionSyncAction.Installed);
259
+ assert.equal(r.path, ".claude/settings.local.json");
260
+ assert.equal(r.globalInstallActive, true);
261
+ assert.match(p.all(), /installed/i);
262
+ assert.equal(projectHasHook(), true);
263
+ });
264
+
265
+ it("proceed via confirm prompt (returns true) → Installed", async () => {
266
+ // Exercises the confirmFn-returns-true path (yes:false but prompt
267
+ // accepts), distinct from the --yes short-circuit above.
268
+ shimHandle = installShim(join(sandbox, "home"));
269
+ const p = makeStubPrinter();
270
+ const r = await installSessionSyncHook({
271
+ noSessionSync: false,
272
+ claudeTargeted: true,
273
+ yes: false,
274
+ json: false,
275
+ global: false,
276
+ p,
277
+ deps: { confirmFn: () => true },
278
+ });
279
+
280
+ assert.equal(r.action, SessionSyncAction.Installed);
281
+ assert.equal(r.globalInstallActive, true);
282
+ assert.equal(projectHasHook(), true);
283
+ });
284
+
285
+ it("re-run with same binary → Unchanged (idempotent)", async () => {
286
+ shimHandle = installShim(join(sandbox, "home"));
287
+ const p1 = makeStubPrinter();
288
+ await installSessionSyncHook({
289
+ noSessionSync: false,
290
+ claudeTargeted: true,
291
+ yes: true,
292
+ json: false,
293
+ global: false,
294
+ p: p1,
295
+ });
296
+
297
+ const p2 = makeStubPrinter();
298
+ const r = await installSessionSyncHook({
299
+ noSessionSync: false,
300
+ claudeTargeted: true,
301
+ yes: true,
302
+ json: false,
303
+ global: false,
304
+ p: p2,
305
+ });
306
+
307
+ assert.equal(r.action, SessionSyncAction.Unchanged);
308
+ assert.equal(r.globalInstallActive, true);
309
+ assert.match(p2.all(), /already installed/i);
310
+ });
311
+
312
+ it("existing stale SkillRepo hook → Updated", async () => {
313
+ shimHandle = installShim(join(sandbox, "home"));
314
+ // Seed a SkillRepo hook pointing at a different binary path so the
315
+ // command differs and triggers the in-place 'updated' replacement.
316
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
317
+ const stale = buildHookCommand("/old/location/skillrepo");
318
+ writeFileSync(
319
+ projectSettingsPath(),
320
+ JSON.stringify(
321
+ {
322
+ hooks: {
323
+ SessionStart: [{ hooks: [{ type: "command", command: stale }] }],
324
+ },
325
+ },
326
+ null,
327
+ 2,
328
+ ),
329
+ );
330
+
331
+ const p = makeStubPrinter();
332
+ const r = await installSessionSyncHook({
333
+ noSessionSync: false,
334
+ claudeTargeted: true,
335
+ yes: true,
336
+ json: false,
337
+ global: false,
338
+ p,
339
+ });
340
+
341
+ assert.equal(r.action, SessionSyncAction.Updated);
342
+ assert.equal(r.globalInstallActive, true);
343
+ assert.match(p.all(), /updated/i);
344
+ });
345
+
346
+ it("Skipped when no binary resolvable (bare-install PATH miss)", async () => {
347
+ // Non-npx, proceed, but skillrepo is NOT on PATH → mergeSessionHook
348
+ // returns action 'skipped' with an actionable reason. Exercises the
349
+ // Skipped branch of tryMergeAndPrint (252-281) and isHookActive's
350
+ // false arm for a non-active action.
351
+ clearSkillrepoFromPath();
352
+ const p = makeStubPrinter();
353
+ const r = await installSessionSyncHook({
354
+ noSessionSync: false,
355
+ claudeTargeted: true,
356
+ yes: true,
357
+ json: false,
358
+ global: false,
359
+ p,
360
+ });
361
+
362
+ assert.equal(r.action, SessionSyncAction.Skipped);
363
+ assert.equal(r.globalInstallActive, false);
364
+ assert.match(p.all(), /skillrepo/i);
365
+ assert.equal(projectHasHook(), false);
366
+ });
367
+
368
+ it("Failed when settings file is corrupt (disk error caught)", async () => {
369
+ // A present-but-corrupt settings file makes mergeSessionHook throw;
370
+ // tryMergeAndPrint's catch (272-280) converts it to Failed and the
371
+ // module does NOT propagate the throw.
372
+ shimHandle = installShim(join(sandbox, "home"));
373
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
374
+ writeFileSync(projectSettingsPath(), "{ not valid json");
375
+
376
+ const p = makeStubPrinter();
377
+ const r = await installSessionSyncHook({
378
+ noSessionSync: false,
379
+ claudeTargeted: true,
380
+ yes: true,
381
+ json: false,
382
+ global: false,
383
+ p,
384
+ });
385
+
386
+ assert.equal(r.action, SessionSyncAction.Failed);
387
+ assert.equal(r.path, null);
388
+ assert.equal(r.globalInstallActive, false);
389
+ assert.match(p.all(), /Session sync failed/i);
390
+ });
391
+ });
392
+
393
+ // ──────────────────────────────────────────────────────────────────
394
+ // 5. npx + existing global on PATH
395
+ // ──────────────────────────────────────────────────────────────────
396
+
397
+ describe("init-session-sync — npx with existing global", () => {
398
+ let shimHandle;
399
+
400
+ beforeEach(() => {
401
+ setup();
402
+ forceNpxMode();
403
+ // A resolvable global skillrepo on PATH → resolveGlobalBinary()
404
+ // returns a path → use-existing-global branch.
405
+ shimHandle = installShim(join(sandbox, "home"));
406
+ });
407
+ afterEach(() => {
408
+ uninstallShim(shimHandle);
409
+ shimHandle = undefined;
410
+ teardown();
411
+ });
412
+
413
+ it("uses the existing global and installs the hook → Installed", async () => {
414
+ const p = makeStubPrinter();
415
+ const r = await installSessionSyncHook({
416
+ noSessionSync: false,
417
+ claudeTargeted: true,
418
+ yes: true,
419
+ json: false,
420
+ global: false,
421
+ p,
422
+ });
423
+
424
+ assert.equal(r.action, SessionSyncAction.Installed);
425
+ // globalInstallActive is forced true regardless of hook outcome.
426
+ assert.equal(r.globalInstallActive, true);
427
+ assert.match(p.all(), /Found global skillrepo/);
428
+ assert.match(p.all(), /using it for session sync/);
429
+ assert.equal(projectHasHook(), true);
430
+ });
431
+
432
+ it("forces globalInstallActive=true even when the hook is Unchanged", async () => {
433
+ // Second run: hook is already present → mergeSessionHook returns
434
+ // Unchanged, but the existing-global branch still overrides
435
+ // globalInstallActive to true (line 177-178). isHookActive would
436
+ // already be true here, so to truly prove the override we also
437
+ // cover the Skipped-but-still-active case below.
438
+ const p1 = makeStubPrinter();
439
+ await installSessionSyncHook({
440
+ noSessionSync: false,
441
+ claudeTargeted: true,
442
+ yes: true,
443
+ json: false,
444
+ global: false,
445
+ p: p1,
446
+ });
447
+
448
+ const p2 = makeStubPrinter();
449
+ const r = await installSessionSyncHook({
450
+ noSessionSync: false,
451
+ claudeTargeted: true,
452
+ yes: true,
453
+ json: false,
454
+ global: false,
455
+ p: p2,
456
+ });
457
+
458
+ assert.equal(r.action, SessionSyncAction.Unchanged);
459
+ assert.equal(r.globalInstallActive, true);
460
+ });
461
+ });
462
+
463
+ // ──────────────────────────────────────────────────────────────────
464
+ // 6-9. npx + no global (auto-install branch)
465
+ // ──────────────────────────────────────────────────────────────────
466
+
467
+ describe("init-session-sync — npx without global (auto-install)", () => {
468
+ let shimHandle;
469
+
470
+ beforeEach(() => {
471
+ setup();
472
+ forceNpxMode();
473
+ // No skillrepo on PATH → resolveGlobalBinary() (line 132) returns
474
+ // null → auto-install branch.
475
+ clearSkillrepoFromPath();
476
+ });
477
+ afterEach(() => {
478
+ uninstallShim(shimHandle);
479
+ shimHandle = undefined;
480
+ teardown();
481
+ });
482
+
483
+ it("declined → Declined + manual-steps hint (npx command)", async () => {
484
+ const p = makeStubPrinter();
485
+ const r = await installSessionSyncHook({
486
+ noSessionSync: false,
487
+ claudeTargeted: true,
488
+ yes: false,
489
+ json: false,
490
+ global: false,
491
+ p,
492
+ deps: { confirmFn: () => false },
493
+ });
494
+
495
+ assert.equal(r.action, SessionSyncAction.Declined);
496
+ assert.equal(r.globalInstallActive, false);
497
+ assert.match(p.all(), /declined/i);
498
+ // printManualSteps uses the detected runner's install command.
499
+ // Under forced npx mode the runner resolves to "npx" → the
500
+ // canonical "npm install -g skillrepo" command.
501
+ assert.match(p.all(), /npm install -g skillrepo/);
502
+ assert.match(p.all(), /session-sync enable/);
503
+ assert.equal(projectHasHook(), false);
504
+ });
505
+
506
+ it("getCliVersion throws → Skipped + manual steps", async () => {
507
+ const p = makeStubPrinter();
508
+ const r = await installSessionSyncHook({
509
+ noSessionSync: false,
510
+ claudeTargeted: true,
511
+ yes: true, // proceed without prompting
512
+ json: false,
513
+ global: false,
514
+ p,
515
+ deps: {
516
+ getCliVersion: () => {
517
+ throw new Error("boom");
518
+ },
519
+ // spawn must never be reached; fail loudly if it is.
520
+ spawn: () => {
521
+ throw new Error("spawn must not run after version-read failure");
522
+ },
523
+ },
524
+ });
525
+
526
+ assert.equal(r.action, SessionSyncAction.Skipped);
527
+ assert.equal(r.globalInstallActive, false);
528
+ assert.match(p.all(), /Could not determine CLI version/);
529
+ assert.match(p.all(), /boom/);
530
+ assert.match(p.all(), /npm install -g skillrepo/);
531
+ assert.equal(projectHasHook(), false);
532
+ });
533
+
534
+ it("install fails (npm non-zero) → Skipped", async () => {
535
+ // Stub spawn returns a child that closes with a non-zero exit code,
536
+ // so installSkillrepoGlobally reports success:false (npm-nonzero).
537
+ const p = makeStubPrinter();
538
+ const r = await installSessionSyncHook({
539
+ noSessionSync: false,
540
+ claudeTargeted: true,
541
+ yes: true,
542
+ json: false,
543
+ global: false,
544
+ p,
545
+ deps: {
546
+ getCliVersion: () => "9.9.9",
547
+ spawn: makeSpawnStub({ exitCode: 1 }),
548
+ },
549
+ });
550
+
551
+ assert.equal(r.action, SessionSyncAction.Skipped);
552
+ assert.equal(r.globalInstallActive, false);
553
+ assert.match(p.all(), /Could not install skillrepo globally/);
554
+ assert.match(p.all(), /npm install -g skillrepo/);
555
+ assert.equal(projectHasHook(), false);
556
+ });
557
+
558
+ it("install succeeds → Installed (auto-install path)", async () => {
559
+ // Stub spawn closes with code 0 AND, just before emitting close,
560
+ // installs a real shim on PATH so installSkillrepoGlobally's
561
+ // post-install resolveGlobalBinary() finds the binary. The
562
+ // top-level resolveGlobalBinary() at line 132 already ran (and
563
+ // returned null) before the spawn, so we still took the
564
+ // auto-install branch.
565
+ let installedShim;
566
+ const p = makeStubPrinter();
567
+ const r = await installSessionSyncHook({
568
+ noSessionSync: false,
569
+ claudeTargeted: true,
570
+ yes: true,
571
+ json: false,
572
+ global: false,
573
+ p,
574
+ deps: {
575
+ getCliVersion: () => "9.9.9",
576
+ spawn: makeSpawnStub({
577
+ exitCode: 0,
578
+ onBeforeClose: () => {
579
+ installedShim = installShim(join(sandbox, "home"));
580
+ },
581
+ }),
582
+ },
583
+ });
584
+
585
+ // Clean up the shim PATH mutation done inside the spawn stub.
586
+ shimHandle = installedShim;
587
+
588
+ assert.equal(r.action, SessionSyncAction.Installed);
589
+ assert.equal(r.globalInstallActive, true);
590
+ assert.match(p.all(), /Running: npm install -g skillrepo@9\.9\.9/);
591
+ assert.match(p.all(), /Installed skillrepo@9\.9\.9 globally/);
592
+ assert.equal(projectHasHook(), true);
593
+ });
594
+
595
+ it("falls back to the real getCliVersion when none is injected", async () => {
596
+ // deps.getCliVersion omitted → the `?? getCliVersion` default
597
+ // (line 203) is exercised. The real read returns the package's
598
+ // own version; spawn is still stubbed so no npm runs. Asserting
599
+ // the printed version is a non-empty semver-ish string proves the
600
+ // real reader ran.
601
+ let installedShim;
602
+ const p = makeStubPrinter();
603
+ const r = await installSessionSyncHook({
604
+ noSessionSync: false,
605
+ claudeTargeted: true,
606
+ yes: true,
607
+ json: false,
608
+ global: false,
609
+ p,
610
+ deps: {
611
+ spawn: makeSpawnStub({
612
+ exitCode: 0,
613
+ onBeforeClose: () => {
614
+ installedShim = installShim(join(sandbox, "home"));
615
+ },
616
+ }),
617
+ },
618
+ });
619
+
620
+ shimHandle = installedShim;
621
+
622
+ assert.equal(r.action, SessionSyncAction.Installed);
623
+ assert.equal(r.globalInstallActive, true);
624
+ assert.match(p.all(), /Running: npm install -g skillrepo@\d+\.\d+\.\d+/);
625
+ });
626
+
627
+ it("non-Error thrown by getCliVersion → String(err) in the message", async () => {
628
+ // Throwing a non-Error (a bare string) makes `err?.message`
629
+ // undefined, exercising the `?? String(err)` arm at line 209.
630
+ const p = makeStubPrinter();
631
+ const r = await installSessionSyncHook({
632
+ noSessionSync: false,
633
+ claudeTargeted: true,
634
+ yes: true,
635
+ json: false,
636
+ global: false,
637
+ p,
638
+ deps: {
639
+ getCliVersion: () => {
640
+ throw "string-failure";
641
+ },
642
+ },
643
+ });
644
+
645
+ assert.equal(r.action, SessionSyncAction.Skipped);
646
+ assert.match(
647
+ p.all(),
648
+ /Could not determine CLI version for global install: string-failure/,
649
+ );
650
+ });
651
+
652
+ it("--json mode runs install silently and still installs the hook", async () => {
653
+ // json:true → installSkillrepoGlobally gets outputMode 'silent',
654
+ // which pipes stdout/stderr. The spawn stub provides those streams.
655
+ let installedShim;
656
+ const p = makeStubPrinter();
657
+ const r = await installSessionSyncHook({
658
+ noSessionSync: false,
659
+ claudeTargeted: true,
660
+ yes: true,
661
+ json: true,
662
+ global: false,
663
+ p,
664
+ deps: {
665
+ getCliVersion: () => "9.9.9",
666
+ spawn: makeSpawnStub({
667
+ exitCode: 0,
668
+ silent: true,
669
+ onBeforeClose: () => {
670
+ installedShim = installShim(join(sandbox, "home"));
671
+ },
672
+ }),
673
+ },
674
+ });
675
+
676
+ shimHandle = installedShim;
677
+
678
+ assert.equal(r.action, SessionSyncAction.Installed);
679
+ assert.equal(r.globalInstallActive, true);
680
+ assert.equal(projectHasHook(), true);
681
+ });
682
+ });
683
+
684
+ // ──────────────────────────────────────────────────────────────────
685
+ // Spawn stub for installSkillrepoGlobally
686
+ // ──────────────────────────────────────────────────────────────────
687
+
688
+ import { EventEmitter } from "node:events";
689
+ import { Readable } from "node:stream";
690
+
691
+ /**
692
+ * Build an injectable `spawn` stub matching the contract
693
+ * `installSkillrepoGlobally` expects:
694
+ * - returns a child EventEmitter
695
+ * - emits `close` with an exit code on a later tick
696
+ * - in `silent` mode the child carries readable `stdout`/`stderr`
697
+ *
698
+ * @param {object} opts
699
+ * @param {number} opts.exitCode - exit code to emit via `close`.
700
+ * @param {boolean} [opts.silent] - attach stdout/stderr streams (for
701
+ * the `outputMode: "silent"` capture path).
702
+ * @param {Function} [opts.onBeforeClose] - run synchronously right
703
+ * before the `close` event fires (used to install a PATH shim
704
+ * so the post-install binary resolution succeeds).
705
+ */
706
+ function makeSpawnStub({ exitCode, silent = false, onBeforeClose }) {
707
+ return function spawnStub() {
708
+ const child = new EventEmitter();
709
+ if (silent) {
710
+ // Provide empty readable streams; installSkillrepoGlobally
711
+ // attaches `data` listeners to drain/capture them.
712
+ child.stdout = Readable.from([]);
713
+ child.stderr = Readable.from([]);
714
+ }
715
+ child.kill = () => {};
716
+ // Fire close on the next tick so the awaiting Promise's listeners
717
+ // are registered first.
718
+ setImmediate(() => {
719
+ if (onBeforeClose) onBeforeClose();
720
+ child.emit("close", exitCode);
721
+ });
722
+ return child;
723
+ };
724
+ }