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
@@ -4,8 +4,10 @@
4
4
  * Covers:
5
5
  * • resolveFlags — every flag, the priority order (CLI flag > config
6
6
  * file > env var > default), error paths
7
- * • effectiveVendors — defaults, --global override, explicit list
8
- * parseVendorList edge cases (alias, all, empty, unknown)
7
+ * • effectiveVendors — defaults, --global override, explicit list,
8
+ * `--agent none` empty-array sentinel
9
+ * • parseAgentList edge cases (token expansion, aliases, none, unknown)
10
+ * • requireVendorTargets — rejects `--agent none` for write commands
9
11
  *
10
12
  * The cli-config helper is shared by all four PR2 commands and PR3a's
11
13
  * write commands, so coverage here is load-bearing.
@@ -17,7 +19,12 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
17
19
  import { join } from "node:path";
18
20
  import { tmpdir } from "node:os";
19
21
 
20
- import { resolveFlags, effectiveVendors } from "../../lib/cli-config.mjs";
22
+ import {
23
+ resolveFlags,
24
+ effectiveVendors,
25
+ requireVendorTargets,
26
+ } from "../../lib/cli-config.mjs";
27
+ import { AGENT_REGISTRY } from "../../lib/agent-registry.mjs";
21
28
  import { isTransientRunnerInvocation } from "../../lib/transient-runners.mjs";
22
29
  import { CliError, EXIT_AUTH, EXIT_VALIDATION } from "../../lib/errors.mjs";
23
30
  import {
@@ -236,73 +243,183 @@ describe("resolveFlags — flag parsing", () => {
236
243
  assert.equal(flags.serverUrl, "https://x");
237
244
  });
238
245
 
239
- it("parses --ide claudeCode", () => {
246
+ it("parses --agent claudeCode", () => {
240
247
  process.env.SKILLREPO_ACCESS_KEY = "k";
241
- const flags = resolveFlags(["--ide", "claudeCode"]);
248
+ const flags = resolveFlags(["--agent", "claudeCode"]);
242
249
  assert.deepEqual(flags.vendors, ["claudeCode"]);
243
250
  });
244
251
 
245
- it("parses --ide alias 'claude' as claudeCode", () => {
252
+ it("parses --agent claude (canonical target shortcut) as claudeCode", () => {
246
253
  process.env.SKILLREPO_ACCESS_KEY = "k";
247
- const flags = resolveFlags(["--ide", "claude"]);
254
+ const flags = resolveFlags(["--agent", "claude"]);
248
255
  assert.deepEqual(flags.vendors, ["claudeCode"]);
249
256
  });
250
257
 
251
- it("parses --ide multi-vendor", () => {
258
+ it("parses --agent agents (cohort token) to every agentsProject vendor", () => {
259
+ // Asserts against the registry directly rather than a literal
260
+ // list, so adding a new `agentsProject` vendor updates this test
261
+ // automatically.
262
+ process.env.SKILLREPO_ACCESS_KEY = "k";
263
+ const flags = resolveFlags(["--agent", "agents"]);
264
+ const expected = AGENT_REGISTRY
265
+ .filter((e) => e.projectTarget === "agentsProject")
266
+ .map((e) => e.key);
267
+ assert.deepEqual(flags.vendors, expected);
268
+ assert.ok(expected.length >= 6, "cohort must contain at least 6 vendors");
269
+ });
270
+
271
+ it("parses --agent claude,agents to claudeCode + every agentsProject vendor", () => {
272
+ process.env.SKILLREPO_ACCESS_KEY = "k";
273
+ const flags = resolveFlags(["--agent", "claude,agents"]);
274
+ const cohort = AGENT_REGISTRY
275
+ .filter((e) => e.projectTarget === "agentsProject")
276
+ .map((e) => e.key);
277
+ assert.deepEqual(flags.vendors, ["claudeCode", ...cohort]);
278
+ });
279
+
280
+ it("parses --agent claude,cursor,gemini (mixed canonical + cohort vendor names)", () => {
252
281
  process.env.SKILLREPO_ACCESS_KEY = "k";
253
- const flags = resolveFlags(["--ide", "claude,cursor,windsurf"]);
254
- assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf"]);
282
+ const flags = resolveFlags(["--agent", "claude,cursor,gemini"]);
283
+ assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "gemini"]);
255
284
  });
256
285
 
257
- it("parses --ide all as the full vendor list", () => {
286
+ it("parses --agent cursor (cohort vendor name as silent alias)", () => {
287
+ // A user typing a vendor name they know (cursor) gets that
288
+ // single vendor — they DON'T silently get expanded to the whole
289
+ // cohort. The cohort expansion is reserved for the explicit
290
+ // `agents` token.
258
291
  process.env.SKILLREPO_ACCESS_KEY = "k";
259
- const flags = resolveFlags(["--ide", "all"]);
260
- assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf", "vscode"]);
292
+ const flags = resolveFlags(["--agent", "cursor"]);
293
+ assert.deepEqual(flags.vendors, ["cursor"]);
261
294
  });
262
295
 
263
- it("rejects --ide cursor,all (mixing all with explicit vendors is ambiguous)", () => {
296
+ it("parses --agent claude-code (registry alias) as claudeCode", () => {
297
+ process.env.SKILLREPO_ACCESS_KEY = "k";
298
+ const flags = resolveFlags(["--agent", "claude-code"]);
299
+ assert.deepEqual(flags.vendors, ["claudeCode"]);
300
+ });
301
+
302
+ it("parses --agent vscode (registry alias) as copilot", () => {
303
+ process.env.SKILLREPO_ACCESS_KEY = "k";
304
+ const flags = resolveFlags(["--agent", "vscode"]);
305
+ assert.deepEqual(flags.vendors, ["copilot"]);
306
+ });
307
+
308
+ it("parses --agent none to the empty-array sentinel", () => {
309
+ // `none` is the explicit "no placement" token. Returns `[]`
310
+ // (frozen) so callers detect via `.length === 0`. The empty
311
+ // array distinguishes "user opted out" from `null` ("user
312
+ // didn't pass --agent at all → use the default").
313
+ process.env.SKILLREPO_ACCESS_KEY = "k";
314
+ const flags = resolveFlags(["--agent", "none"]);
315
+ assert.ok(Array.isArray(flags.vendors));
316
+ assert.equal(flags.vendors.length, 0);
317
+ });
318
+
319
+ it("dedupes --agent claude,claude to a single vendor", () => {
320
+ process.env.SKILLREPO_ACCESS_KEY = "k";
321
+ const flags = resolveFlags(["--agent", "claude,claude"]);
322
+ assert.deepEqual(flags.vendors, ["claudeCode"]);
323
+ });
324
+
325
+ it("dedupes --agent claude,claude-code (alias collapses to canonical)", () => {
326
+ process.env.SKILLREPO_ACCESS_KEY = "k";
327
+ const flags = resolveFlags(["--agent", "claude,claude-code"]);
328
+ assert.deepEqual(flags.vendors, ["claudeCode"]);
329
+ });
330
+
331
+ it("rejects --agent claude,none (mixing none with other tokens is ambiguous)", () => {
332
+ process.env.SKILLREPO_ACCESS_KEY = "k";
333
+ assert.throws(
334
+ () => resolveFlags(["--agent", "claude,none"]),
335
+ (err) =>
336
+ err instanceof CliError &&
337
+ err.exitCode === EXIT_VALIDATION &&
338
+ /cannot mix "none"/.test(err.message),
339
+ );
340
+ });
341
+
342
+ it("rejects --agent none,claude in the other order too", () => {
264
343
  process.env.SKILLREPO_ACCESS_KEY = "k";
265
344
  assert.throws(
266
- () => resolveFlags(["--ide", "cursor,all"]),
267
- (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION && /cannot mix/.test(err.message),
345
+ () => resolveFlags(["--agent", "none,claude"]),
346
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
268
347
  );
269
348
  });
270
349
 
271
- it("rejects --ide all,cursor in the other order too", () => {
350
+ it("rejects --agent all (the legacy --ide token is not supported)", () => {
351
+ // The old `--ide all` is intentionally dropped — `--agent
352
+ // claude,agents` is the new explicit way to get every target.
353
+ // Accepting `all` would be a deprecation alias, which the
354
+ // partner-mode rules forbid (no users to preserve).
272
355
  process.env.SKILLREPO_ACCESS_KEY = "k";
273
356
  assert.throws(
274
- () => resolveFlags(["--ide", "all,cursor"]),
357
+ () => resolveFlags(["--agent", "all"]),
358
+ (err) =>
359
+ err instanceof CliError &&
360
+ err.exitCode === EXIT_VALIDATION &&
361
+ /Unknown --agent target/.test(err.message),
362
+ );
363
+ });
364
+
365
+ it("rejects --agent agents,all (the `all` token is gone in either position)", () => {
366
+ process.env.SKILLREPO_ACCESS_KEY = "k";
367
+ assert.throws(
368
+ () => resolveFlags(["--agent", "agents,all"]),
275
369
  (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
276
370
  );
277
371
  });
278
372
 
279
- it("accepts --ide all,all (degenerate case dedupes to the full set)", () => {
280
- // The `all` token is hard-coded to mean the full vendor list. A
281
- // user passing it twice is a degenerate input but unambiguous —
282
- // both `all`s expand to the same set, so we accept it. This test
283
- // locks the behavior so a future tightening of parseVendorList
284
- // doesn't accidentally start rejecting it.
373
+ it("rejects unknown --agent target", () => {
285
374
  process.env.SKILLREPO_ACCESS_KEY = "k";
286
- const flags = resolveFlags(["--ide", "all,all"]);
287
- assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "windsurf", "vscode"]);
375
+ assert.throws(
376
+ () => resolveFlags(["--agent", "jetbrains"]),
377
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
378
+ );
288
379
  });
289
380
 
290
- it("rejects unknown --ide vendor", () => {
381
+ it("rejects empty --agent list (only commas)", () => {
291
382
  process.env.SKILLREPO_ACCESS_KEY = "k";
292
383
  assert.throws(
293
- () => resolveFlags(["--ide", "jetbrains"]),
384
+ () => resolveFlags(["--agent", ","]),
294
385
  (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
295
386
  );
296
387
  });
297
388
 
298
- it("rejects empty --ide list", () => {
389
+ it("rejects --agent with empty value", () => {
299
390
  process.env.SKILLREPO_ACCESS_KEY = "k";
300
391
  assert.throws(
301
- () => resolveFlags(["--ide", ","]),
392
+ () => resolveFlags(["--agent", ""]),
302
393
  (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
303
394
  );
304
395
  });
305
396
 
397
+ it("gives an actionable error when --agent is the final argv with no value", () => {
398
+ process.env.SKILLREPO_ACCESS_KEY = "k";
399
+ assert.throws(
400
+ () => resolveFlags(["--agent"]),
401
+ (err) =>
402
+ err instanceof CliError &&
403
+ err.exitCode === EXIT_VALIDATION &&
404
+ /Missing value for --agent/.test(err.message),
405
+ );
406
+ });
407
+
408
+ it("rejects the legacy --ide flag entirely", () => {
409
+ // No deprecation alias by design (partner-mode: no users to
410
+ // preserve). The old flag is rejected with a hint pointing the
411
+ // user at --agent so v2→v3 muscle-memory invocations get an
412
+ // actionable error rather than a generic "unknown argument."
413
+ process.env.SKILLREPO_ACCESS_KEY = "k";
414
+ assert.throws(
415
+ () => resolveFlags(["--ide", "claude"]),
416
+ (err) =>
417
+ err instanceof CliError &&
418
+ err.exitCode === EXIT_VALIDATION &&
419
+ /--ide was renamed to --agent/.test(err.message),
420
+ );
421
+ });
422
+
306
423
  it("rejects unknown flag without acceptPositional", () => {
307
424
  process.env.SKILLREPO_ACCESS_KEY = "k";
308
425
  assert.throws(
@@ -390,27 +507,94 @@ describe("resolveFlags — positional callback", () => {
390
507
  // ── effectiveVendors ───────────────────────────────────────────────────
391
508
 
392
509
  describe("effectiveVendors", () => {
393
- it("returns ['claudeCode'] by default", () => {
510
+ it("returns ['claudeCode'] by default (no flags)", () => {
394
511
  assert.deepEqual(effectiveVendors({ vendors: null, global: false }), ["claudeCode"]);
395
512
  });
396
513
 
397
- it("returns undefined for global mode", () => {
398
- assert.equal(effectiveVendors({ vendors: null, global: true }), undefined);
514
+ it("returns ['claudeCode'] for bare --global with no --agent", () => {
515
+ // Bare --global preserves the historical default of writing to
516
+ // ~/.claude/skills/. The user can override with `--global --agent X`.
517
+ assert.deepEqual(effectiveVendors({ vendors: null, global: true }), ["claudeCode"]);
399
518
  });
400
519
 
401
- it("global overrides explicit vendors", () => {
402
- assert.equal(
403
- effectiveVendors({ vendors: ["cursor"], global: true }),
404
- undefined,
520
+ it("preserves --agent under --global (does NOT discard the vendor list)", () => {
521
+ // Intent: `--global --agent windsurf` MUST resolve to ["windsurf"]
522
+ // so placementTargetsFor can map it to windsurfGlobal. The old
523
+ // bug was that --global silently dropped the vendor list,
524
+ // routing every --global invocation to claudeGlobal regardless
525
+ // of the user's explicit --agent choice.
526
+ assert.deepEqual(
527
+ effectiveVendors({ vendors: ["windsurf"], global: true }),
528
+ ["windsurf"],
529
+ );
530
+ assert.deepEqual(
531
+ effectiveVendors({ vendors: ["copilot"], global: true }),
532
+ ["copilot"],
533
+ );
534
+ assert.deepEqual(
535
+ effectiveVendors({ vendors: ["claudeCode", "cursor"], global: true }),
536
+ ["claudeCode", "cursor"],
405
537
  );
406
538
  });
407
539
 
408
- it("returns explicit vendors when set", () => {
540
+ it("returns explicit vendors when set (no --global)", () => {
409
541
  assert.deepEqual(
410
542
  effectiveVendors({ vendors: ["claudeCode", "cursor"], global: false }),
411
543
  ["claudeCode", "cursor"],
412
544
  );
413
545
  });
546
+
547
+ it("returns the empty array verbatim for the --agent none sentinel", () => {
548
+ // The empty-array sentinel must NOT be coerced back to the
549
+ // ['claudeCode'] default — that would silently re-target a user
550
+ // who explicitly opted out via `--agent none`.
551
+ const result = effectiveVendors({ vendors: [], global: false });
552
+ assert.ok(Array.isArray(result));
553
+ assert.equal(result.length, 0);
554
+ });
555
+
556
+ it("preserves the --agent none sentinel under --global", () => {
557
+ // `--global --agent none` should still mean "no placement writes."
558
+ // Don't silently re-target.
559
+ const result = effectiveVendors({ vendors: [], global: true });
560
+ assert.ok(Array.isArray(result));
561
+ assert.equal(result.length, 0);
562
+ });
563
+ });
564
+
565
+ // ── requireVendorTargets ───────────────────────────────────────────────
566
+
567
+ describe("requireVendorTargets", () => {
568
+ it("throws validationError when vendors is the empty array", () => {
569
+ assert.throws(
570
+ () => requireVendorTargets([], "add"),
571
+ (err) =>
572
+ err instanceof CliError &&
573
+ err.exitCode === EXIT_VALIDATION &&
574
+ /--agent none has no effect on `skillrepo add`/.test(err.message),
575
+ );
576
+ });
577
+
578
+ it("includes the command name in the error message", () => {
579
+ assert.throws(
580
+ () => requireVendorTargets([], "remove"),
581
+ (err) =>
582
+ err instanceof CliError &&
583
+ /--agent none has no effect on `skillrepo remove`/.test(err.message),
584
+ );
585
+ });
586
+
587
+ it("does not throw when vendors has entries", () => {
588
+ assert.doesNotThrow(() => requireVendorTargets(["claudeCode"], "add"));
589
+ assert.doesNotThrow(() => requireVendorTargets(["cursor", "gemini"], "get"));
590
+ });
591
+
592
+ it("does not throw when vendors is undefined (the --global case)", () => {
593
+ // `effectiveVendors` returns undefined under --global. Only an
594
+ // explicit empty array is rejected — undefined is the legitimate
595
+ // "use the Claude Code personal target" signal.
596
+ assert.doesNotThrow(() => requireVendorTargets(undefined, "add"));
597
+ });
414
598
  });
415
599
 
416
600
  // ── isTransientRunnerInvocation ───────────────────────────────────────────────────
@@ -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
+ });