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,131 @@
1
+ /**
2
+ * Skill-content SHA helpers (#1553 — `.last-sync` v2).
3
+ *
4
+ * Two flavors of SHA-256 digest computed over a skill's file payload:
5
+ *
6
+ * • `skillMdSha256` — SHA-256 over the SKILL.md content as UTF-8 bytes.
7
+ * The server intentionally returns an empty string for SKILL.md's
8
+ * `sha256` field (see `src/lib/api/skill-response.ts:224`), so
9
+ * clients must compute it locally. Used to detect user edits to
10
+ * the SKILL.md frontmatter or body between syncs.
11
+ *
12
+ * • `filesSha256` — SHA-256 over a deterministic projection of every
13
+ * file's path and content hash. The projection is:
14
+ *
15
+ * <path-1>|<sha-1>\n<path-2>|<sha-2>\n...<path-n>|<sha-n>
16
+ *
17
+ * where entries are sorted by `path` using byte-order comparison
18
+ * (NOT locale-aware), joined by `\n`, with NO trailing newline.
19
+ * Implementations that re-derive this projection MUST match this
20
+ * format byte-for-byte — a trailing newline would change the
21
+ * resulting digest and break every drift comparison. Used to
22
+ * detect edits to ANY file in the skill — references/, scripts/,
23
+ * assets/, plus SKILL.md — in a single constant-size value.
24
+ *
25
+ * Why two SHAs instead of one
26
+ * ---------------------------
27
+ * `filesSha256` is the "anything changed?" rollup. `skillMdSha256`
28
+ * isolates SKILL.md edits, which are by far the most common kind of
29
+ * user modification (the spec encourages keeping SKILL.md under 500
30
+ * lines and pushing detail into references/, so SKILL.md is the
31
+ * working face of a skill). Surfacing "edited" specifically against
32
+ * SKILL.md gives clearer messages and lets `list` and a future
33
+ * `status` distinguish "user tweaked the body" from "support file
34
+ * changed."
35
+ *
36
+ * Path-format requirements
37
+ * ------------------------
38
+ * Callers MUST pass paths in canonical POSIX form (`/` separators,
39
+ * relative to the skill root, no leading `./`). The CLI's sync path
40
+ * uses server-provided paths directly, which already meet this
41
+ * requirement. A future on-disk reader (#1555) is responsible for
42
+ * normalizing platform-native separators back to `/` before calling
43
+ * these helpers — otherwise the same skill would produce different
44
+ * `filesSha256` values on Windows vs. POSIX and every disk read would
45
+ * spuriously report `edited`.
46
+ *
47
+ * Byte-content requirements
48
+ * -------------------------
49
+ * `content` is hashed as UTF-8 bytes. The CLI does not support
50
+ * binary file payloads today (see `validateSkill` in `file-write.mjs`),
51
+ * so `content` is always a JS string and `Buffer.from(content, "utf8")`
52
+ * produces the same bytes written to disk by `writeFileSync(path,
53
+ * content, "utf-8")`. If binary support is added, this helper needs
54
+ * an `encoding` discriminant per file.
55
+ */
56
+
57
+ import { createHash } from "node:crypto";
58
+
59
+ /**
60
+ * @typedef {Object} SkillFileLike
61
+ * @property {string} path - Canonical POSIX path relative to skill root.
62
+ * @property {string} content - UTF-8 string content as written to disk.
63
+ */
64
+
65
+ /**
66
+ * @typedef {Object} SkillShas
67
+ * @property {string | null} skillMdSha256
68
+ * Hex-encoded SHA-256 of SKILL.md's content. `null` when no SKILL.md
69
+ * is present in `files` — that's a malformed skill, and callers
70
+ * should not persist a result with `skillMdSha256: null` to
71
+ * `.last-sync`. The helper still returns it (rather than throwing)
72
+ * so callers can branch — throwing here would couple this pure
73
+ * function to a policy decision that belongs at the call site.
74
+ * @property {string} filesSha256
75
+ * Hex-encoded SHA-256 over the sorted `path|sha` projection of
76
+ * every file. Never `null`: an empty `files` array hashes to the
77
+ * SHA-256 of the empty string, which is a well-defined value.
78
+ */
79
+
80
+ /**
81
+ * Compute both digests for a skill's file payload.
82
+ *
83
+ * Pure function — no I/O, no filesystem access, no clock reads. The
84
+ * same input always produces the same output, which is what lets the
85
+ * sync path and the (future) disk-walk path compare results
86
+ * meaningfully.
87
+ *
88
+ * SKILL.md identification: case-sensitive, root-level only. A file at
89
+ * `path: "skill.md"` (lowercase) or `path: "docs/SKILL.md"` (nested)
90
+ * is NOT treated as the canonical SKILL.md. The spec at agentskills.io
91
+ * mandates the exact filename `SKILL.md` at the skill root; matching
92
+ * the server's behavior here keeps `filesSha256` consistent with
93
+ * server-side hashing rules.
94
+ *
95
+ * @param {ReadonlyArray<SkillFileLike>} files
96
+ * @returns {SkillShas}
97
+ */
98
+ export function computeSkillShas(files) {
99
+ if (!Array.isArray(files)) {
100
+ throw new TypeError("computeSkillShas: files must be an array");
101
+ }
102
+
103
+ /** @type {{ path: string, sha: string }[]} */
104
+ const perFile = [];
105
+ /** @type {string | null} */
106
+ let skillMdSha256 = null;
107
+
108
+ for (const file of files) {
109
+ if (!file || typeof file.path !== "string" || typeof file.content !== "string") {
110
+ throw new TypeError(
111
+ "computeSkillShas: each file must have string `path` and string `content`",
112
+ );
113
+ }
114
+ const sha = createHash("sha256").update(file.content, "utf8").digest("hex");
115
+ perFile.push({ path: file.path, sha });
116
+ if (file.path === "SKILL.md") {
117
+ skillMdSha256 = sha;
118
+ }
119
+ }
120
+
121
+ // Byte-order sort (not locale-aware). The `localeCompare` API is
122
+ // explicitly avoided here — two callers in different locales would
123
+ // otherwise hash to different values for the same skill, defeating
124
+ // the whole point of the digest.
125
+ perFile.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
126
+
127
+ const projection = perFile.map((entry) => `${entry.path}|${entry.sha}`).join("\n");
128
+ const filesSha256 = createHash("sha256").update(projection, "utf8").digest("hex");
129
+
130
+ return { skillMdSha256, filesSha256 };
131
+ }
@@ -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}