skillshelf 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +57 -19
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +31 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/agents.ts +120 -0
  7. package/src/commands/drop.ts +21 -13
  8. package/src/commands/import.ts +44 -28
  9. package/src/commands/infer.ts +6 -6
  10. package/src/commands/link.test.ts +160 -0
  11. package/src/commands/link.ts +317 -0
  12. package/src/commands/ls.ts +118 -18
  13. package/src/commands/mode-surfacing.test.ts +110 -0
  14. package/src/commands/outdated.test.ts +55 -0
  15. package/src/commands/outdated.ts +138 -18
  16. package/src/commands/refresh.ts +133 -0
  17. package/src/commands/remediation.test.ts +149 -0
  18. package/src/commands/rename.test.ts +121 -0
  19. package/src/commands/rename.ts +64 -0
  20. package/src/commands/retag.ts +58 -0
  21. package/src/commands/retire.ts +39 -0
  22. package/src/commands/rm.test.ts +133 -0
  23. package/src/commands/rm.ts +107 -0
  24. package/src/commands/roots.ts +41 -0
  25. package/src/commands/scan.ts +122 -30
  26. package/src/commands/show.ts +4 -1
  27. package/src/commands/status.ts +43 -8
  28. package/src/commands/tag.test.ts +109 -0
  29. package/src/commands/tag.ts +68 -0
  30. package/src/commands/unretire.ts +33 -0
  31. package/src/commands/untag.ts +73 -0
  32. package/src/commands/update.test.ts +71 -0
  33. package/src/commands/update.ts +65 -15
  34. package/src/commands/use.test.ts +92 -0
  35. package/src/commands/use.ts +46 -23
  36. package/src/commands/where.ts +232 -0
  37. package/src/config.test.ts +69 -0
  38. package/src/config.ts +79 -10
  39. package/src/core/agents.test.ts +232 -0
  40. package/src/core/agents.ts +363 -0
  41. package/src/core/bundle.ts +12 -15
  42. package/src/core/core.test.ts +14 -1
  43. package/src/core/crawl.ts +22 -5
  44. package/src/core/dedupe.ts +36 -0
  45. package/src/core/deployments.test.ts +147 -0
  46. package/src/core/deployments.ts +208 -0
  47. package/src/core/fetch.ts +344 -70
  48. package/src/core/indexgen.ts +2 -0
  49. package/src/core/library.test.ts +41 -0
  50. package/src/core/library.ts +61 -16
  51. package/src/core/lifecycle.ts +252 -0
  52. package/src/core/surfaces.ts +46 -0
  53. package/src/core/taxonomy.test.ts +159 -0
  54. package/src/core/taxonomy.ts +190 -0
  55. package/src/lib/fs.ts +2 -2
  56. package/src/types.ts +85 -15
  57. package/src/core/overlay.ts +0 -63
package/README.md CHANGED
@@ -7,11 +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: some in `~/.claude/skills`, some buried in Obsidian or notes
11
- vaults, more copied into a dozen per-project `.claude` directories. You forget which ones
12
- exist, rewrite ones you already have, and copies drift out of sync. The naive fix — dump
13
- everything into `~/.claude/skills` makes every session pay the token cost of loading
14
- hundreds of skill descriptions at once.
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).
15
21
 
16
22
  skillshelf is the middle path: a single git-backed **library** that is a *passive shelf*
17
23
  (nothing auto-loads), plus a CLI to **search, tag, bundle, and load** exactly the skills a
@@ -22,6 +28,11 @@ actually use.
22
28
 
23
29
  skillshelf runs on [Bun](https://bun.sh) (>= 1.0). No other runtime dependencies.
24
30
 
31
+ > **Bun is required, not optional.** The `skl` bin is a TypeScript entrypoint with a
32
+ > `#!/usr/bin/env bun` shebang — there is no compiled Node build. `npm i -g skillshelf`
33
+ > will *not* give you a working `skl` (a `preinstall` guard aborts with a pointer to Bun);
34
+ > use `bunx` or `bun add -g` instead.
35
+
25
36
  ```bash
26
37
  # Run it without installing
27
38
  bunx skillshelf <command>
@@ -82,6 +93,10 @@ skl import deploy-check --from ~/projects/web/.claude/skills/deploy-check --copy
82
93
  # When two copies drifted and you've picked the winner, overwrite the loser:
83
94
  skl import rnaseq-qc --from ~/projects/lab/.claude/skills/rnaseq-qc --force
84
95
 
96
+ # For a skill you actively develop in its own git repo, shelve a LINK instead of a copy —
97
+ # 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
99
+
85
100
  # 3. Tag the now-populated library in one pass. Domain is tags, not folders, so this
86
101
  # runs AFTER import with no reorg — no skill ever has to move because a tag changed.
87
102
  skl infer --emit # hand the payload to your agent, then `skl infer --apply`
@@ -108,9 +123,17 @@ skillshelf separates *owning* a skill from *loading* it.
108
123
  - **On-demand `show`** — prints only the SKILL.md instruction body and lists the paths of
109
124
  any bundled reference files (without reading them). Progressive disclosure: cheap by
110
125
  default, deep when you ask. Works mid-task with no reload.
111
- - **Sidecar overlay** installed third-party skills keep a pristine `upstream/` body plus a
112
- `<skill>.shelf.json` overlay holding *your* tags, bundle membership, and notes. `skl update`
113
- swaps the upstream body cleanly while your taxonomy survives updates never clobber your tags.
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.
134
+ - **Updates never clobber your tags** — domain tags live in the central `taxonomy.json`
135
+ ([ADR-0002](./docs/adr/0002-central-taxonomy-not-sidecars.md)), separate from the skill body, so
136
+ `skl update` can swap an owned skill's upstream `SKILL.md` cleanly while your taxonomy survives.
114
137
 
115
138
  ```
116
139
  skl search / ls / show skl use <bundle>
@@ -130,22 +153,37 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
130
153
  | Command | Summary | Key flags |
131
154
  |---|---|---|
132
155
  | `skl init` | Set up `~/.skillshelf` config + library and link the global-core skills | `--force` |
133
- | `skl scan [roots…]` | Read-only discovery of skill candidates across roots (counts, duplicates, drift) | `--add-root <path>` |
134
- | `skl import <name> --from <path>` | Adopt your own skill into the library (move + symlink-back, or `--copy`) | `--copy`, `--as <slug>`, `--force` |
156
+ | `skl scan [roots…]` | Read-only discovery of skill candidates across roots (counts, duplicates, drift) | `--add-root <path>`, `--remove-root <path>` |
157
+ | `skl roots` | List the persisted scan roots (read-only; no crawl) | |
158
+ | `skl import <name> --from <path>` | Adopt your own skill into the library as an OWNED copy (move + symlink-back, or `--copy`) | `--copy`, `--as <slug>`, `--force` |
159
+ | `skl link [<name>] --from <dev-repo>` | Shelve a dev-repo skill as a LINKED entry (library symlinks to it; the repo stays canonical). `--at <path>` instead collapses a stray copy into the library | `--from`, `--at`, `--force` |
135
160
  | `skl new <name>` | Scaffold a new skill dir + SKILL.md into the library | `--domain <d>`, `--desc "..."`, `--force` |
136
- | `skl ls [bundle]` | One-line listing of the library, or one bundle | `--all` |
161
+ | `skl ls [bundle]` | One-line listing of the library, or one bundle (`--json` carries `mode`/`linkTarget`) | `--all` |
137
162
  | `skl search <kw...>` | Fuzzy match over name + description + domains across the library | — |
138
163
  | `skl show <name>` | Print a skill's SKILL.md body; list reference-file paths (not contents) | — |
139
- | `skl status` | Show which library skills are linked into `./.claude/skills` | — |
140
- | `skl use <bundle>` | Symlink a bundle's skills into `./.claude/skills/` (hot-loads) | — |
141
- | `skl drop <bundle>` | Remove a bundle's symlinks from `./.claude/skills/` | — |
142
- | `skl add <src>` | Install a third-party skill (`github:`/registry), record provenance, auto-tag | `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
143
- | `skl outdated [name]` | Check upstream ref per tracked skill and mark stale ones | — |
144
- | `skl update [name]` | Re-pull upstream body, preserve overlay, diff if local body diverged | `--force`, `--dry-run` |
164
+ | `skl tag <name> <domain>…` | Add domain tag(s) to a skill in the central taxonomy (deterministic, no LLM) | — |
165
+ | `skl untag <name> <domain>` | Remove a domain tag from a skill | — |
166
+ | `skl retag <old> <new>` | Rename a domain across the whole library taxonomy (deterministic) | — |
167
+ | `skl rename <old> <new>` | Rename a skill slug atomically (dir + frontmatter + taxonomy + lock). Alias `skl mv` | — |
168
+ | `skl retire <name>` | Soft-delete a skill into `_retired/` (reversible; excluded from deploys) | — |
169
+ | `skl unretire <name>` | Restore a retired skill back to the active library | |
170
+ | `skl rm <name>` | Delete a skill (dir/symlink + taxonomy + lock), re-index. Refuses a live OWNED skill without `--force`; a LINKED entry `rm`s freely (safe unlink) | `--force`, `--dry-run` |
171
+ | `skl status` | Show which library skills are linked into `./.claude/skills`; flags unmanaged real copies (drift-prone) | — |
172
+ | `skl where [name]` | Map where each skill is deployed across all agents (Claude, Codex, Cursor…); flags copies, drift, 2nd-sources, dead links — a dev repo a library entry links to shows as a clean `✓ source` | `--problems`, `--prune`, `--fix`, `--dry-run` |
173
+ | `skl use <bundle\|skill>` | Symlink a bundle (or a single skill) into `./.claude/skills/` (hot-loads) | — |
174
+ | `skl drop <bundle\|skill>` | Remove a bundle's (or single skill's) symlinks from `./.claude/skills/` | — |
175
+ | `skl refresh` | Re-sync this project's `./.claude/skills` symlinks to current library reality (repoint stale, prune vanished) | `--dry-run` |
176
+ | `skl add <src>` | Install third-party skill(s) into the library (librarian only — no agent-dir writes). One repo = **one clone**: a bare repo with several skills needs `--all`/`--skill`/`--list`; single-skill `add <repo>/<path>` is unchanged. `--list` discovers + prints; `--dry-run` previews drift (new/identical/differs); a `differs` skill is skipped without `--force` | `--all`, `--skill <a,b>`, `--list`, `--dry-run`, `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
177
+ | `skl outdated [name]` | Check upstream ref per tracked skill and mark stale ones (LINKED dev-repo entries are reported, never probed); `--check-local` diffs the local body against its baseline offline | `--check-local` |
178
+ | `skl update [name]` | Re-pull upstream body, preserve domain tags, diff if local body diverged (LINKED entries are skipped — their own git owns versioning) | `--force`, `--dry-run` |
145
179
  | `skl index` | Regenerate `INDEX.md` (catalog grouped by domain) | — |
146
180
  | `skl infer` | Re-run AI domain taxonomy over the library (emit/apply/provider modes) | see below |
147
181
 
148
- Every command also accepts `--json`.
182
+ Every command also accepts `--json`. Destructive/edit verbs (`rm`, `retire`/`unretire`,
183
+ `rename`, `tag`/`untag`/`retag`, `scan --remove-root`, `where --prune`/`--fix`,
184
+ `refresh`) are the inverse + fine-grained-edit family from
185
+ [ADR-0005](./docs/adr/0005-inverse-and-edit-verbs.md): reversible by default, transactional
186
+ across the skill dir + `taxonomy.json` + `shelf.lock.json` + `INDEX.md`.
149
187
 
150
188
  ## AI taxonomy & inference
151
189
 
@@ -163,7 +201,7 @@ skl infer [--emit | --apply <file.json> | --provider <name>] \
163
201
  - `--emit` — print a self-contained prompt + the library payload as JSON. Hand it to whatever
164
202
  agent or model you already have open; it does the reasoning.
165
203
  - `--apply <file.json>` — apply the taxonomy proposal the agent produced back into the library
166
- (for review/approval), updating tags via the overlay.
204
+ (for review/approval), writing tags into the central `taxonomy.json`.
167
205
 
168
206
  **API mode (skillshelf calls an OpenAI-compatible endpoint itself):**
169
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillshelf",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "skl": "./src/cli.ts"
26
26
  },
27
27
  "scripts": {
28
+ "preinstall": "bun --version >/dev/null 2>&1 || { echo '\\nskillshelf requires the Bun runtime (https://bun.sh) — its bin is a TypeScript entrypoint, not a compiled Node script.\\nInstall Bun, then: bun add -g skillshelf (or run without installing: bunx skillshelf <command>)\\n' >&2; exit 1; }",
28
29
  "skl": "bun run src/cli.ts",
29
30
  "test": "bun test"
30
31
  },
@@ -38,5 +39,10 @@
38
39
  "src",
39
40
  "README.md",
40
41
  "LICENSE"
41
- ]
42
+ ],
43
+ "devDependencies": {
44
+ "@types/bun": "^1.3.14",
45
+ "@types/node": "22",
46
+ "typescript": "5.6"
47
+ }
42
48
  }
@@ -4,15 +4,15 @@
4
4
  // - emit : assemble an InferenceCorpus + JSON schema + instruction and print it
5
5
  // to stdout so the HOST agent (Claude Code) can reason over it and
6
6
  // produce a proposal file.
7
- // - apply: read the agent's proposal JSON and write proposed domains/tags into
8
- // each skill's `<name>.shelf.json` overlay (never upstream SKILL.md).
7
+ // - apply: read the agent's proposal JSON and write proposed domains into the
8
+ // central taxonomy.json (never upstream SKILL.md).
9
9
  //
10
10
  // The api.ts adapter reuses buildCorpus() + applyProposal() to close the loop
11
11
  // automatically against any OpenAI-compatible LLM endpoint.
12
12
 
13
- import type { InferenceCorpus, Overlay, Skill } from "../../types.ts";
13
+ import type { InferenceCorpus, Skill } from "../../types.ts";
14
14
  import { listDomains } from "../../core/library.ts";
15
- import { readOverlay, writeOverlay } from "../../core/overlay.ts";
15
+ import { readTaxonomy, writeTaxonomy, domainsForName } from "../../core/taxonomy.ts";
16
16
  import { parseFrontmatter } from "../../lib/frontmatter.ts";
17
17
 
18
18
  /** Max characters of SKILL.md body included per skill in the corpus preview. */
@@ -20,8 +20,9 @@ const BODY_PREVIEW_CHARS = 1200;
20
20
 
21
21
  /**
22
22
  * The proposal shape the host agent (or the gateway) must return: a map of
23
- * skill name -> proposed domains, plus optional primary + notes. Authors apply
24
- * `domains` into each overlay (unioned with existing, never destructive).
23
+ * skill name -> proposed domains, plus optional primary + notes. Applying unions
24
+ * `domains` into the central taxonomy (per skill, never destructive). `notes` is
25
+ * accepted for backward compatibility but is no longer persisted (ADR-0002).
25
26
  */
26
27
  export interface InferenceProposalEntry {
27
28
  name: string;
@@ -82,7 +83,7 @@ export const INFER_INSTRUCTION = [
82
83
  "domain plus any honest secondary tags (a dual-use skill belongs to multiple).",
83
84
  "Keep domain tokens lowercase and hyphenated.",
84
85
  "Return ONE JSON object that validates against `schema` (no prose, no markdown",
85
- "fences). Then run `skl infer --apply <file.json>` to write it into the overlays.",
86
+ "fences). Then run `skl infer --apply <file.json>` to write it into taxonomy.json.",
86
87
  ].join(" ");
87
88
 
88
89
  /** Build the deterministic InferenceCorpus snapshot from loaded skills. */
@@ -185,7 +186,7 @@ export function normalizeProposal(raw: unknown): InferenceProposal {
185
186
  }
186
187
 
187
188
  export interface ApplyResult {
188
- /** skill name -> domains written into its overlay */
189
+ /** skill name -> domains written into the taxonomy for that skill */
189
190
  applied: Array<{ name: string; domains: string[]; added: string[] }>;
190
191
  /** assignment names with no matching skill in the library */
191
192
  unmatched: string[];
@@ -194,11 +195,13 @@ export interface ApplyResult {
194
195
  }
195
196
 
196
197
  /**
197
- * Apply a proposal into each skill's overlay. Domains are UNIONED with the
198
- * skill's existing effective domains (never destructive), written to
199
- * `<name>.shelf.json` only — upstream SKILL.md is never touched.
198
+ * Apply a proposal into the central taxonomy (`<library>/taxonomy.json`). For each
199
+ * assignment, domains are UNIONED with the skill's EXISTING taxonomy entry (never
200
+ * destructive); upstream SKILL.md is never touched. `notes` is dropped (ADR-0002).
201
+ * The taxonomy is read once and written once at the end.
200
202
  */
201
203
  export async function applyProposal(
204
+ libraryPath: string,
202
205
  skills: Skill[],
203
206
  proposal: InferenceProposal,
204
207
  ): Promise<ApplyResult> {
@@ -209,6 +212,8 @@ export async function applyProposal(
209
212
  if (!existing || (existing.mirrorOf && !s.mirrorOf)) byName.set(s.name, s);
210
213
  }
211
214
 
215
+ const tax = await readTaxonomy(libraryPath);
216
+
212
217
  const applied: ApplyResult["applied"] = [];
213
218
  const unmatched: string[] = [];
214
219
  const skipped: string[] = [];
@@ -232,8 +237,9 @@ export async function applyProposal(
232
237
  continue;
233
238
  }
234
239
 
235
- const prev = await readOverlay(skill);
236
- const existingDomains = Array.isArray(prev?.domains) ? prev!.domains : [];
240
+ // Union with the skill's EXISTING taxonomy domains (non-destructive). Resolve
241
+ // by the canonical skill name so mirror copies map to the same entry.
242
+ const existingDomains = domainsForName(tax, skill.name);
237
243
  const merged: string[] = [...existingDomains];
238
244
  const added: string[] = [];
239
245
  for (const d of ordered) {
@@ -243,11 +249,12 @@ export async function applyProposal(
243
249
  }
244
250
  }
245
251
 
246
- const next: Overlay = { ...(prev ?? {}), domains: merged };
247
- if (a.notes) next.notes = a.notes;
248
- await writeOverlay(skill, next);
252
+ tax.skills[skill.name] = merged;
249
253
  applied.push({ name: a.name, domains: merged, added });
250
254
  }
251
255
 
256
+ // Persist the whole taxonomy once.
257
+ await writeTaxonomy(libraryPath, tax);
258
+
252
259
  return { applied, unmatched, skipped };
253
260
  }
package/src/cli.ts CHANGED
@@ -28,19 +28,43 @@ import * as update from "./commands/update.ts";
28
28
  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
+ import * as roots from "./commands/roots.ts";
31
32
  import * as importCmd from "./commands/import.ts";
33
+ import * as link from "./commands/link.ts";
34
+ import * as where from "./commands/where.ts";
35
+ import * as agents from "./commands/agents.ts";
36
+ import * as tag from "./commands/tag.ts";
37
+ import * as untag from "./commands/untag.ts";
38
+ import * as retag from "./commands/retag.ts";
39
+ import * as rm from "./commands/rm.ts";
40
+ import * as retire from "./commands/retire.ts";
41
+ import * as unretire from "./commands/unretire.ts";
42
+ import * as rename from "./commands/rename.ts";
43
+ import * as refresh from "./commands/refresh.ts";
32
44
 
33
45
  // Registration order = display order in help.
34
46
  const MODULES: CommandModule[] = [
35
47
  search,
36
48
  ls,
37
49
  status,
50
+ where,
51
+ agents,
38
52
  show,
39
53
  use,
40
54
  drop,
55
+ refresh,
41
56
  add,
42
57
  scan,
58
+ roots,
43
59
  importCmd,
60
+ link,
61
+ tag,
62
+ untag,
63
+ retag,
64
+ retire,
65
+ unretire,
66
+ rename,
67
+ rm,
44
68
  outdated,
45
69
  update,
46
70
  init,
@@ -54,6 +78,13 @@ for (const mod of MODULES) {
54
78
  COMMANDS.set(mod.meta.name, mod);
55
79
  }
56
80
 
81
+ // Command aliases (not shown in the help listing; resolve to the canonical module).
82
+ const ALIASES: Record<string, string> = { mv: "rename" };
83
+ for (const [alias, target] of Object.entries(ALIASES)) {
84
+ const mod = COMMANDS.get(target);
85
+ if (mod) COMMANDS.set(alias, mod);
86
+ }
87
+
57
88
  function helpText(): string {
58
89
  const lines: string[] = [];
59
90
  lines.push("skl — skillshelf: agent-first skill registry + manager");