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
package/README.md CHANGED
@@ -7,22 +7,42 @@
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).)
21
+
22
+ ## Desktop app
23
+
24
+ A cross-platform desktop UI (React + Tauri) sits on top of the same engine a **Library-first**
25
+ workbench for managing where every skill is deployed across your agents. It reads the **real**
26
+ `skl` library (no separate data store) and every toggle writes the same symlinks the CLI does.
27
+
28
+ ![skillshelf — Library view](docs/images/main.png)
29
+
30
+ - **Library-first, skill-centric list** — scan all skills, flip them on/off per agent inline; a
31
+ top **scope switcher** (Global · each project · + Add project) and a per-scope count bar.
32
+ - **Project scopes** — manage a project's loadout without leaving the app; globally-deployed
33
+ skills show an *"active via Global"* inherited state so you always know what's effectively live.
34
+ - **Two-tier toggles** — a clean on/off for the happy path, but drift / copy / dead / aliased
35
+ surface a resolve flow instead of silently doing the wrong thing (state is derived from the
36
+ filesystem, never stored — so the UI can't lie about what's deployed).
37
+
38
+ ![skillshelf — skill detail drawer](docs/images/detail.png)
39
+
40
+ The detail drawer shows a skill's body, tags, provenance, and an `agent × scope` deployment
41
+ matrix (Global + the projects where it's pinned), plus lifecycle actions.
42
+
43
+ > Browser/dev mode (`cd app && bun run dev`) renders synthetic fixtures with no backend; the
44
+ > packaged desktop app talks to your real `skl` engine. See [`docs/adr/0010-*`](docs/adr/) for the
45
+ > design.
26
46
 
27
47
  ## Install
28
48
 
@@ -85,7 +105,7 @@ skl scan # report every candidate + duplicate/drift group
85
105
  # 2. Adopt the ones you want, one at a time. Each import moves the skill into the
86
106
  # library and leaves a symlink behind so old paths keep resolving.
87
107
  skl import rnaseq-qc --from ~/.claude/skills/rnaseq-qc
88
- skl import xhs-title --from ~/notes/.agents/skills/xhs-title
108
+ skl import headline-picker --from ~/notes/.agents/skills/headline-picker
89
109
 
90
110
  # For a skill living inside a project repo, copy instead of move (no symlink left behind):
91
111
  skl import deploy-check --from ~/projects/web/.claude/skills/deploy-check --copy
@@ -95,7 +115,7 @@ skl import rnaseq-qc --from ~/projects/lab/.claude/skills/rnaseq-qc --force
95
115
 
96
116
  # For a skill you actively develop in its own git repo, shelve a LINK instead of a copy —
97
117
  # the repo stays canonical and edits show up live, no drift, no re-sync (ADR-0004):
98
- skl link --from ~/Documents/GitHub/cairn/skill/cairn
118
+ skl link --from ~/Documents/GitHub/claim-log/skill/claim-log
99
119
 
100
120
  # 3. Tag the now-populated library in one pass. Domain is tags, not folders, so this
101
121
  # runs AFTER import with no reorg — no skill ever has to move because a tag changed.
@@ -123,27 +143,32 @@ skillshelf separates *owning* a skill from *loading* it.
123
143
  - **On-demand `show`** — prints only the SKILL.md instruction body and lists the paths of
124
144
  any bundled reference files (without reading them). Progressive disclosure: cheap by
125
145
  default, deep when you ask. Works mid-task with no reload.
126
- - **Owned vs linked entries** ([ADR-0004](./docs/adr/0004-owned-vs-linked-entries.md)) — the
127
- library is a *bookshelf*: an entry either **owns** its bytes (a real copy; the library is
128
- canonical for downloads and stabilized skills) or is **linked** (a symlink to an external dev
129
- repo that stays canonical for skills you actively develop in their own git, e.g. `cairn`).
130
- `skl link --from <dev-repo>` registers a linked entry; `skl where` shows it as a clean
131
- `✓ source`; `skl update` / `outdated` skip linked entries so they never pull upstream into your
132
- dev repo. The mode is derived from the filesystem (a symlink resolving outside the library),
133
- 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.
134
151
  - **Updates never clobber your tags** — domain tags live in the central `taxonomy.json`
135
152
  ([ADR-0002](./docs/adr/0002-central-taxonomy-not-sidecars.md)), separate from the skill body, so
136
153
  `skl update` can swap an owned skill's upstream `SKILL.md` cleanly while your taxonomy survives.
137
154
 
138
- ```
139
- skl search / ls / show skl use <bundle>
140
- │ │
141
- ┌──────────────────────┐ │ ┌──────────────────────┐ │ ┌─────────────────────┐
142
- │ canonical library │────┴──▶│ bundles = tag query │───┴──▶│ project .claude/
143
- │ (passive git shelf) │ │ bioinfo · coding · … │ │ skills/ (symlinks) │
144
- └──────────┬───────────┘ └──────────────────────┘ └─────────────────────┘
145
-
146
- └──── 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;
147
172
  ```
148
173
 
149
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.3.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",
package/src/cli.ts CHANGED
@@ -29,8 +29,12 @@ import * as infer from "./commands/infer.ts";
29
29
  import * as newCmd from "./commands/new.ts";
30
30
  import * as scan from "./commands/scan.ts";
31
31
  import * as roots from "./commands/roots.ts";
32
+ import * as projects from "./commands/projects.ts";
32
33
  import * as importCmd from "./commands/import.ts";
33
34
  import * as link from "./commands/link.ts";
35
+ import * as track from "./commands/track.ts";
36
+ import * as untrack from "./commands/untrack.ts";
37
+ import * as migrate from "./commands/migrate.ts";
34
38
  import * as where from "./commands/where.ts";
35
39
  import * as agents from "./commands/agents.ts";
36
40
  import * as tag from "./commands/tag.ts";
@@ -56,8 +60,12 @@ const MODULES: CommandModule[] = [
56
60
  add,
57
61
  scan,
58
62
  roots,
63
+ projects,
59
64
  importCmd,
60
65
  link,
66
+ track,
67
+ untrack,
68
+ migrate,
61
69
  tag,
62
70
  untag,
63
71
  retag,
@@ -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,144 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, readFile, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run as outdatedRun } from "./outdated.ts";
6
+ import { run as updateRun } from "./update.ts";
7
+ import { readLockfile, recordEntry } from "../core/provenance.ts";
8
+ import type { Ctx, LockEntry } from "../types.ts";
9
+
10
+ function makeCtx(libraryPath: string) {
11
+ const logs: string[] = [];
12
+ const errors: string[] = [];
13
+ const json: unknown[] = [];
14
+ const ctx = {
15
+ config: { libraryPath },
16
+ libraryPath,
17
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
18
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
19
+ json: (v: unknown) => json.push(v),
20
+ } as unknown as Ctx;
21
+ return { ctx, logs, errors, json };
22
+ }
23
+
24
+ /** Build a tiny on-disk git repo to act as a `git:` upstream for one skill. */
25
+ async function makeGitUpstream(dir: string, name: string, body: string): Promise<void> {
26
+ await mkdir(join(dir, name), { recursive: true });
27
+ await writeFile(join(dir, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n\n${body}\n`);
28
+ const git = (args: string[]) => Bun.spawnSync(["git", "-C", dir, ...args], { stdout: "ignore", stderr: "ignore" });
29
+ git(["init", "-q"]);
30
+ git(["config", "user.email", "t@t.t"]);
31
+ git(["config", "user.name", "t"]);
32
+ git(["add", "-A"]);
33
+ git(["commit", "-q", "-m", "init"]);
34
+ }
35
+
36
+ describe("adopted entries — outdated reports 'adopted', not stale", () => {
37
+ let tmp: string;
38
+ let library: string;
39
+
40
+ beforeEach(async () => {
41
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-adopted-outdated-")));
42
+ library = join(tmp, "library");
43
+ await mkdir(join(library, "foo"), { recursive: true });
44
+ await writeFile(join(library, "foo", "SKILL.md"), "---\nname: foo\ndescription: d\n---\n\nbody\n");
45
+ const entry: LockEntry = {
46
+ name: "foo",
47
+ source: "github:owner/repo",
48
+ ref: "",
49
+ channel: "github",
50
+ installedAt: "2024-01-01T00:00:00.000Z",
51
+ localEdits: false,
52
+ installedHash: "abc",
53
+ adopted: true,
54
+ };
55
+ await recordEntry(library, entry);
56
+ });
57
+ afterEach(async () => {
58
+ await rm(tmp, { recursive: true, force: true });
59
+ });
60
+
61
+ test("status is 'adopted' and never counted stale (no upstream probe)", async () => {
62
+ const { ctx, json } = makeCtx(library);
63
+ const code = await outdatedRun(["--json"], ctx);
64
+ expect(code).toBe(0); // not stale -> exit 0, no network probe of the empty ref
65
+ const report = json[0] as { stale: number; rows: Array<{ name: string; status: string; note: string }> };
66
+ expect(report.stale).toBe(0);
67
+ const row = report.rows.find((r) => r.name === "foo")!;
68
+ expect(row.status).toBe("adopted");
69
+ expect(row.note).toContain("baseline unverified");
70
+ });
71
+ });
72
+
73
+ describe("adopted entries — update is conservative and graduates", () => {
74
+ let tmp: string;
75
+ let library: string;
76
+ let upstream: string;
77
+
78
+ async function seedAdopted(libBody: string, upstreamBody: string): Promise<void> {
79
+ await mkdir(join(library, "foo"), { recursive: true });
80
+ await writeFile(join(library, "foo", "SKILL.md"), `---\nname: foo\ndescription: d\n---\n\n${libBody}\n`);
81
+ upstream = join(tmp, "upstream");
82
+ await makeGitUpstream(upstream, "foo", upstreamBody);
83
+ const entry: LockEntry = {
84
+ name: "foo",
85
+ source: `git:${upstream}#foo`,
86
+ ref: "",
87
+ channel: "git",
88
+ installedAt: "2024-01-01T00:00:00.000Z",
89
+ localEdits: false,
90
+ installedHash: "unverified-baseline-hash",
91
+ adopted: true,
92
+ };
93
+ await recordEntry(library, entry);
94
+ }
95
+
96
+ beforeEach(async () => {
97
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-adopted-update-")));
98
+ library = join(tmp, "library");
99
+ await mkdir(library, { recursive: true });
100
+ });
101
+ afterEach(async () => {
102
+ await rm(tmp, { recursive: true, force: true });
103
+ });
104
+
105
+ test("differing body: requires --force, shows diff, does not clobber", async () => {
106
+ await seedAdopted("LOCAL BODY", "UPSTREAM BODY");
107
+ const { ctx, json } = makeCtx(library);
108
+ const code = await updateRun(["foo", "--json"], ctx);
109
+ // diverged -> exit 2
110
+ expect(code).toBe(2);
111
+ const report = json[0] as { results: Array<{ name: string; outcome: string }> };
112
+ expect(report.results.find((r) => r.name === "foo")!.outcome).toBe("diverged");
113
+ // local body untouched
114
+ const body = await readFile(join(library, "foo", "SKILL.md"), "utf8");
115
+ expect(body).toContain("LOCAL BODY");
116
+ // still adopted (not graduated)
117
+ expect((await readLockfile(library)).entries.foo!.adopted).toBe(true);
118
+ });
119
+
120
+ test("differing body with --force: overwrites and graduates (adopted cleared)", async () => {
121
+ await seedAdopted("LOCAL BODY", "UPSTREAM BODY");
122
+ const { ctx } = makeCtx(library);
123
+ const code = await updateRun(["foo", "--force"], ctx);
124
+ expect(code).toBe(0);
125
+ const body = await readFile(join(library, "foo", "SKILL.md"), "utf8");
126
+ expect(body).toContain("UPSTREAM BODY");
127
+ const e = (await readLockfile(library)).entries.foo!;
128
+ expect(e.adopted).toBe(false); // graduated
129
+ expect(e.ref).not.toBe(""); // real commit pinned
130
+ expect(e.localEdits).toBe(false);
131
+ });
132
+
133
+ test("identical body: graduates without --force (lossless)", async () => {
134
+ await seedAdopted("SAME BODY", "SAME BODY");
135
+ const { ctx } = makeCtx(library);
136
+ const code = await updateRun(["foo"], ctx);
137
+ expect(code).toBe(0);
138
+ const e = (await readLockfile(library)).entries.foo!;
139
+ expect(e.adopted).toBe(false); // graduated
140
+ expect(e.ref).not.toBe("");
141
+ // installedHash now reflects the verified upstream body.
142
+ expect(e.installedHash).not.toBe("unverified-baseline-hash");
143
+ });
144
+ });
@@ -0,0 +1,126 @@
1
+ // `skl agents add|rm` write verbs (ADR-0010 delta 4). Drives the real ctx through
2
+ // an isolated SKILLSHELF_CONFIG so the GUI round-trip is pinned: a registered
3
+ // custom agent persists, reads back tagged `custom:true` in `agents --json` (the
4
+ // flag loadConfig() filters on), and `rm` removes it. This is the round-trip the
5
+ // review flagged as a non-persisting stub.
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { loadContext } from "../config.ts";
12
+ import { run as agentsRun } from "./agents.ts";
13
+ import type { AgentsReport } from "../core/agents.ts";
14
+ import type { Ctx } from "../types.ts";
15
+
16
+ describe("skl agents add|rm write verbs (ADR-0010 delta 4)", () => {
17
+ let tmp: string;
18
+ let cfg: string;
19
+ let library: string;
20
+
21
+ async function makeCtx(): Promise<{ ctx: Ctx; json: unknown[] }> {
22
+ const json: unknown[] = [];
23
+ const ctx = await loadContext({
24
+ env: {
25
+ SKILLSHELF_CONFIG: cfg,
26
+ SKILLSHELF_LIBRARY: library,
27
+ SKILLSHELF_GLOBAL_CORE: join(tmp, ".no-global-core"),
28
+ } as NodeJS.ProcessEnv,
29
+ });
30
+ ctx.json = (v: unknown) => json.push(v);
31
+ ctx.log = () => {};
32
+ ctx.error = () => {};
33
+ return { ctx, json };
34
+ }
35
+
36
+ beforeEach(async () => {
37
+ tmp = await mkdtemp(join(tmpdir(), "skl-agents-config-"));
38
+ cfg = join(tmp, "config.json");
39
+ library = join(tmp, "library");
40
+ await mkdir(library, { recursive: true });
41
+ await writeFile(
42
+ join(library, "config.json"),
43
+ "", // placeholder so library dir is non-empty; real skills not needed here
44
+ );
45
+ });
46
+ afterEach(async () => {
47
+ await rm(tmp, { recursive: true, force: true });
48
+ });
49
+
50
+ test("add persists a custom agent and reports the updated list", async () => {
51
+ const { ctx, json } = await makeCtx();
52
+ const code = await agentsRun(
53
+ [
54
+ "add",
55
+ "cursor",
56
+ "--name",
57
+ "Cursor",
58
+ "--global",
59
+ "~/.cursor/skills",
60
+ "--proj-convention",
61
+ ".cursor/skills",
62
+ "--icon",
63
+ "cursor",
64
+ "--json",
65
+ ],
66
+ ctx,
67
+ );
68
+ expect(code).toBe(0);
69
+ const out = json[0] as { agents: Array<{ id: string; icon?: string }>; added: boolean };
70
+ expect(out.added).toBe(true);
71
+ expect(out.agents.map((a) => a.id)).toContain("cursor");
72
+ expect(out.agents.find((a) => a.id === "cursor")?.icon).toBe("cursor");
73
+ });
74
+
75
+ test("a persisted custom agent reads back tagged custom:true in agents --json", async () => {
76
+ const { ctx } = await makeCtx();
77
+ await agentsRun(
78
+ ["add", "cursor", "--name", "Cursor", "--global", "~/.cursor/skills", "--proj-convention", ".cursor/skills"],
79
+ ctx,
80
+ );
81
+
82
+ // fresh ctx reads config from disk
83
+ const { ctx: ctx2, json } = await makeCtx();
84
+ await agentsRun(["--json"], ctx2);
85
+ const report = json[0] as AgentsReport;
86
+ const cursor = report.agents.find((a) => a.id === "cursor");
87
+ expect(cursor).toBeDefined();
88
+ expect(cursor!.custom).toBe(true);
89
+ // built-in seeds are NOT tagged custom (loadConfig must not pick them up).
90
+ const claude = report.agents.find((a) => a.id === "claude");
91
+ expect(claude?.custom).toBeUndefined();
92
+ });
93
+
94
+ test("rm removes a persisted custom agent", async () => {
95
+ const { ctx } = await makeCtx();
96
+ await agentsRun(
97
+ ["add", "cursor", "--name", "Cursor", "--global", "~/.cursor/skills", "--proj-convention", ".cursor/skills"],
98
+ ctx,
99
+ );
100
+
101
+ const { ctx: ctx2, json } = await makeCtx();
102
+ const code = await agentsRun(["rm", "cursor", "--json"], ctx2);
103
+ expect(code).toBe(0);
104
+ const out = json[0] as { agents: unknown[]; removed: boolean };
105
+ expect(out.removed).toBe(true);
106
+ expect(out.agents).toEqual([]);
107
+ });
108
+
109
+ test("rm on a non-registered id reports removed:false", async () => {
110
+ const { ctx, json } = await makeCtx();
111
+ const code = await agentsRun(["rm", "nope", "--json"], ctx);
112
+ expect(code).toBe(0);
113
+ expect(json[0]).toEqual({ agents: [], removed: false });
114
+ });
115
+
116
+ test("add without required path flags errors", async () => {
117
+ const { ctx } = await makeCtx();
118
+ expect(await agentsRun(["add", "cursor", "--name", "Cursor"], ctx)).toBe(1);
119
+ });
120
+
121
+ test("add/rm without an id errors", async () => {
122
+ const { ctx } = await makeCtx();
123
+ expect(await agentsRun(["add"], ctx)).toBe(1);
124
+ expect(await agentsRun(["rm"], ctx)).toBe(1);
125
+ });
126
+ });