skillrepo 3.2.0 → 4.0.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 (39) hide show
  1. package/README.md +90 -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-session-sync.mjs +1 -1
  7. package/src/commands/init.mjs +435 -111
  8. package/src/commands/list.mjs +1 -1
  9. package/src/commands/remove.mjs +10 -2
  10. package/src/commands/uninstall.mjs +1 -1
  11. package/src/commands/update.mjs +15 -3
  12. package/src/lib/agent-registry.mjs +215 -0
  13. package/src/lib/cli-config.mjs +146 -44
  14. package/src/lib/detect-agents.mjs +112 -0
  15. package/src/lib/file-write.mjs +162 -77
  16. package/src/lib/mcp-merge.mjs +17 -36
  17. package/src/lib/mergers/gitignore.mjs +55 -28
  18. package/src/lib/paths.mjs +27 -25
  19. package/src/lib/prompt-multiselect.mjs +324 -0
  20. package/src/lib/sync.mjs +18 -19
  21. package/src/test/commands/add.test.mjs +18 -3
  22. package/src/test/commands/init-picker.test.mjs +144 -0
  23. package/src/test/commands/init.test.mjs +228 -42
  24. package/src/test/commands/remove.test.mjs +4 -1
  25. package/src/test/commands/update.test.mjs +13 -3
  26. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  27. package/src/test/e2e/cli-commands.test.mjs +39 -13
  28. package/src/test/integration/file-write.integration.test.mjs +31 -10
  29. package/src/test/lib/agent-registry.test.mjs +215 -0
  30. package/src/test/lib/cli-config.test.mjs +222 -38
  31. package/src/test/lib/detect-agents.test.mjs +336 -0
  32. package/src/test/lib/file-write-placement.test.mjs +264 -0
  33. package/src/test/lib/file-write.test.mjs +231 -30
  34. package/src/test/lib/mcp-merge.test.mjs +23 -15
  35. package/src/test/lib/paths.test.mjs +53 -17
  36. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  37. package/src/test/lib/sync.test.mjs +157 -0
  38. package/src/lib/detect-ides.mjs +0 -44
  39. 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,17 @@ describe("runInit — session sync (#884)", () => {
715
894
  const json = JSON.parse(stdout.text());
716
895
  assert.equal(
717
896
  json.sessionSync.action,
718
- "installed",
719
- "--global must install the hook even when vendors doesn't include claudeCode",
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",
720
907
  );
721
- assert.equal(json.sessionSync.path, "~/.claude/settings.local.json");
722
908
  });
723
909
  });
724
910
 
@@ -1433,9 +1619,9 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1433
1619
  assert.equal(parsed.sessionSync.action, "skipped");
1434
1620
  });
1435
1621
 
1436
- it("Branch 2: --ide cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
1622
+ it("Branch 2: --agent cursor (non-Claude target) → spawn never called, action = not-applicable", async () => {
1437
1623
  // 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
1624
+ // Even under npx, if the user targets a non-Claude agent, the
1439
1625
  // SessionStart hook (Claude-specific) is skipped without an
1440
1626
  // install offer.
1441
1627
  makeNpxArgv();
@@ -1451,7 +1637,7 @@ describe("runInit — v3.1.2 step 6 auto-install (npx)", () => {
1451
1637
  "--url",
1452
1638
  serverUrl,
1453
1639
  "--yes",
1454
- "--ide",
1640
+ "--agent",
1455
1641
  "cursor",
1456
1642
  "--json",
1457
1643
  ],
@@ -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
 
@@ -157,16 +157,26 @@ describe("runUpdate — flag handling", () => {
157
157
  assert.ok(dir.startsWith(process.env.HOME));
158
158
  });
159
159
 
160
- it("--ide cursor writes to the project /skills/ fallback", async () => {
160
+ it("--agent cursor writes to .agents/skills/", async () => {
161
161
  server.setLibraryResponse({
162
162
  skills: [makeSkill("cursor-test")],
163
163
  removals: [],
164
164
  syncedAt: "x",
165
165
  });
166
- await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--ide", "cursor"]);
167
- const dir = resolvePlacementDir("projectFallback", "cursor-test");
166
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor"]);
167
+ const dir = resolvePlacementDir("agentsProject", "cursor-test");
168
168
  assert.ok(existsSync(dir));
169
169
  });
170
+
171
+ it("rejects --agent none with a validation error (no targets to sync)", async () => {
172
+ await assert.rejects(
173
+ () => runUpdate(["--key", VALID_KEY, "--url", serverUrl, "--agent", "none"]),
174
+ (err) =>
175
+ err instanceof CliError &&
176
+ err.exitCode === EXIT_VALIDATION &&
177
+ /--agent none has no effect on `skillrepo update`/.test(err.message),
178
+ );
179
+ });
170
180
  });
171
181
 
172
182
  // ── --session-hook mode (#884) ────────────────────────────────────────