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
@@ -528,3 +528,160 @@ describe("runSync — orphan cleanup", () => {
528
528
  assert.ok(existsSync(join(root, "recoverable.tmp", "SKILL.md")));
529
529
  });
530
530
  });
531
+
532
+ // ── runSync — cohort placement classification ──────────────────────────
533
+ //
534
+ // Coverage gap from the QA cross-PR review (#1252): the cohort vendors
535
+ // (cursor / windsurf / gemini / codex / cline / copilot) all share the
536
+ // `agentsProject` placement target via the registry. Cross-vendor
537
+ // re-syncs and partial-vendor re-syncs were unverified — these tests
538
+ // lock the `isAnyTargetPresent` resolution that distinguishes added-vs-
539
+ // updated for cohort-only writes, plus tombstone scoping when only
540
+ // part of a multi-vendor original write is asked to be removed.
541
+
542
+ describe("runSync — cohort placement classification", () => {
543
+ beforeEach(setupServer);
544
+ afterEach(teardownServer);
545
+
546
+ it("re-sync with a different cohort vendor classifies as updated, not added", async () => {
547
+ // First sync writes via vendors: ["cursor"] → the cohort target
548
+ // resolves to `agentsProject` → `.agents/skills/<name>/`.
549
+ server.setLibraryResponse({
550
+ skills: [makeSkill("shared")],
551
+ removals: [],
552
+ syncedAt: "2025-01-01T00:00:00Z",
553
+ });
554
+ const first = await runSync({
555
+ serverUrl,
556
+ apiKey: VALID_KEY,
557
+ vendors: ["cursor"],
558
+ });
559
+ assert.equal(first.added, 1);
560
+
561
+ // Confirm the cohort dir was actually written (not a coincidental
562
+ // pass via some other path).
563
+ const cohortDir = resolvePlacementDir("agentsProject", "shared");
564
+ assert.ok(existsSync(join(cohortDir, "SKILL.md")), "cohort dir must exist after first sync");
565
+
566
+ // Clear the .last-sync state so the second sync isn't 304-short-
567
+ // circuited — we want the writeSkillDir path to actually run so
568
+ // isAnyTargetPresent classifies the existing dir as "updated".
569
+ rmSync(globalLastSyncPath(), { force: true });
570
+
571
+ // Second sync via vendors: ["windsurf"]. Both cursor and windsurf
572
+ // resolve to `agentsProject` per the registry, so the existing
573
+ // `.agents/skills/shared/` should be detected as already-on-disk
574
+ // and the summary must report updated:1, added:0.
575
+ server.setLibraryResponse({
576
+ skills: [makeSkill("shared", "v2")],
577
+ removals: [],
578
+ syncedAt: "2025-01-02T00:00:00Z",
579
+ });
580
+ const second = await runSync({
581
+ serverUrl,
582
+ apiKey: VALID_KEY,
583
+ vendors: ["windsurf"],
584
+ });
585
+ assert.equal(
586
+ second.updated,
587
+ 1,
588
+ "cross-cohort vendor re-sync must classify as updated (both resolve to .agents/skills/)",
589
+ );
590
+ assert.equal(second.added, 0, "must NOT classify as added when target dir already exists");
591
+ });
592
+
593
+ it("claudeCode + cohort then claudeCode-only leaves the cohort copy untouched", async () => {
594
+ // First sync writes to BOTH .claude/skills/ and .agents/skills/
595
+ server.setLibraryResponse({
596
+ skills: [makeSkill("dual")],
597
+ removals: [],
598
+ syncedAt: "2025-01-01T00:00:00Z",
599
+ });
600
+ await runSync({
601
+ serverUrl,
602
+ apiKey: VALID_KEY,
603
+ vendors: ["claudeCode", "cursor"],
604
+ });
605
+
606
+ const claudeDir = resolvePlacementDir("claudeProject", "dual");
607
+ const cohortDir = resolvePlacementDir("agentsProject", "dual");
608
+ assert.ok(existsSync(join(claudeDir, "SKILL.md")), "claude dir must exist");
609
+ assert.ok(existsSync(join(cohortDir, "SKILL.md")), "cohort dir must exist");
610
+
611
+ // Capture the cohort SKILL.md mtime + content before the second
612
+ // sync so we can confirm it was NOT touched. Use content rather
613
+ // than mtime because mtime granularity is filesystem-dependent
614
+ // and can be a flake source on fast machines.
615
+ const cohortBefore = readFileSync(join(cohortDir, "SKILL.md"), "utf-8");
616
+
617
+ rmSync(globalLastSyncPath(), { force: true });
618
+
619
+ // Second sync targets ONLY claudeCode. The cohort copy is not
620
+ // re-written and is not removed — vendor-scoped writes don't
621
+ // touch other vendors' targets.
622
+ server.setLibraryResponse({
623
+ skills: [makeSkill("dual", "claude-only-update")],
624
+ removals: [],
625
+ syncedAt: "2025-01-02T00:00:00Z",
626
+ });
627
+ await runSync({
628
+ serverUrl,
629
+ apiKey: VALID_KEY,
630
+ vendors: ["claudeCode"],
631
+ });
632
+
633
+ // Cohort dir content unchanged — it survives the partial-vendor
634
+ // re-sync untouched.
635
+ assert.ok(existsSync(join(cohortDir, "SKILL.md")), "cohort dir must survive partial re-sync");
636
+ const cohortAfter = readFileSync(join(cohortDir, "SKILL.md"), "utf-8");
637
+ assert.equal(cohortAfter, cohortBefore, "cohort SKILL.md must not be rewritten");
638
+
639
+ // Claude dir DID get updated.
640
+ const claudeAfter = readFileSync(join(claudeDir, "SKILL.md"), "utf-8");
641
+ assert.match(claudeAfter, /claude-only-update/);
642
+ });
643
+
644
+ it("tombstone via cohort vendor removes from .agents/skills/ but leaves .claude/skills/", async () => {
645
+ // Pre-write to BOTH targets via a multi-vendor first sync.
646
+ server.setLibraryResponse({
647
+ skills: [makeSkill("doomed")],
648
+ removals: [],
649
+ syncedAt: "2025-01-01T00:00:00Z",
650
+ });
651
+ await runSync({
652
+ serverUrl,
653
+ apiKey: VALID_KEY,
654
+ vendors: ["claudeCode", "cursor"],
655
+ });
656
+
657
+ const claudeDir = resolvePlacementDir("claudeProject", "doomed");
658
+ const cohortDir = resolvePlacementDir("agentsProject", "doomed");
659
+ assert.ok(existsSync(claudeDir));
660
+ assert.ok(existsSync(cohortDir));
661
+
662
+ rmSync(globalLastSyncPath(), { force: true });
663
+
664
+ // Apply a tombstone with vendors: ["cursor"] only. Vendor-scoped
665
+ // tombstones map to the cohort target (.agents/skills/) and must
666
+ // NOT delete the Claude target — only the explicit vendor scope
667
+ // is touched. This locks the "vendor-scoped removal isn't a
668
+ // footgun" behavior.
669
+ server.setLibraryResponse({
670
+ skills: [],
671
+ removals: [{ owner: "alice", name: "doomed", removedAt: "2025-01-02T00:00:00Z" }],
672
+ syncedAt: "2025-01-02T00:00:00Z",
673
+ });
674
+ const result = await runSync({
675
+ serverUrl,
676
+ apiKey: VALID_KEY,
677
+ vendors: ["cursor"],
678
+ });
679
+
680
+ assert.equal(result.removed, 1);
681
+ assert.ok(!existsSync(cohortDir), "cohort dir must be removed by the cursor-scoped tombstone");
682
+ assert.ok(
683
+ existsSync(claudeDir),
684
+ "Claude Code dir must NOT be removed by a cursor-scoped tombstone — vendor scoping protects untargeted dirs",
685
+ );
686
+ });
687
+ });
@@ -1,44 +0,0 @@
1
- /**
2
- * IDE auto-detection.
3
- * Checks for IDE-specific directories/files in the project and home dir.
4
- */
5
-
6
- import { pathExists } from "./fs-utils.mjs";
7
- import * as paths from "./paths.mjs";
8
-
9
- /**
10
- * @typedef {Object} DetectedIdes
11
- * @property {boolean} claudeCode
12
- * @property {boolean} cursor
13
- * @property {boolean} windsurf
14
- * @property {boolean} vscode
15
- */
16
-
17
- /**
18
- * Detect which IDEs are configured in the current project.
19
- * @returns {DetectedIdes}
20
- */
21
- export function detectIdes() {
22
- return {
23
- claudeCode:
24
- pathExists(paths.claudeMcpJson()) ||
25
- pathExists(paths.claudeDir()),
26
- cursor: pathExists(paths.cursorDir()),
27
- windsurf: pathExists(paths.windsurfDir()),
28
- vscode: pathExists(paths.vscodeDir()),
29
- };
30
- }
31
-
32
- /**
33
- * Format detected IDEs as a human-readable list.
34
- * @param {DetectedIdes} detected
35
- * @returns {{ name: string; detected: boolean }[]}
36
- */
37
- export function formatDetectedIdes(detected) {
38
- return [
39
- { name: "Claude Code", key: "claudeCode", detected: detected.claudeCode },
40
- { name: "Cursor", key: "cursor", detected: detected.cursor },
41
- { name: "Windsurf", key: "windsurf", detected: detected.windsurf },
42
- { name: "VS Code + Copilot", key: "vscode", detected: detected.vscode },
43
- ];
44
- }
@@ -1,65 +0,0 @@
1
- import { describe, it, beforeEach, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
4
- import { join } from "node:path";
5
- import { tmpdir } from "node:os";
6
-
7
- let originalCwd;
8
- let tempDir;
9
-
10
- beforeEach(() => {
11
- originalCwd = process.cwd;
12
- tempDir = mkdtempSync(join(tmpdir(), "cli-test-"));
13
- process.cwd = () => tempDir;
14
- });
15
-
16
- afterEach(() => {
17
- process.cwd = originalCwd;
18
- rmSync(tempDir, { recursive: true, force: true });
19
- });
20
-
21
- describe("IDE detection", () => {
22
- it("detects Claude Code from .claude/ directory", async () => {
23
- const { detectIdes } = await import("../lib/detect-ides.mjs");
24
- mkdirSync(join(tempDir, ".claude"));
25
-
26
- const result = detectIdes();
27
- assert.equal(result.claudeCode, true);
28
- });
29
-
30
- it("detects Cursor from .cursor/ directory", async () => {
31
- const { detectIdes } = await import("../lib/detect-ides.mjs");
32
- mkdirSync(join(tempDir, ".cursor"));
33
-
34
- const result = detectIdes();
35
- assert.equal(result.cursor, true);
36
- });
37
-
38
- it("detects VS Code from .vscode/ directory", async () => {
39
- const { detectIdes } = await import("../lib/detect-ides.mjs");
40
- mkdirSync(join(tempDir, ".vscode"));
41
-
42
- const result = detectIdes();
43
- assert.equal(result.vscode, true);
44
- });
45
-
46
- it("returns all false when no IDE directories exist", async () => {
47
- const { detectIdes } = await import("../lib/detect-ides.mjs");
48
-
49
- const result = detectIdes();
50
- assert.equal(result.claudeCode, false);
51
- assert.equal(result.cursor, false);
52
- assert.equal(result.vscode, false);
53
- // windsurf depends on home dir, skip assertion
54
- });
55
-
56
- // PR4 cross-review cleanup: the old v2.0.0 `getDetectedIdeKeys`
57
- // helper with a "default to claudeCode + cursor when nothing
58
- // detected" fallback was dead code — the plan explicitly
59
- // removed the silent fallback in PR3b, and no command called
60
- // this helper. It was exported and had tests asserting the
61
- // v2.0.0 behavior, which contradicted the actual v3.0.0 code
62
- // path. Deleted; init now refuses with a --ide hint when
63
- // nothing is detected (see the "refuses with clear error when
64
- // no IDE detected and no --ide flag" test in init.test.mjs).
65
- });