skillrepo 4.4.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,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
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Walk on-disk skill placements and compute SHAs (#1555).
3
+ *
4
+ * Read-side counterpart of `file-write.mjs`: given a set of detected
5
+ * vendor keys, enumerate the skill directories each vendor's
6
+ * placement target contains and compute the same SHA pair that
7
+ * `runSync` persists in `.last-sync` v2. The output feeds `drift.mjs`
8
+ * for per-skill state classification.
9
+ *
10
+ * Design notes
11
+ * ------------
12
+ *
13
+ * - **One walk per unique target.** Cohort vendors (cursor, windsurf,
14
+ * gemini, codex, cline, copilot) all share `agentsProject`. Walking
15
+ * the parent dir N times — once per detected cohort vendor — would
16
+ * read every file N times. Instead, we walk each unique target
17
+ * ONCE and expand the result to one entry per (vendor, scope,
18
+ * skill) combination.
19
+ *
20
+ * - **POSIX path normalization for hashing.** `filesSha256` is the
21
+ * SHA-256 of a sorted `<path>|<sha>` projection. The path component
22
+ * MUST be forward-slash-separated to match what the server-side
23
+ * hashing produces (and what `crypto-shas.mjs` documents). Disk
24
+ * paths on Windows have backslashes; we normalize at the
25
+ * computation boundary, not in the data shape.
26
+ *
27
+ * - **Project scope only in v1.** The spec defers `--global` to a
28
+ * future flag. This module walks each detected vendor's
29
+ * `projectTarget` exclusively. When the global flag lands, add a
30
+ * `scopes` parameter that defaults to `["project"]`.
31
+ *
32
+ * - **Errors degrade gracefully.** A skill directory we can't read
33
+ * (permission error, broken symlink, partial file) produces an
34
+ * entry with `present: true` and `skillMdSha256: null` /
35
+ * `filesSha256: null`. The drift classifier treats null SHAs as
36
+ * `edited` — conservative, surfaces the issue to the user without
37
+ * crashing the command.
38
+ *
39
+ * - **`.tmp/` and `.old/` are skipped.** These are crashed-write
40
+ * artifacts handled by `cleanupOrphans` in `file-write.mjs`. They
41
+ * are not skill directories.
42
+ */
43
+
44
+ import { existsSync, readdirSync, readFileSync, lstatSync } from "node:fs";
45
+ import { join, relative, sep } from "node:path";
46
+
47
+ import { computeSkillShas } from "./crypto-shas.mjs";
48
+ import { resolvePlacementRoot } from "./file-write.mjs";
49
+ import { getAgentByKey } from "./agent-registry.mjs";
50
+
51
+ /**
52
+ * @typedef {Object} LocalPlacement
53
+ * @property {string} vendorKey - Canonical agent key from agent-registry.
54
+ * @property {"project"} scope - Always "project" in v1.
55
+ * @property {string} target - PlacementTarget enum value.
56
+ * @property {string} skillName - Directory name on disk.
57
+ * @property {boolean} present - Always true for entries returned here.
58
+ * @property {string | null} skillMdSha256
59
+ * @property {string | null} filesSha256
60
+ */
61
+
62
+ /**
63
+ * Walk every detected vendor's project placement and return a Map
64
+ * keyed by `"<vendorKey>::project::<skillName>"` for O(1) lookup
65
+ * from `list.mjs`.
66
+ *
67
+ * Each unique placement TARGET is walked exactly once. Detected
68
+ * vendors that map to the same target share the walk's results.
69
+ *
70
+ * @param {string[]} detectedVendorKeys
71
+ * @returns {Map<string, LocalPlacement>}
72
+ */
73
+ export function walkDetectedPlacements(detectedVendorKeys) {
74
+ if (!Array.isArray(detectedVendorKeys) || detectedVendorKeys.length === 0) {
75
+ return new Map();
76
+ }
77
+
78
+ // Group detected vendors by their projectTarget. A vendor with a
79
+ // null projectTarget (no documented project-scope placement) is
80
+ // dropped from this map — there's nothing to walk.
81
+ /** @type {Map<string, string[]>} */
82
+ const targetToVendors = new Map();
83
+ for (const key of detectedVendorKeys) {
84
+ const entry = getAgentByKey(key);
85
+ if (!entry || !entry.projectTarget) continue;
86
+ const target = entry.projectTarget;
87
+ if (!targetToVendors.has(target)) targetToVendors.set(target, []);
88
+ targetToVendors.get(target).push(key);
89
+ }
90
+
91
+ /** @type {Map<string, LocalPlacement>} */
92
+ const result = new Map();
93
+ for (const [target, vendorKeys] of targetToVendors) {
94
+ const skillsAtTarget = walkPlacementTarget(target);
95
+ for (const skill of skillsAtTarget) {
96
+ for (const vendorKey of vendorKeys) {
97
+ const key = `${vendorKey}::project::${skill.skillName}`;
98
+ result.set(key, {
99
+ vendorKey,
100
+ scope: "project",
101
+ target,
102
+ skillName: skill.skillName,
103
+ present: true,
104
+ skillMdSha256: skill.skillMdSha256,
105
+ filesSha256: skill.filesSha256,
106
+ });
107
+ }
108
+ }
109
+ }
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * Walk a single placement target's PARENT directory and return one
115
+ * entry per skill subdirectory found. Exposed for tests and for
116
+ * callers that need a single-target view.
117
+ *
118
+ * Output shape: `{ skillName, skillMdSha256, filesSha256 }[]`. The
119
+ * vendor/scope expansion happens in `walkDetectedPlacements`.
120
+ *
121
+ * @param {string} target - PlacementTarget enum value.
122
+ * @returns {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]}
123
+ */
124
+ export function walkPlacementTarget(target) {
125
+ let parentDir;
126
+ try {
127
+ parentDir = resolvePlacementRoot(target);
128
+ } catch {
129
+ // Unknown target — surface as no placements rather than throwing.
130
+ // Defensive: detect-agents + agent-registry should never produce
131
+ // an unknown target, but if they ever do we don't want `list` to
132
+ // crash.
133
+ return [];
134
+ }
135
+
136
+ if (!existsSync(parentDir)) return [];
137
+
138
+ /** @type {string[]} */
139
+ let entries;
140
+ try {
141
+ entries = readdirSync(parentDir);
142
+ } catch {
143
+ return [];
144
+ }
145
+
146
+ /** @type {{ skillName: string, skillMdSha256: string | null, filesSha256: string | null }[]} */
147
+ const out = [];
148
+ for (const entry of entries) {
149
+ // Skip crashed-write artifacts handled by cleanupOrphans.
150
+ if (entry.endsWith(".tmp") || entry.endsWith(".old")) continue;
151
+
152
+ const skillDir = join(parentDir, entry);
153
+ let stat;
154
+ try {
155
+ // lstatSync (not statSync) so a symlink-to-directory at the
156
+ // placements root is rejected here, not recursed into. A
157
+ // circular symlink would otherwise crash `list` with a stack
158
+ // overflow from infinite recursion in walkSkillDirRecursive.
159
+ stat = lstatSync(skillDir);
160
+ } catch {
161
+ continue;
162
+ }
163
+ if (stat.isSymbolicLink()) continue;
164
+ if (!stat.isDirectory()) continue;
165
+
166
+ const shas = computeShasFromDir(skillDir);
167
+ out.push({
168
+ skillName: entry,
169
+ skillMdSha256: shas.skillMdSha256,
170
+ filesSha256: shas.filesSha256,
171
+ });
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Read every file in `skillDir` and compute the same SHA pair that
178
+ * `runSync` persists. On ANY file-read failure (permission, broken
179
+ * symlink, truncated file), returns null SHAs — the caller treats
180
+ * this as `edited` (conservative). Returning null is intentional:
181
+ * we cannot honestly claim `current` on data we couldn't read.
182
+ *
183
+ * @param {string} skillDir
184
+ * @returns {{ skillMdSha256: string | null, filesSha256: string | null }}
185
+ */
186
+ function computeShasFromDir(skillDir) {
187
+ /** @type {{ path: string, content: string }[]} */
188
+ const files = [];
189
+ let readError = false;
190
+ walkSkillDirRecursive(skillDir, skillDir, files, (err) => {
191
+ if (err) readError = true;
192
+ });
193
+
194
+ if (readError || files.length === 0) {
195
+ return { skillMdSha256: null, filesSha256: null };
196
+ }
197
+
198
+ try {
199
+ return computeSkillShas(files);
200
+ } catch {
201
+ // computeSkillShas only throws on bad input shape. We constructed
202
+ // the input ourselves, so a throw indicates a programming error
203
+ // we can't recover from — but treat as null SHAs rather than
204
+ // crashing the user's `list` command.
205
+ return { skillMdSha256: null, filesSha256: null };
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Recursive directory walk that populates a flat `{path, content}[]`
211
+ * array, with paths POSIX-normalized relative to `skillDir`. Errors
212
+ * on individual entries are reported through the `onError` callback
213
+ * (the caller flips a `readError` boolean to discard the partial
214
+ * accum and return null SHAs) but the walk CONTINUES with the
215
+ * remaining siblings.
216
+ *
217
+ * The continue-not-return choice matters: a `readdirSync` listing of
218
+ * [unreadable, readable1, readable2] would previously bail on entry
219
+ * 0 and never see entries 1 and 2. Setting `readError` once and
220
+ * still walking the rest gives the caller correct behavior (null
221
+ * SHAs from the gate at `computeShasFromDir`) and produces a
222
+ * deterministic outcome that doesn't depend on `readdirSync`
223
+ * ordering. The accum is partially populated when `readError` is
224
+ * true but the gate in `computeShasFromDir` discards it.
225
+ *
226
+ * Depth is not capped — agentskills.io spec mandates files at most
227
+ * one level deep from SKILL.md (`scripts/foo.sh`, `references/bar.md`)
228
+ * but the CLI doesn't reject a deeper tree. If the user has hand-
229
+ * authored a deeper structure, we hash it faithfully.
230
+ */
231
+ function walkSkillDirRecursive(rootDir, currentDir, accum, onError) {
232
+ let entries;
233
+ try {
234
+ entries = readdirSync(currentDir);
235
+ } catch (err) {
236
+ // Can't list this directory at all — flag the error and bail
237
+ // out of this level (no entries to walk). The outer caller
238
+ // continues with whatever siblings the parent dir contains.
239
+ onError(err);
240
+ return;
241
+ }
242
+
243
+ for (const entry of entries) {
244
+ const fullPath = join(currentDir, entry);
245
+ let stat;
246
+ try {
247
+ // lstatSync (not statSync) is load-bearing here. statSync
248
+ // follows symlinks, which means:
249
+ // 1. A symlink-to-directory passes isDirectory() and
250
+ // walkSkillDirRecursive recurses into it. A circular
251
+ // symlink (e.g. `references/loop -> .`) infinite-loops
252
+ // and crashes the process with a stack overflow.
253
+ // 2. A symlink-to-file passes isFile() and readFileSync
254
+ // reads the target — potentially OUTSIDE the skill dir.
255
+ // Not a privilege escalation (we only read what the user
256
+ // can read), but a principle-of-least-surprise violation.
257
+ // The agentskills.io spec does not define symlinks as valid
258
+ // skill content. Skip them.
259
+ stat = lstatSync(fullPath);
260
+ } catch (err) {
261
+ onError(err);
262
+ continue;
263
+ }
264
+ if (stat.isSymbolicLink()) continue;
265
+ if (stat.isDirectory()) {
266
+ walkSkillDirRecursive(rootDir, fullPath, accum, onError);
267
+ continue;
268
+ }
269
+ if (!stat.isFile()) continue; // skip sockets / pipes / etc.
270
+
271
+ let content;
272
+ try {
273
+ content = readFileSync(fullPath, "utf8");
274
+ } catch (err) {
275
+ onError(err);
276
+ continue;
277
+ }
278
+ // POSIX-normalize the path so SHA computation matches what the
279
+ // server produces. On POSIX, `sep` is "/" and this is a no-op;
280
+ // on Windows, `sep` is "\\" and we convert.
281
+ const posixRelPath =
282
+ sep === "/" ? relative(rootDir, fullPath) : relative(rootDir, fullPath).split(sep).join("/");
283
+ accum.push({ path: posixRelPath, content });
284
+ }
285
+ }