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,101 @@
1
+ // Load the canonical library: crawl the library root, merge overlays into
2
+ // effective skills, attach provenance from the lockfile, content-hash, list.
3
+
4
+ import { existsSync } from "node:fs";
5
+ import { basename, dirname, sep } from "node:path";
6
+ import type { Skill } from "../types.ts";
7
+ import { crawl } from "./crawl.ts";
8
+ import { withOverlay } from "./overlay.ts";
9
+ import { readLockfile, provenanceForName } from "./provenance.ts";
10
+
11
+ /**
12
+ * Derive a primary-domain hint from a skill dir inside the library: the first
13
+ * path segment under the library root is the primary-domain folder.
14
+ * <lib>/bioinfo/foo/SKILL.md -> "bioinfo"
15
+ */
16
+ function libraryPrimaryDomain(libraryRoot: string): (dir: string) => string | null {
17
+ const rootParts = libraryRoot.replace(/\/+$/, "").split(sep);
18
+ // Structural folders that are not real domains (bridge/layout dirs).
19
+ const STRUCTURAL = new Set([".agents", ".claude", "skills", "skill", "_retired"]);
20
+ return (dir: string) => {
21
+ const parts = dir.split(sep);
22
+ // dir is <lib>/<domain>/<name>; domain is the segment right after root.
23
+ if (parts.length > rootParts.length + 1) {
24
+ const seg = parts[rootParts.length] ?? null;
25
+ if (seg && !STRUCTURAL.has(seg)) return seg;
26
+ }
27
+ // Not a clean domain folder (e.g. a .agents mirror) — let frontmatter win.
28
+ return null;
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Load the canonical library at `libraryPath` into effective Skill[]:
34
+ * crawl + overlay-merge + provenance attach. Returns [] if the path is missing.
35
+ */
36
+ export async function loadLibrary(libraryPath: string): Promise<Skill[]> {
37
+ if (!existsSync(libraryPath)) return [];
38
+
39
+ const { skills } = await crawl([libraryPath], {
40
+ primaryDomainOf: libraryPrimaryDomain(libraryPath),
41
+ });
42
+
43
+ const lock = await readLockfile(libraryPath);
44
+
45
+ const effective: Skill[] = [];
46
+ for (const s of skills) {
47
+ const merged = await withOverlay(s);
48
+ const prov = provenanceForName(lock, merged.name);
49
+ effective.push(prov ? { ...merged, source: prov } : merged);
50
+ }
51
+ // stable ordering: primaryDomain then name
52
+ effective.sort((a, b) => {
53
+ const da = a.primaryDomain ?? "~";
54
+ const db = b.primaryDomain ?? "~";
55
+ if (da !== db) return da < db ? -1 : 1;
56
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
57
+ });
58
+ return effective;
59
+ }
60
+
61
+ /** Filter out retired skills (the activatable set). */
62
+ export function activeSkills(skills: Skill[]): Skill[] {
63
+ return skills.filter((s) => !s.retired);
64
+ }
65
+
66
+ /** Find a skill by exact name (first match). */
67
+ export function findByName(skills: Skill[], name: string): Skill | undefined {
68
+ return skills.find((s) => s.name === name);
69
+ }
70
+
71
+ /** Fuzzy search over name + description. Returns matches scored, best first. */
72
+ export function searchSkills(skills: Skill[], query: string): Skill[] {
73
+ const q = query.toLowerCase().trim();
74
+ if (q === "") return [];
75
+ const terms = q.split(/\s+/);
76
+ const scored: Array<{ skill: Skill; score: number }> = [];
77
+ for (const s of skills) {
78
+ const name = s.name.toLowerCase();
79
+ const desc = s.description.toLowerCase();
80
+ const domains = s.domains.join(" ").toLowerCase();
81
+ let score = 0;
82
+ for (const t of terms) {
83
+ if (name === t) score += 100;
84
+ else if (name.includes(t)) score += 40;
85
+ if (desc.includes(t)) score += 10;
86
+ if (domains.includes(t)) score += 15;
87
+ }
88
+ if (score > 0) scored.push({ skill: s, score });
89
+ }
90
+ scored.sort((a, b) => b.score - a.score || (a.skill.name < b.skill.name ? -1 : 1));
91
+ return scored.map((x) => x.skill);
92
+ }
93
+
94
+ /** All unique domains across the library (sorted). */
95
+ export function listDomains(skills: Skill[]): string[] {
96
+ const set = new Set<string>();
97
+ for (const s of skills) for (const d of s.domains) set.add(d);
98
+ return [...set].sort();
99
+ }
100
+
101
+ export { basename as _basename, dirname as _dirname };
@@ -0,0 +1,63 @@
1
+ // Sidecar overlay (`<skill>.shelf.json`) read/write/merge.
2
+ // Effective skill = upstream frontmatter + overlay.
3
+
4
+ import { join } from "node:path";
5
+ import { existsSync } from "node:fs";
6
+ import type { Overlay, Skill } from "../types.ts";
7
+
8
+ /** Path to a skill's overlay file. */
9
+ export function overlayPath(skill: Skill): string {
10
+ return join(skill.path, `${skill.name}.shelf.json`);
11
+ }
12
+
13
+ /** Read a skill's overlay, or null if none / unreadable. */
14
+ export async function readOverlay(skill: Skill): Promise<Overlay | null> {
15
+ const p = overlayPath(skill);
16
+ if (!existsSync(p)) return null;
17
+ try {
18
+ const text = await Bun.file(p).text();
19
+ const parsed = JSON.parse(text) as Overlay;
20
+ if (!parsed || typeof parsed !== "object") return null;
21
+ return parsed;
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ /** Write a skill's overlay (pretty-printed JSON). */
28
+ export async function writeOverlay(skill: Skill, overlay: Overlay): Promise<void> {
29
+ const p = overlayPath(skill);
30
+ await Bun.write(p, JSON.stringify(overlay, null, 2) + "\n");
31
+ }
32
+
33
+ /**
34
+ * Merge an overlay onto a base skill, producing the effective skill.
35
+ * Overlay domains are unioned onto upstream domains (primary stays first).
36
+ * Does not mutate the input.
37
+ */
38
+ export function applyOverlay(skill: Skill, overlay: Overlay | null): Skill {
39
+ if (!overlay) return skill;
40
+ const domains = [...skill.domains];
41
+ if (Array.isArray(overlay.domains)) {
42
+ for (const d of overlay.domains) {
43
+ const s = String(d).trim();
44
+ if (s !== "" && !domains.includes(s)) domains.push(s);
45
+ }
46
+ }
47
+ const primaryDomain =
48
+ skill.primaryDomain ?? (domains.length > 0 ? domains[0]! : null);
49
+ return { ...skill, domains, primaryDomain };
50
+ }
51
+
52
+ /** Load + apply a skill's overlay from disk in one step. */
53
+ export async function withOverlay(skill: Skill): Promise<Skill> {
54
+ const overlay = await readOverlay(skill);
55
+ return applyOverlay(skill, overlay);
56
+ }
57
+
58
+ /** Bundles a skill belongs to per its overlay (explicit membership). Empty if none. */
59
+ export async function overlayBundles(skill: Skill): Promise<string[]> {
60
+ const overlay = await readOverlay(skill);
61
+ if (!overlay?.bundles) return [];
62
+ return overlay.bundles.map((b) => String(b).trim()).filter((b) => b !== "");
63
+ }
@@ -0,0 +1,130 @@
1
+ // Provenance lockfile read/write + git origin detection for imported skills.
2
+ // Lockfile lives at <library>/shelf.lock.json.
3
+
4
+ import { join } from "node:path";
5
+ import { existsSync } from "node:fs";
6
+ import type { LockEntry, Lockfile, Provenance } from "../types.ts";
7
+
8
+ export const LOCKFILE_NAME = "shelf.lock.json";
9
+
10
+ export function lockfilePath(libraryPath: string): string {
11
+ return join(libraryPath, LOCKFILE_NAME);
12
+ }
13
+
14
+ function emptyLockfile(): Lockfile {
15
+ return { version: 1, entries: {} };
16
+ }
17
+
18
+ /** Read the lockfile at the library root; returns an empty lockfile if absent/invalid. */
19
+ export async function readLockfile(libraryPath: string): Promise<Lockfile> {
20
+ const p = lockfilePath(libraryPath);
21
+ if (!existsSync(p)) return emptyLockfile();
22
+ try {
23
+ const text = await Bun.file(p).text();
24
+ const parsed = JSON.parse(text) as Lockfile;
25
+ if (!parsed || typeof parsed !== "object" || typeof parsed.entries !== "object") {
26
+ return emptyLockfile();
27
+ }
28
+ return { version: 1, entries: parsed.entries ?? {} };
29
+ } catch {
30
+ return emptyLockfile();
31
+ }
32
+ }
33
+
34
+ /** Write the lockfile (pretty JSON). */
35
+ export async function writeLockfile(libraryPath: string, lock: Lockfile): Promise<void> {
36
+ const p = lockfilePath(libraryPath);
37
+ await Bun.write(p, JSON.stringify(lock, null, 2) + "\n");
38
+ }
39
+
40
+ /** Upsert one entry by name and persist. Returns the updated lockfile. */
41
+ export async function recordEntry(
42
+ libraryPath: string,
43
+ entry: LockEntry,
44
+ ): Promise<Lockfile> {
45
+ const lock = await readLockfile(libraryPath);
46
+ lock.entries[entry.name] = entry;
47
+ await writeLockfile(libraryPath, lock);
48
+ return lock;
49
+ }
50
+
51
+ /** Remove one entry by name and persist. Returns true if it existed. */
52
+ export async function removeEntry(libraryPath: string, name: string): Promise<boolean> {
53
+ const lock = await readLockfile(libraryPath);
54
+ if (!(name in lock.entries)) return false;
55
+ delete lock.entries[name];
56
+ await writeLockfile(libraryPath, lock);
57
+ return true;
58
+ }
59
+
60
+ /** Provenance for a skill name from a loaded lockfile, or null. */
61
+ export function provenanceForName(lock: Lockfile, name: string): Provenance | null {
62
+ const e = lock.entries[name];
63
+ if (!e) return null;
64
+ return {
65
+ source: e.source,
66
+ ref: e.ref,
67
+ channel: e.channel,
68
+ installedAt: e.installedAt,
69
+ localEdits: e.localEdits,
70
+ };
71
+ }
72
+
73
+ export interface GitOrigin {
74
+ /** remote URL, e.g. https://github.com/owner/repo.git */
75
+ remote: string;
76
+ /** normalized "github:owner/repo" form if it parses, else the remote */
77
+ source: string;
78
+ /** current commit SHA */
79
+ ref: string;
80
+ }
81
+
82
+ function normalizeGithub(remote: string): string {
83
+ // git@github.com:owner/repo.git | https://github.com/owner/repo(.git)
84
+ const m =
85
+ remote.match(/github\.com[:/]+([^/]+)\/([^/]+?)(?:\.git)?$/) ?? null;
86
+ if (m) return `github:${m[1]}/${m[2]}`;
87
+ return remote;
88
+ }
89
+
90
+ /**
91
+ * Detect the git origin of a directory (an imported skill that retains a .git).
92
+ * Returns null if not a git repo or git is unavailable. Never throws.
93
+ */
94
+ export async function detectGitOrigin(dir: string): Promise<GitOrigin | null> {
95
+ try {
96
+ const remoteProc = Bun.spawnSync(
97
+ ["git", "-C", dir, "config", "--get", "remote.origin.url"],
98
+ { stdout: "pipe", stderr: "ignore" },
99
+ );
100
+ if (remoteProc.exitCode !== 0) return null;
101
+ const remote = remoteProc.stdout.toString().trim();
102
+ if (remote === "") return null;
103
+
104
+ const refProc = Bun.spawnSync(["git", "-C", dir, "rev-parse", "HEAD"], {
105
+ stdout: "pipe",
106
+ stderr: "ignore",
107
+ });
108
+ const ref = refProc.exitCode === 0 ? refProc.stdout.toString().trim() : "";
109
+
110
+ return { remote, source: normalizeGithub(remote), ref };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /** Build a LockEntry from a detected git origin. */
117
+ export function entryFromGit(
118
+ name: string,
119
+ origin: GitOrigin,
120
+ opts: { channel?: string; installedAt?: string } = {},
121
+ ): LockEntry {
122
+ return {
123
+ name,
124
+ source: origin.source,
125
+ ref: origin.ref,
126
+ channel: opts.channel ?? "github",
127
+ installedAt: opts.installedAt ?? new Date().toISOString(),
128
+ localEdits: false,
129
+ };
130
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseFrontmatter, serializeFrontmatter } from "./frontmatter.ts";
3
+
4
+ describe("parseFrontmatter", () => {
5
+ test("scalar key/value + body", () => {
6
+ const r = parseFrontmatter("---\nname: foo\ndescription: hello world\n---\n# Body\n");
7
+ expect(r.hasFrontmatter).toBe(true);
8
+ expect(r.data.name).toBe("foo");
9
+ expect(r.data.description).toBe("hello world");
10
+ expect(r.body).toBe("# Body\n");
11
+ });
12
+
13
+ test("inline list", () => {
14
+ const r = parseFrontmatter('---\ndomains: [bioinfo, "qc-x", coding]\n---\nbody');
15
+ expect(r.data.domains).toEqual(["bioinfo", "qc-x", "coding"]);
16
+ });
17
+
18
+ test("block scalar literal |", () => {
19
+ const r = parseFrontmatter(
20
+ "---\nname: x\ndescription: |\n line one\n line two\n---\nbody",
21
+ );
22
+ expect(r.data.description).toBe("line one\nline two");
23
+ });
24
+
25
+ test("folded block scalar >-", () => {
26
+ const r = parseFrontmatter(
27
+ "---\nname: x\ndescription: >-\n folded line one\n folded line two\n---\nbody",
28
+ );
29
+ expect(r.data.description).toBe("folded line one folded line two");
30
+ });
31
+
32
+ test("block list with - items", () => {
33
+ const r = parseFrontmatter("---\ndomains:\n - a\n - b\n---\nbody");
34
+ expect(r.data.domains).toEqual(["a", "b"]);
35
+ });
36
+
37
+ test("no frontmatter returns whole text as body", () => {
38
+ const r = parseFrontmatter("# just markdown\nno fences");
39
+ expect(r.hasFrontmatter).toBe(false);
40
+ expect(r.body).toBe("# just markdown\nno fences");
41
+ });
42
+
43
+ test("missing closing fence is treated as no frontmatter", () => {
44
+ const r = parseFrontmatter("---\nname: x\nnever closes");
45
+ expect(r.hasFrontmatter).toBe(false);
46
+ });
47
+
48
+ test("round-trips scalars and lists", () => {
49
+ const out = serializeFrontmatter(
50
+ { name: "foo", domains: ["a", "b"] },
51
+ "# Body\n",
52
+ );
53
+ const r = parseFrontmatter(out);
54
+ expect(r.data.name).toBe("foo");
55
+ expect(r.data.domains).toEqual(["a", "b"]);
56
+ expect(r.body.trim()).toBe("# Body");
57
+ });
58
+ });
@@ -0,0 +1,231 @@
1
+ // Minimal YAML frontmatter parser/serializer.
2
+ // Handles the subset real SKILL.md files use:
3
+ // - leading/trailing `---` fences
4
+ // - scalar `key: value`
5
+ // - inline lists `key: [a, b, c]`
6
+ // - block lists (`key:` then ` - item` lines)
7
+ // - block scalars `key: |` and `key: >-` (folded/literal multi-line)
8
+ // - quoted scalars ("..." / '...')
9
+ // Robust to missing/extra keys. NOT a general YAML implementation.
10
+
11
+ export interface Frontmatter {
12
+ data: Record<string, unknown>;
13
+ /** the body after the closing `---` fence (with no leading newline) */
14
+ body: string;
15
+ /** true if a frontmatter block was actually present */
16
+ hasFrontmatter: boolean;
17
+ }
18
+
19
+ const FENCE = "---";
20
+
21
+ function stripQuotes(s: string): string {
22
+ const t = s.trim();
23
+ if (t.length >= 2) {
24
+ const first = t[0];
25
+ const last = t[t.length - 1];
26
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
27
+ return t.slice(1, -1);
28
+ }
29
+ }
30
+ return t;
31
+ }
32
+
33
+ function parseInlineList(s: string): string[] {
34
+ // s includes surrounding brackets: [a, "b, with comma", 'c']
35
+ const inner = s.trim().slice(1, -1).trim();
36
+ if (inner === "") return [];
37
+ // Split on commas OUTSIDE quotes so quoted items containing commas stay intact.
38
+ const items: string[] = [];
39
+ let buf = "";
40
+ let quote: '"' | "'" | null = null;
41
+ for (let i = 0; i < inner.length; i++) {
42
+ const ch = inner[i]!;
43
+ if (quote) {
44
+ if (ch === quote) quote = null;
45
+ buf += ch;
46
+ } else if (ch === '"' || ch === "'") {
47
+ quote = ch;
48
+ buf += ch;
49
+ } else if (ch === ",") {
50
+ items.push(buf);
51
+ buf = "";
52
+ } else {
53
+ buf += ch;
54
+ }
55
+ }
56
+ items.push(buf);
57
+ return items.map((x) => stripQuotes(x.trim())).filter((x) => x.length > 0);
58
+ }
59
+
60
+ function parseScalar(raw: string): unknown {
61
+ const v = stripQuotes(raw.trim());
62
+ if (v === "true") return true;
63
+ if (v === "false") return false;
64
+ if (v === "null" || v === "~" || v === "") return v === "" ? "" : null;
65
+ if (/^-?\d+$/.test(v)) return Number(v);
66
+ return v;
67
+ }
68
+
69
+ /**
70
+ * Parse a SKILL.md (or any markdown) string into frontmatter data + body.
71
+ * Never throws; on malformed input returns hasFrontmatter:false with full text as body.
72
+ */
73
+ export function parseFrontmatter(text: string): Frontmatter {
74
+ const normalized = text.replace(/\r\n/g, "\n");
75
+ if (!normalized.startsWith(FENCE)) {
76
+ return { data: {}, body: normalized, hasFrontmatter: false };
77
+ }
78
+ const lines = normalized.split("\n");
79
+ // first line is the opening fence (may have trailing spaces)
80
+ if (lines[0]?.trim() !== FENCE) {
81
+ return { data: {}, body: normalized, hasFrontmatter: false };
82
+ }
83
+ // find closing fence
84
+ let end = -1;
85
+ for (let i = 1; i < lines.length; i++) {
86
+ if (lines[i]?.trim() === FENCE) {
87
+ end = i;
88
+ break;
89
+ }
90
+ }
91
+ if (end === -1) {
92
+ return { data: {}, body: normalized, hasFrontmatter: false };
93
+ }
94
+
95
+ const fmLines = lines.slice(1, end);
96
+ const body = lines.slice(end + 1).join("\n").replace(/^\n+/, "");
97
+ const data: Record<string, unknown> = {};
98
+
99
+ for (let i = 0; i < fmLines.length; i++) {
100
+ const line = fmLines[i]!;
101
+ if (line.trim() === "" || line.trim().startsWith("#")) continue;
102
+ // top-level keys are unindented
103
+ const m = line.match(/^([A-Za-z0-9_-]+):(.*)$/);
104
+ if (!m) continue;
105
+ const key = m[1]!;
106
+ const rest = m[2]!;
107
+ const restTrim = rest.trim();
108
+
109
+ // block scalar: | or > with optional chomp indicators
110
+ if (/^[|>][+-]?\s*$/.test(restTrim)) {
111
+ const folded = restTrim[0] === ">";
112
+ const collected: string[] = [];
113
+ let j = i + 1;
114
+ // determine block indentation from first non-empty child line
115
+ let blockIndent = -1;
116
+ while (j < fmLines.length) {
117
+ const cur = fmLines[j]!;
118
+ if (cur.trim() === "") {
119
+ collected.push("");
120
+ j++;
121
+ continue;
122
+ }
123
+ const indent = cur.length - cur.trimStart().length;
124
+ if (blockIndent === -1) {
125
+ if (indent === 0) break; // not part of block
126
+ blockIndent = indent;
127
+ }
128
+ if (indent < blockIndent) break;
129
+ collected.push(cur.slice(blockIndent));
130
+ j++;
131
+ }
132
+ i = j - 1;
133
+ // trim trailing blank lines
134
+ while (collected.length && collected[collected.length - 1] === "") {
135
+ collected.pop();
136
+ }
137
+ data[key] = folded
138
+ ? collected.join(" ").replace(/\s+/g, " ").trim()
139
+ : collected.join("\n");
140
+ continue;
141
+ }
142
+
143
+ // inline list
144
+ if (restTrim.startsWith("[") && restTrim.endsWith("]")) {
145
+ data[key] = parseInlineList(restTrim);
146
+ continue;
147
+ }
148
+
149
+ // block list: `key:` followed by ` - item` lines
150
+ if (restTrim === "") {
151
+ const items: string[] = [];
152
+ let j = i + 1;
153
+ while (j < fmLines.length) {
154
+ const cur = fmLines[j]!;
155
+ const ct = cur.trim();
156
+ if (ct === "") {
157
+ j++;
158
+ continue;
159
+ }
160
+ const lm = ct.match(/^-\s+(.*)$/);
161
+ if (!lm || cur.length - cur.trimStart().length === 0) break;
162
+ items.push(stripQuotes(lm[1]!.trim()));
163
+ j++;
164
+ }
165
+ if (items.length > 0) {
166
+ data[key] = items;
167
+ i = j - 1;
168
+ } else {
169
+ data[key] = "";
170
+ }
171
+ continue;
172
+ }
173
+
174
+ // plain scalar
175
+ data[key] = parseScalar(rest);
176
+ }
177
+
178
+ return { data, body, hasFrontmatter: true };
179
+ }
180
+
181
+ function needsQuoting(s: string): boolean {
182
+ return /[:#\[\]{}&*!|>'"%@`,]/.test(s) || /^\s|\s$/.test(s) || s === "";
183
+ }
184
+
185
+ function serializeScalar(v: unknown): string {
186
+ if (v === null) return "null";
187
+ if (typeof v === "boolean" || typeof v === "number") return String(v);
188
+ const s = String(v);
189
+ if (s.includes("\n")) {
190
+ // emit as literal block scalar
191
+ const indented = s
192
+ .split("\n")
193
+ .map((l) => " " + l)
194
+ .join("\n");
195
+ return "|\n" + indented;
196
+ }
197
+ if (needsQuoting(s)) {
198
+ return JSON.stringify(s);
199
+ }
200
+ return s;
201
+ }
202
+
203
+ /**
204
+ * Serialize frontmatter data + body back into a SKILL.md string.
205
+ * Keys are emitted in insertion order. Arrays become inline lists.
206
+ */
207
+ export function serializeFrontmatter(
208
+ data: Record<string, unknown>,
209
+ body: string,
210
+ ): string {
211
+ const out: string[] = [FENCE];
212
+ for (const [key, value] of Object.entries(data)) {
213
+ if (value === undefined) continue;
214
+ if (Array.isArray(value)) {
215
+ const items = value.map((x) =>
216
+ needsQuoting(String(x)) ? JSON.stringify(String(x)) : String(x),
217
+ );
218
+ out.push(`${key}: [${items.join(", ")}]`);
219
+ } else {
220
+ const s = serializeScalar(value);
221
+ if (s.startsWith("|\n")) {
222
+ out.push(`${key}: ${s}`);
223
+ } else {
224
+ out.push(`${key}: ${s}`);
225
+ }
226
+ }
227
+ }
228
+ out.push(FENCE);
229
+ const trimmedBody = body.replace(/^\n+/, "");
230
+ return out.join("\n") + "\n\n" + trimmedBody + (trimmedBody.endsWith("\n") ? "" : "\n");
231
+ }