skillrepo 3.1.1 → 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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Unit tests for src/lib/transient-runners.mjs (#894 / v3.1.2).
3
+ *
4
+ * Covers the three exports:
5
+ * - detectTransientRunner — returns the runner's display NAME
6
+ * (npx / pnpx / yarn dlx / bunx) or null. Used by init.mjs's
7
+ * Next-Steps prefix.
8
+ * - isTransientRunnerInvocation — boolean shortcut. Used as the
9
+ * gate for auto-install and the npx-guard in mergers/session-hook.
10
+ * - isTransientCachePath — boolean for filtering `where`/`which`
11
+ * output. Used by the binary locator.
12
+ *
13
+ * The boolean shortcut's behavior is also covered transitively by
14
+ * `cli-config.test.mjs` (which re-exports it). This file specifically
15
+ * locks in:
16
+ * - The runner-NAME return contract (v3.1.2 added this so init's
17
+ * Next-Steps shows `pnpx skillrepo list` for pnpm dlx users
18
+ * instead of always `npx skillrepo list`).
19
+ * - The `isTransientCachePath` filter behavior the binary locator
20
+ * depends on.
21
+ */
22
+
23
+ import { describe, it } from "node:test";
24
+ import assert from "node:assert/strict";
25
+
26
+ import {
27
+ detectTransientRunner,
28
+ isTransientRunnerInvocation,
29
+ isTransientCachePath,
30
+ globalInstallCommandFor,
31
+ } from "../../lib/transient-runners.mjs";
32
+
33
+ // ── detectTransientRunner — returns the runner display name ────────
34
+
35
+ describe("detectTransientRunner", () => {
36
+ it("returns null for a vanilla stable-install argv", () => {
37
+ const result = detectTransientRunner({
38
+ argv: ["/usr/local/bin/node", "/usr/local/bin/skillrepo"],
39
+ env: {},
40
+ });
41
+ assert.equal(result, null);
42
+ });
43
+
44
+ it("returns 'npx' for an npm npx invocation", () => {
45
+ const result = detectTransientRunner({
46
+ argv: [
47
+ "/usr/local/bin/node",
48
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
49
+ ],
50
+ env: {},
51
+ });
52
+ assert.equal(result, "npx");
53
+ });
54
+
55
+ it("returns 'pnpx' for a pnpm dlx invocation (cache substring)", () => {
56
+ const result = detectTransientRunner({
57
+ argv: [
58
+ "/usr/local/bin/node",
59
+ "/Users/alice/.local/share/pnpm/store/dlx-abc123/node_modules/.bin/skillrepo",
60
+ ],
61
+ env: {},
62
+ });
63
+ assert.equal(result, "pnpx");
64
+ });
65
+
66
+ it("returns 'yarn dlx' for a yarn berry dlx invocation", () => {
67
+ const result = detectTransientRunner({
68
+ argv: [
69
+ "/usr/local/bin/node",
70
+ "/Users/alice/project/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
71
+ ],
72
+ env: {},
73
+ });
74
+ assert.equal(result, "yarn dlx");
75
+ });
76
+
77
+ it("returns 'bunx' for a bun bunx invocation (cache substring)", () => {
78
+ const result = detectTransientRunner({
79
+ argv: [
80
+ "/usr/local/bin/node",
81
+ "/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
82
+ ],
83
+ env: {},
84
+ });
85
+ assert.equal(result, "bunx");
86
+ });
87
+
88
+ it("returns 'npx' from `_` env-var fallback when argv path doesn't match", () => {
89
+ // E.g., a wrapper script symlinks the npx binary somewhere
90
+ // outside the npx cache. The `_` signal still identifies the
91
+ // launcher.
92
+ const result = detectTransientRunner({
93
+ argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
94
+ env: { _: "/usr/local/bin/npx" },
95
+ });
96
+ assert.equal(result, "npx");
97
+ });
98
+
99
+ it("returns 'pnpx' from `_` env-var fallback", () => {
100
+ const result = detectTransientRunner({
101
+ argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
102
+ env: { _: "/usr/local/bin/pnpx" },
103
+ });
104
+ assert.equal(result, "pnpx");
105
+ });
106
+
107
+ it("returns 'bunx' from `_` env-var fallback", () => {
108
+ const result = detectTransientRunner({
109
+ argv: ["/usr/local/bin/node", "/somewhere/bin/skillrepo"],
110
+ env: { _: "/Users/alice/.bun/bin/bunx" },
111
+ });
112
+ assert.equal(result, "bunx");
113
+ });
114
+
115
+ it("argv signal beats `_` env-var (first-match wins on argv)", () => {
116
+ // If both signal a runner, argv wins. Realistically these would
117
+ // signal the SAME runner (npx invocation sets argv to the npx
118
+ // cache AND `_` to the npx binary), but the test pins the
119
+ // priority for the rare disagreement case.
120
+ const result = detectTransientRunner({
121
+ argv: [
122
+ "/usr/local/bin/node",
123
+ "/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
124
+ ],
125
+ env: { _: "/usr/local/bin/npx" },
126
+ });
127
+ // bunx wins because argv is checked first.
128
+ assert.equal(result, "bunx");
129
+ });
130
+
131
+ it("does not false-positive on a directory named 'dlx-utils' (substring not in pnpm cache)", () => {
132
+ // Defensive: `/dlx-` is the substring, but a user-named directory
133
+ // like `/Users/alice/utility-dlx/` doesn't trigger because the
134
+ // substring requires a leading `/` separator before `dlx-`.
135
+ const result = detectTransientRunner({
136
+ argv: ["/usr/local/bin/node", "/Users/alice/utility-dlx/bin/skillrepo"],
137
+ env: {},
138
+ });
139
+ assert.equal(result, null);
140
+ });
141
+
142
+ it("does not false-positive on an `npx`-named user dir without the _npx cache pattern", () => {
143
+ const result = detectTransientRunner({
144
+ argv: ["/usr/local/bin/node", "/opt/npxtools/bin/skillrepo"],
145
+ env: {},
146
+ });
147
+ assert.equal(result, null);
148
+ });
149
+ });
150
+
151
+ // ── isTransientRunnerInvocation — boolean shortcut ──────────────────
152
+
153
+ describe("isTransientRunnerInvocation (boolean shortcut)", () => {
154
+ // The boolean version reads from process.argv / process.env directly
155
+ // (no override hook for this convenience wrapper). The full table
156
+ // of detection cases is covered above via detectTransientRunner;
157
+ // these tests just verify the boolean shape.
158
+ let originalArgv;
159
+ let originalUnderscore;
160
+
161
+ function setup() {
162
+ originalArgv = process.argv;
163
+ originalUnderscore = process.env._;
164
+ delete process.env._;
165
+ }
166
+ function teardown() {
167
+ process.argv = originalArgv;
168
+ if (originalUnderscore === undefined) delete process.env._;
169
+ else process.env._ = originalUnderscore;
170
+ }
171
+
172
+ it("returns true when argv signals a transient runner", () => {
173
+ setup();
174
+ try {
175
+ process.argv = [
176
+ "/usr/local/bin/node",
177
+ "/Users/alice/.npm/_npx/abc/skillrepo",
178
+ ];
179
+ assert.equal(isTransientRunnerInvocation(), true);
180
+ } finally {
181
+ teardown();
182
+ }
183
+ });
184
+
185
+ it("returns false for a stable-install argv", () => {
186
+ setup();
187
+ try {
188
+ process.argv = ["/usr/local/bin/node", "/usr/local/bin/skillrepo"];
189
+ assert.equal(isTransientRunnerInvocation(), false);
190
+ } finally {
191
+ teardown();
192
+ }
193
+ });
194
+ });
195
+
196
+ // ── isTransientCachePath — used by binary locator ──────────────────
197
+
198
+ describe("isTransientCachePath", () => {
199
+ it("identifies an npx cache path", () => {
200
+ assert.equal(
201
+ isTransientCachePath("/Users/alice/.npm/_npx/abc/node_modules/.bin/skillrepo"),
202
+ true,
203
+ );
204
+ });
205
+
206
+ it("identifies a pnpm dlx cache path", () => {
207
+ assert.equal(
208
+ isTransientCachePath("/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo"),
209
+ true,
210
+ );
211
+ });
212
+
213
+ it("identifies a yarn berry cache path", () => {
214
+ assert.equal(
215
+ isTransientCachePath(
216
+ "/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/...",
217
+ ),
218
+ true,
219
+ );
220
+ });
221
+
222
+ it("identifies a bun cache path", () => {
223
+ assert.equal(
224
+ isTransientCachePath("/Users/alice/.bun/install/cache/skillrepo@3.1.2/bin/skillrepo"),
225
+ true,
226
+ );
227
+ });
228
+
229
+ it("identifies a Windows-style npx cache path", () => {
230
+ assert.equal(
231
+ isTransientCachePath(
232
+ "C:\\Users\\alice\\.npm\\_npx\\abc\\node_modules\\.bin\\skillrepo.cmd",
233
+ ),
234
+ true,
235
+ );
236
+ });
237
+
238
+ it("returns false for a stable global path", () => {
239
+ assert.equal(isTransientCachePath("/usr/local/bin/skillrepo"), false);
240
+ assert.equal(isTransientCachePath("/opt/homebrew/bin/skillrepo"), false);
241
+ assert.equal(
242
+ isTransientCachePath("C:\\Program Files\\nodejs\\skillrepo.cmd"),
243
+ false,
244
+ );
245
+ assert.equal(
246
+ isTransientCachePath("/Users/alice/.npm-global/bin/skillrepo"),
247
+ false,
248
+ );
249
+ });
250
+ });
251
+
252
+ // ── globalInstallCommandFor — per-runner install hint ──────────────
253
+
254
+ describe("globalInstallCommandFor", () => {
255
+ it("returns the right install command for each known runner", () => {
256
+ assert.equal(globalInstallCommandFor("npx"), "npm install -g skillrepo");
257
+ assert.equal(globalInstallCommandFor("pnpx"), "pnpm add -g skillrepo");
258
+ // yarn berry has no `yarn global add`; falls back to npm
259
+ // (documented in the TRANSIENT_RUNNERS comment).
260
+ assert.equal(globalInstallCommandFor("yarn dlx"), "npm install -g skillrepo");
261
+ assert.equal(globalInstallCommandFor("bunx"), "bun add -g skillrepo");
262
+ });
263
+
264
+ it("returns null for unknown / null runner names so callers can fall back", () => {
265
+ assert.equal(globalInstallCommandFor(null), null);
266
+ assert.equal(globalInstallCommandFor(undefined), null);
267
+ assert.equal(globalInstallCommandFor(""), null);
268
+ assert.equal(globalInstallCommandFor("not-a-real-runner"), null);
269
+ });
270
+ });
@@ -76,10 +76,11 @@ function teardown() {
76
76
 
77
77
  describe("buildHookCommand", () => {
78
78
  it("produces the exact command shape Claude Code expects (POSIX)", () => {
79
- // INTENT: the shape is load-bearing in three ways (per the
80
- // installer's docstring): absolute path, --session-hook flag,
81
- // `|| true` backstop. A refactor that drops any of the three
82
- // must fail this test loudly.
79
+ // INTENT: the shape is load-bearing in four ways (per the
80
+ // installer's docstring): absolute path, single-quoted to
81
+ // tolerate spaces/parens, --session-hook flag, `|| true`
82
+ // backstop. A refactor that drops any of the four must fail
83
+ // this test loudly.
83
84
  //
84
85
  // The `platform: "linux"` override is explicit because this test
85
86
  // asserts the POSIX command shape. Without it, the test runs
@@ -93,7 +94,7 @@ describe("buildHookCommand", () => {
93
94
  });
94
95
  assert.equal(
95
96
  cmd,
96
- "/usr/local/bin/skillrepo update --session-hook 2>&1 || true",
97
+ "'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true",
97
98
  );
98
99
  });
99
100
 
@@ -128,8 +129,8 @@ describe("buildHookCommand", () => {
128
129
  );
129
130
  assert.match(
130
131
  cmd,
131
- /skillrepo\.cmd update --session-hook 2>&1$/,
132
- "Windows hook command ends at '2>&1' — no shell backstop",
132
+ /skillrepo\.cmd" update --session-hook 2>&1$/,
133
+ "Windows hook command ends at '2>&1' — no shell backstop, with closing quote on the path",
133
134
  );
134
135
  // Fingerprint still present — remover round-trip must work on
135
136
  // Windows too.
@@ -147,6 +148,85 @@ describe("buildHookCommand", () => {
147
148
  assert.match(cmdDarwin, /\|\| true$/);
148
149
  });
149
150
 
151
+ it("v3.1.2: POSIX path with spaces is single-quoted", () => {
152
+ // Real-world case: macOS users with a space in their home dir
153
+ // (`/Users/First Last/.npm-global/bin/skillrepo`). The unquoted
154
+ // command would be parsed by the shell as multiple arguments
155
+ // and silently fail on session start.
156
+ const cmd = buildHookCommand(
157
+ "/Users/First Last/.npm-global/bin/skillrepo",
158
+ { platform: "linux" },
159
+ );
160
+ assert.equal(
161
+ cmd,
162
+ "'/Users/First Last/.npm-global/bin/skillrepo' update --session-hook 2>&1 || true",
163
+ );
164
+ // Fingerprint still present after quoting.
165
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
166
+ });
167
+
168
+ it("v3.1.2: POSIX path with single quote escapes correctly ('\\\\'')", () => {
169
+ // Hardcore edge case: a path with a literal single quote. POSIX
170
+ // shells require closing the quote, escaping the literal `'`,
171
+ // then reopening — the standard `'\\''` trick.
172
+ const cmd = buildHookCommand("/Users/J's bin/skillrepo", {
173
+ platform: "linux",
174
+ });
175
+ assert.equal(
176
+ cmd,
177
+ "'/Users/J'\\''s bin/skillrepo' update --session-hook 2>&1 || true",
178
+ );
179
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
180
+ });
181
+
182
+ it("v3.1.2: Windows path with spaces is double-quoted", () => {
183
+ // Real-world: `C:\Program Files\nodejs\skillrepo.cmd` and
184
+ // `C:\Program Files (x86)\...`. cmd.exe needs double quotes;
185
+ // backslashes inside double quotes are NOT escape characters
186
+ // (they pass through to the resolved path verbatim).
187
+ const cmd = buildHookCommand(
188
+ "C:\\Program Files\\nodejs\\skillrepo.cmd",
189
+ { platform: "win32" },
190
+ );
191
+ assert.equal(
192
+ cmd,
193
+ '"C:\\Program Files\\nodejs\\skillrepo.cmd" update --session-hook 2>&1',
194
+ );
195
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
196
+ });
197
+
198
+ it("v3.1.2: Windows path with parentheses (Program Files (x86)) is preserved verbatim inside double quotes", () => {
199
+ const cmd = buildHookCommand(
200
+ "C:\\Program Files (x86)\\node\\skillrepo.cmd",
201
+ { platform: "win32" },
202
+ );
203
+ assert.ok(cmd.startsWith('"C:\\Program Files (x86)\\node\\skillrepo.cmd"'));
204
+ assert.ok(cmd.includes(SESSION_HOOK_FINGERPRINT));
205
+ });
206
+
207
+ it("v3.1.2: backward-compat — fingerprint matches both unquoted (v3.1.0/3.1.1) and quoted (v3.1.2+) shapes", () => {
208
+ // The fingerprint is `" update --session-hook"` with a leading
209
+ // space. That space sits between the path's closing context
210
+ // (a `'`/`"` quote in v3.1.2, the bare path char in v3.1.0/3.1.1)
211
+ // and `update`. Both shapes contain the leading-space substring.
212
+ // Without this contract, an upgrade from v3.1.1 to v3.1.2 would
213
+ // duplicate the hook entry instead of updating it in place.
214
+ const v311Posix =
215
+ "/usr/local/bin/skillrepo update --session-hook 2>&1 || true";
216
+ const v312Posix =
217
+ "'/usr/local/bin/skillrepo' update --session-hook 2>&1 || true";
218
+ const v311Win =
219
+ "C:\\path\\skillrepo.cmd update --session-hook 2>&1";
220
+ const v312Win =
221
+ '"C:\\path\\skillrepo.cmd" update --session-hook 2>&1';
222
+ for (const cmd of [v311Posix, v312Posix, v311Win, v312Win]) {
223
+ assert.ok(
224
+ cmd.includes(SESSION_HOOK_FINGERPRINT),
225
+ `fingerprint must match shape: ${cmd}`,
226
+ );
227
+ }
228
+ });
229
+
150
230
  it("rejects empty or non-string binary paths", () => {
151
231
  // INTENT: no silent production of a malformed command. The
152
232
  // installer upstream should never pass null/empty, but defensive
@@ -400,8 +480,10 @@ describe("v3.1.1 Windows hook: fingerprint + round-trip contract", () => {
400
480
  `Windows hook must NOT contain "|| true" — cmd.exe has no such builtin. Got: "${cmd}"`,
401
481
  );
402
482
  // 3. Command ends exactly at "2>&1" — sanity on the full shape.
483
+ // The closing `"` is the path's wrapping quote (v3.1.2:
484
+ // paths are quoted to tolerate spaces).
403
485
  assert.ok(
404
- cmd.endsWith("skillrepo.cmd update --session-hook 2>&1"),
486
+ cmd.endsWith(`skillrepo.cmd" update --session-hook 2>&1`),
405
487
  `Windows hook command must end at "2>&1". Got: "${cmd}"`,
406
488
  );
407
489
 
@@ -643,7 +725,13 @@ describe("mergeSessionHook — idempotency", () => {
643
725
  1,
644
726
  "still exactly one SkillRepo hook (not duplicated)",
645
727
  );
646
- assert.ok(skillrepoHooks[0].command.startsWith("/new/path/skillrepo"));
728
+ // Path is shell-quoted (v3.1.2: see buildHookCommand docstring)
729
+ // so the absolute path appears INSIDE the command rather than
730
+ // at position 0.
731
+ assert.ok(
732
+ skillrepoHooks[0].command.includes("/new/path/skillrepo"),
733
+ `updated hook must use new path, got: ${skillrepoHooks[0].command}`,
734
+ );
647
735
  });
648
736
  });
649
737
 
@@ -818,15 +906,18 @@ describe("mergeSessionHook — failure modes", () => {
818
906
  afterEach(teardown);
819
907
 
820
908
  it("returns 'skipped' (not a throw) when the binary cannot be resolved", () => {
821
- // INTENT: an npx user without a global install must not have
822
- // init abort skip session sync with a warning, let the rest
823
- // of init complete. Passing `binaryPath: null` explicitly
824
- // simulates the `which skillrepo` resolution failing.
909
+ // INTENT: a caller passing `binaryPath: null` (e.g. session-sync
910
+ // enable under npx) must not have the action throw. Skip
911
+ // gracefully with an actionable reason. Init bypasses this path
912
+ // in v3.1.2 by passing the post-auto-install absolute path
913
+ // explicitly via `binaryPath`.
825
914
  ASSERT_HOME_ISOLATED();
826
915
  const result = mergeSessionHook({ binaryPath: null });
827
916
  assert.equal(result.action, "skipped");
828
917
  assert.ok(result.reason);
829
- assert.match(result.reason, /global install/i);
918
+ // The remediation hint must mention `npm install -g` so the user
919
+ // has a copy-pasteable next step.
920
+ assert.match(result.reason, /npm install -g/);
830
921
  });
831
922
 
832
923
  it("v3.1.1 fix: returns 'skipped' under npx invocation even when `which skillrepo` would succeed", async () => {
@@ -892,6 +983,185 @@ describe("mergeSessionHook — failure modes", () => {
892
983
  /Cannot parse/i,
893
984
  );
894
985
  });
986
+
987
+ it("v3.1.2 bypass: explicit binaryPath under npx → 'updated' when prior _npx-cache hook exists", async () => {
988
+ // QA gap fix: the bypass-via-binaryPath contract must work
989
+ // for ALL three success states (installed/updated/unchanged),
990
+ // not just "installed" (the empty-disk case). This test
991
+ // exercises "updated" — pre-seed a hook with an _npx cache
992
+ // path command, then call merge with an explicit non-npx
993
+ // binaryPath, assert action is "updated" and the new path
994
+ // replaced the cache path.
995
+ ASSERT_HOME_ISOLATED();
996
+
997
+ const originalArgv = process.argv;
998
+ process.argv = [
999
+ "/usr/local/bin/node",
1000
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
1001
+ ];
1002
+
1003
+ try {
1004
+ // Pre-seed a v3.1.0-style hook with an _npx cache path baked
1005
+ // in (the bug v3.1.1 was trying to prevent).
1006
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
1007
+ const STALE_NPX_PATH =
1008
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo";
1009
+ const stalePath = join(
1010
+ process.cwd(),
1011
+ ".claude",
1012
+ "settings.local.json",
1013
+ );
1014
+ writeFileSync(
1015
+ stalePath,
1016
+ JSON.stringify(
1017
+ {
1018
+ hooks: {
1019
+ SessionStart: [
1020
+ {
1021
+ hooks: [
1022
+ {
1023
+ type: "command",
1024
+ command: `${STALE_NPX_PATH} update --session-hook 2>&1 || true`,
1025
+ },
1026
+ ],
1027
+ },
1028
+ ],
1029
+ },
1030
+ },
1031
+ null,
1032
+ 2,
1033
+ ),
1034
+ );
1035
+
1036
+ const { mergeSessionHook: mergeFresh } = await import(
1037
+ "../../lib/mergers/session-hook.mjs?v312-updated-test=" + Date.now()
1038
+ );
1039
+ const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
1040
+ const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
1041
+
1042
+ assert.equal(
1043
+ result.action,
1044
+ "updated",
1045
+ "stale _npx hook with new binaryPath must be 'updated', not 'installed' or 'skipped'",
1046
+ );
1047
+ // The on-disk file must reflect the new path, not the cache.
1048
+ const written = JSON.parse(readFileSync(stalePath, "utf-8"));
1049
+ const cmd = written.hooks.SessionStart[0].hooks[0].command;
1050
+ assert.ok(
1051
+ cmd.includes(POST_INSTALL_PATH),
1052
+ `updated hook must use new path, got: ${cmd}`,
1053
+ );
1054
+ assert.ok(
1055
+ !cmd.includes("_npx"),
1056
+ "updated hook must NOT leak the prior _npx cache path",
1057
+ );
1058
+ } finally {
1059
+ process.argv = originalArgv;
1060
+ }
1061
+ });
1062
+
1063
+ it("v3.1.2 bypass: explicit binaryPath under npx → 'unchanged' when same hook already present", async () => {
1064
+ // The third success state — repeated init with the same
1065
+ // global. Idempotency: no file write, action is "unchanged".
1066
+ ASSERT_HOME_ISOLATED();
1067
+
1068
+ const originalArgv = process.argv;
1069
+ process.argv = [
1070
+ "/usr/local/bin/node",
1071
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
1072
+ ];
1073
+
1074
+ try {
1075
+ const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
1076
+ const { buildHookCommand: buildFresh, mergeSessionHook: mergeFresh } =
1077
+ await import(
1078
+ "../../lib/mergers/session-hook.mjs?v312-unchanged-test=" + Date.now()
1079
+ );
1080
+ const expectedCmd = buildFresh(POST_INSTALL_PATH);
1081
+
1082
+ // Pre-seed the EXACT command we'd write — idempotent re-run.
1083
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
1084
+ writeFileSync(
1085
+ join(process.cwd(), ".claude", "settings.local.json"),
1086
+ JSON.stringify(
1087
+ {
1088
+ hooks: {
1089
+ SessionStart: [
1090
+ {
1091
+ hooks: [{ type: "command", command: expectedCmd }],
1092
+ },
1093
+ ],
1094
+ },
1095
+ },
1096
+ null,
1097
+ 2,
1098
+ ),
1099
+ );
1100
+
1101
+ const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
1102
+ assert.equal(
1103
+ result.action,
1104
+ "unchanged",
1105
+ "identical hook with same binaryPath must be 'unchanged'",
1106
+ );
1107
+ assert.equal(result.command, expectedCmd);
1108
+ } finally {
1109
+ process.argv = originalArgv;
1110
+ }
1111
+ });
1112
+
1113
+ it("v3.1.2: explicit binaryPath bypasses the isNpxInvocation guard", async () => {
1114
+ // INTENT: init's v3.1.2 auto-install flow runs `npm install -g
1115
+ // skillrepo` itself under npx, then calls mergeSessionHook with
1116
+ // the resulting absolute path passed explicitly via `binaryPath`.
1117
+ // The internal `resolveSkillrepoBinary` early-returns under npx —
1118
+ // but when the caller already has the binary path, that guard
1119
+ // must NOT block the install. The `binaryPath` parameter is the
1120
+ // bypass mechanism.
1121
+ //
1122
+ // This test is the lock-in for the v3.1.2 contract: explicit
1123
+ // `binaryPath` short-circuits the npx detection. If a future
1124
+ // refactor moves the npx guard into mergeSessionHook itself
1125
+ // (rather than resolveSkillrepoBinary), this test fails — and
1126
+ // it should.
1127
+ ASSERT_HOME_ISOLATED();
1128
+
1129
+ const originalArgv = process.argv;
1130
+ process.argv = [
1131
+ "/usr/local/bin/node",
1132
+ "/Users/alice/.npm/_npx/abc123/node_modules/.bin/skillrepo",
1133
+ ];
1134
+
1135
+ try {
1136
+ const { mergeSessionHook: mergeFresh, buildHookCommand: buildFresh } =
1137
+ await import(
1138
+ "../../lib/mergers/session-hook.mjs?v312-bypass-test=" + Date.now()
1139
+ );
1140
+ // Pass an absolute, stable path explicitly — the kind init's
1141
+ // auto-install flow obtains from `resolveGlobalBinary()` after
1142
+ // a successful `npm install -g`.
1143
+ const POST_INSTALL_PATH = "/usr/local/bin/skillrepo";
1144
+ const result = mergeFresh({ binaryPath: POST_INSTALL_PATH });
1145
+ assert.equal(
1146
+ result.action,
1147
+ "installed",
1148
+ "explicit binaryPath under npx must succeed, not skip",
1149
+ );
1150
+ // The hook command must contain the explicit path (not the
1151
+ // _npx cache path).
1152
+ assert.equal(result.command, buildFresh(POST_INSTALL_PATH));
1153
+ assert.ok(
1154
+ result.command.includes(POST_INSTALL_PATH),
1155
+ "hook command must use the explicit path verbatim",
1156
+ );
1157
+ assert.ok(
1158
+ !result.command.includes("_npx"),
1159
+ "hook command must NOT leak the _npx cache path",
1160
+ );
1161
+ } finally {
1162
+ process.argv = originalArgv;
1163
+ }
1164
+ });
895
1165
  });
896
1166
 
897
1167
  describe("removeSessionHook — inverse of install", () => {