skillshelf 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { run } from "./outdated.ts";
5
+ import { run, mapLimit } from "./outdated.ts";
6
6
  import type { Ctx } from "../types.ts";
7
7
 
8
8
  function makeCtx(libraryPath: string) {
@@ -53,3 +53,149 @@ describe("skl outdated — LINKED entries are reported, not probed (ADR-0004)",
53
53
  expect(report.rows.find((r) => r.name === "devskill")!.status).toBe("linked");
54
54
  });
55
55
  });
56
+
57
+ async function git(cwd: string, args: string[]): Promise<void> {
58
+ const p = Bun.spawn(["git", ...args], {
59
+ cwd,
60
+ env: {
61
+ ...process.env,
62
+ GIT_AUTHOR_NAME: "t",
63
+ GIT_AUTHOR_EMAIL: "t@t",
64
+ GIT_COMMITTER_NAME: "t",
65
+ GIT_COMMITTER_EMAIL: "t@t",
66
+ },
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ });
70
+ await p.exited;
71
+ }
72
+
73
+ async function gitOut(cwd: string, args: string[]): Promise<string> {
74
+ const p = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
75
+ const out = await new Response(p.stdout).text();
76
+ await p.exited;
77
+ return out.trim();
78
+ }
79
+
80
+ // Regression: an OWNED github/git entry whose upstream HEAD has advanced past the
81
+ // installed ref MUST surface as "stale". This is the online ref-compare path
82
+ // (classify step 7) that was dead code while checkEntry fed localHash:null — every
83
+ // online row collapsed to "current" and `outdated` could never flag an update
84
+ // (and the UI ↑ badge, keyed off status==="stale", never lit). Uses the git: channel
85
+ // over a real LOCAL repo so the "latest ref" probe is offline (`git ls-remote <path>`).
86
+ describe("skl outdated — online ref-compare flags a moved upstream as stale", () => {
87
+ let tmp: string;
88
+ let library: string;
89
+ let upstream: string;
90
+
91
+ beforeEach(async () => {
92
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-outdated-stale-")));
93
+ library = join(tmp, "library");
94
+ upstream = join(tmp, "upstream");
95
+ await mkdir(join(upstream, "skills", "foo"), { recursive: true });
96
+ await writeFile(
97
+ join(upstream, "skills", "foo", "SKILL.md"),
98
+ "---\nname: foo\ndescription: foo skill\n---\n\nFOO BODY\n",
99
+ );
100
+ await git(upstream, ["init", "-q"]);
101
+ await git(upstream, ["add", "-A"]);
102
+ await git(upstream, ["commit", "-q", "-m", "v1"]);
103
+ const ref1 = await gitOut(upstream, ["rev-parse", "HEAD"]);
104
+
105
+ // Library copy of foo (readable body → a real localHash) pinned to ref1.
106
+ await mkdir(join(library, "foo"), { recursive: true });
107
+ await writeFile(
108
+ join(library, "foo", "SKILL.md"),
109
+ "---\nname: foo\ndescription: foo skill\n---\n\nFOO BODY\n",
110
+ );
111
+ await writeFile(
112
+ join(library, "shelf.lock.json"),
113
+ JSON.stringify({
114
+ version: 1,
115
+ entries: {
116
+ foo: {
117
+ name: "foo",
118
+ source: `git:${upstream}#skills/foo`,
119
+ ref: ref1,
120
+ channel: "git",
121
+ installedAt: "2020-01-01T00:00:00.000Z",
122
+ localEdits: false,
123
+ },
124
+ },
125
+ }),
126
+ );
127
+
128
+ // Advance upstream HEAD past ref1 — the repo moved on since install.
129
+ await writeFile(join(upstream, "skills", "foo", "note.txt"), "changed\n");
130
+ await git(upstream, ["add", "-A"]);
131
+ await git(upstream, ["commit", "-q", "-m", "v2"]);
132
+ });
133
+ afterEach(async () => {
134
+ await rm(tmp, { recursive: true, force: true });
135
+ });
136
+
137
+ test("an owned entry whose upstream HEAD advanced is status 'stale' (exit 2)", async () => {
138
+ const { ctx, json } = makeCtx(library);
139
+ const code = await run(["--json"], ctx);
140
+
141
+ expect(code).toBe(2); // stale exists → non-zero so CI/agents can branch on it
142
+ const report = json[0] as {
143
+ stale: number;
144
+ rows: Array<{ name: string; status: string }>;
145
+ };
146
+ expect(report.stale).toBe(1);
147
+ expect(report.rows.find((r) => r.name === "foo")!.status).toBe("stale");
148
+ });
149
+ });
150
+
151
+ // The concurrency bound is the fix for the "unknown storm" — firing one network
152
+ // probe per skill all at once dropped 59/87 probes to transient failures. These
153
+ // pin the three invariants the storm fix relies on: peak in-flight <= limit, every
154
+ // item runs once, and output order matches input (rows must not scramble).
155
+ describe("mapLimit — bounded-concurrency probe pool", () => {
156
+ test("never runs more than `limit` tasks concurrently", async () => {
157
+ let inflight = 0;
158
+ let peak = 0;
159
+ const items = Array.from({ length: 20 }, (_, i) => i);
160
+ await mapLimit(items, 4, async () => {
161
+ inflight++;
162
+ peak = Math.max(peak, inflight);
163
+ await new Promise((r) => setTimeout(r, 5));
164
+ inflight--;
165
+ return null;
166
+ });
167
+ expect(peak).toBeLessThanOrEqual(4);
168
+ expect(peak).toBeGreaterThan(1); // genuinely parallel, not accidentally serialized
169
+ });
170
+
171
+ test("preserves input order regardless of completion order", async () => {
172
+ // Later items finish FIRST (descending delay); output must still be by index.
173
+ const out = await mapLimit([40, 30, 20, 10], 4, async (ms, i) => {
174
+ await new Promise((r) => setTimeout(r, ms));
175
+ return `${i}:${ms}`;
176
+ });
177
+ expect(out).toEqual(["0:40", "1:30", "2:20", "3:10"]);
178
+ });
179
+
180
+ test("runs every item exactly once", async () => {
181
+ const seen: number[] = [];
182
+ const items = Array.from({ length: 15 }, (_, i) => i);
183
+ const out = await mapLimit(items, 6, async (n) => {
184
+ seen.push(n);
185
+ return n * 2;
186
+ });
187
+ expect(out).toEqual(items.map((n) => n * 2));
188
+ expect([...seen].sort((a, b) => a - b)).toEqual(items);
189
+ });
190
+
191
+ test("limit exceeding the list length still resolves all; empty list is empty", async () => {
192
+ expect(await mapLimit([1, 2, 3], 100, async (n) => n + 1)).toEqual([2, 3, 4]);
193
+ expect(await mapLimit([], 6, async (n: number) => n)).toEqual([]);
194
+ });
195
+
196
+ test("limit <= 0 is clamped to 1 (no undefined holes), still processes all", async () => {
197
+ const out = await mapLimit([1, 2, 3], 0, async (n) => n * 10);
198
+ expect(out).toEqual([10, 20, 30]);
199
+ expect(out.every((v) => v !== undefined)).toBe(true);
200
+ });
201
+ });
@@ -36,7 +36,27 @@ export interface OutdatedRow {
36
36
  note: string;
37
37
  }
38
38
 
39
- async function checkEntry(entry: LockEntry): Promise<OutdatedRow> {
39
+ /**
40
+ * Hash the on-disk SKILL.md body (frontmatter-stripped) of a tracked skill, or null when
41
+ * the file is missing/unreadable. The online check MUST feed this into classify: with
42
+ * localHash==null the classifier short-circuits to "unknown" (reconcile.ts step 4) and
43
+ * NEVER reaches the ref-compare (step 7), so `outdated` could never surface a stale skill
44
+ * — the ref-compare was dead code and the UI ↑ badge (keys off status==="stale") never lit.
45
+ */
46
+ function localBodyHash(name: string, library: Skill[], libraryPath: string): string | null {
47
+ const skill = findByName(library, name);
48
+ const bodyPath = skill?.bodyPath ?? join(libraryPath, name, "SKILL.md");
49
+ try {
50
+ if (existsSync(bodyPath)) {
51
+ return hashContent(parseFrontmatter(readFileSync(bodyPath, "utf8")).body);
52
+ }
53
+ } catch {
54
+ /* leave null */
55
+ }
56
+ return null;
57
+ }
58
+
59
+ async function checkEntry(entry: LockEntry, library: Skill[], libraryPath: string): Promise<OutdatedRow> {
40
60
  const parsed = parseStoredSource(entry.source);
41
61
  const res = await latestRef(parsed);
42
62
  if (!res.ok) {
@@ -51,14 +71,16 @@ async function checkEntry(entry: LockEntry): Promise<OutdatedRow> {
51
71
  };
52
72
  }
53
73
  const latest = res.ref;
54
- // Ref-only online view (no upstream body fetched): classify maps to stale/current
55
- // off the ref compare. Same surface word as before; the disambiguation is internal.
74
+ // Ref-only online view (no upstream body fetched): classify maps to stale/current off
75
+ // the ref compare (step 7) but only if a real localHash is supplied (else the step-4
76
+ // localHash==null guard returns "unknown" before the ref compare is ever reached).
77
+ const localHash = localBodyHash(entry.name, library, libraryPath);
56
78
  const verdict = classify({
57
79
  adopted: false,
58
80
  mode: "owned",
59
81
  installedHash: entry.installedHash ?? null,
60
82
  localEdits: entry.localEdits,
61
- localHash: null,
83
+ localHash,
62
84
  upstreamHash: null,
63
85
  installedRef: entry.ref,
64
86
  latestRef: latest,
@@ -70,8 +92,16 @@ async function checkEntry(entry: LockEntry): Promise<OutdatedRow> {
70
92
  source: entry.source,
71
93
  installedRef: entry.ref,
72
94
  latestRef: latest,
73
- status: verdict === "stale" ? "stale" : "current",
74
- note: entry.localEdits ? "has local edits" : "",
95
+ // Map the verdict explicitly: an unreadable local body yields "unknown" (localHash
96
+ // null classify step 4), NOT a falsely-reassuring "current". Only a real ref-compare
97
+ // (readable body) produces stale/current.
98
+ status: verdict === "stale" ? "stale" : verdict === "unknown" ? "unknown" : "current",
99
+ note:
100
+ verdict === "unknown"
101
+ ? "local SKILL.md missing/unreadable — cannot compare against upstream"
102
+ : entry.localEdits
103
+ ? "has local edits"
104
+ : "",
75
105
  };
76
106
  }
77
107
 
@@ -180,6 +210,38 @@ function checkEntryLocal(entry: LockEntry, library: Skill[], libraryPath: string
180
210
  : { ...base, status: "current", note: "matches installed baseline (offline)" };
181
211
  }
182
212
 
213
+ /**
214
+ * Resolve an array through an async fn with BOUNDED concurrency, preserving input order.
215
+ * `outdated` probes an upstream ref per tracked skill; firing one `git ls-remote` for the
216
+ * WHOLE lockfile at once (unbounded Promise.all) opens dozens of simultaneous TLS handshakes
217
+ * that a flaky network / connection cap turns into a storm of transient "unable to access"
218
+ * failures — every one collapsing to status "unknown". A small pool caps in-flight probes so
219
+ * they succeed instead of overwhelming the transport (a failed probe degrades to "unknown",
220
+ * not a retry — a status check must not hang on a flaky host).
221
+ *
222
+ * `fn` MUST resolve (never reject): a rejection would tear down the pool via Promise.all
223
+ * while sibling workers keep running (risking an unhandled rejection). Every current caller
224
+ * branch is throw-free by contract; a future throwing `fn` must catch internally.
225
+ */
226
+ export async function mapLimit<T, R>(
227
+ items: T[],
228
+ limit: number,
229
+ fn: (item: T, index: number) => Promise<R>,
230
+ ): Promise<R[]> {
231
+ const out: R[] = new Array(items.length);
232
+ let next = 0;
233
+ async function worker(): Promise<void> {
234
+ for (let i = next++; i < items.length; i = next++) {
235
+ out[i] = await fn(items[i]!, i);
236
+ }
237
+ }
238
+ // Clamp to >=1 worker: a limit of 0 (or negative) would spawn none, leaving `out` full
239
+ // of undefined holes. min() with items.length avoids spawning idle workers.
240
+ const workerCount = Math.max(1, Math.min(limit, items.length));
241
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
242
+ return out;
243
+ }
244
+
183
245
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
184
246
  const json = argv.includes("--json");
185
247
  const checkLocal = argv.includes("--check-local");
@@ -197,21 +259,24 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
197
259
  // --from` case — which would otherwise be invisible here.
198
260
  const library = await loadLibrary(libraryPath);
199
261
 
200
- const rows = await Promise.all(
201
- entries.map((e) =>
202
- entryMode(libraryPath, e.name) === "linked"
203
- ? Promise.resolve(linkedRow(e))
204
- : e.adopted === true
205
- ? // An adopted entry has an unverified (often empty) baseline — never probe
206
- // upstream off it; report it as `adopted` so `update` reconciles (ADR-0011).
207
- // --check-local still does the offline body-vs-baseline compare below.
208
- checkLocal
209
- ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
210
- : Promise.resolve(adoptedRow(e))
211
- : checkLocal
212
- ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
213
- : checkEntry(e),
214
- ),
262
+ // Bounded concurrency (not an unbounded Promise.all): the online checkEntry path
263
+ // makes a network probe per skill, and firing all of them at once storms the transport
264
+ // into transient failures ( status "unknown"). Offline branches (linked/adopted/
265
+ // --check-local) resolve instantly and cost nothing extra under the pool.
266
+ const PROBE_CONCURRENCY = 6;
267
+ const rows = await mapLimit(entries, PROBE_CONCURRENCY, (e) =>
268
+ entryMode(libraryPath, e.name) === "linked"
269
+ ? Promise.resolve(linkedRow(e))
270
+ : e.adopted === true
271
+ ? // An adopted entry has an unverified (often empty) baseline — never probe
272
+ // upstream off it; report it as `adopted` so `update` reconciles (ADR-0011).
273
+ // --check-local still does the offline body-vs-baseline compare below.
274
+ checkLocal
275
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
276
+ : Promise.resolve(adoptedRow(e))
277
+ : checkLocal
278
+ ? Promise.resolve(checkEntryLocal(e, library, libraryPath))
279
+ : checkEntry(e, library, libraryPath),
215
280
  );
216
281
 
217
282
  // Augment: LINKED library skills not already represented by a lock entry get a
@@ -0,0 +1,78 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { isTransientGitError, fetchRepo, parseSource, cleanupStaging } from "./fetch.ts";
3
+
4
+ // The retry gate for cloneWithRetry/lsRemoteWithRetry. Getting this wrong is
5
+ // costly in BOTH directions: a definitive failure classed transient burns 3
6
+ // attempts on a 404/auth that can never succeed; a real transient classed
7
+ // definitive fails a whole `skl update --repo` run on one network blip.
8
+ describe("isTransientGitError — retry gate", () => {
9
+ test("the observed LibreSSL handshake blip is transient (retry)", () => {
10
+ // The exact fault that failed all 35 skills in one dry-run this session.
11
+ expect(
12
+ isTransientGitError(
13
+ "fatal: unable to access 'https://github.com/owner/repo.git/': LibreSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443",
14
+ ),
15
+ ).toBe(true);
16
+ });
17
+
18
+ test.each([
19
+ "fatal: unable to access '...': Failed to connect to github.com port 443: Connection reset by peer",
20
+ "fatal: unable to access '...': Could not resolve host: github.com",
21
+ "error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly",
22
+ "fatal: the remote end hung up unexpectedly\nfatal: early EOF",
23
+ "fatal: unable to access '...': Operation timed out after 30001 milliseconds",
24
+ "fatal: the remote end hung up unexpectedly",
25
+ "fatal: protocol error: bad pack header",
26
+ "error: unexpected disconnect while reading sideband packet",
27
+ "kex_exchange_identification: Connection closed by remote host",
28
+ ])("transient network fault → retry: %s", (stderr) => {
29
+ expect(isTransientGitError(stderr)).toBe(true);
30
+ });
31
+
32
+ test.each([
33
+ "fatal: repository 'https://github.com/owner/gone.git/' not found",
34
+ "remote: Repository not found.",
35
+ "fatal: Authentication failed for 'https://github.com/owner/private.git/'",
36
+ "fatal: unable to access '...': The requested URL returned error: 404",
37
+ "fatal: unable to access '...': The requested URL returned error: 403",
38
+ "remote: Permission denied",
39
+ ])("definitive failure → fail fast (no retry): %s", (stderr) => {
40
+ expect(isTransientGitError(stderr)).toBe(false);
41
+ });
42
+
43
+ test("a 404 that also says 'unable to access' still fails fast (guard wins)", () => {
44
+ // "unable to access" is a transient signal, but the 404 must veto it — else
45
+ // a missing repo would be retried 3× for nothing.
46
+ expect(
47
+ isTransientGitError(
48
+ "fatal: unable to access '...': The requested URL returned error: 404",
49
+ ),
50
+ ).toBe(false);
51
+ });
52
+
53
+ test("empty / unrecognized stderr is not treated as transient", () => {
54
+ expect(isTransientGitError("")).toBe(false);
55
+ expect(isTransientGitError("fatal: something totally novel")).toBe(false);
56
+ });
57
+ });
58
+
59
+ // Integration guard for the retry loop's fail-fast path: a clone of a nonexistent
60
+ // local repo fails with a DEFINITIVE error ("does not appear to be a git repository"),
61
+ // so cloneWithRetry must NOT burn 3 attempts on it — it returns quickly with ok:false.
62
+ // (The happy path of cloneWithRetry/lsRemoteWithRetry is exercised by the update
63
+ // rename/orphan test and the outdated online-stale test over real local repos.)
64
+ describe("fetchRepo — non-transient clone failure fails fast", () => {
65
+ test("a missing local git repo returns ok:false without retry churn", async () => {
66
+ const started = performance.now();
67
+ const res = await fetchRepo(parseSource("git:/no/such/skillshelf/repo/xyz"));
68
+ const elapsedMs = performance.now() - started;
69
+
70
+ expect(res.ok).toBe(false);
71
+ if (!res.ok) {
72
+ expect(res.error).toContain("git clone failed");
73
+ await cleanupStaging(res.staging);
74
+ }
75
+ // 3 transient retries would add ~0.9s of backoff; fail-fast stays well under that.
76
+ expect(elapsedMs).toBeLessThan(800);
77
+ });
78
+ });
package/src/core/fetch.ts CHANGED
@@ -579,6 +579,39 @@ function localCloneUrl(localPath: string): string {
579
579
  return pathToFileURL(isAbsolute(localPath) ? localPath : resolve(localPath)).href;
580
580
  }
581
581
 
582
+ /**
583
+ * A `git clone` failure worth retrying: a TRANSIENT network/TLS fault, not a definitive
584
+ * one (repo not found / auth / 404) which will never succeed and must fail fast. The
585
+ * definitive-failure guard is checked FIRST so a "unable to access … 404" is never mistaken
586
+ * for the transient "unable to access … SSL_ERROR_SYSCALL" handshake blip.
587
+ */
588
+ export function isTransientGitError(stderr: string): boolean {
589
+ if (/not found|does not (exist|appear)|repository not found|authentication failed|\b40[134]\b|permission denied|invalid username or password/i.test(stderr)) {
590
+ return false;
591
+ }
592
+ return /ssl_error|ssl_connect|\btls\b|gnutls|connection reset|connection timed out|could not resolve host|recv failure|send failure|unable to access|early eof|rpc failed|temporary failure|operation timed out|connection refused|failed to connect|timed out|remote end hung up|protocol error|unexpected disconnect|kex_exchange_identification|connection closed by remote/i.test(stderr);
593
+ }
594
+
595
+ /**
596
+ * `git clone --depth 1` with a bounded retry on TRANSIENT network/TLS faults. A single
597
+ * flaky handshake (observed: LibreSSL `SSL_ERROR_SYSCALL`) otherwise fails an ENTIRE `skl
598
+ * update --repo` run — the repo is cloned once for the whole group, so one blip reports
599
+ * "error" for every skill in it. Non-transient failures fail fast (one attempt). The partial
600
+ * checkout dir is removed between attempts so the re-clone starts from a clean slate.
601
+ */
602
+ async function cloneWithRetry(cloneTarget: string, checkout: string): Promise<RunResult> {
603
+ const MAX_ATTEMPTS = 3;
604
+ let last: RunResult = { ok: false, code: -1, stdout: "", stderr: "" };
605
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
606
+ last = await run(["git", "clone", "--depth", "1", cloneTarget, checkout]);
607
+ if (last.ok) return last;
608
+ if (attempt === MAX_ATTEMPTS || !isTransientGitError(last.stderr)) return last;
609
+ await rm(checkout, { recursive: true, force: true }).catch(() => {});
610
+ await new Promise((resolve) => setTimeout(resolve, 300 * attempt));
611
+ }
612
+ return last;
613
+ }
614
+
582
615
  /**
583
616
  * Clone a github/git source ONCE into a fresh staging dir and capture HEAD. Shared
584
617
  * by the single-skill fetch (fetchGithub/fetchGit) and the repo-wide fetchRepo so a
@@ -609,7 +642,7 @@ async function cloneToStaging(
609
642
  }
610
643
 
611
644
  const checkout = join(staging, "repo");
612
- const clone = await run(["git", "clone", "--depth", "1", cloneTarget, checkout]);
645
+ const clone = await cloneWithRetry(cloneTarget, checkout);
613
646
  if (!clone.ok) {
614
647
  return {
615
648
  ok: false,
@@ -786,7 +819,10 @@ export async function latestGithubRef(parsed: ParsedSource): Promise<RefResult>
786
819
  }
787
820
  }
788
821
 
789
- // Fallback: ls-remote default HEAD.
822
+ // Fallback: ls-remote default HEAD. NOT retried — this is a status probe (one per
823
+ // skill under `outdated`'s bounded pool); a transient blip degrades to "unknown" (cheap,
824
+ // re-runnable) rather than retrying with backoff, which would make a single hung probe
825
+ // blow a caller's time budget. Retry is reserved for the expensive clone path.
790
826
  if (await hasBinary("git")) {
791
827
  const url = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
792
828
  const r = await run(["git", "ls-remote", url, "HEAD"]);