skillrepo 4.6.0 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.6.0",
3
+ "version": "4.7.0",
4
4
  "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
5
5
  "type": "module",
6
6
  "bin": {
package/src/lib/sync.mjs CHANGED
@@ -69,7 +69,14 @@
69
69
  * render accurate user messages (see
70
70
  * init.mjs step 7).
71
71
  * @property {string} [syncedAt]
72
- * @property {string} syncedAt - ISO timestamp from the server response
72
+ * @property {string} syncedAt - ISO timestamp of the sync: the server
73
+ * response `syncedAt` on a 200, or the
74
+ * previously-cached sync timestamp on a
75
+ * 304 (the server sends no body). NOTE:
76
+ * this is distinct from the re-assertion
77
+ * receipt's own timestamp on a 304, which
78
+ * is the CLI's "now" (a liveness signal),
79
+ * not this cached value.
73
80
  *
74
81
  * @typedef {Object} SyncedSkillEntry
75
82
  * @property {string} version - Version of the skill as synced (e.g. "1.4.0").
@@ -417,7 +424,8 @@ export function deleteLastSyncEntry(owner, name) {
417
424
  * 2. Read the last-sync state file
418
425
  * 3. Call GET /api/v1/library with `If-None-Match` (if we have a
419
426
  * cached ETag) AND `since` (always, for delta semantics)
420
- * 4. Short-circuit on 304 return `notModified: true`
427
+ * 4. On 304 — re-assert the on-disk state via a receipt (liveness +
428
+ * heal) and short-circuit with `notModified: true`
421
429
  * 5. For each skill in the response:
422
430
  * a. Write it via `writeSkillDir` — overwrites if changed
423
431
  * b. Skip writing skills with `filesIncomplete: true` (see comment)
@@ -425,7 +433,9 @@ export function deleteLastSyncEntry(owner, name) {
425
433
  * a. Call `removeSkillDir` — deletes from all configured targets
426
434
  * 7. Persist the new ETag (if the response was complete) for the
427
435
  * next sync
428
- * 8. Return the summary
436
+ * 8. Re-assert the full on-disk state via a sync receipt (#1832) —
437
+ * the confirmed-write signal that sets team sync state
438
+ * 9. Return the summary
429
439
  *
430
440
  * Error handling: any thrown error from the network or filesystem
431
441
  * layer propagates unchanged. The caller (a command) decides how to
@@ -536,6 +546,23 @@ export async function runSync(options) {
536
546
 
537
547
  // Step 4: 304 short-circuit
538
548
  if (result.notModified) {
549
+ // Re-assert the on-disk state even though nothing changed (#1832).
550
+ // The library is unchanged, so what's on disk is exactly the prior
551
+ // `.last-sync` skills map. This receipt:
552
+ // • is the liveness signal that REPLACES the GET-side 304 heartbeat
553
+ // for receipt-era servers (>= 4.7.0 CLIs no longer trigger it), so
554
+ // a deliberately-stable library doesn't drift to "stale"; and
555
+ // • heals any delivery the server missed (a receipt POST that failed
556
+ // on a prior sync), correcting drift in the safe direction.
557
+ // Timestamp is the CLI's "now" so freshness advances; the server's
558
+ // 5-minute future tolerance absorbs ordinary clock skew. Best-effort.
559
+ await reportReceipt(
560
+ serverUrl,
561
+ apiKey,
562
+ receiptEntriesFromSkillsMap(lastSync?.skills),
563
+ new Date().toISOString(),
564
+ stderr,
565
+ );
539
566
  return {
540
567
  added: 0,
541
568
  updated: 0,
@@ -606,11 +633,6 @@ export async function runSync(options) {
606
633
  let anyIncomplete = false;
607
634
  /** @type {Record<string, SyncedSkillEntry>} */
608
635
  const skillsMap = {};
609
- // Skills actually written to disk this sync, for the confirmed-write
610
- // receipt (#1832). Only what we WRITE this round — carry-forward skills
611
- // were reported on the sync that wrote them.
612
- /** @type {{owner: string, name: string, version: string}[]} */
613
- const writtenThisSync = [];
614
636
  // Carry forward entries from the previous sync for skills we did
615
637
  // NOT re-fetch this round. A delta sync that touched 2 of 50
616
638
  // skills must keep the other 48's metadata — otherwise the
@@ -674,17 +696,6 @@ export async function runSync(options) {
674
696
  syncedAt: result.syncedAt,
675
697
  };
676
698
  }
677
-
678
- // Record this write for the sync receipt (#1832). Only entries with a
679
- // real version label can resolve to a delivery server-side, so skip
680
- // null/empty versions (a skill with no current published version).
681
- if (typeof skill.version === "string" && skill.version !== "") {
682
- writtenThisSync.push({
683
- owner: skill.owner,
684
- name: skill.name,
685
- version: skill.version,
686
- });
687
- }
688
699
  }
689
700
 
690
701
  // Step 7: persist new ETag + skills map (only if the response was
@@ -712,33 +723,92 @@ export async function runSync(options) {
712
723
  }
713
724
  }
714
725
 
715
- // Post a sync receipt for the skills actually written this sync
716
- // (#1832). This confirmed-write signal — not the fetch itself — is what
717
- // sets team sync state. Independent of the ETag persist above: skills
718
- // skipped for `filesIncomplete` are excluded from `writtenThisSync`, so
719
- // a partial sync still reports exactly what landed on disk. Best-effort:
720
- // the files are already written and the server dedups replays, so a
721
- // failed receipt is a non-fatal warning, never a sync failure.
722
- if (writtenThisSync.length > 0) {
723
- try {
724
- await postSyncReceipt(serverUrl, apiKey, {
725
- skills: writtenThisSync,
726
- syncedAt: result.syncedAt,
727
- });
728
- } catch (err) {
729
- stderr.write(
730
- ` warning: failed to report sync receipt (${err.message}). ` +
731
- `Your skills are synced locally; team sync status may lag until ` +
732
- `your next sync.\n`,
733
- );
734
- }
735
- }
726
+ // Re-assert the full on-disk state via a sync receipt (#1832). This
727
+ // confirmed-write signal — not the fetch itself — is what sets team
728
+ // sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
729
+ // carry-forward skills + the skills written this round), not just what
730
+ // changed, so the receipt heals any delivery the server missed (a prior
731
+ // receipt POST that failed) and re-stamps freshness, replacing the
732
+ // GET-side heartbeat. `skillsMap` already reflects exactly what is on
733
+ // disk: skills skipped for `filesIncomplete` keep their prior carry-
734
+ // forward version (still on disk) and tombstoned skills were deleted
735
+ // from the map, so a partial or delta sync still re-asserts precisely
736
+ // what landed on disk. The server dedups (user, skill, version, UTC
737
+ // day), so re-asserting unchanged versions doesn't bloat the event log.
738
+ // Best-effort: files are already written, so a failed receipt is a
739
+ // non-fatal warning, never a sync failure.
740
+ await reportReceipt(
741
+ serverUrl,
742
+ apiKey,
743
+ receiptEntriesFromSkillsMap(skillsMap),
744
+ result.syncedAt,
745
+ stderr,
746
+ );
736
747
 
737
748
  return summary;
738
749
  }
739
750
 
740
751
  // ── Internals ──────────────────────────────────────────────────────────
741
752
 
753
+ /**
754
+ * Build sync-receipt entries from a `.last-sync` skills map (#1832).
755
+ *
756
+ * Each key is `"<owner>/<name>"`; the value carries the version label of
757
+ * the skill as written to disk. The returned `[{ owner, name, version }]`
758
+ * array is the CLI's assertion of what it actually has on disk — the
759
+ * evidence the server treats as the single source of truth for sync
760
+ * state. Entries with no version label are skipped: a skill with no
761
+ * published version can't resolve to a delivery server-side. Malformed
762
+ * keys (no `/`, empty owner/name) are skipped defensively rather than
763
+ * producing a bogus receipt entry.
764
+ *
765
+ * @param {Record<string, {version?: string}> | null | undefined} skillsMap
766
+ * @returns {{owner: string, name: string, version: string}[]}
767
+ */
768
+ function receiptEntriesFromSkillsMap(skillsMap) {
769
+ /** @type {{owner: string, name: string, version: string}[]} */
770
+ const entries = [];
771
+ if (!skillsMap || typeof skillsMap !== "object") return entries;
772
+ for (const [key, entry] of Object.entries(skillsMap)) {
773
+ if (!entry || typeof entry !== "object") continue;
774
+ const version = typeof entry.version === "string" ? entry.version : "";
775
+ if (version === "") continue;
776
+ const slashAt = key.indexOf("/");
777
+ if (slashAt < 0) continue;
778
+ const owner = key.slice(0, slashAt);
779
+ const name = key.slice(slashAt + 1);
780
+ if (!owner || !name) continue;
781
+ entries.push({ owner, name, version });
782
+ }
783
+ return entries;
784
+ }
785
+
786
+ /**
787
+ * Post a sync receipt, best-effort (#1832). A no-op when `skills` is
788
+ * empty (nothing on disk to assert — e.g. an empty library). The skill
789
+ * files are already on disk and the server dedups replays, so a failed
790
+ * receipt is a non-fatal warning surfaced via the injected stderr
791
+ * stream — never a sync failure.
792
+ *
793
+ * @param {string} serverUrl
794
+ * @param {string} apiKey
795
+ * @param {{owner: string, name: string, version: string}[]} skills
796
+ * @param {string} syncedAt - ISO timestamp for the delivery + dedup key.
797
+ * @param {NodeJS.WritableStream} stderr
798
+ */
799
+ async function reportReceipt(serverUrl, apiKey, skills, syncedAt, stderr) {
800
+ if (skills.length === 0) return;
801
+ try {
802
+ await postSyncReceipt(serverUrl, apiKey, { skills, syncedAt });
803
+ } catch (err) {
804
+ stderr.write(
805
+ ` warning: failed to report sync receipt (${err.message}). ` +
806
+ `Your skills are synced locally; team sync status may lag until ` +
807
+ `your next sync.\n`,
808
+ );
809
+ }
810
+ }
811
+
742
812
  /**
743
813
  * Check whether ANY of the configured placement targets already
744
814
  * contains a directory for the given skill name. Used to distinguish
@@ -415,7 +415,7 @@ describe("runSync — sync receipts (#1832)", () => {
415
415
  }
416
416
  });
417
417
 
418
- it("does NOT post a receipt on a 304 (nothing written)", async () => {
418
+ it("re-asserts the on-disk state on a 304 (liveness + heal), not nothing", async () => {
419
419
  server.setEtag('"v1"');
420
420
  server.setLibraryResponse({
421
421
  skills: [makeSkill("pdf-helper")],
@@ -427,14 +427,120 @@ describe("runSync — sync receipts (#1832)", () => {
427
427
  assert.equal(server.getReceiptRequestCount(), 1);
428
428
  server.resetReceipts();
429
429
 
430
- // Second sync 304-short-circuits (etag matches, placement present).
430
+ // Second sync 304-short-circuits (etag matches, placement present) but
431
+ // STILL posts a receipt re-asserting the on-disk set. This is the
432
+ // liveness signal that replaces the GET-side 304 heartbeat for
433
+ // receipt-era servers, and it heals any delivery a prior failed
434
+ // receipt POST lost (#1832 PR B).
431
435
  const result = await runSync({
432
436
  serverUrl,
433
437
  apiKey: VALID_KEY,
434
438
  vendors: ["claudeCode"],
435
439
  });
436
440
  assert.equal(result.notModified, true);
437
- assert.equal(server.getReceiptRequestCount(), 0);
441
+ assert.equal(server.getReceiptRequestCount(), 1);
442
+ const receipt = server.getLastReceipt();
443
+ assert.deepEqual(
444
+ receipt.skills.map((s) => s.name),
445
+ ["pdf-helper"],
446
+ );
447
+ assert.equal(receipt.skills[0].version, "1.0.0");
448
+ // syncedAt on a 304 is the CLI's "now" (the server sends no body),
449
+ // so freshness advances — not the stale first-sync timestamp.
450
+ assert.ok(
451
+ Number.isFinite(new Date(receipt.syncedAt).getTime()),
452
+ "304 receipt carries a valid ISO syncedAt",
453
+ );
454
+ assert.notEqual(
455
+ receipt.syncedAt,
456
+ "2025-06-01T00:00:00Z",
457
+ "304 receipt uses the CLI's now, not the prior sync timestamp",
458
+ );
459
+ // Stronger than "not the fixture": prove the timestamp is wall-clock
460
+ // FRESH. A regression that stamped the receipt with any stale cached
461
+ // value (e.g. lastSync.syncedAt) could differ from the fixture and
462
+ // still pass the notEqual above — but it would fail this. The liveness
463
+ // signal that replaces the 304 heartbeat must advance every sync.
464
+ assert.ok(
465
+ Date.now() - new Date(receipt.syncedAt).getTime() < 60_000,
466
+ "304 receipt syncedAt must be wall-clock fresh (within the last minute)",
467
+ );
468
+ });
469
+
470
+ it("re-asserts the full on-disk set (carry-forward + newly written) on a delta sync", async () => {
471
+ server.setEtag('"v1"');
472
+ server.setLibraryResponse({
473
+ skills: [makeSkill("alpha"), makeSkill("beta")],
474
+ removals: [],
475
+ syncedAt: "2025-01-01T00:00:00Z",
476
+ });
477
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
478
+ server.resetReceipts();
479
+
480
+ // Library changes: only `gamma` is new this round; alpha+beta are
481
+ // unchanged and arrive only via the carry-forward `.last-sync` map.
482
+ server.setEtag('"v2"');
483
+ server.setLibraryResponse({
484
+ skills: [makeSkill("gamma")],
485
+ removals: [],
486
+ syncedAt: "2025-01-02T00:00:00Z",
487
+ });
488
+ const result = await runSync({
489
+ serverUrl,
490
+ apiKey: VALID_KEY,
491
+ vendors: ["claudeCode"],
492
+ });
493
+
494
+ assert.equal(result.notModified, false);
495
+ assert.equal(server.getReceiptRequestCount(), 1);
496
+ const receipt = server.getLastReceipt();
497
+ // The receipt re-asserts EVERYTHING on disk, not just the changed
498
+ // skill — that is what heals server drift and keeps freshness honest.
499
+ assert.deepEqual(
500
+ receipt.skills.map((s) => s.name).sort(),
501
+ ["alpha", "beta", "gamma"],
502
+ );
503
+ // A 200 uses the server's response syncedAt for the delivery time.
504
+ assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
505
+ });
506
+
507
+ it("re-asserts carry-forward on a 200 delta that writes nothing new", async () => {
508
+ // Distinct from the 304 short-circuit: here the ETag changes (a real
509
+ // 200) but the response carries no skills to write, so nothing new
510
+ // lands on disk. The receipt must still re-assert the full carry-
511
+ // forward set — the 200 path's re-assertion is driven by the on-disk
512
+ // `.last-sync` map, not by what was written this round.
513
+ server.setEtag('"v1"');
514
+ server.setLibraryResponse({
515
+ skills: [makeSkill("alpha"), makeSkill("beta")],
516
+ removals: [],
517
+ syncedAt: "2025-01-01T00:00:00Z",
518
+ });
519
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
520
+ server.resetReceipts();
521
+
522
+ server.setEtag('"v2"');
523
+ server.setLibraryResponse({
524
+ skills: [],
525
+ removals: [],
526
+ syncedAt: "2025-01-02T00:00:00Z",
527
+ });
528
+ const result = await runSync({
529
+ serverUrl,
530
+ apiKey: VALID_KEY,
531
+ vendors: ["claudeCode"],
532
+ });
533
+
534
+ assert.equal(result.notModified, false);
535
+ assert.equal(result.added, 0);
536
+ assert.equal(result.updated, 0);
537
+ assert.equal(server.getReceiptRequestCount(), 1);
538
+ const receipt = server.getLastReceipt();
539
+ assert.deepEqual(
540
+ receipt.skills.map((s) => s.name).sort(),
541
+ ["alpha", "beta"],
542
+ );
543
+ assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
438
544
  });
439
545
 
440
546
  it("excludes a filesIncomplete skill from the receipt", async () => {
@@ -454,9 +560,48 @@ describe("runSync — sync receipts (#1832)", () => {
454
560
  );
455
561
  });
456
562
 
457
- it("does NOT post a receipt when ALL skills are filesIncomplete", async () => {
458
- // Distinct branch from the mixed case above: `writtenThisSync` stays
459
- // empty, so the `writtenThisSync.length > 0` guard skips the POST.
563
+ it("re-asserts the OLD on-disk version when a newer version fails to inline (filesIncomplete carry-forward)", async () => {
564
+ // First sync writes pdf-helper@1.0.0.
565
+ server.setEtag('"v1"');
566
+ server.setLibraryResponse({
567
+ skills: [makeSkill("pdf-helper")],
568
+ removals: [],
569
+ syncedAt: "2025-01-01T00:00:00Z",
570
+ });
571
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
572
+ server.resetReceipts();
573
+
574
+ // The server tries to deliver pdf-helper@1.1.0 but its files fail to
575
+ // inline. The CLI skips the write (1.0.0 stays on disk) and must
576
+ // re-assert the OLD version it still has — not the version it failed
577
+ // to write, and not nothing.
578
+ const newer = makeSkill("pdf-helper");
579
+ newer.version = "1.1.0";
580
+ newer.filesIncomplete = true;
581
+ server.setEtag('"v2"');
582
+ server.setLibraryResponse({
583
+ skills: [newer],
584
+ removals: [],
585
+ syncedAt: "2025-01-02T00:00:00Z",
586
+ });
587
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
588
+
589
+ assert.equal(server.getReceiptRequestCount(), 1);
590
+ const receipt = server.getLastReceipt();
591
+ assert.deepEqual(receipt.skills, [
592
+ { owner: "alice", name: "pdf-helper", version: "1.0.0" },
593
+ ]);
594
+ // Freshness still advances on a partial sync: the receipt carries the
595
+ // SERVER's 200 response syncedAt (the fresh sync time), not the stale
596
+ // carry-forward entry's own recorded syncedAt.
597
+ assert.equal(receipt.syncedAt, "2025-01-02T00:00:00Z");
598
+ });
599
+
600
+ it("does NOT post a receipt when ALL skills are filesIncomplete (nothing on disk to assert)", async () => {
601
+ // A first sync where every skill is filesIncomplete writes nothing
602
+ // and has no carry-forward `.last-sync` baseline, so the post-sync
603
+ // on-disk set is empty and `reportReceipt`'s empty-skills guard skips
604
+ // the POST.
460
605
  const a = makeSkill("inc-a");
461
606
  a.filesIncomplete = true;
462
607
  const b = makeSkill("inc-b");
@@ -821,10 +966,41 @@ describe("runSync — tombstones", () => {
821
966
  });
822
967
  assert.equal(result.removed, 1);
823
968
  assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
824
- // A tombstone-only sync writes nothing posts no receipt (#1832).
969
+ // The `.last-sync` baseline was reset (rm above) and the only skill
970
+ // was tombstoned, so the post-sync on-disk set is empty → the
971
+ // re-assertion receipt has nothing to send and is skipped (#1832).
825
972
  assert.equal(server.getReceiptRequestCount(), 0);
826
973
  });
827
974
 
975
+ it("re-asserts surviving skills but excludes a tombstoned one", async () => {
976
+ server.setEtag('"v1"');
977
+ server.setLibraryResponse({
978
+ skills: [makeSkill("keep"), makeSkill("drop")],
979
+ removals: [],
980
+ syncedAt: "2025-01-01T00:00:00Z",
981
+ });
982
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
983
+ server.resetReceipts();
984
+
985
+ // `drop` is tombstoned; `keep` stays on disk and must be re-asserted.
986
+ server.setEtag('"v2"');
987
+ server.setLibraryResponse({
988
+ skills: [],
989
+ removals: [
990
+ { owner: "alice", name: "drop", removedAt: "2025-01-02T00:00:00Z" },
991
+ ],
992
+ syncedAt: "2025-01-02T00:00:00Z",
993
+ });
994
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
995
+
996
+ assert.equal(server.getReceiptRequestCount(), 1);
997
+ const receipt = server.getLastReceipt();
998
+ assert.deepEqual(
999
+ receipt.skills.map((s) => s.name),
1000
+ ["keep"],
1001
+ );
1002
+ });
1003
+
828
1004
  it("applies removals BEFORE writes (re-add scenario)", async () => {
829
1005
  // The server returns a tombstone AND a skill with the same name
830
1006
  // (the user removed and re-added in the same window). The CLI