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
@@ -0,0 +1,387 @@
1
+ /**
2
+ * `npm install -g skillrepo@<version>` wrapper for v3.1.2 init's
3
+ * auto-install-global feature (#894).
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * Under `npx skillrepo init`, the v3.1.0/v3.1.1 SessionStart hook
8
+ * design fails because the npx cache path is transient and unsuitable
9
+ * for a long-lived hook command. v3.1.1 worked around this by
10
+ * skipping the hook with a "install globally first" warning — the
11
+ * prompt-then-fail UX bug that v3.1.2 fixes.
12
+ *
13
+ * v3.1.2 fixes it by having init OFFER to run the global install
14
+ * itself when invoked under npx. This module is the spawn wrapper
15
+ * that runs `npm install -g skillrepo@<version>` and reports a
16
+ * structured result.
17
+ *
18
+ * Design constraints
19
+ * ------------------
20
+ * 1. **No new runtime dependencies.** The CLI's only dep is
21
+ * `cli-table3`. This module uses Node built-ins (`child_process`,
22
+ * `path`) only.
23
+ *
24
+ * 2. **Spawn is injectable.** Tests pass a stub spawn so they
25
+ * never actually shell out to npm. Production callers leave
26
+ * `spawn` unset and get the real `child_process.spawn`.
27
+ *
28
+ * 3. **Cross-platform spawn shape.** On Windows, `npm` is a
29
+ * `.cmd` script, not a native binary. `spawn("npm", ...)` on
30
+ * Windows without `shell: true` throws ENOENT. The locator
31
+ * name comes from `platformConventions()` so we don't sprinkle
32
+ * `process.platform === "win32"` checks across the codebase.
33
+ * We use `shell: false` on both platforms — it sidesteps the
34
+ * argument-quoting surprises `shell: true` introduces on Windows
35
+ * and is unnecessary because we control all spawn args.
36
+ *
37
+ * 4. **`stdio` mode is caller-controlled.** Default is `inherit`
38
+ * so npm's progress output streams to the user's terminal during
39
+ * the install (the install can take 10-30 seconds — silent would
40
+ * look hung). `--json` mode passes `outputMode: "silent"` to
41
+ * suppress npm output that would otherwise pollute the JSON
42
+ * stdout.
43
+ *
44
+ * 5. **Always returns a result, never throws on user-recoverable
45
+ * failure.** Init must continue past auto-install failures —
46
+ * the rest of init (config, MCP, first sync) succeeded and the
47
+ * user's library is on disk. Throwing here would abort init.
48
+ * Programmer errors (e.g. missing version arg) DO throw.
49
+ *
50
+ * 6. **5-minute timeout.** Slow registries and corporate proxies
51
+ * can take longer than the typical 10-30 seconds. 5 minutes is
52
+ * well past any reasonable network worst-case but bounds the
53
+ * hang time so init can never wait forever.
54
+ *
55
+ * 7. **Verify success post-install.** A 0 exit code from npm is
56
+ * necessary but not sufficient — the user's npm prefix bin
57
+ * directory might not be on PATH (a common nvm misconfiguration).
58
+ * We re-resolve the binary via `where`/`which`, filtering out
59
+ * `_npx` cache paths, to confirm the install actually produced
60
+ * a usable binary at a stable location.
61
+ *
62
+ * Result enum
63
+ * -----------
64
+ * - `success: true` — npm exited 0 AND the resulting binary is at
65
+ * a stable absolute path. `binaryPath` is set.
66
+ * - `errorCode: "eacces"` — permission denied on the npm prefix.
67
+ * User needs sudo (or to fix npm prefix). `error` carries the
68
+ * actionable message.
69
+ * - `errorCode: "enoent-npm"` — `npm` itself not found on PATH.
70
+ * User needs to install Node or fix PATH.
71
+ * - `errorCode: "npm-nonzero"` — npm ran but exited non-zero for
72
+ * some other reason (network, registry 500, package not found).
73
+ * `error` includes the first ~200 chars of stderr.
74
+ * - `errorCode: "timeout"` — exceeded the 5-minute deadline. We
75
+ * killed the child.
76
+ * - `errorCode: "path-not-updated"` — npm exited 0 but the binary
77
+ * isn't on PATH at a stable location. Either the install actually
78
+ * failed silently or the user's npm prefix bin dir isn't on PATH.
79
+ */
80
+
81
+ import { spawn as defaultSpawn } from "node:child_process";
82
+ import { platformConventions } from "./platform.mjs";
83
+ import { resolveBinaryOnPath } from "./binary-locator.mjs";
84
+
85
+ /**
86
+ * @typedef {Object} GlobalInstallResult
87
+ * @property {boolean} success
88
+ * @property {string | null} binaryPath - Absolute path to the resulting
89
+ * global `skillrepo` binary on success; null on failure.
90
+ * @property {string} [error] - Human-readable failure reason. Set
91
+ * when `success` is false.
92
+ * @property {"eacces" | "enoent-npm" | "npm-nonzero" | "timeout" | "path-not-updated"} [errorCode]
93
+ * Categorized failure code. Set when `success` is false.
94
+ */
95
+
96
+ /**
97
+ * Default timeout for `npm install -g`. 5 minutes covers the worst
98
+ * case of slow registries, corporate proxies, or first-time cache
99
+ * population without letting init hang indefinitely.
100
+ */
101
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
102
+
103
+ /**
104
+ * Run `npm install -g skillrepo@<version>` and return a structured
105
+ * result. Never throws on user-recoverable failure (npm exit codes,
106
+ * permission errors, missing npm) — those are reported via the
107
+ * `errorCode` field. Programmer errors (missing args) DO throw.
108
+ *
109
+ * @param {object} options
110
+ * @param {string} options.version - Semver string to pin
111
+ * (e.g. "3.1.2"). Required.
112
+ * @param {"inherit" | "silent"} [options.outputMode="inherit"] -
113
+ * How to handle npm's stdout/stderr.
114
+ * - "inherit": stream npm output through this process's
115
+ * stdio (the user sees install progress in real time).
116
+ * - "silent": capture and discard. Used in `--json` mode
117
+ * so npm output doesn't pollute the JSON stdout.
118
+ * @param {Function} [options.spawn] - Injected for tests. Defaults
119
+ * to the real `child_process.spawn`. Tests pass a stub so the
120
+ * suite never actually shells out to npm.
121
+ * @param {NodeJS.Platform} [options.platform] - Override for tests
122
+ * that need to exercise Windows spawn semantics on a non-Windows
123
+ * host. Production callers leave this unset.
124
+ * @param {number} [options.timeoutMs=300000] - Maximum time to wait
125
+ * before killing the child and returning `errorCode: "timeout"`.
126
+ * @returns {Promise<GlobalInstallResult>}
127
+ */
128
+ export async function installSkillrepoGlobally({
129
+ version,
130
+ outputMode = "inherit",
131
+ spawn = defaultSpawn,
132
+ platform: platformOverride,
133
+ timeoutMs = DEFAULT_TIMEOUT_MS,
134
+ } = {}) {
135
+ if (typeof version !== "string" || version.length === 0) {
136
+ // Programmer error — no recovery, throw.
137
+ throw new Error(
138
+ "installSkillrepoGlobally: `version` must be a non-empty string.",
139
+ );
140
+ }
141
+
142
+ const conv = platformConventions({ platform: platformOverride });
143
+ // Windows `npm` ships as `npm.cmd` — a batch script. spawn() with
144
+ // `shell: false` requires the literal name on disk, which is
145
+ // `npm.cmd` on Windows and `npm` everywhere else. Same pattern as
146
+ // `binaryLocator` ("which" vs "where").
147
+ const npmCmd = conv.family === "windows" ? "npm.cmd" : "npm";
148
+ const args = ["install", "-g", `skillrepo@${version}`];
149
+
150
+ // stdio mapping:
151
+ // - inherit: npm output streams to user's terminal in real time.
152
+ // - silent (--json mode): pipe stdout/stderr so we can capture
153
+ // them for error categorization, but don't let them touch the
154
+ // terminal. The captured stderr is useful for the
155
+ // "first 200 chars of stderr" failure message.
156
+ const stdio = outputMode === "silent"
157
+ ? ["ignore", "pipe", "pipe"]
158
+ : "inherit";
159
+
160
+ // ── Spawn the child ─────────────────────────────────────────────
161
+ // We use `shell: false` (the spawn default) on both platforms.
162
+ // shell: true on Windows introduces argument-quoting surprises
163
+ // (cmd.exe quoting rules differ from POSIX shells in subtle ways);
164
+ // we control all args, so we don't need shell expansion.
165
+ let child;
166
+ try {
167
+ child = spawn(npmCmd, args, { stdio });
168
+ } catch (err) {
169
+ // Synchronous spawn failure (rare; some Node versions surface
170
+ // ENOENT this way instead of via the `error` event). Treat
171
+ // identically to the async ENOENT path.
172
+ if (err && err.code === "ENOENT") {
173
+ return {
174
+ success: false,
175
+ binaryPath: null,
176
+ errorCode: "enoent-npm",
177
+ error:
178
+ "`npm` was not found on PATH. Install Node.js " +
179
+ "(which bundles npm) or ensure npm is on your PATH, " +
180
+ "then re-run init.",
181
+ };
182
+ }
183
+ // Any other synchronous spawn failure is unexpected — surface
184
+ // as npm-nonzero with the message.
185
+ return {
186
+ success: false,
187
+ binaryPath: null,
188
+ errorCode: "npm-nonzero",
189
+ error: `Failed to spawn npm: ${err?.message ?? String(err)}`,
190
+ };
191
+ }
192
+
193
+ // ── Capture output (silent mode) ───────────────────────────────
194
+ // Buffer stderr for failure-message extraction. stdout is captured
195
+ // too in case we need it later, but we don't currently use it.
196
+ let stderrChunks = [];
197
+ if (outputMode === "silent") {
198
+ if (child.stderr) {
199
+ child.stderr.on("data", (chunk) => stderrChunks.push(chunk));
200
+ }
201
+ if (child.stdout) {
202
+ // Drain to prevent backpressure; we don't actually need the
203
+ // content. Discarding is the goal of silent mode.
204
+ child.stdout.on("data", () => {});
205
+ }
206
+ }
207
+
208
+ // ── Wait for completion or timeout ─────────────────────────────
209
+ const result = await new Promise((resolve) => {
210
+ let settled = false;
211
+ const settle = (value) => {
212
+ if (settled) return;
213
+ settled = true;
214
+ resolve(value);
215
+ };
216
+
217
+ const timer = setTimeout(() => {
218
+ // The `kill()` may not immediately stop a child that's
219
+ // doing network I/O on a slow socket. We don't await child
220
+ // exit after kill — the timeout result is what we report;
221
+ // the OS reaps the child whenever it actually exits.
222
+ try {
223
+ child.kill();
224
+ } catch {
225
+ // Already exited — fine.
226
+ }
227
+ settle({ kind: "timeout" });
228
+ }, timeoutMs);
229
+
230
+ child.on("error", (err) => {
231
+ clearTimeout(timer);
232
+ // Async spawn errors. ENOENT here means `npm` not on PATH.
233
+ if (err && err.code === "ENOENT") {
234
+ settle({ kind: "enoent-npm" });
235
+ return;
236
+ }
237
+ // EACCES at spawn time (rare, usually surfaces in npm output
238
+ // instead). Treat as npm-nonzero with the error message.
239
+ settle({ kind: "spawn-error", message: err?.message ?? String(err) });
240
+ });
241
+
242
+ child.on("close", (code) => {
243
+ clearTimeout(timer);
244
+ settle({ kind: "exit", code });
245
+ });
246
+ });
247
+
248
+ if (result.kind === "timeout") {
249
+ return {
250
+ success: false,
251
+ binaryPath: null,
252
+ errorCode: "timeout",
253
+ error:
254
+ `npm install -g skillrepo@${version} did not complete within ` +
255
+ `${Math.round(timeoutMs / 1000)} seconds. Check your network ` +
256
+ "connection or npm registry status, then re-run init.",
257
+ };
258
+ }
259
+
260
+ if (result.kind === "enoent-npm") {
261
+ return {
262
+ success: false,
263
+ binaryPath: null,
264
+ errorCode: "enoent-npm",
265
+ error:
266
+ "`npm` was not found on PATH. Install Node.js " +
267
+ "(which bundles npm) or ensure npm is on your PATH, " +
268
+ "then re-run init.",
269
+ };
270
+ }
271
+
272
+ if (result.kind === "spawn-error") {
273
+ return {
274
+ success: false,
275
+ binaryPath: null,
276
+ errorCode: "npm-nonzero",
277
+ error: `Failed to run npm: ${result.message}`,
278
+ };
279
+ }
280
+
281
+ // result.kind === "exit"
282
+ const exitCode = result.code;
283
+ // Only build the stderr string when we actually captured it
284
+ // (silent mode). In inherit mode, `stderrChunks` is always empty
285
+ // because the stream wasn't piped to us.
286
+ const stderrText =
287
+ outputMode === "silent"
288
+ ? Buffer.concat(stderrChunks).toString("utf-8")
289
+ : "";
290
+
291
+ if (exitCode !== 0) {
292
+ // EACCES is a common case worth distinguishing — the actionable
293
+ // remediation differs (sudo or fix prefix vs check the npm
294
+ // output). We have two signals available:
295
+ // 1. stderr text contains "EACCES" — only available in silent
296
+ // mode (`--json`) where we capture stderr.
297
+ // 2. exit code 243 — npm's exit code for EACCES on POSIX.
298
+ // Documented in npm's source as the dedicated permission-
299
+ // error code. Available in BOTH inherit and silent modes
300
+ // because exit code is always observable.
301
+ // Trying both gives the user a categorized error in both modes.
302
+ if (stderrText.includes("EACCES") || exitCode === 243) {
303
+ return {
304
+ success: false,
305
+ binaryPath: null,
306
+ errorCode: "eacces",
307
+ error:
308
+ "npm reported a permissions error (EACCES). Run with sudo " +
309
+ "or fix npm's prefix to a writable location: " +
310
+ "https://docs.npmjs.com/resolving-eacces-permissions-errors",
311
+ };
312
+ }
313
+ // Generic npm failure. In silent mode (--json) we have stderr
314
+ // text and include the first 200 chars for diagnosis. In inherit
315
+ // mode the user already saw npm's real output stream past, so
316
+ // we add a short hint pointing at the two most common
317
+ // remediations (permissions, network) without trying to guess
318
+ // which applies.
319
+ const trimmedStderr =
320
+ stderrText.length > 0 ? ` ${stderrText.slice(0, 200).trim()}` : "";
321
+ const inheritHint =
322
+ stderrText.length === 0
323
+ ? " Common causes: permissions (try sudo, or fix npm's prefix) " +
324
+ "or network/registry issues (check your internet connection)."
325
+ : "";
326
+ return {
327
+ success: false,
328
+ binaryPath: null,
329
+ errorCode: "npm-nonzero",
330
+ error: `npm install -g exited with code ${exitCode}.${trimmedStderr}${inheritHint}`,
331
+ };
332
+ }
333
+
334
+ // ── Verify the install actually produced a usable binary ──────
335
+ const binaryPath = resolveGlobalBinary({ platform: platformOverride });
336
+ if (!binaryPath) {
337
+ const isWindows = conv.family === "windows";
338
+ // Windows-specific addendum: PATH changes from `npm install -g`
339
+ // are NOT visible in the current terminal session — the user
340
+ // has to open a new terminal for the change to propagate. POSIX
341
+ // shells inherit the new PATH naturally because npm writes to
342
+ // a directory the user's shell already has on PATH.
343
+ const platformAddendum = isWindows
344
+ ? " On Windows, `npm install -g` does not refresh PATH in the " +
345
+ "current terminal. Open a new PowerShell or cmd.exe window, " +
346
+ "then run `skillrepo session-sync enable`."
347
+ : "";
348
+ return {
349
+ success: false,
350
+ binaryPath: null,
351
+ errorCode: "path-not-updated",
352
+ error:
353
+ "npm install -g succeeded but `skillrepo` was not found on " +
354
+ "PATH. Your npm prefix bin directory may not be on PATH. " +
355
+ "Run `npm config get prefix` and add `<prefix>/bin` to PATH, " +
356
+ "then run `skillrepo session-sync enable`." +
357
+ platformAddendum,
358
+ };
359
+ }
360
+
361
+ return {
362
+ success: true,
363
+ binaryPath,
364
+ };
365
+ }
366
+
367
+ /**
368
+ * Resolve the absolute path of a STABLE (non-cache) `skillrepo`
369
+ * binary on PATH. Used after `npm install -g` to confirm the install
370
+ * produced a usable binary at a path safe to bake into the
371
+ * SessionStart hook command.
372
+ *
373
+ * Thin wrapper over `resolveBinaryOnPath` with `filterTransient: true`
374
+ * preset. The `skipIfTransient` flag is intentionally false — we
375
+ * WANT to find the newly-installed global even when we're running
376
+ * under a transient runner ourselves.
377
+ *
378
+ * @param {object} [options]
379
+ * @param {NodeJS.Platform} [options.platform] - Override for tests.
380
+ * @returns {string | null}
381
+ */
382
+ export function resolveGlobalBinary({ platform: platformOverride } = {}) {
383
+ return resolveBinaryOnPath("skillrepo", {
384
+ filterTransient: true,
385
+ platform: platformOverride,
386
+ });
387
+ }
@@ -75,16 +75,27 @@ import { validationError } from "./errors.mjs";
75
75
  * @param {object} [options.io] - Injected streams for testability
76
76
  * @param {NodeJS.WritableStream} [options.io.stdout=process.stdout]
77
77
  * @param {NodeJS.WritableStream} [options.io.stderr=process.stderr]
78
- * @param {(prompt: string, defaultYes?: boolean) => Promise<boolean>} [options.confirmFn]
78
+ * @param {object} [options.deps] - Test-only dependency injection.
79
+ * Production callers leave this empty. Standardized name
80
+ * across CLI commands (init, init-session-sync, etc.) so all
81
+ * injection points live under a single `deps` namespace.
82
+ * @param {(prompt: string, defaultYes?: boolean) => Promise<boolean>} [options.deps.confirmFn]
79
83
  * Optional injection point for the y/n prompt. Defaults to
80
84
  * the real `confirm` from prompt.mjs. Tests pass a stub to
81
- * avoid spawning a readline interface. This dependency is
82
- * injected rather than monkey-patched because ESM module
83
- * exports are frozen and cannot be reassigned.
85
+ * avoid spawning a readline interface. ESM module exports
86
+ * are frozen and cannot be reassigned, so dependency
87
+ * injection is the only clean way to substitute.
84
88
  * @returns {Promise<McpMergeResult[]>}
85
89
  */
86
90
  export async function mergeMcpForVendors(options) {
87
- const { vendors, mcpUrl, yes = false, io = {}, confirmFn = realConfirm } = options;
91
+ const {
92
+ vendors,
93
+ mcpUrl,
94
+ yes = false,
95
+ io = {},
96
+ deps = {},
97
+ } = options;
98
+ const confirmFn = deps.confirmFn ?? realConfirm;
88
99
  const stdout = io.stdout ?? process.stdout;
89
100
  const stderr = io.stderr ?? process.stderr;
90
101
 
@@ -70,7 +70,6 @@
70
70
  */
71
71
 
72
72
  import { existsSync, readFileSync } from "node:fs";
73
- import { execFileSync } from "node:child_process";
74
73
  import { writeFileAtomic } from "../fs-utils.mjs";
75
74
  import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
76
75
  import {
@@ -79,54 +78,109 @@ import {
79
78
  } from "../paths.mjs";
80
79
  import { diskError, validationError } from "../errors.mjs";
81
80
  import { removeSettingsSessionHook } from "../removers/settings.mjs";
81
+ import { resolveBinaryOnPath } from "../binary-locator.mjs";
82
+ import { platformConventions } from "../platform.mjs";
82
83
 
83
84
  /**
84
85
  * Build the hook command string for a given absolute path. Exported
85
86
  * so tests can assert the exact bytes the installer writes.
86
87
  *
88
+ * Shell shape is platform-specific — see `platform.mjs` for the full
89
+ * rationale. Summary:
90
+ *
91
+ * - **POSIX** (macOS, Linux): `'<path>' update --session-hook 2>&1 || true`.
92
+ * `|| true` catches any non-zero exit at the shell level; primary
93
+ * defense is the `--session-hook` flag contract in the Node process.
94
+ * - **Windows** (cmd.exe / PowerShell): `"<path>" update --session-hook 2>&1`.
95
+ * `|| true` omitted because cmd.exe doesn't know the `true` builtin.
96
+ * `--session-hook` contract is the only defense; consequences of
97
+ * binary-vanished scenarios are slightly noisier in Claude Code's
98
+ * session log but still non-blocking.
99
+ *
100
+ * Path quoting is mandatory: real-world install paths contain spaces
101
+ * (`C:\Program Files\nodejs\skillrepo.cmd`, `/Users/First Last/.npm-global/bin/skillrepo`)
102
+ * and parentheses (`C:\Program Files (x86)\...`). An unquoted path
103
+ * makes the shell parse the command as multiple arguments and the
104
+ * hook silently fails on session start.
105
+ *
106
+ * The fingerprint constant (`SESSION_HOOK_FINGERPRINT` =
107
+ * ` update --session-hook` with leading space) MUST appear in the
108
+ * resulting command for the uninstaller and idempotency walks to
109
+ * find it. Single/double quotes don't break the fingerprint because
110
+ * the leading space sits between the closing quote and `update`.
111
+ * Backward-compat: existing v3.1.0/v3.1.1 hooks with unquoted paths
112
+ * also match the fingerprint (the space sits between the path and
113
+ * `update`), so re-running init detects them and updates in place
114
+ * to the new quoted shape.
115
+ *
116
+ * The suffix is supplied by `platformConventions().hookShellSuffix` —
117
+ * this function doesn't know which OS it's targeting, it just
118
+ * concatenates the convention's suffix.
119
+ *
87
120
  * @param {string} binaryPath - Absolute path to the `skillrepo` binary.
121
+ * @param {object} [options]
122
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
123
+ * Default: `os.platform()`.
88
124
  * @returns {string} The full shell command string.
89
125
  */
90
- export function buildHookCommand(binaryPath) {
126
+ export function buildHookCommand(binaryPath, { platform: platformOverride } = {}) {
91
127
  if (typeof binaryPath !== "string" || binaryPath.length === 0) {
92
128
  throw validationError(
93
129
  "buildHookCommand: binaryPath must be a non-empty string.",
94
130
  );
95
131
  }
96
- return `${binaryPath} update --session-hook 2>&1 || true`;
132
+ const conv = platformConventions({ platform: platformOverride });
133
+ const quotedPath = quoteShellPath(binaryPath, conv.family);
134
+ return `${quotedPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
97
135
  }
98
136
 
99
137
  /**
100
- * Resolve the absolute path of the `skillrepo` binary via `which`.
101
- * Returns null if resolution fails (e.g. user ran `npx skillrepo init`
102
- * without a global install) — the caller should skip hook installation
103
- * with a clear warning rather than fail init.
138
+ * Quote a filesystem path for inclusion in a shell command, using the
139
+ * conventions of the target shell family.
104
140
  *
105
- * @returns {string | null}
141
+ * - **POSIX**: wrap in single quotes; escape any embedded single
142
+ * quote with the standard `'\''` trick (close quote, escaped
143
+ * literal quote, reopen quote). Single quotes suppress ALL shell
144
+ * interpretation, so spaces, `$`, `*`, parens, double quotes,
145
+ * backticks, and backslashes pass through verbatim.
146
+ *
147
+ * - **Windows** (cmd.exe): wrap in double quotes; escape any
148
+ * embedded double quote with `\"`. Backslashes inside the path
149
+ * pass through unchanged (cmd.exe does not interpret backslashes
150
+ * as escapes inside double quotes). Filesystem rules forbid
151
+ * literal `"` in NTFS path components, so the `\"` escape is
152
+ * defensive — paths in the wild won't contain it.
153
+ *
154
+ * @param {string} path
155
+ * @param {"posix" | "windows"} family
156
+ * @returns {string} The quoted path, ready to be interpolated into
157
+ * a shell command.
106
158
  */
107
- export function resolveSkillrepoBinary() {
108
- try {
109
- // 3-second timeout — `which` typically returns in milliseconds,
110
- // but a PATH that includes a network filesystem or a `which`
111
- // alias that does I/O could hang indefinitely. Bounding the
112
- // call ensures `skillrepo init` never stalls on binary
113
- // resolution. Per code-reviewer round-1 LOW finding.
114
- const result = execFileSync("which", ["skillrepo"], {
115
- encoding: "utf-8",
116
- stdio: ["ignore", "pipe", "ignore"],
117
- timeout: 3000,
118
- }).trim();
119
- if (!result) return null;
120
- // Sanity: the resolved path must be absolute. A relative result
121
- // would be meaningless at session-start time because the Claude
122
- // Code hook runner's cwd is undefined.
123
- if (!result.startsWith("/")) return null;
124
- return result;
125
- } catch {
126
- // `which` exits non-zero if the binary isn't found. Treat as
127
- // "no global install, skip session-sync" — caller handles.
128
- return null;
159
+ function quoteShellPath(path, family) {
160
+ if (family === "windows") {
161
+ return `"${path.replace(/"/g, '\\"')}"`;
129
162
  }
163
+ // POSIX
164
+ return `'${path.replace(/'/g, "'\\''")}'`;
165
+ }
166
+
167
+ /**
168
+ * Resolve the absolute path of the `skillrepo` binary on PATH,
169
+ * skipping resolution entirely when the current process is itself
170
+ * a transient-runner invocation (npx, pnpx, yarn dlx, bunx) — those
171
+ * cache paths must not be baked into the long-lived hook command.
172
+ *
173
+ * Thin wrapper over `resolveBinaryOnPath` with the
174
+ * `skipIfTransient` flag preset. Kept as a named export for
175
+ * call-site readability and so existing tests keep working.
176
+ *
177
+ * @returns {string | null}
178
+ */
179
+ export function resolveSkillrepoBinary({ platform: platformOverride } = {}) {
180
+ return resolveBinaryOnPath("skillrepo", {
181
+ skipIfTransient: true,
182
+ platform: platformOverride,
183
+ });
130
184
  }
131
185
 
132
186
  /**
@@ -140,6 +194,16 @@ export function resolveSkillrepoBinary() {
140
194
  * @param {boolean} [options.global=false] - When true, installs to the
141
195
  * user-global `~/.claude/settings.local.json` instead of the
142
196
  * project-local file.
197
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
198
+ * Propagated to both `resolveSkillrepoBinary` and
199
+ * `buildHookCommand` so a test can exercise the full
200
+ * installer path under simulated Windows semantics on a
201
+ * non-Windows host. Production callers leave this unset so
202
+ * both helpers see the real `os.platform()`. This option is
203
+ * the mechanism that closes the architect's round-3 HIGH
204
+ * finding — without it, a Windows-shaped `binaryPath` passed
205
+ * in the test still got a POSIX-shaped command back because
206
+ * `buildHookCommand` read `os.platform()` directly.
143
207
  * @returns {{
144
208
  * path: string;
145
209
  * action: "installed" | "updated" | "unchanged" | "skipped";
@@ -158,23 +222,51 @@ export function resolveSkillrepoBinary() {
158
222
  export function mergeSessionHook({
159
223
  binaryPath: binaryPathOpt,
160
224
  global = false,
225
+ platform: platformOverride,
161
226
  } = {}) {
162
227
  const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
163
228
  const displayPath = global
164
229
  ? "~/.claude/settings.local.json"
165
230
  : ".claude/settings.local.json";
166
231
 
167
- const binaryPath = binaryPathOpt ?? resolveSkillrepoBinary();
232
+ const binaryPath =
233
+ binaryPathOpt ?? resolveSkillrepoBinary({ platform: platformOverride });
168
234
  if (!binaryPath) {
235
+ // Two reasons binaryPath can be null:
236
+ // 1. `isNpxInvocation()` returned true — the user ran
237
+ // `npx skillrepo ...`. The npx cache path is transient and
238
+ // unsuitable for baking into a long-lived hook command.
239
+ // 2. `which skillrepo` returned nothing — no global install
240
+ // exists at all.
241
+ // Both are the same problem from the hook's perspective: we
242
+ // can't produce a command that will still work later.
243
+ //
244
+ // v3.1.2 (#894): `init` bypasses this path under npx by running
245
+ // `npm install -g skillrepo@<version>` itself and then calling
246
+ // `mergeSessionHook` with the resulting `binaryPath` explicitly.
247
+ // The remaining callers that hit this branch are:
248
+ // - `skillrepo session-sync enable` invoked under npx (does
249
+ // not auto-install — a deliberate, explicit user invocation
250
+ // should not silently mutate global package state).
251
+ // - The rare bare-install case where `which skillrepo` fails
252
+ // even though we're not under npx (PATH misconfiguration).
253
+ //
254
+ // Both cases get the same actionable hint pointing the user at
255
+ // `skillrepo init`, which DOES auto-install under npx.
169
256
  return {
170
257
  path: displayPath,
171
258
  action: "skipped",
172
259
  reason:
173
- "Could not resolve a stable path for `skillrepo`. Session sync requires a global install. Run `npm install -g skillrepo` and re-run `skillrepo session-sync enable`.",
260
+ "Session sync requires a stable `skillrepo` binary on PATH. " +
261
+ "Run `npm install -g skillrepo` (or use `skillrepo init`, " +
262
+ "which offers to install globally for you under npx), then " +
263
+ "re-run `skillrepo session-sync enable`.",
174
264
  };
175
265
  }
176
266
 
177
- const desiredCommand = buildHookCommand(binaryPath);
267
+ const desiredCommand = buildHookCommand(binaryPath, {
268
+ platform: platformOverride,
269
+ });
178
270
 
179
271
  // Parse existing file (or start fresh). A corrupt-but-present file
180
272
  // is a hard error: silently overwriting it would destroy any user-