skillshelf 0.3.0 → 0.4.1

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 (40) hide show
  1. package/README.md +60 -35
  2. package/package.json +1 -1
  3. package/src/cli.ts +8 -0
  4. package/src/commands/add.test.ts +118 -0
  5. package/src/commands/add.ts +23 -2
  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 +129 -6
  10. package/src/commands/import.test.ts +71 -0
  11. package/src/commands/import.ts +13 -0
  12. package/src/commands/link.test.ts +40 -26
  13. package/src/commands/link.ts +11 -0
  14. package/src/commands/ls.ts +18 -1
  15. package/src/commands/migrate.test.ts +157 -0
  16. package/src/commands/migrate.ts +260 -0
  17. package/src/commands/new.test.ts +63 -0
  18. package/src/commands/new.ts +12 -0
  19. package/src/commands/outdated.ts +41 -13
  20. package/src/commands/projects.test.ts +85 -0
  21. package/src/commands/projects.ts +80 -0
  22. package/src/commands/rename.test.ts +14 -0
  23. package/src/commands/show.ts +126 -10
  24. package/src/commands/track.test.ts +170 -0
  25. package/src/commands/track.ts +340 -0
  26. package/src/commands/untrack.ts +44 -0
  27. package/src/commands/update.ts +92 -0
  28. package/src/commands/use.test.ts +30 -0
  29. package/src/config.test.ts +130 -1
  30. package/src/config.ts +154 -1
  31. package/src/core/agents.test.ts +92 -5
  32. package/src/core/agents.ts +83 -8
  33. package/src/core/core.test.ts +7 -7
  34. package/src/core/deployments.test.ts +20 -20
  35. package/src/core/fetch.ts +28 -6
  36. package/src/core/library.test.ts +3 -3
  37. package/src/core/library.ts +54 -0
  38. package/src/core/lifecycle.ts +26 -32
  39. package/src/core/taxonomy.test.ts +2 -2
  40. package/src/types.ts +70 -0
@@ -0,0 +1,96 @@
1
+ // `skl agents --json` end-to-end with ADR-0010 config: a persisted-but-empty
2
+ // project must surface as a scope (no phantom deployments), a custom agent must
3
+ // appear in the matrix, and a real deploy into a config-project must be inventoried.
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
6
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { run as agentsRun } from "./agents.ts";
10
+ import { run as useRun } from "./use.ts";
11
+ import { loadLibrary } from "../core/library.ts";
12
+ import type { AgentsReport } from "../core/agents.ts";
13
+ import type { Config, Ctx } from "../types.ts";
14
+
15
+ async function writeSkill(library: string, name: string) {
16
+ const dir = join(library, name);
17
+ await mkdir(dir, { recursive: true });
18
+ await writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${name}\ndomains: [bio]\n---\n\nbody\n`);
19
+ }
20
+
21
+ describe("skl agents --json — config projects + agents (ADR-0010)", () => {
22
+ let tmp: string;
23
+ let library: string;
24
+
25
+ function makeCtx(config: Partial<Config>): { ctx: Ctx; json: unknown[] } {
26
+ const json: unknown[] = [];
27
+ const full: Config = {
28
+ libraryPath: library,
29
+ globalCoreTarget: join(tmp, ".no-global-core"),
30
+ roots: [],
31
+ agents: [],
32
+ projects: [],
33
+ configFile: null,
34
+ configFilePath: join(tmp, "config.json"),
35
+ source: "default",
36
+ ...config,
37
+ };
38
+ const ctx = {
39
+ config: full,
40
+ libraryPath: library,
41
+ roots: full.roots,
42
+ loadLibrary: () => loadLibrary(library),
43
+ log: () => {},
44
+ error: () => {},
45
+ json: (v: unknown) => json.push(v),
46
+ } as unknown as Ctx;
47
+ return { ctx, json };
48
+ }
49
+
50
+ beforeEach(async () => {
51
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-agents-cmd-")));
52
+ library = join(tmp, "library");
53
+ await writeSkill(library, "alpha");
54
+ });
55
+ afterEach(async () => {
56
+ await rm(tmp, { recursive: true, force: true });
57
+ });
58
+
59
+ test("an empty config-project appears as a scope with no phantom deployments", async () => {
60
+ const empty = join(tmp, "scratch");
61
+ await mkdir(empty, { recursive: true });
62
+ const { ctx, json } = makeCtx({ projects: [empty] });
63
+ const code = await agentsRun(["--json"], ctx);
64
+ expect(code).toBe(0);
65
+ const report = json[0] as AgentsReport;
66
+ expect(report.scopes).toContain("scratch");
67
+ // empty project = NO fabricated cell. (The matrix may contain real machine
68
+ // global deployments; the invariant is that nothing is keyed to `scratch`.)
69
+ for (const byAgent of Object.values(report.deployments)) {
70
+ for (const dep of Object.values(byAgent)) {
71
+ expect(dep.p?.scratch).toBeUndefined();
72
+ }
73
+ }
74
+ });
75
+
76
+ test("a custom config-agent appears in the agents list", async () => {
77
+ const { ctx, json } = makeCtx({ agents: [{ id: "pi", name: "PI Agent", short: "PI" }] });
78
+ await agentsRun(["--json"], ctx);
79
+ const report = json[0] as AgentsReport;
80
+ expect(report.agents.map((a) => a.id)).toContain("pi");
81
+ });
82
+
83
+ test("a real deploy into a config-project is inventoried in the matrix", async () => {
84
+ const proj = join(tmp, "webapp");
85
+ await mkdir(proj, { recursive: true });
86
+ // deploy alpha into the config project for claude
87
+ const { ctx: useCtx } = makeCtx({ projects: [proj] });
88
+ await useRun(["alpha", "--agent", "claude", "--project", proj, "--json"], useCtx);
89
+
90
+ const { ctx, json } = makeCtx({ projects: [proj] });
91
+ await agentsRun(["--json"], ctx);
92
+ const report = json[0] as AgentsReport;
93
+ expect(report.scopes).toContain("webapp");
94
+ expect(report.deployments.alpha!.claude!.p!.webapp).toBe("clean");
95
+ });
96
+ });
@@ -6,10 +6,16 @@
6
6
  // skl agents --json full AgentsReport { agents, scopes, deployments }
7
7
  // skl agents <name> one skill's row across agents × scopes
8
8
 
9
- import type { Ctx } from "../types.ts";
9
+ import { basename, join, sep } from "node:path";
10
+ import type { AgentConfigEntry, Ctx } from "../types.ts";
10
11
  import { inventoryDeployments } from "../core/deployments.ts";
11
12
  import { knownAgentSurfacePaths } from "../core/surfaces.ts";
12
- import { computeAgentsReport, resolveReadTarget, type DeployState } from "../core/agents.ts";
13
+ import {
14
+ computeAgentsReport,
15
+ knownAgentIds,
16
+ resolveReadTarget,
17
+ type DeployState,
18
+ } from "../core/agents.ts";
13
19
 
14
20
  export const meta = {
15
21
  name: "agents",
@@ -28,7 +34,24 @@ const GLYPH: Record<DeployState, string> = {
28
34
 
29
35
  const LEGEND = "legend: ✓ clean · ⊙ source · ⚠ drift · □ copy · ✗ dead · · absent";
30
36
 
37
+ /**
38
+ * Scope name for a persisted project dir = its basename — matching the engine's
39
+ * scope derivation (parseDeployTarget / scopeForSurface use the last path segment),
40
+ * so a config project reconciles with the FS-derived scopes in the matrix.
41
+ */
42
+ function projectScopeName(projectPath: string): string {
43
+ return projectPath.split(sep).filter(Boolean).pop() || basename(projectPath) || projectPath;
44
+ }
45
+
31
46
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
47
+ // ── Config write subverbs (ADR-0010 delta 4): `skl agents add|rm` mutate the
48
+ // custom-agent registry in config.json. These are registry metadata only —
49
+ // they never deploy; the matrix stays FS-derived. Checked BEFORE the read
50
+ // path so `add`/`rm` are never mistaken for a skill name. (`add`/`rm` are
51
+ // reserved subverbs; a skill literally named "add"/"rm" is not a concern.)
52
+ if (argv[0] === "add" || argv[0] === "rm") {
53
+ return runConfigSubverb(argv, ctx);
54
+ }
32
55
  try {
33
56
  const rt = resolveReadTarget(argv);
34
57
  if ("error" in rt) {
@@ -40,11 +63,35 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
40
63
  const name = rt.rest.find((a) => !a.startsWith("--")) ?? null;
41
64
 
42
65
  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];
66
+ // Effective agent ids = built-in seeds + custom config agents (so a custom
67
+ // agent's project/global skills dirs are scanned for inventory).
68
+ const agentIds = new Set<string>(knownAgentIds());
69
+ for (const a of ctx.config.agents) {
70
+ if (a && typeof a.id === "string" && a.id.trim() !== "") agentIds.add(a.id);
71
+ }
72
+ // Config-project surfaces (ADR-0010 §5a): scan each persisted project dir for
73
+ // every effective agent so a deployed config-project is inventoried.
74
+ const projectSurfaces: string[] = [];
75
+ for (const proj of ctx.config.projects) {
76
+ for (const id of agentIds) projectSurfaces.push(join(proj, `.${id}`, "skills"));
77
+ }
78
+ // Same surface union as `skl where` (+ any --project surfaces for this call,
79
+ // + persisted config-project surfaces) so the agent matrix sees the same
80
+ // reality and can verify an ad-hoc OR persisted project deploy.
81
+ const surfaces = [
82
+ ...ctx.roots,
83
+ ctx.config.globalCoreTarget,
84
+ ...knownAgentSurfacePaths(),
85
+ ...projectSurfaces,
86
+ ...rt.extraSurfaces,
87
+ ];
46
88
  const report = await inventoryDeployments(surfaces, ctx.libraryPath, lib);
47
- let agentsReport = computeAgentsReport(report);
89
+ // Empty persisted projects still appear as scope rows (basename = scope name);
90
+ // custom agents merge into the matrix. Deployments stay FS-derived.
91
+ let agentsReport = computeAgentsReport(report, undefined, {
92
+ agents: ctx.config.agents,
93
+ extraScopes: ctx.config.projects.map((p) => projectScopeName(p)),
94
+ });
48
95
 
49
96
  // --agent <id> focuses the whole report on that agent: prune the agents list
50
97
  // and the deployments map to just that agent (skills not deployed to it drop).
@@ -118,3 +165,79 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
118
165
  return 1;
119
166
  }
120
167
  }
168
+
169
+ /** Read a `--flag value` option out of argv (returns undefined if absent). */
170
+ function flag(argv: string[], name: string): string | undefined {
171
+ const i = argv.indexOf(name);
172
+ if (i < 0) return undefined;
173
+ const v = argv[i + 1];
174
+ return v && !v.startsWith("--") ? v : undefined;
175
+ }
176
+
177
+ /**
178
+ * `skl agents add <id> --name <n> --global <dir> --proj-convention <c> [--icon k]
179
+ * [--color #rgb]` and `skl agents rm <id>` — persist the custom-agent registry
180
+ * (ADR-0010 delta 4). With `--json` emits `{ agents }` / `{ agents, removed }`
181
+ * (the full custom-agent list) so the GUI can round-trip without a separate read.
182
+ */
183
+ async function runConfigSubverb(argv: string[], ctx: Ctx): Promise<number> {
184
+ const verb = argv[0];
185
+ const json = argv.includes("--json");
186
+ const id = argv[1] && !argv[1].startsWith("--") ? argv[1].trim() : "";
187
+
188
+ if (!id) {
189
+ ctx.error(`skl agents ${verb}: requires an agent id`);
190
+ ctx.error("usage: skl agents add <id> --name <name> --global <dir> --proj-convention <conv> [--icon <key>] [--color <hex>]");
191
+ return 1;
192
+ }
193
+
194
+ if (verb === "rm") {
195
+ const { agents, removed } = await ctx.removeAgent(id);
196
+ if (json) {
197
+ ctx.json({ agents, removed });
198
+ return 0;
199
+ }
200
+ ctx.log(removed ? `Removed custom agent "${id}".` : `No custom agent "${id}" (nothing removed).`);
201
+ return 0;
202
+ }
203
+
204
+ // add
205
+ const name = flag(argv, "--name") ?? id;
206
+ const global = flag(argv, "--global");
207
+ const projConvention = flag(argv, "--proj-convention");
208
+ const icon = flag(argv, "--icon");
209
+ const color = flag(argv, "--color");
210
+ // `--hidden` persists a hide override (ADR-0010 delta 4 / RISK 8): mergeAgents
211
+ // drops `hidden:true` so the agent leaves the matrix everywhere. A hide entry
212
+ // for a SEED carries no paths, so --global/--proj-convention are only required
213
+ // for a real (visible) registration.
214
+ const hidden = argv.includes("--hidden");
215
+ // Global→project inheritance (ADR-0010): default TRUE (the ~/.x/skills
216
+ // convention). `--no-inherits-global` opts out (persists inheritsGlobal:false);
217
+ // `--inherits-global` is accepted for symmetry but is the default, so we only
218
+ // persist the flag when it diverges from the default to keep config minimal.
219
+ const inheritsGlobal = !argv.includes("--no-inherits-global");
220
+ if (!hidden && (!global || !projConvention)) {
221
+ ctx.error("skl agents add: --global and --proj-convention are required");
222
+ return 1;
223
+ }
224
+ const entry: AgentConfigEntry = {
225
+ id,
226
+ name,
227
+ short: name,
228
+ ...(global ? { global } : {}),
229
+ ...(projConvention ? { projConvention } : {}),
230
+ ...(icon ? { icon } : {}),
231
+ ...(color ? { color } : {}),
232
+ ...(hidden ? { hidden: true } : {}),
233
+ ...(inheritsGlobal ? {} : { inheritsGlobal: false }),
234
+ };
235
+ const agents = await ctx.addAgent(entry);
236
+ if (json) {
237
+ ctx.json({ agents, added: true });
238
+ return 0;
239
+ }
240
+ ctx.log(`Registered custom agent "${name}" (${id}). Custom agents (${agents.length}):`);
241
+ for (const a of agents) ctx.log(` ${a.id} ${a.global}`);
242
+ return 0;
243
+ }
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm, 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 "./import.ts";
7
+ import type { Ctx } from "../types.ts";
8
+
9
+ const BODY = "---\nname: caveman\ndescription: a test skill\n---\n\nbody\n";
10
+
11
+ interface Captured {
12
+ ctx: Ctx;
13
+ logs: string[];
14
+ errors: string[];
15
+ json: unknown[];
16
+ }
17
+
18
+ function makeCtx(libraryPath: string): Captured {
19
+ const logs: string[] = [];
20
+ const errors: string[] = [];
21
+ const json: unknown[] = [];
22
+ const ctx = {
23
+ config: { libraryPath },
24
+ libraryPath,
25
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
26
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
27
+ json: (v: unknown) => json.push(v),
28
+ } as unknown as Ctx;
29
+ return { ctx, logs, errors, json };
30
+ }
31
+
32
+ describe("skl import — retired-aware collision", () => {
33
+ let tmp: string;
34
+ let library: string;
35
+ let candidate: string;
36
+
37
+ beforeEach(async () => {
38
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-import-")));
39
+ library = join(tmp, "library");
40
+ await mkdir(library, { recursive: true });
41
+ // A candidate skill dir on disk to import.
42
+ candidate = join(tmp, "ext", "caveman");
43
+ await mkdir(candidate, { recursive: true });
44
+ await writeFile(join(candidate, "SKILL.md"), BODY);
45
+ });
46
+ afterEach(async () => {
47
+ await rm(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ test("importing to a retired name refuses (exit 1, no write)", async () => {
51
+ // Retire "caveman": a tombstone, no active copy.
52
+ await mkdir(join(library, "_retired", "caveman"), { recursive: true });
53
+ await writeFile(join(library, "_retired", "caveman", "SKILL.md"), BODY);
54
+
55
+ const { ctx, errors } = makeCtx(library);
56
+ const code = await run(["caveman", "--from", candidate, "--copy"], ctx);
57
+ expect(code).toBe(1);
58
+ expect(errors.join("\n")).toContain("skl unretire caveman");
59
+
60
+ // No active copy created; the candidate is untouched.
61
+ expect(existsSync(join(library, "caveman"))).toBe(false);
62
+ expect(existsSync(join(candidate, "SKILL.md"))).toBe(true);
63
+ });
64
+
65
+ test("importing a non-retired name still works (no regression)", async () => {
66
+ const { ctx } = makeCtx(library);
67
+ const code = await run(["caveman", "--from", candidate, "--copy", "--json"], ctx);
68
+ expect(code).toBe(0);
69
+ expect(existsSync(join(library, "caveman", "SKILL.md"))).toBe(true);
70
+ });
71
+ });
@@ -34,6 +34,7 @@ import {
34
34
  isSymlink,
35
35
  realpathOrSelfAsync,
36
36
  } from "../lib/fs.ts";
37
+ import { entryStatus } from "../core/library.ts";
37
38
 
38
39
  export const meta = {
39
40
  name: "import",
@@ -202,6 +203,18 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
202
203
  // Flat, non-semantic layout (ADR-0001): always <library>/<name>/.
203
204
  const destDir = join(libraryPath, targetName);
204
205
 
206
+ // Retired-aware guard: refuse if the name exists ONLY as a retired tombstone
207
+ // (<library>/_retired/<name>). Importing beside it would strand a duplicate and
208
+ // break `skl unretire`; --force overwrites an ACTIVE copy, not a retired one, so
209
+ // this fires regardless. The user must unretire first (or import under --as).
210
+ const status = entryStatus(libraryPath, targetName);
211
+ if (status.retired && !status.active) {
212
+ ctx.error(
213
+ `skl import: a retired '${targetName}' exists — run \`skl unretire ${targetName}\` first (or import under another name with --as <slug>)`,
214
+ );
215
+ return 1;
216
+ }
217
+
205
218
  // Idempotency guard: refuse to clobber an existing library skill unless --force
206
219
  // (or the user re-aimed with --as). This protects a managed copy from a stray
207
220
  // re-import.
@@ -6,7 +6,7 @@ import { join } from "node:path";
6
6
  import { run } from "./link.ts";
7
7
  import type { Ctx } from "../types.ts";
8
8
 
9
- const BODY = "---\nname: cairn\ndescription: a test skill\n---\n\nbody\n";
9
+ const BODY = "---\nname: claim-log\ndescription: a test skill\n---\n\nbody\n";
10
10
 
11
11
  async function makeSkillDir(parent: string, name: string, body = BODY): Promise<string> {
12
12
  const dir = join(parent, name);
@@ -54,94 +54,108 @@ describe("skl link --from (LINKED mode)", () => {
54
54
  });
55
55
 
56
56
  test("registers a dev-repo skill as a library symlink", async () => {
57
- const src = await makeSkillDir(devRepo, "cairn");
57
+ const src = await makeSkillDir(devRepo, "claim-log");
58
58
  const { ctx, json } = makeCtx(library);
59
- const code = await run(["cairn", "--from", src, "--json"], ctx);
59
+ const code = await run(["claim-log", "--from", src, "--json"], ctx);
60
60
 
61
61
  expect(code).toBe(0);
62
- const libEntry = join(library, "cairn");
62
+ const libEntry = join(library, "claim-log");
63
63
  const st = await lstat(libEntry);
64
64
  expect(st.isSymbolicLink()).toBe(true);
65
65
  expect(await realpath(libEntry)).toBe(await realpath(src));
66
- expect(json[0]).toMatchObject({ ok: true, name: "cairn", mode: "linked", discarded: false });
66
+ expect(json[0]).toMatchObject({ ok: true, name: "claim-log", mode: "linked", discarded: false });
67
67
  });
68
68
 
69
69
  test("derives the name from the dev-repo dir basename when omitted", async () => {
70
- const src = await makeSkillDir(devRepo, "cairn");
70
+ const src = await makeSkillDir(devRepo, "claim-log");
71
71
  const { ctx } = makeCtx(library);
72
72
  const code = await run(["--from", src], ctx);
73
73
 
74
74
  expect(code).toBe(0);
75
- const libEntry = join(library, "cairn");
75
+ const libEntry = join(library, "claim-log");
76
76
  expect((await lstat(libEntry)).isSymbolicLink()).toBe(true);
77
77
  expect(await realpath(libEntry)).toBe(await realpath(src));
78
78
  });
79
79
 
80
80
  test("is idempotent — re-running reports 'already'", async () => {
81
- const src = await makeSkillDir(devRepo, "cairn");
81
+ const src = await makeSkillDir(devRepo, "claim-log");
82
82
  const { ctx } = makeCtx(library);
83
- await run(["cairn", "--from", src], ctx);
83
+ await run(["claim-log", "--from", src], ctx);
84
84
 
85
85
  const { ctx: ctx2, json } = makeCtx(library);
86
- const code = await run(["cairn", "--from", src, "--json"], ctx2);
86
+ const code = await run(["claim-log", "--from", src, "--json"], ctx2);
87
87
  expect(code).toBe(0);
88
88
  expect(json[0]).toMatchObject({ status: "already", mode: "linked" });
89
89
  });
90
90
 
91
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");
92
+ await makeSkillDir(library, "claim-log"); // a real OWNED copy already in the library
93
+ const src = await makeSkillDir(devRepo, "claim-log");
94
94
  const { ctx, errors } = makeCtx(library);
95
95
 
96
- const code = await run(["cairn", "--from", src], ctx);
96
+ const code = await run(["claim-log", "--from", src], ctx);
97
97
  expect(code).toBe(1);
98
98
  expect(errors.join("\n")).toContain("already exists in the library");
99
99
  // unchanged: still a real dir, not a symlink
100
- expect((await lstat(join(library, "cairn"))).isSymbolicLink()).toBe(false);
100
+ expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(false);
101
+ });
102
+
103
+ test("refuses to shelve over a retired tombstone (points at unretire)", async () => {
104
+ // "claim-log" exists only as a retired tombstone — no active library entry.
105
+ await makeSkillDir(join(library, "_retired"), "claim-log");
106
+ const src = await makeSkillDir(devRepo, "claim-log");
107
+ const { ctx, errors } = makeCtx(library);
108
+
109
+ const code = await run(["claim-log", "--from", src], ctx);
110
+ expect(code).toBe(1);
111
+ expect(errors.join("\n")).toContain("skl unretire claim-log");
112
+ // No active symlink created beside the tombstone.
113
+ expect(existsSync(join(library, "claim-log"))).toBe(false);
114
+ expect(existsSync(join(library, "_retired", "claim-log"))).toBe(true);
101
115
  });
102
116
 
103
117
  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");
118
+ await makeSkillDir(library, "claim-log");
119
+ const src = await makeSkillDir(devRepo, "claim-log");
106
120
  const { ctx, json } = makeCtx(library);
107
121
 
108
- const code = await run(["cairn", "--from", src, "--force", "--json"], ctx);
122
+ const code = await run(["claim-log", "--from", src, "--force", "--json"], ctx);
109
123
  expect(code).toBe(0);
110
- expect((await lstat(join(library, "cairn"))).isSymbolicLink()).toBe(true);
124
+ expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(true);
111
125
  expect(json[0]).toMatchObject({ discarded: true, mode: "linked" });
112
126
  });
113
127
 
114
128
  test("drops a stale lockfile entry so update/outdated skip the now-LINKED skill", async () => {
115
129
  // An owned import existed (real copy + a github lock entry); now convert to LINKED.
116
- await makeSkillDir(library, "cairn");
130
+ await makeSkillDir(library, "claim-log");
117
131
  await writeFile(
118
132
  join(library, "shelf.lock.json"),
119
133
  JSON.stringify({
120
134
  version: 1,
121
135
  entries: {
122
- cairn: { name: "cairn", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
136
+ "claim-log": { name: "claim-log", source: "github:owner/repo", ref: "abc", channel: "github", installedAt: "2020-01-01T00:00:00.000Z", localEdits: false },
123
137
  },
124
138
  }),
125
139
  );
126
- const src = await makeSkillDir(devRepo, "cairn");
140
+ const src = await makeSkillDir(devRepo, "claim-log");
127
141
  const { ctx } = makeCtx(library);
128
142
 
129
- const code = await run(["cairn", "--from", src, "--force"], ctx);
143
+ const code = await run(["claim-log", "--from", src, "--force"], ctx);
130
144
  expect(code).toBe(0);
131
145
  const lock = JSON.parse(await readFile(join(library, "shelf.lock.json"), "utf8"));
132
- expect(lock.entries.cairn).toBeUndefined();
146
+ expect(lock.entries["claim-log"]).toBeUndefined();
133
147
  });
134
148
 
135
149
  test("rejects --at and --from together", async () => {
136
- const src = await makeSkillDir(devRepo, "cairn");
150
+ const src = await makeSkillDir(devRepo, "claim-log");
137
151
  const { ctx, errors } = makeCtx(library);
138
- const code = await run(["cairn", "--from", src, "--at", "/tmp/x"], ctx);
152
+ const code = await run(["claim-log", "--from", src, "--at", "/tmp/x"], ctx);
139
153
  expect(code).toBe(1);
140
154
  expect(errors.join("\n")).toContain("mutually exclusive");
141
155
  });
142
156
 
143
157
  test("refuses a --from source inside the library", async () => {
144
- const inside = await makeSkillDir(library, "cairn");
158
+ const inside = await makeSkillDir(library, "claim-log");
145
159
  const { ctx, errors } = makeCtx(library);
146
160
  const code = await run(["other", "--from", inside], ctx);
147
161
  expect(code).toBe(1);
@@ -39,6 +39,7 @@ import {
39
39
  safeSymlink,
40
40
  realpathOrSelfAsync,
41
41
  } from "../lib/fs.ts";
42
+ import { entryStatus } from "../core/library.ts";
42
43
 
43
44
  export const meta = {
44
45
  name: "link",
@@ -166,6 +167,16 @@ async function runFrom(flags: Flags, ctx: Ctx): Promise<number> {
166
167
  }
167
168
  }
168
169
 
170
+ // Retired-aware guard: refuse if the name exists ONLY as a retired tombstone
171
+ // (<library>/_retired/<name>). Shelving a symlink beside it would strand a duplicate
172
+ // and break `skl unretire`; --force replaces an ACTIVE entry, not a retired one, so
173
+ // this fires regardless. The user must unretire first.
174
+ const status = entryStatus(libraryPath, name);
175
+ if (status.retired && !status.active) {
176
+ ctx.error(`skl link: a retired '${name}' exists — run \`skl unretire ${name}\` first.`);
177
+ return 1;
178
+ }
179
+
169
180
  // An existing library entry won't be clobbered silently.
170
181
  const exists = existsSync(libDir) || isSymlink(libDir);
171
182
  if (exists && !flags.force) {
@@ -2,7 +2,7 @@
2
2
  // bundle (tag query). Excludes retired by default; `--all` includes them.
3
3
 
4
4
  import { statSync } from "node:fs";
5
- import type { Ctx, Skill } from "../types.ts";
5
+ import type { Ctx, Skill, Provenance } from "../types.ts";
6
6
  import { activeSkills, entryModeInfo } from "../core/library.ts";
7
7
  import { resolveBundle } from "../core/bundle.ts";
8
8
  import { inventoryDeployments } from "../core/deployments.ts";
@@ -94,8 +94,11 @@ function toJson(
94
94
  mode,
95
95
  linkTarget,
96
96
  // ADR-0008 §7.1 additions: a string source (UI maps "vendored"/"local"),
97
+ // a human origin label (the real upstream, NOT a hard-coded channel),
97
98
  // stat timestamps, and the count of clean deployment sites.
98
99
  source: s.source ? "vendored" : "local",
100
+ origin: originLabel(s.source),
101
+ channel: s.source ? s.source.channel : null,
99
102
  modifiedAt,
100
103
  createdAt,
101
104
  deployCount: deployCounts.get(s.name) ?? 0,
@@ -103,6 +106,20 @@ function toJson(
103
106
  });
104
107
  }
105
108
 
109
+ /**
110
+ * Short, human display of a vendored skill's real upstream origin, e.g.
111
+ * "jimliu/baoyu-skills" from "github:jimliu/baoyu-skills@skills/baoyu-translate".
112
+ * Null for hand-written (local) skills. Replaces the UI's old hard-coded
113
+ * "dbskill" label so the table tells the truth about where a skill came from.
114
+ */
115
+ function originLabel(prov: Provenance | null): string | null {
116
+ if (!prov) return null;
117
+ const stripped = prov.source.replace(/@.*$/, ""); // drop @subpath
118
+ const colon = stripped.indexOf(":");
119
+ const repo = colon >= 0 ? stripped.slice(colon + 1) : stripped; // drop channel:
120
+ return repo || prov.channel || "vendored";
121
+ }
122
+
106
123
  /** Count clean (`linked`) deployment sites per skill across all surfaces. */
107
124
  async function deployCountsFor(ctx: Ctx, lib: Skill[]): Promise<Map<string, number>> {
108
125
  const counts = new Map<string, number>();