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.
- package/README.md +57 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
|
@@ -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
|
+
}
|
package/src/lib/file-write.mjs
CHANGED
|
@@ -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}
|