skillshelf 0.2.0 → 0.4.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.
Files changed (67) hide show
  1. package/README.md +83 -20
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +39 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/adopted.test.ts +144 -0
  7. package/src/commands/agents-config.test.ts +126 -0
  8. package/src/commands/agents.test.ts +96 -0
  9. package/src/commands/agents.ts +243 -0
  10. package/src/commands/drop.ts +21 -13
  11. package/src/commands/import.ts +44 -28
  12. package/src/commands/infer.ts +6 -6
  13. package/src/commands/link.test.ts +160 -0
  14. package/src/commands/link.ts +317 -0
  15. package/src/commands/ls.ts +136 -19
  16. package/src/commands/migrate.test.ts +157 -0
  17. package/src/commands/migrate.ts +260 -0
  18. package/src/commands/mode-surfacing.test.ts +110 -0
  19. package/src/commands/outdated.test.ts +55 -0
  20. package/src/commands/outdated.ts +166 -18
  21. package/src/commands/projects.test.ts +85 -0
  22. package/src/commands/projects.ts +80 -0
  23. package/src/commands/refresh.ts +133 -0
  24. package/src/commands/remediation.test.ts +149 -0
  25. package/src/commands/rename.test.ts +121 -0
  26. package/src/commands/rename.ts +64 -0
  27. package/src/commands/retag.ts +58 -0
  28. package/src/commands/retire.ts +39 -0
  29. package/src/commands/rm.test.ts +133 -0
  30. package/src/commands/rm.ts +107 -0
  31. package/src/commands/roots.ts +41 -0
  32. package/src/commands/scan.ts +122 -30
  33. package/src/commands/show.ts +130 -11
  34. package/src/commands/status.ts +43 -8
  35. package/src/commands/tag.test.ts +109 -0
  36. package/src/commands/tag.ts +68 -0
  37. package/src/commands/track.test.ts +170 -0
  38. package/src/commands/track.ts +340 -0
  39. package/src/commands/unretire.ts +33 -0
  40. package/src/commands/untag.ts +73 -0
  41. package/src/commands/untrack.ts +44 -0
  42. package/src/commands/update.test.ts +71 -0
  43. package/src/commands/update.ts +157 -15
  44. package/src/commands/use.test.ts +122 -0
  45. package/src/commands/use.ts +46 -23
  46. package/src/commands/where.ts +232 -0
  47. package/src/config.test.ts +198 -0
  48. package/src/config.ts +232 -10
  49. package/src/core/agents.test.ts +319 -0
  50. package/src/core/agents.ts +438 -0
  51. package/src/core/bundle.ts +12 -15
  52. package/src/core/core.test.ts +21 -8
  53. package/src/core/crawl.ts +22 -5
  54. package/src/core/dedupe.ts +36 -0
  55. package/src/core/deployments.test.ts +147 -0
  56. package/src/core/deployments.ts +208 -0
  57. package/src/core/fetch.ts +371 -75
  58. package/src/core/indexgen.ts +2 -0
  59. package/src/core/library.test.ts +41 -0
  60. package/src/core/library.ts +61 -16
  61. package/src/core/lifecycle.ts +252 -0
  62. package/src/core/surfaces.ts +46 -0
  63. package/src/core/taxonomy.test.ts +159 -0
  64. package/src/core/taxonomy.ts +190 -0
  65. package/src/lib/fs.ts +2 -2
  66. package/src/types.ts +155 -15
  67. package/src/core/overlay.ts +0 -63
@@ -19,26 +19,26 @@
19
19
  // --force overwrite an existing same-named library skill
20
20
  //
21
21
  // Provenance: these are the user's OWN skills, not third-party — source is null and
22
- // NO lockfile entry is written (that is `add`'s job). We still create an EMPTY
23
- // overlay (<name>.shelf.json) so taxonomy can be applied later without clobbering
24
- // the upstream SKILL.md.
22
+ // NO lockfile entry is written (that is `add`'s job). Import is purely mechanical
23
+ // (move + symlink-back, or --copy); domain tags are applied later by `skl infer`
24
+ // into the central <library>/taxonomy.json, which never touches the SKILL.md body.
25
25
 
26
26
  import { join, basename, resolve } from "node:path";
27
27
  import { existsSync } from "node:fs";
28
28
  import { rename, cp, rm } from "node:fs/promises";
29
- import type { Ctx, Skill } from "../types.ts";
30
- import { writeOverlay } from "../core/overlay.ts";
29
+ import type { Ctx } from "../types.ts";
31
30
  import {
32
31
  ensureDir,
33
32
  safeSymlink,
34
33
  isDirectory,
34
+ isSymlink,
35
35
  realpathOrSelfAsync,
36
36
  } from "../lib/fs.ts";
37
37
 
38
38
  export const meta = {
39
39
  name: "import",
40
40
  summary: "Adopt your own skill into the library (move + symlink-back, or --copy)",
41
- usage: "skl import <name> --from <path> [--copy | --no-link-back] [--as <slug>] [--force] [--json]",
41
+ usage: "skl import <name> --from <path> [--copy | --no-link-back] [--follow] [--as <slug>] [--force] [--json]",
42
42
  } as const;
43
43
 
44
44
  interface Flags {
@@ -47,6 +47,7 @@ interface Flags {
47
47
  as: string | null;
48
48
  copy: boolean;
49
49
  noLinkBack: boolean;
50
+ follow: boolean;
50
51
  force: boolean;
51
52
  json: boolean;
52
53
  }
@@ -60,6 +61,7 @@ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
60
61
  as: null,
61
62
  copy: false,
62
63
  noLinkBack: false,
64
+ follow: false,
63
65
  force: false,
64
66
  json: false,
65
67
  };
@@ -81,6 +83,8 @@ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
81
83
  flags.copy = true;
82
84
  } else if (a === "--no-link-back" || a === "--no-link") {
83
85
  flags.noLinkBack = true;
86
+ } else if (a === "--follow" || a === "--deref") {
87
+ flags.follow = true;
84
88
  } else if (a === "--force") {
85
89
  flags.force = true;
86
90
  } else if (a === "--json") {
@@ -141,6 +145,12 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
141
145
  );
142
146
  return 1;
143
147
  }
148
+ if (flags.follow && flags.noLinkBack) {
149
+ ctx.error(
150
+ "skl import: --follow copies the dereferenced target; it cannot be combined with --no-link-back (a move option)",
151
+ );
152
+ return 1;
153
+ }
144
154
 
145
155
  // The library name is --as if given, else <name>.
146
156
  const targetName = (flags.as ?? flags.name).trim();
@@ -171,6 +181,23 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
171
181
  return 1;
172
182
  }
173
183
 
184
+ // Symlink safety (option b): a symlinked source dir is refused unless --follow.
185
+ // Without it, a move would `rename` the LINK (the library would point back at the
186
+ // target and own no real copy) and a copy would copy the link itself. With
187
+ // --follow we dereference to the real target and COPY its contents (below).
188
+ const linkSource = isSymlink(fromPath);
189
+ if (linkSource && !flags.follow) {
190
+ const tgt = await realpathOrSelfAsync(fromPath);
191
+ ctx.error(`skl import: --from is a symlink (${fromPath} -> ${tgt}).`);
192
+ ctx.error(
193
+ "Refusing to import a symlink source: a move would relocate the link, not the content.",
194
+ );
195
+ ctx.error(
196
+ "Re-run with --follow (alias --deref) to dereference and copy the target's real contents into the library.",
197
+ );
198
+ return 1;
199
+ }
200
+
174
201
  const libraryPath = ctx.config.libraryPath;
175
202
  // Flat, non-semantic layout (ADR-0001): always <library>/<name>/.
176
203
  const destDir = join(libraryPath, targetName);
@@ -202,12 +229,15 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
202
229
  await rm(destDir, { recursive: true, force: true });
203
230
  }
204
231
 
205
- const mode: "move" | "copy" = flags.copy ? "copy" : "move";
232
+ // A symlinked source with --follow: copy the dereferenced TARGET's contents
233
+ // (never move — that would relocate the canonical store the link points at).
234
+ const srcDir = linkSource ? await realpathOrSelfAsync(fromPath) : fromPath;
235
+ const mode: "move" | "copy" = linkSource ? "copy" : flags.copy ? "copy" : "move";
206
236
  let linkedBack = false;
207
237
 
208
238
  if (mode === "copy") {
209
239
  // Copy into the library; leave the original untouched (no symlink-back).
210
- await cp(fromPath, destDir, {
240
+ await cp(srcDir, destDir, {
211
241
  recursive: true,
212
242
  force: true,
213
243
  filter: (s: string) => basename(s) !== ".git",
@@ -233,26 +263,10 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
233
263
  }
234
264
  }
235
265
 
236
- // Empty overlay so taxonomy (domains/bundles) can be applied later via
237
- // `skl infer` without touching the upstream SKILL.md. These are the user's own
238
- // skills: source/provenance is null and NO lockfile entry is written.
239
- const imported: Skill = {
240
- name: targetName,
241
- description: "",
242
- primaryDomain: null,
243
- domains: [],
244
- path: destDir,
245
- bodyPath: join(destDir, "SKILL.md"),
246
- refFiles: [],
247
- source: null,
248
- retired: false,
249
- mirrorOf: null,
250
- contentHash: "",
251
- };
252
- const overlayPathStr = join(destDir, `${targetName}.shelf.json`);
253
- if (!existsSync(overlayPathStr)) {
254
- await writeOverlay(imported, {});
255
- }
266
+ // Import is mechanical: no domain is decided here. Domain tags are applied
267
+ // later via `skl infer` into the central <library>/taxonomy.json never into
268
+ // the upstream SKILL.md. These are the user's own skills: source/provenance is
269
+ // null and NO lockfile entry is written.
256
270
 
257
271
  const summary = {
258
272
  ok: true,
@@ -261,6 +275,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
261
275
  to: destDir,
262
276
  mode,
263
277
  linkedBack,
278
+ followed: linkSource,
264
279
  };
265
280
 
266
281
  if (flags.json) {
@@ -268,6 +283,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
268
283
  } else {
269
284
  ctx.log(`imported ${targetName}`);
270
285
  ctx.log(` from: ${fromPath}`);
286
+ if (linkSource) ctx.log(` follow: ${fromPath} -> ${srcDir} (copied target contents)`);
271
287
  ctx.log(` to: ${destDir}`);
272
288
  ctx.log(` mode: ${mode}`);
273
289
  if (linkedBack) ctx.log(` link: ${fromPath} -> ${destDir} (old path still resolves)`);
@@ -3,8 +3,8 @@
3
3
  // Dual-mode, LLM-FREE core:
4
4
  // skl infer --emit print {instruction, schema, corpus} for a
5
5
  // host agent to reason over (no LLM call here).
6
- // skl infer --apply <file.json> write the agent's proposal into each skill's
7
- // <name>.shelf.json overlay (never upstream).
6
+ // skl infer --apply <file.json> write the agent's proposal into the central
7
+ // taxonomy.json (never upstream SKILL.md).
8
8
  // skl infer --provider openai API mode: POST the corpus to an
9
9
  // OpenAI-compatible endpoint and apply the
10
10
  // strict-JSON result automatically.
@@ -164,7 +164,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
164
164
  ctx.error("skl infer: proposal contained no assignments");
165
165
  return 1;
166
166
  }
167
- const result = await applyProposal(skills, proposal);
167
+ const result = await applyProposal(ctx.libraryPath, skills, proposal);
168
168
  return reportApply(result, args.json, ctx);
169
169
  }
170
170
 
@@ -193,7 +193,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
193
193
  ctx.error("skl infer: gateway returned no assignments");
194
194
  return 1;
195
195
  }
196
- const result = await applyProposal(skills, inferred.proposal);
196
+ const result = await applyProposal(ctx.libraryPath, skills, inferred.proposal);
197
197
  return reportApply(result, args.json, ctx, prov.config.name, prov.config.model);
198
198
  }
199
199
 
@@ -227,7 +227,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
227
227
  ctx.error(
228
228
  "skl infer: no inference mode available. Provide one of:\n" +
229
229
  " --emit print corpus for a host agent to reason over\n" +
230
- " --apply <file.json> apply an agent proposal into overlays\n" +
230
+ " --apply <file.json> apply an agent proposal into taxonomy.json\n" +
231
231
  ` --provider <name> call an OpenAI-compatible endpoint (${knownProviders().join(", ")})\n` +
232
232
  " --base-url <url> call a custom OpenAI-compatible endpoint\n" +
233
233
  "(auto-emit only activates inside a Claude Code agent context.)",
@@ -268,7 +268,7 @@ function reportApply(
268
268
  ctx.log(` ${a.name}: ${a.domains.join(", ")}${addedNote}`);
269
269
  }
270
270
  ctx.log(
271
- `Applied ${result.applied.length} overlay update${
271
+ `Applied ${result.applied.length} taxonomy update${
272
272
  result.applied.length === 1 ? "" : "s"
273
273
  }.`,
274
274
  );
@@ -0,0 +1,160 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, readFile, rm, lstat, readlink, realpath } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { run } from "./link.ts";
7
+ import type { Ctx } from "../types.ts";
8
+
9
+ const BODY = "---\nname: claim-log\ndescription: a test skill\n---\n\nbody\n";
10
+
11
+ async function makeSkillDir(parent: string, name: string, body = BODY): Promise<string> {
12
+ const dir = join(parent, name);
13
+ await mkdir(dir, { recursive: true });
14
+ await writeFile(join(dir, "SKILL.md"), body);
15
+ return dir;
16
+ }
17
+
18
+ interface Captured {
19
+ ctx: Ctx;
20
+ logs: string[];
21
+ errors: string[];
22
+ json: unknown[];
23
+ }
24
+
25
+ /** Minimal Ctx mock — link.run only reads config.libraryPath + log/error/json. */
26
+ function makeCtx(libraryPath: string): Captured {
27
+ const logs: string[] = [];
28
+ const errors: string[] = [];
29
+ const json: unknown[] = [];
30
+ const ctx = {
31
+ config: { libraryPath },
32
+ libraryPath,
33
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
34
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
35
+ json: (v: unknown) => json.push(v),
36
+ } as unknown as Ctx;
37
+ return { ctx, logs, errors, json };
38
+ }
39
+
40
+ describe("skl link --from (LINKED mode)", () => {
41
+ let tmp: string;
42
+ let library: string;
43
+ let devRepo: string;
44
+
45
+ beforeEach(async () => {
46
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-link-")));
47
+ library = join(tmp, "library");
48
+ devRepo = join(tmp, "dev");
49
+ await mkdir(library, { recursive: true });
50
+ await mkdir(devRepo, { recursive: true });
51
+ });
52
+ afterEach(async () => {
53
+ await rm(tmp, { recursive: true, force: true });
54
+ });
55
+
56
+ test("registers a dev-repo skill as a library symlink", async () => {
57
+ const src = await makeSkillDir(devRepo, "claim-log");
58
+ const { ctx, json } = makeCtx(library);
59
+ const code = await run(["claim-log", "--from", src, "--json"], ctx);
60
+
61
+ expect(code).toBe(0);
62
+ const libEntry = join(library, "claim-log");
63
+ const st = await lstat(libEntry);
64
+ expect(st.isSymbolicLink()).toBe(true);
65
+ expect(await realpath(libEntry)).toBe(await realpath(src));
66
+ expect(json[0]).toMatchObject({ ok: true, name: "claim-log", mode: "linked", discarded: false });
67
+ });
68
+
69
+ test("derives the name from the dev-repo dir basename when omitted", async () => {
70
+ const src = await makeSkillDir(devRepo, "claim-log");
71
+ const { ctx } = makeCtx(library);
72
+ const code = await run(["--from", src], ctx);
73
+
74
+ expect(code).toBe(0);
75
+ const libEntry = join(library, "claim-log");
76
+ expect((await lstat(libEntry)).isSymbolicLink()).toBe(true);
77
+ expect(await realpath(libEntry)).toBe(await realpath(src));
78
+ });
79
+
80
+ test("is idempotent — re-running reports 'already'", async () => {
81
+ const src = await makeSkillDir(devRepo, "claim-log");
82
+ const { ctx } = makeCtx(library);
83
+ await run(["claim-log", "--from", src], ctx);
84
+
85
+ const { ctx: ctx2, json } = makeCtx(library);
86
+ const code = await run(["claim-log", "--from", src, "--json"], ctx2);
87
+ expect(code).toBe(0);
88
+ expect(json[0]).toMatchObject({ status: "already", mode: "linked" });
89
+ });
90
+
91
+ test("refuses to clobber an existing owned library copy without --force", async () => {
92
+ await makeSkillDir(library, "claim-log"); // a real OWNED copy already in the library
93
+ const src = await makeSkillDir(devRepo, "claim-log");
94
+ const { ctx, errors } = makeCtx(library);
95
+
96
+ const code = await run(["claim-log", "--from", src], ctx);
97
+ expect(code).toBe(1);
98
+ expect(errors.join("\n")).toContain("already exists in the library");
99
+ // unchanged: still a real dir, not a symlink
100
+ expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(false);
101
+ });
102
+
103
+ test("--force replaces an owned copy with the symlink and reports discarded", async () => {
104
+ await makeSkillDir(library, "claim-log");
105
+ const src = await makeSkillDir(devRepo, "claim-log");
106
+ const { ctx, json } = makeCtx(library);
107
+
108
+ const code = await run(["claim-log", "--from", src, "--force", "--json"], ctx);
109
+ expect(code).toBe(0);
110
+ expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(true);
111
+ expect(json[0]).toMatchObject({ discarded: true, mode: "linked" });
112
+ });
113
+
114
+ test("drops a stale lockfile entry so update/outdated skip the now-LINKED skill", async () => {
115
+ // An owned import existed (real copy + a github lock entry); now convert to LINKED.
116
+ await makeSkillDir(library, "claim-log");
117
+ await writeFile(
118
+ join(library, "shelf.lock.json"),
119
+ JSON.stringify({
120
+ version: 1,
121
+ entries: {
122
+ "claim-log": { name: "claim-log", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
123
+ },
124
+ }),
125
+ );
126
+ const src = await makeSkillDir(devRepo, "claim-log");
127
+ const { ctx } = makeCtx(library);
128
+
129
+ const code = await run(["claim-log", "--from", src, "--force"], ctx);
130
+ expect(code).toBe(0);
131
+ const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
132
+ expect(lock.entries["claim-log"]).toBeUndefined();
133
+ });
134
+
135
+ test("rejects --at and --from together", async () => {
136
+ const src = await makeSkillDir(devRepo, "claim-log");
137
+ const { ctx, errors } = makeCtx(library);
138
+ const code = await run(["claim-log", "--from", src, "--at", "/tmp/x"], ctx);
139
+ expect(code).toBe(1);
140
+ expect(errors.join("\n")).toContain("mutually exclusive");
141
+ });
142
+
143
+ test("refuses a --from source inside the library", async () => {
144
+ const inside = await makeSkillDir(library, "claim-log");
145
+ const { ctx, errors } = makeCtx(library);
146
+ const code = await run(["other", "--from", inside], ctx);
147
+ expect(code).toBe(1);
148
+ expect(errors.join("\n")).toContain("inside the library");
149
+ });
150
+
151
+ test("refuses a --from dir with no SKILL.md", async () => {
152
+ const bare = join(devRepo, "bare");
153
+ await mkdir(bare, { recursive: true });
154
+ const { ctx, errors } = makeCtx(library);
155
+ const code = await run(["bare", "--from", bare], ctx);
156
+ expect(code).toBe(1);
157
+ expect(errors.join("\n")).toContain("no SKILL.md");
158
+ expect(existsSync(join(library, "bare"))).toBe(false);
159
+ });
160
+ });
@@ -0,0 +1,317 @@
1
+ // `skl link` — manage the symlink relationship between the library and on-disk copies.
2
+ //
3
+ // Two modes (the bookshelf model, ADR-0004):
4
+ //
5
+ // skl link <name> --at <path> OWNED side. The library already owns <name>; replace
6
+ // some other on-disk copy at <path> with a symlink INTO
7
+ // the library — fulfilling the one-canonical-copy rule for
8
+ // locations that were never consolidated (e.g. an old
9
+ // `.claude/skills/<name>` duplicate).
10
+ //
11
+ // skl link [<name>] --from <dev-repo> LINKED side. Register an external dev-repo skill as a
12
+ // library entry: make <library>/<name> a symlink pointing
13
+ // AT the dev repo, which stays canonical. The inverse of
14
+ // --at — the library shelves a reference instead of owning
15
+ // the bytes (for skills you actively develop in their own
16
+ // git repo). Name defaults to the dev-repo dir's basename.
17
+ //
18
+ // --force --at: replace even if <path>'s body differs from the library copy (the divergent
19
+ // copy is DISCARDED). Without it, a content mismatch is refused — pick a
20
+ // winner: keep library (this, with --force) or make <path> canonical
21
+ // (`skl import <name> --from <path> --force`).
22
+ // --from: replace an existing library entry (its current contents are DISCARDED).
23
+ // --json machine-readable summary.
24
+ //
25
+ // Safety: --at never touches the library copy and refuses paths inside the library; --from
26
+ // refuses a source inside the library; both verify the resulting symlink resolves as intended and
27
+ // are idempotent when the link already points where intended.
28
+
29
+ import { join, resolve, basename } from "node:path";
30
+ import { existsSync } from "node:fs";
31
+ import { rm } from "node:fs/promises";
32
+ import { createHash } from "node:crypto";
33
+ import type { Ctx } from "../types.ts";
34
+ import { parseFrontmatter } from "../lib/frontmatter.ts";
35
+ import { removeEntry } from "../core/provenance.ts";
36
+ import {
37
+ isDirectory,
38
+ isSymlink,
39
+ safeSymlink,
40
+ realpathOrSelfAsync,
41
+ } from "../lib/fs.ts";
42
+
43
+ export const meta = {
44
+ name: "link",
45
+ summary: "Link a skill to the library: collapse a copy (--at) or shelve a dev repo (--from)",
46
+ usage: "skl link <name> --at <path> | skl link [<name>] --from <dev-repo> [--force] [--json]",
47
+ } as const;
48
+
49
+ interface Flags {
50
+ name: string | null;
51
+ at: string | null;
52
+ from: string | null;
53
+ force: boolean;
54
+ json: boolean;
55
+ }
56
+
57
+ function parseFlags(argv: string[]): { flags: Flags } | { error: string } {
58
+ const flags: Flags = { name: null, at: null, from: null, force: false, json: false };
59
+ for (let i = 0; i < argv.length; i++) {
60
+ const a = argv[i]!;
61
+ if (a === "--at") {
62
+ const v = argv[++i];
63
+ if (v === undefined) return { error: "--at requires a <path>" };
64
+ flags.at = v;
65
+ } else if (a.startsWith("--at=")) {
66
+ flags.at = a.slice("--at=".length);
67
+ } else if (a === "--from") {
68
+ const v = argv[++i];
69
+ if (v === undefined) return { error: "--from requires a <dev-repo path>" };
70
+ flags.from = v;
71
+ } else if (a.startsWith("--from=")) {
72
+ flags.from = a.slice("--from=".length);
73
+ } else if (a === "--force") {
74
+ flags.force = true;
75
+ } else if (a === "--json") {
76
+ flags.json = true;
77
+ } else if (a.startsWith("--")) {
78
+ return { error: `unknown argument: ${a}` };
79
+ } else if (flags.name === null) {
80
+ flags.name = a;
81
+ } else {
82
+ return { error: `unexpected argument: ${a}` };
83
+ }
84
+ }
85
+ return { flags };
86
+ }
87
+
88
+ /** sha-256 of a SKILL.md body (frontmatter stripped) — matches crawl/dedupe hashing. */
89
+ async function bodyHash(skillMdPath: string): Promise<string | null> {
90
+ if (!existsSync(skillMdPath)) return null;
91
+ try {
92
+ const raw = await Bun.file(skillMdPath).text();
93
+ const { body } = parseFrontmatter(raw);
94
+ return createHash("sha256").update(body, "utf8").digest("hex");
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
101
+ const parsed = parseFlags(argv);
102
+ if ("error" in parsed) {
103
+ ctx.error(`skl link: ${parsed.error}`);
104
+ ctx.error(`usage: ${meta.usage}`);
105
+ return 1;
106
+ }
107
+ const flags = parsed.flags;
108
+
109
+ if (flags.at && flags.from) {
110
+ ctx.error("skl link: --at and --from are mutually exclusive (collapse a copy vs. shelve a dev repo)");
111
+ ctx.error(`usage: ${meta.usage}`);
112
+ return 1;
113
+ }
114
+ if (!flags.at && !flags.from) {
115
+ ctx.error("skl link: one of --at <path> or --from <dev-repo> is required");
116
+ ctx.error(`usage: ${meta.usage}`);
117
+ return 1;
118
+ }
119
+
120
+ return flags.from
121
+ ? await runFrom(flags, ctx)
122
+ : await runAt(flags, ctx);
123
+ }
124
+
125
+ /**
126
+ * LINKED mode: register an external dev-repo skill as a library symlink. The library entry
127
+ * <library>/<name> becomes a symlink pointing AT the dev repo (which stays canonical).
128
+ */
129
+ async function runFrom(flags: Flags, ctx: Ctx): Promise<number> {
130
+ const fromPath = resolve(flags.from!.trim());
131
+ const name = (flags.name?.trim()) || basename(fromPath);
132
+ if (!name || name === "." || name === "/") {
133
+ ctx.error("skl link: could not determine a <name> — pass one explicitly");
134
+ return 1;
135
+ }
136
+ const libraryPath = ctx.config.libraryPath;
137
+ const libDir = join(libraryPath, name);
138
+
139
+ try {
140
+ // The source must be a real skill dir (has a SKILL.md).
141
+ if (!existsSync(fromPath) || !(await isDirectory(fromPath))) {
142
+ ctx.error(`skl link: --from must be an existing directory: ${fromPath}`);
143
+ return 1;
144
+ }
145
+ if (!existsSync(join(fromPath, "SKILL.md"))) {
146
+ ctx.error(`skl link: ${fromPath} has no SKILL.md (not a skill dir).`);
147
+ return 1;
148
+ }
149
+
150
+ // Refuse a source inside the library — that would link the library to itself.
151
+ const fromReal = await realpathOrSelfAsync(fromPath);
152
+ const libRoot = await realpathOrSelfAsync(libraryPath);
153
+ if (fromReal === libRoot || fromReal.startsWith(libRoot + "/")) {
154
+ ctx.error(`skl link: --from is inside the library (${fromPath}) — nothing to register`);
155
+ return 1;
156
+ }
157
+
158
+ // Idempotent: library entry is already a symlink resolving to this source.
159
+ if (isSymlink(libDir)) {
160
+ const cur = await realpathOrSelfAsync(libDir);
161
+ if (cur === fromReal) {
162
+ const summary = { ok: true, name, from: fromPath, to: libDir, status: "already" as const, mode: "linked" as const, discarded: false };
163
+ if (flags.json) ctx.json(summary);
164
+ else ctx.log(`link: library/${name} already points at ${fromPath}`);
165
+ return 0;
166
+ }
167
+ }
168
+
169
+ // An existing library entry won't be clobbered silently.
170
+ const exists = existsSync(libDir) || isSymlink(libDir);
171
+ if (exists && !flags.force) {
172
+ ctx.error(`skl link: '${name}' already exists in the library (${libDir}).`);
173
+ ctx.error("Pass --force to replace it with a symlink to the dev repo (its current contents are discarded).");
174
+ return 1;
175
+ }
176
+ const discarded = exists && !isSymlink(libDir); // a real OWNED copy is being dropped
177
+ if (exists) await rm(libDir, { recursive: true, force: true });
178
+ await safeSymlink(fromPath, libDir, { force: true });
179
+
180
+ // Verify the library entry resolves to the dev repo.
181
+ const linkReal = await realpathOrSelfAsync(libDir);
182
+ if (linkReal !== fromReal) {
183
+ ctx.error(`skl link: verification failed — library/${name} resolves to ${linkReal}, expected ${fromReal}`);
184
+ return 1;
185
+ }
186
+
187
+ // A LINKED entry is not a tracked github import — drop any stale lock entry so
188
+ // `skl update`/`outdated` never try to pull upstream into the dev repo (ADR-0004).
189
+ await removeEntry(libraryPath, name);
190
+
191
+ const summary = { ok: true, name, from: fromPath, to: libDir, status: "linked" as const, mode: "linked" as const, discarded };
192
+ if (flags.json) {
193
+ ctx.json(summary);
194
+ } else {
195
+ ctx.log(`shelved ${name} -> ${fromPath} (LINKED)`);
196
+ if (discarded) ctx.log(" (discarded the previous owned library copy; library now points at the dev repo)");
197
+ }
198
+ return 0;
199
+ } catch (err) {
200
+ ctx.error(`skl link: failed: ${err instanceof Error ? err.message : String(err)}`);
201
+ return 1;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * OWNED mode: replace a redundant on-disk copy at <path> with a symlink INTO the library copy
207
+ * the library already owns.
208
+ */
209
+ async function runAt(flags: Flags, ctx: Ctx): Promise<number> {
210
+ if (!flags.name || flags.name.trim() === "") {
211
+ ctx.error("skl link: a <name> is required with --at");
212
+ ctx.error(`usage: ${meta.usage}`);
213
+ return 1;
214
+ }
215
+
216
+ const name = flags.name.trim();
217
+ const atPath = resolve(flags.at!.trim());
218
+ const libraryPath = ctx.config.libraryPath;
219
+ const libDir = join(libraryPath, name);
220
+
221
+ try {
222
+ // The library must already own this skill — link points AT the canonical copy.
223
+ if (!existsSync(libDir) || !existsSync(join(libDir, "SKILL.md"))) {
224
+ ctx.error(
225
+ `skl link: '${name}' is not in the library (${libDir}). Import it first with \`skl import\`.`,
226
+ );
227
+ return 1;
228
+ }
229
+
230
+ const libReal = await realpathOrSelfAsync(libDir);
231
+
232
+ // Idempotent: already a symlink resolving to the library copy.
233
+ if (isSymlink(atPath)) {
234
+ const cur = await realpathOrSelfAsync(atPath);
235
+ if (cur === libReal) {
236
+ const summary = { ok: true, name, at: atPath, to: libDir, status: "already" as const, discarded: false };
237
+ if (flags.json) ctx.json(summary);
238
+ else ctx.log(`link: ${atPath} already points at the library copy of ${name}`);
239
+ return 0;
240
+ }
241
+ }
242
+
243
+ // Safety: never operate on the library copy itself or anything inside the library.
244
+ const atReal = await realpathOrSelfAsync(atPath);
245
+ if (atReal === libReal) {
246
+ ctx.error(`skl link: --at is the library copy itself (${atPath}) — nothing to do`);
247
+ return 1;
248
+ }
249
+ const libRoot = await realpathOrSelfAsync(libraryPath);
250
+ if (atReal === libRoot || atReal.startsWith(libRoot + "/")) {
251
+ ctx.error(`skl link: refusing to operate on a path inside the library (${atPath})`);
252
+ return 1;
253
+ }
254
+
255
+ // If the target exists as a real dir, require it to look like a skill and compare
256
+ // content. A body mismatch means a real decision the tool won't make silently.
257
+ if (existsSync(atPath) && !isSymlink(atPath)) {
258
+ if (!(await isDirectory(atPath))) {
259
+ ctx.error(`skl link: --at must be a directory (the redundant copy): ${atPath}`);
260
+ return 1;
261
+ }
262
+ const atSkillMd = join(atPath, "SKILL.md");
263
+ if (!existsSync(atSkillMd) && !flags.force) {
264
+ ctx.error(
265
+ `skl link: ${atPath} has no SKILL.md (not a skill dir). Pass --force to replace it anyway.`,
266
+ );
267
+ return 1;
268
+ }
269
+ if (existsSync(atSkillMd) && !flags.force) {
270
+ const [a, b] = await Promise.all([
271
+ bodyHash(atSkillMd),
272
+ bodyHash(join(libDir, "SKILL.md")),
273
+ ]);
274
+ if (a !== b) {
275
+ ctx.error(
276
+ `skl link: ${atPath} differs from the library copy of '${name}'.`,
277
+ );
278
+ ctx.error(
279
+ "Pass --force to discard the divergent copy and replace it with a symlink,",
280
+ );
281
+ ctx.error(
282
+ `or make this copy canonical instead: \`skl import ${name} --from ${atPath} --force\`.`,
283
+ );
284
+ return 1;
285
+ }
286
+ }
287
+ }
288
+
289
+ // Replace the redundant copy with a symlink into the library.
290
+ const discarded = existsSync(atPath) && !isSymlink(atPath);
291
+ if (existsSync(atPath) || isSymlink(atPath)) {
292
+ await rm(atPath, { recursive: true, force: true });
293
+ }
294
+ await safeSymlink(libDir, atPath, { force: true });
295
+
296
+ // Verify the link resolves to the library copy.
297
+ const linkReal = await realpathOrSelfAsync(atPath);
298
+ if (linkReal !== libReal) {
299
+ ctx.error(
300
+ `skl link: verification failed — ${atPath} resolves to ${linkReal}, expected ${libReal}`,
301
+ );
302
+ return 1;
303
+ }
304
+
305
+ const summary = { ok: true, name, at: atPath, to: libDir, status: "linked" as const, discarded };
306
+ if (flags.json) {
307
+ ctx.json(summary);
308
+ } else {
309
+ ctx.log(`linked ${basename(atPath)} -> ${libDir}`);
310
+ if (discarded) ctx.log(" (discarded the redundant copy; old path now resolves to the library)");
311
+ }
312
+ return 0;
313
+ } catch (err) {
314
+ ctx.error(`skl link: failed: ${err instanceof Error ? err.message : String(err)}`);
315
+ return 1;
316
+ }
317
+ }