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
@@ -16,7 +16,7 @@ import { join } from "node:path";
16
16
  import { tmpdir } from "node:os";
17
17
 
18
18
  import { runList } from "../../commands/list.mjs";
19
- import { CliError, EXIT_AUTH } from "../../lib/errors.mjs";
19
+ import { CliError, EXIT_AUTH, EXIT_NETWORK } from "../../lib/errors.mjs";
20
20
  import { computeSkillShas } from "../../lib/crypto-shas.mjs";
21
21
  import { globalLastSyncPath } from "../../lib/paths.mjs";
22
22
  import { createMockServer } from "../e2e/mock-server.mjs";
@@ -198,6 +198,21 @@ describe("runList — error paths", () => {
198
198
  (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
199
199
  );
200
200
  });
201
+
202
+ it("rejects with a network CliError when GET /library returns 500", async () => {
203
+ // `list` goes straight to getLibrary — no prior validate call —
204
+ // so a persistent 500 on the library route is the whole request.
205
+ // getLibrary calls safeFetch with retry:true, but 500 is NOT in
206
+ // TRANSIENT_STATUS_CODES (429/502/503/504 only), so withRetry
207
+ // returns the 500 immediately. mapErrorResponse maps `status >=
208
+ // 500` to networkError → EXIT_NETWORK (exit 1). Asserting the
209
+ // real mapped exit code, not a guess.
210
+ server.setLibraryStatus({ status: 500, body: { error: "boom" } });
211
+ await assert.rejects(
212
+ () => runList(["--key", VALID_KEY, "--url", serverUrl], { stdout }),
213
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
214
+ );
215
+ });
201
216
  });
202
217
 
203
218
  // ── Drift state coverage (#1555) ────────────────────────────────────────
@@ -351,6 +366,54 @@ describe("runList — drift state (#1555)", () => {
351
366
  assert.match(out, /library in sync — but \d+ skill/);
352
367
  });
353
368
 
369
+ it("footer warns that update will OVERWRITE local edits when ETag matches but a skill is EDITED", async () => {
370
+ // Gap (#1555 footer coverage): the EDIT-state test above asserts
371
+ // the *generic* in-sync-with-drift footer line, but NOT the
372
+ // destructive-edit warning that fires specifically when an
373
+ // `edited` row is present. printFooter (list.mjs ~355-365) takes
374
+ // the etagMatches && driftCount > 0 branch, then counts
375
+ // SKILL_STATE.EDITED rows; when editedCount > 0 it emits the
376
+ // OVERWRITE warning instead of the non-destructive refresh line.
377
+ //
378
+ // Setup mirrors the EDIT-state test: seed disk with one content,
379
+ // baseline with a DIFFERENT SHA so the skill classifies as
380
+ // `edited`, ETag matching so the library is "in sync".
381
+ seedClaudeSkillOnDisk("touched", [
382
+ { path: "SKILL.md", content: SKILL_MD_BODY("touched-modified") },
383
+ ]);
384
+ seedLastSync({
385
+ etag: '"v1"',
386
+ skills: {
387
+ "alice/touched": {
388
+ version: "1.0.0",
389
+ skillMdSha256: "f".repeat(64),
390
+ filesSha256: "e".repeat(64),
391
+ syncedAt: "x",
392
+ },
393
+ },
394
+ });
395
+ server.setEtag('"v1"');
396
+ server.setLibraryResponse({
397
+ skills: [makeSkill("alice", "touched", "1.0.0")],
398
+ removals: [],
399
+ syncedAt: "x",
400
+ });
401
+
402
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
403
+ const out = stdout.text();
404
+ // The row itself is EDIT, confirming the precondition.
405
+ assert.match(out, /\bEDIT\b/);
406
+ // The destructive-edit footer line — exact substring from
407
+ // list.mjs's printFooter. A regression that drops the warning (or
408
+ // reverts to the non-destructive "Run `skillrepo update` to
409
+ // refresh." line) would let the CLI silently destroy user edits.
410
+ assert.match(out, /this will OVERWRITE local edits/);
411
+ assert.match(
412
+ out,
413
+ /including 1 with local edit\b/,
414
+ );
415
+ });
416
+
354
417
  it("renders MISS when skill is in library but no on-disk placement", async () => {
355
418
  // No seedClaudeSkillOnDisk → no dir on disk.
356
419
  seedLastSync({
@@ -434,6 +497,147 @@ describe("runList — drift state (#1555)", () => {
434
497
  assert.match(stdout.text(), /library has changed since last sync/);
435
498
  });
436
499
 
500
+ it("uses plural phrasing for multiple in-sync skills (no drift)", async () => {
501
+ // Existing no-drift tests render a SINGLE current skill, hitting the
502
+ // singular `${n} skill in your library` arm (list.mjs ~286). Two
503
+ // current skills with driftCount === 0 exercises the plural arm
504
+ // ("skills") of that same ternary.
505
+ const a = [{ path: "SKILL.md", content: SKILL_MD_BODY("alpha") }];
506
+ const aShas = seedClaudeSkillOnDisk("alpha", a);
507
+ const b = [{ path: "SKILL.md", content: SKILL_MD_BODY("beta") }];
508
+ const bShas = seedClaudeSkillOnDisk("beta", b);
509
+ seedLastSync({
510
+ etag: '"v1"',
511
+ skills: {
512
+ "alice/alpha": {
513
+ version: "1.0.0",
514
+ skillMdSha256: aShas.skillMdSha256,
515
+ filesSha256: aShas.filesSha256,
516
+ syncedAt: "x",
517
+ },
518
+ "alice/beta": {
519
+ version: "1.0.0",
520
+ skillMdSha256: bShas.skillMdSha256,
521
+ filesSha256: bShas.filesSha256,
522
+ syncedAt: "x",
523
+ },
524
+ },
525
+ });
526
+ server.setEtag('"v1"');
527
+ server.setLibraryResponse({
528
+ skills: [
529
+ makeSkill("alice", "alpha", "1.0.0"),
530
+ makeSkill("alice", "beta", "1.0.0"),
531
+ ],
532
+ removals: [],
533
+ syncedAt: "x",
534
+ });
535
+
536
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
537
+ const out = stdout.text();
538
+ // Plural "skills", no "needs attention" suffix (driftCount === 0).
539
+ assert.match(out, /\b2 skills in your library\.\n/);
540
+ assert.match(out, /library in sync — local skills up to date/);
541
+ });
542
+
543
+ it("uses plural phrasing in the OVERWRITE footer when multiple skills are edited", async () => {
544
+ // The destructive-edit footer (list.mjs ~360-363) has singular/plural
545
+ // ternaries on driftCount and editedCount. The single-edit tests cover
546
+ // only the singular arms ("skill"/"shows"/"edit"). TWO edited skills
547
+ // exercises the plural arms ("skills"/"show"/"edits").
548
+ seedClaudeSkillOnDisk("one", [
549
+ { path: "SKILL.md", content: SKILL_MD_BODY("one-modified") },
550
+ ]);
551
+ seedClaudeSkillOnDisk("two", [
552
+ { path: "SKILL.md", content: SKILL_MD_BODY("two-modified") },
553
+ ]);
554
+ seedLastSync({
555
+ etag: '"v1"',
556
+ skills: {
557
+ // Both baselines carry SHAs that won't match disk → both EDITED.
558
+ "alice/one": {
559
+ version: "1.0.0",
560
+ skillMdSha256: "f".repeat(64),
561
+ filesSha256: "e".repeat(64),
562
+ syncedAt: "x",
563
+ },
564
+ "alice/two": {
565
+ version: "1.0.0",
566
+ skillMdSha256: "d".repeat(64),
567
+ filesSha256: "c".repeat(64),
568
+ syncedAt: "x",
569
+ },
570
+ },
571
+ });
572
+ server.setEtag('"v1"');
573
+ server.setLibraryResponse({
574
+ skills: [
575
+ makeSkill("alice", "one", "1.0.0"),
576
+ makeSkill("alice", "two", "1.0.0"),
577
+ ],
578
+ removals: [],
579
+ syncedAt: "x",
580
+ });
581
+
582
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
583
+ const out = stdout.text();
584
+ // Plural drift phrase ("2 skills show") + plural edit count
585
+ // ("including 2 with local edits").
586
+ assert.match(out, /2 skills show local drift/);
587
+ assert.match(out, /including 2 with local edits/);
588
+ assert.match(out, /this will OVERWRITE local edits/);
589
+ });
590
+
591
+ it("treats a null library version as no-version (computeSkillState null arm)", async () => {
592
+ // augmentSkill passes `skill.version ?? null` (list.mjs ~172). Every
593
+ // other test supplies a string version, so the `?? null` right side
594
+ // never ran. A library skill with a null version exercises it — and
595
+ // must not crash; with no baseline it rolls up MISSING.
596
+ server.setEtag('"v1"');
597
+ server.setLibraryResponse({
598
+ skills: [
599
+ { ...makeSkill("alice", "nullver"), version: null },
600
+ ],
601
+ removals: [],
602
+ syncedAt: "x",
603
+ });
604
+
605
+ await runList(["--key", VALID_KEY, "--url", serverUrl, "--json"], { stdout });
606
+ const [item] = JSON.parse(stdout.text());
607
+ assert.equal(item.name, "nullver");
608
+ assert.equal(item.state, "missing");
609
+ });
610
+
611
+ it("sorts same-owner skills when a name field is missing (|| '' guard on name)", async () => {
612
+ // sortByOwnerAndName (list.mjs ~473) compares names only AFTER owners
613
+ // tie. The earlier missing-fields test used distinct owners, so the
614
+ // owner comparison short-circuited and the `(a.name || "")` /
615
+ // `(b.name || "")` guards on the name line never ran. Two skills with
616
+ // the SAME owner — one missing its name — forces the name comparison
617
+ // and exercises the `|| ""` false sides.
618
+ const named = { ...makeSkill("dup", "has-name") };
619
+ const noName = {
620
+ owner: "dup",
621
+ name: undefined,
622
+ version: "1.0.0",
623
+ description: "missing name",
624
+ files: [],
625
+ updatedAt: "2025-01-01T12:00:00Z",
626
+ };
627
+ server.setEtag('"v1"');
628
+ server.setLibraryResponse({
629
+ skills: [named, noName],
630
+ removals: [],
631
+ syncedAt: "x",
632
+ });
633
+
634
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
635
+ const out = stdout.text();
636
+ // No crash; the named skill still renders and both are counted.
637
+ assert.match(out, /has-name/);
638
+ assert.match(out, /2 skills in your library/);
639
+ });
640
+
437
641
  it("--json includes state and placements per item; no library wrapper", async () => {
438
642
  const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("scripted") }];
439
643
  const shas = seedClaudeSkillOnDisk("scripted", files);
@@ -698,3 +902,371 @@ describe("runList — drift state (#1555)", () => {
698
902
  assert.equal(item.placements[0].localVersion, null);
699
903
  });
700
904
  });
905
+
906
+ // ── TTY glyph rendering ─────────────────────────────────────────────────
907
+ //
908
+ // `canUseGlyphs(stream)` (list.mjs ~385) returns false when NO_COLOR is
909
+ // set OR the stream is not a TTY. The default capture stream is non-TTY,
910
+ // so every test above exercises only the ASCII branch (OK/STALE/MISS/EDIT
911
+ // and the [ok]/[!] footer tokens). These tests flip `stream.isTTY = true`
912
+ // (and clear NO_COLOR for the duration) so the glyph branches of
913
+ // renderLocalCell (✓ / ⚠ stale / ✗ miss / ✎ edit) and the footer
914
+ // (✓ / ⚠) render their TRUE side. The stream also carries a numeric
915
+ // `.columns` to exercise streamColumns' first arm (list.mjs ~415).
916
+
917
+ /**
918
+ * A capture stream that reports as an interactive TTY with a fixed
919
+ * width. Used to drive the glyph (Unicode) rendering branches.
920
+ */
921
+ function createTtyCaptureStream(columns = 120) {
922
+ const stream = createCaptureStream();
923
+ stream.isTTY = true;
924
+ stream.columns = columns;
925
+ return stream;
926
+ }
927
+
928
+ describe("runList — TTY glyph rendering", () => {
929
+ let noColorSnapshot;
930
+ let ttyStdout;
931
+
932
+ beforeEach(async () => {
933
+ await setup();
934
+ forceClaudeCodeDetected();
935
+ // canUseGlyphs short-circuits to false if NO_COLOR is set at all
936
+ // (even ""), so clear it for these tests and restore afterward.
937
+ noColorSnapshot = process.env.NO_COLOR;
938
+ delete process.env.NO_COLOR;
939
+ ttyStdout = createTtyCaptureStream();
940
+ });
941
+ afterEach(async () => {
942
+ unforceDetection();
943
+ if (noColorSnapshot === undefined) {
944
+ delete process.env.NO_COLOR;
945
+ } else {
946
+ process.env.NO_COLOR = noColorSnapshot;
947
+ }
948
+ await teardown();
949
+ });
950
+
951
+ it("renders ✓ glyph + ✓ footer glyph when current and in sync (TTY)", async () => {
952
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("clean") }];
953
+ const shas = seedClaudeSkillOnDisk("clean", files);
954
+ seedLastSync({
955
+ etag: '"v1"',
956
+ skills: {
957
+ "alice/clean": {
958
+ version: "1.0.0",
959
+ skillMdSha256: shas.skillMdSha256,
960
+ filesSha256: shas.filesSha256,
961
+ syncedAt: "x",
962
+ },
963
+ },
964
+ });
965
+ server.setEtag('"v1"');
966
+ server.setLibraryResponse({
967
+ skills: [makeSkill("alice", "clean", "1.0.0")],
968
+ removals: [],
969
+ syncedAt: "x",
970
+ });
971
+
972
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStdout });
973
+ const out = ttyStdout.text();
974
+ // Row glyph (renderLocalCell CURRENT → "✓") and footer glyph
975
+ // (printFooter ok → "✓").
976
+ assert.match(out, /✓/);
977
+ // The in-sync footer line, now glyph-prefixed.
978
+ assert.match(out, /✓ library in sync/);
979
+ // No ASCII fallback tokens should appear in TTY mode.
980
+ assert.doesNotMatch(out, /\bOK\b/);
981
+ assert.doesNotMatch(out, /\[ok\]/);
982
+ });
983
+
984
+ it("renders ⚠ stale glyph when library version is newer (TTY)", async () => {
985
+ const files = [{ path: "SKILL.md", content: SKILL_MD_BODY("aging") }];
986
+ const shas = seedClaudeSkillOnDisk("aging", files);
987
+ seedLastSync({
988
+ etag: '"v1"',
989
+ skills: {
990
+ "alice/aging": {
991
+ version: "1.0.0",
992
+ skillMdSha256: shas.skillMdSha256,
993
+ filesSha256: shas.filesSha256,
994
+ syncedAt: "x",
995
+ },
996
+ },
997
+ });
998
+ server.setEtag('"v2"');
999
+ server.setLibraryResponse({
1000
+ skills: [makeSkill("alice", "aging", "1.1.0")],
1001
+ removals: [],
1002
+ syncedAt: "x",
1003
+ });
1004
+
1005
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStdout });
1006
+ const out = ttyStdout.text();
1007
+ // renderLocalCell STALE → "⚠ stale".
1008
+ assert.match(out, /⚠ stale/);
1009
+ // ETag differs → "library has changed" footer, glyph-prefixed.
1010
+ assert.match(out, /⚠ library has changed since last sync/);
1011
+ assert.doesNotMatch(out, /\bSTALE\b/);
1012
+ });
1013
+
1014
+ it("renders ✗ miss glyph when skill has no on-disk placement (TTY)", async () => {
1015
+ // No seedClaudeSkillOnDisk → placement missing.
1016
+ seedLastSync({
1017
+ etag: '"v1"',
1018
+ skills: {
1019
+ "alice/absent": {
1020
+ version: "1.0.0",
1021
+ skillMdSha256: "a".repeat(64),
1022
+ filesSha256: "b".repeat(64),
1023
+ syncedAt: "x",
1024
+ },
1025
+ },
1026
+ });
1027
+ server.setEtag('"v1"');
1028
+ server.setLibraryResponse({
1029
+ skills: [makeSkill("alice", "absent", "1.0.0")],
1030
+ removals: [],
1031
+ syncedAt: "x",
1032
+ });
1033
+
1034
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStdout });
1035
+ const out = ttyStdout.text();
1036
+ // renderLocalCell MISSING → "✗ miss".
1037
+ assert.match(out, /✗ miss/);
1038
+ assert.doesNotMatch(out, /\bMISS\b/);
1039
+ });
1040
+
1041
+ it("renders ✎ edit glyph + ⚠ footer when SHA differs but version matches (TTY)", async () => {
1042
+ seedClaudeSkillOnDisk("touched", [
1043
+ { path: "SKILL.md", content: SKILL_MD_BODY("touched-modified") },
1044
+ ]);
1045
+ seedLastSync({
1046
+ etag: '"v1"',
1047
+ skills: {
1048
+ "alice/touched": {
1049
+ version: "1.0.0",
1050
+ skillMdSha256: "f".repeat(64),
1051
+ filesSha256: "e".repeat(64),
1052
+ syncedAt: "x",
1053
+ },
1054
+ },
1055
+ });
1056
+ server.setEtag('"v1"');
1057
+ server.setLibraryResponse({
1058
+ skills: [makeSkill("alice", "touched", "1.0.0")],
1059
+ removals: [],
1060
+ syncedAt: "x",
1061
+ });
1062
+
1063
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStdout });
1064
+ const out = ttyStdout.text();
1065
+ // renderLocalCell EDITED → "✎ edit".
1066
+ assert.match(out, /✎ edit/);
1067
+ // ETag matches + drift incl. an edit → ⚠-prefixed destructive
1068
+ // warning footer (printFooter warn glyph + OVERWRITE line).
1069
+ assert.match(out, /⚠ library in sync — but/);
1070
+ assert.match(out, /this will OVERWRITE local edits/);
1071
+ assert.doesNotMatch(out, /\bEDIT\b/);
1072
+ assert.doesNotMatch(out, /\[!\]/);
1073
+ });
1074
+
1075
+ it("renders ⚠ footer (non-destructive) when ETag matches with non-edit drift (TTY)", async () => {
1076
+ // MISS drift (no on-disk placement) with a matching ETag → the
1077
+ // etagMatches && driftCount > 0 branch with editedCount === 0, which
1078
+ // emits the ⚠ non-destructive "Run `skillrepo update` to refresh"
1079
+ // footer (printFooter ~366-371).
1080
+ seedLastSync({
1081
+ etag: '"v1"',
1082
+ skills: {
1083
+ "alice/absent": {
1084
+ version: "1.0.0",
1085
+ skillMdSha256: "a".repeat(64),
1086
+ filesSha256: "b".repeat(64),
1087
+ syncedAt: "x",
1088
+ },
1089
+ },
1090
+ });
1091
+ server.setEtag('"v1"');
1092
+ server.setLibraryResponse({
1093
+ skills: [makeSkill("alice", "absent", "1.0.0")],
1094
+ removals: [],
1095
+ syncedAt: "x",
1096
+ });
1097
+
1098
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStdout });
1099
+ const out = ttyStdout.text();
1100
+ assert.match(out, /⚠ library in sync — but/);
1101
+ assert.match(out, /Run `skillrepo update` to refresh\./);
1102
+ // The destructive variant must NOT fire (no EDITED rows).
1103
+ assert.doesNotMatch(out, /OVERWRITE local edits/);
1104
+ });
1105
+ });
1106
+
1107
+ // ── canUseGlyphs NO_COLOR + narrow-width + cell defaults ────────────────
1108
+
1109
+ describe("runList — ASCII / formatting branches", () => {
1110
+ beforeEach(setup);
1111
+ afterEach(teardown);
1112
+
1113
+ it("forces ASCII tokens when NO_COLOR is set even on a TTY stream", async () => {
1114
+ // canUseGlyphs (list.mjs ~386) short-circuits to false the moment
1115
+ // NO_COLOR is present, BEFORE checking isTTY. Drive the TRUE side of
1116
+ // that guard with a TTY stream + NO_COLOR set: the row must render
1117
+ // the ASCII token, not the glyph.
1118
+ const prior = process.env.NO_COLOR;
1119
+ process.env.NO_COLOR = "1";
1120
+ const ttyStream = createCaptureStream();
1121
+ ttyStream.isTTY = true;
1122
+ ttyStream.columns = 120;
1123
+ try {
1124
+ server.setLibraryResponse({
1125
+ skills: [makeSkill("alice", "pdf-helper")],
1126
+ removals: [],
1127
+ syncedAt: "x",
1128
+ });
1129
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: ttyStream });
1130
+ const out = ttyStream.text();
1131
+ assert.match(out, /pdf-helper/);
1132
+ // NO_COLOR wins over isTTY → ASCII MISS token, no glyph.
1133
+ assert.match(out, /\bMISS\b/);
1134
+ assert.doesNotMatch(out, /✗ miss/);
1135
+ } finally {
1136
+ if (prior === undefined) delete process.env.NO_COLOR;
1137
+ else process.env.NO_COLOR = prior;
1138
+ }
1139
+ });
1140
+
1141
+ it("falls back to the 100-col default for a narrow (<=60) terminal width", async () => {
1142
+ // computeColWidths (list.mjs ~429) uses `terminalColumns > 60 ?
1143
+ // Math.min(..., 120) : 100`. A stream reporting width <= 60 takes
1144
+ // the `: 100` floor arm.
1145
+ const narrowStream = createCaptureStream();
1146
+ narrowStream.columns = 40;
1147
+ server.setLibraryResponse({
1148
+ skills: [makeSkill("alice", "pdf-helper")],
1149
+ removals: [],
1150
+ syncedAt: "x",
1151
+ });
1152
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout: narrowStream });
1153
+ assert.match(narrowStream.text(), /pdf-helper/);
1154
+ });
1155
+
1156
+ it("renders em-dash version + singular count for a single skill missing version/description", async () => {
1157
+ // Three branches in one realistic render:
1158
+ // - `s.version || "—"` false side (empty version) — list.mjs ~274
1159
+ // - `truncate(s.description || "", 60)` empty-string fallback — ~277
1160
+ // - `sorted.length === 1 ? "" : "s"` singular arm — ~286
1161
+ const bare = makeSkill("alice", "bare", "", "");
1162
+ server.setLibraryResponse({
1163
+ skills: [bare],
1164
+ removals: [],
1165
+ syncedAt: "x",
1166
+ });
1167
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1168
+ const out = stdout.text();
1169
+ assert.match(out, /bare/);
1170
+ assert.match(out, /—/);
1171
+ // Singular phrasing ("1 skill", not "1 skills").
1172
+ assert.match(out, /\b1 skill in your library\b/);
1173
+ });
1174
+
1175
+ it("renders relative timestamps across all formatRelativeDate buckets", async () => {
1176
+ // formatRelativeDate (list.mjs ~451) walks just-now → m → h → d → w
1177
+ // → ISO-date buckets plus the no-date and invalid-date `—` arms. The
1178
+ // fixed `2025-01-01` updatedAt used everywhere else only ever hits
1179
+ // the oldest (ISO) bucket. Build one skill per bucket with a
1180
+ // computed updatedAt so every branch renders.
1181
+ const now = Date.now();
1182
+ const iso = (msAgo) => new Date(now - msAgo).toISOString();
1183
+ const min = 60 * 1000;
1184
+ const hour = 60 * min;
1185
+ const day = 24 * hour;
1186
+ const week = 7 * day;
1187
+
1188
+ const skills = [
1189
+ { ...makeSkill("o", "a-justnow"), updatedAt: iso(10 * 1000) }, // <60s → "just now"
1190
+ { ...makeSkill("o", "b-minutes"), updatedAt: iso(5 * min) }, // minutes
1191
+ { ...makeSkill("o", "c-hours"), updatedAt: iso(5 * hour) }, // hours
1192
+ { ...makeSkill("o", "d-days"), updatedAt: iso(3 * day) }, // days
1193
+ { ...makeSkill("o", "e-weeks"), updatedAt: iso(3 * week) }, // weeks
1194
+ { ...makeSkill("o", "f-old"), updatedAt: iso(40 * week) }, // > 26w → ISO date
1195
+ { ...makeSkill("o", "g-nodate"), updatedAt: null }, // no date → "—"
1196
+ { ...makeSkill("o", "h-baddate"), updatedAt: "not-a-date" }, // invalid → "—"
1197
+ ];
1198
+ server.setLibraryResponse({ skills, removals: [], syncedAt: "x" });
1199
+
1200
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1201
+ const out = stdout.text();
1202
+ assert.match(out, /just now/);
1203
+ assert.match(out, /\d+m ago/);
1204
+ assert.match(out, /\d+h ago/);
1205
+ assert.match(out, /\d+d ago/);
1206
+ assert.match(out, /\d+w ago/);
1207
+ // The ISO-date fallback for the > 26w skill.
1208
+ assert.match(out, /\d{4}-\d{2}-\d{2}/);
1209
+ });
1210
+
1211
+ it("sorts skills with missing owner/name fields without crashing (|| '' guards)", async () => {
1212
+ // sortByOwnerAndName (list.mjs ~469) defaults owner/name to "" so a
1213
+ // skill missing either field still sorts deterministically instead
1214
+ // of throwing on `undefined.localeCompare`. Exercise the false side
1215
+ // of those `||` guards with skills that omit owner and/or name.
1216
+ const skills = [
1217
+ { ...makeSkill("zebra", "z-named") },
1218
+ { owner: undefined, name: undefined, version: "1.0.0", description: "no ids", files: [], updatedAt: "2025-01-01T12:00:00Z" },
1219
+ { owner: "alpha", name: undefined, version: "1.0.0", description: "no name", files: [], updatedAt: "2025-01-01T12:00:00Z" },
1220
+ ];
1221
+ server.setLibraryResponse({ skills, removals: [], syncedAt: "x" });
1222
+
1223
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1224
+ const out = stdout.text();
1225
+ // Did not crash; the fully-named skill still renders.
1226
+ assert.match(out, /z-named/);
1227
+ assert.match(out, /3 skills in your library/);
1228
+ });
1229
+ });
1230
+
1231
+ // ── streamColumns: process.stdout fallback ──────────────────────────────
1232
+ //
1233
+ // streamColumns (list.mjs ~414) tries the injected stream's `.columns`
1234
+ // first, then falls back to `process.stdout.columns`, then to 100. The
1235
+ // TTY tests above cover the first arm (injected `.columns`). This test
1236
+ // covers the SECOND arm by injecting a stream with NO numeric `.columns`
1237
+ // while temporarily giving process.stdout a numeric column count.
1238
+ describe("runList — streamColumns process.stdout fallback", () => {
1239
+ let stdoutColumnsSnapshot;
1240
+ let hadOwnColumns;
1241
+
1242
+ beforeEach(async () => {
1243
+ await setup();
1244
+ // The capture stream injected by setup() has no `.columns`, so
1245
+ // streamColumns falls through to process.stdout.columns. Force a
1246
+ // numeric value there for the duration of this test.
1247
+ hadOwnColumns = Object.prototype.hasOwnProperty.call(process.stdout, "columns");
1248
+ stdoutColumnsSnapshot = process.stdout.columns;
1249
+ process.stdout.columns = 90;
1250
+ });
1251
+ afterEach(async () => {
1252
+ if (hadOwnColumns) {
1253
+ process.stdout.columns = stdoutColumnsSnapshot;
1254
+ } else {
1255
+ delete process.stdout.columns;
1256
+ }
1257
+ await teardown();
1258
+ });
1259
+
1260
+ it("uses process.stdout.columns when the injected stream has none", async () => {
1261
+ server.setLibraryResponse({
1262
+ skills: [makeSkill("alice", "pdf-helper")],
1263
+ removals: [],
1264
+ syncedAt: "x",
1265
+ });
1266
+ // stdout here is the plain (non-columns) capture stream from setup().
1267
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1268
+ // The table still renders; the assertion is that we exercised the
1269
+ // process.stdout.columns branch without crashing.
1270
+ assert.match(stdout.text(), /pdf-helper/);
1271
+ });
1272
+ });