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
@@ -45,26 +45,32 @@ import {
45
45
  BLOCKED_EXTENSIONS,
46
46
  } from "../../lib/file-write.mjs";
47
47
  import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
48
+ import {
49
+ captureHome,
50
+ setSandboxHome,
51
+ restoreHome,
52
+ } from "../helpers/sandbox-home.mjs";
48
53
 
49
54
  // ── Test sandbox helpers ────────────────────────────────────────────────
50
55
 
51
56
  let sandbox;
52
57
  let originalCwd;
53
- let originalHome;
58
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
59
+ let originalHomeEnv;
54
60
 
55
61
  function setupSandbox() {
56
62
  sandbox = mkdtempSync(join(tmpdir(), "cli-fw-"));
57
63
  mkdirSync(join(sandbox, "project"), { recursive: true });
58
64
  mkdirSync(join(sandbox, "home"), { recursive: true });
59
65
  originalCwd = process.cwd();
60
- originalHome = process.env.HOME;
66
+ originalHomeEnv = captureHome();
61
67
  process.chdir(join(sandbox, "project"));
62
- process.env.HOME = join(sandbox, "home");
68
+ setSandboxHome(join(sandbox, "home"));
63
69
  }
64
70
 
65
71
  function teardownSandbox() {
66
72
  process.chdir(originalCwd);
67
- process.env.HOME = originalHome;
73
+ restoreHome(originalHomeEnv);
68
74
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
69
75
  }
70
76
 
@@ -262,14 +268,18 @@ describe("resolvePlacementDir", () => {
262
268
 
263
269
  it("resolves claudeProject under cwd", () => {
264
270
  const dir = resolvePlacementDir("claudeProject", "pdf-helper");
265
- assert.ok(dir.endsWith("/.claude/skills/pdf-helper"));
266
- assert.ok(dir.startsWith(process.cwd()));
271
+ // Use join() for the expected suffix so the platform-native
272
+ // separator is compared (backslash on Windows, forward slash
273
+ // on POSIX). Hardcoding "/" would silently fail on Windows.
274
+ assert.equal(dir, join(process.cwd(), ".claude", "skills", "pdf-helper"));
267
275
  });
268
276
 
269
277
  it("resolves claudeGlobal under HOME", () => {
270
278
  const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
271
- assert.ok(dir.endsWith("/.claude/skills/pdf-helper"));
272
- assert.ok(dir.startsWith(process.env.HOME));
279
+ assert.equal(
280
+ dir,
281
+ join(process.env.HOME, ".claude", "skills", "pdf-helper"),
282
+ );
273
283
  });
274
284
 
275
285
  it("resolves projectFallback under cwd /skills/", () => {
@@ -456,8 +466,12 @@ describe("writeSkillDir — happy path", () => {
456
466
  const skill = minimalSkill();
457
467
  const result = writeSkillDir(skill, { vendors: ["cursor"] });
458
468
  assert.equal(result.written.length, 1);
459
- assert.ok(result.written[0].includes("/skills/pdf-helper"));
460
- // .gitignore should now contain /skills/
469
+ // Use join() for the expected fragment so Windows's "\" separator
470
+ // matches. The forward-slash literal would fail on windows-latest.
471
+ assert.ok(result.written[0].includes(join("skills", "pdf-helper")));
472
+ // .gitignore should now contain /skills/ (that literal — it's the
473
+ // gitignore pattern, not a filesystem path — and gitignore uses
474
+ // forward slashes even on Windows, per git's own conventions).
461
475
  const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
462
476
  assert.match(gi, /\/skills\//);
463
477
  assert.match(gi, /SkillRepo/);
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Unit tests for src/lib/global-install.mjs (#894 / v3.1.2).
3
+ *
4
+ * These cover the auto-install-global helper that init.mjs step 6
5
+ * uses under npx. The spawn function is injected via the `spawn`
6
+ * option so no test ever shells out to npm.
7
+ *
8
+ * Categories:
9
+ * 1. Spawn invocation shape (POSIX vs Windows command name,
10
+ * args, stdio).
11
+ * 2. Version pinning (always `skillrepo@<version>`).
12
+ * 3. Result enum coverage:
13
+ * success | eacces | enoent-npm | npm-nonzero | timeout |
14
+ * path-not-updated.
15
+ * 4. Output mode: inherit vs silent (stdio shape differs).
16
+ * 5. resolveGlobalBinary: filters _npx cache paths.
17
+ * 6. Programmer-error throws (missing version arg).
18
+ */
19
+
20
+ import { describe, it, beforeEach, afterEach } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import {
23
+ installSkillrepoGlobally,
24
+ resolveGlobalBinary,
25
+ } from "../../lib/global-install.mjs";
26
+ import { makeMockSpawn } from "../helpers/mock-spawn.mjs";
27
+
28
+ // ── installSkillrepoGlobally — spawn invocation shape ────────────
29
+
30
+ describe("installSkillrepoGlobally — spawn shape", () => {
31
+ it("invokes `npm install -g skillrepo@<version>` on POSIX", async () => {
32
+ const spawn = makeMockSpawn({ exitCode: 0 });
33
+ // Pass platform: "linux" to force POSIX shape regardless of host.
34
+ await installSkillrepoGlobally({
35
+ version: "3.1.2",
36
+ spawn,
37
+ platform: "linux",
38
+ });
39
+ assert.equal(spawn.calls.length, 1);
40
+ assert.equal(spawn.calls[0].cmd, "npm");
41
+ assert.deepEqual(spawn.calls[0].args, [
42
+ "install",
43
+ "-g",
44
+ "skillrepo@3.1.2",
45
+ ]);
46
+ });
47
+
48
+ it("invokes `npm.cmd install -g skillrepo@<version>` on Windows", async () => {
49
+ // The Windows code path must use `npm.cmd` because npm on
50
+ // Windows is a batch script, not a native binary. spawn() with
51
+ // `shell: false` requires the literal name on disk. This test
52
+ // is the regression guard for that platform branch.
53
+ const spawn = makeMockSpawn({ exitCode: 0 });
54
+ await installSkillrepoGlobally({
55
+ version: "3.1.2",
56
+ spawn,
57
+ platform: "win32",
58
+ });
59
+ assert.equal(spawn.calls[0].cmd, "npm.cmd");
60
+ assert.deepEqual(spawn.calls[0].args, [
61
+ "install",
62
+ "-g",
63
+ "skillrepo@3.1.2",
64
+ ]);
65
+ });
66
+
67
+ it("uses stdio: 'inherit' by default so npm output streams to terminal", async () => {
68
+ const spawn = makeMockSpawn({ exitCode: 0 });
69
+ await installSkillrepoGlobally({
70
+ version: "3.1.2",
71
+ spawn,
72
+ platform: "linux",
73
+ });
74
+ assert.equal(spawn.calls[0].opts.stdio, "inherit");
75
+ });
76
+
77
+ it("uses stdio: ['ignore','pipe','pipe'] when outputMode is 'silent'", async () => {
78
+ // Silent mode is for --json: npm output must NOT touch the
79
+ // process stdout (which is reserved for the JSON blob), but
80
+ // stderr is captured for failure-message extraction.
81
+ const spawn = makeMockSpawn({ exitCode: 0 });
82
+ await installSkillrepoGlobally({
83
+ version: "3.1.2",
84
+ spawn,
85
+ platform: "linux",
86
+ outputMode: "silent",
87
+ });
88
+ assert.deepEqual(spawn.calls[0].opts.stdio, ["ignore", "pipe", "pipe"]);
89
+ });
90
+
91
+ it("pins the version exactly — no extra suffixes or flags", async () => {
92
+ // Lock in that the spawn args are exactly what we expect, with
93
+ // no future drift to e.g. `--save-exact` or `--no-fund` slipping
94
+ // in. The npm command is intentionally minimal.
95
+ const spawn = makeMockSpawn({ exitCode: 0 });
96
+ await installSkillrepoGlobally({
97
+ version: "3.1.2",
98
+ spawn,
99
+ platform: "linux",
100
+ });
101
+ assert.deepEqual(spawn.calls[0].args, [
102
+ "install",
103
+ "-g",
104
+ "skillrepo@3.1.2",
105
+ ]);
106
+ });
107
+
108
+ it("uses the version arg verbatim (no normalization)", async () => {
109
+ // Defense: a future caller passing a version like "3.1.2-rc.1"
110
+ // or "next" must get exactly that string in the npm spec, not
111
+ // a normalized form. This is the contract: caller controls the
112
+ // version string; the helper just embeds it.
113
+ const spawn = makeMockSpawn({ exitCode: 0 });
114
+ await installSkillrepoGlobally({
115
+ version: "3.2.0-rc.1",
116
+ spawn,
117
+ platform: "linux",
118
+ });
119
+ assert.equal(spawn.calls[0].args[2], "skillrepo@3.2.0-rc.1");
120
+ });
121
+ });
122
+
123
+ // ── installSkillrepoGlobally — programmer error throws ──────────
124
+
125
+ describe("installSkillrepoGlobally — programmer errors", () => {
126
+ it("throws when version is missing", async () => {
127
+ await assert.rejects(
128
+ () => installSkillrepoGlobally({ spawn: makeMockSpawn() }),
129
+ /version.*non-empty string/,
130
+ );
131
+ });
132
+
133
+ it("throws when version is an empty string", async () => {
134
+ await assert.rejects(
135
+ () => installSkillrepoGlobally({ version: "", spawn: makeMockSpawn() }),
136
+ /version.*non-empty string/,
137
+ );
138
+ });
139
+
140
+ it("throws when version is not a string", async () => {
141
+ await assert.rejects(
142
+ () =>
143
+ installSkillrepoGlobally({
144
+ version: 312,
145
+ spawn: makeMockSpawn(),
146
+ }),
147
+ /version.*non-empty string/,
148
+ );
149
+ });
150
+ });
151
+
152
+ // ── installSkillrepoGlobally — result enum ──────────────────────
153
+ //
154
+ // resolveGlobalBinary is called by installSkillrepoGlobally on the
155
+ // success path to verify the binary actually landed on PATH. In
156
+ // these tests we DON'T have a real `skillrepo` to find — so the
157
+ // "happy" tests below assert errorCode === "path-not-updated"
158
+ // rather than success === true. The integration tests in
159
+ // init.test.mjs exercise the full happy path with a shim on PATH.
160
+
161
+ describe("installSkillrepoGlobally — failure categorization", () => {
162
+ it("returns errorCode='eacces' when stderr contains EACCES", async () => {
163
+ const spawn = makeMockSpawn({
164
+ exitCode: 243,
165
+ stderrText:
166
+ "npm ERR! code EACCES\nnpm ERR! Error: EACCES: permission denied",
167
+ });
168
+ const result = await installSkillrepoGlobally({
169
+ version: "3.1.2",
170
+ spawn,
171
+ platform: "linux",
172
+ outputMode: "silent",
173
+ });
174
+ assert.equal(result.success, false);
175
+ assert.equal(result.errorCode, "eacces");
176
+ assert.match(result.error, /permissions/i);
177
+ assert.match(result.error, /sudo/i);
178
+ });
179
+
180
+ it("returns errorCode='enoent-npm' when spawn fires error with code ENOENT", async () => {
181
+ const enoent = Object.assign(new Error("spawn npm ENOENT"), {
182
+ code: "ENOENT",
183
+ });
184
+ const spawn = makeMockSpawn({ error: enoent });
185
+ const result = await installSkillrepoGlobally({
186
+ version: "3.1.2",
187
+ spawn,
188
+ platform: "linux",
189
+ });
190
+ assert.equal(result.success, false);
191
+ assert.equal(result.errorCode, "enoent-npm");
192
+ assert.match(result.error, /npm.*not found/i);
193
+ });
194
+
195
+ it("returns errorCode='npm-nonzero' for generic non-zero exits", async () => {
196
+ const spawn = makeMockSpawn({
197
+ exitCode: 1,
198
+ stderrText: "npm ERR! 404 Not Found - GET https://registry...",
199
+ });
200
+ const result = await installSkillrepoGlobally({
201
+ version: "3.1.2",
202
+ spawn,
203
+ platform: "linux",
204
+ outputMode: "silent",
205
+ });
206
+ assert.equal(result.success, false);
207
+ assert.equal(result.errorCode, "npm-nonzero");
208
+ assert.match(result.error, /exited with code 1/);
209
+ // First ~200 chars of stderr should be included for diagnosis.
210
+ assert.match(result.error, /404 Not Found/);
211
+ });
212
+
213
+ it("includes stderr snippet when in silent mode", async () => {
214
+ const spawn = makeMockSpawn({
215
+ exitCode: 1,
216
+ stderrText: "specific-failure-marker-12345",
217
+ });
218
+ const result = await installSkillrepoGlobally({
219
+ version: "3.1.2",
220
+ spawn,
221
+ platform: "linux",
222
+ outputMode: "silent",
223
+ });
224
+ assert.match(result.error, /specific-failure-marker-12345/);
225
+ });
226
+
227
+ it("returns errorCode='timeout' when child does not complete in time", async () => {
228
+ // Use a hanging spawn + a very short timeout. The result must
229
+ // come back within ~100ms to keep the suite fast.
230
+ const spawn = makeMockSpawn({ hang: true });
231
+ const result = await installSkillrepoGlobally({
232
+ version: "3.1.2",
233
+ spawn,
234
+ platform: "linux",
235
+ timeoutMs: 50,
236
+ });
237
+ assert.equal(result.success, false);
238
+ assert.equal(result.errorCode, "timeout");
239
+ assert.match(result.error, /did not complete/i);
240
+ // The mock's kill should have been invoked.
241
+ assert.equal(spawn.killed, true);
242
+ });
243
+
244
+ it("returns errorCode='path-not-updated' when npm exits 0 but no binary on PATH", async () => {
245
+ // No shim on PATH → resolveGlobalBinary returns null → the
246
+ // helper categorizes as path-not-updated. This is the nvm-
247
+ // misconfiguration case: install succeeded, but the user's
248
+ // npm prefix bin dir isn't on PATH so the binary is invisible
249
+ // to the hook runner.
250
+ const spawn = makeMockSpawn({ exitCode: 0 });
251
+ // Save and clear PATH so resolveGlobalBinary genuinely finds
252
+ // nothing — even if the developer happens to have a global
253
+ // skillrepo installed.
254
+ const originalPath = process.env.PATH;
255
+ process.env.PATH = "/nonexistent/dir";
256
+ try {
257
+ const result = await installSkillrepoGlobally({
258
+ version: "3.1.2",
259
+ spawn,
260
+ platform: "linux",
261
+ });
262
+ assert.equal(result.success, false);
263
+ assert.equal(result.errorCode, "path-not-updated");
264
+ assert.match(result.error, /not found on PATH/);
265
+ } finally {
266
+ if (originalPath === undefined) {
267
+ delete process.env.PATH;
268
+ } else {
269
+ process.env.PATH = originalPath;
270
+ }
271
+ }
272
+ });
273
+
274
+ it("never throws on user-recoverable failures", async () => {
275
+ // Defensive: every failure path must return a result, not
276
+ // throw. The init flow depends on this — a thrown exception
277
+ // here would abort init, which is the wrong behavior because
278
+ // the rest of init succeeded.
279
+ const cases = [
280
+ { exitCode: 1 },
281
+ {
282
+ error: Object.assign(new Error("ENOENT"), { code: "ENOENT" }),
283
+ },
284
+ { exitCode: 243, stderrText: "EACCES" },
285
+ { hang: true },
286
+ ];
287
+ for (const opts of cases) {
288
+ const spawn = makeMockSpawn(opts);
289
+ const result = await installSkillrepoGlobally({
290
+ version: "3.1.2",
291
+ spawn,
292
+ platform: "linux",
293
+ timeoutMs: 50,
294
+ outputMode: "silent",
295
+ });
296
+ assert.equal(typeof result, "object");
297
+ assert.equal(result.success, false);
298
+ assert.ok(result.errorCode, `errorCode set for ${JSON.stringify(opts)}`);
299
+ assert.ok(result.error, `error set for ${JSON.stringify(opts)}`);
300
+ }
301
+ });
302
+ });
303
+
304
+ // ── installSkillrepoGlobally — happy path with binary on PATH ──
305
+
306
+ describe("installSkillrepoGlobally — happy path", () => {
307
+ // Use installShim from skillrepo-shim helper to put a fake
308
+ // skillrepo on PATH. After the mock spawn succeeds,
309
+ // resolveGlobalBinary should find the shim and return success.
310
+ // (PATH is restored by uninstallShim — no separate originalPath
311
+ // capture needed.)
312
+
313
+ let shim;
314
+
315
+ beforeEach(async () => {
316
+ const { installShim } = await import("../helpers/skillrepo-shim.mjs");
317
+ const { mkdtempSync } = await import("node:fs");
318
+ const { tmpdir } = await import("node:os");
319
+ const { join } = await import("node:path");
320
+ const sandbox = mkdtempSync(join(tmpdir(), "sr-globalinstall-"));
321
+ shim = installShim(sandbox);
322
+ // Save the sandbox for cleanup
323
+ shim.sandbox = sandbox;
324
+ });
325
+
326
+ afterEach(async () => {
327
+ const { uninstallShim } = await import("../helpers/skillrepo-shim.mjs");
328
+ const { rmSync } = await import("node:fs");
329
+ uninstallShim(shim);
330
+ if (shim?.sandbox) rmSync(shim.sandbox, { recursive: true, force: true });
331
+ });
332
+
333
+ it("returns success with binaryPath when npm exits 0 and binary is on PATH", async () => {
334
+ const spawn = makeMockSpawn({ exitCode: 0 });
335
+ const result = await installSkillrepoGlobally({
336
+ version: "3.1.2",
337
+ spawn,
338
+ });
339
+ assert.equal(result.success, true, `expected success, got ${JSON.stringify(result)}`);
340
+ assert.ok(
341
+ result.binaryPath,
342
+ "binaryPath must be set on success",
343
+ );
344
+ // The resolved path should be under our sandbox. We check the
345
+ // sandbox's UNIQUE BASENAME (e.g. `sr-globalinstall-XYZ`)
346
+ // rather than `.startsWith(shim.binDir)` because Windows can
347
+ // hand back two different forms of the SAME directory: 8.3
348
+ // short-name (`C:\Users\RUNNER~1\...`) from `os.tmpdir()` vs
349
+ // long-name (`C:\Users\runneradmin\...`) from `where.exe`.
350
+ // The basename is invariant — `mkdtempSync`'s random suffix
351
+ // doesn't have a short-name alias.
352
+ const { basename } = await import("node:path");
353
+ const sandboxName = basename(shim.sandbox);
354
+ assert.ok(
355
+ result.binaryPath.includes(sandboxName) &&
356
+ result.binaryPath.includes("skillrepo"),
357
+ `expected binaryPath to contain sandbox ${sandboxName} and "skillrepo", got ${result.binaryPath}`,
358
+ );
359
+ });
360
+ });
361
+
362
+ // ── resolveGlobalBinary — npx-cache filtering ───────────────────
363
+
364
+ describe("resolveGlobalBinary — transient-cache filtering", () => {
365
+ // The cache-filter logic is integration-tested via the production
366
+ // path (`installSkillrepoGlobally` happy-path test below confirms
367
+ // a stable shim is found through the filter). This describe block
368
+ // covers the negative case: when only transient cache binaries
369
+ // are on PATH, return null.
370
+
371
+ let originalPath;
372
+ beforeEach(() => {
373
+ originalPath = process.env.PATH;
374
+ process.env.PATH = "/nonexistent/dir";
375
+ });
376
+ afterEach(() => {
377
+ if (originalPath === undefined) delete process.env.PATH;
378
+ else process.env.PATH = originalPath;
379
+ });
380
+
381
+ it("returns null when no skillrepo is on PATH", () => {
382
+ assert.equal(resolveGlobalBinary(), null);
383
+ });
384
+ });
385
+
386
+ // ── resolveGlobalBinary — happy path with shim ─────────────────
387
+
388
+ describe("resolveGlobalBinary — happy path", () => {
389
+ // PATH is restored by uninstallShim in afterEach — no separate
390
+ // originalPath capture needed.
391
+ let shim;
392
+ let sandbox;
393
+
394
+ beforeEach(async () => {
395
+ const { installShim } = await import("../helpers/skillrepo-shim.mjs");
396
+ const { mkdtempSync } = await import("node:fs");
397
+ const { tmpdir } = await import("node:os");
398
+ const { join } = await import("node:path");
399
+ sandbox = mkdtempSync(join(tmpdir(), "sr-resolveglobal-"));
400
+ shim = installShim(sandbox);
401
+ });
402
+
403
+ afterEach(async () => {
404
+ const { uninstallShim } = await import("../helpers/skillrepo-shim.mjs");
405
+ const { rmSync } = await import("node:fs");
406
+ uninstallShim(shim);
407
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
408
+ });
409
+
410
+ it("finds a stable global binary on PATH and returns its absolute path", async () => {
411
+ const result = resolveGlobalBinary();
412
+ assert.ok(result, "expected non-null binary path");
413
+ // Same Windows 8.3-vs-long-name reasoning as the
414
+ // `installSkillrepoGlobally — happy path` test above. Compare
415
+ // by the unique random sandbox basename rather than the full
416
+ // path prefix.
417
+ const { basename } = await import("node:path");
418
+ const sandboxName = basename(sandbox);
419
+ assert.ok(
420
+ result.includes(sandboxName) && result.includes("skillrepo"),
421
+ `expected resolved path to contain sandbox ${sandboxName} and "skillrepo", got ${result}`,
422
+ );
423
+ });
424
+ });
@@ -21,10 +21,16 @@ import { tmpdir } from "node:os";
21
21
 
22
22
  import { mergeMcpForVendors, printManualMcpInstructions } from "../../lib/mcp-merge.mjs";
23
23
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
24
+ import {
25
+ captureHome,
26
+ setSandboxHome,
27
+ restoreHome,
28
+ } from "../helpers/sandbox-home.mjs";
24
29
 
25
30
  let sandbox;
26
31
  let originalCwd;
27
- let originalHome;
32
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
33
+ let originalHomeEnv;
28
34
  let stdout;
29
35
  let stderr;
30
36
 
@@ -48,16 +54,16 @@ function setupSandbox() {
48
54
  mkdirSync(join(sandbox, "project"), { recursive: true });
49
55
  mkdirSync(join(sandbox, "home"), { recursive: true });
50
56
  originalCwd = process.cwd();
51
- originalHome = process.env.HOME;
57
+ originalHomeEnv = captureHome();
52
58
  process.chdir(join(sandbox, "project"));
53
- process.env.HOME = join(sandbox, "home");
59
+ setSandboxHome(join(sandbox, "home"));
54
60
  stdout = createCaptureStream();
55
61
  stderr = createCaptureStream();
56
62
  }
57
63
 
58
64
  function teardownSandbox() {
59
65
  process.chdir(originalCwd);
60
- process.env.HOME = originalHome;
66
+ restoreHome(originalHomeEnv);
61
67
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
62
68
  }
63
69
 
@@ -194,7 +200,7 @@ describe("mergeMcpForVendors — user declined", () => {
194
200
  mcpUrl: "https://x.com/mcp",
195
201
  yes: false, // NOT --yes — will call confirmFn
196
202
  io: { stdout, stderr },
197
- confirmFn,
203
+ deps: { confirmFn },
198
204
  });
199
205
 
200
206
  assert.equal(results[0].outcome, "skipped");
@@ -210,7 +216,7 @@ describe("mergeMcpForVendors — user declined", () => {
210
216
  mcpUrl: "https://x.com/mcp",
211
217
  yes: false,
212
218
  io: { stdout, stderr },
213
- confirmFn,
219
+ deps: { confirmFn },
214
220
  });
215
221
  assert.equal(results[0].outcome, "merged");
216
222
  assert.equal(results[1].outcome, "skipped");
@@ -276,7 +282,7 @@ describe("mergeMcpForVendors — failure handling", () => {
276
282
  mcpUrl: "https://x.com/mcp",
277
283
  yes: false,
278
284
  io: { stdout, stderr },
279
- confirmFn,
285
+ deps: { confirmFn },
280
286
  });
281
287
 
282
288
  assert.equal(results.length, 3);
@@ -21,24 +21,30 @@ import {
21
21
  claudeSkillsGlobalRoot,
22
22
  gitignorePath,
23
23
  } from "../../lib/paths.mjs";
24
+ import {
25
+ captureHome,
26
+ setSandboxHome,
27
+ restoreHome,
28
+ } from "../helpers/sandbox-home.mjs";
24
29
 
25
30
  let sandbox;
26
31
  let originalCwd;
27
- let originalHome;
32
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
33
+ let originalHomeEnv;
28
34
 
29
35
  function setupSandbox() {
30
36
  sandbox = mkdtempSync(join(tmpdir(), "cli-paths-"));
31
37
  mkdirSync(join(sandbox, "project"), { recursive: true });
32
38
  mkdirSync(join(sandbox, "home"), { recursive: true });
33
39
  originalCwd = process.cwd();
34
- originalHome = process.env.HOME;
40
+ originalHomeEnv = captureHome();
35
41
  process.chdir(join(sandbox, "project"));
36
- process.env.HOME = join(sandbox, "home");
42
+ setSandboxHome(join(sandbox, "home"));
37
43
  }
38
44
 
39
45
  function teardownSandbox() {
40
46
  process.chdir(originalCwd);
41
- process.env.HOME = originalHome;
47
+ restoreHome(originalHomeEnv);
42
48
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
43
49
  }
44
50