skillrepo 4.5.1 → 4.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.5.1",
3
+ "version": "4.5.2",
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": {
@@ -196,11 +196,11 @@ export async function runAdd(argv, io = {}) {
196
196
  .join(", ")})`;
197
197
  if (wasNewlyAdded) {
198
198
  stdout.write(
199
- `\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} files) → ${where}\n\n`,
199
+ `\n ✓ Added ${formatIdentifier({ owner, name })} to your library (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
200
200
  );
201
201
  } else {
202
202
  stdout.write(
203
- `\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} files) → ${where}\n\n`,
203
+ `\n ✓ ${formatIdentifier({ owner, name })} was already in your library — refreshed (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
204
204
  );
205
205
  }
206
206
  }
@@ -143,6 +143,6 @@ export async function runGet(argv, io = {}) {
143
143
  .map(describePlacementTarget)
144
144
  .join(", ")})`;
145
145
  stdout.write(
146
- `\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} files) → ${where}\n\n`,
146
+ `\n ✓ Fetched ${formatIdentifier({ owner, name })} (${skill.files.length} file${skill.files.length === 1 ? "" : "s"}) → ${where}\n\n`,
147
147
  );
148
148
  }
@@ -243,9 +243,13 @@ function printTable(augmented, detected, out) {
243
243
  .join(", ");
244
244
  out.write(`\n Detected: ${detectedLabel}\n`);
245
245
  } else {
246
- out.write(
247
- "\n No agents detected in this project drift cannot be reported.\n",
248
- );
246
+ // Earlier copy said "drift cannot be reported" but the table
247
+ // below DOES report driftevery skill rolls up as MISSING
248
+ // because no placement exists to compare against. The simpler
249
+ // statement of fact is what users need; the footer ("No sync
250
+ // history…" / "library has changed…") tells them what to do
251
+ // next.
252
+ out.write("\n No agents detected in this project.\n");
249
253
  }
250
254
 
251
255
  const sorted = augmented.slice().sort(sortByOwnerAndName);
@@ -337,12 +341,27 @@ function printFooter(augmented, libraryEtag, lastSync, out, useGlyphs) {
337
341
  }
338
342
 
339
343
  if (etagMatches && driftCount > 0) {
340
- out.write(
341
- `\n ${warn} library in syncbut ${driftCount} skill${
342
- driftCount === 1 ? "" : "s"
343
- } show${driftCount === 1 ? "s" : ""} local drift.\n` +
344
- " Run `skillrepo update` to refresh.\n\n",
345
- );
344
+ // Distinguish EDITED (running update loses user changes) from
345
+ // MISS/STALE (running update is non-destructive fills gaps or
346
+ // pulls a newer version). The earlier copy said "Run `skillrepo
347
+ // update` to refresh" for every drift case, which silently
348
+ // destroyed user edits when EDITED rows were present. Now we
349
+ // warn explicitly when edits are at risk.
350
+ const editedCount = augmented.filter(
351
+ (s) => s.state === SKILL_STATE.EDITED,
352
+ ).length;
353
+ const driftPhrase = `${driftCount} skill${driftCount === 1 ? "" : "s"} show${driftCount === 1 ? "s" : ""}`;
354
+ if (editedCount > 0) {
355
+ out.write(
356
+ `\n ${warn} library in sync — but ${driftPhrase} local drift, including ${editedCount} with local edit${editedCount === 1 ? "" : "s"}.\n` +
357
+ " Run `skillrepo update` to refresh — this will OVERWRITE local edits.\n\n",
358
+ );
359
+ } else {
360
+ out.write(
361
+ `\n ${warn} library in sync — but ${driftPhrase} local drift.\n` +
362
+ " Run `skillrepo update` to refresh.\n\n",
363
+ );
364
+ }
346
365
  return;
347
366
  }
348
367
 
package/src/lib/sync.mjs CHANGED
@@ -85,6 +85,7 @@
85
85
  */
86
86
 
87
87
  import { existsSync, readFileSync } from "node:fs";
88
+ import { join } from "node:path";
88
89
 
89
90
  import { getLibrary } from "./http.mjs";
90
91
  import {
@@ -110,6 +111,103 @@ import { computeSkillShas } from "./crypto-shas.mjs";
110
111
  */
111
112
  export const LAST_SYNC_SCHEMA_VERSION = 2;
112
113
 
114
+ /**
115
+ * Check whether every skill in the `.last-sync` baseline already exists
116
+ * under every placement target the current sync would write to. Returns
117
+ * true when the 304 short-circuit is safe (skipping writes won't leave
118
+ * any placement empty), false when at least one (skill, target) pair
119
+ * is missing on disk.
120
+ *
121
+ * This is the load-bearing guard for the ETag fast path. The contract:
122
+ * "library content unchanged AND every expected placement already has
123
+ * its content" — the second clause is what this function enforces.
124
+ *
125
+ * See the call site in runSync for the full motivation (upgrade-path,
126
+ * --global ↔ project swap, and cwd-switch scenarios all funnel through
127
+ * this gate).
128
+ *
129
+ * @param {Record<string, {skillMdSha256: string, filesSha256: string, version: string}> | null | undefined} skillsMap
130
+ * The `.skills` map from `.last-sync` — keyed by `"<owner>/<name>"`.
131
+ * @param {string[] | undefined} vendors
132
+ * The current sync's vendor list (output of `effectiveVendors`).
133
+ * @param {boolean | undefined} global
134
+ * Whether the current sync is `--global` mode.
135
+ * @returns {boolean}
136
+ */
137
+ export function placementsAreComplete(skillsMap, vendors, global) {
138
+ // Empty baseline: we cannot trust the ETag short-circuit. Two
139
+ // legitimate code paths land here:
140
+ // 1. Genuine fresh install — no `.last-sync` file at all; this
141
+ // function isn't called because runSync's caller checks
142
+ // `lastSync?.etag` before invoking. So we never see this
143
+ // branch in the fresh-install case.
144
+ // 2. v1 → v2 migration — `readLastSync` synthesizes an empty
145
+ // `skills` map for a v1 file (which had no per-skill SHA
146
+ // cache). The etag IS present but the baseline is empty;
147
+ // a 304 would tell us "library unchanged" but we'd still
148
+ // have no per-skill cache, so `list` would render every
149
+ // skill as MISS. Forcing a re-fetch here populates the
150
+ // cache and surfaces the actual library state.
151
+ // The conservative direction is "force re-fetch" — wire cost
152
+ // happens once per upgrade, not every time.
153
+ if (
154
+ !skillsMap ||
155
+ typeof skillsMap !== "object" ||
156
+ Object.keys(skillsMap).length === 0
157
+ ) {
158
+ return false;
159
+ }
160
+
161
+ // No vendors: this is the `--agent none` case or a degenerate config.
162
+ // requireVendorTargets will catch this downstream; from here, treat
163
+ // as "no placements to verify" → trivially complete.
164
+ if (!Array.isArray(vendors) || vendors.length === 0) {
165
+ return true;
166
+ }
167
+
168
+ let targets;
169
+ try {
170
+ targets = placementTargetsFor({ vendors, global: global === true });
171
+ } catch {
172
+ // placementTargetsFor throws on an invalid vendor + scope combo
173
+ // (e.g., `--global` with a vendor that has no `globalTarget`).
174
+ // The downstream runSync write loop will surface the same error
175
+ // with the correct typed exception; conservatively treat as
176
+ // "placement incomplete" so we drop the ETag and force the
177
+ // full-fetch path (which is what we'd want under any error).
178
+ return false;
179
+ }
180
+
181
+ for (const key of Object.keys(skillsMap)) {
182
+ // Skill keys are `"<owner>/<name>"`. We only need the name half
183
+ // because placement directories are keyed by skill name alone.
184
+ const slashAt = key.indexOf("/");
185
+ if (slashAt < 0) continue; // malformed entry — skip rather than crash
186
+ const skillName = key.slice(slashAt + 1);
187
+ if (!skillName) continue;
188
+
189
+ for (const target of targets) {
190
+ const dir = resolvePlacementDir(target, skillName);
191
+ // Probe `SKILL.md` rather than the bare directory. An empty or
192
+ // partial directory left behind by a hostile filesystem
193
+ // condition (manual mkdir, third-party tool, interrupted write
194
+ // before the atomic rename — rare but possible) would otherwise
195
+ // satisfy `existsSync(dir)` and let the 304 fire, leaving the
196
+ // user with a placement that contains no usable skill content.
197
+ // SKILL.md is the spec-required entry point; its presence is
198
+ // the load-bearing invariant the placement-presence check is
199
+ // really asserting.
200
+ if (!existsSync(join(dir, "SKILL.md"))) {
201
+ // ONE missing placement is enough — full re-fetch will re-write
202
+ // EVERY skill to EVERY target, restoring the invariant in one
203
+ // round.
204
+ return false;
205
+ }
206
+ }
207
+ }
208
+ return true;
209
+ }
210
+
113
211
  /**
114
212
  * Read the persisted last-sync state from ~/.claude/skillrepo/.last-sync.
115
213
  * Returns null if the file doesn't exist, is malformed, or has a
@@ -370,8 +468,61 @@ export async function runSync(options) {
370
468
 
371
469
  // Step 3: fetch with conditional headers
372
470
  const opts = {};
373
- if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
374
- if (lastSync?.syncedAt) opts.since = lastSync.syncedAt;
471
+ // The ETag short-circuit is only safe when EVERY skill in
472
+ // `.last-sync`'s baseline is present in EVERY placement directory
473
+ // the current sync would write to. The 304 means "library content
474
+ // unchanged" — but the local placement set can change between
475
+ // syncs even when content does not:
476
+ //
477
+ // - User upgrades from 4.5.0 (single-vendor default) to 4.5.1+
478
+ // (all-detected default) — new vendors detected, their
479
+ // placement dirs are empty.
480
+ // - User runs `update --global` once, then `update` (no flag)
481
+ // later — same vendor, but the resolved placement dir is
482
+ // project-scoped now instead of global.
483
+ // - User runs `update` in project A, then in project B — same
484
+ // vendor and scope, but `<cwd>/.claude/skills/` resolves to a
485
+ // different directory in B than in A.
486
+ //
487
+ // Without this check, any of those cases produces 304 → no writes
488
+ // → all-MISS output in `list`. The fix is a placement-presence
489
+ // check: for every (skill, target) pair the current sync would
490
+ // touch, the skill's directory MUST already exist under the
491
+ // target's root. If ANY is missing, drop `If-None-Match` so the
492
+ // server returns the full library and we populate the empty
493
+ // placement.
494
+ //
495
+ // Performance: N targets × M skills `existsSync` calls. For a 14-
496
+ // skill library with 2 targets, that's 28 stat calls — well under
497
+ // a millisecond. The conditional-fetch wire savings on a clean
498
+ // repeat sync still applies; this is the safety guard above it.
499
+ // BOTH the ETag short-circuit AND the `since` delta-query optimization
500
+ // depend on the same trust invariant: "the local placement set has
501
+ // every skill we expect." If a placement is missing, dropping ONLY
502
+ // `If-None-Match` is insufficient — the server filters `result.skills`
503
+ // by `updatedAt > since`, so a server response that we trigger by
504
+ // dropping the ETag will STILL omit any skill whose content hasn't
505
+ // changed on the server since the last sync. The missing placement
506
+ // would never be repopulated, producing a permanent ETag-miss loop
507
+ // that the user can only escape by `rm ~/.claude/skillrepo/.last-sync`.
508
+ //
509
+ // Verified against the production server at
510
+ // src/lib/queries/library.ts (`gt(skills.updatedAt, since)`): `since`
511
+ // alone is enough to filter unchanged skills out of the response.
512
+ //
513
+ // Fix: gate BOTH headers on the same `placementsAreComplete` check.
514
+ // When placements are incomplete, drop both — server returns the
515
+ // full library, runSync writes every skill to every detected
516
+ // vendor's placement, recovery is complete in one round.
517
+ const placementsComplete =
518
+ lastSync?.etag &&
519
+ placementsAreComplete(lastSync.skills, vendors, global);
520
+ if (placementsComplete) {
521
+ opts.ifNoneMatch = lastSync.etag;
522
+ }
523
+ if (lastSync?.syncedAt && placementsComplete) {
524
+ opts.since = lastSync.syncedAt;
525
+ }
375
526
 
376
527
  // Track whether this is a full or delta sync BEFORE the network
377
528
  // call, for the returned summary's `fullSync` field. A "full" sync
@@ -520,7 +671,13 @@ export async function runSync(options) {
520
671
  }
521
672
  }
522
673
 
523
- // Step 7: persist new ETag + skills map (only if the response was complete)
674
+ // Step 7: persist new ETag + skills map (only if the response was
675
+ // complete). The placement-presence check in `placementsAreComplete`
676
+ // (called above before the conditional request) handles the safety
677
+ // gate for the ETag short-circuit — no need to persist a separate
678
+ // vendor-set field; the on-disk placements themselves are the
679
+ // source of truth for "did the local placement set change in a way
680
+ // that would invalidate 304?"
524
681
  if (!anyIncomplete && result.etag) {
525
682
  try {
526
683
  writeLastSync({
@@ -80,6 +80,18 @@ export function createMockServer(initialPayload, options = {}) {
80
80
  /** @type {{ status: number, body: any } | null} */
81
81
  let forcedError = null;
82
82
 
83
+ // Library-request inspection. Tests use these to distinguish a true
84
+ // 304 wire exchange (`If-None-Match` sent, server replied 304) from
85
+ // a request that produced "up to date" output via some other path.
86
+ // PR #1575's placement-presence fix made this distinction
87
+ // load-bearing — see the assertion at the call sites in
88
+ // update-list-contract.integration.test.mjs.
89
+ /** @type {string | null} */
90
+ let lastLibraryIfNoneMatch = null;
91
+ /** @type {string | null} */
92
+ let lastLibrarySince = null;
93
+ let libraryRequestCount = 0;
94
+
83
95
  // PR3a mutable slots for POST/DELETE library routes
84
96
  //
85
97
  // Each entry is `{ status: number, body: any }` describing the
@@ -486,18 +498,57 @@ export function createMockServer(initialPayload, options = {}) {
486
498
  return;
487
499
  }
488
500
 
489
- // ETag short-circuit
501
+ // ETag short-circuit. Capture the inbound If-None-Match and
502
+ // `since` into inspectable slots BEFORE deciding 304-vs-200 so
503
+ // tests can assert on what the client actually sent — not just
504
+ // whether the response was 304. PR #1575's placement-presence
505
+ // fix makes the wire-level distinction load-bearing.
490
506
  const ifNoneMatch = req.headers["if-none-match"];
507
+ const sinceParam = url.searchParams.get("since");
508
+ lastLibraryIfNoneMatch = ifNoneMatch ?? null;
509
+ lastLibrarySince = sinceParam ?? null;
510
+ libraryRequestCount++;
491
511
  if (libraryEtag && ifNoneMatch === libraryEtag) {
492
512
  res.writeHead(304, { ETag: libraryEtag });
493
513
  res.end();
494
514
  return;
495
515
  }
496
516
 
517
+ // Mirror the production server's `since` filter — only return
518
+ // skills updated AFTER the `since` timestamp. Pre-PR #1575 the
519
+ // mock returned every skill regardless of `since`, which hid a
520
+ // real production bug: dropping the ETag without ALSO dropping
521
+ // `since` made the server respond with an empty delta, leaving
522
+ // newly-detected vendor placements empty forever. Mirroring the
523
+ // filter here means any future regression of that class fails
524
+ // at test time. See src/lib/queries/library.ts in the server
525
+ // for the production behavior (`gt(skills.updatedAt, since)`).
526
+ let respondedSkills = libraryResponse.skills ?? [];
527
+ if (sinceParam && Array.isArray(respondedSkills)) {
528
+ const sinceDate = new Date(sinceParam);
529
+ if (!Number.isNaN(sinceDate.getTime())) {
530
+ respondedSkills = respondedSkills.filter((s) => {
531
+ // updatedAt is a string ISO timestamp in the fixture skills.
532
+ // Missing/invalid → treat as "older than since" → exclude
533
+ // (matches the production semantic of "not modified in
534
+ // the window").
535
+ if (typeof s.updatedAt !== "string") return false;
536
+ const updated = new Date(s.updatedAt);
537
+ if (Number.isNaN(updated.getTime())) return false;
538
+ return updated.getTime() > sinceDate.getTime();
539
+ });
540
+ }
541
+ }
542
+
543
+ const filteredResponse = {
544
+ ...libraryResponse,
545
+ skills: respondedSkills,
546
+ };
547
+
497
548
  const headers = { "Content-Type": "application/json" };
498
549
  if (libraryEtag) headers.ETag = libraryEtag;
499
550
  res.writeHead(200, headers);
500
- res.end(JSON.stringify(libraryResponse));
551
+ res.end(JSON.stringify(filteredResponse));
501
552
  return;
502
553
  }
503
554
 
@@ -586,6 +637,45 @@ export function createMockServer(initialPayload, options = {}) {
586
637
  libraryEtag = etag;
587
638
  },
588
639
 
640
+ /**
641
+ * Inspect the inbound `If-None-Match` header from the LAST request
642
+ * to GET /api/v1/library. Returns the literal header string, or
643
+ * `null` if the request didn't send one (full-fetch path). Used
644
+ * by tests asserting that the placement-presence check correctly
645
+ * dropped or sent the ETag header. See PR #1575's coverage gap
646
+ * 2 — without this accessor, the "304 fires" tests could pass on
647
+ * an implementation that broke the wire-level optimization while
648
+ * preserving the user-facing "up to date" output.
649
+ */
650
+ getLastLibraryIfNoneMatch() {
651
+ return lastLibraryIfNoneMatch;
652
+ },
653
+
654
+ /** Count of GET /api/v1/library requests since server start or reset. */
655
+ getLibraryRequestCount() {
656
+ return libraryRequestCount;
657
+ },
658
+
659
+ /**
660
+ * Inspect the inbound `?since=` query param from the LAST request
661
+ * to GET /api/v1/library. Returns the literal string or `null` if
662
+ * not present. The placement-presence fix must drop BOTH the
663
+ * If-None-Match header AND the `since` query param when placements
664
+ * are incomplete — otherwise the server returns an empty delta
665
+ * and missing placements stay empty. Tests use this to assert the
666
+ * fix at the wire level.
667
+ */
668
+ getLastLibrarySince() {
669
+ return lastLibrarySince;
670
+ },
671
+
672
+ /** Reset the inspection slots — useful between phases of a multi-step test. */
673
+ resetLibraryInspection() {
674
+ lastLibraryIfNoneMatch = null;
675
+ lastLibrarySince = null;
676
+ libraryRequestCount = 0;
677
+ },
678
+
589
679
  /** Register a single-skill response keyed by `owner/name`. */
590
680
  setSkillResponse(owner, name, skill) {
591
681
  skillResponses.set(`${owner}/${name}`, skill);
@@ -80,7 +80,7 @@ const VALID_KEY = "sk_live_test";
80
80
  * helper the unit tests use; replicated here so changes in either
81
81
  * place don't silently break the other.
82
82
  */
83
- function makeSkill(owner, name, version = "1.0.0") {
83
+ function makeSkill(owner, name, version = "1.0.0", updatedAt) {
84
84
  const content = `---\nname: ${name}\ndescription: ${name} description\n---\n\nbody\n`;
85
85
  return {
86
86
  owner,
@@ -99,7 +99,14 @@ function makeSkill(owner, name, version = "1.0.0") {
99
99
  contentType: "text/markdown",
100
100
  },
101
101
  ],
102
- updatedAt: "2025-01-01T12:00:00Z",
102
+ // Default to "now" so the mock server's `since` filter (mirrors
103
+ // production `gt(skills.updatedAt, since)`) treats fresh fixtures
104
+ // as in-window. Tests that need to simulate "skill unchanged
105
+ // since last sync" pass an older string explicitly. Pre-PR #1575
106
+ // mock-server-tightening hardcoded "2025-01-01" which made every
107
+ // skill look stale once `since` was applied — that masked the
108
+ // real production behavior.
109
+ updatedAt: updatedAt ?? new Date().toISOString(),
103
110
  };
104
111
  }
105
112
 
@@ -434,13 +441,18 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
434
441
  assert.match(entry.filesSha256, /^[a-f0-9]{64}$/, "filesSha256 must be a hex SHA-256");
435
442
  });
436
443
 
437
- it("v1 state with NO content changes (304) preserves the etag and lets list see the upgrade transparently", async () => {
438
- // Edge case: user upgrades, runs update, server returns 304 (no
439
- // changes). The v1 etag was carried into the in-memory v2 shape
440
- // so the 304 fires. No writeLastSync happens on 304 — so the
441
- // on-disk file STAYS v1 until something actually changes. This
442
- // is the documented behavior; the test makes it explicit so a
443
- // future "always rewrite on 304" change would surface here.
444
+ it("v1 state with NO content changes first sync after upgrade does a full re-fetch (recovery)", async () => {
445
+ // The recovery path for users upgrading from v1 `.last-sync`
446
+ // (4.4.x and earlier) or any state where the per-skill SHA map
447
+ // is empty. `placementsAreComplete` returns false when the
448
+ // baseline map is empty that's the "we have an etag but no
449
+ // per-skill cache, so we can't trust the 304" signal. runSync
450
+ // drops `If-None-Match`, fetches the full library, and writes
451
+ // both the skill bytes and the per-skill SHA cache. The "library
452
+ // synced" message replaces "up to date" exactly once per
453
+ // upgrade; subsequent syncs (now with a populated skills map
454
+ // AND placement directories on disk) return to the 304 fast
455
+ // path.
444
456
  const v1Path = globalLastSyncPath();
445
457
  mkdirSync(join(v1Path, ".."), { recursive: true });
446
458
  writeFileSync(
@@ -460,27 +472,54 @@ describe("v1 → v2 .last-sync migration round-trip", () => {
460
472
  syncedAt: "2026-05-19T00:00:00Z",
461
473
  });
462
474
 
463
- // Force a 304 the mock server's `setEtag('"unchanged"')`
464
- // combined with the v1 etag triggers the conditional short-circuit.
475
+ // First sync after upgrade: empty skills map → placementsAreComplete
476
+ // returns false full re-fetch. User sees the "synced" message,
477
+ // not "up to date" — recovery is visible.
478
+ server.resetLibraryInspection();
465
479
  await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
466
- assert.match(stdout.text(), /up to date/, "304 should produce the up-to-date message");
480
+ const firstOut = stdout.text();
481
+ // Negative assertion: the misleading "up to date" message must
482
+ // not appear during the recovery sync.
483
+ assert.ok(
484
+ !/up to date/.test(firstOut),
485
+ "first sync after upgrade must NOT 304 — empty skills map forces full re-fetch",
486
+ );
487
+ // Positive assertion: the recovery sync must report at least
488
+ // one write. Without this, a future regression where the command
489
+ // crashed before printing anything would also pass the negative
490
+ // assertion above. See QA Gap 4.
491
+ assert.match(
492
+ firstOut,
493
+ /Library sync complete|added|updated/,
494
+ "first sync after upgrade must positively report a sync, not silently no-op",
495
+ );
496
+ // Wire-level: ETag was dropped — placementsAreComplete returned
497
+ // false for the empty baseline. This is the load-bearing
498
+ // mechanism, not just a side-effect of output.
499
+ assert.equal(
500
+ server.getLastLibraryIfNoneMatch(),
501
+ null,
502
+ "v1 migration must drop If-None-Match (placementsAreComplete returns false for empty map)",
503
+ );
467
504
 
468
- // The on-disk file stayed v1 because runSync's 304 branch
469
- // doesn't write. readLastSync's in-memory migration is what kept
470
- // the conditional request behaving correctly.
471
- const afterRaw = readFileSync(v1Path, "utf-8");
472
- const after = JSON.parse(afterRaw);
473
- assert.equal(after.schemaVersion, 1, "304 should leave the v1 file untouched on disk");
474
-
475
- // But — the migrated in-memory shape is what `list` consumes.
476
- // Calling readLastSync directly should return the v2 shape
477
- // (synthesized from v1 + empty skills map). The integration
478
- // contract is: any reader on the new CLI sees a v2 shape, even
479
- // if the file on disk is still v1.
480
- const migrated = readLastSync();
481
- assert.equal(migrated.schemaVersion, 2);
482
- assert.equal(migrated.etag, '"unchanged"');
483
- assert.deepEqual(migrated.skills, {});
505
+ // After the recovery sync, the on-disk file is v2 with the
506
+ // skills map populated. The skill is on disk in the claudeProject
507
+ // placement the user-visible recovery.
508
+ const after = readLastSync();
509
+ assert.equal(after.schemaVersion, 2);
510
+ assert.ok(after.skills["alice/unmigrated"]);
511
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "unmigrated")));
512
+
513
+ // Second sync with the SAME vendor set: now the ETag short-circuit
514
+ // is safe and 304 fires (this proves we didn't accidentally make
515
+ // every sync do a full re-fetch the fix is targeted).
516
+ stdout = createCaptureStream();
517
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
518
+ assert.match(
519
+ stdout.text(),
520
+ /up to date/,
521
+ "second sync (vendor set now in .last-sync) should 304 normally",
522
+ );
484
523
  });
485
524
  });
486
525
 
@@ -1016,3 +1055,511 @@ describe("additional coverage from production-readiness audit", () => {
1016
1055
  }
1017
1056
  });
1018
1057
  });
1058
+
1059
+ // ───────────────────────────────────────────────────────────────────────
1060
+ // Upgrade-path scenarios — the bugs the fresh-sandbox tests cannot see
1061
+ // ───────────────────────────────────────────────────────────────────────
1062
+ //
1063
+ // The 4.5.0 → 4.5.1 production-readiness audit missed a real user-facing
1064
+ // bug because every test in this file (and every reviewer-spawned
1065
+ // verification) ran in a freshly-created sandbox. The actual user
1066
+ // upgrade path is different: pre-existing `.last-sync` written by the
1067
+ // old CLI, then run the new CLI, expect new behavior to take effect.
1068
+ //
1069
+ // The specific bug: `runSync`'s ETag short-circuit fires when the
1070
+ // server hasn't changed library content since the last sync — but if
1071
+ // the local detected-vendor set has EXPANDED since that sync (the
1072
+ // classic 4.5.0 → 4.5.1 transition: claudeCode-only → all-detected),
1073
+ // the 304 means the new vendors NEVER receive their skills. User sees
1074
+ // "Library is up to date" followed by `list` reporting every skill as
1075
+ // MISS for the newly-detected vendors.
1076
+ //
1077
+ // These tests reproduce that specific scenario by:
1078
+ // 1. Seeding `.last-sync` with a state that mimics the old CLI's
1079
+ // output (single-vendor sync, etag X).
1080
+ // 2. Setting the detection signals for additional vendors.
1081
+ // 3. Running `update` against a server that returns 304 for etag X.
1082
+ // 4. Asserting the new vendors' placements were populated anyway.
1083
+
1084
+ describe("upgrade path: vendor set expands between syncs (regression for the bug 4.5.1 missed)", () => {
1085
+ beforeEach(setup);
1086
+ afterEach(teardown);
1087
+
1088
+ it("new vendor detected after prior sync → ETag is dropped and writes fire for the new vendor", async () => {
1089
+ // STEP 1: simulate the 4.5.0 user's state — only claudeCode
1090
+ // detected, sync completed, `.last-sync` has skill SHAs and an
1091
+ // etag.
1092
+ process.env.CLAUDECODE = "1";
1093
+ server.setEtag('"library-v1"');
1094
+ const skill = makeSkill("alice", "shared", "1.0.0");
1095
+ server.setLibraryResponse({
1096
+ skills: [skill],
1097
+ removals: [],
1098
+ syncedAt: "2026-05-01T00:00:00Z",
1099
+ });
1100
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1101
+
1102
+ // Sanity: only claudeProject got the write at this point.
1103
+ assert.ok(
1104
+ existsSync(resolvePlacementDir("claudeProject", "shared")),
1105
+ "first sync wrote to claudeProject",
1106
+ );
1107
+ assert.equal(
1108
+ existsSync(resolvePlacementDir("agentsProject", "shared")),
1109
+ false,
1110
+ "first sync did NOT write to agentsProject (cursor not detected yet)",
1111
+ );
1112
+
1113
+ // STEP 2: simulate the user installing Cursor — env signal fires
1114
+ // on the NEXT `update` invocation. The library content has not
1115
+ // changed on the server, so the server will return 304.
1116
+ process.env.CURSOR_AGENT = "1";
1117
+
1118
+ // Mock server keeps the same etag → If-None-Match match → 304.
1119
+ // No need to change the library response: the 304 branch in
1120
+ // runSync skips reading the body anyway.
1121
+
1122
+ // STEP 3: run update. Pre-fix: short-circuits on 304, no writes
1123
+ // to cursor, user sees "Library is up to date" but cursor's
1124
+ // placement remains empty.
1125
+ server.resetLibraryInspection();
1126
+ stdout = createCaptureStream();
1127
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1128
+
1129
+ // STEP 4: assert. THIS IS THE LOAD-BEARING CHECK.
1130
+ // The newly-detected cursor vendor's placement MUST exist after
1131
+ // the second `update`, even though the server responded 304.
1132
+ // Pre-fix this assertion fails.
1133
+ assert.ok(
1134
+ existsSync(resolvePlacementDir("agentsProject", "shared")),
1135
+ "agentsProject placement MUST exist after a 304 sync when cursor was newly detected — " +
1136
+ "the ETag short-circuit must not prevent writes to vendors absent from the prior sync",
1137
+ );
1138
+ // Wire-level proof: the client did NOT send If-None-Match — the
1139
+ // placement-presence check correctly forced a full re-fetch.
1140
+ // Without this assertion, a regression that still sent the ETag
1141
+ // but happened to re-write to cursor via some unrelated code
1142
+ // path would pass the disk check above silently. See QA Gap 2.
1143
+ assert.equal(
1144
+ server.getLastLibraryIfNoneMatch(),
1145
+ null,
1146
+ "client must NOT have sent If-None-Match — placement-presence check should have dropped it",
1147
+ );
1148
+
1149
+ // Confirm `list` agrees: every detected vendor reports current.
1150
+ stdout = createCaptureStream();
1151
+ await runList(
1152
+ ["--key", VALID_KEY, "--url", serverUrl, "--json"],
1153
+ { stdout },
1154
+ );
1155
+ const [item] = JSON.parse(stdout.text());
1156
+ assert.equal(
1157
+ item.state,
1158
+ "current",
1159
+ "list must show current after the upgrade-path sync — not MISS",
1160
+ );
1161
+ assert.equal(item.placements.length, 2);
1162
+ for (const p of item.placements) {
1163
+ assert.equal(p.state, "current");
1164
+ }
1165
+ });
1166
+
1167
+ it("vendor set unchanged across syncs → 304 short-circuit still fires (no wasted full re-fetch)", async () => {
1168
+ // Defense-in-depth: the fix must NOT invalidate the 304 unless
1169
+ // the placement set actually changed. A user who runs `update`
1170
+ // twice in a row with the same vendors should still benefit from
1171
+ // the 304 fast path. The assertion is BOTH the user-visible "up
1172
+ // to date" output AND the wire-level `If-None-Match` header —
1173
+ // otherwise a future regression that broke the ETag header but
1174
+ // happened to produce the same output (e.g., full re-fetch with
1175
+ // zero deltas) would pass this test silently. See QA reviewer
1176
+ // Gap 2.
1177
+ process.env.CLAUDECODE = "1";
1178
+ process.env.CURSOR_AGENT = "1";
1179
+ server.setEtag('"library-v1"');
1180
+ server.setLibraryResponse({
1181
+ skills: [makeSkill("alice", "unchanged", "1.0.0")],
1182
+ removals: [],
1183
+ syncedAt: "2026-05-01T00:00:00Z",
1184
+ });
1185
+
1186
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1187
+
1188
+ // Second run with the SAME vendor set — should 304 and produce
1189
+ // the "up to date" message (no writes needed).
1190
+ server.resetLibraryInspection();
1191
+ stdout = createCaptureStream();
1192
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1193
+ assert.match(
1194
+ stdout.text(),
1195
+ /up to date/,
1196
+ "same vendor set + same library = 304 fast path still fires",
1197
+ );
1198
+ // Wire-level proof: the client actually sent If-None-Match.
1199
+ // Without this, a regression that broke the conditional request
1200
+ // but produced the same output would slip past output-only
1201
+ // assertions.
1202
+ assert.equal(
1203
+ server.getLastLibraryIfNoneMatch(),
1204
+ '"library-v1"',
1205
+ "second sync MUST have sent If-None-Match: <prior-etag> — the ETag fast path is load-bearing",
1206
+ );
1207
+ });
1208
+
1209
+ it("--global sync followed by project sync → project placement gets populated", async () => {
1210
+ // Edge case the vendor-set tracking alone misses: same vendors,
1211
+ // different scope. The user runs `update --global` (writes to
1212
+ // ~/.claude/skills/), then runs `update` with no flag (writes to
1213
+ // <cwd>/.claude/skills/). Same vendor key (claudeCode) but
1214
+ // entirely different placement directory. A naive syncedVendors
1215
+ // check would say "set unchanged" → 304 → no writes → project
1216
+ // placement empty → list shows MISS.
1217
+ //
1218
+ // The proper contract: invalidate the ETag whenever the resolved
1219
+ // placement directories for the current sync don't ALL contain
1220
+ // the skills they should. That covers vendor-set expansion AND
1221
+ // scope changes AND cwd switches.
1222
+ process.env.CLAUDECODE = "1";
1223
+ server.setEtag('"library-v1"');
1224
+ server.setLibraryResponse({
1225
+ skills: [makeSkill("alice", "scope-test", "1.0.0")],
1226
+ removals: [],
1227
+ syncedAt: "2026-05-01T00:00:00Z",
1228
+ });
1229
+
1230
+ // First sync: --global → ~/.claude/skills/ (claudeGlobal target).
1231
+ await runUpdate(
1232
+ ["--key", VALID_KEY, "--url", serverUrl, "--global"],
1233
+ { stdout },
1234
+ );
1235
+ assert.ok(
1236
+ existsSync(resolvePlacementDir("claudeGlobal", "scope-test")),
1237
+ "first sync wrote to claudeGlobal",
1238
+ );
1239
+ assert.equal(
1240
+ existsSync(resolvePlacementDir("claudeProject", "scope-test")),
1241
+ false,
1242
+ "first sync did NOT write to claudeProject (no --global flag inverted)",
1243
+ );
1244
+
1245
+ // Second sync: no --global → <cwd>/.claude/skills/ (claudeProject).
1246
+ // Same vendor (claudeCode), same library content (304 from server),
1247
+ // but the destination directory is completely different. Without
1248
+ // the fix, the 304 short-circuit fires and claudeProject stays
1249
+ // empty.
1250
+ stdout = createCaptureStream();
1251
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1252
+
1253
+ // THE LOAD-BEARING CHECK: claudeProject must exist after the
1254
+ // project-scope sync, even though the server 304'd.
1255
+ assert.ok(
1256
+ existsSync(resolvePlacementDir("claudeProject", "scope-test")),
1257
+ "claudeProject placement MUST exist after a 304 sync when the prior sync " +
1258
+ "was --global — the ETag short-circuit must not skip writes when the " +
1259
+ "resolved placement directory is empty",
1260
+ );
1261
+ });
1262
+
1263
+ it("cwd switch between syncs → second project's placement gets populated", async () => {
1264
+ // Same bug class as the --global swap. User runs `update` in
1265
+ // project A, then runs `update` in project B. Both projects use
1266
+ // the same `.last-sync` (lives in HOME, not project) but the
1267
+ // placement directories are cwd-dependent. Without the fix, the
1268
+ // second project sees a 304 and never gets its placements.
1269
+ process.env.CLAUDECODE = "1";
1270
+ server.setEtag('"library-v1"');
1271
+ server.setLibraryResponse({
1272
+ skills: [makeSkill("alice", "cwd-test", "1.0.0")],
1273
+ removals: [],
1274
+ syncedAt: "2026-05-01T00:00:00Z",
1275
+ });
1276
+
1277
+ // First sync in project A.
1278
+ const projectA = process.cwd();
1279
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1280
+ assert.ok(
1281
+ existsSync(resolvePlacementDir("claudeProject", "cwd-test")),
1282
+ "first sync populated project A's placement",
1283
+ );
1284
+
1285
+ // Switch to project B (a separate temp directory inside the same
1286
+ // sandbox so the home-scoped `.last-sync` is shared).
1287
+ const projectB = join(sandbox, "project-b");
1288
+ mkdirSync(projectB, { recursive: true });
1289
+ process.chdir(projectB);
1290
+
1291
+ try {
1292
+ // Same library content, same vendor — server still 304s. Without
1293
+ // the fix, no writes to project B.
1294
+ stdout = createCaptureStream();
1295
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1296
+
1297
+ assert.ok(
1298
+ existsSync(resolvePlacementDir("claudeProject", "cwd-test")),
1299
+ "project B's claudeProject placement MUST exist after sync — " +
1300
+ "ETag short-circuit must not skip writes when the cwd changed",
1301
+ );
1302
+ } finally {
1303
+ process.chdir(projectA);
1304
+ }
1305
+ });
1306
+
1307
+ it("partial baseline recovery: delete 1 of 5 skills' placements → ETag dropped, all re-fetched (QA Gap 1)", async () => {
1308
+ // Most probable failure mode in normal use: user has many
1309
+ // skills, manually deletes one (or a cleanup script trims a
1310
+ // skill they removed), then runs `update`. The library is
1311
+ // unchanged on the server (304), but the placement-presence
1312
+ // check sees the missing dir and forces a full re-fetch. This
1313
+ // restores every skill in one round. The wire-level check
1314
+ // proves the ETag was DROPPED, not silently sent and ignored.
1315
+ process.env.CLAUDECODE = "1";
1316
+ server.setEtag('"library-v1"');
1317
+ server.setLibraryResponse({
1318
+ skills: [
1319
+ makeSkill("alice", "skill-1", "1.0.0"),
1320
+ makeSkill("alice", "skill-2", "1.0.0"),
1321
+ makeSkill("alice", "skill-3", "1.0.0"),
1322
+ makeSkill("alice", "skill-4", "1.0.0"),
1323
+ makeSkill("alice", "skill-5", "1.0.0"),
1324
+ ],
1325
+ removals: [],
1326
+ syncedAt: "2026-05-01T00:00:00Z",
1327
+ });
1328
+
1329
+ // Sync — all 5 land.
1330
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1331
+ for (const n of ["skill-1", "skill-2", "skill-3", "skill-4", "skill-5"]) {
1332
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", n)));
1333
+ }
1334
+
1335
+ // User deletes ONE skill — `skill-3`.
1336
+ rmSync(resolvePlacementDir("claudeProject", "skill-3"), {
1337
+ recursive: true,
1338
+ force: true,
1339
+ });
1340
+
1341
+ server.resetLibraryInspection();
1342
+ stdout = createCaptureStream();
1343
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1344
+
1345
+ // `skill-3` is restored. The other 4 are untouched (idempotent
1346
+ // write — same content, same SHA).
1347
+ assert.ok(
1348
+ existsSync(resolvePlacementDir("claudeProject", "skill-3")),
1349
+ "deleted skill must be restored after self-healing sync",
1350
+ );
1351
+ for (const n of ["skill-1", "skill-2", "skill-4", "skill-5"]) {
1352
+ assert.ok(
1353
+ existsSync(resolvePlacementDir("claudeProject", n)),
1354
+ `${n} must remain present (no collateral damage)`,
1355
+ );
1356
+ }
1357
+ // Wire-level: ETag was dropped — placement-presence check fired.
1358
+ assert.equal(
1359
+ server.getLastLibraryIfNoneMatch(),
1360
+ null,
1361
+ "ETag MUST have been dropped — placement-presence check found the missing skill-3 dir",
1362
+ );
1363
+ });
1364
+
1365
+ it("multi-vendor + manual delete of ONE vendor's placement → next update self-heals", async () => {
1366
+ // Real-world scenario flagged by reviewer: a user with two
1367
+ // vendors syncs successfully, then manually deletes one
1368
+ // vendor's placement directory (rm -rf the cohort dir, or a
1369
+ // hostile cleanup script touched it). The `.last-sync` baseline
1370
+ // and the OTHER vendor's placement are intact, so the server
1371
+ // would 304. Without the placement-presence check, the deleted
1372
+ // vendor's placement would stay empty forever — list would show
1373
+ // MISS for every skill at that vendor.
1374
+ //
1375
+ // The placement-presence check sees the missing claudeProject
1376
+ // SKILL.md and forces a re-fetch, restoring both vendors'
1377
+ // placements in one round. This is the "self-healing" property
1378
+ // of the fix.
1379
+ process.env.CLAUDECODE = "1";
1380
+ process.env.CURSOR_AGENT = "1";
1381
+ server.setEtag('"library-v1"');
1382
+ server.setLibraryResponse({
1383
+ skills: [
1384
+ makeSkill("alice", "a", "1.0.0"),
1385
+ makeSkill("alice", "b", "1.0.0"),
1386
+ ],
1387
+ removals: [],
1388
+ syncedAt: "2026-05-01T00:00:00Z",
1389
+ });
1390
+
1391
+ // Initial sync — both vendors get both skills.
1392
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1393
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "a")));
1394
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "a")));
1395
+ assert.ok(existsSync(resolvePlacementDir("claudeProject", "b")));
1396
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "b")));
1397
+
1398
+ // User manually nukes the entire claudeProject cohort. cursor's
1399
+ // placement at .agents/skills/ remains.
1400
+ rmSync(resolvePlacementDir("claudeProject", "a"), {
1401
+ recursive: true,
1402
+ force: true,
1403
+ });
1404
+ rmSync(resolvePlacementDir("claudeProject", "b"), {
1405
+ recursive: true,
1406
+ force: true,
1407
+ });
1408
+
1409
+ // Next update — server still 304s (same etag) — but the
1410
+ // placement-presence check fires on missing claudeProject
1411
+ // SKILL.md and forces full re-fetch.
1412
+ stdout = createCaptureStream();
1413
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1414
+
1415
+ assert.ok(
1416
+ existsSync(resolvePlacementDir("claudeProject", "a")),
1417
+ "claudeProject `a` must be restored after self-healing sync",
1418
+ );
1419
+ assert.ok(
1420
+ existsSync(resolvePlacementDir("claudeProject", "b")),
1421
+ "claudeProject `b` must be restored after self-healing sync",
1422
+ );
1423
+ // cursor's placements were never touched.
1424
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "a")));
1425
+ assert.ok(existsSync(resolvePlacementDir("agentsProject", "b")));
1426
+ });
1427
+
1428
+ it("forced re-fetch drops BOTH If-None-Match AND ?since= (BLOCKER from Round 2 code-review)", async () => {
1429
+ // The bug that almost shipped: PR #1575's first iteration dropped
1430
+ // `If-None-Match` when placements were incomplete but kept `since`
1431
+ // unconditionally. Production server filters by
1432
+ // `gt(skills.updatedAt, since)`, so a forced re-fetch would
1433
+ // return an empty delta for any skill not modified within the
1434
+ // window — leaving the newly-discovered missing placement empty
1435
+ // forever. Permanent ETag-miss loop.
1436
+ //
1437
+ // This test makes both wire-level conditions explicit. The
1438
+ // mock server now mirrors production's `since` filter (see
1439
+ // mock-server.mjs change in this commit), so a regression that
1440
+ // re-introduces the bug would fail the FULL-LIBRARY assertion
1441
+ // even before the wire-level check.
1442
+ process.env.CLAUDECODE = "1";
1443
+
1444
+ // Use an OLD updatedAt so the skill would be filtered out by
1445
+ // `since` if the bug regressed. The default "now" timestamp
1446
+ // would mask the issue.
1447
+ server.setEtag('"library-v1"');
1448
+ server.setLibraryResponse({
1449
+ skills: [makeSkill("alice", "stale-fixture", "1.0.0", "2025-01-01T00:00:00Z")],
1450
+ removals: [],
1451
+ syncedAt: "2026-05-01T00:00:00Z",
1452
+ });
1453
+
1454
+ // First sync: writes the skill to claudeProject.
1455
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1456
+ const skillDir = resolvePlacementDir("claudeProject", "stale-fixture");
1457
+ assert.ok(existsSync(join(skillDir, "SKILL.md")));
1458
+
1459
+ // Force the placement-incomplete path: delete the SKILL.md
1460
+ // (placementsAreComplete will return false on next sync).
1461
+ rmSync(skillDir, { recursive: true, force: true });
1462
+
1463
+ // Run update. Pre-fix: ETag dropped, since=2026-05-01 sent,
1464
+ // server filters by `gt(updatedAt='2025-01-01', since='2026-05-01')`,
1465
+ // returns empty skills array, runSync writes nothing, placement
1466
+ // stays empty. Post-fix: BOTH headers dropped, server returns
1467
+ // the full library, runSync re-writes everything.
1468
+ server.resetLibraryInspection();
1469
+ stdout = createCaptureStream();
1470
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1471
+
1472
+ // Outcome assertion: the skill is back on disk.
1473
+ assert.ok(
1474
+ existsSync(join(skillDir, "SKILL.md")),
1475
+ "stale-fixture must be restored — full re-fetch must return it even though updatedAt < syncedAt",
1476
+ );
1477
+
1478
+ // Wire-level assertions: BOTH headers dropped on the forced
1479
+ // re-fetch path. If either is sent, the bug has regressed.
1480
+ assert.equal(
1481
+ server.getLastLibraryIfNoneMatch(),
1482
+ null,
1483
+ "If-None-Match MUST be dropped when placements are incomplete",
1484
+ );
1485
+ assert.equal(
1486
+ server.getLastLibrarySince(),
1487
+ null,
1488
+ "?since= MUST also be dropped — otherwise server filters out unchanged skills " +
1489
+ "and the missing placement stays empty (the BLOCKER from Round 2 code-review)",
1490
+ );
1491
+ });
1492
+
1493
+ it("placement dir exists but SKILL.md is missing (partial placement) → next update self-heals", async () => {
1494
+ // Reviewer-flagged HIGH: `existsSync(dir)` alone would satisfy
1495
+ // the placement-presence check even for an empty/partial
1496
+ // directory. The fix probes for SKILL.md specifically. This
1497
+ // test locks in that behavior: a placement directory that
1498
+ // exists but lacks its SKILL.md is treated as MISSING and
1499
+ // forces a re-fetch.
1500
+ process.env.CLAUDECODE = "1";
1501
+ server.setEtag('"library-v1"');
1502
+ server.setLibraryResponse({
1503
+ skills: [makeSkill("alice", "partial", "1.0.0")],
1504
+ removals: [],
1505
+ syncedAt: "2026-05-01T00:00:00Z",
1506
+ });
1507
+
1508
+ // Initial sync writes SKILL.md.
1509
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1510
+ const dir = resolvePlacementDir("claudeProject", "partial");
1511
+ const skillMd = join(dir, "SKILL.md");
1512
+ assert.ok(existsSync(skillMd));
1513
+
1514
+ // User (or hostile tool) deletes just the SKILL.md, leaving the
1515
+ // directory.
1516
+ rmSync(skillMd);
1517
+ assert.ok(existsSync(dir), "dir still exists");
1518
+ assert.equal(existsSync(skillMd), false, "but SKILL.md is gone");
1519
+
1520
+ // Next update — placement-presence check sees no SKILL.md and
1521
+ // forces re-fetch.
1522
+ stdout = createCaptureStream();
1523
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1524
+ assert.ok(
1525
+ existsSync(skillMd),
1526
+ "SKILL.md must be restored — empty dirs must not satisfy the placement check",
1527
+ );
1528
+ });
1529
+
1530
+ it("vendor REMOVED from detected set → next update still 304s (no over-eager full re-fetch)", async () => {
1531
+ // Edge case: user uninstalls a tool between syncs. The detected
1532
+ // vendor set shrinks. The library content hasn't changed.
1533
+ // Question: should the next update do a full re-fetch?
1534
+ //
1535
+ // Answer (per the fix's design): no. We only invalidate the ETag
1536
+ // when the vendor set EXPANDED (added vendors need writes). A
1537
+ // shrunk set has no new writes to do — the remaining vendors
1538
+ // already have what they need. The orphaned placements (from
1539
+ // the removed vendor) are left in place; cleaning them up is a
1540
+ // separate concern.
1541
+ //
1542
+ // Pre-fix: same behavior (304 fires regardless of vendor change).
1543
+ // Post-fix: explicit policy — only invalidate on expansion.
1544
+ process.env.CLAUDECODE = "1";
1545
+ process.env.CURSOR_AGENT = "1";
1546
+ server.setEtag('"library-v1"');
1547
+ server.setLibraryResponse({
1548
+ skills: [makeSkill("alice", "shrinkable", "1.0.0")],
1549
+ removals: [],
1550
+ syncedAt: "2026-05-01T00:00:00Z",
1551
+ });
1552
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1553
+
1554
+ // "Uninstall" cursor — drop the env signal.
1555
+ delete process.env.CURSOR_AGENT;
1556
+
1557
+ stdout = createCaptureStream();
1558
+ await runUpdate(["--key", VALID_KEY, "--url", serverUrl], { stdout });
1559
+ assert.match(
1560
+ stdout.text(),
1561
+ /up to date/,
1562
+ "shrinking the vendor set should not force a wasteful re-fetch",
1563
+ );
1564
+ });
1565
+ });
@@ -38,6 +38,7 @@ import {
38
38
  runSync,
39
39
  readLastSync,
40
40
  writeLastSync,
41
+ placementsAreComplete,
41
42
  LAST_SYNC_SCHEMA_VERSION,
42
43
  } from "../../lib/sync.mjs";
43
44
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
@@ -780,6 +781,48 @@ describe("runSync — ETag round-trip", () => {
780
781
  const after = readFileSync(join(dir, "SKILL.md"), "utf-8");
781
782
  assert.equal(before, after);
782
783
  });
784
+
785
+ it("forced re-fetch (placementsAreComplete=false) sets fullSync=false (prior state existed)", async () => {
786
+ // Locks in the semantic contract from QA Round 2 Gap 5: a forced
787
+ // full re-fetch (placementsAreComplete returns false because a
788
+ // placement was missing) does NOT bump fullSync to true. fullSync
789
+ // is determined by whether prior `syncedAt` existed, not by
790
+ // whether the server returned a delta or full payload. The
791
+ // distinction matters to init.mjs which interprets the
792
+ // `fullSync` × `counters-all-zero` combination — for an `init`
793
+ // that hits a placement-incomplete state, counters won't be all
794
+ // zero (the missing skill produces added>=1), so the consumer
795
+ // distinction is preserved. This test pins that behavior.
796
+ server.setEtag('"v1"');
797
+ server.setLibraryResponse({
798
+ skills: [makeSkill("forced")],
799
+ removals: [],
800
+ syncedAt: "2025-01-01T00:00:00Z",
801
+ });
802
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
803
+
804
+ // Delete the placement on disk — forces re-fetch on next runSync.
805
+ rmSync(resolvePlacementDir("claudeProject", "forced"), {
806
+ recursive: true,
807
+ force: true,
808
+ });
809
+
810
+ const result = await runSync({
811
+ serverUrl,
812
+ apiKey: VALID_KEY,
813
+ vendors: ["claudeCode"],
814
+ });
815
+ // Prior syncedAt was set → fullSync stays false even though a
816
+ // full re-fetch happened. This preserves the init.mjs consumer
817
+ // semantic: fullSync=true means "no prior sync state existed",
818
+ // not "this sync returned a full library payload."
819
+ assert.equal(result.fullSync, false);
820
+ // The forced re-fetch DOES produce a non-zero write count —
821
+ // which is why the behavioral consequence of the semantic
822
+ // mismatch is unreachable. Documents that fact.
823
+ assert.equal(result.added, 1, "forced re-fetch must restore the missing skill");
824
+ assert.equal(result.notModified, false);
825
+ });
783
826
  });
784
827
 
785
828
  // ── runSync — argument validation ──────────────────────────────────────
@@ -1093,3 +1136,180 @@ describe("runSync — cohort placement classification", () => {
1093
1136
  );
1094
1137
  });
1095
1138
  });
1139
+
1140
+ // ───────────────────────────────────────────────────────────────────
1141
+ // placementsAreComplete unit tests (PR #1575 hotfix)
1142
+ // ───────────────────────────────────────────────────────────────────
1143
+ //
1144
+ // Direct unit coverage for the function that decides whether the ETag
1145
+ // short-circuit is safe. The integration tests in
1146
+ // update-list-contract.integration.test.mjs exercise the function
1147
+ // end-to-end through runSync; this suite covers the branches:
1148
+ //
1149
+ // - empty/missing skills map (returns false — force re-fetch)
1150
+ // - empty/missing vendors (returns true — nothing to verify)
1151
+ // - placementTargetsFor throws (returns false — safe direction)
1152
+ // - malformed skill keys (skip without crash)
1153
+ // - skill present in EVERY target (returns true)
1154
+ // - skill missing in ONE target (returns false)
1155
+ // - directory exists but SKILL.md missing (returns false)
1156
+
1157
+ describe("placementsAreComplete — direct unit coverage", () => {
1158
+ beforeEach(() => {
1159
+ sandbox = mkdtempSync(join(tmpdir(), "cli-pac-"));
1160
+ mkdirSync(join(sandbox, "project"), { recursive: true });
1161
+ mkdirSync(join(sandbox, "home"), { recursive: true });
1162
+ originalCwd = process.cwd();
1163
+ originalHomeEnv = captureHome();
1164
+ process.chdir(join(sandbox, "project"));
1165
+ setSandboxHome(join(sandbox, "home"));
1166
+ });
1167
+ afterEach(() => {
1168
+ process.chdir(originalCwd);
1169
+ restoreHome(originalHomeEnv);
1170
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
1171
+ });
1172
+
1173
+ function seedSkillMd(target, skillName) {
1174
+ const dir = resolvePlacementDir(target, skillName);
1175
+ mkdirSync(dir, { recursive: true });
1176
+ writeFileSync(
1177
+ join(dir, "SKILL.md"),
1178
+ `---\nname: ${skillName}\ndescription: x\n---\n`,
1179
+ "utf-8",
1180
+ );
1181
+ }
1182
+
1183
+ it("returns false on null skillsMap (can't trust empty baseline)", () => {
1184
+ assert.equal(placementsAreComplete(null, ["claudeCode"], false), false);
1185
+ });
1186
+
1187
+ it("returns false on undefined skillsMap", () => {
1188
+ assert.equal(placementsAreComplete(undefined, ["claudeCode"], false), false);
1189
+ });
1190
+
1191
+ it("returns false on empty skillsMap object (v1 → v2 recovery path)", () => {
1192
+ assert.equal(placementsAreComplete({}, ["claudeCode"], false), false);
1193
+ });
1194
+
1195
+ it("returns true on empty vendors array (no placements to verify)", () => {
1196
+ // Defensive guard — `requireVendorTargets` in the command layer
1197
+ // catches this case before runSync, but if it ever reached here
1198
+ // we'd return true (no targets means trivially complete).
1199
+ const skills = {
1200
+ "alice/skill": {
1201
+ version: "1.0.0",
1202
+ skillMdSha256: "a".repeat(64),
1203
+ filesSha256: "b".repeat(64),
1204
+ syncedAt: "x",
1205
+ },
1206
+ };
1207
+ assert.equal(placementsAreComplete(skills, [], false), true);
1208
+ });
1209
+
1210
+ it("returns true on undefined vendors (same defensive path)", () => {
1211
+ const skills = {
1212
+ "alice/skill": {
1213
+ version: "1.0.0",
1214
+ skillMdSha256: "a".repeat(64),
1215
+ filesSha256: "b".repeat(64),
1216
+ syncedAt: "x",
1217
+ },
1218
+ };
1219
+ assert.equal(placementsAreComplete(skills, undefined, false), true);
1220
+ });
1221
+
1222
+ it("returns true when every (skill, target) pair has its SKILL.md on disk", () => {
1223
+ seedSkillMd("claudeProject", "skill-a");
1224
+ const skills = {
1225
+ "alice/skill-a": {
1226
+ version: "1.0.0",
1227
+ skillMdSha256: "a".repeat(64),
1228
+ filesSha256: "b".repeat(64),
1229
+ syncedAt: "x",
1230
+ },
1231
+ };
1232
+ assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
1233
+ });
1234
+
1235
+ it("returns false when ONE skill's SKILL.md is missing in ONE target", () => {
1236
+ // Two skills, claudeProject + agentsProject expected. Only one
1237
+ // skill landed in claudeProject; agentsProject has neither.
1238
+ seedSkillMd("claudeProject", "skill-a");
1239
+ const skills = {
1240
+ "alice/skill-a": {
1241
+ version: "1.0.0",
1242
+ skillMdSha256: "a".repeat(64),
1243
+ filesSha256: "b".repeat(64),
1244
+ syncedAt: "x",
1245
+ },
1246
+ };
1247
+ // Both claudeCode and cursor → both targets must have skill-a.
1248
+ // Only claudeProject does → returns false → forces re-fetch.
1249
+ assert.equal(
1250
+ placementsAreComplete(skills, ["claudeCode", "cursor"], false),
1251
+ false,
1252
+ );
1253
+ });
1254
+
1255
+ it("returns false when directory exists but SKILL.md is missing (partial dir guard)", () => {
1256
+ // The HIGH finding from the code-reviewer audit: an empty
1257
+ // directory satisfies existsSync but lacks the load-bearing
1258
+ // SKILL.md. The check probes SKILL.md specifically.
1259
+ const dir = resolvePlacementDir("claudeProject", "partial");
1260
+ mkdirSync(dir, { recursive: true });
1261
+ // NOTE: no writeFileSync — dir exists but is empty.
1262
+ const skills = {
1263
+ "alice/partial": {
1264
+ version: "1.0.0",
1265
+ skillMdSha256: "a".repeat(64),
1266
+ filesSha256: "b".repeat(64),
1267
+ syncedAt: "x",
1268
+ },
1269
+ };
1270
+ assert.equal(placementsAreComplete(skills, ["claudeCode"], false), false);
1271
+ });
1272
+
1273
+ it("returns false when placementTargetsFor throws (conservative direction)", () => {
1274
+ // copilot has globalTarget=null. `--global` with copilot throws
1275
+ // inside placementTargetsFor. placementsAreComplete catches the
1276
+ // throw and returns false so we drop the ETag and the
1277
+ // downstream write loop surfaces the same error with the
1278
+ // correct typed exception.
1279
+ const skills = {
1280
+ "alice/skill": {
1281
+ version: "1.0.0",
1282
+ skillMdSha256: "a".repeat(64),
1283
+ filesSha256: "b".repeat(64),
1284
+ syncedAt: "x",
1285
+ },
1286
+ };
1287
+ assert.equal(
1288
+ placementsAreComplete(skills, ["copilot"], true),
1289
+ false,
1290
+ );
1291
+ });
1292
+
1293
+ it("skips malformed skill keys (no slash) without crashing", () => {
1294
+ seedSkillMd("claudeProject", "good");
1295
+ const skills = {
1296
+ "alice/good": {
1297
+ version: "1.0.0",
1298
+ skillMdSha256: "a".repeat(64),
1299
+ filesSha256: "b".repeat(64),
1300
+ syncedAt: "x",
1301
+ },
1302
+ // Malformed key — no slash. Should be silently skipped so a
1303
+ // corrupt entry doesn't poison the whole check.
1304
+ "no-slash-key": {
1305
+ version: "1.0.0",
1306
+ skillMdSha256: "a".repeat(64),
1307
+ filesSha256: "b".repeat(64),
1308
+ syncedAt: "x",
1309
+ },
1310
+ };
1311
+ // Returns true because the valid skill is on disk and the
1312
+ // malformed entry is skipped (continue) — no false negative.
1313
+ assert.equal(placementsAreComplete(skills, ["claudeCode"], false), true);
1314
+ });
1315
+ });