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
|
@@ -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
|
+
});
|
package/src/lib/detect-ides.mjs
DELETED
|
@@ -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
|
-
});
|