skillrepo 3.0.0 → 3.1.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 (52) hide show
  1. package/README.md +74 -6
  2. package/bin/skillrepo.mjs +14 -0
  3. package/package.json +1 -1
  4. package/src/commands/init.mjs +184 -19
  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 +305 -0
  10. package/src/lib/cli-config.mjs +78 -0
  11. package/src/lib/config.mjs +6 -3
  12. package/src/lib/file-write.mjs +8 -3
  13. package/src/lib/fs-utils.mjs +90 -9
  14. package/src/lib/mergers/session-hook.mjs +378 -0
  15. package/src/lib/paths.mjs +21 -0
  16. package/src/lib/platform.mjs +124 -0
  17. package/src/lib/removers/claude-mcp.mjs +67 -0
  18. package/src/lib/removers/cursor-mcp.mjs +60 -0
  19. package/src/lib/removers/env-local.mjs +55 -0
  20. package/src/lib/removers/gitignore.mjs +108 -0
  21. package/src/lib/removers/settings.mjs +183 -0
  22. package/src/lib/removers/vscode-mcp.mjs +87 -0
  23. package/src/lib/removers/windsurf-mcp.mjs +65 -0
  24. package/src/lib/sync.mjs +26 -0
  25. package/src/test/commands/add.test.mjs +10 -4
  26. package/src/test/commands/get.test.mjs +10 -4
  27. package/src/test/commands/init.test.mjs +428 -4
  28. package/src/test/commands/list.test.mjs +10 -4
  29. package/src/test/commands/remove.test.mjs +10 -4
  30. package/src/test/commands/search.test.mjs +10 -4
  31. package/src/test/commands/session-sync.test.mjs +352 -0
  32. package/src/test/commands/uninstall.test.mjs +774 -0
  33. package/src/test/commands/update.test.mjs +168 -4
  34. package/src/test/helpers/sandbox-home.mjs +161 -0
  35. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  36. package/src/test/integration/file-write.integration.test.mjs +10 -4
  37. package/src/test/lib/artifact-registry.test.mjs +268 -0
  38. package/src/test/lib/cli-config.test.mjs +126 -5
  39. package/src/test/lib/config.test.mjs +10 -4
  40. package/src/test/lib/file-write.test.mjs +24 -10
  41. package/src/test/lib/mcp-merge.test.mjs +10 -4
  42. package/src/test/lib/paths.test.mjs +10 -4
  43. package/src/test/lib/platform.test.mjs +135 -0
  44. package/src/test/lib/sync.test.mjs +20 -4
  45. package/src/test/mergers/session-hook.test.mjs +1175 -0
  46. package/src/test/mergers/uninstall-claude-mcp.test.mjs +145 -0
  47. package/src/test/mergers/uninstall-cursor-mcp.test.mjs +108 -0
  48. package/src/test/mergers/uninstall-env-local.test.mjs +144 -0
  49. package/src/test/mergers/uninstall-gitignore.test.mjs +209 -0
  50. package/src/test/mergers/uninstall-settings.test.mjs +296 -0
  51. package/src/test/mergers/uninstall-vscode-mcp.test.mjs +215 -0
  52. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +128 -0
@@ -16,12 +16,18 @@ import { resolvePlacementDir } from "../../lib/file-write.mjs";
16
16
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
17
17
  import { createMockServer } from "../e2e/mock-server.mjs";
18
18
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
19
+ import {
20
+ captureHome,
21
+ setSandboxHome,
22
+ restoreHome,
23
+ } from "../helpers/sandbox-home.mjs";
19
24
 
20
25
  let sandbox;
21
26
  let server;
22
27
  let serverUrl;
23
28
  let originalCwd;
24
- let originalHome;
29
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
30
+ let originalHomeEnv;
25
31
  let stdout;
26
32
  const VALID_KEY = "sk_live_test";
27
33
 
@@ -49,9 +55,9 @@ async function setup() {
49
55
  mkdirSync(join(sandbox, "project"), { recursive: true });
50
56
  mkdirSync(join(sandbox, "home"), { recursive: true });
51
57
  originalCwd = process.cwd();
52
- originalHome = process.env.HOME;
58
+ originalHomeEnv = captureHome();
53
59
  process.chdir(join(sandbox, "project"));
54
- process.env.HOME = join(sandbox, "home");
60
+ setSandboxHome(join(sandbox, "home"));
55
61
  delete process.env.SKILLREPO_ACCESS_KEY;
56
62
  delete process.env.SKILLREPO_URL;
57
63
 
@@ -65,7 +71,7 @@ async function setup() {
65
71
  async function teardown() {
66
72
  if (server) await server.stop();
67
73
  process.chdir(originalCwd);
68
- process.env.HOME = originalHome;
74
+ restoreHome(originalHomeEnv);
69
75
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
70
76
  server = null;
71
77
  }
@@ -162,3 +168,161 @@ describe("runUpdate — flag handling", () => {
162
168
  assert.ok(existsSync(dir));
163
169
  });
164
170
  });
171
+
172
+ // ── --session-hook mode (#884) ────────────────────────────────────────
173
+ //
174
+ // Session-hook mode is what the Claude Code SessionStart hook #884's
175
+ // installer writes invokes: `skillrepo update --session-hook`. The
176
+ // contract: exit 0 on ALL errors, silent on 304, one-line summary on
177
+ // changes, one-line failure message on error. A sync failure must
178
+ // NEVER block a session start.
179
+ //
180
+ // Architect pre-flight review flagged the flag-acceptance bug as
181
+ // "most likely silent-failure bug" — if `resolveFlags` rejects the
182
+ // flag before the exit-0 contract activates, every session-start
183
+ // hook silently fails with a symptom indistinguishable from 304.
184
+ // Tests in this suite specifically guard against that.
185
+
186
+ describe("runUpdate — --session-hook contract", () => {
187
+ beforeEach(setup);
188
+ afterEach(teardown);
189
+
190
+ it("accepts the --session-hook flag without throwing a validation error", async () => {
191
+ // THE architect-flagged regression guard. If a future refactor
192
+ // removes the acceptPositional callback from update.mjs,
193
+ // resolveFlags throws `Unknown argument: --session-hook` before
194
+ // the exit-0 contract has a chance to fire. The `|| true` shell
195
+ // backstop would catch it, but the user would lose the failure
196
+ // message AND every sync would silently no-op. This test makes
197
+ // that class of regression fail loudly at unit-test time.
198
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
199
+ await assert.doesNotReject(
200
+ () =>
201
+ runUpdate(
202
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
203
+ { stdout },
204
+ ),
205
+ "update --session-hook must NOT throw Unknown argument",
206
+ );
207
+ });
208
+
209
+ it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
210
+ // INTENT: 304 means nothing changed. Printing "Syncing..." on
211
+ // every session start with no value to show would clutter the
212
+ // system-message surface. The contract is SILENCE on 304.
213
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
214
+ await runUpdate(
215
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
216
+ { stdout },
217
+ );
218
+ assert.equal(
219
+ stdout.text(),
220
+ "",
221
+ "session-hook mode must emit zero output when there's nothing to sync",
222
+ );
223
+ });
224
+
225
+ it("prints one line on successful sync with changes", async () => {
226
+ // INTENT: when content CHANGES, surface exactly one actionable
227
+ // line so the user knows their library updated. No banners, no
228
+ // multi-line output — a single line the hook runner can render
229
+ // as a system message.
230
+ server.setLibraryResponse({
231
+ skills: [makeSkill("sync-hook-test")],
232
+ removals: [],
233
+ syncedAt: "x",
234
+ });
235
+ await runUpdate(
236
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
237
+ { stdout },
238
+ );
239
+ const out = stdout.text();
240
+ assert.match(out, /^\[SkillRepo\] Library synced: \d+ added, \d+ updated, \d+ removed\.\n$/);
241
+ });
242
+
243
+ it("exits 0 with a failure message on a server error instead of throwing", async () => {
244
+ // THE load-bearing contract. A sync failure must NEVER block a
245
+ // session start. runUpdate called with --session-hook against an
246
+ // unreachable server must return normally (no throw) and emit a
247
+ // one-line failure message.
248
+ //
249
+ // We simulate an unreachable server by pointing at an invalid
250
+ // port. Without the exit-0 contract, this would throw
251
+ // networkError from runSync; WITH the contract, the catch block
252
+ // swallows it and prints a failure message.
253
+ await assert.doesNotReject(
254
+ () =>
255
+ runUpdate(
256
+ ["--key", VALID_KEY, "--url", "http://127.0.0.1:1", "--session-hook"],
257
+ { stdout },
258
+ ),
259
+ "session-hook mode must NEVER throw — session start must proceed",
260
+ );
261
+ const out = stdout.text();
262
+ assert.match(out, /^\[SkillRepo\] Sync failed: .+\n$/);
263
+ });
264
+
265
+ it("exits 0 with a failure message on auth error (invalid key)", async () => {
266
+ // Auth error is non-retryable AND non-blocking for session start.
267
+ // If the user's key was rotated, we want them to see "access key
268
+ // invalid" in their session system message, but the session must
269
+ // still open normally.
270
+ server.setForcedStatus(401, { error: "Invalid access key" });
271
+ await assert.doesNotReject(
272
+ () =>
273
+ runUpdate(
274
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
275
+ { stdout },
276
+ ),
277
+ );
278
+ const out = stdout.text();
279
+ assert.match(out, /Sync failed/);
280
+ });
281
+
282
+ it("exits 0 with a failure message when no access key is configured", async () => {
283
+ // INTENT: the VERY first session after installing skillrepo, if
284
+ // init hasn't run yet, has no key. The hook must not block the
285
+ // session. This is a genuine first-run scenario, not a synthetic
286
+ // edge case — users who set up Claude Code before running
287
+ // `skillrepo init` will hit exactly this.
288
+ //
289
+ // No --key flag, no cached config, no env var. resolveFlags
290
+ // normally throws authError in this case. Session-hook mode
291
+ // must catch that and exit 0 anyway.
292
+ await assert.doesNotReject(
293
+ () =>
294
+ runUpdate(["--url", serverUrl, "--session-hook"], { stdout }),
295
+ "session-hook mode with no key must NEVER throw",
296
+ );
297
+ const out = stdout.text();
298
+ assert.match(out, /Sync failed/, "failure message must still surface");
299
+ });
300
+
301
+ it("exits 0 when the --session-hook flag appears after other flags", async () => {
302
+ // INTENT: flag ordering is not part of the contract. The installer
303
+ // writes a specific order today, but a user hand-editing their
304
+ // settings (or a future refactor moving flags around) must not
305
+ // break the exit-0 behavior.
306
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
307
+ await assert.doesNotReject(
308
+ () =>
309
+ runUpdate(
310
+ ["--session-hook", "--key", VALID_KEY, "--url", serverUrl],
311
+ { stdout },
312
+ ),
313
+ );
314
+ assert.equal(stdout.text(), "", "still silent on 304 regardless of flag order");
315
+ });
316
+
317
+ it("exits 0 silently when the server returns zero deltas (empty success, not 304)", async () => {
318
+ // INTENT: 304 isn't the only silent-success case. A 200 response
319
+ // with zero added/updated/removed (a fresh sync that happened to
320
+ // produce no work) is also silent — same UX reasoning as 304.
321
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
322
+ await runUpdate(
323
+ ["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
324
+ { stdout },
325
+ );
326
+ assert.equal(stdout.text(), "", "zero-delta 200 is silent");
327
+ });
328
+ });
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Cross-platform sandbox HOME isolation for CLI tests.
3
+ *
4
+ * Problem this solves
5
+ * -------------------
6
+ * Every CLI test that touches the filesystem runs in a temp-dir sandbox and
7
+ * redirects "home" so writes under `~/.claude/` or `~/.claude/skills/` land
8
+ * inside the sandbox instead of polluting the developer's real home.
9
+ *
10
+ * The pre-v3.1.1 pattern set `process.env.HOME` and relied on `os.homedir()`
11
+ * reading from HOME. That works on POSIX — `homedir()` reads HOME first —
12
+ * but it silently fails on Windows. On Windows, `homedir()` reads
13
+ * `USERPROFILE` (NOT HOME), so the tests' HOME redirect is a no-op: the
14
+ * CLI writes to `C:\Users\<runner>\.claude\` (the real user home) instead
15
+ * of the sandbox, and the post-assert `dir.startsWith(process.env.HOME)`
16
+ * fails because the string paths don't match.
17
+ *
18
+ * This surfaced the first time `.github/workflows/ci.yml` gained a
19
+ * `windows-latest` runner in PR #892 — 99 tests failed, none of them real
20
+ * CLI regressions, all because the sandbox isolation wasn't cross-platform.
21
+ *
22
+ * The contract
23
+ * ------------
24
+ * `captureHome()` snapshots BOTH env vars before you overwrite them. The
25
+ * snapshot correctly distinguishes "was undefined" from "was empty
26
+ * string" so `restoreHome()` can `delete` the var rather than set it to
27
+ * the literal string `"undefined"` (which `process.env.X = undefined`
28
+ * would do in Node — verified on Node 20).
29
+ *
30
+ * `setSandboxHome(path)` sets BOTH HOME and USERPROFILE to the same
31
+ * sandbox path. Setting both guarantees `homedir()` lands in the sandbox
32
+ * on every platform without the caller having to know which env var the
33
+ * current platform prefers.
34
+ *
35
+ * `restoreHome(snapshot)` restores the original state, with the delete-
36
+ * on-undefined pattern.
37
+ *
38
+ * Usage
39
+ * -----
40
+ * import { captureHome, setSandboxHome, restoreHome } from "../helpers/sandbox-home.mjs";
41
+ *
42
+ * let originalHomeEnv;
43
+ * function setup() {
44
+ * sandbox = mkdtempSync(join(tmpdir(), "cli-foo-"));
45
+ * originalHomeEnv = captureHome();
46
+ * setSandboxHome(join(sandbox, "home"));
47
+ * }
48
+ * function teardown() {
49
+ * restoreHome(originalHomeEnv);
50
+ * rmSync(sandbox, { recursive: true, force: true });
51
+ * }
52
+ */
53
+
54
+ /**
55
+ * @typedef {Object} HomeEnvSnapshot
56
+ * @property {string | undefined} HOME
57
+ * @property {string | undefined} USERPROFILE
58
+ */
59
+
60
+ /**
61
+ * Snapshot HOME and USERPROFILE as they exist right now. Call before
62
+ * any `setSandboxHome` so `restoreHome` has something to put back.
63
+ *
64
+ * @returns {HomeEnvSnapshot}
65
+ */
66
+ export function captureHome() {
67
+ return {
68
+ HOME: process.env.HOME,
69
+ USERPROFILE: process.env.USERPROFILE,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Set both HOME and USERPROFILE to the sandbox path. After this call,
75
+ * `os.homedir()` will resolve to `path` on every supported platform.
76
+ *
77
+ * @param {string} path - Absolute path to the sandbox "home" directory.
78
+ * Caller is responsible for creating the directory if needed.
79
+ */
80
+ export function setSandboxHome(path) {
81
+ if (typeof path !== "string" || path.length === 0) {
82
+ throw new Error("setSandboxHome: path must be a non-empty string");
83
+ }
84
+ process.env.HOME = path;
85
+ process.env.USERPROFILE = path;
86
+ }
87
+
88
+ /**
89
+ * Restore HOME and USERPROFILE to whatever they were at the time of
90
+ * `captureHome()`. Deletes the env var when the snapshot value was
91
+ * `undefined` — setting `process.env.X = undefined` in Node produces
92
+ * the literal string `"undefined"`, which would corrupt subsequent
93
+ * tests that read the env var.
94
+ *
95
+ * @param {HomeEnvSnapshot} snapshot - Return value of `captureHome()`.
96
+ */
97
+ export function restoreHome(snapshot) {
98
+ if (!snapshot || typeof snapshot !== "object") {
99
+ throw new Error("restoreHome: snapshot must be the object from captureHome()");
100
+ }
101
+ if (snapshot.HOME === undefined) delete process.env.HOME;
102
+ else process.env.HOME = snapshot.HOME;
103
+ if (snapshot.USERPROFILE === undefined) delete process.env.USERPROFILE;
104
+ else process.env.USERPROFILE = snapshot.USERPROFILE;
105
+ }
106
+
107
+ /**
108
+ * Assert both HOME and USERPROFILE point inside a sandbox rooted at
109
+ * `tmpdirRoot` (typically `os.tmpdir()`). This is the integrity check
110
+ * that prevents a test from accidentally writing into the developer's
111
+ * real home when `setSandboxHome()` was forgotten or bypassed.
112
+ *
113
+ * BOTH env vars are checked because `os.homedir()` reads `HOME` on
114
+ * POSIX and `USERPROFILE` on Windows. An assertion that checks only
115
+ * `HOME` would pass on Windows even when USERPROFILE still points at
116
+ * the real user home — defeating the purpose of the guard. The
117
+ * guards migrated from `session-sync.test.mjs`, `uninstall.test.mjs`,
118
+ * and `session-hook.test.mjs` into this helper after a review-round-4
119
+ * finding spotted the USERPROFILE gap.
120
+ *
121
+ * `USERPROFILE` is checked only when it's defined — on POSIX it's
122
+ * typically absent and `setSandboxHome()` does set it alongside
123
+ * HOME so it should be defined under the isolation contract, but we
124
+ * tolerate the "not set at all" state as well since that's
125
+ * equivalent to "can't leak outside the sandbox via it."
126
+ *
127
+ * @param {string} tmpdirRoot - Root path the HOME/USERPROFILE must
128
+ * be inside. Typically `os.tmpdir()`.
129
+ * @param {string} [contextMessage] - Optional context to include in
130
+ * the assertion failure message so the developer can tell
131
+ * which test suite's setup forgot the override.
132
+ * @throws {AssertionError} If either env var is set but falls
133
+ * outside `tmpdirRoot`.
134
+ */
135
+ export function assertHomeIsolated(tmpdirRoot, contextMessage = "") {
136
+ if (typeof tmpdirRoot !== "string" || tmpdirRoot.length === 0) {
137
+ throw new Error(
138
+ "assertHomeIsolated: tmpdirRoot must be a non-empty string " +
139
+ "(usually os.tmpdir())",
140
+ );
141
+ }
142
+ const context = contextMessage ? ` (${contextMessage})` : "";
143
+ if (!process.env.HOME || !process.env.HOME.startsWith(tmpdirRoot)) {
144
+ throw new Error(
145
+ `HOME must point inside "${tmpdirRoot}"${context}. ` +
146
+ `Current HOME="${process.env.HOME}" — setup() forgot setSandboxHome() ` +
147
+ `or something else overwrote it.`,
148
+ );
149
+ }
150
+ if (
151
+ process.env.USERPROFILE !== undefined &&
152
+ !process.env.USERPROFILE.startsWith(tmpdirRoot)
153
+ ) {
154
+ throw new Error(
155
+ `USERPROFILE must point inside "${tmpdirRoot}"${context} ` +
156
+ `when defined. Current USERPROFILE="${process.env.USERPROFILE}" — ` +
157
+ `on Windows, os.homedir() reads USERPROFILE, so a stray real-host ` +
158
+ `value leaks tests into the real user home.`,
159
+ );
160
+ }
161
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Cross-platform `skillrepo` binary shim for CLI tests.
3
+ *
4
+ * Why this exists
5
+ * ---------------
6
+ * Several tests (init session-sync, session-sync command) invoke
7
+ * `runInit` or `runSessionSync`, which internally call
8
+ * `resolveSkillrepoBinary()` → `execFileSync("which" or "where",
9
+ * ["skillrepo"])`. For those calls to succeed deterministically —
10
+ * regardless of whether the developer has a global `skillrepo`
11
+ * install on PATH — the tests drop a fake `skillrepo` binary into
12
+ * a sandbox bin dir and prepend that dir to PATH.
13
+ *
14
+ * The original pattern was POSIX-only. On Windows it failed two ways:
15
+ * 1. The shim was created as a bash script (`#!/bin/sh\nexit 0\n`)
16
+ * which `where.exe` won't match — `where` only resolves files
17
+ * whose extension is in PATHEXT (.CMD by default), not
18
+ * extension-less shell scripts.
19
+ * 2. PATH was joined with `:` which is the POSIX separator;
20
+ * Windows uses `;`.
21
+ *
22
+ * This helper produces shims correctly for both families.
23
+ *
24
+ * Usage
25
+ * -----
26
+ * import { installShim, uninstallShim } from "../helpers/skillrepo-shim.mjs";
27
+ *
28
+ * let shim;
29
+ * function setup() {
30
+ * // ...sandbox + HOME/USERPROFILE setup...
31
+ * shim = installShim(join(sandbox, "home"));
32
+ * }
33
+ * function teardown() {
34
+ * uninstallShim(shim);
35
+ * // ...HOME/USERPROFILE restore + sandbox cleanup...
36
+ * }
37
+ *
38
+ * `installShim(homePath)` returns a `ShimHandle` carrying the path
39
+ * prepended to PATH and the original PATH value — pass it back to
40
+ * `uninstallShim` for restoration.
41
+ */
42
+
43
+ import { mkdirSync, writeFileSync, chmodSync } from "node:fs";
44
+ import { join, delimiter } from "node:path";
45
+ import { platformConventions } from "../../lib/platform.mjs";
46
+
47
+ /**
48
+ * @typedef {Object} ShimHandle
49
+ * @property {string} binDir - The directory we created and prepended.
50
+ * @property {string | undefined} originalPath - PATH as it was before
51
+ * we prepended. Distinguishes "was undefined" from "was empty
52
+ * string" so `uninstallShim` can `delete` rather than set the
53
+ * env var to the literal string `"undefined"` (Node 20
54
+ * coerces `env.X = undefined` to that string, verified).
55
+ */
56
+
57
+ /**
58
+ * Drop a `skillrepo` shim into `<homePath>/bin` and prepend that dir
59
+ * to PATH so the CLI's binary resolver finds it on both POSIX and
60
+ * Windows.
61
+ *
62
+ * @param {string} homePath - Absolute path to the sandbox home dir.
63
+ * Must already exist (caller typically just ran `mkdirSync`).
64
+ * @returns {ShimHandle}
65
+ */
66
+ export function installShim(homePath) {
67
+ if (typeof homePath !== "string" || homePath.length === 0) {
68
+ throw new Error("installShim: homePath must be a non-empty string");
69
+ }
70
+ const conv = platformConventions();
71
+ const binDir = join(homePath, "bin");
72
+ mkdirSync(binDir, { recursive: true });
73
+
74
+ if (conv.family === "windows") {
75
+ // On Windows we create BOTH a `.cmd` and an extension-less shim.
76
+ // `where.exe skillrepo` matches anything whose extension is in
77
+ // PATHEXT (.CMD is default, empty extension is NOT in default
78
+ // PATHEXT) — so the `.cmd` is what production `resolveSkillrepoBinary`
79
+ // finds. The extension-less shim is a courtesy so any test that
80
+ // happens to invoke `bash -c './bin/skillrepo'` directly gets a
81
+ // working file — hence the POSIX shebang content, NOT cmd.exe
82
+ // syntax. (An earlier revision wrote `@exit 0` to both files; the
83
+ // round-3c review caught that the extension-less file with
84
+ // cmd.exe syntax would fail under bash. The `.cmd` gets cmd.exe
85
+ // syntax; the bare file gets POSIX shebang syntax.)
86
+ const cmdShim = join(binDir, "skillrepo.cmd");
87
+ writeFileSync(cmdShim, "@exit 0\r\n");
88
+ const bareShim = join(binDir, "skillrepo");
89
+ writeFileSync(bareShim, "#!/bin/sh\nexit 0\n");
90
+ // On Windows file-system permissions don't gate executability,
91
+ // but chmodSync is a no-op on Windows anyway so we skip it to
92
+ // avoid any platform-conditional chmod paths. Production uses
93
+ // `platformConventions().supportsPosixPermissions` for the same
94
+ // reason in `fs-utils.mjs`.
95
+ } else {
96
+ // POSIX shim — bash script + executable bit.
97
+ const shim = join(binDir, "skillrepo");
98
+ writeFileSync(shim, "#!/bin/sh\nexit 0\n");
99
+ chmodSync(shim, 0o755);
100
+ }
101
+
102
+ // Capture PATH as-is, including the `undefined` case. `??
103
+ // ""` would coalesce to empty string, but then `uninstallShim`
104
+ // can't tell "PATH was undefined" from "PATH was empty" — the
105
+ // restore would then write `""` into an env that had no PATH set,
106
+ // potentially breaking subsequent subprocess spawns that inherit
107
+ // env.
108
+ const originalPath = process.env.PATH;
109
+ // `path.delimiter` is `:` on POSIX, `;` on Windows — the correct
110
+ // separator for whichever platform we're running on.
111
+ process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`;
112
+ return { binDir, originalPath };
113
+ }
114
+
115
+ /**
116
+ * Restore PATH to what it was before `installShim` was called.
117
+ * Deletes the env var if PATH was undefined at capture time —
118
+ * setting `process.env.PATH = undefined` produces the literal
119
+ * string `"undefined"` in Node, which would silently break
120
+ * binary lookups in subsequent tests.
121
+ *
122
+ * @param {ShimHandle | null | undefined} handle - Return value of
123
+ * `installShim`, or null/undefined for a safe no-op on tests
124
+ * that bailed before install ran.
125
+ */
126
+ export function uninstallShim(handle) {
127
+ if (!handle) return;
128
+ if (handle.originalPath === undefined) {
129
+ delete process.env.PATH;
130
+ } else {
131
+ process.env.PATH = handle.originalPath;
132
+ }
133
+ }
@@ -33,24 +33,30 @@ import {
33
33
  cleanupOrphans,
34
34
  resolvePlacementDir,
35
35
  } from "../../lib/file-write.mjs";
36
+ import {
37
+ captureHome,
38
+ setSandboxHome,
39
+ restoreHome,
40
+ } from "../helpers/sandbox-home.mjs";
36
41
 
37
42
  let sandbox;
38
43
  let originalCwd;
39
- let originalHome;
44
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
45
+ let originalHomeEnv;
40
46
 
41
47
  function setupSandbox() {
42
48
  sandbox = mkdtempSync(join(tmpdir(), "cli-fw-int-"));
43
49
  mkdirSync(join(sandbox, "project"), { recursive: true });
44
50
  mkdirSync(join(sandbox, "home"), { recursive: true });
45
51
  originalCwd = process.cwd();
46
- originalHome = process.env.HOME;
52
+ originalHomeEnv = captureHome();
47
53
  process.chdir(join(sandbox, "project"));
48
- process.env.HOME = join(sandbox, "home");
54
+ setSandboxHome(join(sandbox, "home"));
49
55
  }
50
56
 
51
57
  function teardownSandbox() {
52
58
  process.chdir(originalCwd);
53
- process.env.HOME = originalHome;
59
+ restoreHome(originalHomeEnv);
54
60
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
55
61
  }
56
62