skillrepo 4.7.0 → 4.8.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.7.0",
3
+ "version": "4.8.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
@@ -31,6 +31,11 @@
31
31
  * version + content SHAs of every skill written in the last
32
32
  * successful sync. Unblocks per-skill drift detection in `list`
33
33
  * (#1555) and any future `status`-style command.
34
+ * Later (#1911) v2 also carries `cliVersion` — the version of the
35
+ * CLI that last wrote the state file. Additive, not a schema bump:
36
+ * absence is meaningful (a state file with no `cliVersion` was
37
+ * written by a pre-#1911 CLI and triggers the one-time membership
38
+ * heal). See `FULL_RESYNC_FLOOR` / `cliVersionBelowFloor`.
34
39
  *
35
40
  * Forward/backward compatibility
36
41
  * ------------------------------
@@ -88,13 +93,20 @@
88
93
  * @property {number} schemaVersion
89
94
  * @property {string|null} etag
90
95
  * @property {string} syncedAt
96
+ * @property {string} [cliVersion] - v2+ (#1911). Version of the CLI that last
97
+ * wrote this state. Absent on files written
98
+ * by a pre-#1911 CLI; absence triggers the
99
+ * one-time membership heal.
91
100
  * @property {Record<string, SyncedSkillEntry>} skills - v2+. Keyed by `"<owner>/<name>"`.
92
101
  */
93
102
 
94
103
  import { existsSync, readFileSync } from "node:fs";
95
104
  import { join } from "node:path";
96
105
 
106
+ import semver from "semver";
107
+
97
108
  import { getLibrary, postSyncReceipt } from "./http.mjs";
109
+ import { getCliVersion } from "./cli-version.mjs";
98
110
  import {
99
111
  writeSkillDir,
100
112
  removeSkillDir,
@@ -118,6 +130,66 @@ import { computeSkillShas } from "./crypto-shas.mjs";
118
130
  */
119
131
  export const LAST_SYNC_SCHEMA_VERSION = 2;
120
132
 
133
+ /**
134
+ * One-time heal floor (#1911).
135
+ *
136
+ * A `.last-sync` written by a CLI BELOW this version predates the
137
+ * server-side delta-membership fix. Such a state file can be permanently
138
+ * missing skills that were added to the library but never delivered: the
139
+ * old delta filter keyed only on `skills.updatedAt`, so a skill whose
140
+ * content predated the user's `since` was skipped even though the library
141
+ * ETag advanced (count++). The client then cached the advanced ETag and
142
+ * every subsequent `update` short-circuited on a 304 — the skill could
143
+ * never arrive via delta.
144
+ *
145
+ * The server fix makes FUTURE adds correct, but it cannot rescue a client
146
+ * that already cached the advanced ETag (it still matches → 304). So the
147
+ * FIRST sync performed by a CLI at/above this floor forces ONE full fetch
148
+ * (drops `If-None-Match` AND `since`), re-fetching the entire library to
149
+ * fill any gap, then resumes normal delta. `writeLastSync` stamps the
150
+ * running CLI version on every write, so the heal fires at most once per
151
+ * upgrade.
152
+ *
153
+ * MUST equal the CLI version that ships this fix (see packages/cli/package.json).
154
+ */
155
+ export const FULL_RESYNC_FLOOR = "4.8.0";
156
+
157
+ /**
158
+ * True when a `.last-sync`'s recorded writer version is below the heal
159
+ * floor — i.e. the state file was written before the #1911 fix and may be
160
+ * missing never-delivered skills. A missing or unparseable version is
161
+ * treated as below the floor (older CLIs never stamped `cliVersion`), so
162
+ * every pre-fix state file heals exactly once.
163
+ *
164
+ * @param {unknown} recordedVersion - `.last-sync`'s `cliVersion` field.
165
+ * @param {string} floor - The heal floor (a valid semver string).
166
+ * @returns {boolean}
167
+ */
168
+ export function cliVersionBelowFloor(recordedVersion, floor) {
169
+ if (typeof recordedVersion !== "string" || !semver.valid(recordedVersion)) {
170
+ return true;
171
+ }
172
+ return semver.lt(recordedVersion, floor);
173
+ }
174
+
175
+ /**
176
+ * The running CLI's version, or null if it cannot be read. `getCliVersion`
177
+ * throws on a malformed/absent package.json (a broken tarball); persisting
178
+ * last-sync state must never fail on that, so we soft-handle to null here.
179
+ * A null stamp simply means the next run treats this state as pre-floor and
180
+ * heals again — safe, just one extra full fetch in the (production-
181
+ * impossible) broken-tarball case.
182
+ *
183
+ * @returns {string | null}
184
+ */
185
+ function safeCliVersion() {
186
+ try {
187
+ return getCliVersion();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
121
193
  /**
122
194
  * Check whether every skill in the `.last-sync` baseline already exists
123
195
  * under every placement target the current sync would write to. Returns
@@ -297,17 +369,26 @@ export function readLastSync() {
297
369
  * sync"; consumers should treat absent entries as "unknown" rather
298
370
  * than as "missing from library."
299
371
  *
372
+ * Always stamps `cliVersion` with the running CLI's version (#1911). This
373
+ * is the writer-version record the heal gate (`cliVersionBelowFloor`) reads
374
+ * on the next run; a state file written by the current CLI is therefore
375
+ * never re-healed. A caller-supplied `cliVersion` overrides the stamp (used
376
+ * only by tests to simulate a pre-fix writer); production callers omit it.
377
+ *
300
378
  * @param {object} state
301
379
  * @param {string | null} [state.etag]
302
380
  * @param {string} [state.syncedAt]
303
381
  * @param {Record<string, SyncedSkillEntry>} [state.skills]
382
+ * @param {string | null} [state.cliVersion] - Override the writer-version
383
+ * stamp. Defaults to the running CLI's version.
304
384
  */
305
- export function writeLastSync({ etag, syncedAt, skills } = {}) {
385
+ export function writeLastSync({ etag, syncedAt, skills, cliVersion } = {}) {
306
386
  const path = globalLastSyncPath();
307
387
  const body = {
308
388
  schemaVersion: LAST_SYNC_SCHEMA_VERSION,
309
389
  etag: etag ?? null,
310
390
  syncedAt: syncedAt ?? new Date().toISOString(),
391
+ cliVersion: cliVersion !== undefined ? cliVersion : safeCliVersion(),
311
392
  skills: skills && typeof skills === "object" && !Array.isArray(skills) ? skills : {},
312
393
  };
313
394
  try {
@@ -390,6 +471,15 @@ export function upsertLastSyncEntry(skill) {
390
471
  etag: prior?.etag ?? null,
391
472
  syncedAt: prior?.syncedAt,
392
473
  skills: updated,
474
+ // Preserve the prior writer-version too (#1911). `add`/`get` fetch a
475
+ // SINGLE skill — they do NOT perform the full-library reconciliation
476
+ // the membership heal needs, so they must NOT advance the heal gate.
477
+ // Letting `writeLastSync` default-stamp the running version here would
478
+ // let a stuck user "spend" their one-time heal on a single-skill add,
479
+ // leaving every OTHER never-delivered skill stuck forever. `?? null`
480
+ // carries a pre-fix file's absent version forward as an explicit null
481
+ // so the next `update` still heals. Same rationale as etag/syncedAt.
482
+ cliVersion: prior?.cliVersion ?? null,
393
483
  });
394
484
  }
395
485
 
@@ -413,6 +503,10 @@ export function deleteLastSyncEntry(owner, name) {
413
503
  etag: prior.etag ?? null,
414
504
  syncedAt: prior.syncedAt,
415
505
  skills: rest,
506
+ // Preserve the prior writer-version (#1911) — `remove` is not a full
507
+ // reconciliation, so it must not advance the one-time heal gate.
508
+ // See upsertLastSyncEntry for the full rationale.
509
+ cliVersion: prior.cliVersion ?? null,
416
510
  });
417
511
  }
418
512
 
@@ -527,19 +621,41 @@ export async function runSync(options) {
527
621
  const placementsComplete =
528
622
  lastSync?.etag &&
529
623
  placementsAreComplete(lastSync.skills, vendors, global);
530
- if (placementsComplete) {
624
+
625
+ // One-time membership-heal (#1911). A `.last-sync` written by a CLI below
626
+ // the heal floor predates the server delta-membership fix and may be
627
+ // permanently missing skills that were added to the library but never
628
+ // delivered (their content predated `since`, so the old delta skipped
629
+ // them while the ETag still advanced — the client cached the advanced
630
+ // ETag and every later sync 304'd). The server fix can't rescue such a
631
+ // client: its cached ETag still matches, so it keeps getting 304s. Force
632
+ // ONE full fetch (drop BOTH conditional headers) so the entire library
633
+ // is re-fetched and the gap is filled. `writeLastSync` stamps the running
634
+ // version on the resulting write, so this fires at most once per upgrade.
635
+ //
636
+ // Mixed-version note (benign, do NOT "fix" with a different mechanism): if
637
+ // a pre-4.8.0 CLI shares this same global `.last-sync` and runs after a
638
+ // 4.8.0 heal, its `writeLastSync` re-emits a file with no `cliVersion`, so
639
+ // the next 4.8.0 run heals again. That loop is harmless — a full re-fetch
640
+ // is idempotent — and self-resolves once the older install is upgraded.
641
+ const needsFullResync =
642
+ !!lastSync && cliVersionBelowFloor(lastSync.cliVersion, FULL_RESYNC_FLOOR);
643
+
644
+ if (placementsComplete && !needsFullResync) {
531
645
  opts.ifNoneMatch = lastSync.etag;
532
646
  }
533
- if (lastSync?.syncedAt && placementsComplete) {
647
+ if (lastSync?.syncedAt && placementsComplete && !needsFullResync) {
534
648
  opts.since = lastSync.syncedAt;
535
649
  }
536
650
 
537
651
  // Track whether this is a full or delta sync BEFORE the network
538
- // call, for the returned summary's `fullSync` field. A "full" sync
539
- // is one where no `since` was sent which is exactly when no
540
- // prior last-sync state existed. The distinction matters to
541
- // consumers (init.mjs) that need to tell "empty library" from
542
- // "nothing changed since last sync" in the zero-counters case.
652
+ // call, for the returned summary's `fullSync` field. The documented
653
+ // semantic is "no PRIOR last-sync state existed"NOT "no `since` was
654
+ // sent". A forced full re-fetch (placement-incomplete, or the #1911
655
+ // membership heal) still reports `fullSync:false` because prior state
656
+ // existed; only a genuine first sync reports true. Consumers (init.mjs)
657
+ // rely on this to tell "empty library" from "nothing changed since last
658
+ // sync" in the zero-counters case. Locked by sync.test.mjs.
543
659
  const fullSync = !lastSync?.syncedAt;
544
660
 
545
661
  const result = await getLibrary(serverUrl, apiKey, opts);
@@ -571,21 +571,29 @@ export function createMockServer(initialPayload, options = {}) {
571
571
  // newly-detected vendor placements empty forever. Mirroring the
572
572
  // filter here means any future regression of that class fails
573
573
  // at test time. See src/lib/queries/library.ts in the server
574
- // for the production behavior (`gt(skills.updatedAt, since)`).
574
+ // for the production behavior. As of #1911 the production delta is
575
+ // `or(gt(skills.updatedAt, since), gt(skillLibraryItems.addedAt, since))`
576
+ // — a membership add delivers a skill whose CONTENT predates `since`.
577
+ // Mirror BOTH arms here, otherwise no CLI-layer test can exercise the
578
+ // addedAt branch and a server-side revert of it would pass every CLI
579
+ // test. Fixture skills may carry an optional `addedAt` ISO string to
580
+ // drive that arm; absent → only the updatedAt arm applies.
575
581
  let respondedSkills = libraryResponse.skills ?? [];
576
582
  if (sinceParam && Array.isArray(respondedSkills)) {
577
583
  const sinceDate = new Date(sinceParam);
578
584
  if (!Number.isNaN(sinceDate.getTime())) {
579
- respondedSkills = respondedSkills.filter((s) => {
580
- // updatedAt is a string ISO timestamp in the fixture skills.
581
- // Missing/invalid treat as "older than since" → exclude
582
- // (matches the production semantic of "not modified in
583
- // the window").
584
- if (typeof s.updatedAt !== "string") return false;
585
- const updated = new Date(s.updatedAt);
586
- if (Number.isNaN(updated.getTime())) return false;
587
- return updated.getTime() > sinceDate.getTime();
588
- });
585
+ const afterSince = (iso) => {
586
+ // Missing/invalid treat as "older than since" (excluded by
587
+ // this arm), matching the production semantic of "not modified
588
+ // / not added in the window".
589
+ if (typeof iso !== "string") return false;
590
+ const t = new Date(iso);
591
+ if (Number.isNaN(t.getTime())) return false;
592
+ return t.getTime() > sinceDate.getTime();
593
+ };
594
+ respondedSkills = respondedSkills.filter(
595
+ (s) => afterSince(s.updatedAt) || afterSince(s.addedAt),
596
+ );
589
597
  }
590
598
  }
591
599
 
@@ -38,7 +38,11 @@ import {
38
38
  runSync,
39
39
  readLastSync,
40
40
  writeLastSync,
41
+ upsertLastSyncEntry,
42
+ deleteLastSyncEntry,
41
43
  placementsAreComplete,
44
+ cliVersionBelowFloor,
45
+ FULL_RESYNC_FLOOR,
42
46
  LAST_SYNC_SCHEMA_VERSION,
43
47
  } from "../../lib/sync.mjs";
44
48
  import { resolvePlacementDir } from "../../lib/file-write.mjs";
@@ -263,6 +267,22 @@ describe("readLastSync / writeLastSync", () => {
263
267
  writeLastSync({ etag: "x", syncedAt: "x" });
264
268
  assert.ok(existsSync(globalLastSyncPath()));
265
269
  });
270
+
271
+ it("stamps cliVersion with the running CLI version by default (#1911)", () => {
272
+ // Every write records the writer's version so the heal gate
273
+ // (cliVersionBelowFloor) can tell on the next run whether the state
274
+ // predates the #1911 fix. getCliVersion() reads the real
275
+ // packages/cli/package.json, so this is a genuine semver.
276
+ writeLastSync({ etag: '"v"', syncedAt: "x" });
277
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
278
+ assert.match(onDisk.cliVersion, /^\d+\.\d+\.\d+/);
279
+ });
280
+
281
+ it("honors an explicit cliVersion override (test seam for pre-fix state) (#1911)", () => {
282
+ writeLastSync({ etag: '"v"', syncedAt: "x", cliVersion: "4.7.0" });
283
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
284
+ assert.equal(onDisk.cliVersion, "4.7.0");
285
+ });
266
286
  });
267
287
 
268
288
  // ── runSync — empty library ────────────────────────────────────────────
@@ -1126,6 +1146,286 @@ describe("runSync — ETag round-trip", () => {
1126
1146
 
1127
1147
  // ── runSync — argument validation ──────────────────────────────────────
1128
1148
 
1149
+ describe("runSync — one-time membership heal (#1911)", () => {
1150
+ beforeEach(setupServer);
1151
+ afterEach(teardownServer);
1152
+
1153
+ it("cliVersionBelowFloor: missing/invalid/older → true; equal/newer → false", () => {
1154
+ assert.equal(cliVersionBelowFloor(undefined, "4.8.0"), true);
1155
+ assert.equal(cliVersionBelowFloor(null, "4.8.0"), true);
1156
+ assert.equal(cliVersionBelowFloor("", "4.8.0"), true);
1157
+ assert.equal(cliVersionBelowFloor("not-semver", "4.8.0"), true);
1158
+ assert.equal(cliVersionBelowFloor("4.7.0", "4.8.0"), true);
1159
+ assert.equal(cliVersionBelowFloor("4.7.9", "4.8.0"), true);
1160
+ assert.equal(cliVersionBelowFloor("4.8.0", "4.8.0"), false);
1161
+ assert.equal(cliVersionBelowFloor("4.8.1", "4.8.0"), false);
1162
+ assert.equal(cliVersionBelowFloor("5.0.0", "4.8.0"), false);
1163
+ });
1164
+
1165
+ it("FULL_RESYNC_FLOOR equals the shipped CLI version", () => {
1166
+ // The floor MUST equal the version that ships this fix; otherwise the
1167
+ // running CLI's own writes would stamp a version below the floor and
1168
+ // it would re-heal on every run. Lock them together.
1169
+ const pkg = JSON.parse(
1170
+ readFileSync(new URL("../../../package.json", import.meta.url), "utf-8"),
1171
+ );
1172
+ assert.equal(FULL_RESYNC_FLOOR, pkg.version);
1173
+ });
1174
+
1175
+ it("a pre-fix .last-sync forces ONE full re-fetch, then resumes delta", async () => {
1176
+ server.setEtag('"v1"');
1177
+ server.setLibraryResponse({
1178
+ skills: [makeSkill("healme")],
1179
+ removals: [],
1180
+ syncedAt: "2025-01-01T00:00:00Z",
1181
+ });
1182
+
1183
+ // 1) Normal sync by the current CLI → disk + .last-sync stamped with
1184
+ // the current (>= floor) version.
1185
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1186
+
1187
+ // 2) Rewrite .last-sync to mimic a PRE-#1911 writer: identical
1188
+ // etag/skills/syncedAt — so placements are complete and a 304 WOULD
1189
+ // normally fire — but stamped below the heal floor.
1190
+ const prior = readLastSync();
1191
+ writeLastSync({
1192
+ etag: prior.etag,
1193
+ syncedAt: prior.syncedAt,
1194
+ skills: prior.skills,
1195
+ cliVersion: "4.7.0",
1196
+ });
1197
+ server.resetLibraryInspection();
1198
+
1199
+ // 3) Heal: must force a FULL fetch — NEITHER conditional header sent —
1200
+ // despite complete placements and a matching etag.
1201
+ const heal = await runSync({
1202
+ serverUrl,
1203
+ apiKey: VALID_KEY,
1204
+ vendors: ["claudeCode"],
1205
+ });
1206
+ assert.equal(
1207
+ server.getLastLibraryIfNoneMatch(),
1208
+ null,
1209
+ "heal must NOT send If-None-Match",
1210
+ );
1211
+ assert.equal(server.getLastLibrarySince(), null, "heal must NOT send since");
1212
+ assert.equal(heal.notModified, false, "heal performs a real fetch, not a 304");
1213
+
1214
+ // 4) The heal write re-stamped the current version → the next sync
1215
+ // resumes normal delta (sends the conditional, gets a 304).
1216
+ const after = readLastSync();
1217
+ assert.equal(
1218
+ cliVersionBelowFloor(after.cliVersion, FULL_RESYNC_FLOOR),
1219
+ false,
1220
+ "heal must restamp cliVersion at/above the floor",
1221
+ );
1222
+ server.resetLibraryInspection();
1223
+ const resumed = await runSync({
1224
+ serverUrl,
1225
+ apiKey: VALID_KEY,
1226
+ vendors: ["claudeCode"],
1227
+ });
1228
+ assert.equal(
1229
+ server.getLastLibraryIfNoneMatch(),
1230
+ '"v1"',
1231
+ "post-heal sync sends If-None-Match",
1232
+ );
1233
+ assert.equal(
1234
+ resumed.notModified,
1235
+ true,
1236
+ "post-heal sync short-circuits on 304",
1237
+ );
1238
+ });
1239
+
1240
+ it("does NOT force a re-fetch when .last-sync was written at/above the floor", async () => {
1241
+ server.setEtag('"v1"');
1242
+ server.setLibraryResponse({
1243
+ skills: [makeSkill("steady")],
1244
+ removals: [],
1245
+ syncedAt: "2025-01-01T00:00:00Z",
1246
+ });
1247
+ // First sync stamps the current (>= floor) version.
1248
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1249
+ server.resetLibraryInspection();
1250
+
1251
+ const second = await runSync({
1252
+ serverUrl,
1253
+ apiKey: VALID_KEY,
1254
+ vendors: ["claudeCode"],
1255
+ });
1256
+ assert.equal(
1257
+ server.getLastLibraryIfNoneMatch(),
1258
+ '"v1"',
1259
+ "steady-state sync still sends the conditional (no spurious heal)",
1260
+ );
1261
+ assert.equal(second.notModified, true, "no heal → normal 304");
1262
+ });
1263
+
1264
+ it("heals a REAL pre-fix v2 .last-sync (cliVersion key absent, not just old)", async () => {
1265
+ // The actual on-disk shape for users still on < 4.8.0 is a v2 file with
1266
+ // NO `cliVersion` key at all (writeLastSync didn't emit it pre-#1911) —
1267
+ // distinct from an explicit "4.7.0". Prove the heal fires for that shape
1268
+ // end-to-end through runSync (the unit test of cliVersionBelowFloor(undefined)
1269
+ // covers the gate in isolation; this covers the full path).
1270
+ server.setEtag('"v1"');
1271
+ server.setLibraryResponse({
1272
+ skills: [makeSkill("healme2")],
1273
+ removals: [],
1274
+ syncedAt: "2025-01-01T00:00:00Z",
1275
+ });
1276
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1277
+ const prior = readLastSync();
1278
+
1279
+ // Rewrite as a genuine pre-#1911 v2 file: cliVersion key entirely absent.
1280
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
1281
+ writeFileSync(
1282
+ globalLastSyncPath(),
1283
+ JSON.stringify({
1284
+ schemaVersion: 2,
1285
+ etag: prior.etag,
1286
+ syncedAt: prior.syncedAt,
1287
+ skills: prior.skills,
1288
+ }),
1289
+ );
1290
+ const onDisk = JSON.parse(readFileSync(globalLastSyncPath(), "utf-8"));
1291
+ assert.equal(
1292
+ Object.prototype.hasOwnProperty.call(onDisk, "cliVersion"),
1293
+ false,
1294
+ "precondition: cliVersion must be ABSENT, not old",
1295
+ );
1296
+ server.resetLibraryInspection();
1297
+
1298
+ const heal = await runSync({
1299
+ serverUrl,
1300
+ apiKey: VALID_KEY,
1301
+ vendors: ["claudeCode"],
1302
+ });
1303
+ assert.equal(server.getLastLibraryIfNoneMatch(), null, "absent cliVersion must heal");
1304
+ assert.equal(server.getLastLibrarySince(), null, "absent cliVersion must heal");
1305
+ assert.equal(heal.notModified, false);
1306
+ assert.equal(
1307
+ cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
1308
+ false,
1309
+ "heal restamps a real version so it won't re-heal",
1310
+ );
1311
+ });
1312
+
1313
+ it("delta delivers a skill whose content predates `since` but was added after it", async () => {
1314
+ // The CLI-layer counterpart of the server fix: a steady-state DELTA sync
1315
+ // (since sent, NOT a heal) must still receive a skill whose updatedAt is
1316
+ // older than `since` when its addedAt is newer. The mock mirrors the
1317
+ // server's `or(updatedAt, addedAt)` filter so a server-side revert of the
1318
+ // addedAt branch would fail here, not silently pass.
1319
+ const S = "2025-06-01T00:00:00Z";
1320
+ server.setEtag('"e1"');
1321
+ server.setLibraryResponse({
1322
+ skills: [{ ...makeSkill("base"), updatedAt: "2025-06-02T00:00:00Z" }],
1323
+ removals: [],
1324
+ syncedAt: S,
1325
+ });
1326
+ // Baseline sync: puts `base` on disk, stamps current version (no heal).
1327
+ await runSync({ serverUrl, apiKey: VALID_KEY, vendors: ["claudeCode"] });
1328
+
1329
+ // A new skill enters the library: OLD content (updatedAt < S), just ADDED
1330
+ // (addedAt > S). Library etag changes (not a 304).
1331
+ server.setEtag('"e2"');
1332
+ server.setLibraryResponse({
1333
+ skills: [
1334
+ { ...makeSkill("base"), updatedAt: "2025-06-02T00:00:00Z" },
1335
+ {
1336
+ ...makeSkill("added-old"),
1337
+ updatedAt: "2025-01-01T00:00:00Z",
1338
+ addedAt: "2025-12-01T00:00:00Z",
1339
+ },
1340
+ ],
1341
+ removals: [],
1342
+ syncedAt: "2025-12-02T00:00:00Z",
1343
+ });
1344
+ server.resetLibraryInspection();
1345
+ const result = await runSync({
1346
+ serverUrl,
1347
+ apiKey: VALID_KEY,
1348
+ vendors: ["claudeCode"],
1349
+ });
1350
+
1351
+ assert.equal(server.getLastLibrarySince(), S, "must be a DELTA (since sent), not a heal");
1352
+ assert.ok(
1353
+ existsSync(join(resolvePlacementDir("claudeProject", "added-old"), "SKILL.md")),
1354
+ "addedAt-only skill must be delivered via the membership branch",
1355
+ );
1356
+ assert.equal(result.added, 1, "the added-old skill counts as newly added");
1357
+ });
1358
+
1359
+ it("upsertLastSyncEntry preserves a pre-fix cliVersion so the heal isn't burned", () => {
1360
+ // A stuck pre-fix client whose FIRST 4.8.0 command is `add`/`get`
1361
+ // (→ upsertLastSyncEntry) must NOT have its heal-gate version advanced —
1362
+ // add fetches ONE skill, not the full library, so other stuck skills
1363
+ // remain missing and the next `update` must still heal.
1364
+ writeLastSync({
1365
+ etag: '"e"',
1366
+ syncedAt: "2025-01-01T00:00:00Z",
1367
+ skills: {},
1368
+ cliVersion: "4.7.0",
1369
+ });
1370
+ upsertLastSyncEntry({
1371
+ owner: "alice",
1372
+ name: "added",
1373
+ version: "1.0.0",
1374
+ files: [
1375
+ { path: "SKILL.md", content: "---\nname: added\ndescription: d\n---\nbody" },
1376
+ ],
1377
+ });
1378
+ assert.equal(
1379
+ readLastSync().cliVersion,
1380
+ "4.7.0",
1381
+ "add/get must not advance the heal-gate version",
1382
+ );
1383
+ });
1384
+
1385
+ it("upsertLastSyncEntry carries an ABSENT cliVersion forward (still below floor)", () => {
1386
+ mkdirSync(join(process.env.HOME, ".claude", "skillrepo"), { recursive: true });
1387
+ writeFileSync(
1388
+ globalLastSyncPath(),
1389
+ JSON.stringify({ schemaVersion: 2, etag: '"e"', syncedAt: "s", skills: {} }),
1390
+ );
1391
+ upsertLastSyncEntry({
1392
+ owner: "alice",
1393
+ name: "added",
1394
+ version: "1.0.0",
1395
+ files: [
1396
+ { path: "SKILL.md", content: "---\nname: added\ndescription: d\n---\nbody" },
1397
+ ],
1398
+ });
1399
+ assert.equal(
1400
+ cliVersionBelowFloor(readLastSync().cliVersion, FULL_RESYNC_FLOOR),
1401
+ true,
1402
+ "absent cliVersion stays below floor → next update still heals",
1403
+ );
1404
+ });
1405
+
1406
+ it("deleteLastSyncEntry preserves a pre-fix cliVersion", () => {
1407
+ writeLastSync({
1408
+ etag: '"e"',
1409
+ syncedAt: "s",
1410
+ skills: {
1411
+ "alice/x": {
1412
+ version: "1.0.0",
1413
+ skillMdSha256: "a".repeat(64),
1414
+ filesSha256: "b".repeat(64),
1415
+ syncedAt: "s",
1416
+ },
1417
+ },
1418
+ cliVersion: "4.7.0",
1419
+ });
1420
+ deleteLastSyncEntry("alice", "x");
1421
+ assert.equal(
1422
+ readLastSync().cliVersion,
1423
+ "4.7.0",
1424
+ "remove must not advance the heal-gate version",
1425
+ );
1426
+ });
1427
+ });
1428
+
1129
1429
  describe("runSync — input validation", () => {
1130
1430
  beforeEach(setupServer);
1131
1431
  afterEach(teardownServer);