skillrepo 4.5.2 → 4.6.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.5.2",
3
+ "version": "4.6.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": {
@@ -56,7 +56,14 @@ export async function runList(argv, io = {}) {
56
56
  const stdout = io.stdout ?? process.stdout;
57
57
  const flags = resolveFlags(argv);
58
58
 
59
- const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey);
59
+ // `list` is a read-only drift check — it must NEVER set sync state.
60
+ // Use the manifest read (#1832): metadata only, no file bodies, and the
61
+ // server records no delivery for it. Per-skill drift is computed from
62
+ // on-disk SHAs + `.last-sync` below, never from the response body, so
63
+ // the empty `files` array a manifest returns is irrelevant here.
64
+ const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey, {
65
+ manifest: true,
66
+ });
60
67
 
61
68
  // Defensive guard: `list` calls getLibrary without an If-None-Match
62
69
  // header so it can't legitimately receive a 304 today. But getLibrary's
package/src/lib/http.mjs CHANGED
@@ -305,10 +305,20 @@ async function mapErrorResponse(res, url) {
305
305
  if (res.status === 401) {
306
306
  // Derive the auth URL from the request URL's origin so users
307
307
  // running against staging see a staging hint, not a prod hint.
308
- // `url` here is the full request URL (e.g.
309
- // https://staging.skillrepo.dev/api/v1/library); `new URL(...).origin`
310
- // gives us "https://staging.skillrepo.dev" which is what
311
- // `cliAuthUrl` expects.
308
+ // `url` here is the full request URL — typically
309
+ // `https://staging.skillrepo.dev/api/v1/library` (canonical) or
310
+ // `https://api.staging.skillrepo.dev/v1/library` (subdomain alias
311
+ // per #1588). `new URL(...).origin` strips back to the host —
312
+ // `https://staging.skillrepo.dev` or `https://api.staging.skillrepo.dev`
313
+ // — and `cliAuthUrl` builds the `/cli/auth` URL from there.
314
+ //
315
+ // Known limitation: if the request URL uses the api.* / mcp.*
316
+ // subdomain, the resulting auth hint URL points at that subdomain
317
+ // (`https://api.staging.skillrepo.dev/cli/auth`), which 404s
318
+ // because /cli/auth only lives on the canonical host. Today the
319
+ // CLI defaults to the canonical URL so this is latent; surfaces
320
+ // only when a user passes `--url https://api.<env>.skillrepo.dev`
321
+ // explicitly. Tracked separately — not blocking this PR.
312
322
  let authUrl = DEFAULT_CLI_AUTH_URL;
313
323
  try {
314
324
  authUrl = cliAuthUrl(new URL(url).origin);
@@ -526,12 +536,20 @@ export async function validateAccessKey(serverUrl, apiKey, source = "validate")
526
536
  * @param {object} [opts]
527
537
  * @param {string} [opts.ifNoneMatch] - ETag to send as If-None-Match
528
538
  * @param {string} [opts.since] - ISO timestamp for delta filter
539
+ * @param {boolean} [opts.manifest] - Request a metadata-only manifest:
540
+ * the server returns empty `files` for every skill and records NO
541
+ * delivery. Used by `skillrepo list` for a read-only drift check that
542
+ * must not set sync state (#1832).
529
543
  * @returns {Promise<LibrarySyncResult>}
530
544
  */
531
545
  export async function getLibrary(serverUrl, apiKey, opts = {}) {
532
546
  const base = normalizeUrl(serverUrl);
533
547
  const params = new URLSearchParams();
534
548
  if (opts.since) params.set("since", opts.since);
549
+ // Manifest mode (#1832): metadata-only, non-recording read used by
550
+ // `skillrepo list`. A peek must not set sync state, so the server
551
+ // returns empty `files` and records no delivery for this request.
552
+ if (opts.manifest) params.set("manifest", "1");
535
553
  const url = params.toString()
536
554
  ? `${base}/api/v1/library?${params.toString()}`
537
555
  : `${base}/api/v1/library`;
@@ -573,6 +591,56 @@ export async function getLibrary(serverUrl, apiKey, opts = {}) {
573
591
  };
574
592
  }
575
593
 
594
+ /**
595
+ * POST /api/v1/library/sync-receipts — report confirmed local writes (#1832).
596
+ *
597
+ * Called by `runSync` AFTER it successfully writes skill files to disk.
598
+ * Carries the (owner, name, version) pairs actually written so the server
599
+ * records sync state from a confirmed write, never from a fetch response.
600
+ *
601
+ * Best-effort + retryable: the receipt is telemetry and the files are
602
+ * already on disk by the time we call this, so callers treat a failure as
603
+ * a non-fatal warning. Transient 5xx / network failures retry — the server
604
+ * dedups by `(user, skill, version, UTC day)` and stamps idempotently, so
605
+ * a replay is safe. A non-2xx after retries throws a typed error.
606
+ *
607
+ * @param {string} serverUrl
608
+ * @param {string} apiKey
609
+ * @param {object} receipt
610
+ * @param {{owner: string, name: string, version: string}[]} receipt.skills
611
+ * The skills written to disk this sync. Empty array is a valid no-op.
612
+ * @param {string} receipt.syncedAt - ISO timestamp of the sync.
613
+ * @returns {Promise<{recorded: number}>}
614
+ */
615
+ export async function postSyncReceipt(serverUrl, apiKey, receipt) {
616
+ const url = `${normalizeUrl(serverUrl)}/api/v1/library/sync-receipts`;
617
+ const res = await safeFetch(url, {
618
+ method: "POST",
619
+ headers: {
620
+ ...(await authHeaders(apiKey)),
621
+ "Content-Type": "application/json",
622
+ },
623
+ body: JSON.stringify({
624
+ syncedAt: receipt.syncedAt,
625
+ skills: receipt.skills,
626
+ }),
627
+ // Idempotent server-side (dedup bucket + idempotent UPDATE), so a
628
+ // transient 5xx / network blip is safe to retry.
629
+ retry: true,
630
+ });
631
+ if (!res.ok) {
632
+ const err = await mapErrorResponse(res, url);
633
+ if (err === null) {
634
+ throw validationError(
635
+ `The server at ${normalizeUrl(serverUrl)} does not expose ` +
636
+ `/api/v1/library/sync-receipts. Is --url correct?`,
637
+ );
638
+ }
639
+ throw err;
640
+ }
641
+ return parseJsonOrThrow(res, url);
642
+ }
643
+
576
644
  // ── /api/v1/skills/[owner]/[name] ───────────────────────────────────────
577
645
 
578
646
  /**
package/src/lib/sync.mjs CHANGED
@@ -87,7 +87,7 @@
87
87
  import { existsSync, readFileSync } from "node:fs";
88
88
  import { join } from "node:path";
89
89
 
90
- import { getLibrary } from "./http.mjs";
90
+ import { getLibrary, postSyncReceipt } from "./http.mjs";
91
91
  import {
92
92
  writeSkillDir,
93
93
  removeSkillDir,
@@ -606,6 +606,11 @@ export async function runSync(options) {
606
606
  let anyIncomplete = false;
607
607
  /** @type {Record<string, SyncedSkillEntry>} */
608
608
  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 = [];
609
614
  // Carry forward entries from the previous sync for skills we did
610
615
  // NOT re-fetch this round. A delta sync that touched 2 of 50
611
616
  // skills must keep the other 48's metadata — otherwise the
@@ -669,6 +674,17 @@ export async function runSync(options) {
669
674
  syncedAt: result.syncedAt,
670
675
  };
671
676
  }
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
+ }
672
688
  }
673
689
 
674
690
  // Step 7: persist new ETag + skills map (only if the response was
@@ -696,6 +712,28 @@ export async function runSync(options) {
696
712
  }
697
713
  }
698
714
 
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
+ }
736
+
699
737
  return summary;
700
738
  }
701
739
 
@@ -92,6 +92,20 @@ describe("runList — happy path", () => {
92
92
  assert.match(out, /2 skills in your library/);
93
93
  });
94
94
 
95
+ it("makes a non-recording manifest read (#1832)", async () => {
96
+ server.setLibraryResponse({
97
+ skills: [makeSkill("alice", "pdf-helper")],
98
+ removals: [],
99
+ syncedAt: "x",
100
+ });
101
+ await runList(["--key", VALID_KEY, "--url", serverUrl], { stdout });
102
+ // `list` is a peek — it must hit the metadata-only manifest path so
103
+ // the server records no delivery for it...
104
+ assert.equal(server.getLastLibraryManifest(), "1");
105
+ // ...and it never posts a sync receipt (that's a write-confirm signal).
106
+ assert.equal(server.getReceiptRequestCount(), 0);
107
+ });
108
+
95
109
  it("sorts alphabetically by owner then name", async () => {
96
110
  server.setLibraryResponse({
97
111
  skills: [
@@ -91,6 +91,19 @@ export function createMockServer(initialPayload, options = {}) {
91
91
  /** @type {string | null} */
92
92
  let lastLibrarySince = null;
93
93
  let libraryRequestCount = 0;
94
+ // #1832 — `?manifest=1` capture for the GET /api/v1/library route so
95
+ // tests can assert `skillrepo list` made the non-recording manifest read.
96
+ /** @type {string | null} */
97
+ let lastLibraryManifest = null;
98
+
99
+ // #1832 — POST /api/v1/library/sync-receipts inspection + response slot.
100
+ // `lastReceipt` holds the most recent JSON-parsed receipt body;
101
+ // `receiptResponse` overrides the default 200 so tests can simulate
102
+ // failures (the CLI treats a failed receipt as a non-fatal warning).
103
+ let lastReceipt = null;
104
+ let receiptRequestCount = 0;
105
+ /** @type {{ status: number, body: any } | null} */
106
+ let receiptResponse = null;
94
107
 
95
108
  // PR3a mutable slots for POST/DELETE library routes
96
109
  //
@@ -274,6 +287,41 @@ export function createMockServer(initialPayload, options = {}) {
274
287
  return;
275
288
  }
276
289
 
290
+ // ── #1832: POST /api/v1/library/sync-receipts (confirmed-write) ─
291
+ //
292
+ // The CLI posts this after `runSync` writes skills to disk. Default
293
+ // 200 echoes a `recorded` count; tests can override via
294
+ // setReceiptResponse to exercise the non-fatal-warning path.
295
+ if (
296
+ url.pathname === "/api/v1/library/sync-receipts" &&
297
+ req.method === "POST"
298
+ ) {
299
+ if (!checkAuth(req, res)) return;
300
+ let body;
301
+ try {
302
+ body = rawBody.length > 0 ? JSON.parse(rawBody) : {};
303
+ } catch {
304
+ res.writeHead(400, { "Content-Type": "application/json" });
305
+ res.end(
306
+ JSON.stringify({ error: "Body must be JSON", code: "bad_request" }),
307
+ );
308
+ return;
309
+ }
310
+ lastReceipt = body;
311
+ receiptRequestCount++;
312
+ if (receiptResponse) {
313
+ res.writeHead(receiptResponse.status, {
314
+ "Content-Type": "application/json",
315
+ });
316
+ res.end(JSON.stringify(receiptResponse.body ?? {}));
317
+ return;
318
+ }
319
+ const recorded = Array.isArray(body?.skills) ? body.skills.length : 0;
320
+ res.writeHead(200, { "Content-Type": "application/json" });
321
+ res.end(JSON.stringify({ recorded }));
322
+ return;
323
+ }
324
+
277
325
  // ── #1452: POST /api/v1/library (multipart file-push) ──────────
278
326
  //
279
327
  // Accepts multipart/form-data and returns a synthesized
@@ -507,6 +555,7 @@ export function createMockServer(initialPayload, options = {}) {
507
555
  const sinceParam = url.searchParams.get("since");
508
556
  lastLibraryIfNoneMatch = ifNoneMatch ?? null;
509
557
  lastLibrarySince = sinceParam ?? null;
558
+ lastLibraryManifest = url.searchParams.get("manifest");
510
559
  libraryRequestCount++;
511
560
  if (libraryEtag && ifNoneMatch === libraryEtag) {
512
561
  res.writeHead(304, { ETag: libraryEtag });
@@ -673,9 +722,53 @@ export function createMockServer(initialPayload, options = {}) {
673
722
  resetLibraryInspection() {
674
723
  lastLibraryIfNoneMatch = null;
675
724
  lastLibrarySince = null;
725
+ lastLibraryManifest = null;
676
726
  libraryRequestCount = 0;
677
727
  },
678
728
 
729
+ /**
730
+ * Inspect the inbound `?manifest=` query param from the LAST request
731
+ * to GET /api/v1/library. `"1"` means the client made the
732
+ * non-recording manifest read (#1832, `skillrepo list`); `null`
733
+ * means a normal recording sync.
734
+ */
735
+ getLastLibraryManifest() {
736
+ return lastLibraryManifest;
737
+ },
738
+
739
+ // ── #1832: sync-receipt slots ─────────────────────────────────
740
+
741
+ /**
742
+ * Return the most recent POST /api/v1/library/sync-receipts body
743
+ * (JSON-parsed), or null if none received. Tests assert on
744
+ * `.skills` (the confirmed-write list) and `.syncedAt`.
745
+ */
746
+ getLastReceipt() {
747
+ return lastReceipt;
748
+ },
749
+
750
+ /** Count of sync-receipt POSTs since server start or reset. */
751
+ getReceiptRequestCount() {
752
+ return receiptRequestCount;
753
+ },
754
+
755
+ /**
756
+ * Override the sync-receipt response to simulate a failure (e.g.
757
+ * `{ status: 500, body: { error: "boom" } }`). The CLI treats a
758
+ * failed receipt as a non-fatal warning, so tests use this to
759
+ * assert the sync still succeeds. Pass null to restore the 200.
760
+ */
761
+ setReceiptResponse(override) {
762
+ receiptResponse = override;
763
+ },
764
+
765
+ /** Reset sync-receipt inspection slots. */
766
+ resetReceipts() {
767
+ lastReceipt = null;
768
+ receiptRequestCount = 0;
769
+ receiptResponse = null;
770
+ },
771
+
679
772
  /** Register a single-skill response keyed by `owner/name`. */
680
773
  setSkillResponse(owner, name, skill) {
681
774
  skillResponses.set(`${owner}/${name}`, skill);
@@ -35,6 +35,7 @@ import { once } from "node:events";
35
35
  import {
36
36
  validateAccessKey,
37
37
  getLibrary,
38
+ postSyncReceipt,
38
39
  getSkill,
39
40
  searchSkills,
40
41
  addSkillToLibrary,
@@ -520,6 +521,20 @@ describe("getLibrary", () => {
520
521
  }
521
522
  });
522
523
 
524
+ it("sends manifest=1 when the manifest option is set (#1832)", async () => {
525
+ let capturedUrl;
526
+ const srv = await startServer((req, res) => {
527
+ capturedUrl = req.url;
528
+ jsonRes(res, 200, { skills: [], removals: [], syncedAt: "x" });
529
+ });
530
+ try {
531
+ await getLibrary(srv.url, VALID_KEY, { manifest: true });
532
+ assert.match(capturedUrl, /[?&]manifest=1/);
533
+ } finally {
534
+ await srv.close();
535
+ }
536
+ });
537
+
523
538
  it("tolerates missing fields in the response (defaults applied)", async () => {
524
539
  const srv = await startServer((req, res) => {
525
540
  jsonRes(res, 200, {});
@@ -563,6 +578,94 @@ describe("getLibrary", () => {
563
578
  });
564
579
  });
565
580
 
581
+ // ── postSyncReceipt ─────────────────────────────────────────────────────
582
+
583
+ describe("postSyncReceipt", () => {
584
+ it("POSTs the receipt body and returns the parsed result", async () => {
585
+ const srv = await startServer((req, res) => {
586
+ jsonRes(res, 200, { recorded: 2 });
587
+ });
588
+ try {
589
+ const result = await postSyncReceipt(srv.url, VALID_KEY, {
590
+ syncedAt: "2025-06-01T00:00:00Z",
591
+ skills: [
592
+ { owner: "alice", name: "pdf-helper", version: "1.0" },
593
+ { owner: "bob", name: "code-review", version: "2.1" },
594
+ ],
595
+ });
596
+ assert.equal(result.recorded, 2);
597
+ assert.equal(srv.lastRequest.method, "POST");
598
+ assert.match(srv.lastRequest.url, /\/api\/v1\/library\/sync-receipts$/);
599
+ assert.equal(
600
+ srv.lastRequest.headers.authorization,
601
+ `Bearer ${VALID_KEY}`,
602
+ );
603
+ assert.match(
604
+ srv.lastRequest.headers["content-type"],
605
+ /application\/json/,
606
+ );
607
+ const sent = JSON.parse(srv.lastRequest.body);
608
+ assert.equal(sent.syncedAt, "2025-06-01T00:00:00Z");
609
+ assert.equal(sent.skills.length, 2);
610
+ assert.deepEqual(sent.skills[0], {
611
+ owner: "alice",
612
+ name: "pdf-helper",
613
+ version: "1.0",
614
+ });
615
+ } finally {
616
+ await srv.close();
617
+ }
618
+ });
619
+
620
+ it("throws authError on 401", async () => {
621
+ const srv = await startServer((req, res) =>
622
+ jsonRes(res, 401, { error: "nope" }),
623
+ );
624
+ try {
625
+ await assert.rejects(
626
+ () =>
627
+ postSyncReceipt(srv.url, VALID_KEY, { syncedAt: "x", skills: [] }),
628
+ (err) => err instanceof CliError && err.exitCode === EXIT_AUTH,
629
+ );
630
+ } finally {
631
+ await srv.close();
632
+ }
633
+ });
634
+
635
+ it("throws validationError on 404 (endpoint missing — wrong URL)", async () => {
636
+ const srv = await startServer((req, res) =>
637
+ jsonRes(res, 404, { error: "nope" }),
638
+ );
639
+ try {
640
+ await assert.rejects(
641
+ () =>
642
+ postSyncReceipt(srv.url, VALID_KEY, { syncedAt: "x", skills: [] }),
643
+ (err) => err instanceof CliError && err.exitCode === EXIT_VALIDATION,
644
+ );
645
+ } finally {
646
+ await srv.close();
647
+ }
648
+ });
649
+
650
+ it("throws networkError on a persistent 5xx (runSync treats it as non-fatal)", async () => {
651
+ // Pins the http-layer contract: a 5xx must THROW so runSync's
652
+ // try/catch surfaces the non-fatal warning. If this ever started
653
+ // returning null instead, the warning path would silently break.
654
+ const srv = await startServer((req, res) =>
655
+ jsonRes(res, 500, { error: "boom" }),
656
+ );
657
+ try {
658
+ await assert.rejects(
659
+ () =>
660
+ postSyncReceipt(srv.url, VALID_KEY, { syncedAt: "x", skills: [] }),
661
+ (err) => err instanceof CliError && err.exitCode === EXIT_NETWORK,
662
+ );
663
+ } finally {
664
+ await srv.close();
665
+ }
666
+ });
667
+ });
668
+
566
669
  // ── getSkill ────────────────────────────────────────────────────────────
567
670
 
568
671
  describe("getSkill", () => {
@@ -76,7 +76,7 @@ describe("mergeMcpForVendors — happy path", () => {
76
76
  it("creates .mcp.json when it does not exist (claudeCode)", async () => {
77
77
  const results = await mergeMcpForVendors({
78
78
  vendors: ["claudeCode"],
79
- mcpUrl: "https://skillrepo.dev/api/mcp",
79
+ mcpUrl: "https://mcp.skillrepo.dev",
80
80
  yes: true,
81
81
  io: { stdout, stderr },
82
82
  });
@@ -88,7 +88,7 @@ describe("mergeMcpForVendors — happy path", () => {
88
88
  const mcp = JSON.parse(readFileSync(join(process.cwd(), ".mcp.json"), "utf-8"));
89
89
  assert.ok(mcp.mcpServers?.skillrepo);
90
90
  assert.equal(mcp.mcpServers.skillrepo.type, "http");
91
- assert.equal(mcp.mcpServers.skillrepo.url, "https://skillrepo.dev/api/mcp");
91
+ assert.equal(mcp.mcpServers.skillrepo.url, "https://mcp.skillrepo.dev");
92
92
  });
93
93
 
94
94
  it("merges into existing .mcp.json preserving other servers", async () => {
@@ -390,6 +390,124 @@ describe("runSync — write skills", () => {
390
390
  });
391
391
  });
392
392
 
393
+ // ── runSync — sync receipts (#1832) ────────────────────────────────────
394
+
395
+ describe("runSync — sync receipts (#1832)", () => {
396
+ beforeEach(setupServer);
397
+ afterEach(teardownServer);
398
+
399
+ it("posts a receipt with the skills it wrote", async () => {
400
+ server.setLibraryResponse({
401
+ skills: [makeSkill("pdf-helper"), makeSkill("code-review")],
402
+ removals: [],
403
+ syncedAt: "2025-06-01T00:00:00Z",
404
+ });
405
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
406
+
407
+ assert.equal(server.getReceiptRequestCount(), 1);
408
+ const receipt = server.getLastReceipt();
409
+ assert.equal(receipt.syncedAt, "2025-06-01T00:00:00Z");
410
+ const names = receipt.skills.map((s) => s.name).sort();
411
+ assert.deepEqual(names, ["code-review", "pdf-helper"]);
412
+ for (const s of receipt.skills) {
413
+ assert.equal(s.owner, "alice");
414
+ assert.equal(s.version, "1.0.0");
415
+ }
416
+ });
417
+
418
+ it("does NOT post a receipt on a 304 (nothing written)", async () => {
419
+ server.setEtag('"v1"');
420
+ server.setLibraryResponse({
421
+ skills: [makeSkill("pdf-helper")],
422
+ removals: [],
423
+ syncedAt: "2025-06-01T00:00:00Z",
424
+ });
425
+ // First sync writes + posts a receipt.
426
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
427
+ assert.equal(server.getReceiptRequestCount(), 1);
428
+ server.resetReceipts();
429
+
430
+ // Second sync 304-short-circuits (etag matches, placement present).
431
+ const result = await runSync({
432
+ serverUrl,
433
+ apiKey: VALID_KEY,
434
+ vendors: ["claudeCode"],
435
+ });
436
+ assert.equal(result.notModified, true);
437
+ assert.equal(server.getReceiptRequestCount(), 0);
438
+ });
439
+
440
+ it("excludes a filesIncomplete skill from the receipt", async () => {
441
+ const incomplete = makeSkill("incomplete");
442
+ incomplete.filesIncomplete = true;
443
+ server.setLibraryResponse({
444
+ skills: [incomplete, makeSkill("complete")],
445
+ removals: [],
446
+ syncedAt: "x",
447
+ });
448
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
449
+
450
+ const receipt = server.getLastReceipt();
451
+ assert.deepEqual(
452
+ receipt.skills.map((s) => s.name),
453
+ ["complete"],
454
+ );
455
+ });
456
+
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.
460
+ const a = makeSkill("inc-a");
461
+ a.filesIncomplete = true;
462
+ const b = makeSkill("inc-b");
463
+ b.filesIncomplete = true;
464
+ server.setLibraryResponse({ skills: [a, b], removals: [], syncedAt: "x" });
465
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
466
+ assert.equal(server.getReceiptRequestCount(), 0);
467
+ });
468
+
469
+ it("omits skills with no version label from the receipt", async () => {
470
+ const draft = makeSkill("draft");
471
+ draft.version = null;
472
+ server.setLibraryResponse({
473
+ skills: [draft, makeSkill("published")],
474
+ removals: [],
475
+ syncedAt: "x",
476
+ });
477
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
478
+
479
+ const receipt = server.getLastReceipt();
480
+ assert.deepEqual(
481
+ receipt.skills.map((s) => s.name),
482
+ ["published"],
483
+ );
484
+ });
485
+
486
+ it("a failed receipt POST is non-fatal — the sync still succeeds", async () => {
487
+ const { createCaptureStream } = await import(
488
+ "../helpers/capture-stream.mjs"
489
+ );
490
+ const stderr = createCaptureStream();
491
+ server.setReceiptResponse({ status: 500, body: { error: "boom" } });
492
+ server.setLibraryResponse({
493
+ skills: [makeSkill("pdf-helper")],
494
+ removals: [],
495
+ syncedAt: "x",
496
+ });
497
+ const result = await runSync({
498
+ serverUrl,
499
+ apiKey: VALID_KEY,
500
+ vendors: ["claudeCode"],
501
+ io: { stderr },
502
+ });
503
+ // Files still written, summary intact despite the receipt failure.
504
+ assert.equal(result.added, 1);
505
+ const dir = resolvePlacementDir("claudeProject", "pdf-helper");
506
+ assert.ok(existsSync(join(dir, "SKILL.md")));
507
+ assert.match(stderr.text(), /failed to report sync receipt/);
508
+ });
509
+ });
510
+
393
511
  // ── runSync — skills map population (v2) ───────────────────────────────
394
512
  //
395
513
  // #1553 — every successful sync populates `.last-sync.skills` with the
@@ -684,6 +802,9 @@ describe("runSync — tombstones", () => {
684
802
  });
685
803
  await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
686
804
  assert.ok(existsSync(resolvePlacementDir("claudeProject", "doomed")));
805
+ // Reset receipt tracking so the tombstone-only sync below is measured
806
+ // from zero (the first sync legitimately posted one).
807
+ server.resetReceipts();
687
808
 
688
809
  // Now the server says it's removed (the response has the tombstone
689
810
  // AND the skill is no longer in skills[])
@@ -700,6 +821,8 @@ describe("runSync — tombstones", () => {
700
821
  });
701
822
  assert.equal(result.removed, 1);
702
823
  assert.ok(!existsSync(resolvePlacementDir("claudeProject", "doomed")));
824
+ // A tombstone-only sync writes nothing → posts no receipt (#1832).
825
+ assert.equal(server.getReceiptRequestCount(), 0);
703
826
  });
704
827
 
705
828
  it("applies removals BEFORE writes (re-add scenario)", async () => {
@@ -22,14 +22,14 @@ afterEach(() => {
22
22
  describe("Claude Code MCP config merger", () => {
23
23
  it("creates .mcp.json when file does not exist", async () => {
24
24
  const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
25
- const result = mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp");
25
+ const result = mergeClaudeMcpConfig("https://mcp.skillrepo.dev");
26
26
 
27
27
  assert.equal(result.action, "created");
28
28
  assert.equal(result.path, ".mcp.json");
29
29
 
30
30
  const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
31
31
  assert.equal(content.mcpServers.skillrepo.type, "http");
32
- assert.equal(content.mcpServers.skillrepo.url, "https://skillrepo.dev/api/mcp");
32
+ assert.equal(content.mcpServers.skillrepo.url, "https://mcp.skillrepo.dev");
33
33
  assert.equal(content.mcpServers.skillrepo.headers.Authorization, "Bearer ${SKILLREPO_ACCESS_KEY}");
34
34
  });
35
35
 
@@ -44,7 +44,7 @@ describe("Claude Code MCP config merger", () => {
44
44
  };
45
45
  writeFileSync(join(tempDir, ".mcp.json"), JSON.stringify(existing));
46
46
 
47
- const result = mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp");
47
+ const result = mergeClaudeMcpConfig("https://mcp.skillrepo.dev");
48
48
 
49
49
  assert.equal(result.action, "merged"); // adding to existing file
50
50
  const content = JSON.parse(readFileSync(join(tempDir, ".mcp.json"), "utf-8"));
@@ -73,6 +73,6 @@ describe("Claude Code MCP config merger", () => {
73
73
  const { mergeClaudeMcpConfig } = await import("../../lib/mergers/claude-mcp.mjs");
74
74
  writeFileSync(join(tempDir, ".mcp.json"), "not json");
75
75
 
76
- assert.throws(() => mergeClaudeMcpConfig("https://skillrepo.dev/api/mcp"), /invalid JSON/);
76
+ assert.throws(() => mergeClaudeMcpConfig("https://mcp.skillrepo.dev"), /invalid JSON/);
77
77
  });
78
78
  });
@@ -59,7 +59,7 @@ describe("removeClaudeMcp", () => {
59
59
  mcpServers: {
60
60
  skillrepo: {
61
61
  type: "http",
62
- url: "https://skillrepo.dev/api/mcp",
62
+ url: "https://mcp.skillrepo.dev",
63
63
  headers: { Authorization: "Bearer ${SKILLREPO_ACCESS_KEY}" },
64
64
  },
65
65
  otherTool: {
@@ -61,7 +61,7 @@ describe("removeVscodeMcp", () => {
61
61
  servers: {
62
62
  skillrepo: {
63
63
  type: "http",
64
- url: "https://skillrepo.dev/api/mcp",
64
+ url: "https://mcp.skillrepo.dev",
65
65
  headers: { Authorization: "Bearer ${input:skillrepo-api-key}" },
66
66
  },
67
67
  anotherTool: {