skillshelf 0.1.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,545 @@
1
+ // Download plumbing for `skl add` / `skl update`.
2
+ //
3
+ // skillshelf's value-add is provenance + overlay + taxonomy + bundles — NOT
4
+ // downloading. So this module only shells out to commodity tools:
5
+ // - github channel: `git` (clone/ls-remote) + optional `gh api` for latest ref.
6
+ // - vercel-registry channel: the external `skills` CLI (if installed).
7
+ //
8
+ // Everything here is best-effort and never throws: callers get a discriminated
9
+ // FetchResult / RefResult with `ok` and a human `error` string on failure.
10
+
11
+ import { join, basename, isAbsolute, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { tmpdir } from "node:os";
14
+ import { existsSync, type Dirent } from "node:fs";
15
+ import { mkdtemp, rm, readdir, cp } from "node:fs/promises";
16
+ import { isDirectory } from "../lib/fs.ts";
17
+
18
+ export type Channel = "github" | "vercel-registry" | "git";
19
+
20
+ /** A parsed `skl add <src>` argument. */
21
+ export interface ParsedSource {
22
+ channel: Channel;
23
+ /** normalized "github:owner/repo" (no subpath) or the registry skill name */
24
+ source: string;
25
+ /** github owner (github channel only) */
26
+ owner?: string;
27
+ /** github repo (github channel only) */
28
+ repo?: string;
29
+ /** subpath inside the repo to the skill dir, e.g. "skills/foo" ("" if repo root) */
30
+ subpath: string;
31
+ /** the registry skill name (vercel-registry channel only) */
32
+ registryName?: string;
33
+ /** absolute local git repo path / clone URL (git channel only) */
34
+ localPath?: string;
35
+ /** raw input as given */
36
+ raw: string;
37
+ }
38
+
39
+ /**
40
+ * Parse `skl add <src>`.
41
+ * github:owner/repo -> whole repo (skill at root)
42
+ * github:owner/repo/path/to/skill -> subpath
43
+ * https://github.com/owner/repo(/tree/<ref>)?/path -> normalized
44
+ * git:/abs/path[#subpath] -> local git repo / clone URL (offline-friendly)
45
+ * file:///abs/path[#subpath] -> same, as a file:// URL
46
+ * /abs/path or ./rel/path -> local git repo on disk
47
+ * <name> -> vercel-registry skill name
48
+ * Never throws; returns { channel:"vercel-registry" } for anything that is not
49
+ * recognizably a github reference.
50
+ *
51
+ * The `git` channel exists so a local on-disk git repo (or any clone URL) can be
52
+ * installed and updated without GitHub — `git clone` works against a filesystem
53
+ * path, which keeps the add/update plumbing fully testable offline.
54
+ */
55
+ export function parseSource(raw: string): ParsedSource {
56
+ const input = raw.trim();
57
+
58
+ // Local git repo / clone URL. Subpath is carried after a `#` so absolute paths
59
+ // (which contain `/`) round-trip cleanly through the lockfile `source` string.
60
+ // git:/abs/path#subpath | file:///abs/path | /abs/path | ./rel
61
+ let gitTarget: string | null = null;
62
+ if (input.startsWith("git:")) {
63
+ gitTarget = input.slice("git:".length);
64
+ } else if (input.startsWith("file://")) {
65
+ try {
66
+ const hashAt = input.indexOf("#");
67
+ const url = hashAt >= 0 ? input.slice(0, hashAt) : input;
68
+ const frag = hashAt >= 0 ? input.slice(hashAt) : "";
69
+ gitTarget = fileURLToPath(url) + frag;
70
+ } catch {
71
+ gitTarget = null;
72
+ }
73
+ } else if (isAbsolute(input) || input.startsWith("./") || input.startsWith("../")) {
74
+ gitTarget = input;
75
+ }
76
+ if (gitTarget != null) {
77
+ const hashAt = gitTarget.indexOf("#");
78
+ const rawPath = hashAt >= 0 ? gitTarget.slice(0, hashAt) : gitTarget;
79
+ const subpath = (hashAt >= 0 ? gitTarget.slice(hashAt + 1) : "").replace(/^\/+|\/+$/g, "");
80
+ const localPath = isAbsolute(rawPath) ? rawPath : resolve(rawPath);
81
+ return {
82
+ channel: "git",
83
+ source: `git:${localPath}${subpath ? `#${subpath}` : ""}`,
84
+ subpath,
85
+ localPath,
86
+ raw,
87
+ };
88
+ }
89
+
90
+ // github:owner/repo[/subpath...]
91
+ let m = input.match(/^github:([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
92
+ if (m) {
93
+ const owner = m[1]!;
94
+ const repo = m[2]!.replace(/\.git$/, "");
95
+ const subpath = (m[3] ?? "").replace(/^\/+|\/+$/g, "");
96
+ return {
97
+ channel: "github",
98
+ source: `github:${owner}/${repo}`,
99
+ owner,
100
+ repo,
101
+ subpath,
102
+ raw,
103
+ };
104
+ }
105
+
106
+ // https://github.com/owner/repo[/tree/<ref>]/subpath or git@github.com:owner/repo
107
+ m = input.match(
108
+ /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/(?:tree|blob)\/[^/]+)?(?:\/(.+?))?\/?$/,
109
+ );
110
+ if (m && /github\.com/.test(input)) {
111
+ const owner = m[1]!;
112
+ const repo = m[2]!.replace(/\.git$/, "");
113
+ const subpath = (m[3] ?? "").replace(/^\/+|\/+$/g, "");
114
+ return {
115
+ channel: "github",
116
+ source: `github:${owner}/${repo}`,
117
+ owner,
118
+ repo,
119
+ subpath,
120
+ raw,
121
+ };
122
+ }
123
+
124
+ // Fallback: a bare name -> registry channel.
125
+ return {
126
+ channel: "vercel-registry",
127
+ source: input,
128
+ subpath: "",
129
+ registryName: input,
130
+ raw,
131
+ };
132
+ }
133
+
134
+ /** Outcome of a download into a staging directory. */
135
+ export type FetchResult =
136
+ | {
137
+ ok: true;
138
+ /** abs path to the skill dir (containing SKILL.md) inside the staging area */
139
+ skillDir: string;
140
+ /** installed ref (commit SHA for github, version/name for registry) */
141
+ ref: string;
142
+ /** abs staging root the caller MUST clean up via cleanupStaging() */
143
+ staging: string;
144
+ channel: Channel;
145
+ source: string;
146
+ }
147
+ | { ok: false; error: string; staging?: string };
148
+
149
+ /** Outcome of an upstream "latest ref" check. */
150
+ export type RefResult =
151
+ | { ok: true; ref: string }
152
+ | { ok: false; error: string };
153
+
154
+ interface RunResult {
155
+ ok: boolean;
156
+ code: number;
157
+ stdout: string;
158
+ stderr: string;
159
+ }
160
+
161
+ /** Run a command, capturing output. Never throws (missing binary -> ok:false). */
162
+ async function run(cmd: string[], cwd?: string): Promise<RunResult> {
163
+ try {
164
+ const proc = Bun.spawn(cmd, {
165
+ cwd,
166
+ stdout: "pipe",
167
+ stderr: "pipe",
168
+ stdin: "ignore",
169
+ });
170
+ const [stdout, stderr] = await Promise.all([
171
+ new Response(proc.stdout).text(),
172
+ new Response(proc.stderr).text(),
173
+ ]);
174
+ const code = await proc.exited;
175
+ return { ok: code === 0, code, stdout, stderr };
176
+ } catch (err) {
177
+ return {
178
+ ok: false,
179
+ code: -1,
180
+ stdout: "",
181
+ stderr: err instanceof Error ? err.message : String(err),
182
+ };
183
+ }
184
+ }
185
+
186
+ /** True if a binary is on PATH. */
187
+ export async function hasBinary(bin: string): Promise<boolean> {
188
+ const r = await run(["which", bin]);
189
+ return r.ok && r.stdout.trim() !== "";
190
+ }
191
+
192
+ /** Locate the single skill dir (containing SKILL.md) under a checkout subtree. */
193
+ async function locateSkillDir(root: string, subpath: string): Promise<string | null> {
194
+ const start = subpath ? join(root, subpath) : root;
195
+ if (!existsSync(start)) return null;
196
+ if (existsSync(join(start, "SKILL.md"))) return start;
197
+
198
+ // No SKILL.md at the named path: search shallowly for exactly one skill dir.
199
+ const candidates: string[] = [];
200
+ async function scan(dir: string, depth: number): Promise<void> {
201
+ if (depth > 4 || candidates.length > 1) return;
202
+ let entries: Dirent[] = [];
203
+ try {
204
+ entries = await readdir(dir, { withFileTypes: true });
205
+ } catch {
206
+ return;
207
+ }
208
+ if (entries.some((e) => e.isFile() && e.name === "SKILL.md")) {
209
+ candidates.push(dir);
210
+ return; // don't descend into a skill subtree
211
+ }
212
+ for (const e of entries) {
213
+ if (e.name === ".git" || e.name === "node_modules") continue;
214
+ const full = join(dir, e.name);
215
+ if (e.isDirectory() || (e.isSymbolicLink() && (await isDirectory(full)))) {
216
+ await scan(full, depth + 1);
217
+ }
218
+ }
219
+ }
220
+ await scan(start, 0);
221
+ return candidates.length === 1 ? candidates[0]! : null;
222
+ }
223
+
224
+ /**
225
+ * Clone a github repo into a fresh staging dir and locate the skill dir.
226
+ * Shells out to `git clone --depth 1`. The caller cleans up `staging`.
227
+ */
228
+ export async function fetchGithub(parsed: ParsedSource): Promise<FetchResult> {
229
+ if (parsed.channel !== "github" || !parsed.owner || !parsed.repo) {
230
+ return { ok: false, error: `not a github source: ${parsed.raw}` };
231
+ }
232
+ if (!(await hasBinary("git"))) {
233
+ return { ok: false, error: "git is not installed (required for github channel)" };
234
+ }
235
+
236
+ let staging: string;
237
+ try {
238
+ staging = await mkdtemp(join(tmpdir(), "skl-fetch-"));
239
+ } catch (err) {
240
+ return {
241
+ ok: false,
242
+ error: `could not create staging dir: ${err instanceof Error ? err.message : String(err)}`,
243
+ };
244
+ }
245
+
246
+ const checkout = join(staging, "repo");
247
+ const url = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
248
+ const clone = await run(["git", "clone", "--depth", "1", url, checkout]);
249
+ if (!clone.ok) {
250
+ return {
251
+ ok: false,
252
+ error: `git clone failed for ${url}: ${clone.stderr.trim() || `exit ${clone.code}`}`,
253
+ staging,
254
+ };
255
+ }
256
+
257
+ const headProc = await run(["git", "-C", checkout, "rev-parse", "HEAD"]);
258
+ const ref = headProc.ok ? headProc.stdout.trim() : "";
259
+
260
+ const skillDir = await locateSkillDir(checkout, parsed.subpath);
261
+ if (!skillDir) {
262
+ return {
263
+ ok: false,
264
+ error: parsed.subpath
265
+ ? `no SKILL.md found at ${parsed.subpath} in ${parsed.source}`
266
+ : `no unambiguous SKILL.md found in ${parsed.source} (specify a subpath)`,
267
+ staging,
268
+ };
269
+ }
270
+
271
+ return { ok: true, skillDir, ref, staging, channel: "github", source: parsed.source };
272
+ }
273
+
274
+ /**
275
+ * Clone a local git repo (or any clone URL) into a staging dir and locate the
276
+ * skill dir. Unlike fetchGithub this does not assume a github.com URL, so it
277
+ * works against an on-disk path (offline). The caller cleans up `staging`.
278
+ */
279
+ export async function fetchGit(parsed: ParsedSource): Promise<FetchResult> {
280
+ if (parsed.channel !== "git" || !parsed.localPath) {
281
+ return { ok: false, error: `not a git source: ${parsed.raw}` };
282
+ }
283
+ if (!(await hasBinary("git"))) {
284
+ return { ok: false, error: "git is not installed (required for git channel)" };
285
+ }
286
+
287
+ let staging: string;
288
+ try {
289
+ staging = await mkdtemp(join(tmpdir(), "skl-fetch-"));
290
+ } catch (err) {
291
+ return {
292
+ ok: false,
293
+ error: `could not create staging dir: ${err instanceof Error ? err.message : String(err)}`,
294
+ };
295
+ }
296
+
297
+ const checkout = join(staging, "repo");
298
+ const clone = await run(["git", "clone", "--depth", "1", parsed.localPath, checkout]);
299
+ if (!clone.ok) {
300
+ return {
301
+ ok: false,
302
+ error: `git clone failed for ${parsed.localPath}: ${clone.stderr.trim() || `exit ${clone.code}`}`,
303
+ staging,
304
+ };
305
+ }
306
+
307
+ const headProc = await run(["git", "-C", checkout, "rev-parse", "HEAD"]);
308
+ const ref = headProc.ok ? headProc.stdout.trim() : "";
309
+
310
+ const skillDir = await locateSkillDir(checkout, parsed.subpath);
311
+ if (!skillDir) {
312
+ return {
313
+ ok: false,
314
+ error: parsed.subpath
315
+ ? `no SKILL.md found at ${parsed.subpath} in ${parsed.source}`
316
+ : `no unambiguous SKILL.md found in ${parsed.source} (specify a subpath)`,
317
+ staging,
318
+ };
319
+ }
320
+
321
+ return { ok: true, skillDir, ref, staging, channel: "git", source: parsed.source };
322
+ }
323
+
324
+ /**
325
+ * Fetch a registry skill via the external `skills` CLI into a staging dir.
326
+ * Degrades gracefully (ok:false) if `skills` is not installed.
327
+ */
328
+ export async function fetchRegistry(parsed: ParsedSource): Promise<FetchResult> {
329
+ const name = parsed.registryName ?? parsed.source;
330
+ if (!(await hasBinary("skills"))) {
331
+ return {
332
+ ok: false,
333
+ error:
334
+ "the `skills` CLI is not installed; cannot fetch from the registry. " +
335
+ "Install it, or use a github: source instead.",
336
+ };
337
+ }
338
+
339
+ let staging: string;
340
+ try {
341
+ staging = await mkdtemp(join(tmpdir(), "skl-fetch-"));
342
+ } catch (err) {
343
+ return {
344
+ ok: false,
345
+ error: `could not create staging dir: ${err instanceof Error ? err.message : String(err)}`,
346
+ };
347
+ }
348
+
349
+ // `skills add <name>` vendors into ./.claude/skills or ./skills under cwd.
350
+ const add = await run(["skills", "add", name], staging);
351
+ if (!add.ok) {
352
+ return {
353
+ ok: false,
354
+ error: `\`skills add ${name}\` failed: ${add.stderr.trim() || `exit ${add.code}`}`,
355
+ staging,
356
+ };
357
+ }
358
+
359
+ const skillDir = await locateSkillDir(staging, "");
360
+ if (!skillDir) {
361
+ return {
362
+ ok: false,
363
+ error: `\`skills add ${name}\` produced no SKILL.md`,
364
+ staging,
365
+ };
366
+ }
367
+
368
+ // Best-effort version: query the registry for the resolved version.
369
+ let ref = name;
370
+ const info = await run(["skills", "info", name]);
371
+ if (info.ok) {
372
+ const v = info.stdout.match(/version[":\s]+([0-9][\w.-]*)/i);
373
+ if (v) ref = v[1]!;
374
+ }
375
+
376
+ return { ok: true, skillDir, ref, staging, channel: "vercel-registry", source: name };
377
+ }
378
+
379
+ /** Dispatch a fetch by channel. */
380
+ export async function fetchSource(parsed: ParsedSource): Promise<FetchResult> {
381
+ if (parsed.channel === "github") return fetchGithub(parsed);
382
+ if (parsed.channel === "git") return fetchGit(parsed);
383
+ return fetchRegistry(parsed);
384
+ }
385
+
386
+ /**
387
+ * Latest upstream commit SHA for a github source. Prefers `gh api` (auth +
388
+ * subpath-aware), falls back to `git ls-remote`. Never throws.
389
+ */
390
+ export async function latestGithubRef(parsed: ParsedSource): Promise<RefResult> {
391
+ if (parsed.channel !== "github" || !parsed.owner || !parsed.repo) {
392
+ return { ok: false, error: `not a github source: ${parsed.source}` };
393
+ }
394
+
395
+ // Prefer gh api: gives the latest commit touching the subpath if one is set.
396
+ if (await hasBinary("gh")) {
397
+ const path = parsed.subpath ? `&path=${encodeURIComponent(parsed.subpath)}` : "";
398
+ const endpoint = `repos/${parsed.owner}/${parsed.repo}/commits?per_page=1${path}`;
399
+ const r = await run(["gh", "api", endpoint, "--jq", ".[0].sha"]);
400
+ if (r.ok) {
401
+ const sha = r.stdout.trim();
402
+ if (sha && sha !== "null") return { ok: true, ref: sha };
403
+ }
404
+ }
405
+
406
+ // Fallback: ls-remote default HEAD (repo-level, not subpath-aware).
407
+ if (await hasBinary("git")) {
408
+ const url = `https://github.com/${parsed.owner}/${parsed.repo}.git`;
409
+ const r = await run(["git", "ls-remote", url, "HEAD"]);
410
+ if (r.ok) {
411
+ const sha = r.stdout.split(/\s+/)[0]?.trim();
412
+ if (sha) return { ok: true, ref: sha };
413
+ }
414
+ return {
415
+ ok: false,
416
+ error: `git ls-remote failed for ${url}: ${r.stderr.trim() || `exit ${r.code}`}`,
417
+ };
418
+ }
419
+
420
+ return { ok: false, error: "neither gh nor git is available to check the upstream ref" };
421
+ }
422
+
423
+ /** Latest registry version for a vercel-registry skill name. Degrades gracefully. */
424
+ export async function latestRegistryRef(name: string): Promise<RefResult> {
425
+ if (!(await hasBinary("skills"))) {
426
+ return { ok: false, error: "the `skills` CLI is not installed; cannot check the registry" };
427
+ }
428
+ const r = await run(["skills", "info", name]);
429
+ if (!r.ok) {
430
+ return { ok: false, error: `\`skills info ${name}\` failed: ${r.stderr.trim() || `exit ${r.code}`}` };
431
+ }
432
+ const v = r.stdout.match(/version[":\s]+([0-9][\w.-]*)/i);
433
+ return v ? { ok: true, ref: v[1]! } : { ok: true, ref: name };
434
+ }
435
+
436
+ /** Latest upstream commit SHA for a local git source, via `git ls-remote`. */
437
+ export async function latestGitRef(parsed: ParsedSource): Promise<RefResult> {
438
+ if (parsed.channel !== "git" || !parsed.localPath) {
439
+ return { ok: false, error: `not a git source: ${parsed.source}` };
440
+ }
441
+ if (!(await hasBinary("git"))) {
442
+ return { ok: false, error: "git is not installed (required for git channel)" };
443
+ }
444
+ const r = await run(["git", "ls-remote", parsed.localPath, "HEAD"]);
445
+ if (r.ok) {
446
+ const sha = r.stdout.split(/\s+/)[0]?.trim();
447
+ if (sha) return { ok: true, ref: sha };
448
+ }
449
+ return {
450
+ ok: false,
451
+ error: `git ls-remote failed for ${parsed.localPath}: ${r.stderr.trim() || `exit ${r.code}`}`,
452
+ };
453
+ }
454
+
455
+ /** Latest upstream ref dispatched by parsed channel. */
456
+ export async function latestRef(parsed: ParsedSource): Promise<RefResult> {
457
+ if (parsed.channel === "github") return latestGithubRef(parsed);
458
+ if (parsed.channel === "git") return latestGitRef(parsed);
459
+ return latestRegistryRef(parsed.registryName ?? parsed.source);
460
+ }
461
+
462
+ /**
463
+ * Re-parse a stored lockfile `source` string ("github:owner/repo" possibly with
464
+ * "@subpath" or "/subpath") back into a ParsedSource for ref-checking/re-pull.
465
+ */
466
+ export function parseStoredSource(source: string): ParsedSource {
467
+ // git: sources carry their subpath after a `#` and may contain `@` in the
468
+ // path, so they must round-trip verbatim. Only github sources use the
469
+ // "owner/repo@subpath" convention that needs `@`→`/` normalization.
470
+ if (source.startsWith("git:") || source.startsWith("file://")) {
471
+ return parseSource(source);
472
+ }
473
+ // tolerate "github:owner/repo@subpath" and "github:owner/repo/subpath"
474
+ const at = source.replace(/@/, "/");
475
+ return parseSource(at);
476
+ }
477
+
478
+ /**
479
+ * Copy a fetched skill dir into a destination dir (the new home in the library).
480
+ * Excludes the upstream .git. Overwrites the destination contents. Returns dest.
481
+ */
482
+ export async function copySkillDir(srcDir: string, destDir: string): Promise<string> {
483
+ await cp(srcDir, destDir, {
484
+ recursive: true,
485
+ force: true,
486
+ filter: (s: string) => basename(s) !== ".git",
487
+ });
488
+ return destDir;
489
+ }
490
+
491
+ /** Remove a staging directory created by a fetch. Never throws. */
492
+ export async function cleanupStaging(staging: string | undefined): Promise<void> {
493
+ if (!staging) return;
494
+ try {
495
+ await rm(staging, { recursive: true, force: true });
496
+ } catch {
497
+ /* best effort */
498
+ }
499
+ }
500
+
501
+ /** Read the SKILL.md body text of a skill dir. "" if absent/unreadable. */
502
+ export async function readSkillBody(skillDir: string): Promise<string> {
503
+ const p = join(skillDir, "SKILL.md");
504
+ if (!existsSync(p)) return "";
505
+ try {
506
+ return await Bun.file(p).text();
507
+ } catch {
508
+ return "";
509
+ }
510
+ }
511
+
512
+ /** Produce a unified diff between two texts using the `diff` binary if present. */
513
+ export async function unifiedDiff(
514
+ aText: string,
515
+ bText: string,
516
+ aLabel: string,
517
+ bLabel: string,
518
+ ): Promise<string> {
519
+ let staging: string | null = null;
520
+ try {
521
+ staging = await mkdtemp(join(tmpdir(), "skl-diff-"));
522
+ const aPath = join(staging, "a");
523
+ const bPath = join(staging, "b");
524
+ await Bun.write(aPath, aText);
525
+ await Bun.write(bPath, bText);
526
+ const r = await run([
527
+ "diff",
528
+ "-u",
529
+ "--label",
530
+ aLabel,
531
+ "--label",
532
+ bLabel,
533
+ aPath,
534
+ bPath,
535
+ ]);
536
+ // diff exits 1 when files differ; that is success for us.
537
+ if (r.stdout.trim() !== "") return r.stdout;
538
+ if (r.ok) return ""; // identical
539
+ return r.stdout || r.stderr;
540
+ } catch {
541
+ return "(diff unavailable)";
542
+ } finally {
543
+ await cleanupStaging(staging ?? undefined);
544
+ }
545
+ }
@@ -0,0 +1,89 @@
1
+ // Generate INDEX.md — catalog grouped by primary domain.
2
+
3
+ import { join } from "node:path";
4
+ import type { Skill } from "../types.ts";
5
+
6
+ const UNCLASSIFIED = "_unclassified";
7
+
8
+ function oneLine(desc: string): string {
9
+ const flat = desc.replace(/\s+/g, " ").trim();
10
+ if (flat.length <= 160) return flat;
11
+ return flat.slice(0, 157).trimEnd() + "...";
12
+ }
13
+
14
+ /**
15
+ * Build the INDEX.md content string from a loaded library. Groups by
16
+ * primaryDomain, retired skills listed in a trailing section.
17
+ */
18
+ export function generateIndex(
19
+ skills: Skill[],
20
+ opts: { generatedAt?: string } = {},
21
+ ): string {
22
+ const active = skills.filter((s) => !s.retired);
23
+ const retired = skills.filter((s) => s.retired);
24
+
25
+ const byDomain = new Map<string, Skill[]>();
26
+ for (const s of active) {
27
+ const d = s.primaryDomain ?? UNCLASSIFIED;
28
+ const arr = byDomain.get(d);
29
+ if (arr) arr.push(s);
30
+ else byDomain.set(d, [s]);
31
+ }
32
+
33
+ const domains = [...byDomain.keys()].sort((a, b) => {
34
+ if (a === UNCLASSIFIED) return 1;
35
+ if (b === UNCLASSIFIED) return -1;
36
+ return a < b ? -1 : 1;
37
+ });
38
+
39
+ const lines: string[] = [];
40
+ lines.push("# Skill Index");
41
+ lines.push("");
42
+ lines.push(
43
+ `> Generated by \`skl index\`. ${active.length} active skill${active.length === 1 ? "" : "s"}` +
44
+ (retired.length ? `, ${retired.length} retired` : "") +
45
+ ` across ${domains.length} domain${domains.length === 1 ? "" : "s"}.`,
46
+ );
47
+ const ts = opts.generatedAt ?? new Date().toISOString();
48
+ lines.push(`> Last generated: ${ts}`);
49
+ lines.push("");
50
+
51
+ for (const domain of domains) {
52
+ const arr = byDomain
53
+ .get(domain)!
54
+ .slice()
55
+ .sort((a, b) => (a.name < b.name ? -1 : 1));
56
+ lines.push(`## ${domain}`);
57
+ lines.push("");
58
+ for (const s of arr) {
59
+ const extra = s.domains.filter((d) => d !== s.primaryDomain);
60
+ const tags = extra.length ? ` _(also: ${extra.join(", ")})_` : "";
61
+ const prov = s.source ? ` \`[${s.source.source}]\`` : "";
62
+ lines.push(`- **${s.name}**${tags}${prov} — ${oneLine(s.description)}`);
63
+ }
64
+ lines.push("");
65
+ }
66
+
67
+ if (retired.length) {
68
+ lines.push("## _retired");
69
+ lines.push("");
70
+ for (const s of retired.slice().sort((a, b) => (a.name < b.name ? -1 : 1))) {
71
+ lines.push(`- ~~${s.name}~~ — ${oneLine(s.description)}`);
72
+ }
73
+ lines.push("");
74
+ }
75
+
76
+ return lines.join("\n");
77
+ }
78
+
79
+ /** Write INDEX.md into the library root. Returns the path written. */
80
+ export async function writeIndex(
81
+ libraryPath: string,
82
+ skills: Skill[],
83
+ opts: { generatedAt?: string } = {},
84
+ ): Promise<string> {
85
+ const content = generateIndex(skills, opts);
86
+ const out = join(libraryPath, "INDEX.md");
87
+ await Bun.write(out, content + "\n");
88
+ return out;
89
+ }