skillshelf 0.2.0 → 0.3.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 (57) hide show
  1. package/README.md +57 -19
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +31 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/agents.ts +120 -0
  7. package/src/commands/drop.ts +21 -13
  8. package/src/commands/import.ts +44 -28
  9. package/src/commands/infer.ts +6 -6
  10. package/src/commands/link.test.ts +160 -0
  11. package/src/commands/link.ts +317 -0
  12. package/src/commands/ls.ts +118 -18
  13. package/src/commands/mode-surfacing.test.ts +110 -0
  14. package/src/commands/outdated.test.ts +55 -0
  15. package/src/commands/outdated.ts +138 -18
  16. package/src/commands/refresh.ts +133 -0
  17. package/src/commands/remediation.test.ts +149 -0
  18. package/src/commands/rename.test.ts +121 -0
  19. package/src/commands/rename.ts +64 -0
  20. package/src/commands/retag.ts +58 -0
  21. package/src/commands/retire.ts +39 -0
  22. package/src/commands/rm.test.ts +133 -0
  23. package/src/commands/rm.ts +107 -0
  24. package/src/commands/roots.ts +41 -0
  25. package/src/commands/scan.ts +122 -30
  26. package/src/commands/show.ts +4 -1
  27. package/src/commands/status.ts +43 -8
  28. package/src/commands/tag.test.ts +109 -0
  29. package/src/commands/tag.ts +68 -0
  30. package/src/commands/unretire.ts +33 -0
  31. package/src/commands/untag.ts +73 -0
  32. package/src/commands/update.test.ts +71 -0
  33. package/src/commands/update.ts +65 -15
  34. package/src/commands/use.test.ts +92 -0
  35. package/src/commands/use.ts +46 -23
  36. package/src/commands/where.ts +232 -0
  37. package/src/config.test.ts +69 -0
  38. package/src/config.ts +79 -10
  39. package/src/core/agents.test.ts +232 -0
  40. package/src/core/agents.ts +363 -0
  41. package/src/core/bundle.ts +12 -15
  42. package/src/core/core.test.ts +14 -1
  43. package/src/core/crawl.ts +22 -5
  44. package/src/core/dedupe.ts +36 -0
  45. package/src/core/deployments.test.ts +147 -0
  46. package/src/core/deployments.ts +208 -0
  47. package/src/core/fetch.ts +344 -70
  48. package/src/core/indexgen.ts +2 -0
  49. package/src/core/library.test.ts +41 -0
  50. package/src/core/library.ts +61 -16
  51. package/src/core/lifecycle.ts +252 -0
  52. package/src/core/surfaces.ts +46 -0
  53. package/src/core/taxonomy.test.ts +159 -0
  54. package/src/core/taxonomy.ts +190 -0
  55. package/src/lib/fs.ts +2 -2
  56. package/src/types.ts +85 -15
  57. package/src/core/overlay.ts +0 -63
@@ -0,0 +1,120 @@
1
+ // `skl agents [name]` — the multi-agent deployment matrix: for each known agent
2
+ // (claude, codex, …) and scope (Global / a project), what is each skill's
3
+ // deployment state? Built on the same reality `skl where` inventories. ADR-0008 §7.3.
4
+ //
5
+ // skl agents human summary of installed agents + per-agent counts
6
+ // skl agents --json full AgentsReport { agents, scopes, deployments }
7
+ // skl agents <name> one skill's row across agents × scopes
8
+
9
+ import type { Ctx } from "../types.ts";
10
+ import { inventoryDeployments } from "../core/deployments.ts";
11
+ import { knownAgentSurfacePaths } from "../core/surfaces.ts";
12
+ import { computeAgentsReport, resolveReadTarget, type DeployState } from "../core/agents.ts";
13
+
14
+ export const meta = {
15
+ name: "agents",
16
+ summary: "Show each skill's deployment state across known agents (claude, codex, …) and scopes",
17
+ usage: "skl agents [name] [--agent <id>] [--project <dir>] [--json]",
18
+ } as const;
19
+
20
+ const GLYPH: Record<DeployState, string> = {
21
+ clean: "✓",
22
+ source: "⊙",
23
+ drift: "⚠",
24
+ copy: "□",
25
+ dead: "✗",
26
+ absent: "·",
27
+ };
28
+
29
+ const LEGEND = "legend: ✓ clean · ⊙ source · ⚠ drift · □ copy · ✗ dead · · absent";
30
+
31
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
32
+ try {
33
+ const rt = resolveReadTarget(argv);
34
+ if ("error" in rt) {
35
+ ctx.error(`skl agents: ${rt.error}`);
36
+ ctx.error("usage: " + meta.usage);
37
+ return 1;
38
+ }
39
+ const json = rt.rest.includes("--json");
40
+ const name = rt.rest.find((a) => !a.startsWith("--")) ?? null;
41
+
42
+ const lib = await ctx.loadLibrary();
43
+ // Same surface union as `skl where` (+ any --project surfaces for this call) so
44
+ // the agent matrix sees the same reality and can verify an ad-hoc project deploy.
45
+ const surfaces = [...ctx.roots, ctx.config.globalCoreTarget, ...knownAgentSurfacePaths(), ...rt.extraSurfaces];
46
+ const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
47
+ let agentsReport = computeAgentsReport(report);
48
+
49
+ // --agent <id> focuses the whole report on that agent: prune the agents list
50
+ // and the deployments map to just that agent (skills not deployed to it drop).
51
+ if (rt.agentId) {
52
+ const id = rt.agentId;
53
+ const deployments: typeof agentsReport.deployments = {};
54
+ for (const [skill, byAgent] of Object.entries(agentsReport.deployments)) {
55
+ if (byAgent[id]) deployments[skill] = { [id]: byAgent[id] };
56
+ }
57
+ agentsReport = {
58
+ agents: agentsReport.agents.filter((a) => a.id === id),
59
+ scopes: agentsReport.scopes,
60
+ deployments,
61
+ };
62
+ }
63
+
64
+ if (json) {
65
+ if (name !== null) {
66
+ ctx.json({
67
+ name,
68
+ agents: agentsReport.agents,
69
+ scopes: agentsReport.scopes,
70
+ deployment: agentsReport.deployments[name] ?? {},
71
+ });
72
+ return 0;
73
+ }
74
+ ctx.json(agentsReport);
75
+ return 0;
76
+ }
77
+
78
+ // --- human view ------------------------------------------------------
79
+ const { agents, scopes, deployments } = agentsReport;
80
+ if (name !== null) {
81
+ const byAgent = deployments[name] ?? {};
82
+ ctx.log(`${name} — deployment across agents:`);
83
+ for (const a of agents) {
84
+ const dep = byAgent[a.id];
85
+ const g = dep?.g ?? "absent";
86
+ const projects = dep?.p
87
+ ? Object.entries(dep.p).map(([s, st]) => `${s} ${GLYPH[st]}`).join(" ")
88
+ : "";
89
+ const installed = a.installed ? "" : " (not installed)";
90
+ ctx.log(` ${a.short.padEnd(10)} ${GLYPH[g]} global·${g}${installed}${projects ? ` ${projects}` : ""}`);
91
+ }
92
+ ctx.log("");
93
+ ctx.log(LEGEND);
94
+ return 0;
95
+ }
96
+
97
+ ctx.log(`Agents (${agents.filter((a) => a.installed).length} installed) · scopes: ${scopes.join(", ")}`);
98
+ for (const a of agents) {
99
+ let clean = 0;
100
+ let problems = 0;
101
+ for (const byAgent of Object.values(deployments)) {
102
+ const dep = byAgent[a.id];
103
+ if (!dep) continue;
104
+ const states: DeployState[] = [dep.g ?? "absent", ...Object.values(dep.p ?? {})];
105
+ for (const st of states) {
106
+ if (st === "clean" || st === "source") clean++;
107
+ else if (st !== "absent") problems++;
108
+ }
109
+ }
110
+ const tag = a.installed ? "" : " (not installed)";
111
+ ctx.log(` ${a.short.padEnd(10)} ${a.global}${tag} — ${clean} clean, ${problems} need attention`);
112
+ }
113
+ ctx.log("");
114
+ ctx.log(LEGEND);
115
+ return 0;
116
+ } catch (err) {
117
+ ctx.error(`skl agents failed: ${err instanceof Error ? err.message : String(err)}`);
118
+ return 1;
119
+ }
120
+ }
@@ -5,12 +5,14 @@
5
5
  import { join } from "node:path";
6
6
  import type { Ctx } from "../types.ts";
7
7
  import { resolveBundle } from "../core/bundle.ts";
8
+ import { findByName } from "../core/library.ts";
9
+ import { parseDeployTarget } from "../core/agents.ts";
8
10
  import { isSymlink, realpathOrSelf, realpathOrSelfAsync, removeSymlink } from "../lib/fs.ts";
9
11
 
10
12
  export const meta = {
11
13
  name: "drop",
12
- summary: "Remove a bundle's symlinks from ./.claude/skills/",
13
- usage: "skl drop <bundle> [--json]",
14
+ summary: "Remove a bundle's (or skill's) symlinks from an agent's skills dir (default: ./.claude/skills/)",
15
+ usage: "skl drop <bundle|skill> [--agent <id>] [--global | --project <name>] [--json]",
14
16
  } as const;
15
17
 
16
18
  interface DropResult {
@@ -19,14 +21,17 @@ interface DropResult {
19
21
  status: "removed" | "absent" | "skipped";
20
22
  }
21
23
 
22
- function projectSkillsDir(): string {
23
- return join(process.cwd(), ".claude", "skills");
24
- }
25
-
26
24
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
27
25
  try {
28
26
  const json = argv.includes("--json");
29
- const bundleName = argv.find((a) => !a.startsWith("-"));
27
+ const parsed = parseDeployTarget(argv);
28
+ if ("error" in parsed) {
29
+ ctx.error(`skl drop: ${parsed.error}`);
30
+ ctx.error("usage: " + meta.usage);
31
+ return 1;
32
+ }
33
+ const { positionals, target } = parsed;
34
+ const bundleName = positionals[0];
30
35
 
31
36
  if (!bundleName) {
32
37
  ctx.error("usage: " + meta.usage);
@@ -36,12 +41,15 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
36
41
  // Include retired so a bundle that was `use`d (which excludes retired) and a
37
42
  // later drop stay symmetric on the active set — we match on the same active set.
38
43
  const skills = await ctx.loadLibrary();
39
- const bundle = await resolveBundle(
40
- skills.filter((s) => !s.retired),
41
- bundleName,
42
- );
44
+ const active = skills.filter((s) => !s.retired);
45
+ // Mirror `use`: resolve a single skill name first, else a bundle, so
46
+ // `skl drop <skill>` undoes `skl use <skill>` symmetrically.
47
+ const single = findByName(active, bundleName);
48
+ const bundle = single
49
+ ? { name: single.name, skills: [single] }
50
+ : await resolveBundle(active, bundleName);
43
51
 
44
- const skillsDir = projectSkillsDir();
52
+ const skillsDir = target.dir;
45
53
  const results: DropResult[] = [];
46
54
 
47
55
  for (const s of bundle.skills) {
@@ -68,7 +76,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
68
76
  const removedCount = results.filter((r) => r.status === "removed").length;
69
77
 
70
78
  if (json) {
71
- ctx.json({ bundle: bundle.name, skillsDir, results, removed: removedCount });
79
+ ctx.json({ bundle: bundle.name, skillsDir, agent: target.agentId, scope: target.scope, results, removed: removedCount });
72
80
  } else {
73
81
  if (bundle.skills.length === 0) {
74
82
  ctx.log(`Bundle '${bundleName}' has no skills; nothing to drop.`);
@@ -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: cairn\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, "cairn");
58
+ const { ctx, json } = makeCtx(library);
59
+ const code = await run(["cairn", "--from", src, "--json"], ctx);
60
+
61
+ expect(code).toBe(0);
62
+ const libEntry = join(library, "cairn");
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: "cairn", 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, "cairn");
71
+ const { ctx } = makeCtx(library);
72
+ const code = await run(["--from", src], ctx);
73
+
74
+ expect(code).toBe(0);
75
+ const libEntry = join(library, "cairn");
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, "cairn");
82
+ const { ctx } = makeCtx(library);
83
+ await run(["cairn", "--from", src], ctx);
84
+
85
+ const { ctx: ctx2, json } = makeCtx(library);
86
+ const code = await run(["cairn", "--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, "cairn"); // a real OWNED copy already in the library
93
+ const src = await makeSkillDir(devRepo, "cairn");
94
+ const { ctx, errors } = makeCtx(library);
95
+
96
+ const code = await run(["cairn", "--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, "cairn"))).isSymbolicLink()).toBe(false);
101
+ });
102
+
103
+ test("--force replaces an owned copy with the symlink and reports discarded", async () => {
104
+ await makeSkillDir(library, "cairn");
105
+ const src = await makeSkillDir(devRepo, "cairn");
106
+ const { ctx, json } = makeCtx(library);
107
+
108
+ const code = await run(["cairn", "--from", src, "--force", "--json"], ctx);
109
+ expect(code).toBe(0);
110
+ expect((await lstat(join(library, "cairn"))).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, "cairn");
117
+ await writeFile(
118
+ join(library, "shelf.lock.json"),
119
+ JSON.stringify({
120
+ version: 1,
121
+ entries: {
122
+ cairn: { name: "cairn", 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, "cairn");
127
+ const { ctx } = makeCtx(library);
128
+
129
+ const code = await run(["cairn", "--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.cairn).toBeUndefined();
133
+ });
134
+
135
+ test("rejects --at and --from together", async () => {
136
+ const src = await makeSkillDir(devRepo, "cairn");
137
+ const { ctx, errors } = makeCtx(library);
138
+ const code = await run(["cairn", "--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, "cairn");
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
+ });