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,309 @@
1
+ // API inference adapter — closes the loop automatically against any
2
+ // OpenAI-compatible chat/completions endpoint. The LLM-FREE core lives in
3
+ // agent.ts; this file is the ONLY place a network LLM call happens.
4
+ //
5
+ // Configuration (base URL, API key, model) resolves from, highest precedence
6
+ // first:
7
+ // 1. CLI flags (--base-url, --model; --provider for a base-URL preset)
8
+ // 2. Environment vars (SKILLSHELF_LLM_* , then OPENAI_* fallbacks)
9
+ // 3. Optional dotenv ($SKILLSHELF_ENV_FILE, default ./.env if present)
10
+ //
11
+ // Nothing here is provider-specific: any server that speaks the OpenAI
12
+ // chat/completions schema works (OpenAI, OpenRouter, Groq, Ollama, vLLM,
13
+ // a local gateway, …).
14
+
15
+ import { existsSync } from "node:fs";
16
+ import type { InferenceCorpus } from "../../types.ts";
17
+ import {
18
+ INFER_INSTRUCTION,
19
+ PROPOSAL_SCHEMA,
20
+ normalizeProposal,
21
+ type InferenceProposal,
22
+ } from "./agent.ts";
23
+
24
+ export interface ProviderConfig {
25
+ /** OpenAI-compatible base, including /v1 */
26
+ base: string;
27
+ /** bearer key */
28
+ apiKey: string;
29
+ /** chat model id */
30
+ model: string;
31
+ /** provider label */
32
+ name: string;
33
+ }
34
+
35
+ /** Sensible default endpoint + model when nothing else is configured. */
36
+ const DEFAULT_BASE = "https://api.openai.com/v1";
37
+ const DEFAULT_MODEL = "gpt-4o-mini"; // placeholder default; override with --model / *_MODEL
38
+
39
+ /**
40
+ * Generic public provider presets. These set ONLY a default base URL — the API
41
+ * key always comes from the environment (or dotenv). `custom` means "use
42
+ * --base-url / SKILLSHELF_LLM_BASE_URL". No private/institutional presets.
43
+ */
44
+ const PROVIDER_BASES: Record<string, string> = {
45
+ openai: "https://api.openai.com/v1",
46
+ openrouter: "https://openrouter.ai/api/v1",
47
+ groq: "https://api.groq.com/openai/v1",
48
+ ollama: "http://localhost:11434/v1",
49
+ custom: "", // resolved entirely from flags/env
50
+ };
51
+
52
+ export function knownProviders(): string[] {
53
+ return Object.keys(PROVIDER_BASES);
54
+ }
55
+
56
+ /**
57
+ * Parse `export KEY=value` / `KEY=value` lines from a dotenv file.
58
+ * Strips surrounding quotes; ignores comments. Never throws.
59
+ */
60
+ async function readEnvFile(file: string): Promise<Record<string, string>> {
61
+ const out: Record<string, string> = {};
62
+ if (!existsSync(file)) return out;
63
+ let text = "";
64
+ try {
65
+ text = await Bun.file(file).text();
66
+ } catch {
67
+ return out;
68
+ }
69
+ for (const lineRaw of text.split("\n")) {
70
+ const line = lineRaw.trim();
71
+ if (line === "" || line.startsWith("#")) continue;
72
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
73
+ if (!m) continue;
74
+ let val = m[2]!.trim();
75
+ if (
76
+ val.length >= 2 &&
77
+ ((val.startsWith('"') && val.endsWith('"')) ||
78
+ (val.startsWith("'") && val.endsWith("'")))
79
+ ) {
80
+ val = val.slice(1, -1);
81
+ }
82
+ out[m[1]!] = val;
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Resolve the dotenv path: explicit $SKILLSHELF_ENV_FILE, else ./.env if present. */
88
+ function resolveEnvFilePath(env: NodeJS.ProcessEnv, override?: string): string | null {
89
+ const explicit = override?.trim() || env.SKILLSHELF_ENV_FILE?.trim();
90
+ if (explicit) return explicit;
91
+ return existsSync(".env") ? ".env" : null;
92
+ }
93
+
94
+ /** First non-empty trimmed value, or "". */
95
+ function firstNonEmpty(...vals: (string | undefined)[]): string {
96
+ for (const v of vals) {
97
+ if (v && v.trim() !== "") return v.trim();
98
+ }
99
+ return "";
100
+ }
101
+
102
+ /**
103
+ * Resolve an LLM config for an OpenAI-compatible endpoint.
104
+ *
105
+ * Precedence (highest first): explicit opts (from CLI flags) > env vars
106
+ * (SKILLSHELF_LLM_* then OPENAI_*) > dotenv file. `opts.provider` only seeds a
107
+ * default base URL; the key is always resolved from env/dotenv. Returns an
108
+ * `error` string instead of throwing when no API key can be resolved.
109
+ */
110
+ export async function resolveProvider(
111
+ provider: string | undefined,
112
+ opts: {
113
+ base?: string;
114
+ model?: string;
115
+ env?: NodeJS.ProcessEnv;
116
+ envFile?: string;
117
+ } = {},
118
+ ): Promise<{ config: ProviderConfig } | { error: string }> {
119
+ const env = opts.env ?? process.env;
120
+
121
+ let presetBase = "";
122
+ let providerName = "custom";
123
+ if (provider !== undefined && provider !== "") {
124
+ if (!(provider in PROVIDER_BASES)) {
125
+ return {
126
+ error: `unknown provider "${provider}". known: ${knownProviders().join(", ")}`,
127
+ };
128
+ }
129
+ providerName = provider;
130
+ presetBase = PROVIDER_BASES[provider]!;
131
+ }
132
+
133
+ const filePath = resolveEnvFilePath(env, opts.envFile);
134
+ const fileEnv = filePath ? await readEnvFile(filePath) : {};
135
+
136
+ // base: flag > SKILLSHELF_LLM_BASE_URL > OPENAI_BASE_URL > provider preset > default
137
+ const base = firstNonEmpty(
138
+ opts.base,
139
+ env.SKILLSHELF_LLM_BASE_URL,
140
+ fileEnv.SKILLSHELF_LLM_BASE_URL,
141
+ env.OPENAI_BASE_URL,
142
+ fileEnv.OPENAI_BASE_URL,
143
+ presetBase,
144
+ DEFAULT_BASE,
145
+ );
146
+
147
+ // key: SKILLSHELF_LLM_API_KEY > OPENAI_API_KEY (env then dotenv)
148
+ const apiKey = firstNonEmpty(
149
+ env.SKILLSHELF_LLM_API_KEY,
150
+ fileEnv.SKILLSHELF_LLM_API_KEY,
151
+ env.OPENAI_API_KEY,
152
+ fileEnv.OPENAI_API_KEY,
153
+ );
154
+
155
+ // model: flag > SKILLSHELF_LLM_MODEL > OPENAI_MODEL > default
156
+ const model = firstNonEmpty(
157
+ opts.model,
158
+ env.SKILLSHELF_LLM_MODEL,
159
+ fileEnv.SKILLSHELF_LLM_MODEL,
160
+ env.OPENAI_MODEL,
161
+ fileEnv.OPENAI_MODEL,
162
+ DEFAULT_MODEL,
163
+ );
164
+
165
+ if (apiKey === "") {
166
+ return {
167
+ error:
168
+ "missing API key. Set SKILLSHELF_LLM_API_KEY (or OPENAI_API_KEY) in the " +
169
+ "environment or a dotenv file ($SKILLSHELF_ENV_FILE, default ./.env).",
170
+ };
171
+ }
172
+
173
+ return { config: { base, apiKey, model, name: providerName } };
174
+ }
175
+
176
+ /** Extract the first balanced JSON object from a possibly-fenced string. */
177
+ function extractJsonObject(text: string): string | null {
178
+ const trimmed = text.trim();
179
+ // strip ```json fences if present
180
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
181
+ const candidate = fenced ? fenced[1]!.trim() : trimmed;
182
+ const start = candidate.indexOf("{");
183
+ if (start === -1) return null;
184
+ let depth = 0;
185
+ let inStr = false;
186
+ let esc = false;
187
+ for (let i = start; i < candidate.length; i++) {
188
+ const c = candidate[i]!;
189
+ if (inStr) {
190
+ if (esc) esc = false;
191
+ else if (c === "\\") esc = true;
192
+ else if (c === '"') inStr = false;
193
+ continue;
194
+ }
195
+ if (c === '"') inStr = true;
196
+ else if (c === "{") depth++;
197
+ else if (c === "}") {
198
+ depth--;
199
+ if (depth === 0) return candidate.slice(start, i + 1);
200
+ }
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * POST the corpus to the endpoint and parse a strict-JSON proposal back.
207
+ * Requests JSON via response_format; falls back to brace-extraction if the
208
+ * model wraps the JSON in prose/fences.
209
+ */
210
+ export async function inferViaApi(
211
+ corpus: InferenceCorpus,
212
+ config: ProviderConfig,
213
+ opts: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
214
+ ): Promise<{ proposal: InferenceProposal } | { error: string }> {
215
+ const fetchImpl = opts.fetchImpl ?? fetch;
216
+ const userPayload = JSON.stringify({ schema: PROPOSAL_SCHEMA, corpus });
217
+ const body = {
218
+ model: config.model,
219
+ temperature: 0,
220
+ response_format: { type: "json_object" },
221
+ messages: [
222
+ {
223
+ role: "system",
224
+ content:
225
+ INFER_INSTRUCTION +
226
+ " Respond with ONLY the JSON object — no markdown, no commentary.",
227
+ },
228
+ { role: "user", content: userPayload },
229
+ ],
230
+ };
231
+
232
+ const controller = new AbortController();
233
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 120_000);
234
+ let res: Response;
235
+ try {
236
+ res = await fetchImpl(`${config.base.replace(/\/+$/, "")}/chat/completions`, {
237
+ method: "POST",
238
+ headers: {
239
+ "content-type": "application/json",
240
+ authorization: `Bearer ${config.apiKey}`,
241
+ },
242
+ body: JSON.stringify(body),
243
+ signal: controller.signal,
244
+ });
245
+ } catch (e) {
246
+ clearTimeout(timeout);
247
+ const msg = e instanceof Error ? e.message : String(e);
248
+ return { error: `LLM request failed: ${msg}` };
249
+ }
250
+ clearTimeout(timeout);
251
+
252
+ if (!res.ok) {
253
+ let detail = "";
254
+ try {
255
+ detail = (await res.text()).slice(0, 500);
256
+ } catch {
257
+ /* ignore */
258
+ }
259
+ return { error: `LLM endpoint returned ${res.status} ${res.statusText}: ${detail}` };
260
+ }
261
+
262
+ let data: unknown;
263
+ try {
264
+ data = await res.json();
265
+ } catch (e) {
266
+ const msg = e instanceof Error ? e.message : String(e);
267
+ return { error: `LLM response was not JSON: ${msg}` };
268
+ }
269
+
270
+ const content = extractContent(data);
271
+ if (content === null) {
272
+ return { error: "LLM response had no message content" };
273
+ }
274
+
275
+ let parsed: unknown;
276
+ try {
277
+ parsed = JSON.parse(content);
278
+ } catch {
279
+ const obj = extractJsonObject(content);
280
+ if (!obj) return { error: "could not parse JSON from LLM content" };
281
+ try {
282
+ parsed = JSON.parse(obj);
283
+ } catch (e) {
284
+ const msg = e instanceof Error ? e.message : String(e);
285
+ return { error: `could not parse extracted JSON: ${msg}` };
286
+ }
287
+ }
288
+
289
+ return { proposal: normalizeProposal(parsed) };
290
+ }
291
+
292
+ /** Pull `choices[0].message.content` from an OpenAI-compatible response. */
293
+ function extractContent(data: unknown): string | null {
294
+ if (!data || typeof data !== "object") return null;
295
+ const choices = (data as Record<string, unknown>).choices;
296
+ if (!Array.isArray(choices) || choices.length === 0) return null;
297
+ const first = choices[0] as Record<string, unknown>;
298
+ const message = first.message as Record<string, unknown> | undefined;
299
+ const content = message?.content;
300
+ if (typeof content === "string") return content;
301
+ // some endpoints return content as an array of parts
302
+ if (Array.isArray(content)) {
303
+ const text = content
304
+ .map((p) => (p && typeof p === "object" ? String((p as Record<string, unknown>).text ?? "") : ""))
305
+ .join("");
306
+ return text || null;
307
+ }
308
+ return null;
309
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bun
2
+ // skillshelf CLI entry / router.
3
+ //
4
+ // Responsibilities:
5
+ // - parse argv[0] as the subcommand (`skl <cmd> ...`)
6
+ // - build the execution Ctx once via config.loadContext()
7
+ // - dispatch to the matching command module's run(restArgv, ctx)
8
+ // - `skl` (no args) / `skl help` -> print help listing every command's meta
9
+ // - clean non-zero exit on unknown command
10
+ //
11
+ // Both `bun run src/cli.ts <cmd>` and the installed bin `skl <cmd>` route here
12
+ // (package.json bin -> ./src/cli.ts).
13
+
14
+ import type { CommandModule, Ctx } from "./types.ts";
15
+ import { loadContext } from "./config.ts";
16
+
17
+ import * as search from "./commands/search.ts";
18
+ import * as ls from "./commands/ls.ts";
19
+ import * as status from "./commands/status.ts";
20
+ import * as show from "./commands/show.ts";
21
+ import * as index from "./commands/index.ts";
22
+ import * as use from "./commands/use.ts";
23
+ import * as drop from "./commands/drop.ts";
24
+ import * as init from "./commands/init.ts";
25
+ import * as add from "./commands/add.ts";
26
+ import * as outdated from "./commands/outdated.ts";
27
+ import * as update from "./commands/update.ts";
28
+ import * as infer from "./commands/infer.ts";
29
+ import * as newCmd from "./commands/new.ts";
30
+
31
+ // Registration order = display order in help.
32
+ const MODULES: CommandModule[] = [
33
+ search,
34
+ ls,
35
+ status,
36
+ show,
37
+ use,
38
+ drop,
39
+ add,
40
+ outdated,
41
+ update,
42
+ init,
43
+ newCmd,
44
+ index,
45
+ infer,
46
+ ];
47
+
48
+ const COMMANDS = new Map<string, CommandModule>();
49
+ for (const mod of MODULES) {
50
+ COMMANDS.set(mod.meta.name, mod);
51
+ }
52
+
53
+ function helpText(): string {
54
+ const lines: string[] = [];
55
+ lines.push("skl — skillshelf: agent-first skill registry + manager");
56
+ lines.push("");
57
+ lines.push("Usage: skl <command> [args] [--json]");
58
+ lines.push("");
59
+ lines.push("Commands:");
60
+ const width = Math.max(...MODULES.map((m) => m.meta.name.length));
61
+ for (const mod of MODULES) {
62
+ lines.push(` ${mod.meta.name.padEnd(width)} ${mod.meta.summary}`);
63
+ }
64
+ lines.push("");
65
+ lines.push("Run `skl help <command>` for command-specific usage.");
66
+ return lines.join("\n");
67
+ }
68
+
69
+ function commandHelp(mod: CommandModule): string {
70
+ return [`${mod.meta.name} — ${mod.meta.summary}`, `Usage: ${mod.meta.usage}`].join("\n");
71
+ }
72
+
73
+ async function main(rawArgv: string[]): Promise<number> {
74
+ // No subcommand -> help.
75
+ const first = rawArgv[0];
76
+ if (first === undefined || first === "--help" || first === "-h") {
77
+ console.log(helpText());
78
+ return 0;
79
+ }
80
+
81
+ if (first === "help") {
82
+ const target = rawArgv[1];
83
+ if (target !== undefined) {
84
+ const mod = COMMANDS.get(target);
85
+ if (mod) {
86
+ console.log(commandHelp(mod));
87
+ return 0;
88
+ }
89
+ console.error(`Unknown command: ${target}`);
90
+ console.error("");
91
+ console.error(helpText());
92
+ return 1;
93
+ }
94
+ console.log(helpText());
95
+ return 0;
96
+ }
97
+
98
+ const mod = COMMANDS.get(first);
99
+ if (!mod) {
100
+ console.error(`Unknown command: ${first}`);
101
+ console.error("");
102
+ console.error(helpText());
103
+ return 1;
104
+ }
105
+
106
+ let ctx: Ctx;
107
+ try {
108
+ ctx = await loadContext();
109
+ } catch (err) {
110
+ console.error(`Failed to initialize skillshelf: ${err instanceof Error ? err.message : String(err)}`);
111
+ return 1;
112
+ }
113
+
114
+ // argv passed to the command = everything AFTER the subcommand.
115
+ const rest = rawArgv.slice(1);
116
+ try {
117
+ const code = await mod.run(rest, ctx);
118
+ return typeof code === "number" ? code : 0;
119
+ } catch (err) {
120
+ // Commands are contracted not to throw, but guard the router regardless.
121
+ ctx.error(`skl ${first}: ${err instanceof Error ? err.message : String(err)}`);
122
+ return 1;
123
+ }
124
+ }
125
+
126
+ const code = await main(process.argv.slice(2));
127
+ process.exit(code);
@@ -0,0 +1,222 @@
1
+ // skl add <src> — install a third-party skill into the library.
2
+ //
3
+ // Flow:
4
+ // 1. parse <src> (github:owner/repo[/path] or a bare registry name)
5
+ // 2. shell out (git / `skills`) to DOWNLOAD only — never reinvent fetching
6
+ // 3. copy the skill dir into the library under its primary-domain folder
7
+ // 4. write a provenance lockfile entry (source + ref + channel + installedAt)
8
+ // 5. create an empty overlay (<name>.shelf.json) so taxonomy survives updates
9
+ // 6. call the inference tagging hook if one is available, else leave untagged
10
+ //
11
+ // Read-only commands take --json; add is a write, but still emits a --json
12
+ // summary on success for agent consumption.
13
+
14
+ import { join, basename } from "node:path";
15
+ import { existsSync } from "node:fs";
16
+ import type { Ctx, Skill, LockEntry } from "../types.ts";
17
+ import {
18
+ parseSource,
19
+ fetchSource,
20
+ copySkillDir,
21
+ cleanupStaging,
22
+ readSkillBody,
23
+ } from "../core/fetch.ts";
24
+ import { parseFrontmatter } from "../lib/frontmatter.ts";
25
+ import { hashContent } from "../core/crawl.ts";
26
+ import { recordEntry } from "../core/provenance.ts";
27
+ import { writeOverlay } from "../core/overlay.ts";
28
+ import { ensureDir } from "../lib/fs.ts";
29
+
30
+ export const meta = {
31
+ name: "add",
32
+ summary: "Install a third-party skill (github:/registry), record provenance, tag",
33
+ usage: "skl add <src> [--domain <d>] [--name <slug>] [--no-infer] [--force] [--json]",
34
+ } as const;
35
+
36
+ interface Flags {
37
+ json: boolean;
38
+ domain: string | null;
39
+ name: string | null;
40
+ infer: boolean;
41
+ force: boolean;
42
+ src: string | null;
43
+ }
44
+
45
+ function parseFlags(argv: string[]): Flags {
46
+ const f: Flags = { json: false, domain: null, name: null, infer: true, force: false, src: null };
47
+ for (let i = 0; i < argv.length; i++) {
48
+ const a = argv[i]!;
49
+ if (a === "--json") f.json = true;
50
+ else if (a === "--no-infer") f.infer = false;
51
+ else if (a === "--force") f.force = true;
52
+ else if (a === "--domain") f.domain = argv[++i] ?? null;
53
+ else if (a === "--name") f.name = argv[++i] ?? null;
54
+ else if (a === "--domain=" || a.startsWith("--domain=")) f.domain = a.slice("--domain=".length);
55
+ else if (a.startsWith("--name=")) f.name = a.slice("--name=".length);
56
+ else if (!a.startsWith("-") && f.src === null) f.src = a;
57
+ }
58
+ return f;
59
+ }
60
+
61
+ /** Slug from frontmatter `name`, else the source dir name. */
62
+ async function deriveName(skillDir: string, override: string | null): Promise<string> {
63
+ if (override && override.trim() !== "") return override.trim();
64
+ const body = await readSkillBody(skillDir);
65
+ const { data } = parseFrontmatter(body);
66
+ if (typeof data.name === "string" && data.name.trim() !== "") return data.name.trim();
67
+ return basename(skillDir);
68
+ }
69
+
70
+ /**
71
+ * Optionally run an AI inference tagging pass over the freshly-installed skill.
72
+ *
73
+ * The taxonomy pass (`skl infer`) is corpus-based and lives in the inference
74
+ * adapters; there is no committed single-skill tagging hook in the public API.
75
+ * To stay decoupled (and to leave the skill *untagged* rather than fail when no
76
+ * hook is present), we look for an OPTIONAL convention module that may export a
77
+ * `tagSkill(skill) => string[]`. The specifier is built at runtime so a missing
78
+ * module degrades gracefully instead of becoming a static import error.
79
+ *
80
+ * On any failure the skill stays untagged (empty overlay) — a valid state.
81
+ * Returns the domains written, if any.
82
+ */
83
+ async function maybeInferTags(skill: Skill): Promise<string[] | null> {
84
+ const candidates = ["../core/infer.ts", "../adapters/inference/tag.ts"];
85
+ for (const rel of candidates) {
86
+ try {
87
+ // Non-literal specifier: keeps a missing optional module from becoming a
88
+ // static import/resolution error; degrades to "untagged" at runtime.
89
+ const spec: string = rel;
90
+ const mod: unknown = await import(spec).catch(() => null);
91
+ if (!mod || typeof mod !== "object") continue;
92
+ const hook = (mod as Record<string, unknown>).tagSkill;
93
+ if (typeof hook !== "function") continue;
94
+ const result = await (hook as (s: Skill) => Promise<string[] | null>)(skill);
95
+ if (Array.isArray(result)) {
96
+ return result.filter((d) => typeof d === "string" && d.trim() !== "");
97
+ }
98
+ return null;
99
+ } catch {
100
+ /* try next candidate / leave untagged */
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
107
+ const flags = parseFlags(argv);
108
+ if (!flags.src) {
109
+ ctx.error("usage:", meta.usage);
110
+ return 1;
111
+ }
112
+
113
+ const parsed = parseSource(flags.src);
114
+
115
+ // 1+2. DOWNLOAD into a staging dir (shell out only).
116
+ const fetched = await fetchSource(parsed);
117
+ if (!fetched.ok) {
118
+ await cleanupStaging(fetched.staging);
119
+ ctx.error("add: download failed:", fetched.error);
120
+ return 1;
121
+ }
122
+
123
+ try {
124
+ // 3. Determine destination in the library.
125
+ const name = await deriveName(fetched.skillDir, flags.name);
126
+ const domainFolder = flags.domain && flags.domain.trim() !== "" ? flags.domain.trim() : null;
127
+ const destDir = domainFolder
128
+ ? join(ctx.config.libraryPath, domainFolder, name)
129
+ : join(ctx.config.libraryPath, name);
130
+
131
+ if (existsSync(destDir) && !flags.force) {
132
+ ctx.error(
133
+ `add: ${name} already exists at ${destDir} (use --force to overwrite, or skl update ${name} to re-pull)`,
134
+ );
135
+ return 1;
136
+ }
137
+
138
+ await ensureDir(domainFolder ? join(ctx.config.libraryPath, domainFolder) : ctx.config.libraryPath);
139
+ await copySkillDir(fetched.skillDir, destDir);
140
+
141
+ // 4. Provenance lockfile entry. Record the installed body hash so a later
142
+ // `skl update` can tell a user hand-edit apart from upstream moving forward.
143
+ const installedBody = parseFrontmatter(await readSkillBody(fetched.skillDir)).body;
144
+ // git: sources already encode their subpath as `#subpath` in fetched.source;
145
+ // only github sources use the `@subpath` suffix convention here.
146
+ const subSuffix = parsed.subpath && parsed.channel !== "git" ? `@${parsed.subpath}` : "";
147
+ const entry: LockEntry = {
148
+ name,
149
+ source: `${fetched.source}${subSuffix}`,
150
+ ref: fetched.ref,
151
+ channel: fetched.channel,
152
+ installedAt: new Date().toISOString(),
153
+ localEdits: false,
154
+ installedHash: hashContent(installedBody),
155
+ };
156
+ await recordEntry(ctx.config.libraryPath, entry);
157
+
158
+ // 5. Empty overlay so taxonomy survives future updates.
159
+ const installed: Skill = {
160
+ name,
161
+ description: "",
162
+ primaryDomain: domainFolder,
163
+ domains: domainFolder ? [domainFolder] : [],
164
+ path: destDir,
165
+ bodyPath: join(destDir, "SKILL.md"),
166
+ refFiles: [],
167
+ source: {
168
+ source: entry.source,
169
+ ref: entry.ref,
170
+ channel: entry.channel,
171
+ installedAt: entry.installedAt,
172
+ localEdits: false,
173
+ },
174
+ retired: false,
175
+ mirrorOf: null,
176
+ contentHash: "",
177
+ };
178
+ const overlayPathStr = join(destDir, `${name}.shelf.json`);
179
+ if (!existsSync(overlayPathStr)) {
180
+ await writeOverlay(installed, domainFolder ? { domains: [domainFolder] } : {});
181
+ }
182
+
183
+ // 6. Inference tagging hook (best-effort, leaves untagged if unavailable).
184
+ let inferredDomains: string[] | null = null;
185
+ if (flags.infer) {
186
+ inferredDomains = await maybeInferTags(installed);
187
+ if (inferredDomains && inferredDomains.length > 0) {
188
+ await writeOverlay(installed, { domains: inferredDomains });
189
+ }
190
+ }
191
+
192
+ const summary = {
193
+ ok: true,
194
+ name,
195
+ path: destDir,
196
+ source: entry.source,
197
+ ref: entry.ref,
198
+ channel: entry.channel,
199
+ installedAt: entry.installedAt,
200
+ tagged: Boolean(inferredDomains && inferredDomains.length > 0),
201
+ domains: inferredDomains ?? (domainFolder ? [domainFolder] : []),
202
+ };
203
+
204
+ if (flags.json) {
205
+ ctx.json(summary);
206
+ } else {
207
+ ctx.log(`added ${name}`);
208
+ ctx.log(` path: ${destDir}`);
209
+ ctx.log(` source: ${entry.source}`);
210
+ ctx.log(` ref: ${entry.ref || "(unknown)"}`);
211
+ ctx.log(` channel: ${entry.channel}`);
212
+ if (summary.tagged) ctx.log(` domains: ${summary.domains.join(", ")}`);
213
+ else ctx.log(` domains: (untagged — run \`skl infer\` to assign)`);
214
+ }
215
+ return 0;
216
+ } catch (err) {
217
+ ctx.error("add: failed:", err instanceof Error ? err.message : String(err));
218
+ return 1;
219
+ } finally {
220
+ await cleanupStaging(fetched.staging);
221
+ }
222
+ }