skillrepo 4.5.0 → 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.0",
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": {
@@ -46,6 +46,7 @@ import {
46
46
  effectiveVendors,
47
47
  requireVendorTargets,
48
48
  } from "../lib/cli-config.mjs";
49
+ import { upsertLastSyncEntry } from "../lib/sync.mjs";
49
50
  import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
50
51
  import { validationError } from "../lib/errors.mjs";
51
52
 
@@ -158,6 +159,20 @@ export async function runAdd(argv, io = {}) {
158
159
 
159
160
  writeSkillDir(skill, { vendors, global: flags.global });
160
161
 
162
+ // Update `.last-sync` so `list` immediately recognizes this skill
163
+ // as `current`. Without this the user adds a skill, then runs
164
+ // `list`, and sees their freshly-added skill marked MISS — the
165
+ // exact same class of contract gap that PR #1574 fixed for the
166
+ // multi-vendor case in `update`/`list`. See `upsertLastSyncEntry`
167
+ // docstring for the etag-preservation rationale.
168
+ try {
169
+ upsertLastSyncEntry(skill);
170
+ } catch {
171
+ // Non-fatal: skill is on disk, the state file write failure
172
+ // does not change that. Same semantics as runSync's warn-and-
173
+ // proceed for last-sync persistence failures.
174
+ }
175
+
161
176
  if (flags.json) {
162
177
  stdout.write(
163
178
  JSON.stringify(
@@ -181,11 +196,11 @@ export async function runAdd(argv, io = {}) {
181
196
  .join(", ")})`;
182
197
  if (wasNewlyAdded) {
183
198
  stdout.write(
184
- `\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`,
185
200
  );
186
201
  } else {
187
202
  stdout.write(
188
- `\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`,
189
204
  );
190
205
  }
191
206
  }
@@ -36,6 +36,7 @@ import {
36
36
  effectiveVendors,
37
37
  requireVendorTargets,
38
38
  } from "../lib/cli-config.mjs";
39
+ import { upsertLastSyncEntry } from "../lib/sync.mjs";
39
40
  import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
40
41
  import { validationError } from "../lib/errors.mjs";
41
42
 
@@ -109,6 +110,21 @@ export async function runGet(argv, io = {}) {
109
110
 
110
111
  writeSkillDir(skill, { vendors, global: flags.global });
111
112
 
113
+ // Update `.last-sync` so `list` recognizes this skill as `current`
114
+ // afterward instead of reporting a stale `MISSING`. Best-effort:
115
+ // a state-file write failure is non-fatal (the files are already
116
+ // on disk), but writeLastSync throws diskError if it cannot
117
+ // persist — we catch and continue. Pre-PR #1574 `get` skipped
118
+ // this entirely, which surfaced every freshly-fetched skill as
119
+ // MISS in the very next `list` invocation.
120
+ try {
121
+ upsertLastSyncEntry(skill);
122
+ } catch {
123
+ // Same failure semantics as runSync's "couldn't persist last
124
+ // sync state" path: warn and proceed, the user's intent (files
125
+ // on disk) was honored.
126
+ }
127
+
112
128
  if (flags.json) {
113
129
  stdout.write(
114
130
  JSON.stringify({
@@ -127,6 +143,6 @@ export async function runGet(argv, io = {}) {
127
143
  .map(describePlacementTarget)
128
144
  .join(", ")})`;
129
145
  stdout.write(
130
- `\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`,
131
147
  );
132
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
 
@@ -43,6 +43,7 @@ import {
43
43
  effectiveVendors,
44
44
  requireVendorTargets,
45
45
  } from "../lib/cli-config.mjs";
46
+ import { deleteLastSyncEntry } from "../lib/sync.mjs";
46
47
  import { parseIdentifier, formatIdentifier } from "../lib/identifier.mjs";
47
48
  import { validationError } from "../lib/errors.mjs";
48
49
 
@@ -111,6 +112,18 @@ export async function runRemove(argv, io = {}) {
111
112
  // owner-namespacing caveat.
112
113
  const localResult = removeSkillDir(name, { vendors, global: flags.global });
113
114
 
115
+ // Step 2.5: purge the `.last-sync` entry for this skill so a future
116
+ // re-add of the same identifier doesn't compare against a stale
117
+ // baseline. Idempotent: no-op if the entry isn't there. Non-fatal
118
+ // on write failure — the user's primary intent (skill gone from
119
+ // library + disk) was honored; a stale baseline is recoverable
120
+ // via the next `update`.
121
+ try {
122
+ deleteLastSyncEntry(owner, name);
123
+ } catch {
124
+ // Same warn-and-proceed semantics as runSync's last-sync writes.
125
+ }
126
+
114
127
  if (flags.json) {
115
128
  stdout.write(
116
129
  JSON.stringify(
@@ -140,7 +140,21 @@ export const AGENT_REGISTRY = Object.freeze([
140
140
  agentHook: null,
141
141
  detectionSignals: Object.freeze([
142
142
  Object.freeze({ type: "env", value: "CLAUDECODE" }),
143
- Object.freeze({ type: "home", value: ".claude" }),
143
+ // Home signal MUST be a file Claude Code creates that SkillRepo
144
+ // itself never writes. The earlier `.claude` (bare dir) signal
145
+ // false-positive-detected on any user who'd ever run a SkillRepo
146
+ // command — because `init` writes config to
147
+ // `~/.claude/skillrepo/config.json` and every `runSync` writes
148
+ // `.last-sync` to the same parent, both of which create the
149
+ // `~/.claude/` directory as a side effect. Cursor-only users
150
+ // (or any non-Claude-Code user) were then incorrectly detected
151
+ // as Claude Code users on every subsequent `list` invocation,
152
+ // producing MISS for every skill at the claudeProject placement
153
+ // that was never populated. Probing `settings.json` — created by
154
+ // Claude Code on first run and never touched by SkillRepo — is
155
+ // the load-bearing distinction. See PR #1574 production-
156
+ // readiness audit for the full chain.
157
+ Object.freeze({ type: "home", value: ".claude/settings.json" }),
144
158
  Object.freeze({ type: "project", value: ".claude" }),
145
159
  ]),
146
160
  }),
@@ -25,6 +25,7 @@ import {
25
25
  AGENTS_COHORT_KEYS,
26
26
  getAgentByAlias,
27
27
  } from "./agent-registry.mjs";
28
+ import { detectAgents } from "./detect-agents.mjs";
28
29
  import { authError, validationError } from "./errors.mjs";
29
30
 
30
31
  const ALL_VENDOR_KEYS = AGENT_REGISTRY.map((entry) => entry.key);
@@ -204,22 +205,48 @@ export function resolveFlags(argv, opts = {}) {
204
205
  * always wins. Both flags propagate together, so `--global
205
206
  * --agent windsurf` correctly resolves to `["windsurf"]` and
206
207
  * placementTargetsFor maps that to `windsurfGlobal`.
207
- * 2. No `--agent` defaults to `["claudeCode"]`, regardless of
208
- * `--global`. The v3.0.0 CLI deliberately does NOT silently
209
- * fall back to `[claudeCode, cursor]` like v2.0.0 did.
208
+ * 2. No `--agent` defaults to ALL DETECTED vendors (4.5.1+) — every
209
+ * agent whose detection signal fires in the current cwd / HOME /
210
+ * env. This matches `init`'s picker behavior and `list`'s drift
211
+ * detection model: the CLI assumes you want to keep every
212
+ * agent on your machine in sync.
213
+ * 3. Fallback to `["claudeCode"]` when nothing is detected (fresh
214
+ * machine with no agent installed yet — extremely rare, but
215
+ * preserves the historical default so `skillrepo init` from
216
+ * scratch still has a meaningful behavior).
217
+ *
218
+ * History: 4.4.0 and earlier defaulted to `["claudeCode"]` only,
219
+ * which created a mismatch with `list`'s drift detection in 4.5.0:
220
+ * `list` reported drift against all detected vendors while
221
+ * `update`/`add`/`remove`/`get` only wrote to Claude Code's
222
+ * placement. Multi-agent users saw every skill as `MISS` even after
223
+ * a full sync because the cohort `.agents/skills/` directory was
224
+ * never written to. See #1573 follow-up.
210
225
  *
211
226
  * The empty-array `--agent none` sentinel is returned verbatim —
212
227
  * callers detect "no placement" via `vendors.length === 0`. The
213
228
  * default-fallback branch uses an explicit null/undefined check so
214
229
  * the empty array survives.
215
230
  */
216
- export function effectiveVendors(flags) {
231
+ export function effectiveVendors(flags, { detect = defaultDetectVendors } = {}) {
217
232
  if (flags.vendors === null || flags.vendors === undefined) {
218
- return ["claudeCode"];
233
+ const detected = detect();
234
+ return detected.length > 0 ? detected : ["claudeCode"];
219
235
  }
220
236
  return flags.vendors;
221
237
  }
222
238
 
239
+ /**
240
+ * Default detection callable for `effectiveVendors`. Exposed for tests
241
+ * that want to inject a deterministic vendor list via the second-arg
242
+ * `{ detect }` option — production callers should not pass anything.
243
+ */
244
+ function defaultDetectVendors() {
245
+ return detectAgents()
246
+ .filter((d) => d.detected)
247
+ .map((d) => d.key);
248
+ }
249
+
223
250
  /**
224
251
  * Reject `--agent none` for commands that have no meaningful behavior
225
252
  * without a placement target. `init` and `update` accept `--agent none`
@@ -34,12 +34,51 @@
34
34
  * so probes are sandbox-isolated on every platform.
35
35
  */
36
36
 
37
- import { existsSync } from "node:fs";
37
+ import { existsSync, statSync } from "node:fs";
38
38
  import { homedir } from "node:os";
39
39
  import { join } from "node:path";
40
40
 
41
41
  import { AGENT_REGISTRY } from "./agent-registry.mjs";
42
42
 
43
+ /**
44
+ * Returns true when `path` exists and is reachable. Wraps `existsSync`
45
+ * for symmetry with the file-vs-dir formatters below; kept as a
46
+ * separate helper so a future refinement (e.g., disallow broken
47
+ * symlinks) can land in one place.
48
+ */
49
+ function probeFsPath(path) {
50
+ return existsSync(path);
51
+ }
52
+
53
+ /**
54
+ * Format the `reason` for a fired `home` signal. Bare-directory signals
55
+ * (`.cursor`, `~/.codeium/windsurf`) get a trailing slash; file-shaped
56
+ * signals (`.claude/settings.json`) do not, because rendering a file
57
+ * path with a trailing slash misleads the user in init's
58
+ * "(detected: …)" hint string. We `statSync` to distinguish — that
59
+ * avoids brittle string heuristics (we cannot just look for a dot in
60
+ * the last segment, because directories with dots in their names are
61
+ * permitted by every supported filesystem).
62
+ */
63
+ function formatHomeReason(value, path) {
64
+ return isFile(path) ? `~/${value}` : `~/${value}/`;
65
+ }
66
+
67
+ function formatProjectReason(value, path) {
68
+ return isFile(path) ? value : `${value}/`;
69
+ }
70
+
71
+ function isFile(path) {
72
+ try {
73
+ return statSync(path).isFile();
74
+ } catch {
75
+ // statSync after a successful existsSync is unlikely to throw, but
76
+ // a TOCTOU between the two would crash the picker. Default to the
77
+ // directory format on any read error.
78
+ return false;
79
+ }
80
+ }
81
+
43
82
  /**
44
83
  * @typedef {Object} AgentDetection
45
84
  * @property {string} key - Canonical agent key.
@@ -72,13 +111,11 @@ function probeSignal(signal) {
72
111
  }
73
112
  if (signal.type === "home") {
74
113
  const path = join(homedir(), signal.value);
75
- if (!existsSync(path)) return null;
76
- return `~/${signal.value}/`;
114
+ return probeFsPath(path) ? formatHomeReason(signal.value, path) : null;
77
115
  }
78
116
  if (signal.type === "project") {
79
117
  const path = join(process.cwd(), signal.value);
80
- if (!existsSync(path)) return null;
81
- return `${signal.value}/`;
118
+ return probeFsPath(path) ? formatProjectReason(signal.value, path) : null;
82
119
  }
83
120
  // Unknown type — defensive guard. Callers should never see this
84
121
  // because the registry's frozen entries are typed.
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
@@ -218,6 +316,99 @@ export function writeLastSync({ etag, syncedAt, skills } = {}) {
218
316
  }
219
317
  }
220
318
 
319
+ /**
320
+ * Add or replace a single skill's entry in `.last-sync` without
321
+ * touching the library-level `etag`/`syncedAt` fields.
322
+ *
323
+ * Why this exists: `get` and `add` write skills to disk one at a time
324
+ * without going through `runSync`. Pre-PR #1574 those commands left
325
+ * `.last-sync` untouched — meaning `list` afterwards couldn't see the
326
+ * skill in its baseline map and rendered it as `MISSING` even though
327
+ * it was sitting right there on disk. This helper closes that
328
+ * contract gap: any command that writes one skill's bytes to disk
329
+ * MUST also record the corresponding SHAs in `.last-sync` so the
330
+ * read-side drift detection can find them.
331
+ *
332
+ * Library-level `etag` and `syncedAt` are preserved verbatim from the
333
+ * prior state because they describe "what library snapshot did we
334
+ * fetch last" — `get` and `add` did not refresh that snapshot, so
335
+ * overwriting those fields would break the 304 short-circuit on the
336
+ * NEXT `update` (the server would think we're already past a state
337
+ * we never reached).
338
+ *
339
+ * Non-fatal on read/write failure: the actual skill files were
340
+ * already written to disk before we got here, so a broken state file
341
+ * should not roll back the user's intent. We throw on `writeLastSync`
342
+ * failure (same as the bulk path) so the caller can surface the
343
+ * warning, but otherwise this function is best-effort.
344
+ *
345
+ * @param {Object} skill - The skill that was just written.
346
+ * @param {string} skill.owner
347
+ * @param {string} skill.name
348
+ * @param {string} skill.version
349
+ * @param {{ path: string, content: string }[]} skill.files
350
+ */
351
+ export function upsertLastSyncEntry(skill) {
352
+ if (!skill || typeof skill !== "object") return;
353
+ if (typeof skill.owner !== "string" || typeof skill.name !== "string") return;
354
+ if (!Array.isArray(skill.files)) return;
355
+
356
+ const shas = computeSkillShas(skill.files);
357
+ // computeSkillShas returns nulls when there's no SKILL.md; persisting
358
+ // null SHAs would later classify as `edited`, which is wrong for a
359
+ // skill we just installed. Skip the upsert in that pathological case
360
+ // — list.mjs's `MISSING` verdict for an unknown skill is a more
361
+ // accurate signal than a fabricated `current` or `edited`.
362
+ if (shas.skillMdSha256 === null || shas.filesSha256 === null) return;
363
+
364
+ const prior = readLastSync();
365
+ const priorSkills =
366
+ prior && prior.skills && typeof prior.skills === "object"
367
+ ? prior.skills
368
+ : {};
369
+
370
+ const key = `${skill.owner}/${skill.name}`;
371
+ const updated = {
372
+ ...priorSkills,
373
+ [key]: {
374
+ version: typeof skill.version === "string" ? skill.version : "",
375
+ skillMdSha256: shas.skillMdSha256,
376
+ filesSha256: shas.filesSha256,
377
+ syncedAt: new Date().toISOString(),
378
+ },
379
+ };
380
+
381
+ writeLastSync({
382
+ // Preserve etag and syncedAt — see docstring "why this exists."
383
+ etag: prior?.etag ?? null,
384
+ syncedAt: prior?.syncedAt,
385
+ skills: updated,
386
+ });
387
+ }
388
+
389
+ /**
390
+ * Remove a single skill's entry from `.last-sync` without touching
391
+ * library-level fields. Used by `remove` after deleting the on-disk
392
+ * files. Idempotent: if the entry isn't there, no-op.
393
+ *
394
+ * @param {string} owner
395
+ * @param {string} name
396
+ */
397
+ export function deleteLastSyncEntry(owner, name) {
398
+ if (typeof owner !== "string" || typeof name !== "string") return;
399
+ const prior = readLastSync();
400
+ if (!prior || !prior.skills || typeof prior.skills !== "object") return;
401
+ const key = `${owner}/${name}`;
402
+ if (!(key in prior.skills)) return;
403
+ // eslint-disable-next-line no-unused-vars -- destructure-to-omit pattern
404
+ const { [key]: _removed, ...rest } = prior.skills;
405
+ writeLastSync({
406
+ etag: prior.etag ?? null,
407
+ syncedAt: prior.syncedAt,
408
+ skills: rest,
409
+ });
410
+ }
411
+
221
412
  /**
222
413
  * Run a library sync against the server.
223
414
  *
@@ -277,8 +468,61 @@ export async function runSync(options) {
277
468
 
278
469
  // Step 3: fetch with conditional headers
279
470
  const opts = {};
280
- if (lastSync?.etag) opts.ifNoneMatch = lastSync.etag;
281
- 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
+ }
282
526
 
283
527
  // Track whether this is a full or delta sync BEFORE the network
284
528
  // call, for the returned summary's `fullSync` field. A "full" sync
@@ -427,7 +671,13 @@ export async function runSync(options) {
427
671
  }
428
672
  }
429
673
 
430
- // 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?"
431
681
  if (!anyIncomplete && result.etag) {
432
682
  try {
433
683
  writeLastSync({