skillrepo 4.3.0 → 4.5.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 +160 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/init.mjs +60 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/config.mjs +6 -0
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/lib/telemetry.mjs +201 -0
- package/src/test/commands/init.test.mjs +85 -0
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/config.test.mjs +33 -0
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
- package/src/test/lib/telemetry.test.mjs +289 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `src/lib/placement-walk.mjs` (#1555).
|
|
3
|
+
*
|
|
4
|
+
* Sandboxed filesystem tests. Uses `sandbox-home.mjs` (sets BOTH
|
|
5
|
+
* HOME and USERPROFILE) so the walker's `~/.codeium/windsurf/skills/`
|
|
6
|
+
* lookups don't escape the sandbox on Windows.
|
|
7
|
+
*
|
|
8
|
+
* Each test scenario seeds a fake placement directory under the
|
|
9
|
+
* sandbox CWD (for project-scope targets) or sandbox HOME (for
|
|
10
|
+
* global-scope targets), runs the walker, and asserts on the
|
|
11
|
+
* resulting SHAs and structure.
|
|
12
|
+
*
|
|
13
|
+
* The SHAs are NOT pinned to specific hex values — that's
|
|
14
|
+
* `crypto-shas.test.mjs`'s job. Here we assert SHA stability
|
|
15
|
+
* properties (changing content changes the SHA) and structural
|
|
16
|
+
* properties (one entry per skill, multi-vendor cohort expansion).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import {
|
|
22
|
+
mkdirSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
mkdtempSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
chmodSync,
|
|
28
|
+
symlinkSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
|
|
33
|
+
import { walkDetectedPlacements, walkPlacementTarget } from "../../lib/placement-walk.mjs";
|
|
34
|
+
import { computeSkillShas } from "../../lib/crypto-shas.mjs";
|
|
35
|
+
import {
|
|
36
|
+
captureHome,
|
|
37
|
+
setSandboxHome,
|
|
38
|
+
restoreHome,
|
|
39
|
+
} from "../helpers/sandbox-home.mjs";
|
|
40
|
+
|
|
41
|
+
let sandbox;
|
|
42
|
+
let originalCwd;
|
|
43
|
+
/** @type {import("../helpers/sandbox-home.mjs").HomeEnvSnapshot} */
|
|
44
|
+
let originalHomeEnv;
|
|
45
|
+
|
|
46
|
+
function setup() {
|
|
47
|
+
sandbox = mkdtempSync(join(tmpdir(), "placement-walk-"));
|
|
48
|
+
mkdirSync(join(sandbox, "project"), { recursive: true });
|
|
49
|
+
mkdirSync(join(sandbox, "home"), { recursive: true });
|
|
50
|
+
originalCwd = process.cwd();
|
|
51
|
+
originalHomeEnv = captureHome();
|
|
52
|
+
process.chdir(join(sandbox, "project"));
|
|
53
|
+
setSandboxHome(join(sandbox, "home"));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function teardown() {
|
|
57
|
+
process.chdir(originalCwd);
|
|
58
|
+
restoreHome(originalHomeEnv);
|
|
59
|
+
if (sandbox && existsSync(sandbox)) {
|
|
60
|
+
rmSync(sandbox, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Seed a skill directory at the given target's parent. */
|
|
65
|
+
function seedSkill(targetParent, skillName, files) {
|
|
66
|
+
const dir = join(targetParent, skillName);
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const filePath = join(dir, ...file.path.split("/"));
|
|
70
|
+
mkdirSync(join(filePath, ".."), { recursive: true });
|
|
71
|
+
writeFileSync(filePath, file.content, "utf8");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Make a standard SKILL.md content with the canonical frontmatter. */
|
|
76
|
+
function skillMd(name, body = `# ${name}\n`) {
|
|
77
|
+
return `---\nname: ${name}\ndescription: ${name} skill\n---\n\n${body}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── walkPlacementTarget ────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
describe("walkPlacementTarget", () => {
|
|
83
|
+
beforeEach(setup);
|
|
84
|
+
afterEach(teardown);
|
|
85
|
+
|
|
86
|
+
it("returns empty array when the parent dir does not exist", () => {
|
|
87
|
+
// No `.claude/skills/` created — walker must not throw.
|
|
88
|
+
assert.deepEqual(walkPlacementTarget("claudeProject"), []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns empty array for an unknown target (defensive)", () => {
|
|
92
|
+
assert.deepEqual(walkPlacementTarget("notARealTarget"), []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns one entry per skill subdirectory at the target", () => {
|
|
96
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
97
|
+
seedSkill(parent, "pdf-helper", [
|
|
98
|
+
{ path: "SKILL.md", content: skillMd("pdf-helper") },
|
|
99
|
+
]);
|
|
100
|
+
seedSkill(parent, "code-review", [
|
|
101
|
+
{ path: "SKILL.md", content: skillMd("code-review") },
|
|
102
|
+
]);
|
|
103
|
+
const result = walkPlacementTarget("claudeProject");
|
|
104
|
+
assert.equal(result.length, 2);
|
|
105
|
+
const names = result.map((r) => r.skillName).sort();
|
|
106
|
+
assert.deepEqual(names, ["code-review", "pdf-helper"]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("computes SHAs that match crypto-shas on the same content", () => {
|
|
110
|
+
// Cross-check that the disk walker produces the same digests as
|
|
111
|
+
// the in-memory helper that runSync uses. Without this property,
|
|
112
|
+
// a fresh sync would immediately report `edited` on every skill
|
|
113
|
+
// because the persisted SHA would never match the read-back SHA.
|
|
114
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
115
|
+
const content = skillMd("matching-skill");
|
|
116
|
+
seedSkill(parent, "matching-skill", [{ path: "SKILL.md", content }]);
|
|
117
|
+
|
|
118
|
+
const result = walkPlacementTarget("claudeProject");
|
|
119
|
+
assert.equal(result.length, 1);
|
|
120
|
+
|
|
121
|
+
const expected = computeSkillShas([{ path: "SKILL.md", content }]);
|
|
122
|
+
assert.equal(result[0].skillMdSha256, expected.skillMdSha256);
|
|
123
|
+
assert.equal(result[0].filesSha256, expected.filesSha256);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("handles multi-file skills (SKILL.md + references/ + scripts/)", () => {
|
|
127
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
128
|
+
const files = [
|
|
129
|
+
{ path: "SKILL.md", content: skillMd("multi") },
|
|
130
|
+
{ path: "references/notes.md", content: "# Notes\n" },
|
|
131
|
+
{ path: "scripts/run.sh", content: "#!/bin/sh\necho hi\n" },
|
|
132
|
+
];
|
|
133
|
+
seedSkill(parent, "multi", files);
|
|
134
|
+
|
|
135
|
+
const result = walkPlacementTarget("claudeProject");
|
|
136
|
+
assert.equal(result.length, 1);
|
|
137
|
+
|
|
138
|
+
const expected = computeSkillShas(files);
|
|
139
|
+
assert.equal(result[0].skillMdSha256, expected.skillMdSha256);
|
|
140
|
+
assert.equal(result[0].filesSha256, expected.filesSha256);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("skips .tmp/ and .old/ orphan directories", () => {
|
|
144
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
145
|
+
seedSkill(parent, "live-skill", [
|
|
146
|
+
{ path: "SKILL.md", content: skillMd("live-skill") },
|
|
147
|
+
]);
|
|
148
|
+
seedSkill(parent, "crashed.tmp", [
|
|
149
|
+
{ path: "SKILL.md", content: skillMd("crashed") },
|
|
150
|
+
]);
|
|
151
|
+
seedSkill(parent, "rollback.old", [
|
|
152
|
+
{ path: "SKILL.md", content: skillMd("rollback") },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const result = walkPlacementTarget("claudeProject");
|
|
156
|
+
assert.equal(result.length, 1);
|
|
157
|
+
assert.equal(result[0].skillName, "live-skill");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("returns null SHAs (not throw) when a skill dir contains an unreadable file", () => {
|
|
161
|
+
// Permission-denied simulation: write a file, then chmod 000.
|
|
162
|
+
// Skipped on Windows (chmod is a no-op there) and on root (root
|
|
163
|
+
// bypasses permission checks).
|
|
164
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
165
|
+
|
|
166
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
167
|
+
const skillDir = join(parent, "unreadable");
|
|
168
|
+
mkdirSync(skillDir, { recursive: true });
|
|
169
|
+
const filePath = join(skillDir, "SKILL.md");
|
|
170
|
+
writeFileSync(filePath, skillMd("unreadable"), "utf8");
|
|
171
|
+
chmodSync(filePath, 0o000);
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const result = walkPlacementTarget("claudeProject");
|
|
175
|
+
assert.equal(result.length, 1);
|
|
176
|
+
assert.equal(result[0].skillName, "unreadable");
|
|
177
|
+
assert.equal(result[0].skillMdSha256, null);
|
|
178
|
+
assert.equal(result[0].filesSha256, null);
|
|
179
|
+
} finally {
|
|
180
|
+
chmodSync(filePath, 0o644);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("continues past an unreadable sibling and still produces null SHAs (deterministic)", () => {
|
|
185
|
+
// Multi-file regression for the return→continue fix in
|
|
186
|
+
// walkSkillDirRecursive. A skill with one unreadable file +
|
|
187
|
+
// multiple readable siblings used to bail on the unreadable
|
|
188
|
+
// entry and silently drop the remaining files from the hash
|
|
189
|
+
// input. Outcome was still "null SHAs → edited" (correct), but
|
|
190
|
+
// the ordering of readdirSync could affect intermediate state.
|
|
191
|
+
//
|
|
192
|
+
// After the fix: we continue past the unreadable file, the
|
|
193
|
+
// readError flag is set, and the caller's gate at
|
|
194
|
+
// computeShasFromDir discards the partial accum → null SHAs.
|
|
195
|
+
// Behavior is deterministic regardless of readdirSync ordering.
|
|
196
|
+
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
197
|
+
|
|
198
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
199
|
+
const skillDir = join(parent, "partial");
|
|
200
|
+
mkdirSync(join(skillDir, "references"), { recursive: true });
|
|
201
|
+
const skillMdPath = join(skillDir, "SKILL.md");
|
|
202
|
+
const refPath = join(skillDir, "references", "notes.md");
|
|
203
|
+
const lockedPath = join(skillDir, "locked.md");
|
|
204
|
+
|
|
205
|
+
writeFileSync(skillMdPath, skillMd("partial"), "utf8");
|
|
206
|
+
writeFileSync(refPath, "# notes\n", "utf8");
|
|
207
|
+
writeFileSync(lockedPath, "blocked\n", "utf8");
|
|
208
|
+
chmodSync(lockedPath, 0o000);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const result = walkPlacementTarget("claudeProject");
|
|
212
|
+
assert.equal(result.length, 1);
|
|
213
|
+
const partial = result[0];
|
|
214
|
+
assert.equal(partial.skillName, "partial");
|
|
215
|
+
// Null SHAs — the partial-accum gate fired.
|
|
216
|
+
assert.equal(partial.skillMdSha256, null);
|
|
217
|
+
assert.equal(partial.filesSha256, null);
|
|
218
|
+
} finally {
|
|
219
|
+
chmodSync(lockedPath, 0o644);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("skips non-directory entries at the parent root", () => {
|
|
224
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
225
|
+
mkdirSync(parent, { recursive: true });
|
|
226
|
+
// Stray file at the root of the skills/ dir
|
|
227
|
+
writeFileSync(join(parent, "README.txt"), "junk", "utf8");
|
|
228
|
+
seedSkill(parent, "real-skill", [
|
|
229
|
+
{ path: "SKILL.md", content: skillMd("real-skill") },
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
const result = walkPlacementTarget("claudeProject");
|
|
233
|
+
assert.equal(result.length, 1);
|
|
234
|
+
assert.equal(result[0].skillName, "real-skill");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns null SHAs for an empty skill directory", () => {
|
|
238
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
239
|
+
mkdirSync(join(parent, "empty"), { recursive: true });
|
|
240
|
+
|
|
241
|
+
const result = walkPlacementTarget("claudeProject");
|
|
242
|
+
assert.equal(result.length, 1);
|
|
243
|
+
assert.equal(result[0].skillName, "empty");
|
|
244
|
+
// No files read → null SHAs → drift classifier treats as `edited`.
|
|
245
|
+
assert.equal(result[0].skillMdSha256, null);
|
|
246
|
+
assert.equal(result[0].filesSha256, null);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("does NOT crash on a circular symlink inside a skill directory", () => {
|
|
250
|
+
// Stack-overflow regression guard. Previously the walker used
|
|
251
|
+
// statSync (which follows symlinks), so a `references/loop -> .`
|
|
252
|
+
// symlink caused infinite recursion. lstatSync + isSymbolicLink
|
|
253
|
+
// skip avoids the crash. Documented as the spec choice: symlinks
|
|
254
|
+
// are not valid skill content per agentskills.io.
|
|
255
|
+
if (process.platform === "win32") return; // symlink creation requires admin on Windows
|
|
256
|
+
|
|
257
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
258
|
+
const skillDir = join(parent, "with-loop");
|
|
259
|
+
mkdirSync(join(skillDir, "references"), { recursive: true });
|
|
260
|
+
writeFileSync(
|
|
261
|
+
join(skillDir, "SKILL.md"),
|
|
262
|
+
skillMd("with-loop"),
|
|
263
|
+
"utf8",
|
|
264
|
+
);
|
|
265
|
+
writeFileSync(
|
|
266
|
+
join(skillDir, "references", "real.md"),
|
|
267
|
+
"# real\n",
|
|
268
|
+
"utf8",
|
|
269
|
+
);
|
|
270
|
+
// Circular symlink: references/loop points back to the skill root.
|
|
271
|
+
// Pre-fix this would infinite-recurse.
|
|
272
|
+
symlinkSync(skillDir, join(skillDir, "references", "loop"));
|
|
273
|
+
|
|
274
|
+
// Must complete without throwing or hanging.
|
|
275
|
+
const result = walkPlacementTarget("claudeProject");
|
|
276
|
+
assert.equal(result.length, 1);
|
|
277
|
+
assert.equal(result[0].skillName, "with-loop");
|
|
278
|
+
// SHAs are valid (the symlink was skipped, the real files were hashed).
|
|
279
|
+
assert.equal(typeof result[0].skillMdSha256, "string");
|
|
280
|
+
assert.equal(result[0].skillMdSha256.length, 64);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("skips a symlink-to-directory at the placements root (no recursion into target)", () => {
|
|
284
|
+
if (process.platform === "win32") return;
|
|
285
|
+
|
|
286
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
287
|
+
mkdirSync(parent, { recursive: true });
|
|
288
|
+
// A skill placed elsewhere (outside .claude/skills/).
|
|
289
|
+
const outsideDir = join(process.cwd(), "outside");
|
|
290
|
+
mkdirSync(outsideDir, { recursive: true });
|
|
291
|
+
writeFileSync(join(outsideDir, "SKILL.md"), skillMd("outside"), "utf8");
|
|
292
|
+
// Symlink at the placement root pointing OUT of the skills dir.
|
|
293
|
+
// The walker must not treat this as a valid skill.
|
|
294
|
+
symlinkSync(outsideDir, join(parent, "symlinked-skill"));
|
|
295
|
+
// Also seed a real skill so we can verify the walker still
|
|
296
|
+
// enumerates the legitimate one.
|
|
297
|
+
seedSkill(parent, "real-skill", [
|
|
298
|
+
{ path: "SKILL.md", content: skillMd("real-skill") },
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
const result = walkPlacementTarget("claudeProject");
|
|
302
|
+
assert.equal(result.length, 1);
|
|
303
|
+
assert.equal(result[0].skillName, "real-skill");
|
|
304
|
+
// Belt-and-suspenders: explicitly assert the symlinked entry is
|
|
305
|
+
// absent from the result. The length-and-first-name checks above
|
|
306
|
+
// would catch a regression where the symlink was enumerated as
|
|
307
|
+
// a second result, but they don't document the intent as
|
|
308
|
+
// clearly as a direct absence check.
|
|
309
|
+
const names = result.map((r) => r.skillName);
|
|
310
|
+
assert.ok(
|
|
311
|
+
!names.includes("symlinked-skill"),
|
|
312
|
+
"symlinked-skill entry must be absent from the result",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("skips a symlink-to-file inside a skill directory (no content read from outside)", () => {
|
|
317
|
+
if (process.platform === "win32") return;
|
|
318
|
+
|
|
319
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
320
|
+
const skillDir = join(parent, "linkholder");
|
|
321
|
+
mkdirSync(skillDir, { recursive: true });
|
|
322
|
+
writeFileSync(join(skillDir, "SKILL.md"), skillMd("linkholder"), "utf8");
|
|
323
|
+
|
|
324
|
+
// Target file lives outside the skill dir.
|
|
325
|
+
const outsideFile = join(process.cwd(), "outside.txt");
|
|
326
|
+
writeFileSync(outsideFile, "secrets\n", "utf8");
|
|
327
|
+
symlinkSync(outsideFile, join(skillDir, "link.md"));
|
|
328
|
+
|
|
329
|
+
const result = walkPlacementTarget("claudeProject");
|
|
330
|
+
assert.equal(result.length, 1);
|
|
331
|
+
|
|
332
|
+
// The expected hash is computed from SKILL.md alone — the
|
|
333
|
+
// symlink and its target are NOT included in the projection.
|
|
334
|
+
const expected = computeSkillShas([
|
|
335
|
+
{ path: "SKILL.md", content: skillMd("linkholder") },
|
|
336
|
+
]);
|
|
337
|
+
assert.equal(result[0].skillMdSha256, expected.skillMdSha256);
|
|
338
|
+
assert.equal(result[0].filesSha256, expected.filesSha256);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("walks the agentsProject cohort root", () => {
|
|
342
|
+
const parent = join(process.cwd(), ".agents", "skills");
|
|
343
|
+
seedSkill(parent, "cohort-skill", [
|
|
344
|
+
{ path: "SKILL.md", content: skillMd("cohort-skill") },
|
|
345
|
+
]);
|
|
346
|
+
|
|
347
|
+
const result = walkPlacementTarget("agentsProject");
|
|
348
|
+
assert.equal(result.length, 1);
|
|
349
|
+
assert.equal(result[0].skillName, "cohort-skill");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// ── walkDetectedPlacements ──────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
describe("walkDetectedPlacements", () => {
|
|
356
|
+
beforeEach(setup);
|
|
357
|
+
afterEach(teardown);
|
|
358
|
+
|
|
359
|
+
it("returns an empty map when no vendors detected", () => {
|
|
360
|
+
assert.equal(walkDetectedPlacements([]).size, 0);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("returns an empty map for unknown vendor keys", () => {
|
|
364
|
+
assert.equal(walkDetectedPlacements(["not-a-real-vendor"]).size, 0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("returns one entry per (vendor, skill) for a Claude Code skill", () => {
|
|
368
|
+
const parent = join(process.cwd(), ".claude", "skills");
|
|
369
|
+
seedSkill(parent, "claude-only", [
|
|
370
|
+
{ path: "SKILL.md", content: skillMd("claude-only") },
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
const placements = walkDetectedPlacements(["claudeCode"]);
|
|
374
|
+
assert.equal(placements.size, 1);
|
|
375
|
+
assert.ok(placements.has("claudeCode::project::claude-only"));
|
|
376
|
+
const entry = placements.get("claudeCode::project::claude-only");
|
|
377
|
+
assert.equal(entry.vendorKey, "claudeCode");
|
|
378
|
+
assert.equal(entry.scope, "project");
|
|
379
|
+
assert.equal(entry.target, "claudeProject");
|
|
380
|
+
assert.equal(entry.present, true);
|
|
381
|
+
assert.equal(typeof entry.skillMdSha256, "string");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("expands the same .agents/skills/ skill across all detected cohort vendors", () => {
|
|
385
|
+
// Cohort vendors (cursor, windsurf, gemini, codex, cline, copilot)
|
|
386
|
+
// all share `agentsProject`. Walking once produces one disk read;
|
|
387
|
+
// expansion emits one entry per detected vendor so list.mjs can
|
|
388
|
+
// render the per-vendor placement breakdown without re-reading.
|
|
389
|
+
const parent = join(process.cwd(), ".agents", "skills");
|
|
390
|
+
seedSkill(parent, "shared-cohort", [
|
|
391
|
+
{ path: "SKILL.md", content: skillMd("shared-cohort") },
|
|
392
|
+
]);
|
|
393
|
+
|
|
394
|
+
const placements = walkDetectedPlacements(["cursor", "windsurf"]);
|
|
395
|
+
assert.equal(placements.size, 2);
|
|
396
|
+
const cursor = placements.get("cursor::project::shared-cohort");
|
|
397
|
+
const windsurf = placements.get("windsurf::project::shared-cohort");
|
|
398
|
+
assert.ok(cursor);
|
|
399
|
+
assert.ok(windsurf);
|
|
400
|
+
// Identical SHAs — both see the same physical file.
|
|
401
|
+
assert.equal(cursor.skillMdSha256, windsurf.skillMdSha256);
|
|
402
|
+
assert.equal(cursor.filesSha256, windsurf.filesSha256);
|
|
403
|
+
// Different vendor keys, same target.
|
|
404
|
+
assert.equal(cursor.vendorKey, "cursor");
|
|
405
|
+
assert.equal(windsurf.vendorKey, "windsurf");
|
|
406
|
+
assert.equal(cursor.target, "agentsProject");
|
|
407
|
+
assert.equal(windsurf.target, "agentsProject");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("walks each unique target only once (cohort vendors do not multiply reads)", () => {
|
|
411
|
+
// Indirect verification: if the walk read files multiple times,
|
|
412
|
+
// we'd see correct behavior anyway because the operation is
|
|
413
|
+
// idempotent. So we verify a STRUCTURAL property — every cohort
|
|
414
|
+
// vendor in the input that maps to the same target emits one
|
|
415
|
+
// entry per skill, regardless of cohort size.
|
|
416
|
+
const parent = join(process.cwd(), ".agents", "skills");
|
|
417
|
+
seedSkill(parent, "one", [{ path: "SKILL.md", content: skillMd("one") }]);
|
|
418
|
+
seedSkill(parent, "two", [{ path: "SKILL.md", content: skillMd("two") }]);
|
|
419
|
+
|
|
420
|
+
const placements = walkDetectedPlacements(["cursor", "gemini", "codex"]);
|
|
421
|
+
// 3 vendors × 2 skills = 6 entries.
|
|
422
|
+
assert.equal(placements.size, 6);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("walks both Claude Code AND cohort targets when both detected", () => {
|
|
426
|
+
const claudeParent = join(process.cwd(), ".claude", "skills");
|
|
427
|
+
const cohortParent = join(process.cwd(), ".agents", "skills");
|
|
428
|
+
seedSkill(claudeParent, "for-claude", [
|
|
429
|
+
{ path: "SKILL.md", content: skillMd("for-claude") },
|
|
430
|
+
]);
|
|
431
|
+
seedSkill(cohortParent, "for-cohort", [
|
|
432
|
+
{ path: "SKILL.md", content: skillMd("for-cohort") },
|
|
433
|
+
]);
|
|
434
|
+
|
|
435
|
+
const placements = walkDetectedPlacements(["claudeCode", "cursor"]);
|
|
436
|
+
assert.equal(placements.size, 2);
|
|
437
|
+
assert.ok(placements.has("claudeCode::project::for-claude"));
|
|
438
|
+
assert.ok(placements.has("cursor::project::for-cohort"));
|
|
439
|
+
// Critically, claudeCode does NOT see for-cohort and cursor
|
|
440
|
+
// does NOT see for-claude — placement targets are distinct.
|
|
441
|
+
assert.equal(placements.has("claudeCode::project::for-cohort"), false);
|
|
442
|
+
assert.equal(placements.has("cursor::project::for-claude"), false);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("ignores vendors with null projectTarget (e.g. future scope changes)", () => {
|
|
446
|
+
// Defense in depth: every current registry entry has a non-null
|
|
447
|
+
// projectTarget. If a future entry ever has null (e.g. a personal-
|
|
448
|
+
// scope-only vendor), this guard ensures we don't blow up.
|
|
449
|
+
// We can't easily fixture this without registry surgery, but
|
|
450
|
+
// we cover the code path by passing an empty array.
|
|
451
|
+
assert.equal(walkDetectedPlacements([]).size, 0);
|
|
452
|
+
});
|
|
453
|
+
});
|