skillshelf 0.4.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.
package/README.md CHANGED
@@ -7,22 +7,17 @@
7
7
  [![CI](https://img.shields.io/github/actions/workflow/status/Wang-Cankun/skillshelf/ci.yml?branch=main)](https://github.com/Wang-Cankun/skillshelf/actions)
8
8
  [![npm](https://img.shields.io/npm/v/skillshelf.svg)](https://www.npmjs.com/package/skillshelf)
9
9
 
10
- Your skills are scattered across **every agent you use** — some in `~/.claude/skills`, some in
11
- `~/.codex/skills` or `~/.cursor/skills`, some buried in Obsidian or notes vaults, more copied into
12
- a dozen per-project `.claude` / `.codex` directories. Each tool scatters its own copies and
13
- symlinks; you forget which ones exist, rewrite ones you already have, and copies drift out of sync.
14
- The naive fix — dump everything into one agent's dir — makes every session pay the token cost of
15
- loading hundreds of skill descriptions at once.
16
-
17
- skillshelf is **agent-agnostic** (Claude Code, Codex, Cursor, and compatible agents): the library
18
- is a neutral source, and `skl where` maps where every skill is actually deployed across all of
19
- them surfacing untracked copies, drift, and dead links. It's the curation layer *over* your
20
- agent dirs, complementary to installers like [`vercel-labs/skills`](https://github.com/vercel-labs/skills).
21
-
22
- skillshelf is the middle path: a single git-backed **library** that is a *passive shelf*
23
- (nothing auto-loads), plus a CLI to **search, tag, bundle, and load** exactly the skills a
24
- project needs, exactly when it needs them. Find anything in one place; pay only for what you
25
- actually use.
10
+ Your skills are scattered across **every agent you use** — `~/.claude/skills`, `~/.codex/skills`,
11
+ `~/.cursor/skills`, Obsidian vaults, and a dozen per-project `.claude` dirs. Copies drift, you
12
+ rewrite skills you already have, and you forget which exist. The naive fix dump everything into
13
+ one agent's dir makes every session pay to load hundreds of skill descriptions at once.
14
+
15
+ skillshelf is the middle path: one git-backed **library** that is a *passive shelf* (nothing
16
+ auto-loads), plus a CLI to **search, tag, bundle, and load** exactly the skills a project needs,
17
+ when it needs them. It's **agent-agnostic** (Claude Code, Codex, Cursor, …) `skl where` maps
18
+ where every skill is actually deployed across all of them, surfacing untracked copies, drift, and
19
+ dead links. Find anything in one place; pay only for what you use. (Complementary to installers
20
+ like [`vercel-labs/skills`](https://github.com/vercel-labs/skills).)
26
21
 
27
22
  ## Desktop app
28
23
 
@@ -148,27 +143,32 @@ skillshelf separates *owning* a skill from *loading* it.
148
143
  - **On-demand `show`** — prints only the SKILL.md instruction body and lists the paths of
149
144
  any bundled reference files (without reading them). Progressive disclosure: cheap by
150
145
  default, deep when you ask. Works mid-task with no reload.
151
- - **Owned vs linked entries** ([ADR-0004](./docs/adr/0004-owned-vs-linked-entries.md)) — the
152
- library is a *bookshelf*: an entry either **owns** its bytes (a real copy; the library is
153
- canonical for downloads and stabilized skills) or is **linked** (a symlink to an external dev
154
- repo that stays canonical for skills you actively develop in their own git, e.g. `claim-log`).
155
- `skl link --from <dev-repo>` registers a linked entry; `skl where` shows it as a clean
156
- `✓ source`; `skl update` / `outdated` skip linked entries so they never pull upstream into your
157
- dev repo. The mode is derived from the filesystem (a symlink resolving outside the library),
158
- never stored, so it can't go stale.
146
+ - **Owned vs linked entries** ([ADR-0004](./docs/adr/0004-owned-vs-linked-entries.md)) — an entry
147
+ either **owns** its bytes (a real copy for downloads and stabilized skills) or is **linked**
148
+ (`skl link --from <dev-repo>` a symlink to an external repo that stays canonical, for skills you
149
+ develop in their own git). `update` / `outdated` skip linked entries so they never push upstream
150
+ into your dev repo. The mode is derived from the filesystem, never stored, so it can't go stale.
159
151
  - **Updates never clobber your tags** — domain tags live in the central `taxonomy.json`
160
152
  ([ADR-0002](./docs/adr/0002-central-taxonomy-not-sidecars.md)), separate from the skill body, so
161
153
  `skl update` can swap an owned skill's upstream `SKILL.md` cleanly while your taxonomy survives.
162
154
 
163
- ```
164
- skl search / ls / show skl use <bundle>
165
- │ │
166
- ┌──────────────────────┐ │ ┌──────────────────────┐ │ ┌─────────────────────┐
167
- │ canonical library │────┴──▶│ bundles = tag query │───┴──▶│ project .claude/
168
- │ (passive git shelf) │ │ bioinfo · coding · … │ │ skills/ (symlinks) │
169
- └──────────┬───────────┘ └──────────────────────┘ └─────────────────────┘
170
-
171
- └──── thin global core ──▶ ~/.claude/skills (always-on, bounded)
155
+ ```mermaid
156
+ flowchart LR
157
+ L["📚 Canonical library<br/><i>passive git shelf</i>"]
158
+ B["🏷️ Bundles<br/><i>tag query — bioinfo · coding · …</i>"]
159
+ P["📁 Project .claude/skills/<br/><i>symlinks, on demand</i>"]
160
+ G["⚡ ~/.claude/skills<br/><i>thin global core, always-on</i>"]
161
+
162
+ L -- "skl use bundle" --> B
163
+ B -- symlink --> P
164
+ L -- "thin global core" --> G
165
+
166
+ search(["skl search · ls · show"]) -. reads .-> L
167
+
168
+ classDef shelf fill:#1f2937,stroke:#4b5563,color:#e5e7eb;
169
+ classDef live fill:#064e3b,stroke:#10b981,color:#d1fae5;
170
+ class L,B shelf;
171
+ class P,G live;
172
172
  ```
173
173
 
174
174
  See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,118 @@
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 "./add.ts";
7
+ import type { Ctx } from "../types.ts";
8
+
9
+ interface Captured {
10
+ ctx: Ctx;
11
+ logs: string[];
12
+ errors: string[];
13
+ json: unknown[];
14
+ }
15
+
16
+ /** Minimal Ctx mock — add.run reads config.libraryPath + log/error/json. */
17
+ function makeCtx(libraryPath: string): Captured {
18
+ const logs: string[] = [];
19
+ const errors: string[] = [];
20
+ const json: unknown[] = [];
21
+ const ctx = {
22
+ config: { libraryPath },
23
+ libraryPath,
24
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
25
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
26
+ json: (v: unknown) => json.push(v),
27
+ } as unknown as Ctx;
28
+ return { ctx, logs, errors, json };
29
+ }
30
+
31
+ function skillBody(name: string): string {
32
+ return `---\nname: ${name}\ndescription: a ${name} skill for testing\n---\n\n# ${name}\n\nbody for ${name}\n`;
33
+ }
34
+
35
+ /** Build a real local git repo holding the given skills, for offline `git:` add. */
36
+ async function makeGitRepo(parent: string, skills: string[]): Promise<string> {
37
+ const repo = join(parent, "src-repo");
38
+ await mkdir(repo, { recursive: true });
39
+ for (const s of skills) {
40
+ await mkdir(join(repo, s), { recursive: true });
41
+ await writeFile(join(repo, s, "SKILL.md"), skillBody(s));
42
+ }
43
+ const run = (cmd: string[]) =>
44
+ Bun.spawn(cmd, { cwd: repo, stdout: "ignore", stderr: "ignore" }).exited;
45
+ await run(["git", "init", "-q"]);
46
+ await run(["git", "config", "user.email", "test@example.com"]);
47
+ await run(["git", "config", "user.name", "test"]);
48
+ await run(["git", "add", "-A"]);
49
+ await run(["git", "commit", "-q", "-m", "init"]);
50
+ return repo;
51
+ }
52
+
53
+ describe("skl add — retired-aware collision", () => {
54
+ let tmp: string;
55
+ let library: string;
56
+
57
+ beforeEach(async () => {
58
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-add-")));
59
+ library = join(tmp, "library");
60
+ await mkdir(library, { recursive: true });
61
+ });
62
+ afterEach(async () => {
63
+ await rm(tmp, { recursive: true, force: true });
64
+ });
65
+
66
+ test("--all over a repo where one name is retired: skips it (no duplicate), installs the rest", async () => {
67
+ const repo = await makeGitRepo(tmp, ["caveman", "tdd"]);
68
+ // Retire "caveman": a tombstone under _retired/, NO active copy.
69
+ await mkdir(join(library, "_retired", "caveman"), { recursive: true });
70
+ await writeFile(join(library, "_retired", "caveman", "SKILL.md"), skillBody("caveman"));
71
+
72
+ const { ctx, json } = makeCtx(library);
73
+ const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
74
+ expect(code).toBe(0);
75
+
76
+ const out = json[0] as {
77
+ results: Array<{ name: string; status: string; verdict: string; reason: string }>;
78
+ };
79
+ const caveman = out.results.find((r) => r.name === "caveman")!;
80
+ const tdd = out.results.find((r) => r.name === "tdd")!;
81
+
82
+ expect(caveman.status).toBe("skipped");
83
+ expect(caveman.verdict).toBe("retired");
84
+ expect(caveman.reason).toContain("skl unretire caveman");
85
+
86
+ // No active duplicate beside the tombstone.
87
+ expect(existsSync(join(library, "caveman"))).toBe(false);
88
+ expect(existsSync(join(library, "_retired", "caveman"))).toBe(true);
89
+
90
+ // The non-colliding name still installs.
91
+ expect(tdd.status).toBe("installed");
92
+ expect(existsSync(join(library, "tdd", "SKILL.md"))).toBe(true);
93
+ });
94
+
95
+ test("single add of a retired name refuses (exit 1, no duplicate)", async () => {
96
+ const repo = await makeGitRepo(tmp, ["caveman"]);
97
+ await mkdir(join(library, "_retired", "caveman"), { recursive: true });
98
+ await writeFile(join(library, "_retired", "caveman", "SKILL.md"), skillBody("caveman"));
99
+
100
+ const { ctx, errors } = makeCtx(library);
101
+ const code = await run([`git:${repo}`, "--no-infer"], ctx);
102
+ expect(code).toBe(1);
103
+ expect(errors.join("\n")).toContain("skl unretire caveman");
104
+ expect(existsSync(join(library, "caveman"))).toBe(false);
105
+ });
106
+
107
+ test("active-collision still refuses without --force (no regression)", async () => {
108
+ const repo = await makeGitRepo(tmp, ["tdd"]);
109
+ // An ACTIVE copy already exists.
110
+ await mkdir(join(library, "tdd"), { recursive: true });
111
+ await writeFile(join(library, "tdd", "SKILL.md"), skillBody("tdd") + "local edit\n");
112
+
113
+ const { ctx, errors } = makeCtx(library);
114
+ const code = await run([`git:${repo}`, "--no-infer"], ctx);
115
+ expect(code).toBe(1);
116
+ expect(errors.join("\n")).toContain("already exists");
117
+ });
118
+ });
@@ -38,7 +38,7 @@ import { hashContent } from "../core/crawl.ts";
38
38
  import { recordEntry } from "../core/provenance.ts";
39
39
  import { setDomainsForName } from "../core/taxonomy.ts";
40
40
  import { assertSafeName } from "../core/lifecycle.ts";
41
- import { loadLibrary, findByName } from "../core/library.ts";
41
+ import { loadLibrary, findByName, entryStatus } from "../core/library.ts";
42
42
  import { ensureDir, isSymlink, realpathOrSelf } from "../lib/fs.ts";
43
43
 
44
44
  export const meta = {
@@ -217,7 +217,7 @@ interface InstallOptions {
217
217
  interface InstallOutcome {
218
218
  name: string;
219
219
  subpath: string;
220
- verdict: Verdict | "duplicate";
220
+ verdict: Verdict | "duplicate" | "retired";
221
221
  status: "installed" | "skipped" | "error";
222
222
  reason: string;
223
223
  path: string;
@@ -268,6 +268,21 @@ async function installOne(
268
268
  return { ...base, reason: err instanceof Error ? err.message : String(err) };
269
269
  }
270
270
 
271
+ // Retired-aware collision guard: if this name exists ONLY as a retired tombstone
272
+ // (<library>/_retired/<name>), do NOT install a fresh active copy beside it — that
273
+ // strands a duplicate and breaks `skl unretire`. The user must unretire first. This
274
+ // fires regardless of --force (force overwrites an ACTIVE copy, not a retired one).
275
+ // Checked against the flat library root (retirement is never under a domain folder).
276
+ const status = entryStatus(opts.libraryPath, rawName);
277
+ if (status.retired && !status.active) {
278
+ return {
279
+ ...base,
280
+ verdict: "retired",
281
+ status: "skipped",
282
+ reason: `a retired '${rawName}' exists — run \`skl unretire ${rawName}\` first`,
283
+ };
284
+ }
285
+
271
286
  const destDir = destDirFor(opts.libraryPath, opts.domainFolder, rawName);
272
287
  const verdict = await driftVerdict(skill, destDir);
273
288
  base.verdict = verdict;
@@ -608,6 +623,12 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
608
623
  ctx.error("add:", o.reason);
609
624
  return 1;
610
625
  }
626
+ // A retired-name collision is a refusal in single mode (no duplicate written):
627
+ // surface it as an error + non-zero exit, pointing the user at `skl unretire`.
628
+ if (o.status === "skipped") {
629
+ ctx.error("add:", o.reason);
630
+ return 1;
631
+ }
611
632
  // Legacy single-skill summary shape (unchanged for existing consumers).
612
633
  const summary = {
613
634
  ok: true,
@@ -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.
@@ -100,6 +100,20 @@ describe("skl link --from (LINKED mode)", () => {
100
100
  expect((await lstat(join(library, "claim-log"))).isSymbolicLink()).toBe(false);
101
101
  });
102
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);
115
+ });
116
+
103
117
  test("--force replaces an owned copy with the symlink and reports discarded", async () => {
104
118
  await makeSkillDir(library, "claim-log");
105
119
  const src = await makeSkillDir(devRepo, "claim-log");
@@ -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) {
@@ -0,0 +1,63 @@
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 "./new.ts";
7
+ import type { Ctx } from "../types.ts";
8
+
9
+ interface Captured {
10
+ ctx: Ctx;
11
+ logs: string[];
12
+ errors: string[];
13
+ json: unknown[];
14
+ }
15
+
16
+ function makeCtx(libraryPath: string): Captured {
17
+ const logs: string[] = [];
18
+ const errors: string[] = [];
19
+ const json: unknown[] = [];
20
+ const ctx = {
21
+ config: { libraryPath },
22
+ libraryPath,
23
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
24
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
25
+ json: (v: unknown) => json.push(v),
26
+ } as unknown as Ctx;
27
+ return { ctx, logs, errors, json };
28
+ }
29
+
30
+ describe("skl new — retired-aware collision", () => {
31
+ let tmp: string;
32
+ let library: string;
33
+
34
+ beforeEach(async () => {
35
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-new-")));
36
+ library = join(tmp, "library");
37
+ await mkdir(library, { recursive: true });
38
+ });
39
+ afterEach(async () => {
40
+ await rm(tmp, { recursive: true, force: true });
41
+ });
42
+
43
+ test("`skl new <retired-name>` refuses (exit 1, no write)", async () => {
44
+ await mkdir(join(library, "_retired", "caveman"), { recursive: true });
45
+ await writeFile(
46
+ join(library, "_retired", "caveman", "SKILL.md"),
47
+ "---\nname: caveman\ndescription: a test skill\n---\n\nbody\n",
48
+ );
49
+
50
+ const { ctx, errors } = makeCtx(library);
51
+ const code = await run(["caveman"], ctx);
52
+ expect(code).toBe(1);
53
+ expect(errors.join("\n")).toContain("skl unretire caveman");
54
+ expect(existsSync(join(library, "caveman"))).toBe(false);
55
+ });
56
+
57
+ test("`skl new <fresh-name>` still scaffolds (no regression)", async () => {
58
+ const { ctx } = makeCtx(library);
59
+ const code = await run(["fresh-skill"], ctx);
60
+ expect(code).toBe(0);
61
+ expect(existsSync(join(library, "fresh-skill", "SKILL.md"))).toBe(true);
62
+ });
63
+ });
@@ -11,6 +11,7 @@ import { existsSync } from "node:fs";
11
11
  import type { Ctx } from "../types.ts";
12
12
  import { serializeFrontmatter } from "../lib/frontmatter.ts";
13
13
  import { ensureDir } from "../lib/fs.ts";
14
+ import { entryStatus } from "../core/library.ts";
14
15
 
15
16
  export const meta = {
16
17
  name: "new",
@@ -119,6 +120,17 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
119
120
  const skillDir = join(libraryPath, name);
120
121
  const bodyPath = join(skillDir, "SKILL.md");
121
122
 
123
+ // Retired-aware guard: refuse if the name exists ONLY as a retired tombstone
124
+ // (<library>/_retired/<name>). Scaffolding a fresh active copy beside it would
125
+ // strand a duplicate and break `skl unretire`; this fires regardless of --force.
126
+ const status = entryStatus(libraryPath, name);
127
+ if (status.retired && !status.active) {
128
+ ctx.error(
129
+ `skl new: a retired '${name}' exists — run \`skl unretire ${name}\` first (or choose another name)`,
130
+ );
131
+ return 1;
132
+ }
133
+
122
134
  if (existsSync(bodyPath) && !args.force) {
123
135
  ctx.error(
124
136
  `skl new: SKILL.md already exists at ${bodyPath} — pass --force to overwrite`,
@@ -69,6 +69,20 @@ describe("skl rename — atomic slug move (friction #5)", () => {
69
69
  expect(errors.join("\n")).toContain("already exists");
70
70
  });
71
71
 
72
+ test("refuses renaming TO a retired name (points at unretire)", async () => {
73
+ // "beta" exists only as a retired tombstone — no active entry.
74
+ await mkdir(join(library, "_retired", "beta"), { recursive: true });
75
+ await writeFile(join(library, "_retired", "beta", "SKILL.md"), "---\nname: beta\n---\n\nb\n");
76
+
77
+ const { ctx, errors } = makeCtx(library);
78
+ const code = await renameRun(["alpha", "beta"], ctx);
79
+ expect(code).toBe(1);
80
+ expect(errors.join("\n")).toContain("skl unretire beta");
81
+ // alpha untouched; no active beta created.
82
+ expect(existsSync(join(library, "alpha", "SKILL.md"))).toBe(true);
83
+ expect(existsSync(join(library, "beta"))).toBe(false);
84
+ });
85
+
72
86
  test("refuses a missing source", async () => {
73
87
  const { ctx, errors } = makeCtx(library);
74
88
  const code = await renameRun(["ghost", "x"], ctx);
@@ -123,6 +123,60 @@ export function entryModeInfo(libraryPath: string, name: string): EntryModeInfo
123
123
  return owned ? { mode: "owned", linkTarget: null } : { mode: "linked", linkTarget: real };
124
124
  }
125
125
 
126
+ /** Directory holding retired (soft-deleted) tombstones, relative to the library root. */
127
+ export const RETIRED_DIR = "_retired";
128
+
129
+ /**
130
+ * Reject a skill name that is not a single path segment — the choke point that keeps a
131
+ * crafted/typo'd/agent-supplied `name` (e.g. "../../etc") from escaping the library when
132
+ * it reaches `join(libraryPath, name)` and then `rm`/`rename`/copy. A name with no path
133
+ * separator and no `.`/`..` cannot resolve outside its parent dir, so containment is
134
+ * guaranteed without over-restricting otherwise-unusual existing slugs. Throws on a bad
135
+ * name. Lives here (not in lifecycle.ts) so it sits beside entryStatus — the single
136
+ * existence-resolution primitive both the read guards (add/import/new/link) and the write
137
+ * mutations (lifecycle.ts re-exports it) funnel through.
138
+ */
139
+ export function assertSafeName(name: string): void {
140
+ if (
141
+ name === "" ||
142
+ name === "." ||
143
+ name === ".." ||
144
+ name.includes("/") ||
145
+ name.includes("\\") ||
146
+ name.includes("\0")
147
+ ) {
148
+ throw new Error(`invalid skill name '${name}' — must be a single name, no path separators or '..'`);
149
+ }
150
+ }
151
+
152
+ /** Whether a name occupies the active and/or retired slot in the library. */
153
+ export interface EntryStatus {
154
+ /** <library>/<name> exists (real dir or symlink) */
155
+ active: boolean;
156
+ /** <library>/_retired/<name> exists (real dir or symlink) */
157
+ retired: boolean;
158
+ }
159
+
160
+ /**
161
+ * Single source of truth for "is this name taken?" across BOTH locations a skill can
162
+ * live: the active slot <library>/<name> and the retired tombstone
163
+ * <library>/_retired/<name>. Existence = existsSync OR isSymlink, so a LINKED entry (a
164
+ * symlink whose target may be absent) still counts as present. Name-validated via
165
+ * assertSafeName so a path-escaping `name` can never be joined; collision guards in
166
+ * add/import/new/link and the write mutations in lifecycle.ts (which delegates locateEntry
167
+ * to this) both resolve existence here, so the active+retired rule lives in exactly one
168
+ * place. Kept dependency-free of lifecycle.ts to avoid an import cycle.
169
+ */
170
+ export function entryStatus(libraryPath: string, name: string): EntryStatus {
171
+ assertSafeName(name);
172
+ const activePath = join(libraryPath, name);
173
+ const retiredPath = join(libraryPath, RETIRED_DIR, name);
174
+ return {
175
+ active: existsSync(activePath) || isSymlink(activePath),
176
+ retired: existsSync(retiredPath) || isSymlink(retiredPath),
177
+ };
178
+ }
179
+
126
180
  /** All unique domains across the library (sorted). */
127
181
  export function listDomains(skills: Skill[]): string[] {
128
182
  const set = new Set<string>();
@@ -18,32 +18,13 @@ import { isSymlink, ensureDir } from "../lib/fs.ts";
18
18
  import { parseFrontmatter, serializeFrontmatter } from "../lib/frontmatter.ts";
19
19
  import { readTaxonomy, writeTaxonomy } from "./taxonomy.ts";
20
20
  import { readLockfile, writeLockfile } from "./provenance.ts";
21
- import { loadLibrary } from "./library.ts";
21
+ import { loadLibrary, entryStatus, RETIRED_DIR, assertSafeName } from "./library.ts";
22
22
  import { writeIndex } from "./indexgen.ts";
23
23
 
24
- const RETIRED_DIR = "_retired";
25
-
26
- /**
27
- * Reject a skill name that is not a single path segment — the choke point that keeps
28
- * a crafted/typo'd/agent-supplied `name` (e.g. "../../etc") from escaping the library
29
- * when it reaches `join(libraryPath, name)` and then `rm`/`rename`. A name with no path
30
- * separator and no `.`/`..` cannot resolve outside its parent dir, so containment is
31
- * guaranteed without over-restricting otherwise-unusual existing slugs. Throws on a bad
32
- * name; every name-keyed mutation funnels through locateEntry, so validating here covers
33
- * removeSkill / retireSkill / unretireSkill / renameSkill in one place.
34
- */
35
- export function assertSafeName(name: string): void {
36
- if (
37
- name === "" ||
38
- name === "." ||
39
- name === ".." ||
40
- name.includes("/") ||
41
- name.includes("\\") ||
42
- name.includes("\0")
43
- ) {
44
- throw new Error(`invalid skill name '${name}' — must be a single name, no path separators or '..'`);
45
- }
46
- }
24
+ // assertSafeName + RETIRED_DIR live in library.ts beside entryStatus (the single
25
+ // existence-resolution primitive) and are re-exported here so existing importers that
26
+ // reach for them via lifecycle.ts (e.g. add.ts) keep working.
27
+ export { assertSafeName, RETIRED_DIR };
47
28
 
48
29
  /** Regenerate INDEX.md from the current library state. Returns the path written. */
49
30
  export async function reindexLibrary(libraryPath: string): Promise<string> {
@@ -63,14 +44,20 @@ export interface EntryLocation {
63
44
  isLink: boolean;
64
45
  }
65
46
 
66
- /** Locate a skill entry across the active and retired locations. */
47
+ /**
48
+ * Locate a skill entry across the active and retired locations. The active/retired
49
+ * existence rule is NOT re-implemented here — it delegates to entryStatus (the single
50
+ * source of truth, which also runs assertSafeName), then enriches the two booleans with
51
+ * the resolved path (active preferred) and whether that path is a symlink, the extra the
52
+ * write mutations need.
53
+ */
67
54
  export function locateEntry(libraryPath: string, name: string): EntryLocation {
68
- assertSafeName(name);
69
- const activePath = join(libraryPath, name);
70
- const retiredPath = join(libraryPath, RETIRED_DIR, name);
71
- const active = existsSync(activePath) || isSymlink(activePath);
72
- const retired = existsSync(retiredPath) || isSymlink(retiredPath);
73
- const path = active ? activePath : retired ? retiredPath : null;
55
+ const { active, retired } = entryStatus(libraryPath, name);
56
+ const path = active
57
+ ? join(libraryPath, name)
58
+ : retired
59
+ ? join(libraryPath, RETIRED_DIR, name)
60
+ : null;
74
61
  const isLink = path ? isSymlink(path) : false;
75
62
  return { active, retired, path, isLink };
76
63
  }
@@ -181,7 +168,14 @@ export async function renameSkill(
181
168
  ): Promise<RenameResult> {
182
169
  const loc = locateEntry(libraryPath, from);
183
170
  if (!loc.path) throw new Error(`'${from}' is not in the library`);
184
- if (locateEntry(libraryPath, to).path) {
171
+ const dest = locateEntry(libraryPath, to);
172
+ if (dest.path) {
173
+ // A retired-only target is a tombstone, not a usable name: point at `skl unretire`
174
+ // (renaming onto it would either collide or shadow the retired copy) rather than the
175
+ // generic "choose another name". An active target keeps the original message.
176
+ if (dest.retired && !dest.active) {
177
+ throw new Error(`a retired '${to}' exists — run \`skl unretire ${to}\` first (or choose another name)`);
178
+ }
185
179
  throw new Error(`'${to}' already exists in the library — choose another name`);
186
180
  }
187
181