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.
- package/README.md +90 -27
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +435 -111
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +1 -1
- package/src/commands/update.mjs +15 -3
- package/src/lib/agent-registry.mjs +215 -0
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +228 -42
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +13 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/sync.test.mjs +157 -0
- package/src/lib/detect-ides.mjs +0 -44
- 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
|
-
*
|
|
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 {
|
|
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 --
|
|
246
|
+
it("parses --agent claudeCode", () => {
|
|
240
247
|
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
241
|
-
const flags = resolveFlags(["--
|
|
248
|
+
const flags = resolveFlags(["--agent", "claudeCode"]);
|
|
242
249
|
assert.deepEqual(flags.vendors, ["claudeCode"]);
|
|
243
250
|
});
|
|
244
251
|
|
|
245
|
-
it("parses --
|
|
252
|
+
it("parses --agent claude (canonical target shortcut) as claudeCode", () => {
|
|
246
253
|
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
247
|
-
const flags = resolveFlags(["--
|
|
254
|
+
const flags = resolveFlags(["--agent", "claude"]);
|
|
248
255
|
assert.deepEqual(flags.vendors, ["claudeCode"]);
|
|
249
256
|
});
|
|
250
257
|
|
|
251
|
-
it("parses --
|
|
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(["--
|
|
254
|
-
assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "
|
|
282
|
+
const flags = resolveFlags(["--agent", "claude,cursor,gemini"]);
|
|
283
|
+
assert.deepEqual(flags.vendors, ["claudeCode", "cursor", "gemini"]);
|
|
255
284
|
});
|
|
256
285
|
|
|
257
|
-
it("parses --
|
|
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(["--
|
|
260
|
-
assert.deepEqual(flags.vendors, ["
|
|
292
|
+
const flags = resolveFlags(["--agent", "cursor"]);
|
|
293
|
+
assert.deepEqual(flags.vendors, ["cursor"]);
|
|
261
294
|
});
|
|
262
295
|
|
|
263
|
-
it("
|
|
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(["--
|
|
267
|
-
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION
|
|
345
|
+
() => resolveFlags(["--agent", "none,claude"]),
|
|
346
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
268
347
|
);
|
|
269
348
|
});
|
|
270
349
|
|
|
271
|
-
it("rejects --
|
|
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(["--
|
|
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("
|
|
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
|
-
|
|
287
|
-
|
|
375
|
+
assert.throws(
|
|
376
|
+
() => resolveFlags(["--agent", "jetbrains"]),
|
|
377
|
+
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
378
|
+
);
|
|
288
379
|
});
|
|
289
380
|
|
|
290
|
-
it("rejects
|
|
381
|
+
it("rejects empty --agent list (only commas)", () => {
|
|
291
382
|
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
292
383
|
assert.throws(
|
|
293
|
-
() => resolveFlags(["--
|
|
384
|
+
() => resolveFlags(["--agent", ","]),
|
|
294
385
|
(err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
|
|
295
386
|
);
|
|
296
387
|
});
|
|
297
388
|
|
|
298
|
-
it("rejects empty
|
|
389
|
+
it("rejects --agent with empty value", () => {
|
|
299
390
|
process.env.SKILLREPO_ACCESS_KEY = "k";
|
|
300
391
|
assert.throws(
|
|
301
|
-
() => resolveFlags(["--
|
|
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
|
|
398
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
+
});
|