skillrepo 4.15.0 → 4.17.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 CHANGED
@@ -167,9 +167,10 @@ skillrepo update [--global] [--agent <list>] [--json] [--silent]
167
167
 
168
168
  Pulls the latest state of your library from the server using a delta
169
169
  sync. Writes new and updated skills, removes skills that were removed
170
- from the library, and skips skills that are unchanged. Uses ETag
171
- caching so repeat runs return `304 Not Modified` when nothing has
172
- changed.
170
+ from the library, and skips skills that are unchanged. In a repo that
171
+ declares a skillset, `update` instead reconciles that declared set (a
172
+ full-set resolve, not a delta). Uses ETag caching so repeat runs return
173
+ `304 Not Modified` when nothing has changed.
173
174
 
174
175
  `--silent` suppresses normal output and writes a single `{}` line to
175
176
  stdout on success. Designed for SessionStart hooks that pipe stdout
@@ -246,48 +247,80 @@ One-shot fetch. Does NOT mutate your library or the server — just reads
246
247
  `GET /api/v1/skills/{owner}/{name}` and writes the skill to disk. Use
247
248
  this to preview or pin a specific skill without adding it to your library.
248
249
 
249
- ### `list` — show what's in your library and what needs syncing
250
+ ### `list` — show what's on disk and how it compares to the server
250
251
 
251
252
  ```sh
252
253
  skillrepo list [--json]
253
254
  ```
254
255
 
255
- Renders your library as a table with a per-row `Local` column showing
256
- on-disk drift state for each detected vendor:
256
+ `list` is disk-first: it walks the skills actually on disk, then
257
+ annotates each against your last sync (local integrity) and the server
258
+ (governance + freshness). This means an on-disk skill is never hidden —
259
+ a hand-authored directory, a skill the server has withdrawn, or one you
260
+ deleted all show up, alongside the ones that are healthy.
257
261
 
258
- - `OK` local copy matches the last sync.
259
- - `STALE` — library has a newer version than what's on disk.
260
- - `MISS` — library has the skill but no on-disk placement (or no sync
261
- history yet — run `skillrepo update` to establish a baseline).
262
- - `EDIT` — local files have been modified since the last sync (different
263
- SHA than what was persisted).
262
+ The `State` column shows one worst-wins signal per row, staying quiet
263
+ when a row is healthy:
264
264
 
265
- When multiple vendors are detected, the column shows a worst-state-wins
266
- rollup (`missing > edited > stale > current`). The `--json` output
267
- includes a `placements[]` array per item with per-vendor states for
268
- scripts that want the full breakdown.
269
-
270
- A footer reports library-level sync state:
271
-
272
- - `library in sync — local skills up to date` — everything is current.
273
- - `library in sync — but N skills show local drift` — library hasn't
274
- changed but some local placements need attention.
275
- - `library has changed since last sync` — registry has new content; run
265
+ - `OK` on disk, matches your last sync, and the server hasn't moved on.
266
+ - `EDITED` local files were modified since the last sync; `update`
267
+ would overwrite them.
268
+ - `BEHIND` the server has a newer version than what you synced; run
276
269
  `skillrepo update`.
277
- - `No sync history on this machine` fresh install or
278
- baseline-less state; run `skillrepo update`.
279
-
280
- Glyphs (`✓` / `⚠`) are used in TTY contexts; ASCII fallbacks (`OK` /
281
- `[!]` / `STALE` / `MISS` / `EDIT`) appear when stdout is not a TTY or
282
- `NO_COLOR` is set, so piped output stays clean.
283
-
284
- `--json` is a bare array of skill objects with the additional fields
285
- `state` (rollup) and `placements[]`. Existing scripts that consume
286
- the pre-#1555 `--json` shape keep working the additions are purely
287
- per-item, no top-level wrapper.
288
-
289
- Uses the same cached ETag as `update` for the library-level footer
290
- state.
270
+ - `SERVING` the server rolled a recalled version back to an earlier
271
+ one; you're being served the fallback.
272
+ - `WITHDRAWN` — on disk and once served to you, but the server no longer
273
+ serves it. The CLI can't tell a recall from an unpublish or a skillset
274
+ re-scope, so it doesn't guess it flags that it's no longer served
275
+ (remove it, or investigate).
276
+ - `MISSING` — expected (in your library, or your repo's skillset) but not
277
+ on disk.
278
+ - `FOREIGN` on disk in a managed directory, but not from your library.
279
+ - `NOT DELIVERED` a skillset member the CLI declined to write (edited
280
+ locally, replaced, incomplete, or invalid), with the reason.
281
+ - `NOT CHECKED` — the server couldn't be reached, so freshness is
282
+ unknown. `list` never reports a stale copy as current.
283
+
284
+ `list` speaks the dashboard's `LATEST` / `SERVING` vocabulary (green is
285
+ used only for a served head version), so healthy and recall-fallback
286
+ states read identically on the CLI and the web. Where the CLI can't verify
287
+ a governance cause — a skill that's simply no longer served — it says
288
+ `WITHDRAWN` rather than asserting `RECALLED`; `recalled` appears only in a
289
+ `SERVING` row's reason, where the server names the recalled version. Each attention row also carries a
290
+ plain-English reason — what changed, why, and what to do — listed in an
291
+ attention block beneath the table. Both `list` views render the same way
292
+ (see "Same view everywhere" below).
293
+
294
+ When multiple vendors are detected, the local integrity axis is a
295
+ worst-wins rollup (`missing > edited > present`).
296
+
297
+ **Same view everywhere.** A repo that declares a skillset shows its
298
+ delivered set in the identical table — same columns, same colours, same
299
+ attention block as the whole-library view — plus a compliance line for
300
+ the declared set. `list` issues a non-recording freshness probe for the
301
+ declared set (a full read that resolves and records nothing) so a
302
+ recalled-then-restored skill reads `BEHIND` even inside the sync throttle
303
+ window, and a recall fallback reads `SERVING` rather than a false green.
304
+
305
+ **Offline-capable.** `list` works with no network: the local axes are
306
+ computed from disk, and the server axis degrades to `NOT CHECKED` rather
307
+ than failing.
308
+
309
+ **Read-only.** `list` never records a delivery, writes sync state, or
310
+ consumes a warning `update` owes you.
311
+
312
+ The `State` chip is coloured to match the dashboard — green for the one
313
+ healthy state (`OK` / `LATEST`), amber for `SERVING`, red for `WITHDRAWN`
314
+ and `MISSING`. Glyphs and colour are used in TTY contexts; plain ASCII
315
+ tokens (`OK` / `EDITED` / `BEHIND` / …) with no colour appear when stdout
316
+ is not a TTY or `NO_COLOR` is set, so piped output stays clean.
317
+
318
+ `--json` is a bare array of skill objects (a top-level object with a
319
+ `skillset` block in a declared repo). Each row carries the full model —
320
+ `classification`, `local`, `governance`, `freshness`, the collapsed
321
+ `signal`, and a per-vendor `placements[]` array. The pre-#2806 `state`
322
+ (worst-wins drift rollup) field is kept for backward compatibility, so
323
+ existing scripts keep working.
291
324
 
292
325
  ### `search` — explore the registry
293
326
 
@@ -460,7 +493,7 @@ Auto-refresh hooks for Windsurf and Cline are not yet supported — those agents
460
493
 
461
494
  **On 304 (nothing changed) the hook is silent.** You only see output when your library actually syncs or a failure happens. No "Syncing…" noise on every session.
462
495
 
463
- **Throttled to skip redundant syncs (v4.9.0+).** Because the hook fires on *every* editor session, the hook-triggered `update` (`--session-hook` / `--silent`) syncs at most once per ~15 minutes: within that window it exits immediately with **no network call**. This bounds redundant per-session load. The tradeoff is that a brand-new session started shortly after a recent sync may be up to ~15 minutes behind a just-published change — and the same bound applies to local repair: if you delete or corrupt a synced skill's folder on disk, the hook restores it on its next non-throttled run, up to ~15 minutes later. A bare `skillrepo update` **you** run yourself is never throttled — it always syncs immediately (repairing any local damage) and resets the window. `skillrepo list` is likewise never throttled (it always queries the server live), though it doesn't reset the window.
496
+ **Throttled to skip redundant syncs (v4.9.0+).** Because the hook fires on *every* editor session, the hook-triggered `update` (`--session-hook` / `--silent`) syncs at most once per ~15 minutes: within that window it exits immediately with **no network call**. This bounds redundant per-session load. The tradeoff is that a brand-new session started shortly after a recent sync may be up to ~15 minutes behind a just-published change — and the same bound applies to local repair: if you delete or corrupt a synced skill's folder on disk, the hook restores it on its next non-throttled run, up to ~15 minutes later. A bare `skillrepo update` **you** run yourself is never throttled — it always syncs immediately (repairing any local damage) and resets the window. `skillrepo list` is likewise never throttled: it reads the server live (a non-recording request) when reachable and degrades to local-only when it isn't, and it never resets the window. A repo that declares a skillset throttles on its **own** per-repo clock, so an unrelated repo's bulk sync elsewhere on the machine can no longer starve its refresh (v4.16.0+).
464
497
 
465
498
  Flags:
466
499
 
package/bin/skillrepo.mjs CHANGED
@@ -54,7 +54,7 @@ const COMMANDS = {
54
54
  run: async (argv) => runInit(argv),
55
55
  },
56
56
  update: {
57
- description: "Sync your library against the registry (delta + tombstones)",
57
+ description: "Sync your library, or this repo's declared skillset, from the registry",
58
58
  usage: "skillrepo update [--global] [--agent <list>] [--json]",
59
59
  run: async (argv) => runUpdate(argv),
60
60
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "skillrepo",
3
- "version": "4.15.0",
4
- "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
3
+ "version": "4.17.0",
4
+ "description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library, synced across your dev environment",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "skillrepo": "./bin/skillrepo.mjs"
@@ -0,0 +1,285 @@
1
+ /**
2
+ * `skillrepo list` — data-gathering providers (#2806).
3
+ *
4
+ * Everything that turns the environment (detected vendors, the server,
5
+ * the last-sync baseline, the per-repo state) into the normalized inputs
6
+ * the disk-first engine (`lib/list-engine.mjs`) classifies. The command
7
+ * orchestrators in `list.mjs` compose these with the walk + the engine +
8
+ * the renderers (`list-render.mjs`).
9
+ *
10
+ * READ-ONLY to the letter: the library manifest peek (#1832) and the
11
+ * skillset freshness probe (`manifest=1&skillset=…`) both record NO
12
+ * delivery, and neither writes local state. The one server-touching
13
+ * failure mode that MUST NOT crash a status command — an unreachable /
14
+ * transient server — degrades the server axis to NOT CHECKED here, never
15
+ * a false "up to date".
16
+ */
17
+
18
+ import { getLibrary } from "../lib/http.mjs";
19
+ import { readLastSync } from "../lib/sync.mjs";
20
+ import { detectAgents } from "../lib/detect-agents.mjs";
21
+ import { getAgentByKey } from "../lib/agent-registry.mjs";
22
+ import { EXIT_NETWORK } from "../lib/errors.mjs";
23
+ import {
24
+ scanGlobalBoundary,
25
+ managedGlobalNamesFrom,
26
+ GLOBAL_SHADOWED_CATEGORY,
27
+ GLOBAL_LIBRARY_CATEGORY,
28
+ GLOBAL_FOREIGN_CATEGORY,
29
+ } from "../lib/global-boundary.mjs";
30
+ import {
31
+ scanForeignContent,
32
+ buildRepoCompliance,
33
+ } from "../lib/foreign-content.mjs";
34
+
35
+ // ── detection + baseline shaping ────────────────────────────────────────
36
+
37
+ /**
38
+ * Detect the vendors with footprint on this machine/project and
39
+ * pre-resolve the ones that contribute a project-scope placement. Shared
40
+ * by both views. Returns the detected entries (for the "Detected:" line)
41
+ * and the flat key list (for the walk + engine).
42
+ *
43
+ * @returns {{ detected: object[], detectedKeys: string[] }}
44
+ */
45
+ export function resolveDetected() {
46
+ const detected = detectAgents().filter((d) => d.detected);
47
+ const detectedVendorEntries = detected
48
+ .map((d) => getAgentByKey(d.key))
49
+ .filter((entry) => entry !== null && entry.projectTarget !== null);
50
+ return { detected, detectedKeys: detectedVendorEntries.map((e) => e.key) };
51
+ }
52
+
53
+ /**
54
+ * Build the baseline map keyed `"owner/name"` from a `.last-sync`-style
55
+ * skills map, dropping entries that fail the required-string shape
56
+ * check (a tampered or half-written entry). The whole-library baseline.
57
+ *
58
+ * @param {Record<string, unknown>} skillsMap
59
+ * @returns {Map<string, {version: string, skillMdSha256: string, filesSha256: string}>}
60
+ */
61
+ export function buildBaselineByKey(skillsMap) {
62
+ const out = new Map();
63
+ if (!skillsMap || typeof skillsMap !== "object") return out;
64
+ for (const [key, raw] of Object.entries(skillsMap)) {
65
+ const entry = validateBaselineShape(raw);
66
+ if (entry) out.set(key, entry);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Shape a skillset repo's `resolved` manifest into the engine's expected
73
+ * set + baseline. Every resolved member is expected (delivered or not);
74
+ * only `written: true` members with a valid SHA pair contribute a
75
+ * baseline (a served-but-unwritten member has no on-disk content of ours
76
+ * to diff against).
77
+ *
78
+ * @param {import("../lib/repo-sync-state.mjs").RepoResolvedEntry[]} resolved
79
+ * @returns {{ expected: object[], baselineByKey: Map<string, object> }}
80
+ */
81
+ export function buildSkillsetInputs(resolved) {
82
+ const expected = resolved.map((e) => ({
83
+ owner: e.owner,
84
+ name: e.name,
85
+ version: e.version ?? null,
86
+ written: e.written === true,
87
+ unwrittenReason: typeof e.unwrittenReason === "string" ? e.unwrittenReason : null,
88
+ }));
89
+ const baselineByKey = new Map();
90
+ for (const e of resolved) {
91
+ if (e && e.written === true) {
92
+ const b = validateBaselineShape({
93
+ version: e.version,
94
+ skillMdSha256: e.skillMdSha256,
95
+ filesSha256: e.filesSha256,
96
+ });
97
+ if (b) baselineByKey.set(`${e.owner}/${e.name}`, b);
98
+ }
99
+ }
100
+ return { expected, baselineByKey };
101
+ }
102
+
103
+ /**
104
+ * Reject a baseline entry missing required string fields — a tampered
105
+ * or partially-written entry returns null, treated as "no baseline".
106
+ */
107
+ function validateBaselineShape(entry) {
108
+ if (!entry || typeof entry !== "object") return null;
109
+ if (
110
+ typeof entry.version !== "string" ||
111
+ typeof entry.skillMdSha256 !== "string" ||
112
+ typeof entry.filesSha256 !== "string"
113
+ ) {
114
+ return null;
115
+ }
116
+ return entry;
117
+ }
118
+
119
+ // ── whole-library server truth ──────────────────────────────────────────
120
+
121
+ /**
122
+ * Fetch the non-recording whole-library manifest (#1832) and shape it
123
+ * into the engine's expected set + server truth + display metadata.
124
+ * Governance rides on every skill since #2793. No `If-None-Match` is
125
+ * sent — the undeclared request stays byte-identical to the pre-#2672
126
+ * CLI (D2 wire identity).
127
+ *
128
+ * Offline-capable: a network/unreachable/transient failure (EXIT_NETWORK
129
+ * covers a fetch throw, a timeout, a 5xx, and an exhausted 429) degrades
130
+ * to a disk-first offline view — server axis NOT CHECKED, expected set
131
+ * falling back to what we last synced. A bad key / bad URL (auth /
132
+ * validation / scope) is a real problem the user must fix, so those
133
+ * still throw.
134
+ *
135
+ * @param {object} args
136
+ * @param {ReturnType<typeof import("../lib/cli-config.mjs").resolveFlags>} args.flags
137
+ * @param {Map<string, object>} args.baselineByKey - The offline fallback expected set.
138
+ * @returns {Promise<{ expected: object[], serverTruth: object, metaByKey: Map<string, object>, offline: boolean }>}
139
+ */
140
+ export async function fetchLibraryData({ flags, baselineByKey }) {
141
+ const metaByKey = new Map();
142
+ try {
143
+ const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey, {
144
+ manifest: true,
145
+ });
146
+ const skills = Array.isArray(libraryResponse.skills) ? libraryResponse.skills : [];
147
+ const expected = skills.map((s) => ({ owner: s.owner, name: s.name, version: s.version ?? null }));
148
+ const byKey = new Map();
149
+ for (const s of skills) {
150
+ byKey.set(`${s.owner}/${s.name}`, {
151
+ servedVersion: s.version ?? null,
152
+ governance: s.governance ?? null,
153
+ });
154
+ metaByKey.set(`${s.owner}/${s.name}`, {
155
+ description: s.description ?? "",
156
+ updatedAt: s.updatedAt ?? null,
157
+ filesIncomplete: s.filesIncomplete ?? false,
158
+ });
159
+ }
160
+ return { expected, serverTruth: { checked: true, byKey }, metaByKey, offline: false };
161
+ } catch (err) {
162
+ if (!(err && err.exitCode === EXIT_NETWORK)) throw err;
163
+ const expected = [...baselineByKey.entries()].map(([key, b]) => {
164
+ const slash = key.indexOf("/");
165
+ return { owner: key.slice(0, slash), name: key.slice(slash + 1), version: b.version ?? null };
166
+ });
167
+ return {
168
+ expected,
169
+ serverTruth: { checked: false, byKey: null },
170
+ metaByKey,
171
+ offline: true,
172
+ };
173
+ }
174
+ }
175
+
176
+ // ── skillset server truth + governance ──────────────────────────────────
177
+
178
+ /**
179
+ * Fire the non-recording skillset freshness probe (an UNCONDITIONAL 200
180
+ * manifest read) and shape the result into the engine's `ServerTruth` plus
181
+ * the display metadata for the Updated / Description columns. NEVER throws
182
+ * — the probe is a best-effort annotation, so every failure mode collapses
183
+ * to `checked: false` (NOT CHECKED).
184
+ *
185
+ * Unconditional by design (#2807, resolving the Phase-1 boundary): the
186
+ * whole-library variant already issues a full 200 every time
187
+ * (`fetchLibraryData` sends no `If-None-Match`), and the two `list` views
188
+ * must read identically. A conditional `If-None-Match` → 304 would return
189
+ * no body, so a steady-state recall fallback would render a quiet green OK
190
+ * instead of `serving` (green for a non-latest served version — the exact
191
+ * invariant this epic exists to hold). #1847's 304-path DB-load concern is
192
+ * about the SessionStart sync THROTTLE (a hot, per-session path), not this
193
+ * cold, explicit `list` probe, so the conditional saved nothing that
194
+ * mattered and cost the governance signal. The read remains non-recording
195
+ * (records no delivery / heartbeat / sync-state) and `list` never persists
196
+ * its etag. See `docs/SKILL-STATE-MODEL.md` §7.
197
+ *
198
+ * @param {object} args
199
+ * @param {ReturnType<typeof import("../lib/cli-config.mjs").resolveFlags>} args.flags
200
+ * @param {string} args.skillsetRef
201
+ * @param {string} args.repoName
202
+ * @param {string[]} args.extra
203
+ * @returns {Promise<{ serverTruth: import("../lib/list-engine.mjs").ServerTruth, metaByKey: Map<string, object> }>}
204
+ */
205
+ export async function probeSkillsetFreshness({ flags, skillsetRef, repoName, extra }) {
206
+ const metaByKey = new Map();
207
+ try {
208
+ const resp = await getLibrary(flags.serverUrl, flags.apiKey, {
209
+ manifest: true,
210
+ skillset: skillsetRef,
211
+ repo: repoName,
212
+ extra,
213
+ });
214
+ const skills = Array.isArray(resp.skills) ? resp.skills : [];
215
+ const byKey = new Map();
216
+ for (const s of skills) {
217
+ byKey.set(`${s.owner}/${s.name}`, {
218
+ servedVersion: s.version ?? null,
219
+ governance: s.governance ?? null,
220
+ });
221
+ metaByKey.set(`${s.owner}/${s.name}`, {
222
+ description: s.description ?? "",
223
+ updatedAt: s.updatedAt ?? null,
224
+ filesIncomplete: s.filesIncomplete ?? false,
225
+ });
226
+ }
227
+ return { serverTruth: { checked: true, byKey }, metaByKey };
228
+ } catch {
229
+ // Best-effort to the letter: an offline machine, a bad key, an
230
+ // unresolvable skillset, or a server hiccup all leave the freshness
231
+ // axis NOT CHECKED (and Updated / Description blank) rather than
232
+ // failing a read-only status command.
233
+ return { serverTruth: { checked: false, byKey: null }, metaByKey };
234
+ }
235
+ }
236
+
237
+ /**
238
+ * Run the read-only governance scans for the skillset view ONCE and
239
+ * return both the #2665 compliance object and the raw global-boundary
240
+ * scan. Unchanged from pre-#2806: compliance replays the delivery-reason
241
+ * counts from `resolved` and re-scans foreign + global content fresh
242
+ * from disk (read-only, never committing seen-state).
243
+ *
244
+ * @param {object} args
245
+ * @param {string} args.skillsetRef
246
+ * @param {import("../lib/repo-sync-state.mjs").RepoResolvedEntry[]} args.resolved
247
+ * @param {string[]} args.detectedKeys
248
+ * @param {string} args.rootDir
249
+ */
250
+ export function evaluateSkillsetGovernance({ skillsetRef, resolved, detectedKeys, rootDir }) {
251
+ const managedSkills = {};
252
+ for (const e of resolved) {
253
+ if (e && e.owner && e.name) managedSkills[`${e.owner}/${e.name}`] = { version: e.version };
254
+ }
255
+
256
+ let foreignCount = 0;
257
+ let gbScan = null;
258
+ try {
259
+ foreignCount = scanForeignContent({
260
+ vendors: detectedKeys,
261
+ global: false,
262
+ managedSkills,
263
+ baseDir: rootDir,
264
+ }).foreignCount;
265
+ gbScan = scanGlobalBoundary({
266
+ vendors: detectedKeys,
267
+ memberNames: new Set(resolved.map((e) => e.name)),
268
+ managedGlobalNames: managedGlobalNamesFrom(readLastSync()?.skills),
269
+ baseDir: rootDir,
270
+ });
271
+ } catch {
272
+ // Best-effort: a scan failure must never crash the delivered-set view.
273
+ }
274
+
275
+ const compliance = buildRepoCompliance({
276
+ skillsetRef,
277
+ foreignCount,
278
+ shadowedCount: gbScan?.counts?.[GLOBAL_SHADOWED_CATEGORY] ?? 0,
279
+ globalBeyondCount:
280
+ (gbScan?.counts?.[GLOBAL_LIBRARY_CATEGORY] ?? 0) +
281
+ (gbScan?.counts?.[GLOBAL_FOREIGN_CATEGORY] ?? 0),
282
+ resolved,
283
+ });
284
+ return { compliance, gbScan };
285
+ }