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.
- package/README.md +60 -35
- package/package.json +1 -1
- package/src/cli.ts +8 -0
- package/src/commands/add.test.ts +118 -0
- package/src/commands/add.ts +23 -2
- package/src/commands/adopted.test.ts +144 -0
- package/src/commands/agents-config.test.ts +126 -0
- package/src/commands/agents.test.ts +96 -0
- package/src/commands/agents.ts +129 -6
- package/src/commands/import.test.ts +71 -0
- package/src/commands/import.ts +13 -0
- package/src/commands/link.test.ts +40 -26
- package/src/commands/link.ts +11 -0
- package/src/commands/ls.ts +18 -1
- package/src/commands/migrate.test.ts +157 -0
- package/src/commands/migrate.ts +260 -0
- package/src/commands/new.test.ts +63 -0
- package/src/commands/new.ts +12 -0
- package/src/commands/outdated.ts +41 -13
- package/src/commands/projects.test.ts +85 -0
- package/src/commands/projects.ts +80 -0
- package/src/commands/rename.test.ts +14 -0
- package/src/commands/show.ts +126 -10
- package/src/commands/track.test.ts +170 -0
- package/src/commands/track.ts +340 -0
- package/src/commands/untrack.ts +44 -0
- package/src/commands/update.ts +92 -0
- package/src/commands/use.test.ts +30 -0
- package/src/config.test.ts +130 -1
- package/src/config.ts +154 -1
- package/src/core/agents.test.ts +92 -5
- package/src/core/agents.ts +83 -8
- package/src/core/core.test.ts +7 -7
- package/src/core/deployments.test.ts +20 -20
- package/src/core/fetch.ts +28 -6
- package/src/core/library.test.ts +3 -3
- package/src/core/library.ts +54 -0
- package/src/core/lifecycle.ts +26 -32
- package/src/core/taxonomy.test.ts +2 -2
- package/src/types.ts +70 -0
package/README.md
CHANGED
|
@@ -7,22 +7,42 @@
|
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
|
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/
|
|
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)) —
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
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
|
+
});
|
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,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
|
+
});
|