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.
@@ -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
+ });