skillrepo 4.3.0 → 4.5.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.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Per-skill drift classification (#1555 — `list` local-awareness).
3
+ *
4
+ * Pure functions for deciding "is this skill current, stale, missing,
5
+ * or edited?" given the three input axes:
6
+ *
7
+ * 1. **Library state** — version from `GET /api/v1/library`.
8
+ * 2. **Last-sync baseline** — version + content SHAs persisted by
9
+ * `runSync` to `~/.claude/skillrepo/.last-sync` (v2 schema).
10
+ * 3. **On-disk presence** — { present, skillMdSha256, filesSha256 }
11
+ * from walking the vendor's placement directory.
12
+ *
13
+ * No I/O, no clock, no env reads. Same input → same output. This is
14
+ * the layer that gets exhaustive unit tests; the file-system walker
15
+ * and the `list` renderer compose this with their own concerns.
16
+ */
17
+
18
+ import semver from "semver";
19
+
20
+ /**
21
+ * Per-vendor placement state. The four values cover the
22
+ * "where is this skill, and is it the right version" question. A
23
+ * fifth state, `extra` (on disk but not in the library), lives in
24
+ * the renderer because it depends on the library shape, not just
25
+ * the per-skill comparison here.
26
+ */
27
+ export const SKILL_STATE = Object.freeze({
28
+ CURRENT: "current",
29
+ STALE: "stale",
30
+ MISSING: "missing",
31
+ EDITED: "edited",
32
+ });
33
+
34
+ /**
35
+ * Compute the placement-level state for a single (skill, vendor) pair.
36
+ *
37
+ * Decision order — first match wins:
38
+ *
39
+ * 1. **No on-disk presence** → `missing`. The vendor's placement
40
+ * directory does not contain this skill.
41
+ * 2. **No last-sync baseline** → `missing`. We can't verify
42
+ * anything without the SHA from the prior sync. Even if the
43
+ * directory exists, treating it as `current` would lie about
44
+ * drift we cannot detect. The user's resolution is the same as
45
+ * true `missing`: run `skillrepo update` to establish a
46
+ * baseline. This matches the spec's "fresh install → missing
47
+ * everywhere" behavior.
48
+ * 3. **Library version newer than synced version** → `stale`. The
49
+ * registry has moved on; the user is behind.
50
+ * 4. **Either SHA differs from baseline** → `edited`. Same
51
+ * version, content drifted. The user (or an agent) touched the
52
+ * files on disk after the last sync.
53
+ * 5. **Otherwise** → `current`.
54
+ *
55
+ * Semver comparison falls back to strict string equality when either
56
+ * value is not a valid semver. The fallback's effect: any non-semver
57
+ * mismatch is treated as `stale` (the user is "behind" some
58
+ * unparseable label). Pre-existing test fixtures (`"1.0.0"`,
59
+ * `"2.0.1"`) all parse; this branch exists for forward-compat with
60
+ * versioning schemes the registry may adopt later.
61
+ *
62
+ * @param {Object} input
63
+ * @param {string | null} input.libraryVersion
64
+ * The current version per the library response, or null if the
65
+ * library entry has no version yet (shouldn't happen in production
66
+ * but the type allows it).
67
+ * @param {{ version: string, skillMdSha256: string, filesSha256: string } | null} input.lastSyncEntry
68
+ * Persisted state from the last successful sync, or null if no
69
+ * baseline exists for this skill.
70
+ * @param {{ present: boolean, skillMdSha256: string | null, filesSha256: string | null } | null} input.localPlacement
71
+ * Disk presence at one vendor's placement target. `present: false`
72
+ * means the directory is absent; non-null SHAs imply `present:
73
+ * true`.
74
+ * @returns {"current" | "stale" | "missing" | "edited"}
75
+ */
76
+ export function computeSkillState({ libraryVersion, lastSyncEntry, localPlacement }) {
77
+ if (!localPlacement || !localPlacement.present) {
78
+ return SKILL_STATE.MISSING;
79
+ }
80
+ if (!lastSyncEntry) {
81
+ // On-disk but no baseline → cannot verify. Spec choice: report
82
+ // missing rather than fabricating a "current" verdict. See the
83
+ // docstring's decision-order comment.
84
+ return SKILL_STATE.MISSING;
85
+ }
86
+
87
+ if (libraryVersionIsNewer(libraryVersion, lastSyncEntry.version)) {
88
+ return SKILL_STATE.STALE;
89
+ }
90
+
91
+ const skillMdMatches =
92
+ localPlacement.skillMdSha256 !== null &&
93
+ localPlacement.skillMdSha256 === lastSyncEntry.skillMdSha256;
94
+ const filesMatch =
95
+ localPlacement.filesSha256 !== null &&
96
+ localPlacement.filesSha256 === lastSyncEntry.filesSha256;
97
+
98
+ if (skillMdMatches && filesMatch) return SKILL_STATE.CURRENT;
99
+ return SKILL_STATE.EDITED;
100
+ }
101
+
102
+ /**
103
+ * Reduce per-vendor states for a single skill to a single
104
+ * worst-state-wins value for the `Local` column in `list`'s table
105
+ * and the top-level `state` in `--json`.
106
+ *
107
+ * Precedence (high → low severity):
108
+ * missing > edited > stale > current
109
+ *
110
+ * Reasoning: the user's action depends on the worst state.
111
+ * - `missing` requires `skillrepo update` to populate the placement.
112
+ * - `edited` requires user judgement (keep edits? overwrite?).
113
+ * - `stale` requires `skillrepo update` to fetch the new version.
114
+ * - `current` requires nothing.
115
+ *
116
+ * Edited ranks above stale because the user is more likely to lose
117
+ * work on `edited` (overwrite by sync). Missing ranks above edited
118
+ * because the missing placement is the most immediately broken.
119
+ *
120
+ * An empty input array returns `missing` — there are no detected
121
+ * vendors to check against, so we can't claim anything is current.
122
+ * Callers that have a true "no vendors detected" case usually
123
+ * short-circuit before getting here, but the safe fallback is the
124
+ * conservative one.
125
+ *
126
+ * @param {ReadonlyArray<"current" | "stale" | "missing" | "edited">} perVendorStates
127
+ * @returns {"current" | "stale" | "missing" | "edited"}
128
+ */
129
+ export function rollupState(perVendorStates) {
130
+ if (!Array.isArray(perVendorStates) || perVendorStates.length === 0) {
131
+ return SKILL_STATE.MISSING;
132
+ }
133
+ const order = [
134
+ SKILL_STATE.MISSING,
135
+ SKILL_STATE.EDITED,
136
+ SKILL_STATE.STALE,
137
+ SKILL_STATE.CURRENT,
138
+ ];
139
+ for (const s of order) {
140
+ if (perVendorStates.includes(s)) return s;
141
+ }
142
+ // Defensive fallback for an array containing an unknown enum value.
143
+ // Treating it as the worst case (missing) is the conservative
144
+ // choice — it tells the user "something's off, run update" rather
145
+ // than silently rendering as current.
146
+ return SKILL_STATE.MISSING;
147
+ }
148
+
149
+ /**
150
+ * Strict semver `>` with a string-fallback escape hatch.
151
+ *
152
+ * `semver.gt` throws on invalid versions; we wrap with `semver.valid`
153
+ * to avoid the throw. When EITHER side is not valid semver, fall
154
+ * back to a plain string inequality — any difference is treated as
155
+ * the library being "ahead," which classifies the skill as `stale`.
156
+ * This is the right safety direction: false-stale tells the user to
157
+ * run `update`, which is harmless; false-current would mask drift.
158
+ *
159
+ * @param {string | null} libraryVersion
160
+ * @param {string | null} syncedVersion
161
+ * @returns {boolean}
162
+ */
163
+ function libraryVersionIsNewer(libraryVersion, syncedVersion) {
164
+ if (typeof libraryVersion !== "string" || libraryVersion === "") return false;
165
+ if (typeof syncedVersion !== "string" || syncedVersion === "") {
166
+ // No baseline version → we can't decide ordering. Caller treats
167
+ // this as "version comparison inconclusive" and continues to
168
+ // the SHA check.
169
+ return false;
170
+ }
171
+ if (semver.valid(libraryVersion) && semver.valid(syncedVersion)) {
172
+ return semver.gt(libraryVersion, syncedVersion);
173
+ }
174
+ return libraryVersion !== syncedVersion;
175
+ }
@@ -227,10 +227,25 @@ export function describePlacementTarget(target) {
227
227
  }
228
228
  }
229
229
 
230
+ /**
231
+ * Resolve the parent directory for a `PlacementTarget` — the dir
232
+ * that contains all skill subdirectories for this target. Exposed so
233
+ * read-side consumers (`placement-walk.mjs` for `list`'s drift
234
+ * detection in #1555) can enumerate skill placements without
235
+ * duplicating the target→root mapping that already lives here.
236
+ *
237
+ * @param {PlacementTarget} target
238
+ * @returns {string}
239
+ */
240
+ export function resolvePlacementRoot(target) {
241
+ return placementRootFn(target)();
242
+ }
243
+
230
244
  /**
231
245
  * Map a `PlacementTarget` to its parent root resolver. Used by
232
246
  * `cleanupOrphans` to know which directories to scan for `.tmp`/`.old`
233
- * siblings.
247
+ * siblings, and by `resolvePlacementRoot` above for read-side
248
+ * consumers.
234
249
  *
235
250
  * @param {PlacementTarget} target
236
251
  * @returns {() => string}
@@ -0,0 +1,366 @@
1
+ /**
2
+ * npm-registry self-staleness checker (#1554, part of epic #1552).
3
+ *
4
+ * The CLI ships with no self-staleness detection today. This module adds
5
+ * a daily-cached check against `https://registry.npmjs.org/skillrepo/latest`
6
+ * so a stale `npx skillrepo`/global install gets nudged toward an upgrade
7
+ * after the primary command completes. The nudge is a HINT, not a feature:
8
+ * every failure mode (network error, non-2xx, parse failure, timeout,
9
+ * read-only FS) returns null and never throws.
10
+ *
11
+ * Design contract
12
+ * ---------------
13
+ *
14
+ * 1. **Fire-and-forget at the call site.** `bin/skillrepo.mjs` invokes
15
+ * this module after `await COMMANDS[command].run(rest)` returns and
16
+ * races the promise with `setTimeout(0)`. On a cache HIT the checker
17
+ * resolves before the timer fires and the nudge is emitted before
18
+ * the binary exits. On a cache MISS the timer wins and the in-flight
19
+ * fetch is left running in the background — it has its own 2s
20
+ * timeout, writes the cache on completion, and lets the NEXT
21
+ * invocation use it. The user's command output appears before the
22
+ * process exits; the residual fetch happens in parallel.
23
+ *
24
+ * 2. **Silent failure mode.** No "couldn't reach npm." Users opening a
25
+ * Network tab to debug a CLI command don't want a second unrelated
26
+ * request explained. If the check can't complete, the user sees no
27
+ * artifact at all.
28
+ *
29
+ * 3. **24h positive cache, 1h negative cache.** A successful read pins
30
+ * the answer for 24 hours — npm publishes are rare enough that more
31
+ * frequent polling is just rude. A failed read pins for 1 hour so a
32
+ * transient outage doesn't blind the CLI for a full day; AND
33
+ * `checkedAt` still updates on failure so we don't hammer a broken
34
+ * endpoint every command in the meantime.
35
+ *
36
+ * 4. **Kill switches.** `SKILLREPO_NO_UPDATE_CHECK=1` is the explicit
37
+ * opt-out. `process.env.CI === "true"` auto-disables (GitHub Actions,
38
+ * GitLab CI, CircleCI, and most other vendors set this). Both are
39
+ * checked before any network or cache I/O.
40
+ *
41
+ * 5. **JSON suppression at the binary level.** The checker itself does
42
+ * NOT inspect `argv` for `--json`; that's the dispatcher's job.
43
+ * When a parent command was invoked with `--json`, the dispatcher
44
+ * skips calling `checkForCliUpdate` entirely so we never inject text
45
+ * into a structured stream — not even on stderr, because some tools
46
+ * (notably ts-node + Vitest hooks) read both streams.
47
+ *
48
+ * 6. **Live version comparison on the read path.** The cache records
49
+ * `currentCliVersion` so a stale cache from a previous binary
50
+ * doesn't keep nudging the user after they upgrade. The render-time
51
+ * comparison is `latestPublishedVersion` vs. the LIVE version
52
+ * (`io.currentVersion`), not the cached one — the cached value is
53
+ * only used to validate freshness.
54
+ *
55
+ * 7. **Windows compat.** All paths use `path.join`; never separator
56
+ * concatenation. The cache directory is created with
57
+ * `mkdir { recursive: true }` so Windows drive-letter roots work
58
+ * identically to POSIX `~/.claude/...`.
59
+ */
60
+
61
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
62
+ import { dirname } from "node:path";
63
+
64
+ import semver from "semver";
65
+
66
+ import { globalNpmVersionCheckPath } from "./paths.mjs";
67
+
68
+ /** Endpoint that resolves npm's `dist-tags.latest` for `skillrepo`. */
69
+ export const NPM_LATEST_URL = "https://registry.npmjs.org/skillrepo/latest";
70
+
71
+ /** Hard cap on the fetch. Network calls slower than this are treated as a no-result. */
72
+ export const FETCH_TIMEOUT_MS = 2000;
73
+
74
+ /** Cache TTL on a successful fetch (24 hours). */
75
+ export const POSITIVE_TTL_MS = 24 * 60 * 60 * 1000;
76
+
77
+ /** Cache TTL on a failed fetch (1 hour) — fast retry without hammering. */
78
+ export const NEGATIVE_TTL_MS = 60 * 60 * 1000;
79
+
80
+ /**
81
+ * Cache file location — delegated to `paths.mjs` so every CLI-written
82
+ * path lives in a single registry. Tests inject via `sandbox-home.mjs`
83
+ * (which sets both HOME and USERPROFILE so `homedir()` reads sandbox
84
+ * locations on POSIX AND Windows), not by mocking this function.
85
+ *
86
+ * Kept as a re-export rather than a direct import in callers so the
87
+ * test boundary stays consistent with the module's other public API.
88
+ */
89
+ export function cachePath() {
90
+ return globalNpmVersionCheckPath();
91
+ }
92
+
93
+ /** Current cache schema. Bump only when fields change incompatibly. */
94
+ export const CACHE_SCHEMA_VERSION = 1;
95
+
96
+ /**
97
+ * Try to read and parse the cache file. Returns null on any failure
98
+ * (missing file, malformed JSON, wrong schemaVersion, missing required
99
+ * field). The caller treats null as "cache miss" and proceeds to fetch.
100
+ *
101
+ * This intentionally does NOT differentiate between "no cache yet" and
102
+ * "cache exists but is unusable" — both lead to the same recovery path.
103
+ */
104
+ function readCache() {
105
+ let raw;
106
+ try {
107
+ raw = readFileSync(cachePath(), "utf-8");
108
+ } catch {
109
+ return null;
110
+ }
111
+
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ } catch {
116
+ return null;
117
+ }
118
+
119
+ if (!parsed || typeof parsed !== "object") return null;
120
+ if (parsed.schemaVersion !== CACHE_SCHEMA_VERSION) return null;
121
+ if (typeof parsed.checkedAt !== "string") return null;
122
+ if (typeof parsed.currentCliVersion !== "string") return null;
123
+ if (typeof parsed.fetchOk !== "boolean") return null;
124
+ // latestPublishedVersion may be null when fetchOk: false; both shapes
125
+ // are valid cache entries.
126
+ if (
127
+ parsed.latestPublishedVersion !== null &&
128
+ typeof parsed.latestPublishedVersion !== "string"
129
+ ) {
130
+ return null;
131
+ }
132
+
133
+ return parsed;
134
+ }
135
+
136
+ /**
137
+ * Persist the cache. Best-effort: read-only filesystems (Docker layers,
138
+ * some CI snapshots) cause writeFileSync to throw EROFS/EACCES, which
139
+ * we swallow. The user still sees the nudge on this run (if applicable);
140
+ * they just won't get a cached answer next time.
141
+ */
142
+ function writeCache(entry, io) {
143
+ const target = cachePath();
144
+ const dir = dirname(target);
145
+ const writeFn = io.writeFile;
146
+ const mkdirFn = io.mkdir;
147
+ const existsFn = io.exists;
148
+
149
+ try {
150
+ if (!existsFn(dir)) {
151
+ mkdirFn(dir, { recursive: true });
152
+ }
153
+ writeFn(target, JSON.stringify(entry, null, 2) + "\n", "utf-8");
154
+ } catch {
155
+ // Read-only FS or permission denied — silently give up. The user's
156
+ // primary command output already showed; we will not interrupt it
157
+ // with a cache-write warning. Next invocation will re-fetch.
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Decide whether a cache entry is still fresh given the CURRENT live
163
+ * version. Two gates must pass:
164
+ *
165
+ * 1. The cache must have been written by the same CLI version that's
166
+ * running now. If the user upgraded between calls, the cached
167
+ * `latestPublishedVersion` may now be the version they're running
168
+ * — discarding the entry forces a re-check so we don't nudge the
169
+ * user to upgrade to the same version they just installed.
170
+ * 2. `checkedAt` must be within the appropriate TTL — positive for
171
+ * successful fetches, negative for failed ones.
172
+ *
173
+ * Returns true when the cache is reusable; false means re-fetch.
174
+ */
175
+ function isCacheFresh(entry, currentVersion, now) {
176
+ if (entry.currentCliVersion !== currentVersion) return false;
177
+ const checkedAtMs = Date.parse(entry.checkedAt);
178
+ if (!Number.isFinite(checkedAtMs)) return false;
179
+ const ageMs = now - checkedAtMs;
180
+ const ttl = entry.fetchOk ? POSITIVE_TTL_MS : NEGATIVE_TTL_MS;
181
+ return ageMs < ttl && ageMs >= 0;
182
+ }
183
+
184
+ /**
185
+ * Fetch `/skillrepo/latest` from the npm registry with a 2-second hard
186
+ * timeout. Returns the parsed `version` string on success, null on any
187
+ * failure (network, non-2xx, parse, abort).
188
+ *
189
+ * The `/latest` shortcut returns the version manifest for `dist-tags.latest`
190
+ * — about 1.5 KB on the wire. The User-Agent identifies the CLI so npm's
191
+ * abuse tooling can rate-limit us specifically if we ever misbehave.
192
+ *
193
+ * Accept header note: the issue spec called for
194
+ * `application/vnd.npm.install-v1+json`, but the npm registry returns
195
+ * HTTP 406 for that media type on the `/latest` endpoint — the install
196
+ * manifest type is only valid against the FULL packument
197
+ * (`/skillrepo`), which is ~10x larger. `application/json` is the
198
+ * documented accept for the `/latest` shortcut and is what `npm view`
199
+ * itself uses under the hood. Verified via curl on 2026-05-19. The
200
+ * issue body should be updated to reflect this; tracked in the PR.
201
+ */
202
+ async function fetchLatestVersion(currentVersion, fetchImpl) {
203
+ const controller = new AbortController();
204
+ const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
205
+ try {
206
+ const res = await fetchImpl(NPM_LATEST_URL, {
207
+ method: "GET",
208
+ headers: {
209
+ Accept: "application/json",
210
+ "User-Agent": `skillrepo-cli/${currentVersion} (+https://skillrepo.dev)`,
211
+ },
212
+ signal: controller.signal,
213
+ });
214
+ if (!res || !res.ok) return null;
215
+ let body;
216
+ try {
217
+ body = await res.json();
218
+ } catch {
219
+ return null;
220
+ }
221
+ if (!body || typeof body !== "object") return null;
222
+ if (typeof body.version !== "string" || body.version.length === 0) {
223
+ return null;
224
+ }
225
+ // Validate semver shape upfront — a malformed version from a
226
+ // compromised mirror should not surface to the user as a nudge.
227
+ if (!semver.valid(body.version)) return null;
228
+ return body.version;
229
+ } catch {
230
+ return null;
231
+ } finally {
232
+ clearTimeout(timeoutId);
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Style helpers — duplicated from bin/skillrepo.mjs to keep this module
238
+ * standalone (a CLI nudge module that pulls in the dispatcher's helpers
239
+ * would create a cycle). `bold`/`dim` mirror the existing http.mjs:248
240
+ * pattern.
241
+ */
242
+ function dim(s) {
243
+ return process.stderr.isTTY && !process.env.NO_COLOR ? `\x1b[2m${s}\x1b[0m` : s;
244
+ }
245
+ function bold(s) {
246
+ return process.stderr.isTTY && !process.env.NO_COLOR ? `\x1b[1m${s}\x1b[0m` : s;
247
+ }
248
+
249
+ /**
250
+ * Render the nudge to stderr. Two lines, indented to match the
251
+ * dispatcher's error/help shape. Never throws — if stderr is closed
252
+ * (e.g. parent pipe shut down) the write call's exception is swallowed
253
+ * at the call site so a failing nudge can't break the user's flow.
254
+ */
255
+ function emitNudge(currentVersion, latestVersion, stderrWrite) {
256
+ const line1 = dim(
257
+ ` A newer ${bold("skillrepo")} is available: ${currentVersion} → ${latestVersion}`,
258
+ );
259
+ const line2 = dim(` Upgrade: ${bold("npm install -g skillrepo@latest")}`);
260
+ try {
261
+ stderrWrite(`\n${line1}\n${line2}\n`);
262
+ } catch {
263
+ // Closed stream. Nothing to do.
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Are the kill switches set? Centralized so the bin layer and any
269
+ * test fixtures share the same view of "should we even bother."
270
+ */
271
+ export function updateCheckDisabledByEnv() {
272
+ const explicit = process.env.SKILLREPO_NO_UPDATE_CHECK;
273
+ if (explicit && explicit !== "0" && explicit.toLowerCase() !== "false") {
274
+ return true;
275
+ }
276
+ if (process.env.CI === "true") return true;
277
+ return false;
278
+ }
279
+
280
+ /**
281
+ * Main entry point.
282
+ *
283
+ * @param {object} params
284
+ * @param {string} params.currentVersion - The CLI's own version
285
+ * (`getCliVersion()` from the dispatcher).
286
+ * @param {object} [params.io] - I/O dependency injection. Production
287
+ * callers pass nothing and get Node built-ins; tests inject mocks.
288
+ * Shape:
289
+ * - `fetch`: fetch impl (default `globalThis.fetch`)
290
+ * - `now`: () => number, ms since epoch (default `Date.now`)
291
+ * - `writeFile`/`mkdir`/`exists`: fs primitives for the cache write
292
+ * path. Reads use real `fs` directly (no injection seam) — tests
293
+ * redirect the read path via `sandbox-home.mjs` instead.
294
+ * (defaults from `node:fs`)
295
+ * - `stderrWrite`: (s) => void (default `process.stderr.write`)
296
+ *
297
+ * @returns {Promise<{ nudged: boolean, reason?: string }>} Resolves
298
+ * with a tiny status object — tests assert on it directly. Production
299
+ * callers ignore the return value.
300
+ */
301
+ export async function checkForCliUpdate({ currentVersion, io = {} } = {}) {
302
+ if (updateCheckDisabledByEnv()) {
303
+ return { nudged: false, reason: "disabled" };
304
+ }
305
+ if (typeof currentVersion !== "string" || !semver.valid(currentVersion)) {
306
+ // Defensive: a malformed live version is a programming error
307
+ // upstream. Don't try to compare against random strings.
308
+ return { nudged: false, reason: "invalid-current-version" };
309
+ }
310
+
311
+ const resolvedIo = {
312
+ fetch: io.fetch ?? globalThis.fetch,
313
+ now: io.now ?? Date.now,
314
+ writeFile: io.writeFile ?? writeFileSync,
315
+ mkdir: io.mkdir ?? mkdirSync,
316
+ exists: io.exists ?? existsSync,
317
+ stderrWrite: io.stderrWrite ?? ((s) => process.stderr.write(s)),
318
+ };
319
+
320
+ if (typeof resolvedIo.fetch !== "function") {
321
+ // Node < 18 has no global fetch. The CLI's minimum is 18 (README),
322
+ // so this should never fire — but a missing fetch isn't a user-
323
+ // visible failure either.
324
+ return { nudged: false, reason: "no-fetch" };
325
+ }
326
+
327
+ const now = resolvedIo.now();
328
+ const cache = readCache();
329
+ let latestVersion = null;
330
+
331
+ if (cache && isCacheFresh(cache, currentVersion, now)) {
332
+ // Fresh cache. Reuse its latestPublishedVersion (which may be null
333
+ // for a negative cache hit — in which case we don't nudge).
334
+ latestVersion = cache.latestPublishedVersion;
335
+ } else {
336
+ // Cache miss / stale / version mismatch. Try the network. The
337
+ // fetch has its own 2-second AbortController; the dispatcher's
338
+ // `Promise.race` with `setTimeout(0)` means we return synchronously
339
+ // either way — but the fetch keeps running in the background so
340
+ // the cache lands and the NEXT invocation can use it.
341
+ latestVersion = await fetchLatestVersion(currentVersion, resolvedIo.fetch);
342
+ const entry = {
343
+ schemaVersion: CACHE_SCHEMA_VERSION,
344
+ checkedAt: new Date(now).toISOString(),
345
+ currentCliVersion: currentVersion,
346
+ latestPublishedVersion: latestVersion,
347
+ fetchOk: latestVersion !== null,
348
+ };
349
+ writeCache(entry, resolvedIo);
350
+ }
351
+
352
+ if (!latestVersion) {
353
+ return { nudged: false, reason: "no-result" };
354
+ }
355
+
356
+ // Compare against the LIVE current version, not the cached
357
+ // currentCliVersion. The cached value was only used to gate freshness;
358
+ // the user is running THIS binary now, and that's what they need a
359
+ // nudge relative to.
360
+ if (!semver.gt(latestVersion, currentVersion)) {
361
+ return { nudged: false, reason: "up-to-date" };
362
+ }
363
+
364
+ emitNudge(currentVersion, latestVersion, resolvedIo.stderrWrite);
365
+ return { nudged: true };
366
+ }
package/src/lib/paths.mjs CHANGED
@@ -28,6 +28,16 @@ export const vscodeMcpJson = () => join(cwd(), ".vscode", "mcp.json");
28
28
  export const globalConfigPath = () => join(homedir(), ".claude", "skillrepo", "config.json");
29
29
  export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo", ".last-sync");
30
30
 
31
+ /**
32
+ * npm-registry self-staleness check cache (#1554). Separate file from
33
+ * `.last-sync` — different concern (CLI's own version vs. library
34
+ * content state) and a schema bump in one must not gate the other.
35
+ * Lives under the same `~/.claude/skillrepo/` directory so cleanup
36
+ * stays simple (rm -rf the parent removes both).
37
+ */
38
+ export const globalNpmVersionCheckPath = () =>
39
+ join(homedir(), ".claude", "skillrepo", ".npm-version-check");
40
+
31
41
  // ── Skill placement targets ────────────────────────────────────────────
32
42
  //
33
43
  // Per-vendor placement decisions live in `agent-registry.mjs`. This