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.
- package/README.md +137 -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-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- 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/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- 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/removers/agent-hooks.mjs +83 -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 +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -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/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
- package/src/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
|
@@ -148,6 +148,61 @@ describe("validateFilePath", () => {
|
|
|
148
148
|
const err = validateFilePath("%E0%A4%A");
|
|
149
149
|
assert.match(err, /URL encoding/);
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
// QA cross-PR review (#1252): lock the current behavior of the
|
|
153
|
+
// safety pre-check against backslash + Unicode-encoded traversal
|
|
154
|
+
// patterns. validateFilePath uses an ASCII `..` substring check —
|
|
155
|
+
// backslash variants reduce to ASCII-`..` after decode and ARE
|
|
156
|
+
// caught; the full-width-period U+FF0E variant is NOT caught.
|
|
157
|
+
// Both behaviors are intentional:
|
|
158
|
+
// • Catching the backslash forms is correct and we lock it so a
|
|
159
|
+
// future regex tightening doesn't accidentally drop them.
|
|
160
|
+
// • NOT catching the full-width form is a documented limitation
|
|
161
|
+
// of the path-preserving CLI — the registry-side validator at
|
|
162
|
+
// `src/lib/skills/file-validation.ts` is the authoritative
|
|
163
|
+
// security boundary. Locking the gap with this test forces a
|
|
164
|
+
// future author who tries to "fix" this without the depth
|
|
165
|
+
// check or registry validator backing them up to confront the
|
|
166
|
+
// actual invariant.
|
|
167
|
+
it("rejects backslash-style path traversal (..\\\\etc\\\\passwd)", () => {
|
|
168
|
+
// Backslash-style traversal still contains the literal `..`
|
|
169
|
+
// substring, so the existing ASCII check catches it.
|
|
170
|
+
const err = validateFilePath("..\\etc\\passwd");
|
|
171
|
+
assert.match(err, /traversal/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("rejects URL-encoded backslash path traversal (..%5Cetc%5Cpasswd)", () => {
|
|
175
|
+
// After decodeURIComponent, this becomes `..\etc\passwd`. The
|
|
176
|
+
// ASCII `..` substring check still fires — locking that this
|
|
177
|
+
// wrapping doesn't bypass the check.
|
|
178
|
+
const err = validateFilePath("..%5Cetc%5Cpasswd");
|
|
179
|
+
assert.match(err, /traversal/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does NOT catch full-width period Unicode traversal (../etc/passwd) — known gap", () => {
|
|
183
|
+
// U+FF0E (FULLWIDTH FULL STOP) is visually identical to the
|
|
184
|
+
// ASCII period but is a different codepoint. The ASCII `..`
|
|
185
|
+
// substring check does NOT match this sequence. The CLI's
|
|
186
|
+
// validateFilePath returns null (accepts the path) because:
|
|
187
|
+
// • The substring check fires only on ASCII `..`.
|
|
188
|
+
// • There is no traversal at the resolved-path level — `..`
|
|
189
|
+
// is just a normal directory name on disk.
|
|
190
|
+
// • Depth here is 2 segments, well under MAX_PATH_DEPTH=5.
|
|
191
|
+
//
|
|
192
|
+
// The registry-side validator at
|
|
193
|
+
// `src/lib/skills/file-validation.ts` MUST also reject paths with
|
|
194
|
+
// homoglyph-style traversal attempts. The CLI is path-preserving
|
|
195
|
+
// and is NOT the security boundary; the registry is. This test
|
|
196
|
+
// pins the current behavior and exists as a forcing function:
|
|
197
|
+
// any future contributor who reads it and assumes the CLI is the
|
|
198
|
+
// authority should be redirected to the server-side validator.
|
|
199
|
+
const result = validateFilePath("../etc/passwd");
|
|
200
|
+
assert.equal(
|
|
201
|
+
result,
|
|
202
|
+
null,
|
|
203
|
+
"Locking known limitation: the CLI's ASCII substring check does not catch full-width-period traversal. The server-side validator is the authoritative security boundary. See file-validation.ts.",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
151
206
|
});
|
|
152
207
|
|
|
153
208
|
// ── isValidSkillName ────────────────────────────────────────────────────
|
|
@@ -220,31 +275,34 @@ describe("placementTargetsFor", () => {
|
|
|
220
275
|
beforeEach(setupSandbox);
|
|
221
276
|
afterEach(teardownSandbox);
|
|
222
277
|
|
|
223
|
-
it("returns [claudeGlobal] for --global", () => {
|
|
224
|
-
assert.deepEqual(
|
|
278
|
+
it("returns [claudeGlobal] for --global claudeCode", () => {
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
placementTargetsFor({ global: true, vendors: ["claudeCode"] }),
|
|
281
|
+
["claudeGlobal"],
|
|
282
|
+
);
|
|
225
283
|
});
|
|
226
284
|
|
|
227
285
|
it("returns [claudeProject] for vendor=claudeCode only", () => {
|
|
228
286
|
assert.deepEqual(placementTargetsFor({ vendors: ["claudeCode"] }), ["claudeProject"]);
|
|
229
287
|
});
|
|
230
288
|
|
|
231
|
-
it("returns [
|
|
232
|
-
assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["
|
|
289
|
+
it("returns [agentsProject] for vendor=cursor only", () => {
|
|
290
|
+
assert.deepEqual(placementTargetsFor({ vendors: ["cursor"] }), ["agentsProject"]);
|
|
233
291
|
});
|
|
234
292
|
|
|
235
|
-
it("returns [claudeProject,
|
|
293
|
+
it("returns [claudeProject, agentsProject] for both", () => {
|
|
236
294
|
assert.deepEqual(
|
|
237
295
|
placementTargetsFor({ vendors: ["claudeCode", "cursor"] }),
|
|
238
|
-
["claudeProject", "
|
|
296
|
+
["claudeProject", "agentsProject"],
|
|
239
297
|
);
|
|
240
298
|
});
|
|
241
299
|
|
|
242
|
-
it("dedupes the
|
|
300
|
+
it("dedupes the agents target when multiple cohort vendors are present", () => {
|
|
243
301
|
const targets = placementTargetsFor({
|
|
244
|
-
vendors: ["cursor", "windsurf", "
|
|
302
|
+
vendors: ["cursor", "windsurf", "copilot"],
|
|
245
303
|
});
|
|
246
|
-
// Only one
|
|
247
|
-
assert.equal(targets.filter((t) => t === "
|
|
304
|
+
// Only one agentsProject — not three
|
|
305
|
+
assert.equal(targets.filter((t) => t === "agentsProject").length, 1);
|
|
248
306
|
});
|
|
249
307
|
|
|
250
308
|
it("throws on empty vendors without --global", () => {
|
|
@@ -282,9 +340,9 @@ describe("resolvePlacementDir", () => {
|
|
|
282
340
|
);
|
|
283
341
|
});
|
|
284
342
|
|
|
285
|
-
it("resolves
|
|
286
|
-
const dir = resolvePlacementDir("
|
|
287
|
-
assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
|
|
343
|
+
it("resolves agentsProject under cwd/.agents/skills/<name>", () => {
|
|
344
|
+
const dir = resolvePlacementDir("agentsProject", "pdf-helper");
|
|
345
|
+
assert.equal(dir, join(process.cwd(), ".agents", "skills", "pdf-helper"));
|
|
288
346
|
});
|
|
289
347
|
|
|
290
348
|
it("throws on unknown target", () => {
|
|
@@ -455,35 +513,39 @@ describe("writeSkillDir — happy path", () => {
|
|
|
455
513
|
}
|
|
456
514
|
});
|
|
457
515
|
|
|
458
|
-
it("writes to global home dir with --global", () => {
|
|
516
|
+
it("writes to global home dir with --global --agent claudeCode", () => {
|
|
459
517
|
const skill = minimalSkill();
|
|
460
|
-
const result = writeSkillDir(skill, {
|
|
518
|
+
const result = writeSkillDir(skill, {
|
|
519
|
+
global: true,
|
|
520
|
+
vendors: ["claudeCode"],
|
|
521
|
+
});
|
|
461
522
|
assert.equal(result.written.length, 1);
|
|
462
523
|
assert.ok(result.written[0].startsWith(process.env.HOME));
|
|
463
524
|
});
|
|
464
525
|
|
|
465
|
-
it("
|
|
526
|
+
it("writes to .agents/skills/ when only a cohort vendor is requested", () => {
|
|
466
527
|
const skill = minimalSkill();
|
|
467
528
|
const result = writeSkillDir(skill, { vendors: ["cursor"] });
|
|
468
529
|
assert.equal(result.written.length, 1);
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
// gitignore
|
|
474
|
-
// forward slashes
|
|
530
|
+
assert.ok(
|
|
531
|
+
result.written[0].includes(join(".agents", "skills", "pdf-helper")),
|
|
532
|
+
`expected dir under .agents/skills/, got ${result.written[0]}`,
|
|
533
|
+
);
|
|
534
|
+
// .gitignore should now contain `.agents/skills/` — the gitignore
|
|
535
|
+
// pattern uses forward slashes on every platform per git's
|
|
536
|
+
// conventions.
|
|
475
537
|
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
476
|
-
assert.match(gi,
|
|
538
|
+
assert.match(gi, /\.agents\/skills\//);
|
|
477
539
|
assert.match(gi, /SkillRepo/);
|
|
478
540
|
});
|
|
479
541
|
|
|
480
|
-
it("idempotent .gitignore management — does not double-add /skills/", () => {
|
|
542
|
+
it("idempotent .gitignore management — does not double-add .agents/skills/", () => {
|
|
481
543
|
const skill = minimalSkill();
|
|
482
544
|
writeSkillDir(skill, { vendors: ["cursor"] });
|
|
483
545
|
writeSkillDir(skill, { vendors: ["cursor"] });
|
|
484
546
|
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
485
|
-
const matches = gi.match(
|
|
486
|
-
assert.equal(matches.length, 1, ".gitignore should have exactly one /skills/ entry");
|
|
547
|
+
const matches = gi.match(/\.agents\/skills\//g) || [];
|
|
548
|
+
assert.equal(matches.length, 1, ".gitignore should have exactly one .agents/skills/ entry");
|
|
487
549
|
});
|
|
488
550
|
|
|
489
551
|
it("appends to an existing .gitignore without clobbering", () => {
|
|
@@ -492,7 +554,7 @@ describe("writeSkillDir — happy path", () => {
|
|
|
492
554
|
const gi = readFileSync(join(process.cwd(), ".gitignore"), "utf-8");
|
|
493
555
|
assert.match(gi, /node_modules/);
|
|
494
556
|
assert.match(gi, /\.env/);
|
|
495
|
-
assert.match(gi,
|
|
557
|
+
assert.match(gi, /\.agents\/skills\//);
|
|
496
558
|
});
|
|
497
559
|
});
|
|
498
560
|
|
|
@@ -601,6 +663,95 @@ describe("writeSkillDir — update path", () => {
|
|
|
601
663
|
assert.ok(existsSync(join(result.written[0], "SKILL.md")));
|
|
602
664
|
assert.ok(!existsSync(`${targetDir}.tmp`));
|
|
603
665
|
});
|
|
666
|
+
|
|
667
|
+
// QA cross-PR review (#1252): the existing tests cover stale `.tmp/`
|
|
668
|
+
// recovery (a previous crash before the rename dance completed) and
|
|
669
|
+
// the `.tmp/`-occupied-by-a-file edge case, but NOT the symmetric
|
|
670
|
+
// case where `.old/` is left behind by a previous crash. The .old/
|
|
671
|
+
// dir is created in step 2 of the rename dance and removed in
|
|
672
|
+
// step 4 — a crash between the two leaves it stranded. The next
|
|
673
|
+
// writeSkillDir call detects-and-cleans it via the
|
|
674
|
+
// `if (existsSync(oldDir))` branch in writeSkillToDir.
|
|
675
|
+
//
|
|
676
|
+
// Skipping on Windows: the POSIX-only rename dance is the code path
|
|
677
|
+
// that exposes the stale-`.old/` branch. Windows uses a different
|
|
678
|
+
// `rmSync(targetDir) → renameSync(tmpDir, targetDir)` flow with no
|
|
679
|
+
// intermediate `.old/` step at all — there's nothing equivalent to
|
|
680
|
+
// verify.
|
|
681
|
+
it("succeeds when a stale .old/ from a prior crash is present", { skip: process.platform === "win32" }, () => {
|
|
682
|
+
// Pre-create the live target with v1 content.
|
|
683
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
684
|
+
|
|
685
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
686
|
+
const oldDir = `${targetDir}.old`;
|
|
687
|
+
|
|
688
|
+
// Simulate a previous crash: a stale `.old/` survives next to the
|
|
689
|
+
// live target. The next write must detect-and-clean it before
|
|
690
|
+
// staging the current target into place. The crash artifact is a
|
|
691
|
+
// directory with a marker file we'll confirm is gone after the
|
|
692
|
+
// write; if the cleanup branch were skipped, the stale `.old/`
|
|
693
|
+
// would block the renameSync(targetDir, oldDir) step (POSIX
|
|
694
|
+
// rename onto an existing directory fails with ENOTEMPTY).
|
|
695
|
+
mkdirSync(oldDir, { recursive: true });
|
|
696
|
+
writeFileSync(join(oldDir, "ghost.txt"), "leftover from a prior crash");
|
|
697
|
+
|
|
698
|
+
// The actual write — should clean the stale `.old/` and complete.
|
|
699
|
+
const updated = minimalSkill({
|
|
700
|
+
files: [
|
|
701
|
+
{
|
|
702
|
+
path: "SKILL.md",
|
|
703
|
+
content: "---\nname: pdf-helper\ndescription: After recovery.\n---\n\nv2.\n",
|
|
704
|
+
},
|
|
705
|
+
],
|
|
706
|
+
});
|
|
707
|
+
const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
|
|
708
|
+
|
|
709
|
+
// The new content is at the live target.
|
|
710
|
+
const skillMd = readFileSync(join(result.written[0], "SKILL.md"), "utf-8");
|
|
711
|
+
assert.match(skillMd, /v2/);
|
|
712
|
+
|
|
713
|
+
// The stale `.old/` is gone — including the ghost file marker.
|
|
714
|
+
assert.ok(!existsSync(oldDir), "stale .old/ must be cleaned during the next write");
|
|
715
|
+
// Step 4 of the rename dance also cleans the post-rename .old/,
|
|
716
|
+
// so the post-state is "no .old/ at all" regardless of which
|
|
717
|
+
// branch fired. We assert the absence; the marker-file path
|
|
718
|
+
// proves the cleanup was the pre-flight branch (a no-op cleanup
|
|
719
|
+
// would have left ghost.txt in place because the live target's
|
|
720
|
+
// own .old/ is fresh content from THIS write, not the marker).
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("succeeds when both stale .tmp/ AND stale .old/ from a prior crash are present", { skip: process.platform === "win32" }, () => {
|
|
724
|
+
// Compound recovery scenario: a prior write crashed mid-dance,
|
|
725
|
+
// leaving BOTH a stale .tmp/ (from step 1, populating new files)
|
|
726
|
+
// AND a stale .old/ (from step 2, after the live target was
|
|
727
|
+
// moved aside but step 3's rename never landed). The next write
|
|
728
|
+
// must clean both before proceeding.
|
|
729
|
+
writeSkillDir(minimalSkill(), { vendors: ["claudeCode"] });
|
|
730
|
+
|
|
731
|
+
const targetDir = resolvePlacementDir("claudeProject", "pdf-helper");
|
|
732
|
+
const tmpDir = `${targetDir}.tmp`;
|
|
733
|
+
const oldDir = `${targetDir}.old`;
|
|
734
|
+
|
|
735
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
736
|
+
writeFileSync(join(tmpDir, "tmp-leftover.txt"), "from a crashed step 1");
|
|
737
|
+
mkdirSync(oldDir, { recursive: true });
|
|
738
|
+
writeFileSync(join(oldDir, "old-leftover.txt"), "from a crashed step 2");
|
|
739
|
+
|
|
740
|
+
const updated = minimalSkill({
|
|
741
|
+
files: [
|
|
742
|
+
{
|
|
743
|
+
path: "SKILL.md",
|
|
744
|
+
content: "---\nname: pdf-helper\ndescription: Recovered.\n---\n\nv3.\n",
|
|
745
|
+
},
|
|
746
|
+
],
|
|
747
|
+
});
|
|
748
|
+
const result = writeSkillDir(updated, { vendors: ["claudeCode"] });
|
|
749
|
+
|
|
750
|
+
const skillMd = readFileSync(join(result.written[0], "SKILL.md"), "utf-8");
|
|
751
|
+
assert.match(skillMd, /v3/);
|
|
752
|
+
assert.ok(!existsSync(tmpDir), "stale .tmp/ must be cleaned");
|
|
753
|
+
assert.ok(!existsSync(oldDir), "stale .old/ must be cleaned");
|
|
754
|
+
});
|
|
604
755
|
});
|
|
605
756
|
|
|
606
757
|
describe("writeSkillDir — return shape", () => {
|
|
@@ -770,14 +921,14 @@ describe("cleanupOrphans", () => {
|
|
|
770
921
|
// Drop one orphan in each root we know about. .tmp/ entries need
|
|
771
922
|
// a live sibling so the safety invariant doesn't preserve them.
|
|
772
923
|
const claudeRoot = join(process.cwd(), ".claude", "skills");
|
|
773
|
-
const
|
|
924
|
+
const agentsRoot = join(process.cwd(), ".agents", "skills");
|
|
774
925
|
const globalRoot = join(process.env.HOME, ".claude", "skills");
|
|
775
926
|
mkdirSync(claudeRoot, { recursive: true });
|
|
776
|
-
mkdirSync(
|
|
927
|
+
mkdirSync(agentsRoot, { recursive: true });
|
|
777
928
|
mkdirSync(globalRoot, { recursive: true });
|
|
778
929
|
mkdirSync(join(claudeRoot, "ghost")); // live sibling for the .tmp below
|
|
779
930
|
mkdirSync(join(claudeRoot, "ghost.tmp"));
|
|
780
|
-
mkdirSync(join(
|
|
931
|
+
mkdirSync(join(agentsRoot, "ghost.old")); // .old has no invariant — always cleaned
|
|
781
932
|
mkdirSync(join(globalRoot, "ghost")); // live sibling for the .tmp below
|
|
782
933
|
mkdirSync(join(globalRoot, "ghost.tmp"));
|
|
783
934
|
|
|
@@ -795,4 +946,54 @@ describe("cleanupOrphans", () => {
|
|
|
795
946
|
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
796
947
|
assert.deepEqual(result.cleaned, []);
|
|
797
948
|
});
|
|
949
|
+
|
|
950
|
+
// QA cross-PR review (#1252): the permutation where BOTH .tmp/ and
|
|
951
|
+
// .old/ exist but the live target is MISSING was uncovered. This
|
|
952
|
+
// tests the divergence between the two recovery invariants:
|
|
953
|
+
// • .tmp/ — the user's only recovered copy of a crashed-mid-write
|
|
954
|
+
// skill when live target is missing → MUST be preserved
|
|
955
|
+
// (invariant #2 in the cleanupOrphans header docstring).
|
|
956
|
+
// • .old/ — transient state of the rename dance with no recovery
|
|
957
|
+
// value when the live target is missing AND a .tmp/ exists
|
|
958
|
+
// (the .tmp/ IS the recovery) → must be cleaned.
|
|
959
|
+
//
|
|
960
|
+
// If this test fails because cleanupOrphans cleans .tmp/ when
|
|
961
|
+
// live target is missing, that's a real regression of invariant #2
|
|
962
|
+
// — DO NOT change the test to match wrong behavior.
|
|
963
|
+
it("preserves .tmp/ but cleans .old/ when live target is missing", () => {
|
|
964
|
+
const root = join(process.cwd(), ".claude", "skills");
|
|
965
|
+
mkdirSync(root, { recursive: true });
|
|
966
|
+
|
|
967
|
+
// Stage the permutation: <name>.tmp/ + <name>.old/ + NO live <name>/
|
|
968
|
+
const tmpDir = join(root, "ghost.tmp");
|
|
969
|
+
const oldDir = join(root, "ghost.old");
|
|
970
|
+
mkdirSync(tmpDir);
|
|
971
|
+
writeFileSync(join(tmpDir, "tmp-content.txt"), "user's only copy of a crashed-mid-write skill");
|
|
972
|
+
mkdirSync(oldDir);
|
|
973
|
+
writeFileSync(join(oldDir, "old-content.txt"), "transient state with no recovery value");
|
|
974
|
+
|
|
975
|
+
// Live target is intentionally absent
|
|
976
|
+
assert.ok(!existsSync(join(root, "ghost")), "precondition: no live target");
|
|
977
|
+
|
|
978
|
+
const result = cleanupOrphans({ vendors: ["claudeCode"] });
|
|
979
|
+
|
|
980
|
+
// .tmp/ is preserved (recovery invariant)
|
|
981
|
+
assert.ok(
|
|
982
|
+
existsSync(tmpDir),
|
|
983
|
+
".tmp/ must be preserved when live target is missing — it is the user's only copy",
|
|
984
|
+
);
|
|
985
|
+
assert.ok(
|
|
986
|
+
existsSync(join(tmpDir, "tmp-content.txt")),
|
|
987
|
+
"the .tmp/ contents must survive intact",
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
// .old/ is cleaned (no recovery value because the .tmp/ IS the
|
|
991
|
+
// recovery; .old/ would be the pre-replacement live target,
|
|
992
|
+
// which is precisely what's missing here)
|
|
993
|
+
assert.ok(!existsSync(oldDir), ".old/ must be cleaned regardless of live-target presence");
|
|
994
|
+
|
|
995
|
+
// The summary reports exactly the .old/ cleanup
|
|
996
|
+
assert.equal(result.cleaned.length, 1, "exactly one orphan cleaned (.old/, not .tmp/)");
|
|
997
|
+
assert.match(result.cleaned[0], /ghost\.old$/);
|
|
998
|
+
});
|
|
798
999
|
});
|
|
@@ -19,7 +19,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "nod
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { tmpdir } from "node:os";
|
|
21
21
|
|
|
22
|
-
import { mergeMcpForVendors
|
|
22
|
+
import { mergeMcpForVendors } from "../../lib/mcp-merge.mjs";
|
|
23
23
|
import { createCaptureStream } from "../helpers/capture-stream.mjs";
|
|
24
24
|
import {
|
|
25
25
|
captureHome,
|
|
@@ -302,7 +302,8 @@ describe("mergeMcpForVendors — failure handling", () => {
|
|
|
302
302
|
});
|
|
303
303
|
|
|
304
304
|
it("dedupes duplicate vendors (round-1 review fix)", async () => {
|
|
305
|
-
// --
|
|
305
|
+
// --agent claude,claude used to run the merger twice. Now dedupes
|
|
306
|
+
// both at parse time and inside mergeMcpForVendors as defense-in-depth.
|
|
306
307
|
const results = await mergeMcpForVendors({
|
|
307
308
|
vendors: ["claudeCode", "claudeCode", "cursor", "cursor"],
|
|
308
309
|
mcpUrl: "https://x.com/mcp",
|
|
@@ -313,6 +314,26 @@ describe("mergeMcpForVendors — failure handling", () => {
|
|
|
313
314
|
assert.equal(results[0].vendor, "claudeCode");
|
|
314
315
|
assert.equal(results[1].vendor, "cursor");
|
|
315
316
|
});
|
|
317
|
+
|
|
318
|
+
it("silently skips registry vendors with hasMcp:false (gemini, codex, cline)", async () => {
|
|
319
|
+
// Cohort vendors without a documented MCP merger must NOT report
|
|
320
|
+
// 'failed' — they're a deliberate registry classification.
|
|
321
|
+
const results = await mergeMcpForVendors({
|
|
322
|
+
vendors: ["claudeCode", "gemini", "codex", "cline", "cursor"],
|
|
323
|
+
mcpUrl: "https://x.com/mcp",
|
|
324
|
+
yes: true,
|
|
325
|
+
io: { stdout, stderr },
|
|
326
|
+
});
|
|
327
|
+
const reportedKeys = results.map((r) => r.vendor);
|
|
328
|
+
assert.deepEqual(
|
|
329
|
+
reportedKeys,
|
|
330
|
+
["claudeCode", "cursor"],
|
|
331
|
+
"only the two MCP-supported vendors should appear in results",
|
|
332
|
+
);
|
|
333
|
+
for (const r of results) {
|
|
334
|
+
assert.equal(r.outcome, "merged", `${r.vendor} should be merged, got ${r.outcome}`);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
316
337
|
});
|
|
317
338
|
|
|
318
339
|
// ── mergeMcpForVendors — input validation ──────────────────────────────
|
|
@@ -336,16 +357,3 @@ describe("mergeMcpForVendors — input validation", () => {
|
|
|
336
357
|
});
|
|
337
358
|
});
|
|
338
359
|
|
|
339
|
-
// ── printManualMcpInstructions ─────────────────────────────────────────
|
|
340
|
-
|
|
341
|
-
describe("printManualMcpInstructions", () => {
|
|
342
|
-
it("prints a copy-pasteable MCP config blob", () => {
|
|
343
|
-
const out = createCaptureStream();
|
|
344
|
-
printManualMcpInstructions("https://skillrepo.dev/api/mcp", { stdout: out });
|
|
345
|
-
const text = out.text();
|
|
346
|
-
assert.match(text, /mcpServers/);
|
|
347
|
-
assert.match(text, /skillrepo/);
|
|
348
|
-
assert.match(text, /https:\/\/skillrepo\.dev\/api\/mcp/);
|
|
349
|
-
assert.match(text, /SKILLREPO_ACCESS_KEY/);
|
|
350
|
-
});
|
|
351
|
-
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for the
|
|
2
|
+
* Unit tests for the skill placement path resolvers in paths.mjs.
|
|
3
3
|
*
|
|
4
4
|
* The pre-existing exports (claudeMcpJson, cursorMcpJson, etc.) are
|
|
5
5
|
* exercised indirectly by the existing mergers tests; this file
|
|
6
|
-
* focuses on the
|
|
6
|
+
* focuses on the skill placement + gitignore exports.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
@@ -15,10 +15,14 @@ import { tmpdir, homedir } from "node:os";
|
|
|
15
15
|
import {
|
|
16
16
|
claudeSkillsProject,
|
|
17
17
|
claudeSkillsGlobal,
|
|
18
|
-
projectSkillsFallback,
|
|
19
|
-
projectSkillsFallbackRoot,
|
|
20
18
|
claudeSkillsProjectRoot,
|
|
21
19
|
claudeSkillsGlobalRoot,
|
|
20
|
+
agentsSkillsProject,
|
|
21
|
+
agentsSkillsProjectRoot,
|
|
22
|
+
agentsSkillsGlobal,
|
|
23
|
+
agentsSkillsGlobalRoot,
|
|
24
|
+
windsurfSkillsGlobal,
|
|
25
|
+
windsurfSkillsGlobalRoot,
|
|
22
26
|
gitignorePath,
|
|
23
27
|
} from "../../lib/paths.mjs";
|
|
24
28
|
import {
|
|
@@ -59,28 +63,60 @@ describe("paths.mjs — skill placement targets", () => {
|
|
|
59
63
|
|
|
60
64
|
it("claudeSkillsGlobal is under HOME/.claude/skills/<name>", () => {
|
|
61
65
|
const dir = claudeSkillsGlobal("pdf-helper");
|
|
62
|
-
// homedir() reads HOME on POSIX so respects our sandbox
|
|
63
66
|
assert.equal(dir, join(homedir(), ".claude", "skills", "pdf-helper"));
|
|
64
67
|
});
|
|
65
68
|
|
|
66
|
-
it("projectSkillsFallback is under cwd/skills/<name>", () => {
|
|
67
|
-
const dir = projectSkillsFallback("pdf-helper");
|
|
68
|
-
assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
69
|
it("claudeSkillsProjectRoot is the parent of project-local skills", () => {
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
assert.equal(
|
|
71
|
+
claudeSkillsProjectRoot(),
|
|
72
|
+
join(process.cwd(), ".claude", "skills"),
|
|
73
|
+
);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
it("claudeSkillsGlobalRoot is the parent of personal skills", () => {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
assert.equal(claudeSkillsGlobalRoot(), join(homedir(), ".claude", "skills"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("agentsSkillsProject is under cwd/.agents/skills/<name>", () => {
|
|
81
|
+
assert.equal(
|
|
82
|
+
agentsSkillsProject("pdf-helper"),
|
|
83
|
+
join(process.cwd(), ".agents", "skills", "pdf-helper"),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("agentsSkillsProjectRoot is the parent of cohort skills", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
agentsSkillsProjectRoot(),
|
|
90
|
+
join(process.cwd(), ".agents", "skills"),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("agentsSkillsGlobal is under HOME/.agents/skills/<name>", () => {
|
|
95
|
+
assert.equal(
|
|
96
|
+
agentsSkillsGlobal("pdf-helper"),
|
|
97
|
+
join(homedir(), ".agents", "skills", "pdf-helper"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("agentsSkillsGlobalRoot is the parent of personal cohort skills", () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
agentsSkillsGlobalRoot(),
|
|
104
|
+
join(homedir(), ".agents", "skills"),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("windsurfSkillsGlobal is under HOME/.codeium/windsurf/skills/<name>", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
windsurfSkillsGlobal("pdf-helper"),
|
|
111
|
+
join(homedir(), ".codeium", "windsurf", "skills", "pdf-helper"),
|
|
112
|
+
);
|
|
79
113
|
});
|
|
80
114
|
|
|
81
|
-
it("
|
|
82
|
-
|
|
83
|
-
|
|
115
|
+
it("windsurfSkillsGlobalRoot is the parent of Windsurf personal skills", () => {
|
|
116
|
+
assert.equal(
|
|
117
|
+
windsurfSkillsGlobalRoot(),
|
|
118
|
+
join(homedir(), ".codeium", "windsurf", "skills"),
|
|
119
|
+
);
|
|
84
120
|
});
|
|
85
121
|
|
|
86
122
|
it("gitignorePath is at cwd/.gitignore", () => {
|