skillrepo 3.1.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 (33) hide show
  1. package/README.md +3 -1
  2. package/package.json +1 -1
  3. package/src/commands/init.mjs +52 -5
  4. package/src/lib/artifact-registry.mjs +43 -3
  5. package/src/lib/cli-config.mjs +78 -0
  6. package/src/lib/config.mjs +6 -3
  7. package/src/lib/file-write.mjs +8 -3
  8. package/src/lib/fs-utils.mjs +9 -10
  9. package/src/lib/mergers/session-hook.mjs +99 -19
  10. package/src/lib/platform.mjs +124 -0
  11. package/src/lib/sync.mjs +26 -0
  12. package/src/test/commands/add.test.mjs +10 -4
  13. package/src/test/commands/get.test.mjs +10 -4
  14. package/src/test/commands/init.test.mjs +228 -15
  15. package/src/test/commands/list.test.mjs +10 -4
  16. package/src/test/commands/remove.test.mjs +10 -4
  17. package/src/test/commands/search.test.mjs +10 -4
  18. package/src/test/commands/session-sync.test.mjs +25 -23
  19. package/src/test/commands/uninstall.test.mjs +20 -14
  20. package/src/test/commands/update.test.mjs +10 -4
  21. package/src/test/helpers/sandbox-home.mjs +161 -0
  22. package/src/test/helpers/skillrepo-shim.mjs +133 -0
  23. package/src/test/integration/file-write.integration.test.mjs +10 -4
  24. package/src/test/lib/cli-config.test.mjs +126 -5
  25. package/src/test/lib/config.test.mjs +10 -4
  26. package/src/test/lib/file-write.test.mjs +24 -10
  27. package/src/test/lib/mcp-merge.test.mjs +10 -4
  28. package/src/test/lib/paths.test.mjs +10 -4
  29. package/src/test/lib/platform.test.mjs +135 -0
  30. package/src/test/lib/sync.test.mjs +20 -4
  31. package/src/test/mergers/session-hook.test.mjs +441 -11
  32. package/src/test/mergers/uninstall-settings.test.mjs +12 -1
  33. package/src/test/mergers/uninstall-windsurf-mcp.test.mjs +10 -4
package/README.md CHANGED
@@ -142,7 +142,9 @@ Installs (or removes) a Claude Code [SessionStart hook](https://docs.claude.com/
142
142
 
143
143
  By default `skillrepo init` prompts you to install this hook. If you said no (or passed `--no-session-sync`), run `session-sync enable` later to turn it on.
144
144
 
145
- **The hook cannot block your session.** The command it runs is `skillrepo update --session-hook 2>&1 || true`. That flag makes `update` exit 0 on every failure network outage, revoked key, disk error, anything — and print a single-line failure message to your session. The `|| true` shell backstop catches anything that escapes. Session starts are never blocked by sync failures.
145
+ **Requires a stable global install.** The hook is skipped (with a clear warning) when `skillrepo init` is invoked via `npx`, because the npx cache path is transient and the baked-in hook command would break on the next cache eviction. Install globally with `npm install -g skillrepo` before running `session-sync enable`.
146
+
147
+ **The hook cannot block your session.** The command it runs is `<path-to-skillrepo> update --session-hook 2>&1 [|| true]`. The `--session-hook` flag makes `update` exit 0 on every failure — network outage, revoked key, disk error, anything — and print a single-line failure message to your session. On POSIX systems the `|| true` shell backstop is appended as belt-and-suspenders; on Windows it's omitted because cmd.exe doesn't know the `true` builtin (the `--session-hook` flag's exit-0 contract is the primary defense regardless of platform). Session starts are never blocked by sync failures.
146
148
 
147
149
  **On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
148
150
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,7 +43,7 @@
43
43
  import { validateAccessKey } from "../lib/http.mjs";
44
44
  import { detectIdes, formatDetectedIdes } from "../lib/detect-ides.mjs";
45
45
  import { readConfig, writeConfig } from "../lib/config.mjs";
46
- import { resolveFlags, effectiveVendors } from "../lib/cli-config.mjs";
46
+ import { resolveFlags, effectiveVendors, isNpxInvocation } from "../lib/cli-config.mjs";
47
47
  import { mergeMcpForVendors, printManualMcpInstructions } from "../lib/mcp-merge.mjs";
48
48
  import { runSync } from "../lib/sync.mjs";
49
49
  import { mergeEnvLocal } from "../lib/mergers/env-local.mjs";
@@ -519,16 +519,46 @@ export async function runInit(argv, io = {}) {
519
519
  updated: 0,
520
520
  removed: 0,
521
521
  notModified: false,
522
+ // On a synthesized failure summary we genuinely don't know
523
+ // whether the sync WOULD have been full or delta — the network
524
+ // call never completed. Architect review (v3.1.1) flagged that
525
+ // emitting `fullSync: false` here is misleading for --json
526
+ // consumers: it looks like a legitimate "delta sync returned
527
+ // zero" signal. Using `null` makes the unknown-state
528
+ // explicit — any typed consumer must handle it separately
529
+ // from true/false. The always-present `sync.failureReason`
530
+ // field is still the authoritative "did the sync fail"
531
+ // indicator; fullSync is just additional context.
532
+ fullSync: null,
522
533
  syncedAt: new Date().toISOString(),
523
534
  };
524
535
  }
525
536
 
537
+ const zeroDeltas =
538
+ syncSummary.added + syncSummary.updated + syncSummary.removed === 0;
539
+
526
540
  if (syncFailedReason) {
527
541
  // The warning already printed; the step-summary success line
528
542
  // would be misleading, so we skip it. Any helpful "next steps"
529
543
  // is in the final `SkillRepo is ready` block.
530
- } else if (syncSummary.notModified || syncSummary.added + syncSummary.updated + syncSummary.removed === 0) {
544
+ } else if (syncSummary.notModified) {
545
+ // 304 Not Modified — the client had the current ETag already.
546
+ // Definitively "up to date" regardless of whether the library
547
+ // is empty or populated.
548
+ p.success("Library is up to date.");
549
+ } else if (zeroDeltas && syncSummary.fullSync) {
550
+ // Full sync (no prior .last-sync state existed) with zero
551
+ // results — the account's library is genuinely empty.
531
552
  p.success("No skills in library yet (add some with `skillrepo add @owner/name`)");
553
+ } else if (zeroDeltas) {
554
+ // Delta sync with zero results — nothing changed since the
555
+ // last sync. Could be zero skills total, or N skills all
556
+ // unchanged. Without a full-sync roundtrip we can't tell, so
557
+ // the accurate phrasing is "no changes." Before this fix, the
558
+ // init step-7 message conflated this with the truly-empty
559
+ // case, which lied to any user who had skills but had already
560
+ // synced them on a prior run.
561
+ p.success("Library is up to date (no changes since last sync).");
532
562
  } else {
533
563
  p.success(
534
564
  `${syncSummary.added} added, ${syncSummary.updated} updated, ${syncSummary.removed} removed`,
@@ -581,10 +611,27 @@ export async function runInit(argv, io = {}) {
581
611
  }
582
612
 
583
613
  stdout.write("\n ✓ SkillRepo is ready.\n\n");
614
+ // Pick the command prefix the user can actually run. If they
615
+ // invoked init via `npx skillrepo ...`, bare `skillrepo list` will
616
+ // fail with "command not found" — they need `npx skillrepo list`.
617
+ // Under a global install, the bare command is correct. We default
618
+ // to bare and add the `npx` prefix ONLY when we can detect the
619
+ // current invocation is npx.
620
+ const prefix = isNpxInvocation() ? "npx skillrepo" : "skillrepo";
584
621
  stdout.write(" Next steps:\n");
585
- stdout.write("skillrepo list — see what's in your library\n");
586
- stdout.write("skillrepo search <query> — find skills\n");
587
- stdout.write("skillrepo add @owner/name — add a skill\n\n");
622
+ stdout.write(`${prefix} list — see what's in your library\n`);
623
+ stdout.write(`${prefix} search <query> — find skills\n`);
624
+ stdout.write(`${prefix} add @owner/name — add a skill\n`);
625
+ if (isNpxInvocation()) {
626
+ // Soft recommendation: running under npx works but every command
627
+ // re-downloads the package. Global install is faster AND enables
628
+ // the session-sync feature (which requires a stable binary path).
629
+ stdout.write(
630
+ "\n Tip: `npm install -g skillrepo` for faster commands " +
631
+ "and to enable session-start sync.\n",
632
+ );
633
+ }
634
+ stdout.write("\n");
588
635
  }
589
636
 
590
637
  /**
@@ -88,8 +88,48 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
88
88
  /**
89
89
  * Substring that identifies a SessionStart hook command entry as
90
90
  * SkillRepo-owned. The #884 installer writes a hook whose `command`
91
- * field contains `skillrepo update --session-hook`; any entry whose
92
- * command contains this substring is removed by the uninstall path.
91
+ * field ends with `<binary-path> update --session-hook ...`; any
92
+ * entry whose command contains ` update --session-hook` (with the
93
+ * leading space) is removed by the uninstall path.
94
+ *
95
+ * The leading space is a lightweight word boundary — it requires
96
+ * that `update` is preceded by whitespace (i.e. it's an argv token
97
+ * after the binary path), not a suffix of a longer identifier like
98
+ * `toolupdate` or `postupdate`. Without the space, a hypothetical
99
+ * binary at `/usr/local/bin/myapp-update` invoked with
100
+ * `--session-hook` as `/usr/local/bin/myapp-update --session-hook`
101
+ * would NOT match (because the substring would be `-update
102
+ * --session-hook`, not ` update --session-hook`), whereas a naive
103
+ * `update --session-hook` fingerprint would have.
104
+ *
105
+ * The leading space does NOT eliminate all false-positive classes.
106
+ * A command like `brew update --session-hook` DOES match the
107
+ * fingerprint — the space between `brew` and `update` is exactly
108
+ * what we key on. The primary protection against real-world false
109
+ * positives is the specificity of the two-token combination
110
+ * `update --session-hook` itself: `--session-hook` is not a
111
+ * conventional flag name used by tools other than SkillRepo, so the
112
+ * chance of a coincidental match is astronomically low. The test
113
+ * at `session-hook.test.mjs` "the fingerprint is specific enough
114
+ * that innocuous user hooks do NOT match it" enumerates plausible
115
+ * user-hook commands and confirms none trip the predicate.
116
+ *
117
+ * The fingerprint is also deliberately platform-neutral. Earlier
118
+ * versions matched the longer `skillrepo update --session-hook`
119
+ * substring, but that pattern silently fails to match Windows hook
120
+ * commands because npm installs the CLI as a `.cmd` shim — the
121
+ * absolute path on Windows ends `...\skillrepo.cmd`, which puts the
122
+ * `.cmd` extension between `skillrepo` and `update` in the command
123
+ * string. The shorter ` update --session-hook` substring is present
124
+ * on both:
125
+ * POSIX: `/usr/local/bin/skillrepo update --session-hook 2>&1 || true`
126
+ * Windows: `C:\path\skillrepo.cmd update --session-hook 2>&1`
127
+ *
128
+ * Backward-compat: any v3.1.0 hook contains
129
+ * `skillrepo update --session-hook`, which is a strict superset of
130
+ * ` update --session-hook` (the space between `skillrepo` and `update`
131
+ * is the space we're matching). So upgrades still correctly identify
132
+ * and update the old entry in place.
93
133
  *
94
134
  * Exported so #884's installer can import and use the same constant —
95
135
  * this is the module boundary that makes #884 depend on #885 rather
@@ -97,7 +137,7 @@ export const VSCODE_INPUT_ID = "skillrepo-api-key";
97
137
  * 5.3) notes the bidirectional-fingerprint requirement; centralizing
98
138
  * it here enforces it at the language level.
99
139
  */
100
- export const SESSION_HOOK_FINGERPRINT = "skillrepo update --session-hook";
140
+ export const SESSION_HOOK_FINGERPRINT = " update --session-hook";
101
141
 
102
142
  // ── Artifact descriptors ────────────────────────────────────────────
103
143
 
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Shared credential + flag resolution for command modules.
3
3
  *
4
+ * Also houses process-environment helpers that multiple command
5
+ * modules need — specifically `isNpxInvocation()` which several
6
+ * surfaces use to decide whether the user has a stable global
7
+ * install or is running a transient npx download.
8
+ *
4
9
  * Every command needs to:
5
10
  * 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
6
11
  * 2. Fall back to ~/.claude/skillrepo/config.json
@@ -21,6 +26,70 @@ import { authError, validationError } from "./errors.mjs";
21
26
  const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
22
27
  const VENDOR_ALIASES = { claude: "claudeCode" };
23
28
 
29
+ /**
30
+ * True when the current process was launched via `npx skillrepo ...`
31
+ * rather than from a stable global install.
32
+ *
33
+ * Why this matters:
34
+ *
35
+ * - `npx skillrepo init` downloads the package into `~/.npm/_npx/<hash>/`
36
+ * and exposes its `.bin/skillrepo` on PATH for the subprocess only.
37
+ * `execFileSync("which", ["skillrepo"])` DOES find that path, but it
38
+ * is a transient cache location. npm eviction, a version bump, or
39
+ * `npm cache clean` later invalidates the absolute path, so any
40
+ * on-disk reference to it (e.g. a SessionStart hook command baked
41
+ * in at install time) silently breaks.
42
+ *
43
+ * - The architect design for #884 explicitly specified that npx
44
+ * users should skip the session-sync step with a "requires a global
45
+ * install" warning. The `which`-based resolver in
46
+ * `mergers/session-hook.mjs` alone is too permissive — it finds the
47
+ * npx cache path and treats it as stable. This helper closes that
48
+ * gap by detecting npx unambiguously.
49
+ *
50
+ * - `init`'s "Next steps" output also needs to know: under npx, the
51
+ * right hint is `npx skillrepo list` (or "install globally first"),
52
+ * not bare `skillrepo list` (which would fail for the user).
53
+ *
54
+ * Detection uses two signals, either one sufficient:
55
+ *
56
+ * 1. `process.argv[1]` contains `/_npx/` (or Windows `\_npx\`) —
57
+ * the primary signal. npx-launched scripts literally live inside
58
+ * `~/.npm/_npx/<hash>/node_modules/.bin/...` so the executable
59
+ * path itself names the cache directory. Highest reliability,
60
+ * no false-positive surface.
61
+ *
62
+ * 2. `process.env._` ends with `/npx` (or `\npx` on Windows) —
63
+ * legacy fallback for shells that set `_` to the launched
64
+ * command. Defensive against shim layouts where argv[1] has
65
+ * been symlinked through a path that doesn't contain `_npx`.
66
+ *
67
+ * Why NOT `process.env.npm_command === "exec"`: this signal was
68
+ * considered but rejected in v3.1.1 review. `npm_command=exec` is
69
+ * also set when a stable-install user runs `skillrepo init` from a
70
+ * `package.json` lifecycle script (e.g. `"postinstall": "skillrepo
71
+ * init --yes"`) or invokes `npm exec skillrepo ...` directly. In
72
+ * those cases the user has a real global install and should NOT
73
+ * have session-sync skipped or see `npx skillrepo` in Next Steps.
74
+ * The argv[1] signal already catches real npx invocations
75
+ * unambiguously; adding npm_command trades a minor coverage gain
76
+ * (shim layouts) for a false-positive surface that affects real
77
+ * users. See v3.1.1 PR review cycle for the full discussion.
78
+ *
79
+ * @returns {boolean}
80
+ */
81
+ export function isNpxInvocation() {
82
+ const execPath = process.argv[1] ?? "";
83
+ if (execPath.includes("/_npx/") || execPath.includes("\\_npx\\")) {
84
+ return true;
85
+ }
86
+ const underscore = process.env._ ?? "";
87
+ if (underscore.endsWith("/npx") || underscore.endsWith("\\npx")) {
88
+ return true;
89
+ }
90
+ return false;
91
+ }
92
+
24
93
  /**
25
94
  * @typedef {Object} ResolvedFlags
26
95
  * @property {string} serverUrl
@@ -85,6 +154,15 @@ export function resolveFlags(argv, opts = {}) {
85
154
  } else if (arg === "--help" || arg === "-h") {
86
155
  // Dispatcher should have intercepted this. Defensive no-op.
87
156
  continue;
157
+ } else if (arg === "--verbose") {
158
+ // Global flag set by the dispatcher into SKILLREPO_VERBOSE=1
159
+ // so http.mjs's retry logger can honor it. It's a first-class
160
+ // flag, not an unknown arg — accept it silently in every
161
+ // command that passes through resolveFlags. Before this
162
+ // branch existed, any command that consumed argv via
163
+ // resolveFlags rejected `--verbose` with "Unknown argument",
164
+ // breaking the flag documented in the top-level --help.
165
+ continue;
88
166
  } else {
89
167
  // Allow the caller to consume a positional arg before we treat
90
168
  // it as unknown. This is how `get @owner/name` and
@@ -43,10 +43,10 @@ import {
43
43
  unlinkSync,
44
44
  } from "node:fs";
45
45
  import { dirname } from "node:path";
46
- import { platform } from "node:os";
47
46
 
48
47
  import { globalConfigPath } from "./paths.mjs";
49
48
  import { diskError, validationError } from "./errors.mjs";
49
+ import { platformConventions } from "./platform.mjs";
50
50
 
51
51
  /**
52
52
  * Current schema version. Bump this on any structural change.
@@ -187,8 +187,11 @@ export function writeConfig(config) {
187
187
 
188
188
  // chmod the temp file before renaming so the destination never
189
189
  // exists with world-readable perms (which would be a brief
190
- // credential leak window on a shared system).
191
- if (platform() !== "win32") {
190
+ // credential leak window on a shared system). Windows callers
191
+ // route through platformConventions().supportsPosixPermissions
192
+ // see platform.mjs for why we skip chmod there instead of pretend-
193
+ // applying it.
194
+ if (platformConventions().supportsPosixPermissions) {
192
195
  try {
193
196
  chmodSync(tmpPath, 0o600);
194
197
  } catch {
@@ -50,7 +50,6 @@ import {
50
50
  statSync,
51
51
  } from "node:fs";
52
52
  import { dirname, join, isAbsolute, relative } from "node:path";
53
- import { platform } from "node:os";
54
53
 
55
54
  import { readFileSafe, writeFileSafe } from "./fs-utils.mjs";
56
55
  import {
@@ -63,6 +62,7 @@ import {
63
62
  gitignorePath,
64
63
  } from "./paths.mjs";
65
64
  import { CliError, validationError, diskError } from "./errors.mjs";
65
+ import { platformConventions } from "./platform.mjs";
66
66
 
67
67
  // ── Constants (mirror the server-side validators in src/lib/skills/) ────
68
68
 
@@ -562,8 +562,13 @@ function writeSkillToDir(skill, targetDir) {
562
562
  }
563
563
  }
564
564
 
565
- // 2 + 3 + 4: rename dance (POSIX atomic on same filesystem; best-effort on Windows)
566
- if (platform() === "win32") {
565
+ // 2 + 3 + 4: rename dance. POSIX is atomic on the same filesystem;
566
+ // Windows has to do remove-then-rename because renameSync fails on
567
+ // existing directory targets. The split is named via
568
+ // platformConventions().supportsAtomicDirectoryRename so the intent
569
+ // reads as a capability check, not a platform check. See
570
+ // platform.mjs for the rationale.
571
+ if (!platformConventions().supportsAtomicDirectoryRename) {
567
572
  // Windows: rename fails on existing destinations and locked files,
568
573
  // so we fall back to remove-then-rename. There is a window where
569
574
  // the live target is gone but the rename has not yet completed.
@@ -14,6 +14,7 @@ import {
14
14
  unlinkSync,
15
15
  } from "node:fs";
16
16
  import { dirname } from "node:path";
17
+ import { platformConventions } from "./platform.mjs";
17
18
 
18
19
  /**
19
20
  * Read a file as UTF-8, returning null if it doesn't exist.
@@ -44,15 +45,6 @@ export function writeFileSafe(filePath, content) {
44
45
  writeFileSync(filePath, content, "utf-8");
45
46
  }
46
47
 
47
- /**
48
- * Write a file and mark it executable (0o755).
49
- * Used for the Cursor session hook which is invoked directly via shebang.
50
- */
51
- export function writeExecutable(filePath, content) {
52
- writeFileSafe(filePath, content);
53
- chmodSync(filePath, 0o755);
54
- }
55
-
56
48
  /**
57
49
  * Check if a path exists (file or directory).
58
50
  */
@@ -105,7 +97,7 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
105
97
  throw new Error(`Cannot write ${tmpPath}: ${err.message}`, { cause: err });
106
98
  }
107
99
 
108
- if (mode !== undefined && process.platform !== "win32") {
100
+ if (mode !== undefined && platformConventions().supportsPosixPermissions) {
109
101
  try {
110
102
  chmodSync(tmpPath, mode);
111
103
  } catch {
@@ -115,6 +107,13 @@ export function writeFileAtomic(filePath, content, { mode } = {}) {
115
107
  // file after the write.
116
108
  }
117
109
  }
110
+ // On Windows we deliberately skip chmod entirely. Node lets the call
111
+ // succeed on Windows but the mode bits don't map to anything the
112
+ // ACL layer enforces, so a "success" return would mislead the caller
113
+ // into thinking the credential file is access-restricted when it
114
+ // isn't. Windows users needing per-user protection should rely on
115
+ // %APPDATA%'s inherited ACLs (which default to the current user) or
116
+ // apply DACL restrictions at the OS level — outside this CLI's scope.
118
117
 
119
118
  try {
120
119
  renameSync(tmpPath, filePath);
@@ -71,6 +71,7 @@
71
71
 
72
72
  import { existsSync, readFileSync } from "node:fs";
73
73
  import { execFileSync } from "node:child_process";
74
+ import { isAbsolute } from "node:path";
74
75
  import { writeFileAtomic } from "../fs-utils.mjs";
75
76
  import { SESSION_HOOK_FINGERPRINT } from "../artifact-registry.mjs";
76
77
  import {
@@ -79,21 +80,43 @@ import {
79
80
  } from "../paths.mjs";
80
81
  import { diskError, validationError } from "../errors.mjs";
81
82
  import { removeSettingsSessionHook } from "../removers/settings.mjs";
83
+ import { isNpxInvocation } from "../cli-config.mjs";
84
+ import { platformConventions } from "../platform.mjs";
82
85
 
83
86
  /**
84
87
  * Build the hook command string for a given absolute path. Exported
85
88
  * so tests can assert the exact bytes the installer writes.
86
89
  *
90
+ * Shell shape is platform-specific — see `platform.mjs` for the full
91
+ * rationale. Summary:
92
+ *
93
+ * - **POSIX** (macOS, Linux): `<path> update --session-hook 2>&1 || true`.
94
+ * `|| true` catches any non-zero exit at the shell level; primary
95
+ * defense is the `--session-hook` flag contract in the Node process.
96
+ * - **Windows** (cmd.exe / PowerShell): `<path> update --session-hook 2>&1`.
97
+ * `|| true` omitted because cmd.exe doesn't know the `true` builtin.
98
+ * `--session-hook` contract is the only defense; consequences of
99
+ * binary-vanished scenarios are slightly noisier in Claude Code's
100
+ * session log but still non-blocking.
101
+ *
102
+ * The suffix is supplied by `platformConventions().hookShellSuffix` —
103
+ * this function doesn't know which OS it's targeting, it just
104
+ * concatenates the convention's suffix.
105
+ *
87
106
  * @param {string} binaryPath - Absolute path to the `skillrepo` binary.
107
+ * @param {object} [options]
108
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
109
+ * Default: `os.platform()`.
88
110
  * @returns {string} The full shell command string.
89
111
  */
90
- export function buildHookCommand(binaryPath) {
112
+ export function buildHookCommand(binaryPath, { platform: platformOverride } = {}) {
91
113
  if (typeof binaryPath !== "string" || binaryPath.length === 0) {
92
114
  throw validationError(
93
115
  "buildHookCommand: binaryPath must be a non-empty string.",
94
116
  );
95
117
  }
96
- return `${binaryPath} update --session-hook 2>&1 || true`;
118
+ const conv = platformConventions({ platform: platformOverride });
119
+ return `${binaryPath} update --session-hook 2>&1${conv.hookShellSuffix}`;
97
120
  }
98
121
 
99
122
  /**
@@ -104,27 +127,56 @@ export function buildHookCommand(binaryPath) {
104
127
  *
105
128
  * @returns {string | null}
106
129
  */
107
- export function resolveSkillrepoBinary() {
130
+ export function resolveSkillrepoBinary({ platform: platformOverride } = {}) {
131
+ // npx-invocation guard. Returns null early before any OS-specific
132
+ // logic runs — npx detection is platform-neutral (argv and env
133
+ // checks only) so it doesn't need the conventions object.
134
+ if (isNpxInvocation()) {
135
+ return null;
136
+ }
137
+
138
+ // Platform-specific binary locator name comes from the single
139
+ // source of truth in platform.mjs. Adding a new locator for a
140
+ // new platform is one edit in platform.mjs, not a scattered
141
+ // search for `platform() === "win32"` conditionals. See
142
+ // platform.mjs for the full rationale.
143
+ const conv = platformConventions({ platform: platformOverride });
144
+
108
145
  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"], {
146
+ // 3-second timeout — `which`/`where` typically return in
147
+ // milliseconds, but a PATH that includes a network filesystem
148
+ // or a shell alias that does I/O could hang indefinitely.
149
+ // Bounding the call ensures `skillrepo init` never stalls on
150
+ // binary resolution.
151
+ const raw = execFileSync(conv.binaryLocator, ["skillrepo"], {
115
152
  encoding: "utf-8",
116
153
  stdio: ["ignore", "pipe", "ignore"],
117
154
  timeout: 3000,
118
- }).trim();
155
+ });
156
+ // Windows `where` can return multiple matching paths (one per
157
+ // PATH entry containing the binary) on separate lines. Take
158
+ // only the first. `which` always returns a single path but the
159
+ // split is harmless there. This is the one line where the
160
+ // platform difference actually leaks through — all platforms
161
+ // receive potentially-multi-line output that we canonicalize
162
+ // the same way.
163
+ const result = raw.split(/\r?\n/)[0].trim();
119
164
  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;
165
+ // Sanity: the resolved path must be absolute. A relative
166
+ // result would be meaningless at session-start time because
167
+ // the Claude Code hook runner's cwd is undefined. `isAbsolute`
168
+ // handles both POSIX (`/foo/bar`) and Windows (`C:\foo\bar`)
169
+ // path styles — it's Node's built-in cross-platform check,
170
+ // not a platform-conditional we need to own.
171
+ if (!isAbsolute(result)) return null;
124
172
  return result;
125
173
  } catch {
126
- // `which` exits non-zero if the binary isn't found. Treat as
127
- // "no global install, skip session-sync" caller handles.
174
+ // Locator exits non-zero if the binary isn't on PATH, or throws
175
+ // ENOENT if the locator itself isn't available (e.g. a minimal
176
+ // container image without `which`, or a Windows system with
177
+ // `where.exe` missing which is effectively never — but still
178
+ // safe-handled). Either way: null → caller routes to the
179
+ // architect-specified "requires stable install" skip message.
128
180
  return null;
129
181
  }
130
182
  }
@@ -140,6 +192,16 @@ export function resolveSkillrepoBinary() {
140
192
  * @param {boolean} [options.global=false] - When true, installs to the
141
193
  * user-global `~/.claude/settings.local.json` instead of the
142
194
  * project-local file.
195
+ * @param {NodeJS.Platform} [options.platform] - Override for testing.
196
+ * Propagated to both `resolveSkillrepoBinary` and
197
+ * `buildHookCommand` so a test can exercise the full
198
+ * installer path under simulated Windows semantics on a
199
+ * non-Windows host. Production callers leave this unset so
200
+ * both helpers see the real `os.platform()`. This option is
201
+ * the mechanism that closes the architect's round-3 HIGH
202
+ * finding — without it, a Windows-shaped `binaryPath` passed
203
+ * in the test still got a POSIX-shaped command back because
204
+ * `buildHookCommand` read `os.platform()` directly.
143
205
  * @returns {{
144
206
  * path: string;
145
207
  * action: "installed" | "updated" | "unchanged" | "skipped";
@@ -158,23 +220,41 @@ export function resolveSkillrepoBinary() {
158
220
  export function mergeSessionHook({
159
221
  binaryPath: binaryPathOpt,
160
222
  global = false,
223
+ platform: platformOverride,
161
224
  } = {}) {
162
225
  const filePath = global ? claudeSettingsLocalGlobal() : claudeSettingsLocal();
163
226
  const displayPath = global
164
227
  ? "~/.claude/settings.local.json"
165
228
  : ".claude/settings.local.json";
166
229
 
167
- const binaryPath = binaryPathOpt ?? resolveSkillrepoBinary();
230
+ const binaryPath =
231
+ binaryPathOpt ?? resolveSkillrepoBinary({ platform: platformOverride });
168
232
  if (!binaryPath) {
233
+ // Two reasons binaryPath can be null:
234
+ // 1. `isNpxInvocation()` returned true — the user ran
235
+ // `npx skillrepo ...`. The npx cache path is transient and
236
+ // unsuitable for baking into a long-lived hook command.
237
+ // 2. `which skillrepo` returned nothing — no global install
238
+ // exists at all.
239
+ // Both are the same problem from the hook's perspective: we
240
+ // can't produce a command that will still work later. The
241
+ // architect's #884 design specified the same warning text for
242
+ // both cases.
169
243
  return {
170
244
  path: displayPath,
171
245
  action: "skipped",
172
246
  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`.",
247
+ "Session sync requires a stable `skillrepo` binary on PATH. " +
248
+ "Under `npx skillrepo ...` or without a global install, the " +
249
+ "hook would bind to a transient path that eventually breaks. " +
250
+ "Install globally with `npm install -g skillrepo` and re-run " +
251
+ "`skillrepo session-sync enable`.",
174
252
  };
175
253
  }
176
254
 
177
- const desiredCommand = buildHookCommand(binaryPath);
255
+ const desiredCommand = buildHookCommand(binaryPath, {
256
+ platform: platformOverride,
257
+ });
178
258
 
179
259
  // Parse existing file (or start fresh). A corrupt-but-present file
180
260
  // is a hard error: silently overwriting it would destroy any user-