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
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Unit tests for src/lib/detect-agents.mjs (#1236, Phase 3 of #876).
3
+ *
4
+ * Coverage:
5
+ * - Per-signal-type probes (env / home / project)
6
+ * - OR semantics: any signal fires → detected
7
+ * - Priority order: env > home > project (first-fire wins for the
8
+ * human-readable `reason`)
9
+ * - All seven registry agents produce a result (registry-driven)
10
+ * - Cross-platform path resolution via sandbox-home
11
+ * - Truthy-vs-empty env handling (empty string is NOT truthy)
12
+ */
13
+
14
+ import { describe, it, beforeEach, afterEach } from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { tmpdir } from "node:os";
19
+
20
+ import { detectAgents } from "../../lib/detect-agents.mjs";
21
+ import { AGENT_REGISTRY } from "../../lib/agent-registry.mjs";
22
+ import {
23
+ captureHome,
24
+ setSandboxHome,
25
+ restoreHome,
26
+ } from "../helpers/sandbox-home.mjs";
27
+
28
+ let sandbox;
29
+ let originalCwd;
30
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
31
+ let originalHomeEnv;
32
+ /** @type {Record<string, string | undefined>} */
33
+ let originalEnvVars;
34
+
35
+ // Every env var the registry references as a detection signal. Each
36
+ // test snapshots and restores these so a stray env var on the host
37
+ // machine cannot leak into the assertions.
38
+ const ENV_VAR_NAMES = Array.from(
39
+ new Set(
40
+ AGENT_REGISTRY.flatMap((entry) =>
41
+ entry.detectionSignals
42
+ .filter((s) => s.type === "env")
43
+ .map((s) => s.value),
44
+ ),
45
+ ),
46
+ );
47
+
48
+ function setup() {
49
+ sandbox = mkdtempSync(join(tmpdir(), "cli-detect-agents-"));
50
+ mkdirSync(join(sandbox, "home"), { recursive: true });
51
+ mkdirSync(join(sandbox, "project"), { recursive: true });
52
+ originalCwd = process.cwd();
53
+ originalHomeEnv = captureHome();
54
+ // Snapshot every detection-relevant env var. Delete each so the
55
+ // baseline is "no signals firing" — individual tests opt-in to the
56
+ // env vars they want to set.
57
+ originalEnvVars = {};
58
+ for (const name of ENV_VAR_NAMES) {
59
+ originalEnvVars[name] = process.env[name];
60
+ delete process.env[name];
61
+ }
62
+ process.chdir(join(sandbox, "project"));
63
+ setSandboxHome(join(sandbox, "home"));
64
+ }
65
+
66
+ function teardown() {
67
+ process.chdir(originalCwd);
68
+ restoreHome(originalHomeEnv);
69
+ for (const name of ENV_VAR_NAMES) {
70
+ if (originalEnvVars[name] === undefined) delete process.env[name];
71
+ else process.env[name] = originalEnvVars[name];
72
+ }
73
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
74
+ }
75
+
76
+ function findResult(detections, key) {
77
+ return detections.find((d) => d.key === key);
78
+ }
79
+
80
+ // ── Baseline ────────────────────────────────────────────────────────
81
+
82
+ describe("detectAgents — baseline", () => {
83
+ beforeEach(setup);
84
+ afterEach(teardown);
85
+
86
+ it("returns one result per registry entry, in registry order", () => {
87
+ const result = detectAgents();
88
+ assert.equal(result.length, AGENT_REGISTRY.length);
89
+ for (let i = 0; i < AGENT_REGISTRY.length; i++) {
90
+ assert.equal(result[i].key, AGENT_REGISTRY[i].key);
91
+ assert.equal(result[i].displayName, AGENT_REGISTRY[i].displayName);
92
+ }
93
+ });
94
+
95
+ it("returns detected=false and reason=null for every agent in a clean sandbox", () => {
96
+ const result = detectAgents();
97
+ for (const detection of result) {
98
+ assert.equal(
99
+ detection.detected,
100
+ false,
101
+ `${detection.key} should not be detected in a clean sandbox`,
102
+ );
103
+ assert.equal(detection.reason, null);
104
+ }
105
+ });
106
+ });
107
+
108
+ // ── Per-signal-type probes ──────────────────────────────────────────
109
+
110
+ describe("detectAgents — env signal", () => {
111
+ beforeEach(setup);
112
+ afterEach(teardown);
113
+
114
+ it("detects Claude Code via CLAUDECODE env var", () => {
115
+ process.env.CLAUDECODE = "1";
116
+ const result = findResult(detectAgents(), "claudeCode");
117
+ assert.equal(result.detected, true);
118
+ assert.equal(result.reason, "CLAUDECODE=1");
119
+ });
120
+
121
+ it("detects Cursor via CURSOR_AGENT env var", () => {
122
+ process.env.CURSOR_AGENT = "1";
123
+ const result = findResult(detectAgents(), "cursor");
124
+ assert.equal(result.detected, true);
125
+ assert.equal(result.reason, "CURSOR_AGENT=1");
126
+ });
127
+
128
+ it("detects Cursor via CURSOR_CLI env var (fallback signal)", () => {
129
+ process.env.CURSOR_CLI = "1";
130
+ const result = findResult(detectAgents(), "cursor");
131
+ assert.equal(result.detected, true);
132
+ assert.equal(result.reason, "CURSOR_CLI=1");
133
+ });
134
+
135
+ it("detects Gemini via GEMINI_CLI env var", () => {
136
+ process.env.GEMINI_CLI = "1";
137
+ const result = findResult(detectAgents(), "gemini");
138
+ assert.equal(result.detected, true);
139
+ assert.equal(result.reason, "GEMINI_CLI=1");
140
+ });
141
+
142
+ it("detects Cline via CLINE_ACTIVE='true'", () => {
143
+ process.env.CLINE_ACTIVE = "true";
144
+ const result = findResult(detectAgents(), "cline");
145
+ assert.equal(result.detected, true);
146
+ assert.equal(result.reason, "CLINE_ACTIVE=true");
147
+ });
148
+
149
+ it("does NOT fire on empty-string env var (e.g., CLAUDECODE='')", () => {
150
+ process.env.CLAUDECODE = "";
151
+ const result = findResult(detectAgents(), "claudeCode");
152
+ assert.equal(result.detected, false);
153
+ assert.equal(result.reason, null);
154
+ });
155
+
156
+ it("does NOT detect Codex via env var (Codex has no documented active-session env)", () => {
157
+ // Defensive lock: a future maintainer who adds CODEX_HOME as an
158
+ // env signal would silently produce false positives because
159
+ // CODEX_HOME is a config var Codex *reads*, not one it sets in
160
+ // spawned shells. This test holds the line.
161
+ process.env.CODEX_HOME = "/some/path";
162
+ const result = findResult(detectAgents(), "codex");
163
+ assert.equal(result.detected, false);
164
+ });
165
+ });
166
+
167
+ describe("detectAgents — home signal", () => {
168
+ beforeEach(setup);
169
+ afterEach(teardown);
170
+
171
+ it("detects Claude Code via ~/.claude/ directory", () => {
172
+ mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
173
+ const result = findResult(detectAgents(), "claudeCode");
174
+ assert.equal(result.detected, true);
175
+ assert.equal(result.reason, "~/.claude/");
176
+ });
177
+
178
+ it("detects Cursor via ~/.cursor/ directory", () => {
179
+ mkdirSync(join(process.env.HOME, ".cursor"), { recursive: true });
180
+ const result = findResult(detectAgents(), "cursor");
181
+ assert.equal(result.detected, true);
182
+ assert.equal(result.reason, "~/.cursor/");
183
+ });
184
+
185
+ it("detects Windsurf via ~/.codeium/windsurf/ directory (Codeium prefix)", () => {
186
+ mkdirSync(join(process.env.HOME, ".codeium", "windsurf"), {
187
+ recursive: true,
188
+ });
189
+ const result = findResult(detectAgents(), "windsurf");
190
+ assert.equal(result.detected, true);
191
+ assert.equal(result.reason, "~/.codeium/windsurf/");
192
+ });
193
+
194
+ it("detects Codex via ~/.codex/ directory (HOME-only — no env signal)", () => {
195
+ mkdirSync(join(process.env.HOME, ".codex"), { recursive: true });
196
+ const result = findResult(detectAgents(), "codex");
197
+ assert.equal(result.detected, true);
198
+ assert.equal(result.reason, "~/.codex/");
199
+ });
200
+
201
+ it("detects Copilot via ~/.copilot/ directory", () => {
202
+ mkdirSync(join(process.env.HOME, ".copilot"), { recursive: true });
203
+ const result = findResult(detectAgents(), "copilot");
204
+ assert.equal(result.detected, true);
205
+ assert.equal(result.reason, "~/.copilot/");
206
+ });
207
+ });
208
+
209
+ describe("detectAgents — project signal", () => {
210
+ beforeEach(setup);
211
+ afterEach(teardown);
212
+
213
+ it("detects Claude Code via project .claude/ directory", () => {
214
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
215
+ const result = findResult(detectAgents(), "claudeCode");
216
+ assert.equal(result.detected, true);
217
+ assert.equal(result.reason, ".claude/");
218
+ });
219
+
220
+ it("detects Cursor via project .cursor/ directory", () => {
221
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
222
+ const result = findResult(detectAgents(), "cursor");
223
+ assert.equal(result.detected, true);
224
+ assert.equal(result.reason, ".cursor/");
225
+ });
226
+
227
+ it("detects Windsurf via project .windsurf/ directory", () => {
228
+ mkdirSync(join(process.cwd(), ".windsurf"), { recursive: true });
229
+ const result = findResult(detectAgents(), "windsurf");
230
+ assert.equal(result.detected, true);
231
+ assert.equal(result.reason, ".windsurf/");
232
+ });
233
+
234
+ it("detects Gemini via project .gemini/ directory", () => {
235
+ mkdirSync(join(process.cwd(), ".gemini"), { recursive: true });
236
+ const result = findResult(detectAgents(), "gemini");
237
+ assert.equal(result.detected, true);
238
+ assert.equal(result.reason, ".gemini/");
239
+ });
240
+
241
+ it("detects Codex via project .codex/ directory", () => {
242
+ mkdirSync(join(process.cwd(), ".codex"), { recursive: true });
243
+ const result = findResult(detectAgents(), "codex");
244
+ assert.equal(result.detected, true);
245
+ assert.equal(result.reason, ".codex/");
246
+ });
247
+
248
+ it("detects Cline via project .cline/ directory", () => {
249
+ mkdirSync(join(process.cwd(), ".cline"), { recursive: true });
250
+ const result = findResult(detectAgents(), "cline");
251
+ assert.equal(result.detected, true);
252
+ assert.equal(result.reason, ".cline/");
253
+ });
254
+
255
+ it("detects Copilot via .github/skills/ directory", () => {
256
+ mkdirSync(join(process.cwd(), ".github", "skills"), { recursive: true });
257
+ const result = findResult(detectAgents(), "copilot");
258
+ assert.equal(result.detected, true);
259
+ assert.equal(result.reason, ".github/skills/");
260
+ });
261
+ });
262
+
263
+ // ── OR semantics + priority order ───────────────────────────────────
264
+
265
+ describe("detectAgents — OR semantics + priority", () => {
266
+ beforeEach(setup);
267
+ afterEach(teardown);
268
+
269
+ it("fires when only one of multiple signals is present", () => {
270
+ // Cursor has env + home + project; only project is set here.
271
+ mkdirSync(join(process.cwd(), ".cursor"), { recursive: true });
272
+ const result = findResult(detectAgents(), "cursor");
273
+ assert.equal(result.detected, true);
274
+ assert.equal(result.reason, ".cursor/");
275
+ });
276
+
277
+ it("env signal wins over home signal when both fire", () => {
278
+ process.env.CLAUDECODE = "1";
279
+ mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
280
+ const result = findResult(detectAgents(), "claudeCode");
281
+ assert.equal(result.detected, true);
282
+ // env first by registry signal order — the formatted reason
283
+ // confirms the env signal won.
284
+ assert.equal(result.reason, "CLAUDECODE=1");
285
+ });
286
+
287
+ it("env signal wins over project signal when both fire", () => {
288
+ process.env.CLAUDECODE = "1";
289
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
290
+ const result = findResult(detectAgents(), "claudeCode");
291
+ assert.equal(result.detected, true);
292
+ assert.equal(result.reason, "CLAUDECODE=1");
293
+ });
294
+
295
+ it("home signal wins over project signal when env is absent", () => {
296
+ mkdirSync(join(process.env.HOME, ".claude"), { recursive: true });
297
+ mkdirSync(join(process.cwd(), ".claude"), { recursive: true });
298
+ const result = findResult(detectAgents(), "claudeCode");
299
+ assert.equal(result.detected, true);
300
+ assert.equal(result.reason, "~/.claude/");
301
+ });
302
+
303
+ it("primary env signal wins over fallback env signal (Cursor: CURSOR_AGENT > CURSOR_CLI)", () => {
304
+ process.env.CURSOR_AGENT = "1";
305
+ process.env.CURSOR_CLI = "1";
306
+ const result = findResult(detectAgents(), "cursor");
307
+ assert.equal(result.reason, "CURSOR_AGENT=1");
308
+ });
309
+ });
310
+
311
+ // ── Registry-driven coverage (the real durability win) ─────────────
312
+
313
+ describe("detectAgents — registry-driven coverage", () => {
314
+ beforeEach(setup);
315
+ afterEach(teardown);
316
+
317
+ it("every agent has at least one detection signal declared", () => {
318
+ // Locks the registry contract: a future entry that forgets
319
+ // detectionSignals would silently never fire.
320
+ for (const entry of AGENT_REGISTRY) {
321
+ assert.ok(
322
+ Array.isArray(entry.detectionSignals) &&
323
+ entry.detectionSignals.length > 0,
324
+ `${entry.key} must have at least one detection signal`,
325
+ );
326
+ }
327
+ });
328
+
329
+ it("every agent's first signal is reachable (probe doesn't throw)", () => {
330
+ // Smoke test: detectAgents() must succeed regardless of which
331
+ // signal types the registry uses. A typo'd signal type would
332
+ // silently return null forever; this test catches that by
333
+ // proving a clean run completes.
334
+ assert.doesNotThrow(() => detectAgents());
335
+ });
336
+ });
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Per-agent placement-target tests for file-write.mjs (#1234).
3
+ *
4
+ * The cohort-dedupe assertion is the central piece: every vendor
5
+ * except claudeCode resolves to `agentsProject`, and the result of
6
+ * `placementTargetsFor` for a multi-vendor cohort must be one entry,
7
+ * not six. This is what makes `--agent cursor,windsurf,gemini,codex,
8
+ * cline,copilot` produce a single write instead of duplicating the
9
+ * skill across paths nobody reads.
10
+ */
11
+
12
+ import { describe, it, beforeEach, afterEach } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { tmpdir, homedir } from "node:os";
17
+
18
+ import {
19
+ placementTargetsFor,
20
+ resolvePlacementDir,
21
+ } from "../../lib/file-write.mjs";
22
+ import { CliError, EXIT_VALIDATION } from "../../lib/errors.mjs";
23
+ import {
24
+ captureHome,
25
+ setSandboxHome,
26
+ restoreHome,
27
+ } from "../helpers/sandbox-home.mjs";
28
+
29
+ let sandbox;
30
+ let originalCwd;
31
+ /** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
32
+ let originalHomeEnv;
33
+
34
+ function setupSandbox() {
35
+ sandbox = mkdtempSync(join(tmpdir(), "cli-fw-place-"));
36
+ mkdirSync(join(sandbox, "project"), { recursive: true });
37
+ mkdirSync(join(sandbox, "home"), { recursive: true });
38
+ originalCwd = process.cwd();
39
+ originalHomeEnv = captureHome();
40
+ process.chdir(join(sandbox, "project"));
41
+ setSandboxHome(join(sandbox, "home"));
42
+ }
43
+
44
+ function teardownSandbox() {
45
+ process.chdir(originalCwd);
46
+ restoreHome(originalHomeEnv);
47
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
48
+ }
49
+
50
+ describe("placementTargetsFor — project scope", () => {
51
+ beforeEach(setupSandbox);
52
+ afterEach(teardownSandbox);
53
+
54
+ it("claudeCode → [claudeProject]", () => {
55
+ assert.deepEqual(
56
+ placementTargetsFor({ vendors: ["claudeCode"] }),
57
+ ["claudeProject"],
58
+ );
59
+ });
60
+
61
+ it("cursor → [agentsProject]", () => {
62
+ assert.deepEqual(
63
+ placementTargetsFor({ vendors: ["cursor"] }),
64
+ ["agentsProject"],
65
+ );
66
+ });
67
+
68
+ it("windsurf → [agentsProject]", () => {
69
+ assert.deepEqual(
70
+ placementTargetsFor({ vendors: ["windsurf"] }),
71
+ ["agentsProject"],
72
+ );
73
+ });
74
+
75
+ it("gemini → [agentsProject]", () => {
76
+ assert.deepEqual(
77
+ placementTargetsFor({ vendors: ["gemini"] }),
78
+ ["agentsProject"],
79
+ );
80
+ });
81
+
82
+ it("codex → [agentsProject]", () => {
83
+ assert.deepEqual(
84
+ placementTargetsFor({ vendors: ["codex"] }),
85
+ ["agentsProject"],
86
+ );
87
+ });
88
+
89
+ it("cline → [agentsProject]", () => {
90
+ assert.deepEqual(
91
+ placementTargetsFor({ vendors: ["cline"] }),
92
+ ["agentsProject"],
93
+ );
94
+ });
95
+
96
+ it("copilot → [agentsProject]", () => {
97
+ assert.deepEqual(
98
+ placementTargetsFor({ vendors: ["copilot"] }),
99
+ ["agentsProject"],
100
+ );
101
+ });
102
+
103
+ it("claudeCode + cursor → [claudeProject, agentsProject]", () => {
104
+ assert.deepEqual(
105
+ placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
106
+ ["claudeProject", "agentsProject"],
107
+ );
108
+ });
109
+
110
+ it("cohort dedupe: cursor + windsurf + gemini + codex + cline + copilot → [agentsProject]", () => {
111
+ const targets = placementTargetsFor({
112
+ vendors: ["cursor", "windsurf", "gemini", "codex", "cline", "copilot"],
113
+ });
114
+ assert.deepEqual(
115
+ targets,
116
+ ["agentsProject"],
117
+ "six cohort vendors must collapse to ONE write",
118
+ );
119
+ });
120
+
121
+ it("preserves order: first-seen-target wins", () => {
122
+ const targets = placementTargetsFor({
123
+ vendors: ["cursor", "claudeCode", "windsurf"],
124
+ });
125
+ // cursor pushes agentsProject first, claudeCode pushes claudeProject
126
+ // second, windsurf is a dedupe of agentsProject.
127
+ assert.deepEqual(targets, ["agentsProject", "claudeProject"]);
128
+ });
129
+ });
130
+
131
+ describe("placementTargetsFor — global scope", () => {
132
+ beforeEach(setupSandbox);
133
+ afterEach(teardownSandbox);
134
+
135
+ it("--global claudeCode → [claudeGlobal]", () => {
136
+ assert.deepEqual(
137
+ placementTargetsFor({ global: true, vendors: ["claudeCode"] }),
138
+ ["claudeGlobal"],
139
+ );
140
+ });
141
+
142
+ it("--global cursor → [agentsGlobal]", () => {
143
+ assert.deepEqual(
144
+ placementTargetsFor({ global: true, vendors: ["cursor"] }),
145
+ ["agentsGlobal"],
146
+ );
147
+ });
148
+
149
+ it("--global windsurf → [windsurfGlobal] (vendor-specific personal path)", () => {
150
+ assert.deepEqual(
151
+ placementTargetsFor({ global: true, vendors: ["windsurf"] }),
152
+ ["windsurfGlobal"],
153
+ );
154
+ });
155
+
156
+ it("--global gemini → [agentsGlobal]", () => {
157
+ assert.deepEqual(
158
+ placementTargetsFor({ global: true, vendors: ["gemini"] }),
159
+ ["agentsGlobal"],
160
+ );
161
+ });
162
+
163
+ it("--global codex → [agentsGlobal]", () => {
164
+ assert.deepEqual(
165
+ placementTargetsFor({ global: true, vendors: ["codex"] }),
166
+ ["agentsGlobal"],
167
+ );
168
+ });
169
+
170
+ it("--global cline → [agentsGlobal]", () => {
171
+ assert.deepEqual(
172
+ placementTargetsFor({ global: true, vendors: ["cline"] }),
173
+ ["agentsGlobal"],
174
+ );
175
+ });
176
+
177
+ it("--global copilot → throws validationError (no personal scope)", () => {
178
+ assert.throws(
179
+ () => placementTargetsFor({ global: true, vendors: ["copilot"] }),
180
+ (err) =>
181
+ err instanceof CliError &&
182
+ err.exitCode === EXIT_VALIDATION &&
183
+ /has no documented personal scope/.test(err.message),
184
+ );
185
+ });
186
+
187
+ it("--global cohort dedupes the agents cohort to [agentsGlobal, windsurfGlobal]", () => {
188
+ const targets = placementTargetsFor({
189
+ global: true,
190
+ vendors: ["cursor", "windsurf", "gemini", "codex", "cline"],
191
+ });
192
+ // cursor/gemini/codex/cline → agentsGlobal; windsurf → windsurfGlobal.
193
+ // Order is first-seen.
194
+ assert.deepEqual(targets, ["agentsGlobal", "windsurfGlobal"]);
195
+ });
196
+ });
197
+
198
+ describe("placementTargetsFor — error paths", () => {
199
+ beforeEach(setupSandbox);
200
+ afterEach(teardownSandbox);
201
+
202
+ it("rejects an empty vendors list when not --global", () => {
203
+ assert.throws(
204
+ () => placementTargetsFor({ vendors: [] }),
205
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
206
+ );
207
+ });
208
+
209
+ it("rejects an unknown vendor", () => {
210
+ assert.throws(
211
+ () => placementTargetsFor({ vendors: ["jetbrains"] }),
212
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
213
+ );
214
+ });
215
+
216
+ it("rejects the deleted projectFallback target", () => {
217
+ assert.throws(
218
+ () => resolvePlacementDir("projectFallback", "pdf-helper"),
219
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
220
+ );
221
+ });
222
+
223
+ it("rejects bare --global with no vendors", () => {
224
+ // Every caller (init, add, get, update, remove) routes through
225
+ // `effectiveVendors`, which guarantees a non-empty vendor list
226
+ // before placementTargetsFor sees it. An empty-vendors call here
227
+ // is a programming error in a future caller, not a user input —
228
+ // surface it loudly rather than silently routing to claudeGlobal.
229
+ assert.throws(
230
+ () => placementTargetsFor({ global: true, vendors: [] }),
231
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
232
+ );
233
+ assert.throws(
234
+ () => placementTargetsFor({ global: true }),
235
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
236
+ );
237
+ });
238
+ });
239
+
240
+ describe("resolvePlacementDir — cross-platform paths", () => {
241
+ beforeEach(setupSandbox);
242
+ afterEach(teardownSandbox);
243
+
244
+ it("agentsProject resolves under cwd/.agents/skills/<name>", () => {
245
+ assert.equal(
246
+ resolvePlacementDir("agentsProject", "pdf-helper"),
247
+ join(process.cwd(), ".agents", "skills", "pdf-helper"),
248
+ );
249
+ });
250
+
251
+ it("agentsGlobal resolves under HOME/.agents/skills/<name>", () => {
252
+ assert.equal(
253
+ resolvePlacementDir("agentsGlobal", "pdf-helper"),
254
+ join(homedir(), ".agents", "skills", "pdf-helper"),
255
+ );
256
+ });
257
+
258
+ it("windsurfGlobal resolves under HOME/.codeium/windsurf/skills/<name>", () => {
259
+ assert.equal(
260
+ resolvePlacementDir("windsurfGlobal", "pdf-helper"),
261
+ join(homedir(), ".codeium", "windsurf", "skills", "pdf-helper"),
262
+ );
263
+ });
264
+ });