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 +33 -33
- package/package.json +1 -1
- package/src/commands/add.test.ts +118 -0
- package/src/commands/add.ts +23 -2
- package/src/commands/import.test.ts +71 -0
- package/src/commands/import.ts +13 -0
- package/src/commands/link.test.ts +14 -0
- package/src/commands/link.ts +11 -0
- package/src/commands/new.test.ts +63 -0
- package/src/commands/new.ts +12 -0
- package/src/commands/rename.test.ts +14 -0
- package/src/core/library.ts +54 -0
- package/src/core/lifecycle.ts +26 -32
package/README.md
CHANGED
|
@@ -7,22 +7,17 @@
|
|
|
7
7
|
[](https://github.com/Wang-Cankun/skillshelf/actions)
|
|
8
8
|
[](https://www.npmjs.com/package/skillshelf)
|
|
9
9
|
|
|
10
|
-
Your skills are scattered across **every agent you use** —
|
|
11
|
-
`~/.
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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)) —
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
@@ -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
|
+
});
|
package/src/commands/add.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/import.ts
CHANGED
|
@@ -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");
|
package/src/commands/link.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/commands/new.ts
CHANGED
|
@@ -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);
|
package/src/core/library.ts
CHANGED
|
@@ -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>();
|
package/src/core/lifecycle.ts
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
|