skillrepo 3.1.1 → 3.1.3

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.
@@ -25,6 +25,7 @@ import {
25
25
  setSandboxHome,
26
26
  restoreHome,
27
27
  } from "../helpers/sandbox-home.mjs";
28
+ import { isolatePathEnv } from "../helpers/path-isolation.mjs";
28
29
 
29
30
  let sandbox;
30
31
  let server;
@@ -890,8 +891,16 @@ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
890
891
  ];
891
892
 
892
893
  server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
894
+ // v3.1.2: pass --no-session-sync to scope this test to the
895
+ // Next-Steps prefix/tip behavior (its actual intent). Without
896
+ // the flag, step 6 would attempt the new auto-install-global
897
+ // flow, which actually shells out to `npm install -g skillrepo`
898
+ // (a test pollutant — slow, network-dependent, and could
899
+ // mutate the developer's global node_modules). Adding
900
+ // --no-session-sync skips step 6 entirely while leaving the
901
+ // npx-detection prefix and Tip behaviors fully exercised.
893
902
  await runInit(
894
- ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
903
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
895
904
  { stdout, stderr },
896
905
  );
897
906
 
@@ -907,4 +916,674 @@ describe("runInit — v3.1.1 Next steps prefix (bug 1)", () => {
907
916
  "npx invocation must show the Next-Steps global-install tip",
908
917
  );
909
918
  });
919
+
920
+ // ── v3.1.2: per-runner Next-Steps prefix ────────────────────────
921
+ //
922
+ // v3.1.1 hardcoded `npx skillrepo` for ALL transient runners. After
923
+ // extending detection to pnpm dlx / yarn berry dlx / bunx, the
924
+ // prefix must match the actual runner so the user can copy-paste
925
+ // the suggested command.
926
+
927
+ it("v3.1.2: shows `pnpx skillrepo` prefix when launched via pnpm dlx", async () => {
928
+ process.argv = [
929
+ "/usr/local/bin/node",
930
+ "/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo",
931
+ ];
932
+
933
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
934
+ await runInit(
935
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
936
+ { stdout, stderr },
937
+ );
938
+
939
+ const out = stdout.text();
940
+ // Match the bullet line exactly so we don't accidentally match
941
+ // `pnpx skillrepo list` while looking for `npx skillrepo list`
942
+ // (the substring overlap is real — pnpx ends with "npx").
943
+ assert.match(out, /^ +• pnpx skillrepo list/m);
944
+ assert.doesNotMatch(
945
+ out,
946
+ /^ +• npx skillrepo list/m,
947
+ "must NOT show bare `npx` prefix when invoked via pnpx",
948
+ );
949
+ });
950
+
951
+ it("v3.1.2: shows `pnpm add -g` install command in Tip when launched via pnpm dlx", async () => {
952
+ // Round-3 fix for the architect's NOTE 1: the Tip line was
953
+ // hardcoded `npm install -g skillrepo` even for pnpx/yarn dlx/
954
+ // bunx users. This test locks the per-runner Tip behavior.
955
+ process.argv = [
956
+ "/usr/local/bin/node",
957
+ "/Users/alice/.local/share/pnpm/store/dlx-abc/node_modules/.bin/skillrepo",
958
+ ];
959
+
960
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
961
+ await runInit(
962
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
963
+ { stdout, stderr },
964
+ );
965
+
966
+ const out = stdout.text();
967
+ assert.match(
968
+ out,
969
+ /Tip: `pnpm add -g skillrepo`/,
970
+ "pnpx user must see the pnpm install command, not npm",
971
+ );
972
+ assert.doesNotMatch(
973
+ out,
974
+ /Tip: `npm install -g skillrepo`/,
975
+ "must NOT show the bare npm command for a pnpx user",
976
+ );
977
+ });
978
+
979
+ it("v3.1.2: shows `bun add -g` install command in Tip when launched via bunx", async () => {
980
+ process.argv = [
981
+ "/usr/local/bin/node",
982
+ "/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
983
+ ];
984
+
985
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
986
+ await runInit(
987
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
988
+ { stdout, stderr },
989
+ );
990
+
991
+ assert.match(stdout.text(), /Tip: `bun add -g skillrepo`/);
992
+ });
993
+
994
+ it("v3.1.2: yarn berry dlx Tip falls back to `npm install -g` (yarn berry has no global add)", async () => {
995
+ // Yarn berry deliberately ships no `yarn global add` — see the
996
+ // comment on TRANSIENT_RUNNERS in transient-runners.mjs. Users
997
+ // who want a persistent global on yarn-berry get the universal
998
+ // npm fallback.
999
+ process.argv = [
1000
+ "/usr/local/bin/node",
1001
+ "/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
1002
+ ];
1003
+
1004
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1005
+ await runInit(
1006
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
1007
+ { stdout, stderr },
1008
+ );
1009
+
1010
+ assert.match(stdout.text(), /Tip: `npm install -g skillrepo`/);
1011
+ });
1012
+
1013
+ it("v3.1.2: shows `yarn dlx skillrepo` prefix when launched via yarn berry dlx", async () => {
1014
+ process.argv = [
1015
+ "/usr/local/bin/node",
1016
+ "/Users/alice/proj/.yarn/berry/cache/skillrepo-npm-3.1.2-abc/node_modules/skillrepo/bin/skillrepo.mjs",
1017
+ ];
1018
+
1019
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1020
+ await runInit(
1021
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
1022
+ { stdout, stderr },
1023
+ );
1024
+
1025
+ const out = stdout.text();
1026
+ assert.match(out, /yarn dlx skillrepo list/);
1027
+ });
1028
+
1029
+ it("v3.1.2: shows `bunx skillrepo` prefix when launched via bunx", async () => {
1030
+ process.argv = [
1031
+ "/usr/local/bin/node",
1032
+ "/Users/alice/.bun/install/cache/skillrepo@3.1.2/node_modules/.bin/skillrepo",
1033
+ ];
1034
+
1035
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1036
+ await runInit(
1037
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--no-session-sync"],
1038
+ { stdout, stderr },
1039
+ );
1040
+
1041
+ const out = stdout.text();
1042
+ assert.match(out, /bunx skillrepo list/);
1043
+ });
1044
+ });
1045
+
1046
+ // ── v3.1.2 (#894): init step 6 auto-install-global behavior ─────────
1047
+ //
1048
+ // Six branches in init.mjs step 6 under v3.1.2:
1049
+ //
1050
+ // 1. --no-session-sync → opted-out, no install attempted
1051
+ // 2. non-Claude target → not-applicable, no install attempted
1052
+ // 3. non-npx, no global on PATH → mergeSessionHook returns "skipped"
1053
+ // (existing v3.1.1 behavior, preserved)
1054
+ // 4. npx + EXISTING global → use existing path, no auto-install
1055
+ // 5. npx + NO global, decline → "declined", manual instructions printed
1056
+ // 6. npx + NO global, accept → auto-install runs (mocked spawn).
1057
+ // On success, hook installed via the
1058
+ // explicit binaryPath bypass.
1059
+ //
1060
+ // All tests mock spawn via the new `deps.spawn` injection point so
1061
+ // no test ever shells out to `npm install -g`.
1062
+
1063
+ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1064
+ // Process-state isolation:
1065
+ // - We control isTransientRunnerInvocation()'s output via
1066
+ // process.argv[1] and process.env._.
1067
+ // - We CLEAR PATH so resolveGlobalBinary() genuinely returns
1068
+ // null (otherwise the developer's locally-installed `skillrepo`
1069
+ // makes Branch 4 fire when the test expects Branch 6). This
1070
+ // matters even more in v3.1.3+ which scans PATH directly in
1071
+ // Node — any pre-existing skillrepo on the dev's PATH would
1072
+ // be visible to the locator.
1073
+ // Tests that DO want a "global on PATH" scenario explicitly install
1074
+ // a shim via `installShim` in their body (see Branch 4 + idempotency
1075
+ // tests below); the shim helper prepends its own bin dir to PATH
1076
+ // so it survives the cleared baseline.
1077
+ let originalArgv;
1078
+ let originalUnderscore;
1079
+ let originalNpmCommand;
1080
+ let restorePath;
1081
+ let shimSandbox;
1082
+ let shimHandle;
1083
+
1084
+ beforeEach(async () => {
1085
+ await setup();
1086
+ originalArgv = process.argv;
1087
+ originalUnderscore = process.env._;
1088
+ originalNpmCommand = process.env.npm_command;
1089
+ restorePath = isolatePathEnv();
1090
+ shimSandbox = null;
1091
+ shimHandle = null;
1092
+ });
1093
+
1094
+ afterEach(async () => {
1095
+ process.argv = originalArgv;
1096
+ if (originalUnderscore === undefined) delete process.env._;
1097
+ else process.env._ = originalUnderscore;
1098
+ if (originalNpmCommand === undefined) delete process.env.npm_command;
1099
+ else process.env.npm_command = originalNpmCommand;
1100
+ if (shimHandle) {
1101
+ const { uninstallShim } = await import("../helpers/skillrepo-shim.mjs");
1102
+ uninstallShim(shimHandle);
1103
+ }
1104
+ if (shimSandbox) rmSync(shimSandbox, { recursive: true, force: true });
1105
+ // Restore PATH last (after uninstallShim, which restores PATH
1106
+ // to its pre-shim state — usually our cleared baseline). The
1107
+ // outer restore puts the dev's real PATH back for the next
1108
+ // test suite.
1109
+ restorePath();
1110
+ await teardown();
1111
+ });
1112
+
1113
+ function makeNpxArgv() {
1114
+ process.argv = [
1115
+ "/usr/local/bin/node",
1116
+ "/Users/alice/.npm/_npx/dc129a78aca3fc9c/node_modules/.bin/skillrepo",
1117
+ ];
1118
+ delete process.env._;
1119
+ delete process.env.npm_command;
1120
+ }
1121
+
1122
+ // NOTE: the "Branch 5: npx + --json without --yes → declined"
1123
+ // path is defensive code that is unreachable through `runInit`
1124
+ // today. Step 5 (MCP auto-merge) prompts unconditionally
1125
+ // per-vendor when `--yes` is not passed, so a non-interactive
1126
+ // `--json` invocation hangs at step 5 before reaching step 6.
1127
+ // The branch exists so a future refactor that makes step 5
1128
+ // non-interactive under `--json` (e.g. default-yes for MCP merge
1129
+ // under --json) gets the right step 6 semantics for free. Until
1130
+ // that refactor lands, we cannot integration-test the branch
1131
+ // without injecting a `confirmFn` into both step 5 and step 6,
1132
+ // which would be more test scaffolding than the defensive code
1133
+ // is worth. Coverage for the underlying "no install + manual
1134
+ // instructions" semantic is provided by the failure-path tests
1135
+ // below (Branch 6 with mock-failed spawn) which assert the
1136
+ // identical printed message.
1137
+
1138
+ // NOTE: a "Branch 6 success" integration test (npx + --yes +
1139
+ // spawn-success + binary appears on PATH post-install) cannot be
1140
+ // cleanly written through `runInit`. The structural problem: if
1141
+ // we install the shim on PATH BEFORE calling runInit, init's
1142
+ // `resolveGlobalBinary()` finds it during the npx-mode check and
1143
+ // takes Branch 4 (pre-existing global) instead. Installing the
1144
+ // shim AFTER spawn fires would require a callback hook in
1145
+ // mock-spawn that runs in the child's close handler — extra
1146
+ // scaffolding for a path that's already covered by the
1147
+ // composition of:
1148
+ // - global-install.test.mjs "happy path" (spawn → success →
1149
+ // resolveGlobalBinary finds shim → success result)
1150
+ // - session-hook.test.mjs "v3.1.2 bypass" (binaryPath param
1151
+ // bypasses npx guard → hook installed)
1152
+ // - init.test.mjs Branch 4 below (when a global exists, init
1153
+ // uses it directly via the binaryPath parameter)
1154
+ // These three together cover the same end-to-end semantic.
1155
+
1156
+ it("Branch 6 failure: npx + --yes + spawn-fail (npm exit 1) → init exits 0, manual instructions printed", async () => {
1157
+ makeNpxArgv();
1158
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1159
+
1160
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1161
+ const spawn = makeMockSpawn({
1162
+ exitCode: 1,
1163
+ stderrText: "npm ERR! some failure",
1164
+ });
1165
+
1166
+ // No exception thrown — init must continue past auto-install
1167
+ // failure and complete the rest of its steps.
1168
+ await runInit(
1169
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1170
+ { stdout, stderr },
1171
+ { spawn },
1172
+ );
1173
+
1174
+ // Hook NOT installed.
1175
+ assert.equal(
1176
+ existsSync(join(process.cwd(), ".claude", "settings.local.json")),
1177
+ false,
1178
+ );
1179
+
1180
+ // Output mentions failure + manual instructions + the npm error
1181
+ // snippet for diagnosis.
1182
+ const out = stdout.text();
1183
+ assert.match(out, /Could not install skillrepo globally/);
1184
+ assert.match(out, /npm install -g skillrepo/);
1185
+ // Sync (step 7) still ran — no early abort
1186
+ assert.match(out, /Library is up to date|No skills in library yet/);
1187
+ });
1188
+
1189
+ it("Branch 6 EACCES (--json mode): spawn EACCES → eacces-categorized error in JSON sessionSync block", async () => {
1190
+ // EACCES detection requires stderr capture, which only happens
1191
+ // in --json mode (`outputMode: "silent"`). In non-json/inherit
1192
+ // mode, npm's stderr goes to the user's terminal directly so
1193
+ // we don't capture it — meaning categorization falls back to
1194
+ // generic "npm-nonzero." That asymmetry is intentional: the
1195
+ // user always sees the real npm output one way or another.
1196
+ //
1197
+ // This test exercises the categorization path under --json
1198
+ // where the helper's structured error is the only signal the
1199
+ // (machine) consumer has.
1200
+ makeNpxArgv();
1201
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1202
+
1203
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1204
+ const spawn = makeMockSpawn({
1205
+ exitCode: 243,
1206
+ stderrText: "npm ERR! Error: EACCES: permission denied",
1207
+ });
1208
+
1209
+ await runInit(
1210
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
1211
+ { stdout, stderr },
1212
+ { spawn },
1213
+ );
1214
+
1215
+ // In --json mode the human warnings go to stderr, but the
1216
+ // machine-readable JSON on stdout is the contract.
1217
+ const json = JSON.parse(stdout.text());
1218
+ assert.equal(json.sessionSync.action, "skipped");
1219
+ // Init still succeeded overall — the sync block is present and
1220
+ // the JSON parses cleanly.
1221
+ assert.ok(json.sync, "sync block must be present after EACCES failure");
1222
+ });
1223
+
1224
+ it("Branch 6 generic failure (non-json mode, non-EACCES exit code): falls back to generic error message", async () => {
1225
+ // In non-json/inherit mode, stderr is NOT captured (it streams
1226
+ // to the user's terminal directly). The helper categorizes the
1227
+ // failure based ONLY on the exit code in inherit mode:
1228
+ // - 243 → EACCES (npm's documented permission-error code)
1229
+ // - any other non-zero → generic "npm-nonzero"
1230
+ // This test exercises the generic path with a non-243 exit
1231
+ // (e.g., a network or registry-side failure). Since the user
1232
+ // already saw the real npm output via inherit, the helper's
1233
+ // human message just confirms the failure happened.
1234
+ makeNpxArgv();
1235
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1236
+
1237
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1238
+ const spawn = makeMockSpawn({
1239
+ exitCode: 1,
1240
+ stderrText: "irrelevant — won't be captured in inherit mode",
1241
+ });
1242
+
1243
+ await runInit(
1244
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1245
+ { stdout, stderr },
1246
+ { spawn },
1247
+ );
1248
+
1249
+ const out = stdout.text();
1250
+ // Generic categorization message — exit code is mentioned but
1251
+ // no specific category like "EACCES" or "ENOENT".
1252
+ assert.match(out, /Could not install skillrepo globally/);
1253
+ assert.match(out, /exited with code 1/);
1254
+ // Manual-install instructions printed.
1255
+ assert.match(out, /npm install -g skillrepo/);
1256
+ // Init still completed.
1257
+ assert.match(out, /Library is up to date|No skills in library yet/);
1258
+ });
1259
+
1260
+ it("Branch 6 EACCES via exit code 243 (non-json mode): categorized as eacces even without stderr capture", async () => {
1261
+ // The v3.1.2 review surfaced an issue: EACCES detection in
1262
+ // inherit mode was impossible because stderr wasn't captured.
1263
+ // Fix: detect via exit code 243 (npm's documented EACCES exit
1264
+ // code on POSIX). This test locks in that exit-code-based
1265
+ // categorization works in inherit mode.
1266
+ makeNpxArgv();
1267
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1268
+
1269
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1270
+ const spawn = makeMockSpawn({
1271
+ exitCode: 243,
1272
+ // No stderrText — inherit mode wouldn't capture it anyway,
1273
+ // and we're proving the exit-code path works without stderr.
1274
+ });
1275
+
1276
+ await runInit(
1277
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1278
+ { stdout, stderr },
1279
+ { spawn },
1280
+ );
1281
+
1282
+ const out = stdout.text();
1283
+ // EACCES-categorized message — the actionable hint about sudo
1284
+ // and the npm permissions docs URL must appear.
1285
+ assert.match(out, /permissions error \(EACCES\)/);
1286
+ assert.match(out, /sudo/);
1287
+ assert.match(out, /npmjs.com\/resolving-eacces/);
1288
+ // Init still completed.
1289
+ assert.match(out, /Library is up to date|No skills in library yet/);
1290
+ });
1291
+
1292
+ it("Branch 4: npx + EXISTING global on PATH → no auto-install, hook uses existing", async () => {
1293
+ // Pre-stage a shim that resolveGlobalBinary will find — this
1294
+ // simulates the user already having `npm install -g skillrepo`
1295
+ // done before running `npx skillrepo init`. Init must detect
1296
+ // the pre-existing global, skip the install offer, and use the
1297
+ // existing path for the hook.
1298
+ makeNpxArgv();
1299
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1300
+
1301
+ const { installShim } = await import("../helpers/skillrepo-shim.mjs");
1302
+ shimSandbox = mkdtempSync(join(tmpdir(), "sr-init-preexisting-"));
1303
+ shimHandle = installShim(shimSandbox);
1304
+
1305
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1306
+ const spawn = makeMockSpawn({ exitCode: 0 });
1307
+
1308
+ await runInit(
1309
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1310
+ { stdout, stderr },
1311
+ { spawn },
1312
+ );
1313
+
1314
+ // Spawn was NEVER called — pre-existing global was used directly.
1315
+ assert.equal(
1316
+ spawn.calls.length,
1317
+ 0,
1318
+ "spawn must NOT be called when a global is already on PATH",
1319
+ );
1320
+
1321
+ // Hook installed.
1322
+ const settingsPath = join(
1323
+ process.cwd(),
1324
+ ".claude",
1325
+ "settings.local.json",
1326
+ );
1327
+ assert.ok(existsSync(settingsPath));
1328
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
1329
+ const hookCmd = settings.hooks.SessionStart[0].hooks[0].command;
1330
+ // The path is now shell-quoted (v3.1.2: see buildHookCommand
1331
+ // docstring for rationale), so it does not appear at position 0
1332
+ // verbatim — it appears after the opening quote character.
1333
+ //
1334
+ // Cross-platform path comparison via the UNIQUE SANDBOX-DIR
1335
+ // BASENAME (e.g. `sr-init-preexisting-XYZ`): on Windows,
1336
+ // `os.tmpdir()` returns the 8.3 short-name form
1337
+ // (`C:\Users\RUNNER~1\AppData\Local\Temp\...`) while `where.exe`
1338
+ // returns the long-name form (`C:\Users\runneradmin\...`) for
1339
+ // the SAME directory. The two strings differ, and `realpathSync`
1340
+ // does NOT expand 8.3 short-names (those are filesystem aliases,
1341
+ // not symlinks). The basename of the sandbox dir, however, is
1342
+ // identical in both forms — it's the random suffix `mkdtempSync`
1343
+ // appended, which doesn't have a short-name alias. Asserting the
1344
+ // basename appears in the hookCmd is robust across platforms
1345
+ // and path-form representations.
1346
+ const { basename } = await import("node:path");
1347
+ const sandboxName = basename(shimSandbox);
1348
+ assert.ok(
1349
+ hookCmd.includes(sandboxName) && hookCmd.includes("skillrepo"),
1350
+ `expected hook command to contain sandbox ${sandboxName} and binary "skillrepo", got: ${hookCmd}`,
1351
+ );
1352
+
1353
+ // Output communicates the action (not silent).
1354
+ const out = stdout.text();
1355
+ assert.match(out, /Found global skillrepo at/);
1356
+ });
1357
+
1358
+ it("Branch 1: npx + --no-session-sync → spawn never called, action = opted-out", async () => {
1359
+ makeNpxArgv();
1360
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1361
+
1362
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1363
+ const spawn = makeMockSpawn({ exitCode: 0 });
1364
+
1365
+ await runInit(
1366
+ [
1367
+ "--key",
1368
+ VALID_KEY,
1369
+ "--url",
1370
+ serverUrl,
1371
+ "--yes",
1372
+ "--no-session-sync",
1373
+ "--json",
1374
+ ],
1375
+ { stdout, stderr },
1376
+ { spawn },
1377
+ );
1378
+
1379
+ assert.equal(spawn.calls.length, 0);
1380
+ const json = JSON.parse(stdout.text());
1381
+ assert.equal(json.sessionSync.action, "opted-out");
1382
+ assert.equal(
1383
+ existsSync(join(process.cwd(), ".claude", "settings.local.json")),
1384
+ false,
1385
+ );
1386
+ });
1387
+
1388
+ it("--json mode: stdout contains ONLY JSON (no npm output mixed in) when auto-install runs", async () => {
1389
+ // Critical regression guard: in --json mode, npm output must
1390
+ // NOT pollute stdout. The `outputMode: "silent"` switch in
1391
+ // installSkillrepoGlobally is what enforces this; the test
1392
+ // proves the wiring is correct.
1393
+ makeNpxArgv();
1394
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1395
+
1396
+ // Use a spawn mock that "writes to stdout" — except since we're
1397
+ // in silent mode, child.stdout is piped (not inherited) and
1398
+ // production code drains it. The mock doesn't actually emit
1399
+ // stdout data, but the test's value is that the entire stdout
1400
+ // stream is parseable JSON regardless.
1401
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1402
+ const spawn = makeMockSpawn({
1403
+ exitCode: 1,
1404
+ stderrText: "noisy npm error output",
1405
+ });
1406
+
1407
+ await runInit(
1408
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
1409
+ { stdout, stderr },
1410
+ { spawn },
1411
+ );
1412
+
1413
+ // The entire stdout text must be valid JSON.
1414
+ let parsed;
1415
+ assert.doesNotThrow(() => {
1416
+ parsed = JSON.parse(stdout.text());
1417
+ }, "stdout must be valid JSON");
1418
+ assert.equal(parsed.sessionSync.action, "skipped");
1419
+ });
1420
+
1421
+ it("Branch 2: --ide cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
1422
+ // QA gap fix: previously had no test for the non-Claude branch.
1423
+ // Even under npx, if the user targets a non-Claude IDE, the
1424
+ // SessionStart hook (Claude-specific) is skipped without an
1425
+ // install offer.
1426
+ makeNpxArgv();
1427
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1428
+
1429
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1430
+ const spawn = makeMockSpawn({ exitCode: 0 });
1431
+
1432
+ await runInit(
1433
+ [
1434
+ "--key",
1435
+ VALID_KEY,
1436
+ "--url",
1437
+ serverUrl,
1438
+ "--yes",
1439
+ "--ide",
1440
+ "cursor",
1441
+ "--json",
1442
+ ],
1443
+ { stdout, stderr },
1444
+ { spawn },
1445
+ );
1446
+
1447
+ assert.equal(spawn.calls.length, 0);
1448
+ const json = JSON.parse(stdout.text());
1449
+ assert.equal(json.sessionSync.action, "not-applicable");
1450
+ assert.equal(json.sessionSync.path, null);
1451
+ });
1452
+
1453
+ it("--json contract: sessionSync.action = 'failed' is reachable when settings.local.json is corrupt", async () => {
1454
+ // QA gap fix (BLOCKING): The "failed" action enum value was
1455
+ // documented in the JSON output block but never exercised by
1456
+ // a --json integration test. Pre-seed a corrupt
1457
+ // settings.local.json before init runs the existing-global
1458
+ // branch. mergeSessionHook throws diskError → init catches →
1459
+ // sessionSyncAction = "failed" → JSON serialization.
1460
+ makeNpxArgv();
1461
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1462
+
1463
+ // Pre-seed a corrupt settings file so mergeSessionHook throws.
1464
+ const claudeDir = join(process.cwd(), ".claude");
1465
+ mkdirSync(claudeDir, { recursive: true });
1466
+ writeFileSync(
1467
+ join(claudeDir, "settings.local.json"),
1468
+ "{ this is not valid JSON",
1469
+ );
1470
+
1471
+ // Pre-existing global on PATH so init takes Branch 4 (which
1472
+ // calls mergeSessionHook directly with binaryPath).
1473
+ const { installShim } = await import("../helpers/skillrepo-shim.mjs");
1474
+ shimSandbox = mkdtempSync(join(tmpdir(), "sr-init-failed-"));
1475
+ shimHandle = installShim(shimSandbox);
1476
+
1477
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1478
+ const spawn = makeMockSpawn({ exitCode: 0 });
1479
+
1480
+ await runInit(
1481
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
1482
+ { stdout, stderr },
1483
+ { spawn },
1484
+ );
1485
+
1486
+ // JSON output is still valid (no unhandled exception aborted
1487
+ // serialization) and reports the "failed" action enum value.
1488
+ const json = JSON.parse(stdout.text());
1489
+ assert.equal(
1490
+ json.sessionSync.action,
1491
+ "failed",
1492
+ "corrupt settings.local.json must surface as action='failed'",
1493
+ );
1494
+ // Sync block still present — failure was non-fatal.
1495
+ assert.ok(json.sync, "sync block must be present after disk error");
1496
+ });
1497
+
1498
+ it("getCliVersion crash recovery: init exits 0 when getCliVersion throws (defensive)", async () => {
1499
+ // QA gap fix (BLOCKING): If the CLI's package.json is corrupt,
1500
+ // getCliVersion throws — but init must not crash. We exercise
1501
+ // the recovery path by stubbing the cli-version module to throw.
1502
+ //
1503
+ // We can't easily mock the import statically, so we use a
1504
+ // dynamic import + property override pattern: import the module,
1505
+ // monkey-patch getCliVersion, run init, restore.
1506
+ makeNpxArgv();
1507
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1508
+
1509
+ // Inject a `getCliVersion` stub via the deps parameter that
1510
+ // throws as if the CLI's package.json were broken. ESM exports
1511
+ // can't be monkey-patched, so deps injection is the only way
1512
+ // to exercise the defensive try/catch.
1513
+ const throwingGetCliVersion = () => {
1514
+ throw new Error("synthetic: package.json missing version");
1515
+ };
1516
+
1517
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1518
+ const spawn = makeMockSpawn({ exitCode: 0 });
1519
+
1520
+ // Should NOT throw — init's defensive try/catch must catch
1521
+ // the version-read error and continue.
1522
+ await runInit(
1523
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1524
+ { stdout, stderr },
1525
+ { spawn, getCliVersion: throwingGetCliVersion },
1526
+ );
1527
+
1528
+ const out = stdout.text();
1529
+ assert.match(
1530
+ out,
1531
+ /Could not determine CLI version for global install/,
1532
+ "init must surface the version-read failure with actionable instructions",
1533
+ );
1534
+ assert.match(
1535
+ out,
1536
+ /npm install -g skillrepo/,
1537
+ "fallback message must include manual install hint",
1538
+ );
1539
+ // Spawn must NOT have been called (we never had a version to
1540
+ // pin to).
1541
+ assert.equal(spawn.calls.length, 0);
1542
+ // Init still completed.
1543
+ assert.match(out, /Library is up to date|No skills in library yet/);
1544
+ });
1545
+
1546
+ it("idempotency: re-running init after a successful auto-install does NOT spawn again", async () => {
1547
+ // Simulate the second run: a global skillrepo is already on
1548
+ // PATH from a prior init. resolveGlobalBinary finds it and
1549
+ // init skips the auto-install branch entirely (Branch 4).
1550
+ makeNpxArgv();
1551
+ server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
1552
+
1553
+ const { installShim } = await import("../helpers/skillrepo-shim.mjs");
1554
+ shimSandbox = mkdtempSync(join(tmpdir(), "sr-init-idempotent-"));
1555
+ shimHandle = installShim(shimSandbox);
1556
+
1557
+ const { makeMockSpawn } = await import("../helpers/mock-spawn.mjs");
1558
+ const spawn = makeMockSpawn({ exitCode: 0 });
1559
+
1560
+ // First run with shim already in place — install branch skipped.
1561
+ await runInit(
1562
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1563
+ { stdout, stderr },
1564
+ { spawn },
1565
+ );
1566
+ assert.equal(spawn.calls.length, 0, "first run with pre-existing global");
1567
+
1568
+ // Second run — same. Spawn must STILL be 0.
1569
+ const stdout2 = createCaptureStream();
1570
+ const stderr2 = createCaptureStream();
1571
+ await runInit(
1572
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
1573
+ { stdout: stdout2, stderr: stderr2 },
1574
+ { spawn },
1575
+ );
1576
+ assert.equal(spawn.calls.length, 0, "second run also uses existing global");
1577
+
1578
+ // The hook should be unchanged.
1579
+ const settingsPath = join(
1580
+ process.cwd(),
1581
+ ".claude",
1582
+ "settings.local.json",
1583
+ );
1584
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
1585
+ // Only one SessionStart group, only one hook in it — no duplicates.
1586
+ assert.equal(settings.hooks.SessionStart.length, 1);
1587
+ assert.equal(settings.hooks.SessionStart[0].hooks.length, 1);
1588
+ });
910
1589
  });