skillrepo 4.8.0 → 4.8.2

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 (43) hide show
  1. package/bin/skillrepo.mjs +6 -3
  2. package/package.json +1 -1
  3. package/src/commands/add.mjs +2 -1
  4. package/src/commands/init.mjs +15 -5
  5. package/src/lib/http.mjs +25 -11
  6. package/src/test/commands/add.test.mjs +79 -2
  7. package/src/test/commands/get.test.mjs +131 -2
  8. package/src/test/commands/init-session-sync.test.mjs +724 -0
  9. package/src/test/commands/init.test.mjs +159 -2
  10. package/src/test/commands/list.test.mjs +573 -1
  11. package/src/test/commands/publish.test.mjs +136 -3
  12. package/src/test/commands/push.test.mjs +280 -1
  13. package/src/test/commands/remove.test.mjs +221 -2
  14. package/src/test/commands/search.test.mjs +203 -1
  15. package/src/test/commands/session-sync.test.mjs +227 -1
  16. package/src/test/commands/uninstall.test.mjs +216 -0
  17. package/src/test/commands/update.test.mjs +218 -0
  18. package/src/test/dispatcher.test.mjs +103 -2
  19. package/src/test/e2e/advertised-surface.test.mjs +207 -0
  20. package/src/test/e2e/cli-commands.test.mjs +1 -1
  21. package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
  22. package/src/test/e2e/update-check-suppression.test.mjs +135 -0
  23. package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
  24. package/src/test/lib/browser-open.test.mjs +43 -0
  25. package/src/test/lib/config.test.mjs +87 -0
  26. package/src/test/lib/crypto-shas.test.mjs +17 -0
  27. package/src/test/lib/file-write.test.mjs +244 -0
  28. package/src/test/lib/fs-utils.test.mjs +259 -0
  29. package/src/test/lib/global-install.test.mjs +134 -0
  30. package/src/test/lib/http-timeout.test.mjs +114 -0
  31. package/src/test/lib/http.test.mjs +616 -1
  32. package/src/test/lib/mcp-merge.test.mjs +157 -0
  33. package/src/test/lib/npm-update-check.test.mjs +180 -0
  34. package/src/test/lib/placement-walk.test.mjs +132 -0
  35. package/src/test/lib/skill-walk.test.mjs +39 -1
  36. package/src/test/lib/sync.test.mjs +139 -5
  37. package/src/test/lib/telemetry.test.mjs +34 -0
  38. package/src/test/mergers/claude-mcp.test.mjs +30 -0
  39. package/src/test/mergers/cursor-mcp.test.mjs +115 -0
  40. package/src/test/mergers/env-local.test.mjs +126 -0
  41. package/src/test/mergers/vscode-mcp.test.mjs +177 -0
  42. package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
  43. package/src/test/resolve-key.test.mjs +33 -0
@@ -185,6 +185,163 @@ describe("mergeMcpForVendors — preview messages", () => {
185
185
  // The merge should fail, not merge
186
186
  assert.equal(results[0].outcome, "failed");
187
187
  });
188
+
189
+ it("previews warning when existing JSON is valid but not an object (array)", async () => {
190
+ // Parses fine but `typeof config !== "object"`-style guard: an
191
+ // array is typeof "object" yet semantically wrong, and a bare
192
+ // number/string is the real non-object case. Use a JSON number.
193
+ writeFileSync(join(process.cwd(), ".mcp.json"), "42");
194
+ const results = await mergeMcpForVendors({
195
+ vendors: ["claudeCode"],
196
+ mcpUrl: "https://x.com/mcp",
197
+ yes: true,
198
+ io: { stdout, stderr },
199
+ });
200
+ assert.match(stdout.text(), /not a JSON object/);
201
+ // The underlying merger still runs on "yes" and throws (number is
202
+ // not a valid config), so the outcome is "failed".
203
+ assert.equal(results[0].outcome, "failed");
204
+ });
205
+
206
+ it("previews warning when existing JSON is null", async () => {
207
+ // JSON `null` parses to null → the `!config` half of the guard.
208
+ writeFileSync(join(process.cwd(), ".mcp.json"), "null");
209
+ await mergeMcpForVendors({
210
+ vendors: ["claudeCode"],
211
+ mcpUrl: "https://x.com/mcp",
212
+ yes: true,
213
+ io: { stdout, stderr },
214
+ });
215
+ assert.match(stdout.text(), /not a JSON object/);
216
+ });
217
+
218
+ it("previews 'add skillrepo entry to empty mcpServers' when no other servers", async () => {
219
+ // Existing file with an empty mcpServers object and no skillrepo
220
+ // entry → the "no other servers" add branch.
221
+ writeFileSync(
222
+ join(process.cwd(), ".mcp.json"),
223
+ JSON.stringify({ mcpServers: {} }),
224
+ );
225
+ await mergeMcpForVendors({
226
+ vendors: ["claudeCode"],
227
+ mcpUrl: "https://x.com/mcp",
228
+ yes: true,
229
+ io: { stdout, stderr },
230
+ });
231
+ assert.match(stdout.text(), /add skillrepo entry to empty mcpServers/);
232
+ });
233
+
234
+ it("previews 'update existing (no other servers)' when only skillrepo present", async () => {
235
+ // skillrepo entry exists and is the only server → the existingEntry
236
+ // + otherServers.length === 0 branch.
237
+ writeFileSync(
238
+ join(process.cwd(), ".mcp.json"),
239
+ JSON.stringify({ mcpServers: { skillrepo: { url: "https://old.example" } } }),
240
+ );
241
+ await mergeMcpForVendors({
242
+ vendors: ["claudeCode"],
243
+ mcpUrl: "https://new.example/mcp",
244
+ yes: true,
245
+ io: { stdout, stderr },
246
+ });
247
+ assert.match(stdout.text(), /update existing skillrepo entry \(no other servers\)/);
248
+ });
249
+
250
+ it("previews singular server count for one other server (add branch)", async () => {
251
+ // One other server, no skillrepo → singular "1 existing server".
252
+ writeFileSync(
253
+ join(process.cwd(), ".mcp.json"),
254
+ JSON.stringify({ mcpServers: { only: { url: "https://only.example" } } }),
255
+ );
256
+ await mergeMcpForVendors({
257
+ vendors: ["claudeCode"],
258
+ mcpUrl: "https://x.com/mcp",
259
+ yes: true,
260
+ io: { stdout, stderr },
261
+ });
262
+ assert.match(stdout.text(), /1 existing server preserved/);
263
+ assert.doesNotMatch(stdout.text(), /1 existing servers preserved/);
264
+ });
265
+
266
+ it("previews plural server count for multiple other servers (add branch)", async () => {
267
+ // Two other servers, no skillrepo → plural "2 existing servers"
268
+ // (covers the "s" arm of the add-branch ternary).
269
+ writeFileSync(
270
+ join(process.cwd(), ".mcp.json"),
271
+ JSON.stringify({
272
+ mcpServers: {
273
+ alpha: { url: "https://alpha.example" },
274
+ beta: { url: "https://beta.example" },
275
+ },
276
+ }),
277
+ );
278
+ await mergeMcpForVendors({
279
+ vendors: ["claudeCode"],
280
+ mcpUrl: "https://x.com/mcp",
281
+ yes: true,
282
+ io: { stdout, stderr },
283
+ });
284
+ assert.match(stdout.text(), /2 existing servers preserved: alpha, beta/);
285
+ });
286
+
287
+ it("previews update with one other server (singular update branch)", async () => {
288
+ // skillrepo entry present + exactly one other server → the update
289
+ // branch's singular "1 other server preserved".
290
+ writeFileSync(
291
+ join(process.cwd(), ".mcp.json"),
292
+ JSON.stringify({
293
+ mcpServers: {
294
+ skillrepo: { url: "https://old.example" },
295
+ alpha: { url: "https://alpha.example" },
296
+ },
297
+ }),
298
+ );
299
+ await mergeMcpForVendors({
300
+ vendors: ["claudeCode"],
301
+ mcpUrl: "https://new.example/mcp",
302
+ yes: true,
303
+ io: { stdout, stderr },
304
+ });
305
+ assert.match(stdout.text(), /update existing skillrepo entry \(1 other server preserved: alpha\)/);
306
+ });
307
+
308
+ it("previews update with multiple other servers (plural update branch)", async () => {
309
+ // skillrepo entry present + two other servers → the update branch's
310
+ // plural "2 other servers preserved".
311
+ writeFileSync(
312
+ join(process.cwd(), ".mcp.json"),
313
+ JSON.stringify({
314
+ mcpServers: {
315
+ skillrepo: { url: "https://old.example" },
316
+ alpha: { url: "https://alpha.example" },
317
+ beta: { url: "https://beta.example" },
318
+ },
319
+ }),
320
+ );
321
+ await mergeMcpForVendors({
322
+ vendors: ["claudeCode"],
323
+ mcpUrl: "https://new.example/mcp",
324
+ yes: true,
325
+ io: { stdout, stderr },
326
+ });
327
+ assert.match(stdout.text(), /update existing skillrepo entry \(2 other servers preserved: alpha, beta\)/);
328
+ });
329
+
330
+ it("previews 'add to empty mcpServers' when the mcpServers key is absent entirely", async () => {
331
+ // Valid object with NO mcpServers key → the `?? {}` fallback on
332
+ // config.mcpServers fires, yielding an empty server set.
333
+ writeFileSync(
334
+ join(process.cwd(), ".mcp.json"),
335
+ JSON.stringify({ unrelated: true }),
336
+ );
337
+ await mergeMcpForVendors({
338
+ vendors: ["claudeCode"],
339
+ mcpUrl: "https://x.com/mcp",
340
+ yes: true,
341
+ io: { stdout, stderr },
342
+ });
343
+ assert.match(stdout.text(), /add skillrepo entry to empty mcpServers/);
344
+ });
188
345
  });
189
346
 
190
347
  // ── mergeMcpForVendors — user declined ─────────────────────────────────
@@ -655,6 +655,186 @@ describe("constants", () => {
655
655
  });
656
656
  });
657
657
 
658
+ // ── readCache field-validation branches (each malformed shape → miss) ──
659
+ //
660
+ // readCache parses the on-disk file then rejects it field-by-field. Each
661
+ // of these seeds a JSON-parseable cache file that passes the prior gates
662
+ // but trips exactly one validation branch, proving the entry is treated
663
+ // as a miss (a re-fetch fires). The "wrong schemaVersion" case is already
664
+ // covered above; these cover the remaining per-field guards.
665
+
666
+ describe("checkForCliUpdate (readCache field validation → cache miss)", () => {
667
+ const validFetch = () =>
668
+ makeIo({ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) } });
669
+
670
+ it("treats a non-object JSON cache (e.g. `null`) as a miss", async () => {
671
+ mkdirSync(join(testHome, ".claude", "skillrepo"), { recursive: true });
672
+ writeFileSync(cachePath(), "null", "utf-8");
673
+ const fixture = validFetch();
674
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
675
+ assert.equal(fixture.getFetchCalls().length, 1, "non-object cache must force a re-fetch");
676
+ assert.equal(result.nudged, true);
677
+ });
678
+
679
+ it("treats a missing checkedAt (non-string) as a miss", async () => {
680
+ writeCacheFile({
681
+ schemaVersion: CACHE_SCHEMA_VERSION,
682
+ // checkedAt intentionally a number, not a string
683
+ checkedAt: 12345,
684
+ currentCliVersion: "4.3.0",
685
+ latestPublishedVersion: "4.5.0",
686
+ fetchOk: true,
687
+ });
688
+ const fixture = validFetch();
689
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
690
+ assert.equal(fixture.getFetchCalls().length, 1);
691
+ assert.equal(result.nudged, true);
692
+ });
693
+
694
+ it("treats a missing currentCliVersion (non-string) as a miss", async () => {
695
+ writeCacheFile({
696
+ schemaVersion: CACHE_SCHEMA_VERSION,
697
+ checkedAt: new Date().toISOString(),
698
+ // currentCliVersion intentionally missing
699
+ latestPublishedVersion: "4.5.0",
700
+ fetchOk: true,
701
+ });
702
+ const fixture = validFetch();
703
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
704
+ assert.equal(fixture.getFetchCalls().length, 1);
705
+ assert.equal(result.nudged, true);
706
+ });
707
+
708
+ it("treats a non-boolean fetchOk as a miss", async () => {
709
+ writeCacheFile({
710
+ schemaVersion: CACHE_SCHEMA_VERSION,
711
+ checkedAt: new Date().toISOString(),
712
+ currentCliVersion: "4.3.0",
713
+ latestPublishedVersion: "4.5.0",
714
+ fetchOk: "yes", // not a boolean
715
+ });
716
+ const fixture = validFetch();
717
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
718
+ assert.equal(fixture.getFetchCalls().length, 1);
719
+ assert.equal(result.nudged, true);
720
+ });
721
+
722
+ it("treats a latestPublishedVersion of the wrong type (number) as a miss", async () => {
723
+ // latestPublishedVersion may be a string OR null; a number is
724
+ // neither and must invalidate the entry.
725
+ writeCacheFile({
726
+ schemaVersion: CACHE_SCHEMA_VERSION,
727
+ checkedAt: new Date().toISOString(),
728
+ currentCliVersion: "4.3.0",
729
+ latestPublishedVersion: 450, // wrong type
730
+ fetchOk: true,
731
+ });
732
+ const fixture = validFetch();
733
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
734
+ assert.equal(fixture.getFetchCalls().length, 1);
735
+ assert.equal(result.nudged, true);
736
+ });
737
+
738
+ it("treats an unparseable checkedAt timestamp as not-fresh (isCacheFresh re-fetches)", async () => {
739
+ // The field IS a string (passes readCache) but Date.parse can't
740
+ // parse it → isCacheFresh's `Number.isFinite(checkedAtMs)` is false
741
+ // → re-fetch.
742
+ writeCacheFile({
743
+ schemaVersion: CACHE_SCHEMA_VERSION,
744
+ checkedAt: "not-a-real-date",
745
+ currentCliVersion: "4.3.0",
746
+ latestPublishedVersion: "4.5.0",
747
+ fetchOk: true,
748
+ });
749
+ const fixture = validFetch();
750
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
751
+ assert.equal(fixture.getFetchCalls().length, 1, "unparseable checkedAt must re-fetch");
752
+ assert.equal(result.nudged, true);
753
+ });
754
+ });
755
+
756
+ // ── fetchLatestVersion + emitNudge + no-fetch edge branches ────────────
757
+
758
+ describe("checkForCliUpdate (fetch body + emit + no-fetch edges)", () => {
759
+ it("a JSON body that is not an object (null) yields no result", async () => {
760
+ // `!body || typeof body !== "object"` — the registry returns a bare
761
+ // `null` JSON body. fetchLatestVersion bails before reading
762
+ // `.version`, the cache records fetchOk:false, and no nudge fires.
763
+ const fixture = makeIo({
764
+ fetchResult: { ok: true, json: async () => null },
765
+ });
766
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
767
+ assert.equal(result.nudged, false);
768
+ assert.equal(JSON.parse(fixture.getWriteCalls()[0].contents).fetchOk, false);
769
+ });
770
+
771
+ it("a no-result is treated identically when no fetch impl is available", async () => {
772
+ // `typeof resolvedIo.fetch !== "function"` — a truthy-but-not-callable
773
+ // fetch (defensive against a misconfigured DI) short-circuits with
774
+ // reason "no-fetch" and never attempts a network call or cache write.
775
+ const fixture = makeIo({
776
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
777
+ });
778
+ fixture.io.fetch = 42; // truthy, not a function
779
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
780
+ assert.equal(result.nudged, false);
781
+ assert.equal(result.reason, "no-fetch");
782
+ assert.equal(fixture.getWriteCalls().length, 0, "no cache write without a fetch impl");
783
+ });
784
+
785
+ it("a throwing stderrWrite during the nudge is swallowed (never crashes)", async () => {
786
+ // emitNudge wraps the stderr write in try/catch so a closed pipe
787
+ // can't break the user's flow. Inject a stderrWrite that throws and
788
+ // assert the call still resolves nudged:true.
789
+ const fixture = makeIo({
790
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
791
+ });
792
+ fixture.io.stderrWrite = () => {
793
+ throw new Error("EPIPE: write to closed stream");
794
+ };
795
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
796
+ // The nudge "fired" (we attempted to write) even though the write
797
+ // threw — the return value reflects the decision, not the I/O outcome.
798
+ assert.equal(result.nudged, true);
799
+ });
800
+ });
801
+
802
+ // ── dim/bold ANSI styling under a TTY without NO_COLOR ─────────────────
803
+
804
+ describe("checkForCliUpdate (nudge styling — TTY color branch)", () => {
805
+ it("emits ANSI dim/bold escapes when stderr is a TTY and NO_COLOR is unset", async () => {
806
+ // The `dim`/`bold` helpers gate on `process.stderr.isTTY &&
807
+ // !process.env.NO_COLOR`. Every other test runs under a piped
808
+ // (non-TTY) stderr, so only the no-color branch is exercised. Force
809
+ // the TTY branch deterministically and assert the emitted nudge
810
+ // carries the ANSI escape codes.
811
+ const originalIsTTY = process.stderr.isTTY;
812
+ const prevNoColor = process.env.NO_COLOR;
813
+ delete process.env.NO_COLOR;
814
+ Object.defineProperty(process.stderr, "isTTY", {
815
+ value: true,
816
+ configurable: true,
817
+ });
818
+ const fixture = makeIo({
819
+ fetchResult: { ok: true, json: async () => ({ version: "4.5.0" }) },
820
+ });
821
+ try {
822
+ const result = await checkForCliUpdate({ currentVersion: "4.3.0", io: fixture.io });
823
+ assert.equal(result.nudged, true);
824
+ const out = fixture.getStderrWrites().join("");
825
+ assert.ok(out.includes("\x1b[2m"), "dim() must emit the ANSI dim escape on a TTY");
826
+ assert.ok(out.includes("\x1b[1m"), "bold() must emit the ANSI bold escape on a TTY");
827
+ } finally {
828
+ Object.defineProperty(process.stderr, "isTTY", {
829
+ value: originalIsTTY,
830
+ configurable: true,
831
+ });
832
+ if (prevNoColor === undefined) delete process.env.NO_COLOR;
833
+ else process.env.NO_COLOR = prevNoColor;
834
+ }
835
+ });
836
+ });
837
+
658
838
  // ── Notes on coverage gaps ─────────────────────────────────────────────
659
839
  //
660
840
  // 1. The `--json` suppression lives in `bin/skillrepo.mjs`. Asserting
@@ -29,6 +29,7 @@ import {
29
29
  } from "node:fs";
30
30
  import { join } from "node:path";
31
31
  import { tmpdir } from "node:os";
32
+ import { execFileSync } from "node:child_process";
32
33
 
33
34
  import { walkDetectedPlacements, walkPlacementTarget } from "../../lib/placement-walk.mjs";
34
35
  import { computeSkillShas } from "../../lib/crypto-shas.mjs";
@@ -348,6 +349,137 @@ describe("walkPlacementTarget", () => {
348
349
  assert.equal(result.length, 1);
349
350
  assert.equal(result[0].skillName, "cohort-skill");
350
351
  });
352
+
353
+ it("returns empty array when the parent root exists but is unreadable (readdir EACCES)", () => {
354
+ // Covers the `catch` around readdirSync(parentDir): the root dir
355
+ // exists (so the existsSync gate passes) but cannot be listed.
356
+ // chmod 000 makes readdir throw EACCES → the walker swallows it and
357
+ // returns []. Skipped on Windows (chmod is a no-op) and root (root
358
+ // bypasses permission checks).
359
+ if (process.platform === "win32" || process.getuid?.() === 0) return;
360
+
361
+ const parent = join(process.cwd(), ".claude", "skills");
362
+ mkdirSync(parent, { recursive: true });
363
+ seedSkill(parent, "hidden", [
364
+ { path: "SKILL.md", content: skillMd("hidden") },
365
+ ]);
366
+ chmodSync(parent, 0o000);
367
+ try {
368
+ assert.deepEqual(walkPlacementTarget("claudeProject"), []);
369
+ } finally {
370
+ chmodSync(parent, 0o755);
371
+ }
372
+ });
373
+
374
+ it("skips a root entry whose lstat fails (no-exec parent dir)", () => {
375
+ // Covers the `catch` around lstatSync(skillDir) for an entry at the
376
+ // parent root. A directory with read but NOT execute (search)
377
+ // permission lets readdirSync enumerate the names but makes
378
+ // lstatSync on each child fail with EACCES. The walker `continue`s
379
+ // past the unstatable entry, yielding an empty result rather than
380
+ // crashing.
381
+ if (process.platform === "win32" || process.getuid?.() === 0) return;
382
+
383
+ const parent = join(process.cwd(), ".claude", "skills");
384
+ mkdirSync(parent, { recursive: true });
385
+ seedSkill(parent, "unstatable", [
386
+ { path: "SKILL.md", content: skillMd("unstatable") },
387
+ ]);
388
+ // 0o444 = read but no execute/search → readdir lists, lstat denied.
389
+ chmodSync(parent, 0o444);
390
+ try {
391
+ const result = walkPlacementTarget("claudeProject");
392
+ // The entry could not be stat'd, so it is skipped entirely.
393
+ assert.deepEqual(result, []);
394
+ } finally {
395
+ chmodSync(parent, 0o755);
396
+ }
397
+ });
398
+
399
+ it("produces null SHAs when a skill contains an unreadable SUBDIRECTORY (recursive readdir EACCES)", () => {
400
+ // Covers the `catch` around the recursive readdirSync(currentDir)
401
+ // for a nested directory inside a skill. The subdir exists but
402
+ // chmod 000 makes its listing fail → onError fires → readError set
403
+ // → computeShasFromDir gate returns null SHAs.
404
+ if (process.platform === "win32" || process.getuid?.() === 0) return;
405
+
406
+ const parent = join(process.cwd(), ".claude", "skills");
407
+ const skillDir = join(parent, "bad-subdir");
408
+ mkdirSync(skillDir, { recursive: true });
409
+ writeFileSync(join(skillDir, "SKILL.md"), skillMd("bad-subdir"), "utf8");
410
+ const subDir = join(skillDir, "references");
411
+ mkdirSync(subDir, { recursive: true });
412
+ writeFileSync(join(subDir, "note.md"), "# note\n", "utf8");
413
+ chmodSync(subDir, 0o000);
414
+ try {
415
+ const result = walkPlacementTarget("claudeProject");
416
+ assert.equal(result.length, 1);
417
+ assert.equal(result[0].skillName, "bad-subdir");
418
+ assert.equal(result[0].skillMdSha256, null);
419
+ assert.equal(result[0].filesSha256, null);
420
+ } finally {
421
+ chmodSync(subDir, 0o755);
422
+ }
423
+ });
424
+
425
+ it("skips a non-regular-file entry inside a skill (FIFO / named pipe)", () => {
426
+ // Covers the `if (!stat.isFile()) continue` guard in the recursive
427
+ // walk: an entry that is neither a symlink, a directory, nor a
428
+ // regular file (a FIFO here) is skipped — only the real SKILL.md is
429
+ // hashed. POSIX-only: FIFOs require `mkfifo`, which Windows lacks,
430
+ // and Node has no portable FIFO-creation API.
431
+ if (process.platform === "win32") return;
432
+ let mkfifoOk = true;
433
+ const parent = join(process.cwd(), ".claude", "skills");
434
+ const skillDir = join(parent, "with-fifo");
435
+ mkdirSync(skillDir, { recursive: true });
436
+ writeFileSync(join(skillDir, "SKILL.md"), skillMd("with-fifo"), "utf8");
437
+ try {
438
+ execFileSync("mkfifo", [join(skillDir, "pipe")]);
439
+ } catch {
440
+ // mkfifo not available on this runner — nothing to assert.
441
+ mkfifoOk = false;
442
+ }
443
+ if (!mkfifoOk) return;
444
+
445
+ const result = walkPlacementTarget("claudeProject");
446
+ assert.equal(result.length, 1);
447
+ assert.equal(result[0].skillName, "with-fifo");
448
+ // The FIFO contributed nothing; the SHAs reflect SKILL.md alone.
449
+ const expected = computeSkillShas([
450
+ { path: "SKILL.md", content: skillMd("with-fifo") },
451
+ ]);
452
+ assert.equal(result[0].skillMdSha256, expected.skillMdSha256);
453
+ assert.equal(result[0].filesSha256, expected.filesSha256);
454
+ });
455
+
456
+ it("produces null SHAs when a nested entry's lstat fails (recursive lstat EACCES)", () => {
457
+ // Covers the `catch` around the recursive lstatSync(fullPath) for an
458
+ // entry inside a subdirectory. The subdir is readable (readdir lists
459
+ // its children) but has no execute bit, so lstat on each child fails
460
+ // → onError → readError → null SHAs.
461
+ if (process.platform === "win32" || process.getuid?.() === 0) return;
462
+
463
+ const parent = join(process.cwd(), ".claude", "skills");
464
+ const skillDir = join(parent, "bad-nested-stat");
465
+ mkdirSync(skillDir, { recursive: true });
466
+ writeFileSync(join(skillDir, "SKILL.md"), skillMd("bad-nested-stat"), "utf8");
467
+ const subDir = join(skillDir, "references");
468
+ mkdirSync(subDir, { recursive: true });
469
+ writeFileSync(join(subDir, "note.md"), "# note\n", "utf8");
470
+ // 0o444 → readable (readdir lists "note.md") but not searchable
471
+ // (lstat on note.md denied).
472
+ chmodSync(subDir, 0o444);
473
+ try {
474
+ const result = walkPlacementTarget("claudeProject");
475
+ assert.equal(result.length, 1);
476
+ assert.equal(result[0].skillName, "bad-nested-stat");
477
+ assert.equal(result[0].skillMdSha256, null);
478
+ assert.equal(result[0].filesSha256, null);
479
+ } finally {
480
+ chmodSync(subDir, 0o755);
481
+ }
482
+ });
351
483
  });
352
484
 
353
485
  // ── walkDetectedPlacements ──────────────────────────────────────────────
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { describe, it, beforeEach, afterEach } from "node:test";
13
13
  import assert from "node:assert/strict";
14
- import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
14
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
15
15
  import { join } from "node:path";
16
16
  import { tmpdir } from "node:os";
17
17
 
@@ -110,6 +110,44 @@ describe("walkSkillFiles", () => {
110
110
  assert.deepEqual(paths, ["SKILL.md"]);
111
111
  });
112
112
 
113
+ it("returns an empty array when the skill directory does not exist (ENOENT)", async () => {
114
+ // readdir on a missing root throws ENOENT — the walker swallows it
115
+ // and returns [] rather than propagating.
116
+ const walked = await walkSkillFiles(join(sandbox, "does-not-exist"));
117
+ assert.deepEqual(walked, []);
118
+ });
119
+
120
+ it("re-throws non-ENOENT readdir errors (ENOTDIR when the path is a file)", async () => {
121
+ // readdir on a regular file throws ENOTDIR, which is NOT swallowed —
122
+ // it must propagate so the caller learns the path is not a directory.
123
+ const filePath = join(sandbox, "not-a-dir.txt");
124
+ writeFileSync(filePath, "x");
125
+ await assert.rejects(
126
+ () => walkSkillFiles(filePath),
127
+ (err) => err && err.code === "ENOTDIR",
128
+ );
129
+ });
130
+
131
+ it(
132
+ "skips symlink entries (not regular files)",
133
+ { skip: process.platform === "win32" },
134
+ async () => {
135
+ // A symlink-to-file is not `entry.isFile()`, so the walker skips
136
+ // it via the `if (!entry.isFile()) continue` guard. Only the real
137
+ // SKILL.md is collected.
138
+ file("SKILL.md");
139
+ const realTarget = join(sandbox, "real.md");
140
+ writeFileSync(realTarget, "real");
141
+ symlinkSync(realTarget, join(sandbox, "link.md"));
142
+
143
+ const walked = await walkSkillFiles(sandbox);
144
+ const paths = walked.map((f) => f.relativePath);
145
+ assert.ok(!paths.includes("link.md"), "symlink should be excluded");
146
+ assert.ok(paths.includes("SKILL.md"));
147
+ assert.ok(paths.includes("real.md"));
148
+ },
149
+ );
150
+
113
151
  it("returns absolute paths + sizes for each file", async () => {
114
152
  file("SKILL.md", "ab");
115
153
  file("references/a.md", "abc");