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