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
@@ -432,7 +432,9 @@ describe("CLI E2E — read commands", () => {
432
432
  // ── PR3b: init ──────────────────────────────────────────────────
433
433
 
434
434
  it("init writes config + MCP + runs first sync (happy path)", async () => {
435
- // Create a .claude/ marker so detectIdes finds claudeCode
435
+ // Create a .claude/ marker so detection finds claudeCode (the
436
+ // Phase 3 picker pre-checks the Claude Code row when its signal
437
+ // fires).
436
438
  mkdirSync(join(tempDir, ".claude"), { recursive: true });
437
439
 
438
440
  const r = await runCli([
@@ -468,28 +470,52 @@ describe("CLI E2E — read commands", () => {
468
470
  assert.equal(json.account.slug, "mock");
469
471
  });
470
472
 
471
- it("init --ide claude works in empty dir (headless CI scenario)", async () => {
473
+ it("init --agent claude works in empty dir (non-interactive scenario)", async () => {
472
474
  const r = await runCli([
473
475
  "init",
474
476
  "--key", VALID_KEY,
475
477
  "--url", `http://127.0.0.1:${port}`,
476
478
  "--yes",
477
- "--ide", "claude",
479
+ "--agent", "claude",
478
480
  ]);
479
481
  assert.equal(r.status, 0, `stderr: ${r.stderr}`);
480
482
  assert.ok(existsSync(join(tempDir, ".mcp.json")));
481
483
  });
482
484
 
483
- it("init with no IDEs detected and no --ide exits 5", async () => {
484
- // No .claude marker, no --ide flag
485
- const r = await runCli([
486
- "init",
487
- "--key", VALID_KEY,
488
- "--url", `http://127.0.0.1:${port}`,
489
- "--yes",
490
- ]);
491
- assert.equal(r.status, 5);
492
- assert.match(r.stderr, /No IDEs detected/);
485
+ it("init in an empty dir under --yes configures both default targets (#1236)", async () => {
486
+ // Phase 3 (#1236) replaced the "No agent targets detected refuse"
487
+ // branch with the two-row picker. Under --yes with no detection
488
+ // signals, both rows are pre-checked (the spec rationale: writing
489
+ // a few KB the user didn't strictly need is trivial; CI running
490
+ // `init --yes` on a fresh clone and writing nothing is broken
491
+ // automation). The CLI must succeed and configure both targets.
492
+ //
493
+ // The env-var clears below force the genuine "no signal" state.
494
+ // The test host's shell may have CLAUDECODE=1 (Claude Code
495
+ // session), CURSOR_AGENT=1, etc. — without clearing those, the
496
+ // subprocess detection would pre-fire, defeating the test.
497
+ const r = await runCli(
498
+ [
499
+ "init",
500
+ "--key", VALID_KEY,
501
+ "--url", `http://127.0.0.1:${port}`,
502
+ "--yes",
503
+ "--json",
504
+ ],
505
+ {
506
+ CLAUDECODE: "",
507
+ CURSOR_AGENT: "",
508
+ CURSOR_CLI: "",
509
+ GEMINI_CLI: "",
510
+ CLINE_ACTIVE: "",
511
+ },
512
+ );
513
+ assert.equal(r.status, 0, `stderr: ${r.stderr}`);
514
+ const json = JSON.parse(r.stdout);
515
+ assert.equal(json.action, "initialized");
516
+ // Both targets present: claudeCode + the cohort.
517
+ assert.ok(json.vendors.includes("claudeCode"));
518
+ assert.ok(json.vendors.length > 1, "cohort vendors must also be configured");
493
519
  });
494
520
 
495
521
  it("init with 401 from validate exits 2", async () => {
@@ -108,9 +108,12 @@ describe("file-write.mjs integration — round-trip", () => {
108
108
  }
109
109
  });
110
110
 
111
- it("writes to global dir under HOME with --global", () => {
111
+ it("writes to global dir under HOME with --global --agent claudeCode", () => {
112
112
  const skill = multiFileSkill();
113
- const result = writeSkillDir(skill, { global: true });
113
+ const result = writeSkillDir(skill, {
114
+ global: true,
115
+ vendors: ["claudeCode"],
116
+ });
114
117
  assert.equal(result.written.length, 1);
115
118
  const dir = result.written[0];
116
119
  assert.ok(dir.startsWith(process.env.HOME), "should write under HOME");
@@ -190,13 +193,13 @@ describe("file-write.mjs integration — orphan recovery", () => {
190
193
 
191
194
  it("cleanupOrphans removes injected .tmp/ (with live siblings) and .old/ across all roots", () => {
192
195
  // Inject orphans in claudeProject root, claudeGlobal root, and
193
- // projectFallback root. .tmp/ entries need a live sibling so the
196
+ // agentsProject root. .tmp/ entries need a live sibling so the
194
197
  // safety invariant doesn't preserve them as recoverable.
195
198
  const claudeProjectRoot = join(process.cwd(), ".claude", "skills");
196
- const fallbackRoot = join(process.cwd(), "skills");
199
+ const agentsRoot = join(process.cwd(), ".agents", "skills");
197
200
  const globalRoot = join(process.env.HOME, ".claude", "skills");
198
201
  mkdirSync(claudeProjectRoot, { recursive: true });
199
- mkdirSync(fallbackRoot, { recursive: true });
202
+ mkdirSync(agentsRoot, { recursive: true });
200
203
  mkdirSync(globalRoot, { recursive: true });
201
204
 
202
205
  // ghost-1: live sibling + .tmp + .old (.tmp gets cleaned because live exists)
@@ -205,8 +208,8 @@ describe("file-write.mjs integration — orphan recovery", () => {
205
208
  mkdirSync(join(claudeProjectRoot, "ghost-1.old"));
206
209
  writeFileSync(join(claudeProjectRoot, "ghost-1.tmp", "garbage.txt"), "leftover");
207
210
 
208
- // ghost-2: just a .old/ in the fallback root (.old has no invariant)
209
- mkdirSync(join(fallbackRoot, "ghost-2.old"));
211
+ // ghost-2: just a .old/ in the agents cohort root (.old has no invariant)
212
+ mkdirSync(join(agentsRoot, "ghost-2.old"));
210
213
 
211
214
  // ghost-3: live sibling + .tmp in the global root
212
215
  mkdirSync(join(globalRoot, "ghost-3"));
@@ -217,7 +220,7 @@ describe("file-write.mjs integration — orphan recovery", () => {
217
220
 
218
221
  assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.tmp")));
219
222
  assert.ok(!existsSync(join(claudeProjectRoot, "ghost-1.old")));
220
- assert.ok(!existsSync(join(fallbackRoot, "ghost-2.old")));
223
+ assert.ok(!existsSync(join(agentsRoot, "ghost-2.old")));
221
224
  assert.ok(!existsSync(join(globalRoot, "ghost-3.tmp")));
222
225
  // Live siblings preserved
223
226
  assert.ok(existsSync(join(claudeProjectRoot, "ghost-1")));
@@ -272,14 +275,32 @@ describe("file-write.mjs integration — remove + .gitignore", () => {
272
275
  }
273
276
  });
274
277
 
275
- it("write to fallback creates .gitignore entry; remove does not delete it", () => {
278
+ it("write to .agents/skills/ creates .gitignore entry; remove does not delete it", () => {
276
279
  writeSkillDir(multiFileSkill(), { vendors: ["cursor"] });
277
280
  const giBefore = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
278
- assert.match(giBefore, /\/skills\//);
281
+ assert.match(giBefore, /\.agents\/skills\//);
279
282
 
280
283
  removeSkillDir("pdf-helper", { vendors: ["cursor"] });
281
284
  const giAfter = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
282
285
  // remove should not touch .gitignore
283
286
  assert.equal(giAfter, giBefore);
284
287
  });
288
+
289
+ it("cohort dedupe end-to-end: vendors=[cursor, windsurf] writes ONE skill", () => {
290
+ const skill = multiFileSkill();
291
+ const result = writeSkillDir(skill, { vendors: ["cursor", "windsurf"] });
292
+ assert.equal(
293
+ result.written.length,
294
+ 1,
295
+ "cohort vendors must collapse to a single write",
296
+ );
297
+ const dir = result.written[0];
298
+ assert.ok(
299
+ dir.includes(join(".agents", "skills", "pdf-helper")),
300
+ `expected dir under .agents/skills/, got ${dir}`,
301
+ );
302
+ for (const file of skill.files) {
303
+ assert.ok(existsSync(join(dir, file.path)));
304
+ }
305
+ });
285
306
  });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Unit tests for src/lib/agent-registry.mjs (#1234).
3
+ *
4
+ * Locks the registry shape: 7 entries, no duplicate keys, no
5
+ * duplicate aliases, all targets resolvable in
6
+ * `resolvePlacementDir`. The registry is the single source of truth
7
+ * for placement decisions; a regression here breaks every downstream
8
+ * caller (file-write, cli-config, mcp-merge, gitignore).
9
+ */
10
+
11
+ import { describe, it, beforeEach, afterEach } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+
17
+ import {
18
+ AGENT_REGISTRY,
19
+ getAgentByKey,
20
+ getAgentByAlias,
21
+ } from "../../lib/agent-registry.mjs";
22
+ import { resolvePlacementDir } from "../../lib/file-write.mjs";
23
+ import {
24
+ captureHome,
25
+ setSandboxHome,
26
+ restoreHome,
27
+ } from "../helpers/sandbox-home.mjs";
28
+
29
+ const EXPECTED_KEYS = [
30
+ "claudeCode",
31
+ "cursor",
32
+ "windsurf",
33
+ "gemini",
34
+ "codex",
35
+ "cline",
36
+ "copilot",
37
+ ];
38
+
39
+ describe("AGENT_REGISTRY — shape", () => {
40
+ it("contains exactly the seven supported agents", () => {
41
+ const keys = AGENT_REGISTRY.map((entry) => entry.key);
42
+ assert.deepEqual(keys.sort(), [...EXPECTED_KEYS].sort());
43
+ });
44
+
45
+ it("each entry has the required fields", () => {
46
+ for (const entry of AGENT_REGISTRY) {
47
+ assert.equal(typeof entry.key, "string", `key for ${entry.key}`);
48
+ assert.equal(typeof entry.displayName, "string", `displayName for ${entry.key}`);
49
+ assert.ok(Array.isArray(entry.aliases), `aliases for ${entry.key}`);
50
+ assert.equal(typeof entry.projectTarget, "string", `projectTarget for ${entry.key}`);
51
+ assert.ok(
52
+ entry.globalTarget === null || typeof entry.globalTarget === "string",
53
+ `globalTarget for ${entry.key} must be string|null`,
54
+ );
55
+ assert.equal(typeof entry.hasMcp, "boolean", `hasMcp for ${entry.key} must be boolean`);
56
+ assert.ok(
57
+ Array.isArray(entry.detectionSignals),
58
+ `detectionSignals for ${entry.key} must be an array`,
59
+ );
60
+ assert.ok(
61
+ entry.detectionSignals.length > 0,
62
+ `detectionSignals for ${entry.key} must be non-empty`,
63
+ );
64
+ }
65
+ });
66
+
67
+ it("every detection signal has a valid type and a non-empty value", () => {
68
+ // Locks the DetectionSignal shape: a typo'd `type` would silently
69
+ // fall through detect-agents.mjs's probe switch and never fire.
70
+ const VALID_TYPES = new Set(["env", "home", "project"]);
71
+ for (const entry of AGENT_REGISTRY) {
72
+ for (const signal of entry.detectionSignals) {
73
+ assert.ok(
74
+ VALID_TYPES.has(signal.type),
75
+ `${entry.key} signal has invalid type "${signal.type}"`,
76
+ );
77
+ assert.equal(
78
+ typeof signal.value,
79
+ "string",
80
+ `${entry.key} signal value must be a string`,
81
+ );
82
+ assert.ok(
83
+ signal.value.length > 0,
84
+ `${entry.key} signal value must be non-empty`,
85
+ );
86
+ }
87
+ }
88
+ });
89
+
90
+ it("hasMcp matches the four-vendor MCP merger inventory", () => {
91
+ const withMcp = AGENT_REGISTRY.filter((entry) => entry.hasMcp).map((e) => e.key).sort();
92
+ assert.deepEqual(
93
+ withMcp,
94
+ ["claudeCode", "copilot", "cursor", "windsurf"],
95
+ "hasMcp must reflect which vendors have an MCP merger today",
96
+ );
97
+ });
98
+
99
+ it("has no duplicate canonical keys", () => {
100
+ const keys = AGENT_REGISTRY.map((entry) => entry.key);
101
+ assert.equal(keys.length, new Set(keys).size, "duplicate canonical keys");
102
+ });
103
+
104
+ it("has no duplicate aliases (across all entries)", () => {
105
+ const allAliases = AGENT_REGISTRY.flatMap((entry) => entry.aliases);
106
+ assert.equal(
107
+ allAliases.length,
108
+ new Set(allAliases).size,
109
+ "duplicate alias across registry entries",
110
+ );
111
+ });
112
+
113
+ it("no alias collides with a canonical key from a different entry", () => {
114
+ for (const entry of AGENT_REGISTRY) {
115
+ for (const alias of entry.aliases) {
116
+ const collision = AGENT_REGISTRY.find(
117
+ (other) => other.key === alias && other.key !== entry.key,
118
+ );
119
+ assert.equal(
120
+ collision,
121
+ undefined,
122
+ `alias "${alias}" on "${entry.key}" collides with canonical key of "${collision?.key}"`,
123
+ );
124
+ }
125
+ }
126
+ });
127
+
128
+ it("copilot is the only vendor with no global target", () => {
129
+ const noGlobal = AGENT_REGISTRY.filter((entry) => entry.globalTarget === null);
130
+ assert.deepEqual(
131
+ noGlobal.map((e) => e.key),
132
+ ["copilot"],
133
+ );
134
+ });
135
+ });
136
+
137
+ describe("getAgentByKey", () => {
138
+ it("returns the entry for a known canonical key", () => {
139
+ const entry = getAgentByKey("claudeCode");
140
+ assert.equal(entry?.key, "claudeCode");
141
+ assert.equal(entry?.projectTarget, "claudeProject");
142
+ assert.equal(entry?.globalTarget, "claudeGlobal");
143
+ });
144
+
145
+ it("returns undefined for an unknown key", () => {
146
+ assert.equal(getAgentByKey("jetbrains"), undefined);
147
+ });
148
+
149
+ it("does not resolve aliases (only canonical keys)", () => {
150
+ // 'claude' is an alias of 'claudeCode' — getAgentByKey is strict.
151
+ assert.equal(getAgentByKey("claude"), undefined);
152
+ });
153
+ });
154
+
155
+ describe("getAgentByAlias", () => {
156
+ it("resolves a canonical key to itself", () => {
157
+ assert.equal(getAgentByAlias("cursor"), "cursor");
158
+ });
159
+
160
+ it("resolves the 'claude' alias to 'claudeCode'", () => {
161
+ assert.equal(getAgentByAlias("claude"), "claudeCode");
162
+ });
163
+
164
+ it("resolves the 'vscode' alias to 'copilot'", () => {
165
+ assert.equal(getAgentByAlias("vscode"), "copilot");
166
+ });
167
+
168
+ it("returns null for an unknown name", () => {
169
+ assert.equal(getAgentByAlias("jetbrains"), null);
170
+ });
171
+
172
+ it("is case-sensitive", () => {
173
+ assert.equal(getAgentByAlias("CLAUDE"), null);
174
+ });
175
+ });
176
+
177
+ describe("AGENT_REGISTRY — placement targets resolve", () => {
178
+ let sandbox;
179
+ let originalCwd;
180
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
181
+ let originalHomeEnv;
182
+
183
+ beforeEach(() => {
184
+ sandbox = mkdtempSync(join(tmpdir(), "cli-agent-reg-"));
185
+ mkdirSync(join(sandbox, "project"), { recursive: true });
186
+ mkdirSync(join(sandbox, "home"), { recursive: true });
187
+ originalCwd = process.cwd();
188
+ originalHomeEnv = captureHome();
189
+ process.chdir(join(sandbox, "project"));
190
+ setSandboxHome(join(sandbox, "home"));
191
+ });
192
+
193
+ afterEach(() => {
194
+ process.chdir(originalCwd);
195
+ restoreHome(originalHomeEnv);
196
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
197
+ });
198
+
199
+ it("every projectTarget resolves without throwing", () => {
200
+ for (const entry of AGENT_REGISTRY) {
201
+ const dir = resolvePlacementDir(entry.projectTarget, "test-skill");
202
+ assert.equal(typeof dir, "string");
203
+ assert.ok(dir.length > 0);
204
+ }
205
+ });
206
+
207
+ it("every non-null globalTarget resolves without throwing", () => {
208
+ for (const entry of AGENT_REGISTRY) {
209
+ if (entry.globalTarget === null) continue;
210
+ const dir = resolvePlacementDir(entry.globalTarget, "test-skill");
211
+ assert.equal(typeof dir, "string");
212
+ assert.ok(dir.length > 0);
213
+ }
214
+ });
215
+ });