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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/package.json +42 -0
- package/src/adapters/inference/agent.ts +253 -0
- package/src/adapters/inference/api.ts +309 -0
- package/src/cli.ts +127 -0
- package/src/commands/add.ts +222 -0
- package/src/commands/drop.ts +89 -0
- package/src/commands/index.ts +46 -0
- package/src/commands/infer.ts +282 -0
- package/src/commands/init.ts +124 -0
- package/src/commands/ls.ts +80 -0
- package/src/commands/new.ts +163 -0
- package/src/commands/outdated.ts +113 -0
- package/src/commands/search.ts +61 -0
- package/src/commands/show.ts +70 -0
- package/src/commands/status.ts +117 -0
- package/src/commands/update.ts +267 -0
- package/src/commands/use.ts +100 -0
- package/src/config.ts +107 -0
- package/src/core/bundle.ts +62 -0
- package/src/core/core.test.ts +68 -0
- package/src/core/crawl.ts +267 -0
- package/src/core/dedupe.ts +67 -0
- package/src/core/fetch.ts +545 -0
- package/src/core/indexgen.ts +89 -0
- package/src/core/library.ts +101 -0
- package/src/core/overlay.ts +63 -0
- package/src/core/provenance.ts +130 -0
- package/src/lib/frontmatter.test.ts +58 -0
- package/src/lib/frontmatter.ts +231 -0
- package/src/lib/fs.ts +186 -0
- package/src/types.ts +186 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// skl update [name] — re-pull the upstream SKILL.md body for tracked skills.
|
|
2
|
+
//
|
|
3
|
+
// Invariants (the whole point of this command):
|
|
4
|
+
// - The overlay (<name>.shelf.json) is NEVER touched: taxonomy/bundles survive.
|
|
5
|
+
// - Only the upstream body (SKILL.md + bundled reference files) is replaced.
|
|
6
|
+
// - If the LOCAL body diverged from the previously-installed upstream (the user
|
|
7
|
+
// hand-edited it), DO NOT clobber. Show a diff and skip, unless --force.
|
|
8
|
+
// - Bundled reference files are refreshed alongside SKILL.md.
|
|
9
|
+
//
|
|
10
|
+
// Read-ish/destructive command. Supports --json for a structured report; --dry-run
|
|
11
|
+
// to preview without writing; --force to overwrite diverged local edits.
|
|
12
|
+
|
|
13
|
+
import { join, basename } from "node:path";
|
|
14
|
+
import { existsSync } from "node:fs";
|
|
15
|
+
import { cp, rm, readdir } from "node:fs/promises";
|
|
16
|
+
import type { Ctx, LockEntry } from "../types.ts";
|
|
17
|
+
import { readLockfile, recordEntry } from "../core/provenance.ts";
|
|
18
|
+
import {
|
|
19
|
+
parseStoredSource,
|
|
20
|
+
fetchSource,
|
|
21
|
+
cleanupStaging,
|
|
22
|
+
readSkillBody,
|
|
23
|
+
unifiedDiff,
|
|
24
|
+
} from "../core/fetch.ts";
|
|
25
|
+
import { hashContent } from "../core/crawl.ts";
|
|
26
|
+
import { parseFrontmatter } from "../lib/frontmatter.ts";
|
|
27
|
+
import { loadLibrary, findByName } from "../core/library.ts";
|
|
28
|
+
|
|
29
|
+
export const meta = {
|
|
30
|
+
name: "update",
|
|
31
|
+
summary: "Re-pull upstream body, preserve overlay, diff if local body diverged",
|
|
32
|
+
usage: "skl update [name] [--force] [--dry-run] [--json]",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
type Outcome = "updated" | "uptodate" | "diverged" | "skipped" | "error";
|
|
36
|
+
|
|
37
|
+
interface Result {
|
|
38
|
+
name: string;
|
|
39
|
+
source: string;
|
|
40
|
+
channel: string;
|
|
41
|
+
fromRef: string;
|
|
42
|
+
toRef: string | null;
|
|
43
|
+
outcome: Outcome;
|
|
44
|
+
note: string;
|
|
45
|
+
diff?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Body text after frontmatter, for content comparison/hash. */
|
|
49
|
+
function bodyOf(text: string): string {
|
|
50
|
+
return parseFrontmatter(text).body;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Replace SKILL.md + bundled reference files from upstream; preserve overlay/lock. */
|
|
54
|
+
async function applyUpstream(destDir: string, upstreamDir: string, name: string): Promise<void> {
|
|
55
|
+
const PRESERVE = new Set([`${name}.shelf.json`, "shelf.lock.json"]);
|
|
56
|
+
// Remove existing upstream-managed files (everything except overlay/lock/.git).
|
|
57
|
+
let entries: Awaited<ReturnType<typeof readdir>> = [];
|
|
58
|
+
try {
|
|
59
|
+
entries = await readdir(destDir, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
/* dest may not exist yet */
|
|
62
|
+
}
|
|
63
|
+
for (const e of entries) {
|
|
64
|
+
if (PRESERVE.has(e.name) || e.name === ".git") continue;
|
|
65
|
+
await rm(join(destDir, e.name), { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
// Copy fresh upstream content (minus its .git).
|
|
68
|
+
await cp(upstreamDir, destDir, {
|
|
69
|
+
recursive: true,
|
|
70
|
+
force: true,
|
|
71
|
+
filter: (s: string) => basename(s) !== ".git",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function updateOne(
|
|
76
|
+
ctx: Ctx,
|
|
77
|
+
entry: LockEntry,
|
|
78
|
+
destDir: string,
|
|
79
|
+
opts: { force: boolean; dryRun: boolean },
|
|
80
|
+
): Promise<Result> {
|
|
81
|
+
const parsed = parseStoredSource(entry.source);
|
|
82
|
+
const fetched = await fetchSource(parsed);
|
|
83
|
+
if (!fetched.ok) {
|
|
84
|
+
await cleanupStaging(fetched.staging);
|
|
85
|
+
return {
|
|
86
|
+
name: entry.name,
|
|
87
|
+
source: entry.source,
|
|
88
|
+
channel: entry.channel,
|
|
89
|
+
fromRef: entry.ref,
|
|
90
|
+
toRef: null,
|
|
91
|
+
outcome: "error",
|
|
92
|
+
note: fetched.error,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const upstreamText = await readSkillBody(fetched.skillDir);
|
|
98
|
+
const localPath = join(destDir, "SKILL.md");
|
|
99
|
+
const localText = existsSync(localPath) ? await Bun.file(localPath).text() : "";
|
|
100
|
+
|
|
101
|
+
const upstreamBody = bodyOf(upstreamText);
|
|
102
|
+
const localBody = bodyOf(localText);
|
|
103
|
+
|
|
104
|
+
const upstreamHash = hashContent(upstreamBody);
|
|
105
|
+
const localHash = hashContent(localBody);
|
|
106
|
+
|
|
107
|
+
// Already current: local body matches upstream and ref unchanged.
|
|
108
|
+
if (localHash === upstreamHash && fetched.ref === entry.ref) {
|
|
109
|
+
return {
|
|
110
|
+
name: entry.name,
|
|
111
|
+
source: entry.source,
|
|
112
|
+
channel: entry.channel,
|
|
113
|
+
fromRef: entry.ref,
|
|
114
|
+
toRef: fetched.ref,
|
|
115
|
+
outcome: "uptodate",
|
|
116
|
+
note: "already at latest upstream body",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// True 3-way divergence: did the USER hand-edit the local body since install?
|
|
121
|
+
// Compare against installedHash (the upstream body recorded at install/update
|
|
122
|
+
// time) — NOT against current upstream, so a normal upstream-moved-forward
|
|
123
|
+
// update is applied, and only genuine local edits are protected.
|
|
124
|
+
// Legacy entries without installedHash fall back to the localEdits flag.
|
|
125
|
+
const userEdited =
|
|
126
|
+
entry.installedHash != null
|
|
127
|
+
? localHash !== entry.installedHash
|
|
128
|
+
: entry.localEdits === true;
|
|
129
|
+
const localDiverged = userEdited && localHash !== upstreamHash;
|
|
130
|
+
if (localDiverged && !opts.force) {
|
|
131
|
+
const diff = await unifiedDiff(
|
|
132
|
+
localText,
|
|
133
|
+
upstreamText,
|
|
134
|
+
`${entry.name} (local)`,
|
|
135
|
+
`${entry.name} (upstream ${fetched.ref.slice(0, 10)})`,
|
|
136
|
+
);
|
|
137
|
+
return {
|
|
138
|
+
name: entry.name,
|
|
139
|
+
source: entry.source,
|
|
140
|
+
channel: entry.channel,
|
|
141
|
+
fromRef: entry.ref,
|
|
142
|
+
toRef: fetched.ref,
|
|
143
|
+
outcome: "diverged",
|
|
144
|
+
note: "local body diverged from upstream; not clobbering (use --force to overwrite)",
|
|
145
|
+
diff,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (opts.dryRun) {
|
|
150
|
+
return {
|
|
151
|
+
name: entry.name,
|
|
152
|
+
source: entry.source,
|
|
153
|
+
channel: entry.channel,
|
|
154
|
+
fromRef: entry.ref,
|
|
155
|
+
toRef: fetched.ref,
|
|
156
|
+
outcome: "updated",
|
|
157
|
+
note: opts.force && localDiverged ? "would overwrite diverged local body (dry-run)" : "would update (dry-run)",
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Apply: replace body + ref files, preserve overlay/lock.
|
|
162
|
+
await applyUpstream(destDir, fetched.skillDir, entry.name);
|
|
163
|
+
|
|
164
|
+
// Update lockfile ref + record the new installed body hash + clear localEdits
|
|
165
|
+
// (the on-disk body now equals upstream again, so we are pristine).
|
|
166
|
+
const updatedEntry: LockEntry = {
|
|
167
|
+
...entry,
|
|
168
|
+
ref: fetched.ref,
|
|
169
|
+
installedAt: new Date().toISOString(),
|
|
170
|
+
localEdits: false,
|
|
171
|
+
installedHash: upstreamHash,
|
|
172
|
+
};
|
|
173
|
+
await recordEntry(ctx.config.libraryPath, updatedEntry);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
name: entry.name,
|
|
177
|
+
source: entry.source,
|
|
178
|
+
channel: entry.channel,
|
|
179
|
+
fromRef: entry.ref,
|
|
180
|
+
toRef: fetched.ref,
|
|
181
|
+
outcome: "updated",
|
|
182
|
+
note: opts.force && localDiverged ? "overwrote diverged local body" : "upstream body re-pulled; overlay preserved",
|
|
183
|
+
};
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return {
|
|
186
|
+
name: entry.name,
|
|
187
|
+
source: entry.source,
|
|
188
|
+
channel: entry.channel,
|
|
189
|
+
fromRef: entry.ref,
|
|
190
|
+
toRef: fetched.ref,
|
|
191
|
+
outcome: "error",
|
|
192
|
+
note: err instanceof Error ? err.message : String(err),
|
|
193
|
+
};
|
|
194
|
+
} finally {
|
|
195
|
+
await cleanupStaging(fetched.staging);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
200
|
+
const json = argv.includes("--json");
|
|
201
|
+
const force = argv.includes("--force");
|
|
202
|
+
const dryRun = argv.includes("--dry-run");
|
|
203
|
+
const nameArg = argv.find((a) => !a.startsWith("-")) ?? null;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const lock = await readLockfile(ctx.config.libraryPath);
|
|
207
|
+
let entries = Object.values(lock.entries);
|
|
208
|
+
if (nameArg) entries = entries.filter((e) => e.name === nameArg);
|
|
209
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
210
|
+
|
|
211
|
+
if (entries.length === 0) {
|
|
212
|
+
if (json) ctx.json({ ok: true, updated: 0, diverged: 0, results: [] });
|
|
213
|
+
else if (nameArg) ctx.error(`no tracked skill named "${nameArg}"`);
|
|
214
|
+
else ctx.log("no tracked third-party skills (lockfile is empty)");
|
|
215
|
+
return nameArg && !json ? 1 : 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Resolve on-disk dirs via the library so renames/domain folders are honored.
|
|
219
|
+
const library = await loadLibrary(ctx.config.libraryPath);
|
|
220
|
+
|
|
221
|
+
const results: Result[] = [];
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const skill = findByName(library, entry.name);
|
|
224
|
+
const destDir = skill?.path ?? join(ctx.config.libraryPath, entry.name);
|
|
225
|
+
results.push(await updateOne(ctx, entry, destDir, { force, dryRun }));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const updated = results.filter((r) => r.outcome === "updated").length;
|
|
229
|
+
const diverged = results.filter((r) => r.outcome === "diverged").length;
|
|
230
|
+
const errored = results.filter((r) => r.outcome === "error").length;
|
|
231
|
+
|
|
232
|
+
if (json) {
|
|
233
|
+
ctx.json({ ok: errored === 0, updated, diverged, errors: errored, results });
|
|
234
|
+
} else {
|
|
235
|
+
for (const r of results) {
|
|
236
|
+
const tag =
|
|
237
|
+
r.outcome === "updated"
|
|
238
|
+
? "updated "
|
|
239
|
+
: r.outcome === "uptodate"
|
|
240
|
+
? "current "
|
|
241
|
+
: r.outcome === "diverged"
|
|
242
|
+
? "DIVERGED "
|
|
243
|
+
: r.outcome === "error"
|
|
244
|
+
? "ERROR "
|
|
245
|
+
: "skipped ";
|
|
246
|
+
ctx.log(`${tag} ${r.name.padEnd(28)} ${r.note}`);
|
|
247
|
+
if (r.outcome === "diverged" && r.diff) {
|
|
248
|
+
ctx.log("");
|
|
249
|
+
ctx.log(r.diff.trimEnd());
|
|
250
|
+
ctx.log("");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
ctx.log("");
|
|
254
|
+
ctx.log(
|
|
255
|
+
`${results.length} tracked, ${updated} updated, ${diverged} diverged${errored ? `, ${errored} error(s)` : ""}.`,
|
|
256
|
+
);
|
|
257
|
+
if (diverged > 0) ctx.log("re-run with --force to overwrite diverged local bodies.");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Non-zero if any error or any unresolved divergence (blocks CI/agents).
|
|
261
|
+
if (errored > 0) return 1;
|
|
262
|
+
return diverged > 0 ? 2 : 0;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
ctx.error("update: failed:", err instanceof Error ? err.message : String(err));
|
|
265
|
+
return 1;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// `skl use <bundle>` — symlink every skill in a bundle into ./.claude/skills/
|
|
2
|
+
// so Claude Code can natively hot-load them. Idempotent: re-running re-points
|
|
3
|
+
// links without error. Reports what was linked (and is JSON-parseable on --json).
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { Ctx } from "../types.ts";
|
|
7
|
+
import { resolveBundle } from "../core/bundle.ts";
|
|
8
|
+
import { activeSkills } from "../core/library.ts";
|
|
9
|
+
import { safeSymlink, isSymlink, realpathOrSelf, realpathOrSelfAsync } from "../lib/fs.ts";
|
|
10
|
+
|
|
11
|
+
export const meta = {
|
|
12
|
+
name: "use",
|
|
13
|
+
summary: "Symlink a bundle's skills into ./.claude/skills/ (hot-loads)",
|
|
14
|
+
usage: "skl use <bundle> [--json]",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
interface LinkResult {
|
|
18
|
+
name: string;
|
|
19
|
+
target: string;
|
|
20
|
+
link: string;
|
|
21
|
+
status: "linked" | "already" | "conflict";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Project skills dir for the cwd. */
|
|
25
|
+
function projectSkillsDir(): string {
|
|
26
|
+
return join(process.cwd(), ".claude", "skills");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function run(argv: string[], ctx: Ctx): Promise<number> {
|
|
30
|
+
try {
|
|
31
|
+
const json = argv.includes("--json");
|
|
32
|
+
const bundleName = argv.find((a) => !a.startsWith("-"));
|
|
33
|
+
|
|
34
|
+
if (!bundleName) {
|
|
35
|
+
ctx.error("usage: " + meta.usage);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const skills = await ctx.loadLibrary();
|
|
40
|
+
const bundle = await resolveBundle(activeSkills(skills), bundleName);
|
|
41
|
+
|
|
42
|
+
if (bundle.skills.length === 0) {
|
|
43
|
+
if (json) {
|
|
44
|
+
ctx.json({ bundle: bundleName, linked: [], skillsDir: projectSkillsDir(), error: "empty-bundle" });
|
|
45
|
+
} else {
|
|
46
|
+
ctx.error(`No active skills match bundle '${bundleName}'.`);
|
|
47
|
+
}
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const skillsDir = projectSkillsDir();
|
|
52
|
+
const results: LinkResult[] = [];
|
|
53
|
+
|
|
54
|
+
for (const s of bundle.skills) {
|
|
55
|
+
const link = join(skillsDir, s.name);
|
|
56
|
+
const target = s.path;
|
|
57
|
+
let status: LinkResult["status"] = "linked";
|
|
58
|
+
|
|
59
|
+
// Determine prior state for accurate reporting before we touch it.
|
|
60
|
+
if (isSymlink(link)) {
|
|
61
|
+
const cur = realpathOrSelf(link);
|
|
62
|
+
const want = await realpathOrSelfAsync(target);
|
|
63
|
+
if (cur === want) status = "already";
|
|
64
|
+
} else if (await pathTakenNonLink(link)) {
|
|
65
|
+
// A real (non-symlink) file/dir occupies the slot — don't clobber it.
|
|
66
|
+
results.push({ name: s.name, target, link, status: "conflict" });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await safeSymlink(target, link);
|
|
71
|
+
results.push({ name: s.name, target, link, status });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const conflicts = results.filter((r) => r.status === "conflict");
|
|
75
|
+
|
|
76
|
+
if (json) {
|
|
77
|
+
ctx.json({ bundle: bundle.name, skillsDir, linked: results });
|
|
78
|
+
} else {
|
|
79
|
+
ctx.log(`Bundle '${bundle.name}' -> ${skillsDir}`);
|
|
80
|
+
for (const r of results) {
|
|
81
|
+
const tag =
|
|
82
|
+
r.status === "linked" ? "linked" : r.status === "already" ? "ok" : "SKIP (real file present)";
|
|
83
|
+
ctx.log(` ${r.name} [${tag}]`);
|
|
84
|
+
}
|
|
85
|
+
ctx.log("");
|
|
86
|
+
ctx.log("Reminder: add '.claude/skills/' to this project's .gitignore so these symlinks aren't committed.");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return conflicts.length > 0 ? 1 : 0;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
ctx.error(`skl use failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True if linkPath exists as a real (non-symlink) entry. */
|
|
97
|
+
async function pathTakenNonLink(linkPath: string): Promise<boolean> {
|
|
98
|
+
const { existsSync } = await import("node:fs");
|
|
99
|
+
return existsSync(linkPath) && !isSymlink(linkPath);
|
|
100
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Configuration resolution + Ctx construction.
|
|
2
|
+
// Library path resolution order:
|
|
3
|
+
// 1. env SKILLSHELF_LIBRARY
|
|
4
|
+
// 2. ~/.skillshelf/config.json { "library": "...", "globalCore": "..." }
|
|
5
|
+
// 3. default ~/.skillshelf/library
|
|
6
|
+
// Global-core symlink target defaults to ~/.claude/skills.
|
|
7
|
+
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join, resolve, isAbsolute } from "node:path";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import type { Config, ConfigFile, Ctx, Skill } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CONFIG_FILE = join(homedir(), ".skillshelf", "config.json");
|
|
14
|
+
export const DEFAULT_LIBRARY = join(homedir(), ".skillshelf", "library");
|
|
15
|
+
export const DEFAULT_GLOBAL_CORE = join(homedir(), ".claude", "skills");
|
|
16
|
+
|
|
17
|
+
function expandHome(p: string): string {
|
|
18
|
+
if (p === "~") return homedir();
|
|
19
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function abs(p: string): string {
|
|
24
|
+
const e = expandHome(p);
|
|
25
|
+
return isAbsolute(e) ? e : resolve(e);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readConfigFile(file: string): Promise<ConfigFile | null> {
|
|
29
|
+
if (!existsSync(file)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const text = await Bun.file(file).text();
|
|
32
|
+
const parsed = JSON.parse(text) as ConfigFile;
|
|
33
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the full Config (library path + global-core target + provenance).
|
|
41
|
+
* `configFilePath` override is mainly for tests.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveConfig(opts: {
|
|
44
|
+
env?: NodeJS.ProcessEnv;
|
|
45
|
+
configFilePath?: string;
|
|
46
|
+
} = {}): Promise<Config> {
|
|
47
|
+
const env = opts.env ?? process.env;
|
|
48
|
+
const configFilePath = opts.configFilePath ?? DEFAULT_CONFIG_FILE;
|
|
49
|
+
|
|
50
|
+
const fileCfg = await readConfigFile(configFilePath);
|
|
51
|
+
const usedConfigFile = fileCfg ? configFilePath : null;
|
|
52
|
+
|
|
53
|
+
let libraryPath: string;
|
|
54
|
+
let source: Config["source"];
|
|
55
|
+
|
|
56
|
+
const envLib = env.SKILLSHELF_LIBRARY;
|
|
57
|
+
if (envLib && envLib.trim() !== "") {
|
|
58
|
+
libraryPath = abs(envLib.trim());
|
|
59
|
+
source = "env";
|
|
60
|
+
} else if (fileCfg?.library && fileCfg.library.trim() !== "") {
|
|
61
|
+
libraryPath = abs(fileCfg.library.trim());
|
|
62
|
+
source = "config";
|
|
63
|
+
} else {
|
|
64
|
+
libraryPath = DEFAULT_LIBRARY;
|
|
65
|
+
source = "default";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const globalCoreTarget =
|
|
69
|
+
env.SKILLSHELF_GLOBAL_CORE && env.SKILLSHELF_GLOBAL_CORE.trim() !== ""
|
|
70
|
+
? abs(env.SKILLSHELF_GLOBAL_CORE.trim())
|
|
71
|
+
: fileCfg?.globalCore && fileCfg.globalCore.trim() !== ""
|
|
72
|
+
? abs(fileCfg.globalCore.trim())
|
|
73
|
+
: DEFAULT_GLOBAL_CORE;
|
|
74
|
+
|
|
75
|
+
return { libraryPath, globalCoreTarget, configFile: usedConfigFile, source };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the execution Ctx handed to every command's run().
|
|
80
|
+
* `loadLibrary` is lazily wired to avoid a circular import at module load.
|
|
81
|
+
*/
|
|
82
|
+
export async function loadContext(opts: {
|
|
83
|
+
env?: NodeJS.ProcessEnv;
|
|
84
|
+
configFilePath?: string;
|
|
85
|
+
} = {}): Promise<Ctx> {
|
|
86
|
+
const config = await resolveConfig(opts);
|
|
87
|
+
|
|
88
|
+
const ctx: Ctx = {
|
|
89
|
+
config,
|
|
90
|
+
libraryPath: config.libraryPath,
|
|
91
|
+
loadLibrary: async (): Promise<Skill[]> => {
|
|
92
|
+
const { loadLibrary } = await import("./core/library.ts");
|
|
93
|
+
return loadLibrary(config.libraryPath);
|
|
94
|
+
},
|
|
95
|
+
log: (...args: unknown[]) => {
|
|
96
|
+
console.log(...args);
|
|
97
|
+
},
|
|
98
|
+
json: (value: unknown) => {
|
|
99
|
+
console.log(JSON.stringify(value));
|
|
100
|
+
},
|
|
101
|
+
error: (...args: unknown[]) => {
|
|
102
|
+
console.error(...args);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return ctx;
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Bundles = tag queries over domains[]. Resolving a bundle yields every skill
|
|
2
|
+
// tagged with the bundle's domain (regardless of physical folder). Plus explicit
|
|
3
|
+
// overlay bundle membership.
|
|
4
|
+
|
|
5
|
+
import type { Bundle, Skill } from "../types.ts";
|
|
6
|
+
import { overlayBundles } from "./overlay.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolve a single bundle name against the library. A skill is in the bundle if:
|
|
10
|
+
* - its `domains[]` contains the bundle name, OR
|
|
11
|
+
* - its overlay `bundles[]` lists the bundle name.
|
|
12
|
+
* Retired skills are excluded by default.
|
|
13
|
+
*/
|
|
14
|
+
export async function resolveBundle(
|
|
15
|
+
skills: Skill[],
|
|
16
|
+
bundleName: string,
|
|
17
|
+
opts: { includeRetired?: boolean } = {},
|
|
18
|
+
): Promise<Bundle> {
|
|
19
|
+
const name = bundleName.trim();
|
|
20
|
+
const matched: Skill[] = [];
|
|
21
|
+
for (const s of skills) {
|
|
22
|
+
if (s.retired && !opts.includeRetired) continue;
|
|
23
|
+
if (s.domains.includes(name)) {
|
|
24
|
+
matched.push(s);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const ob = await overlayBundles(s);
|
|
28
|
+
if (ob.includes(name)) matched.push(s);
|
|
29
|
+
}
|
|
30
|
+
matched.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
31
|
+
return { name, skills: matched };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* List every available bundle (one per distinct domain tag) with its resolved
|
|
36
|
+
* skills. Useful for `skl ls` with no argument and the trigger-bridge menu.
|
|
37
|
+
*/
|
|
38
|
+
export async function listBundles(
|
|
39
|
+
skills: Skill[],
|
|
40
|
+
opts: { includeRetired?: boolean } = {},
|
|
41
|
+
): Promise<Bundle[]> {
|
|
42
|
+
const names = new Set<string>();
|
|
43
|
+
for (const s of skills) {
|
|
44
|
+
if (s.retired && !opts.includeRetired) continue;
|
|
45
|
+
for (const d of s.domains) names.add(d);
|
|
46
|
+
for (const b of await overlayBundles(s)) names.add(b);
|
|
47
|
+
}
|
|
48
|
+
const bundles: Bundle[] = [];
|
|
49
|
+
for (const name of [...names].sort()) {
|
|
50
|
+
bundles.push(await resolveBundle(skills, name, opts));
|
|
51
|
+
}
|
|
52
|
+
return bundles;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Synchronous resolve when overlays were already merged into domains[]. */
|
|
56
|
+
export function resolveBundleSync(skills: Skill[], bundleName: string): Bundle {
|
|
57
|
+
const name = bundleName.trim();
|
|
58
|
+
const matched = skills
|
|
59
|
+
.filter((s) => !s.retired && s.domains.includes(name))
|
|
60
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
61
|
+
return { name, skills: matched };
|
|
62
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadLibrary, searchSkills, findByName } from "./library.ts";
|
|
4
|
+
import { findDuplicates, driftedGroups } from "./dedupe.ts";
|
|
5
|
+
import { resolveBundle } from "./bundle.ts";
|
|
6
|
+
import { generateIndex } from "./indexgen.ts";
|
|
7
|
+
|
|
8
|
+
const FIXTURES = join(import.meta.dir, "..", "..", "fixtures", "library");
|
|
9
|
+
|
|
10
|
+
describe("core against fixtures", () => {
|
|
11
|
+
test("loads all skills with overlays + provenance + mirrors + retired", async () => {
|
|
12
|
+
const lib = await loadLibrary(FIXTURES);
|
|
13
|
+
expect(lib.length).toBe(12);
|
|
14
|
+
|
|
15
|
+
const retired = lib.filter((s) => s.retired).map((s) => s.name);
|
|
16
|
+
expect(retired).toContain("old-deseq-helper");
|
|
17
|
+
|
|
18
|
+
const mirror = lib.find((s) => s.mirrorOf);
|
|
19
|
+
expect(mirror?.name).toBe("commit-push");
|
|
20
|
+
expect(mirror?.primaryDomain).toBe("coding");
|
|
21
|
+
|
|
22
|
+
const thirdParty = findByName(lib, "xhs-title");
|
|
23
|
+
expect(thirdParty?.source?.source).toBe(
|
|
24
|
+
"github:dontbesilent2025/dbskill@skills/xhs-title",
|
|
25
|
+
);
|
|
26
|
+
// overlay added green-card domain
|
|
27
|
+
expect(thirdParty?.domains).toContain("green-card");
|
|
28
|
+
|
|
29
|
+
const qc = findByName(lib, "rnaseq-qc");
|
|
30
|
+
expect(qc?.refFiles.length).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("detects drifted duplicate scrna-cluster + identical commit-push mirror", async () => {
|
|
34
|
+
const lib = await loadLibrary(FIXTURES);
|
|
35
|
+
const groups = findDuplicates(lib);
|
|
36
|
+
const drifted = driftedGroups(groups).map((g) => g.name);
|
|
37
|
+
expect(drifted).toContain("scrna-cluster");
|
|
38
|
+
|
|
39
|
+
const commit = groups.find((g) => g.name === "commit-push");
|
|
40
|
+
expect(commit?.identical).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("bundle resolves by domain + overlay membership", async () => {
|
|
44
|
+
const lib = await loadLibrary(FIXTURES);
|
|
45
|
+
const bio = await resolveBundle(lib, "bioinfo");
|
|
46
|
+
expect(bio.skills.map((s) => s.name)).toContain("rnaseq-qc");
|
|
47
|
+
expect(bio.skills.some((s) => s.retired)).toBe(false);
|
|
48
|
+
|
|
49
|
+
const gc = await resolveBundle(lib, "green-card");
|
|
50
|
+
const names = gc.skills.map((s) => s.name);
|
|
51
|
+
expect(names).toContain("eb1a-evidence");
|
|
52
|
+
expect(names).toContain("xhs-title"); // via overlay union
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("search ranks name match high", async () => {
|
|
56
|
+
const lib = await loadLibrary(FIXTURES);
|
|
57
|
+
const res = searchSkills(lib, "commit");
|
|
58
|
+
expect(res[0]?.name).toBe("commit-push");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("generateIndex groups by domain and lists retired", async () => {
|
|
62
|
+
const lib = await loadLibrary(FIXTURES);
|
|
63
|
+
const md = generateIndex(lib, { generatedAt: "FIXED" });
|
|
64
|
+
expect(md).toContain("## bioinfo");
|
|
65
|
+
expect(md).toContain("## _retired");
|
|
66
|
+
expect(md).toContain("old-deseq-helper");
|
|
67
|
+
});
|
|
68
|
+
});
|