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.
- package/bin/skillrepo.mjs +6 -3
- package/package.json +1 -1
- package/src/commands/add.mjs +2 -1
- package/src/commands/init.mjs +15 -5
- package/src/lib/http.mjs +25 -11
- package/src/test/commands/add.test.mjs +79 -2
- package/src/test/commands/get.test.mjs +131 -2
- package/src/test/commands/init-session-sync.test.mjs +724 -0
- package/src/test/commands/init.test.mjs +159 -2
- package/src/test/commands/list.test.mjs +573 -1
- package/src/test/commands/publish.test.mjs +136 -3
- package/src/test/commands/push.test.mjs +280 -1
- package/src/test/commands/remove.test.mjs +221 -2
- package/src/test/commands/search.test.mjs +203 -1
- package/src/test/commands/session-sync.test.mjs +227 -1
- package/src/test/commands/uninstall.test.mjs +216 -0
- package/src/test/commands/update.test.mjs +218 -0
- package/src/test/dispatcher.test.mjs +103 -2
- package/src/test/e2e/advertised-surface.test.mjs +207 -0
- package/src/test/e2e/cli-commands.test.mjs +1 -1
- package/src/test/e2e/uninstall-interactive.test.mjs +93 -0
- package/src/test/e2e/update-check-suppression.test.mjs +135 -0
- package/src/test/integration/update-list-contract.integration.test.mjs +66 -0
- package/src/test/lib/browser-open.test.mjs +43 -0
- package/src/test/lib/config.test.mjs +87 -0
- package/src/test/lib/crypto-shas.test.mjs +17 -0
- package/src/test/lib/file-write.test.mjs +244 -0
- package/src/test/lib/fs-utils.test.mjs +259 -0
- package/src/test/lib/global-install.test.mjs +134 -0
- package/src/test/lib/http-timeout.test.mjs +114 -0
- package/src/test/lib/http.test.mjs +616 -1
- package/src/test/lib/mcp-merge.test.mjs +157 -0
- package/src/test/lib/npm-update-check.test.mjs +180 -0
- package/src/test/lib/placement-walk.test.mjs +132 -0
- package/src/test/lib/skill-walk.test.mjs +39 -1
- package/src/test/lib/sync.test.mjs +139 -5
- package/src/test/lib/telemetry.test.mjs +34 -0
- package/src/test/mergers/claude-mcp.test.mjs +30 -0
- package/src/test/mergers/cursor-mcp.test.mjs +115 -0
- package/src/test/mergers/env-local.test.mjs +126 -0
- package/src/test/mergers/vscode-mcp.test.mjs +177 -0
- package/src/test/mergers/windsurf-mcp.test.mjs +144 -0
- 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
|
+
});
|