skillshelf 0.4.1 → 0.6.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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/adapters/inference/api.ts +1 -4
- package/src/commands/add.published.test.ts +390 -0
- package/src/commands/add.ts +169 -374
- package/src/commands/agents.ts +63 -54
- package/src/commands/drop.ts +28 -9
- package/src/commands/import.ts +8 -9
- package/src/commands/index.ts +3 -3
- package/src/commands/infer.ts +27 -26
- package/src/commands/link.ts +34 -23
- package/src/commands/ls.ts +25 -23
- package/src/commands/migrate.ts +4 -4
- package/src/commands/new.ts +1 -2
- package/src/commands/outdated.ts +74 -55
- package/src/commands/projects.ts +33 -26
- package/src/commands/rename.ts +1 -3
- package/src/commands/retag.ts +1 -3
- package/src/commands/retire-reindex-once.test.ts +107 -0
- package/src/commands/retire.ts +18 -18
- package/src/commands/rm.test.ts +60 -0
- package/src/commands/rm.ts +29 -17
- package/src/commands/scan.ts +74 -66
- package/src/commands/search.ts +1 -5
- package/src/commands/status.ts +39 -37
- package/src/commands/tag.ts +1 -3
- package/src/commands/track.ts +7 -209
- package/src/commands/unretire.ts +14 -15
- package/src/commands/update.test.ts +135 -0
- package/src/commands/update.ts +291 -209
- package/src/commands/use.test.ts +52 -0
- package/src/commands/use.ts +80 -34
- package/src/commands/where.ts +61 -55
- package/src/config.test.ts +1 -1
- package/src/core/agent-matrix.test.ts +153 -0
- package/src/core/agent-matrix.ts +184 -0
- package/src/core/agents.test.ts +4 -4
- package/src/core/agents.ts +55 -139
- package/src/core/bundle.ts +0 -9
- package/src/core/crawl.ts +27 -28
- package/src/core/deployments.ts +0 -11
- package/src/core/fetch.ts +105 -3
- package/src/core/indexgen.ts +10 -6
- package/src/core/library.ts +1 -3
- package/src/core/lifecycle.ts +66 -1
- package/src/core/reconcile.test.ts +203 -0
- package/src/core/reconcile.ts +142 -0
- package/src/core/report.test.ts +167 -0
- package/src/core/report.ts +129 -0
- package/src/core/surfaces.ts +1 -0
- package/src/core/vendor.test.ts +383 -0
- package/src/core/vendor.ts +564 -0
- package/src/lib/frontmatter.test.ts +17 -0
- package/src/lib/frontmatter.ts +76 -9
- package/src/lib/fs.ts +1 -72
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**A package manager for your agent skills — one canonical library, loaded on demand, never all at once.**
|
|
4
4
|
|
|
5
5
|
[](./LICENSE)
|
|
6
|
-
[](https://bun.sh)
|
|
7
7
|
[](https://github.com/Wang-Cankun/skillshelf/actions)
|
|
8
8
|
[](https://www.npmjs.com/package/skillshelf)
|
|
9
9
|
|
|
@@ -46,7 +46,7 @@ matrix (Global + the projects where it's pinned), plus lifecycle actions.
|
|
|
46
46
|
|
|
47
47
|
## Install
|
|
48
48
|
|
|
49
|
-
skillshelf runs on [Bun](https://bun.sh) (>= 1.
|
|
49
|
+
skillshelf runs on [Bun](https://bun.sh) (>= 1.2). No other runtime dependencies.
|
|
50
50
|
|
|
51
51
|
> **Bun is required, not optional.** The `skl` bin is a TypeScript entrypoint with a
|
|
52
52
|
> `#!/usr/bin/env bun` shebang — there is no compiled Node build. `npm i -g skillshelf`
|
|
@@ -198,7 +198,7 @@ See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) for the full design.
|
|
|
198
198
|
| `skl use <bundle\|skill>` | Symlink a bundle (or a single skill) into `./.claude/skills/` (hot-loads) | — |
|
|
199
199
|
| `skl drop <bundle\|skill>` | Remove a bundle's (or single skill's) symlinks from `./.claude/skills/` | — |
|
|
200
200
|
| `skl refresh` | Re-sync this project's `./.claude/skills` symlinks to current library reality (repoint stale, prune vanished) | `--dry-run` |
|
|
201
|
-
| `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` |
|
|
201
|
+
| `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. `--all` installs the **published set** (the `.claude-plugin`/`marketplace.json` manifest allowlist when present, else every discovered skill; minus `metadata.internal`); an unpublished skill installs only via `--skill <name>`. A published set over **15** skills refuses without `--yes` (a count gate on blast radius; `--skill` is never gated). `--list` discovers + prints (marks `published`/`unpublished`); `--dry-run` previews drift (new/identical/differs); a `differs` skill is skipped without `--force` ([ADR-0012](./docs/adr/0012-published-set-and-all-count-gate.md)) | `--all`, `--skill <a,b>`, `--list`, `--yes`, `--dry-run`, `--domain <d>`, `--name <slug>`, `--no-infer`, `--force` |
|
|
202
202
|
| `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` |
|
|
203
203
|
| `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` |
|
|
204
204
|
| `skl index` | Regenerate `INDEX.md` (catalog grouped by domain) | — |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillshelf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Agent-first skill registry + manager for Claude Code and compatible agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test": "bun test"
|
|
31
31
|
},
|
|
32
32
|
"engines": {
|
|
33
|
-
"bun": ">=1.
|
|
33
|
+
"bun": ">=1.2.0"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
|
@@ -93,10 +93,7 @@ function resolveEnvFilePath(env: NodeJS.ProcessEnv, override?: string): string |
|
|
|
93
93
|
|
|
94
94
|
/** First non-empty trimmed value, or "". */
|
|
95
95
|
function firstNonEmpty(...vals: (string | undefined)[]): string {
|
|
96
|
-
|
|
97
|
-
if (v && v.trim() !== "") return v.trim();
|
|
98
|
-
}
|
|
99
|
-
return "";
|
|
96
|
+
return vals.find(v => v?.trim())?.trim() ?? "";
|
|
100
97
|
}
|
|
101
98
|
|
|
102
99
|
/**
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// ADR-0012 — published set (manifest allowlist + metadata.internal) + the > 15 count gate.
|
|
2
|
+
//
|
|
3
|
+
// These tests drive `skl add` over real local git repos (offline `git:` channel,
|
|
4
|
+
// HOME-isolated library) exercising:
|
|
5
|
+
// (a) manifest-present repo -> --all installs only the allowlisted subset
|
|
6
|
+
// (b) --skill reaches an UNPUBLISHED (folder-excluded) skill, never gated
|
|
7
|
+
// (c) metadata.internal:true excluded from --all but installable by name
|
|
8
|
+
// (d) marketplace.json union across plugins
|
|
9
|
+
// (e) the > 15 count gate trips, and --yes bypasses it
|
|
10
|
+
// (f) --list shows the FULL set with published/unpublished/internal markers
|
|
11
|
+
// (g) a no-manifest repo still installs every skill (under the gate)
|
|
12
|
+
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { run } from "./add.ts";
|
|
19
|
+
import { discoverSkills } from "../core/fetch.ts";
|
|
20
|
+
import type { Ctx } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
interface Captured {
|
|
23
|
+
ctx: Ctx;
|
|
24
|
+
logs: string[];
|
|
25
|
+
errors: string[];
|
|
26
|
+
json: unknown[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeCtx(libraryPath: string): Captured {
|
|
30
|
+
const logs: string[] = [];
|
|
31
|
+
const errors: string[] = [];
|
|
32
|
+
const json: unknown[] = [];
|
|
33
|
+
const ctx = {
|
|
34
|
+
config: { libraryPath },
|
|
35
|
+
libraryPath,
|
|
36
|
+
log: (...a: unknown[]) => logs.push(a.join(" ")),
|
|
37
|
+
error: (...a: unknown[]) => errors.push(a.join(" ")),
|
|
38
|
+
json: (v: unknown) => json.push(v),
|
|
39
|
+
} as unknown as Ctx;
|
|
40
|
+
return { ctx, logs, errors, json };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function skillBody(
|
|
44
|
+
name: string,
|
|
45
|
+
opts: { internal?: boolean; internalFlow?: boolean; description?: string } = {},
|
|
46
|
+
): string {
|
|
47
|
+
const desc = opts.description ?? `a ${name} skill for testing`;
|
|
48
|
+
// Block style by default; flow style (`metadata: {internal: true}`) exercises the
|
|
49
|
+
// YAML-flow-mapping path so the internal signal can't be bypassed by author style.
|
|
50
|
+
const meta = opts.internalFlow
|
|
51
|
+
? `metadata: {internal: true}\n`
|
|
52
|
+
: opts.internal
|
|
53
|
+
? `metadata:\n internal: true\n`
|
|
54
|
+
: "";
|
|
55
|
+
return `---\nname: ${name}\ndescription: ${desc}\n${meta}---\n\n# ${name}\n\nbody for ${name}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SkillSpec {
|
|
59
|
+
/** repo-relative dir, e.g. "engineering/foo" */
|
|
60
|
+
path: string;
|
|
61
|
+
/** frontmatter name (defaults to basename of path) */
|
|
62
|
+
name?: string;
|
|
63
|
+
internal?: boolean;
|
|
64
|
+
/** mark internal via inline flow mapping instead of a block mapping */
|
|
65
|
+
internalFlow?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build a real local git repo with the given skills + optional manifest files. */
|
|
69
|
+
async function makeRepo(
|
|
70
|
+
parent: string,
|
|
71
|
+
skills: SkillSpec[],
|
|
72
|
+
manifest?: { kind: "plugin" | "marketplace"; json: unknown },
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const repo = join(parent, "src-repo");
|
|
75
|
+
await mkdir(repo, { recursive: true });
|
|
76
|
+
for (const s of skills) {
|
|
77
|
+
const dir = join(repo, s.path);
|
|
78
|
+
await mkdir(dir, { recursive: true });
|
|
79
|
+
const nm = s.name ?? s.path.split("/").pop()!;
|
|
80
|
+
await writeFile(
|
|
81
|
+
join(dir, "SKILL.md"),
|
|
82
|
+
skillBody(nm, { internal: s.internal, internalFlow: s.internalFlow }),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (manifest) {
|
|
86
|
+
await mkdir(join(repo, ".claude-plugin"), { recursive: true });
|
|
87
|
+
const file = manifest.kind === "plugin" ? "plugin.json" : "marketplace.json";
|
|
88
|
+
await writeFile(join(repo, ".claude-plugin", file), JSON.stringify(manifest.json, null, 2));
|
|
89
|
+
}
|
|
90
|
+
const gitRun = (cmd: string[]) =>
|
|
91
|
+
Bun.spawn(cmd, { cwd: repo, stdout: "ignore", stderr: "ignore" }).exited;
|
|
92
|
+
await gitRun(["git", "init", "-q"]);
|
|
93
|
+
await gitRun(["git", "config", "user.email", "test@example.com"]);
|
|
94
|
+
await gitRun(["git", "config", "user.name", "test"]);
|
|
95
|
+
await gitRun(["git", "add", "-A"]);
|
|
96
|
+
await gitRun(["git", "commit", "-q", "-m", "init"]);
|
|
97
|
+
return repo;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("ADR-0012 published set + count gate", () => {
|
|
101
|
+
let tmp: string;
|
|
102
|
+
let library: string;
|
|
103
|
+
|
|
104
|
+
beforeEach(async () => {
|
|
105
|
+
tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-pub-")));
|
|
106
|
+
library = join(tmp, "library");
|
|
107
|
+
await mkdir(library, { recursive: true });
|
|
108
|
+
});
|
|
109
|
+
afterEach(async () => {
|
|
110
|
+
await rm(tmp, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// (a) manifest-present repo -> --all installs only the allowlisted subset.
|
|
114
|
+
test("plugin.json allowlist bounds --all to the listed skills", async () => {
|
|
115
|
+
const repo = await makeRepo(
|
|
116
|
+
tmp,
|
|
117
|
+
[
|
|
118
|
+
{ path: "engineering/alpha" },
|
|
119
|
+
{ path: "engineering/beta" },
|
|
120
|
+
{ path: "deprecated/gamma" },
|
|
121
|
+
{ path: "in-progress/delta" },
|
|
122
|
+
],
|
|
123
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/beta"] } },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const { ctx, json } = makeCtx(library);
|
|
127
|
+
const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
|
|
128
|
+
expect(code).toBe(0);
|
|
129
|
+
|
|
130
|
+
const out = json[0] as { results: Array<{ name: string; status: string }> };
|
|
131
|
+
const installed = out.results.filter((r) => r.status === "installed").map((r) => r.name).sort();
|
|
132
|
+
expect(installed).toEqual(["alpha", "beta"]);
|
|
133
|
+
expect(existsSync(join(library, "alpha", "SKILL.md"))).toBe(true);
|
|
134
|
+
expect(existsSync(join(library, "beta", "SKILL.md"))).toBe(true);
|
|
135
|
+
expect(existsSync(join(library, "gamma"))).toBe(false);
|
|
136
|
+
expect(existsSync(join(library, "delta"))).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// (b) --skill reaches an UNPUBLISHED (folder-excluded) skill, NOT gated.
|
|
140
|
+
test("--skill installs an unpublished skill the manifest omits", async () => {
|
|
141
|
+
const repo = await makeRepo(
|
|
142
|
+
tmp,
|
|
143
|
+
[
|
|
144
|
+
{ path: "engineering/alpha" },
|
|
145
|
+
{ path: "deprecated/gamma" },
|
|
146
|
+
],
|
|
147
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha"] } },
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const { ctx, json } = makeCtx(library);
|
|
151
|
+
const code = await run([`git:${repo}`, "--skill", "gamma", "--no-infer", "--json"], ctx);
|
|
152
|
+
expect(code).toBe(0);
|
|
153
|
+
const out = json[0] as { results: Array<{ name: string; status: string }> };
|
|
154
|
+
const gamma = out.results.find((r) => r.name === "gamma")!;
|
|
155
|
+
expect(gamma.status).toBe("installed");
|
|
156
|
+
expect(existsSync(join(library, "gamma", "SKILL.md"))).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// (c) metadata.internal:true excluded from --all but installable by name.
|
|
160
|
+
test("metadata.internal skill is excluded from --all but installable by --skill", async () => {
|
|
161
|
+
const repo = await makeRepo(
|
|
162
|
+
tmp,
|
|
163
|
+
[
|
|
164
|
+
{ path: "engineering/alpha" },
|
|
165
|
+
{ path: "engineering/secret", internal: true },
|
|
166
|
+
],
|
|
167
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// --all skips the internal one even though the manifest lists it.
|
|
171
|
+
const all = makeCtx(library);
|
|
172
|
+
const codeAll = await run([`git:${repo}`, "--all", "--no-infer", "--json"], all.ctx);
|
|
173
|
+
expect(codeAll).toBe(0);
|
|
174
|
+
const outAll = all.json[0] as { results: Array<{ name: string; status: string }> };
|
|
175
|
+
const installedAll = outAll.results.filter((r) => r.status === "installed").map((r) => r.name);
|
|
176
|
+
expect(installedAll).toEqual(["alpha"]);
|
|
177
|
+
expect(existsSync(join(library, "secret"))).toBe(false);
|
|
178
|
+
|
|
179
|
+
// ...but --skill reaches it.
|
|
180
|
+
const byName = makeCtx(library);
|
|
181
|
+
const codeName = await run([`git:${repo}`, "--skill", "secret", "--no-infer", "--json"], byName.ctx);
|
|
182
|
+
expect(codeName).toBe(0);
|
|
183
|
+
expect(existsSync(join(library, "secret", "SKILL.md"))).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// (c2) the internal signal can't be bypassed by writing it as a YAML FLOW mapping
|
|
187
|
+
// (`metadata: {internal: true}`) instead of a block mapping.
|
|
188
|
+
test("flow-style metadata.internal is still excluded from --all", async () => {
|
|
189
|
+
const repo = await makeRepo(
|
|
190
|
+
tmp,
|
|
191
|
+
[
|
|
192
|
+
{ path: "engineering/alpha" },
|
|
193
|
+
{ path: "engineering/secret", internalFlow: true },
|
|
194
|
+
],
|
|
195
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
|
|
196
|
+
);
|
|
197
|
+
const { ctx, json } = makeCtx(library);
|
|
198
|
+
const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
|
|
199
|
+
expect(code).toBe(0);
|
|
200
|
+
const out = json[0] as { results: Array<{ name: string; status: string }> };
|
|
201
|
+
expect(out.results.filter((r) => r.status === "installed").map((r) => r.name)).toEqual(["alpha"]);
|
|
202
|
+
expect(existsSync(join(library, "secret"))).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// (d) marketplace.json union across plugins.
|
|
206
|
+
test("marketplace.json unions every plugin's skills", async () => {
|
|
207
|
+
const repo = await makeRepo(
|
|
208
|
+
tmp,
|
|
209
|
+
[
|
|
210
|
+
{ path: "engineering/alpha" },
|
|
211
|
+
{ path: "productivity/beta" },
|
|
212
|
+
{ path: "deprecated/gamma" },
|
|
213
|
+
],
|
|
214
|
+
{
|
|
215
|
+
kind: "marketplace",
|
|
216
|
+
json: {
|
|
217
|
+
plugins: [
|
|
218
|
+
{ name: "eng", skills: ["./engineering/alpha"] },
|
|
219
|
+
{ name: "prod", skills: ["./productivity/beta"] },
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const { ctx, json } = makeCtx(library);
|
|
226
|
+
const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
|
|
227
|
+
expect(code).toBe(0);
|
|
228
|
+
const out = json[0] as { results: Array<{ name: string; status: string }> };
|
|
229
|
+
const installed = out.results.filter((r) => r.status === "installed").map((r) => r.name).sort();
|
|
230
|
+
expect(installed).toEqual(["alpha", "beta"]);
|
|
231
|
+
expect(existsSync(join(library, "gamma"))).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// (e) the count gate trips at > 15 published, and --yes bypasses it.
|
|
235
|
+
test("count gate refuses > 15 published skills; --yes bypasses", async () => {
|
|
236
|
+
// 16 skills, NO manifest -> all 16 are published.
|
|
237
|
+
const specs: SkillSpec[] = Array.from({ length: 16 }, (_, i) => ({
|
|
238
|
+
path: `skills/s${String(i).padStart(2, "0")}`,
|
|
239
|
+
}));
|
|
240
|
+
const repo = await makeRepo(tmp, specs);
|
|
241
|
+
|
|
242
|
+
const gated = makeCtx(library);
|
|
243
|
+
const codeGated = await run([`git:${repo}`, "--all", "--no-infer", "--json"], gated.ctx);
|
|
244
|
+
expect(codeGated).toBe(1);
|
|
245
|
+
const errText = gated.errors.join("\n");
|
|
246
|
+
expect(errText).toContain("16");
|
|
247
|
+
expect(errText).toMatch(/--yes/);
|
|
248
|
+
// Nothing installed.
|
|
249
|
+
expect(existsSync(join(library, "s00"))).toBe(false);
|
|
250
|
+
|
|
251
|
+
const bypass = makeCtx(library);
|
|
252
|
+
const codeYes = await run([`git:${repo}`, "--all", "--yes", "--no-infer", "--json"], bypass.ctx);
|
|
253
|
+
expect(codeYes).toBe(0);
|
|
254
|
+
const out = bypass.json[0] as { counts: { installed: number } };
|
|
255
|
+
expect(out.counts.installed).toBe(16);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("count gate does NOT trip at exactly 15", async () => {
|
|
259
|
+
const specs: SkillSpec[] = Array.from({ length: 15 }, (_, i) => ({
|
|
260
|
+
path: `skills/s${String(i).padStart(2, "0")}`,
|
|
261
|
+
}));
|
|
262
|
+
const repo = await makeRepo(tmp, specs);
|
|
263
|
+
const { ctx, json } = makeCtx(library);
|
|
264
|
+
const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
|
|
265
|
+
expect(code).toBe(0);
|
|
266
|
+
const out = json[0] as { counts: { installed: number } };
|
|
267
|
+
expect(out.counts.installed).toBe(15);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("--skill is never gated even over a large repo", async () => {
|
|
271
|
+
const specs: SkillSpec[] = Array.from({ length: 20 }, (_, i) => ({
|
|
272
|
+
path: `skills/s${String(i).padStart(2, "0")}`,
|
|
273
|
+
}));
|
|
274
|
+
const repo = await makeRepo(tmp, specs);
|
|
275
|
+
const { ctx } = makeCtx(library);
|
|
276
|
+
const code = await run([`git:${repo}`, "--skill", "s00,s01", "--no-infer", "--json"], ctx);
|
|
277
|
+
expect(code).toBe(0);
|
|
278
|
+
expect(existsSync(join(library, "s00", "SKILL.md"))).toBe(true);
|
|
279
|
+
expect(existsSync(join(library, "s01", "SKILL.md"))).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// (f) --list shows the FULL set with published/unpublished/internal markers.
|
|
283
|
+
test("--list marks every skill published/unpublished/internal; never gated", async () => {
|
|
284
|
+
const repo = await makeRepo(
|
|
285
|
+
tmp,
|
|
286
|
+
[
|
|
287
|
+
{ path: "engineering/alpha" },
|
|
288
|
+
{ path: "deprecated/gamma" },
|
|
289
|
+
{ path: "engineering/secret", internal: true },
|
|
290
|
+
],
|
|
291
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const { ctx, json } = makeCtx(library);
|
|
295
|
+
const code = await run([`git:${repo}`, "--list", "--json"], ctx);
|
|
296
|
+
expect(code).toBe(0);
|
|
297
|
+
const out = json[0] as {
|
|
298
|
+
skills: Array<{ name: string; published: boolean; internal: boolean }>;
|
|
299
|
+
};
|
|
300
|
+
const byName = new Map(out.skills.map((s) => [s.name, s]));
|
|
301
|
+
expect(out.skills.length).toBe(3);
|
|
302
|
+
expect(byName.get("alpha")!.published).toBe(true);
|
|
303
|
+
expect(byName.get("alpha")!.internal).toBe(false);
|
|
304
|
+
expect(byName.get("gamma")!.published).toBe(false); // unlisted by manifest
|
|
305
|
+
expect(byName.get("secret")!.published).toBe(false); // internal excluded
|
|
306
|
+
expect(byName.get("secret")!.internal).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("--list is never gated even over a large repo", async () => {
|
|
310
|
+
const specs: SkillSpec[] = Array.from({ length: 30 }, (_, i) => ({
|
|
311
|
+
path: `skills/s${String(i).padStart(2, "0")}`,
|
|
312
|
+
}));
|
|
313
|
+
const repo = await makeRepo(tmp, specs);
|
|
314
|
+
const { ctx, json } = makeCtx(library);
|
|
315
|
+
const code = await run([`git:${repo}`, "--list", "--json"], ctx);
|
|
316
|
+
expect(code).toBe(0);
|
|
317
|
+
const out = json[0] as { skills: unknown[] };
|
|
318
|
+
expect(out.skills.length).toBe(30);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// (g) no-manifest repo still installs every (valid) discovered skill, under the gate.
|
|
322
|
+
test("no-manifest repo: --all installs every skill (today's behavior)", async () => {
|
|
323
|
+
const repo = await makeRepo(tmp, [
|
|
324
|
+
{ path: "skills/alpha" },
|
|
325
|
+
{ path: "skills/beta" },
|
|
326
|
+
{ path: "skills/gamma" },
|
|
327
|
+
]);
|
|
328
|
+
const { ctx, json } = makeCtx(library);
|
|
329
|
+
const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
|
|
330
|
+
expect(code).toBe(0);
|
|
331
|
+
const out = json[0] as { counts: { installed: number } };
|
|
332
|
+
expect(out.counts.installed).toBe(3);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// --dry-run runs over the PUBLISHED set (the set --all would install), never gated.
|
|
336
|
+
test("--dry-run preflights only the published set", async () => {
|
|
337
|
+
const repo = await makeRepo(
|
|
338
|
+
tmp,
|
|
339
|
+
[
|
|
340
|
+
{ path: "engineering/alpha" },
|
|
341
|
+
{ path: "deprecated/gamma" },
|
|
342
|
+
],
|
|
343
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha"] } },
|
|
344
|
+
);
|
|
345
|
+
const { ctx, json } = makeCtx(library);
|
|
346
|
+
const code = await run([`git:${repo}`, "--all", "--dry-run", "--json"], ctx);
|
|
347
|
+
expect(code).toBe(0);
|
|
348
|
+
const out = json[0] as { skills: Array<{ name: string }>; willInstall: number };
|
|
349
|
+
const names = out.skills.map((s) => s.name).sort();
|
|
350
|
+
expect(names).toEqual(["alpha"]); // gamma is unpublished -> not previewed
|
|
351
|
+
expect(out.willInstall).toBe(1);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Discovery still finds EVERYTHING (existence) — the manifest is an allowlist, not a source of truth.
|
|
355
|
+
test("discoverSkills still surfaces all skills, tagged published/internal", async () => {
|
|
356
|
+
const repo = await makeRepo(
|
|
357
|
+
tmp,
|
|
358
|
+
[
|
|
359
|
+
{ path: "engineering/alpha" },
|
|
360
|
+
{ path: "deprecated/gamma" },
|
|
361
|
+
{ path: "engineering/secret", internal: true },
|
|
362
|
+
],
|
|
363
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./engineering/secret"] } },
|
|
364
|
+
);
|
|
365
|
+
// discoverSkills runs against a CHECKOUT root; clone manually to a plain dir.
|
|
366
|
+
const checkout = join(tmp, "checkout");
|
|
367
|
+
await Bun.spawn(["git", "clone", "-q", repo, checkout], { stdout: "ignore", stderr: "ignore" }).exited;
|
|
368
|
+
const found = await discoverSkills(checkout);
|
|
369
|
+
const byName = new Map(found.map((d) => [d.name, d]));
|
|
370
|
+
expect(found.length).toBe(3);
|
|
371
|
+
expect(byName.get("alpha")!.published).toBe(true);
|
|
372
|
+
expect(byName.get("gamma")!.published).toBe(false);
|
|
373
|
+
expect(byName.get("secret")!.published).toBe(false);
|
|
374
|
+
expect(byName.get("secret")!.internal).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// A manifest entry that points at a path with no valid SKILL.md contributes nothing.
|
|
378
|
+
test("manifest allowlisting a nonexistent path contributes nothing", async () => {
|
|
379
|
+
const repo = await makeRepo(
|
|
380
|
+
tmp,
|
|
381
|
+
[{ path: "engineering/alpha" }],
|
|
382
|
+
{ kind: "plugin", json: { name: "x", skills: ["./engineering/alpha", "./does/not/exist"] } },
|
|
383
|
+
);
|
|
384
|
+
const { ctx, json } = makeCtx(library);
|
|
385
|
+
const code = await run([`git:${repo}`, "--all", "--no-infer", "--json"], ctx);
|
|
386
|
+
expect(code).toBe(0);
|
|
387
|
+
const out = json[0] as { counts: { installed: number } };
|
|
388
|
+
expect(out.counts.installed).toBe(1);
|
|
389
|
+
});
|
|
390
|
+
});
|