skillrepo 3.2.0 → 4.1.0

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 (53) hide show
  1. package/README.md +137 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
18
18
  import { runInit } from "../../commands/init.mjs";
19
19
  import { readConfig } from "../../lib/config.mjs";
20
20
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
21
+ import { AGENT_REGISTRY } from "../../lib/agent-registry.mjs";
21
22
  import { createMockServer } from "../e2e/mock-server.mjs";
22
23
  import { createCaptureStream } from "../helpers/capture-stream.mjs";
23
24
  import {
@@ -33,19 +34,48 @@ let serverUrl;
33
34
  let originalCwd;
34
35
  /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
35
36
  let originalHomeEnv;
37
+ /** @type {Record<string, string | undefined>} */
38
+ let originalDetectionEnv;
36
39
  let stdout;
37
40
  let stderr;
38
41
  const VALID_KEY = "sk_live_init_test";
39
42
 
43
+ // Every env var the agent registry uses as a detection signal. The
44
+ // init test runner inherits the developer's shell env, which on
45
+ // machines running an agent (e.g., CLAUDECODE=1 inside Claude Code)
46
+ // would otherwise pre-fire detection and silently change which
47
+ // targets the picker auto-selects. Clearing these in setup() makes
48
+ // every test deterministic.
49
+ const DETECTION_ENV_VARS = Array.from(
50
+ new Set(
51
+ AGENT_REGISTRY.flatMap((entry) =>
52
+ entry.detectionSignals
53
+ .filter((s) => s.type === "env")
54
+ .map((s) => s.value),
55
+ ),
56
+ ),
57
+ );
58
+
40
59
  async function setup() {
41
60
  sandbox = mkdtempSync(join(tmpdir(), "cli-cmd-init-"));
42
- // init defaults to detecting IDEs in cwd. Create a `.claude/`
43
- // marker so detection finds claudeCode and the command doesn't
44
- // refuse for "no IDEs detected".
61
+ // The Phase 3 picker (#1236) pre-checks the Claude Code row when
62
+ // detection finds a Claude signal. Most existing tests rely on
63
+ // that pre-check landing vendors include claudeCode → MCP /
64
+ // session-sync hooks fire. Create the `.claude/` marker so the
65
+ // existing tests' assumptions still hold; tests that need the
66
+ // empty-detection state strip it explicitly.
45
67
  mkdirSync(join(sandbox, "project", ".claude"), { recursive: true });
46
68
  mkdirSync(join(sandbox, "home"), { recursive: true });
47
69
  originalCwd = process.cwd();
48
70
  originalHomeEnv = captureHome();
71
+ // Snapshot every detection env var so a stray host-env var
72
+ // (e.g. CLAUDECODE=1 inside a Claude Code session) doesn't
73
+ // pre-fire a signal and skew the picker's auto-selection.
74
+ originalDetectionEnv = {};
75
+ for (const name of DETECTION_ENV_VARS) {
76
+ originalDetectionEnv[name] = process.env[name];
77
+ delete process.env[name];
78
+ }
49
79
  process.chdir(join(sandbox, "project"));
50
80
  setSandboxHome(join(sandbox, "home"));
51
81
  delete process.env.SKILLREPO_ACCESS_KEY;
@@ -63,6 +93,12 @@ async function teardown() {
63
93
  if (server) await server.stop();
64
94
  process.chdir(originalCwd);
65
95
  restoreHome(originalHomeEnv);
96
+ if (originalDetectionEnv) {
97
+ for (const name of DETECTION_ENV_VARS) {
98
+ if (originalDetectionEnv[name] === undefined) delete process.env[name];
99
+ else process.env[name] = originalDetectionEnv[name];
100
+ }
101
+ }
66
102
  if (sandbox) rmSync(sandbox, { recursive: true, force: true });
67
103
  server = null;
68
104
  }
@@ -139,9 +175,9 @@ describe("runInit — happy path", () => {
139
175
  assert.ok(Array.isArray(json.mcp.merged));
140
176
  });
141
177
 
142
- it("respects --ide flag to override detection", async () => {
178
+ it("respects --agent flag to override detection", async () => {
143
179
  await runInit(
144
- ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
180
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "claude"],
145
181
  { stdout, stderr },
146
182
  );
147
183
  const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
@@ -224,31 +260,176 @@ describe("runInit — error paths", () => {
224
260
  );
225
261
  });
226
262
 
227
- it("refuses with clear error when no IDE detected and no --ide flag", async () => {
228
- // Remove the .claude marker that setup() created
263
+ it("non-interactive scenario: explicit --agent claude works in empty project", async () => {
264
+ // Remove the .claude marker empty dir
229
265
  rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
230
- await assert.rejects(
231
- () => runInit(
232
- ["--key", VALID_KEY, "--url", serverUrl, "--yes"],
266
+ // With --agent claude, init should proceed even in an empty dir
267
+ await runInit(
268
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "claude"],
269
+ { stdout, stderr },
270
+ );
271
+ // And write the MCP config
272
+ assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
273
+ });
274
+ });
275
+
276
+ // ── Phase 3 picker (#1236) ────────────────────────────────────────────
277
+
278
+ describe("runInit — Phase 3 detection picker (#1236)", () => {
279
+ beforeEach(setup);
280
+ afterEach(teardown);
281
+
282
+ it("--yes + no detection signals → both default rows pre-checked → both targets configured", async () => {
283
+ // Spec rationale: "writing a few KB the user didn't strictly
284
+ // need is trivial; CI running init --yes on a fresh clone and
285
+ // writing nothing is broken automation." A fresh-clone --yes run
286
+ // must configure BOTH targets. Strip the .claude marker setup()
287
+ // creates so we land in the genuine no-detection state.
288
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
289
+ await runInit(
290
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
291
+ { stdout, stderr },
292
+ );
293
+ const json = JSON.parse(stdout.text());
294
+ assert.equal(json.action, "initialized");
295
+ assert.ok(
296
+ json.vendors.includes("claudeCode"),
297
+ "Claude Code must be configured on a fresh-clone --yes run",
298
+ );
299
+ // Cohort vendors (anything that lives at .agents/skills/) must
300
+ // also be configured. We don't assert the full list because the
301
+ // registry is the source of truth — instead we assert at least
302
+ // one cohort vendor besides claudeCode.
303
+ const nonClaude = json.vendors.filter((v) => v !== "claudeCode");
304
+ assert.ok(
305
+ nonClaude.length > 0,
306
+ "cohort vendors must also be configured on a fresh-clone --yes run",
307
+ );
308
+ });
309
+
310
+ it("--yes + only Claude signal → only Claude row pre-checked → only claudeCode configured", async () => {
311
+ // setup() already creates .claude/, so the Claude row pre-checks
312
+ // and the cohort row does not. The picker auto-selects only the
313
+ // pre-checked rows under --yes.
314
+ await runInit(
315
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
316
+ { stdout, stderr },
317
+ );
318
+ const json = JSON.parse(stdout.text());
319
+ assert.deepEqual(
320
+ json.vendors,
321
+ ["claudeCode"],
322
+ "only the Claude Code target must be configured when only its signal fires",
323
+ );
324
+ });
325
+
326
+ it("--yes + only cohort signal → only cohort row pre-checked → cohort configured (no claudeCode)", async () => {
327
+ // Strip the .claude marker setup() creates and add a cohort
328
+ // signal instead — .cursor/ should fire the cohort row.
329
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
330
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
331
+ await runInit(
332
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
333
+ { stdout, stderr },
334
+ );
335
+ const json = JSON.parse(stdout.text());
336
+ assert.ok(
337
+ !json.vendors.includes("claudeCode"),
338
+ "Claude Code must NOT be configured when only the cohort signal fires",
339
+ );
340
+ assert.ok(
341
+ json.vendors.includes("cursor"),
342
+ "cohort vendors must be configured when a cohort signal fires",
343
+ );
344
+ });
345
+
346
+ it("--yes + both signals → both rows pre-checked → both targets configured", async () => {
347
+ // setup() creates .claude/, add .cursor/ for cohort signal.
348
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
349
+ await runInit(
350
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
351
+ { stdout, stderr },
352
+ );
353
+ const json = JSON.parse(stdout.text());
354
+ assert.ok(json.vendors.includes("claudeCode"));
355
+ assert.ok(json.vendors.includes("cursor"));
356
+ });
357
+
358
+ it("does not throw 'No agent targets detected' (the old refuse-branch is gone)", async () => {
359
+ // The pre-Phase-3 init refused with this error when no signal
360
+ // fired. Phase 3 deletes that branch entirely — fresh clones
361
+ // configure both targets. This test locks the deletion: even
362
+ // with no detection markers, init must NOT throw a validation
363
+ // error about agent detection.
364
+ rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
365
+ await assert.doesNotReject(() =>
366
+ runInit(
367
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--json"],
233
368
  { stdout, stderr },
234
369
  ),
235
- (err) =>
236
- err instanceof CliError &&
237
- err.exitCode === EXIT_VALIDATION &&
238
- /No IDEs detected/.test(err.message),
239
370
  );
371
+ const json = JSON.parse(stdout.text());
372
+ assert.equal(json.action, "initialized");
240
373
  });
374
+ });
241
375
 
242
- it("headless CI scenario: explicit --ide claude works in empty project", async () => {
243
- // Remove the .claude marker — empty dir
376
+ // ── --agent none (skip placement) ─────────────────────────────────────
377
+
378
+ describe("runInit — --agent none", () => {
379
+ beforeEach(setup);
380
+ afterEach(teardown);
381
+
382
+ it("skips first sync but still writes config + JSON summary", async () => {
383
+ // `--agent none` is the explicit "no placement writes" sentinel.
384
+ // Init's credential write, gitignore management, and MCP merge
385
+ // (which is a no-op for empty vendors via mergeMcpForVendors)
386
+ // still run; only step 7 is skipped. The library sync is the
387
+ // expensive network call — skipping it is the user's intent.
388
+ //
389
+ // We DON'T preconfigure a mock library response. If init were
390
+ // (incorrectly) calling the library endpoint, the mock server's
391
+ // default empty response would still let the test pass — that's
392
+ // why we also assert the readable success line.
393
+ await runInit(
394
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none", "--json"],
395
+ { stdout, stderr },
396
+ );
397
+ const json = JSON.parse(stdout.text());
398
+ assert.equal(json.action, "initialized");
399
+ assert.deepEqual(json.vendors, [], "vendors must be empty under --agent none");
400
+ // The synthesized sync summary uses fullSync=null to signal
401
+ // "the network call never ran" — same shape as the failure
402
+ // recovery path. Locking it here keeps that contract explicit.
403
+ assert.equal(json.sync.added, 0);
404
+ assert.equal(json.sync.updated, 0);
405
+ assert.equal(json.sync.removed, 0);
406
+ assert.equal(json.sync.fullSync, null);
407
+ // The session-sync hook is Claude-specific, so under --agent
408
+ // none (no Claude target, no --global) it must be skipped.
409
+ assert.equal(json.sessionSync.action, "not-applicable");
410
+ });
411
+
412
+ it("does NOT throw 'No agent targets detected' when --agent none is passed in an empty dir", async () => {
413
+ // Without --agent none, an empty dir would refuse with the
414
+ // "No agent targets detected" error. The `none` sentinel skips
415
+ // detection entirely — the user already told us what they want.
244
416
  rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
245
- // With --ide claude, init should proceed even in an empty dir
246
417
  await runInit(
247
- ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--ide", "claude"],
418
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none", "--json"],
248
419
  { stdout, stderr },
249
420
  );
250
- // And write the MCP config
251
- assert.ok(existsSync(join(process.cwd(), ".mcp.json")));
421
+ const json = JSON.parse(stdout.text());
422
+ assert.equal(json.action, "initialized");
423
+ });
424
+
425
+ it("config is persisted on disk after --agent none init", async () => {
426
+ await runInit(
427
+ ["--key", VALID_KEY, "--url", serverUrl, "--yes", "--agent", "none"],
428
+ { stdout, stderr },
429
+ );
430
+ const cfg = readConfig();
431
+ assert.equal(cfg.apiKey, VALID_KEY);
432
+ assert.equal(cfg.serverUrl, serverUrl);
252
433
  });
253
434
  });
254
435
 
@@ -640,9 +821,9 @@ describe("runInit — session sync (#884)", () => {
640
821
  assert.equal(json.sessionSync.path, null);
641
822
  });
642
823
 
643
- it("skips session sync entirely when only non-Claude-Code IDEs are targeted (cross-PR review fix)", async () => {
824
+ it("skips session sync entirely when only non-Claude-Code agents are targeted (cross-PR review fix)", async () => {
644
825
  // Cross-PR review flagged: before this guard, a user running
645
- // `skillrepo init --ide cursor` would get a Claude Code-specific
826
+ // `skillrepo init --agent cursor` would get a Claude Code-specific
646
827
  // SessionStart hook written to `.claude/settings.local.json`.
647
828
  // Cursor never reads that file, so the hook was silent useless
648
829
  // state that `skillrepo uninstall` later had to clean up.
@@ -651,7 +832,7 @@ describe("runInit — session sync (#884)", () => {
651
832
  // `claudeCode` is not in the resolved vendors list AND
652
833
  // `--global` is not passed. This test proves the skip fires.
653
834
  //
654
- // Use --ide cursor to force vendors = ["cursor"]. Bypass the
835
+ // Use --agent cursor to force vendors = ["cursor"]. Bypass the
655
836
  // .claude/ auto-detection by creating .cursor/ instead.
656
837
  mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
657
838
  rmSync(join(process.cwd(), ".claude"), { recursive: true, force: true });
@@ -663,7 +844,7 @@ describe("runInit — session sync (#884)", () => {
663
844
  "--url",
664
845
  serverUrl,
665
846
  "--yes",
666
- "--ide",
847
+ "--agent",
667
848
  "cursor",
668
849
  "--json",
669
850
  ],
@@ -686,17 +867,15 @@ describe("runInit — session sync (#884)", () => {
686
867
  );
687
868
  });
688
869
 
689
- it("still installs session sync under --global even without claudeCode in vendors", async () => {
690
- // INTENT: `--global` writes to `~/.claude/settings.local.json`,
691
- // which IS Claude Code's user-wide settings path. A user who
692
- // runs `skillrepo init --global` (even without `--ide claude`)
693
- // is implicitly targeting Claude Code. The guard must allow
694
- // this path so `--global` users still get auto-sync.
695
- //
696
- // Note: the setup() helper already creates `.claude/` in the
697
- // project, which would normally push vendors to include
698
- // claudeCode. Force vendors = ["cursor"] via --ide to exercise
699
- // the "--global overrides vendors" branch.
870
+ it("does NOT install Claude SessionStart hook for --global --agent cursor", async () => {
871
+ // INTENT: the SessionStart hook is Claude Code-specific. Pre-#1249,
872
+ // `claudeTargeted` was `Boolean(flags.global) || vendors.includes("claudeCode")`
873
+ // because bare `--global` historically routed to Claude. After
874
+ // #1249's effectiveVendors fix preserves --agent under --global,
875
+ // `--global --agent cursor` legitimately targets Cursor only —
876
+ // installing a Claude hook the user never asked for is a bug.
877
+ // Manual E2E sweep on staging caught this regression; this test
878
+ // pins the corrected behavior.
700
879
  await runInit(
701
880
  [
702
881
  "--key",
@@ -705,7 +884,7 @@ describe("runInit — session sync (#884)", () => {
705
884
  serverUrl,
706
885
  "--yes",
707
886
  "--global",
708
- "--ide",
887
+ "--agent",
709
888
  "cursor",
710
889
  "--json",
711
890
  ],
@@ -715,10 +894,298 @@ describe("runInit — session sync (#884)", () => {
715
894
  const json = JSON.parse(stdout.text());
716
895
  assert.equal(
717
896
  json.sessionSync.action,
897
+ "not-applicable",
898
+ "Claude SessionStart hook must NOT install for --global --agent cursor",
899
+ );
900
+ assert.equal(json.sessionSync.path, null);
901
+ // Critical: the user-wide settings.local.json file must NOT have
902
+ // been written. A Cursor-only --global user should never see a
903
+ // Claude-specific file materialize.
904
+ assert.ok(
905
+ !existsSync(join(sandbox, "home", ".claude", "settings.local.json")),
906
+ "~/.claude/settings.local.json must NOT be written for Cursor-only --global init",
907
+ );
908
+ });
909
+ });
910
+
911
+ // ── runInit — cohort SessionStart hooks (#1240) ────────────────────
912
+ //
913
+ // Sibling step to the Claude session-hook block above. These tests
914
+ // exercise the cohort installer wired in init.mjs step 6 alongside
915
+ // `installSessionSyncHook`. The cohort installer writes user-scope
916
+ // hook configs for Cursor / Gemini CLI / Codex CLI / VS Code +
917
+ // Copilot — every selected vendor with a non-null `agentHook` spec.
918
+ //
919
+ // HOME isolation is the load-bearing safety property: the cohort
920
+ // hooks live at `~/.<vendor>/...` so a sandbox HOME leak would
921
+ // pollute the developer's real home. The setupWithShim helper
922
+ // already overrides HOME / USERPROFILE; that's the gate.
923
+
924
+ describe("runInit — cohort SessionStart hooks (#1240)", () => {
925
+ beforeEach(setupWithShim);
926
+ afterEach(teardownWithShim);
927
+
928
+ it("writes cohort hooks for every selected non-Claude vendor with --yes --agent", async () => {
929
+ await runInit(
930
+ [
931
+ "--key", VALID_KEY,
932
+ "--url", serverUrl,
933
+ "--yes",
934
+ "--agent", "cursor,gemini,codex,copilot",
935
+ "--json",
936
+ ],
937
+ { stdout, stderr },
938
+ );
939
+
940
+ const json = JSON.parse(stdout.text());
941
+ assert.ok(
942
+ Array.isArray(json.sessionSync.cohortHooks),
943
+ "sessionSync.cohortHooks must be an array",
944
+ );
945
+ assert.equal(json.sessionSync.cohortHooks.length, 4);
946
+ for (const r of json.sessionSync.cohortHooks) {
947
+ assert.equal(
948
+ r.action,
949
+ "installed",
950
+ `expected install for ${r.vendorKey}, got ${r.action}`,
951
+ );
952
+ }
953
+
954
+ // Each vendor's file landed on disk under the sandbox HOME
955
+ const home = join(sandbox, "home");
956
+ assert.ok(existsSync(join(home, ".cursor", "hooks.json")));
957
+ assert.ok(existsSync(join(home, ".gemini", "settings.json")));
958
+ assert.ok(existsSync(join(home, ".codex", "hooks.json")));
959
+ assert.ok(
960
+ existsSync(
961
+ join(home, ".copilot", "hooks", "skillrepo-update.json"),
962
+ ),
963
+ );
964
+ });
965
+
966
+ it("each cohort hook command is `npx --yes skillrepo update --silent`", async () => {
967
+ // INTENT: the universal command is the single source of truth.
968
+ // A future refactor that drifts one vendor's command out of sync
969
+ // breaks the round-trip uninstall via the shared fingerprint.
970
+ await runInit(
971
+ [
972
+ "--key", VALID_KEY,
973
+ "--url", serverUrl,
974
+ "--yes",
975
+ "--agent", "cursor,gemini",
976
+ ],
977
+ { stdout, stderr },
978
+ );
979
+
980
+ const home = join(sandbox, "home");
981
+
982
+ const cursor = JSON.parse(
983
+ readFileSync(join(home, ".cursor", "hooks.json"), "utf-8"),
984
+ );
985
+ assert.equal(
986
+ cursor.hooks.sessionStart[0].command,
987
+ "npx --yes skillrepo update --silent",
988
+ );
989
+ assert.equal(cursor.version, 1);
990
+
991
+ const gemini = JSON.parse(
992
+ readFileSync(join(home, ".gemini", "settings.json"), "utf-8"),
993
+ );
994
+ const geminiHook = gemini.hooks.SessionStart[0].hooks[0];
995
+ assert.equal(geminiHook.command, "npx --yes skillrepo update --silent");
996
+ assert.equal(geminiHook.type, "command");
997
+ assert.equal(geminiHook.timeout, 60000);
998
+ });
999
+
1000
+ it("--no-session-sync skips ALL cohort hooks (semantics widened in #1240)", async () => {
1001
+ await runInit(
1002
+ [
1003
+ "--key", VALID_KEY,
1004
+ "--url", serverUrl,
1005
+ "--yes",
1006
+ "--agent", "cursor,gemini",
1007
+ "--no-session-sync",
1008
+ "--json",
1009
+ ],
1010
+ { stdout, stderr },
1011
+ );
1012
+
1013
+ const json = JSON.parse(stdout.text());
1014
+ // No cohort hooks installed
1015
+ assert.equal(json.sessionSync.cohortHooks.length, 0);
1016
+
1017
+ // Files do not exist
1018
+ const home = join(sandbox, "home");
1019
+ assert.ok(!existsSync(join(home, ".cursor", "hooks.json")));
1020
+ assert.ok(!existsSync(join(home, ".gemini", "settings.json")));
1021
+ });
1022
+
1023
+ it("re-running init is idempotent — exactly one cohort entry per vendor file", async () => {
1024
+ const args = [
1025
+ "--key", VALID_KEY,
1026
+ "--url", serverUrl,
1027
+ "--yes",
1028
+ "--agent", "cursor,gemini",
1029
+ ];
1030
+ await runInit(args, { stdout, stderr });
1031
+ stdout.clear();
1032
+ await runInit([...args, "--json"], { stdout, stderr });
1033
+
1034
+ // Second run reports unchanged for every cohort vendor
1035
+ const json = JSON.parse(stdout.text());
1036
+ for (const r of json.sessionSync.cohortHooks) {
1037
+ assert.equal(r.action, "unchanged", `${r.vendorKey} should be unchanged`);
1038
+ }
1039
+
1040
+ // Cursor file has exactly one SkillRepo entry, not two
1041
+ const home = join(sandbox, "home");
1042
+ const cursor = JSON.parse(
1043
+ readFileSync(join(home, ".cursor", "hooks.json"), "utf-8"),
1044
+ );
1045
+ const skillrepoEntries = cursor.hooks.sessionStart.filter((h) =>
1046
+ h.command?.includes("skillrepo update --silent"),
1047
+ );
1048
+ assert.equal(skillrepoEntries.length, 1);
1049
+ });
1050
+
1051
+ it("does NOT install cohort hooks for vendors not in the selected list", async () => {
1052
+ // --agent cursor only → gemini/codex/copilot files must NOT exist
1053
+ await runInit(
1054
+ [
1055
+ "--key", VALID_KEY,
1056
+ "--url", serverUrl,
1057
+ "--yes",
1058
+ "--agent", "cursor",
1059
+ "--json",
1060
+ ],
1061
+ { stdout, stderr },
1062
+ );
1063
+
1064
+ const home = join(sandbox, "home");
1065
+ assert.ok(existsSync(join(home, ".cursor", "hooks.json")));
1066
+ assert.ok(!existsSync(join(home, ".gemini", "settings.json")));
1067
+ assert.ok(!existsSync(join(home, ".codex", "hooks.json")));
1068
+ assert.ok(
1069
+ !existsSync(join(home, ".copilot", "hooks", "skillrepo-update.json")),
1070
+ );
1071
+
1072
+ const json = JSON.parse(stdout.text());
1073
+ assert.equal(json.sessionSync.cohortHooks.length, 1);
1074
+ assert.equal(json.sessionSync.cohortHooks[0].vendorKey, "cursor");
1075
+ });
1076
+
1077
+ it("does NOT install cohort hooks for windsurf or cline (deferred per agent registry)", async () => {
1078
+ // Windsurf and Cline are deliberately excluded from the cohort
1079
+ // installer per the registry's `agentHook: null`. A user
1080
+ // selecting them should get file-based skill placement but NO
1081
+ // hook-config writes.
1082
+ await runInit(
1083
+ [
1084
+ "--key", VALID_KEY,
1085
+ "--url", serverUrl,
1086
+ "--yes",
1087
+ "--agent", "windsurf,cline",
1088
+ "--json",
1089
+ ],
1090
+ { stdout, stderr },
1091
+ );
1092
+
1093
+ const home = join(sandbox, "home");
1094
+ assert.ok(!existsSync(join(home, ".cline", "hooks.json")));
1095
+ // Windsurf has no hook config file by spec
1096
+ const json = JSON.parse(stdout.text());
1097
+ assert.equal(json.sessionSync.cohortHooks.length, 0);
1098
+ });
1099
+
1100
+ it("Copilot install surfaces a Preview-status warning to the user (#1244)", async () => {
1101
+ // INTENT: GitHub currently labels Copilot's hook system as
1102
+ // Preview. The init step-6 cohort flow adds a one-time `p.warning`
1103
+ // when Copilot was among the installed vendors so a user knows
1104
+ // the hook schema may shift before GA. This test locks the warning
1105
+ // to the installed-Copilot path.
1106
+ await runInit(
1107
+ [
1108
+ "--key", VALID_KEY,
1109
+ "--url", serverUrl,
1110
+ "--yes",
1111
+ "--agent", "copilot",
1112
+ ],
1113
+ { stdout, stderr },
1114
+ );
1115
+ assert.match(
1116
+ stdout.text(),
1117
+ /Copilot.*Preview/,
1118
+ "Copilot's Preview-status caveat must surface on a successful copilot install",
1119
+ );
1120
+ });
1121
+
1122
+ it("Preview warning does NOT fire when Copilot is not selected", async () => {
1123
+ // INTENT: the warning is Copilot-specific. Installing for cohort
1124
+ // vendors WITHOUT Copilot must not surface the Preview note.
1125
+ await runInit(
1126
+ [
1127
+ "--key", VALID_KEY,
1128
+ "--url", serverUrl,
1129
+ "--yes",
1130
+ "--agent", "cursor,gemini",
1131
+ ],
1132
+ { stdout, stderr },
1133
+ );
1134
+ assert.doesNotMatch(
1135
+ stdout.text(),
1136
+ /Preview/,
1137
+ "Preview warning is Copilot-specific and must NOT fire for cursor/gemini installs",
1138
+ );
1139
+ });
1140
+
1141
+ it("init JSON output's cohortHooks array reports per-vendor failures with reason (#1239 coverage gap 44)", async () => {
1142
+ // INTENT: when a per-vendor cohort install fails (e.g. corrupt
1143
+ // existing hook config), the failure must surface in
1144
+ // `sessionSync.cohortHooks[].action === "failed"` with a `reason`
1145
+ // field. Force a failure by pre-seeding Cursor's hook file with
1146
+ // invalid JSON so the merger throws diskError.
1147
+ const home = join(sandbox, "home");
1148
+ mkdirSync(join(home, ".cursor"), { recursive: true });
1149
+ writeFileSync(
1150
+ join(home, ".cursor", "hooks.json"),
1151
+ "{not valid json",
1152
+ );
1153
+
1154
+ await runInit(
1155
+ [
1156
+ "--key", VALID_KEY,
1157
+ "--url", serverUrl,
1158
+ "--yes",
1159
+ "--agent", "cursor,gemini",
1160
+ "--json",
1161
+ ],
1162
+ { stdout, stderr },
1163
+ );
1164
+ const json = JSON.parse(stdout.text());
1165
+
1166
+ const cursorResult = json.sessionSync.cohortHooks.find(
1167
+ (h) => h.vendorKey === "cursor",
1168
+ );
1169
+ assert.equal(
1170
+ cursorResult.action,
1171
+ "failed",
1172
+ "cursor install must report failed when its config is corrupt",
1173
+ );
1174
+ assert.match(
1175
+ cursorResult.reason,
1176
+ /Cannot parse/,
1177
+ "failure reason must be actionable (parse error mentions the file)",
1178
+ );
1179
+
1180
+ // Critical: sibling vendor still installed despite cursor's failure
1181
+ const geminiResult = json.sessionSync.cohortHooks.find(
1182
+ (h) => h.vendorKey === "gemini",
1183
+ );
1184
+ assert.equal(
1185
+ geminiResult.action,
718
1186
  "installed",
719
- "--global must install the hook even when vendors doesn't include claudeCode",
1187
+ "Gemini install must succeed even when Cursor's failed (per-vendor isolation)",
720
1188
  );
721
- assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
722
1189
  });
723
1190
  });
724
1191
 
@@ -1433,9 +1900,9 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1433
1900
  assert.equal(parsed.sessionSync.action, "skipped");
1434
1901
  });
1435
1902
 
1436
- it("Branch 2: --ide cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
1903
+ it("Branch 2: --agent cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
1437
1904
  // QA gap fix: previously had no test for the non-Claude branch.
1438
- // Even under npx, if the user targets a non-Claude IDE, the
1905
+ // Even under npx, if the user targets a non-Claude agent, the
1439
1906
  // SessionStart hook (Claude-specific) is skipped without an
1440
1907
  // install offer.
1441
1908
  makeNpxArgv();
@@ -1451,7 +1918,7 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1451
1918
  "--url",
1452
1919
  serverUrl,
1453
1920
  "--yes",
1454
- "--ide",
1921
+ "--agent",
1455
1922
  "cursor",
1456
1923
  "--json",
1457
1924
  ],
@@ -178,7 +178,10 @@ describe("runRemove — happy path", () => {
178
178
  });
179
179
 
180
180
  it("--global removes from home dir", async () => {
181
- writeSkillDir(makeSkill("alice", "pdf-helper"), { global: true });
181
+ writeSkillDir(makeSkill("alice", "pdf-helper"), {
182
+ global: true,
183
+ vendors: ["claudeCode"],
184
+ });
182
185
  const dir = resolvePlacementDir("claudeGlobal", "pdf-helper");
183
186
  assert.ok(existsSync(dir));
184
187