Skip to content
284 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import {
5 BUILTIN_MARKETPLACES,
6 fetchPlan,
7 githubRepoOf,
8 isReservedMarketplaceName,
9 marketplaceNameProblem,
10 namespaced,
11 parseMarketplaceAddress,
12 parseMarketplaceManifest,
13 parseMcpServers,
14 parsePluginManifest,
15 parsePluginRef,
16 parsePluginSource,
17 pluginLayout,
18 pluginNameProblem,
19 pluginRef,
20 subagentOf,
21} from "./claude-plugin.ts";
22import { parseFrontMatter, splitFrontMatter } from "./skill-format.ts";
23
24/** A subagent file read the way the agents service reads one. */
25function parseSubagentFile(text: string) {
26 const split = splitFrontMatter(text);
27 if (!split.ok) return { ok: false as const, message: split.message };
28 try {
29 return subagentOf(parseFrontMatter(split.yaml), split.body);
30 } catch (error) {
31 return { ok: false as const, message: (error as Error).message };
32 }
33}
34
35// The two public marketplaces as they are published, in miniature.
36const OFFICIAL = {
37 name: "claude-plugins-official",
38 owner: { name: "Anthropic", email: "support@anthropic.com" },
39 plugins: [
40 { name: "agent-sdk-dev", source: "./plugins/agent-sdk-dev", description: "Development kit", category: "development", author: { name: "Anthropic" } },
41 { name: "asana", source: "./external_plugins/asana", category: "productivity", description: "Asana" },
42 {
43 name: "airtable",
44 source: { source: "git-subdir", url: "https://github.com/Airtable/skills.git", path: "plugins/airtable", ref: "main", sha: "812ee67f1fd3d76fb45ff8df40afaa0448602ba8" },
45 category: "productivity",
46 description: "Airtable",
47 },
48 { name: "aikido", source: { source: "url", url: "https://github.com/AikidoSec/aikido-claude-plugin.git", sha: "02f018ad4da175a2a5ad6ca9451c3d309d105fab" } },
49 { name: "from-npm", source: { source: "npm", package: "@acme/plugin" } },
50 { name: "bad entry", source: "./x" },
51 { name: "no-source" },
52 ],
53};
54
55const SKILLS = {
56 name: "anthropic-agent-skills",
57 owner: { name: "Keith Lazuka", email: "klazuka@anthropic.com" },
58 metadata: { description: "Anthropic example skills", version: "1.0.0" },
59 plugins: [
60 { name: "document-skills", description: "Documents", source: "./", strict: false, skills: ["./skills/xlsx", "./skills/docx", "./skills/pptx", "./skills/pdf"] },
61 { name: "claude-api", description: "Claude API", source: "./", strict: false, skills: ["./skills/claude-api"] },
62 ],
63};
64
65test("a marketplace manifest reads as published, noting the entries it can't read", () => {
66 const read = parseMarketplaceManifest(OFFICIAL);
67 assert.ok(read.ok);
68 assert.equal(read.value.name, "claude-plugins-official");
69 assert.equal(read.value.owner.name, "Anthropic");
70 assert.deepEqual(
71 read.value.plugins.map((p) => [p.name, p.source.source, p.category]),
72 [
73 ["agent-sdk-dev", "path", "development"],
74 ["asana", "path", "productivity"],
75 ["airtable", "git-subdir", "productivity"],
76 ["aikido", "url", null],
77 ["from-npm", "npm", null],
78 ],
79 );
80 assert.deepEqual(read.value.problems, ["Plugin 6: A plugin's name is letters, digits, dots, underscores and hyphens, like pr-review-toolkit.", "Plugin 7: no-source has no source."]);
81 const skills = parseMarketplaceManifest(SKILLS);
82 assert.ok(skills.ok);
83 assert.equal(skills.value.description, "Anthropic example skills");
84 assert.equal(skills.value.version, "1.0.0");
85 assert.deepEqual(skills.value.plugins[0]!.skills, ["skills/xlsx", "skills/docx", "skills/pptx", "skills/pdf"]);
86 assert.equal(skills.value.plugins[0]!.strict, false);
87});
88
89test("a manifest without a name, an owner or plugins is refused", () => {
90 assert.equal(parseMarketplaceManifest({ owner: { name: "x" }, plugins: [] }).ok, false);
91 assert.equal(parseMarketplaceManifest({ name: "x", plugins: [] }).ok, false);
92 assert.equal(parseMarketplaceManifest({ name: "x", owner: "Someone" }).ok, false);
93 assert.equal(parseMarketplaceManifest({ name: "../x", owner: { name: "x" }, plugins: [] }).ok, false);
94 assert.equal(parseMarketplaceManifest("[]").ok, false);
95});
96
97test("plugin sources: every form Claude Code takes, and what each refuses", () => {
98 assert.deepEqual(parsePluginSource("./plugins/foo/"), { ok: true, value: { source: "path", path: "./plugins/foo" } });
99 assert.equal(parsePluginSource("plugins/foo").ok, false);
100 assert.equal(parsePluginSource("./plugins/../foo").ok, false);
101 assert.deepEqual(parsePluginSource({ source: "github", repo: "acme/tools", ref: "v1", sha: "ABCDEF0123456789abcdef0123456789abcdef01" }), {
102 ok: true,
103 value: { source: "github", repo: "acme/tools", ref: "v1", sha: "abcdef0123456789abcdef0123456789abcdef01" },
104 });
105 assert.equal(parsePluginSource({ source: "github", repo: "not a repo" }).ok, false);
106 assert.equal(parsePluginSource({ source: "url", url: "ftp://x" }).ok, false);
107 assert.equal(parsePluginSource({ source: "git-subdir", url: "https://github.com/a/b.git", path: "../x" }).ok, false);
108 assert.deepEqual(parsePluginSource({ source: "archive", url: "https://x.dev/p.zip", sha256: "ab".repeat(32) }), { ok: true, value: { source: "archive", url: "https://x.dev/p.zip", sha256: "ab".repeat(32) } });
109 assert.deepEqual(parsePluginSource({ source: "command", command: "make plugin", mode: "link" }), { ok: true, value: { source: "command", command: "make plugin", mode: "link" } });
110 assert.equal(parsePluginSource({ source: "pip", package: "x" }).ok, false);
111});
112
113test("where to fetch a plugin from follows its source and its marketplace", () => {
114 const github = { source: "github", repo: "anthropics/claude-plugins-official" } as const;
115 assert.deepEqual(fetchPlan({ source: "path", path: "./plugins/agent-sdk-dev" }, github), { kind: "github", repo: "anthropics/claude-plugins-official", ref: null, sha: null, path: "plugins/agent-sdk-dev" });
116 assert.deepEqual(fetchPlan({ source: "path", path: "./" }, { source: "github", repo: "anthropics/skills", ref: "main" }), { kind: "github", repo: "anthropics/skills", ref: "main", sha: null, path: "" });
117 assert.deepEqual(fetchPlan({ source: "path", path: "./foo" }, { source: "g1t", repo: "flagon-io/plugins" }), { kind: "g1t", repo: "flagon-io/plugins", ref: null, path: "foo" });
118 assert.deepEqual(fetchPlan({ source: "git-subdir", url: "https://github.com/Airtable/skills.git", path: "plugins/airtable", ref: "main", sha: "8".repeat(40) }, github), {
119 kind: "github",
120 repo: "Airtable/skills",
121 ref: "main",
122 sha: "8".repeat(40),
123 path: "plugins/airtable",
124 });
125 assert.deepEqual(fetchPlan({ source: "url", url: "https://github.com/AikidoSec/aikido-claude-plugin.git", sha: "0".repeat(40) }, github), { kind: "github", repo: "AikidoSec/aikido-claude-plugin", ref: null, sha: "0".repeat(40), path: "" });
126 assert.deepEqual(fetchPlan({ source: "url", url: "https://g1t.sh/flagon-io/tools" }, github, { host: "g1t.sh" }), { kind: "g1t", repo: "flagon-io/tools", ref: null, path: "" });
127 assert.equal(fetchPlan({ source: "url", url: "https://gitlab.com/a/b.git" }, github).kind, "unsupported");
128 assert.equal(fetchPlan({ source: "npm", package: "x" }, github).kind, "unsupported");
129 assert.equal(fetchPlan({ source: "command", command: "x" }, github).kind, "unsupported");
130 assert.equal(fetchPlan({ source: "path", path: "./x" }, { source: "url", url: "https://x.dev/marketplace.json" }).kind, "unsupported");
131 assert.deepEqual(fetchPlan({ source: "path", path: "foo" }, github, { pluginRoot: "plugins" }), { kind: "github", repo: "anthropics/claude-plugins-official", ref: null, sha: null, path: "plugins/foo" });
132 assert.deepEqual(fetchPlan({ source: "archive", url: "https://x.dev/p.zip" }, github), { kind: "archive", url: "https://x.dev/p.zip", sha256: null });
133});
134
135test("a marketplace address is read the ways people write one", () => {
136 assert.deepEqual(parseMarketplaceAddress("anthropics/skills"), { ok: true, value: { source: "github", repo: "anthropics/skills" } });
137 assert.deepEqual(parseMarketplaceAddress("acme/tools#v2"), { ok: true, value: { source: "github", repo: "acme/tools", ref: "v2" } });
138 assert.deepEqual(parseMarketplaceAddress("https://github.com/anthropics/claude-plugins-official"), { ok: true, value: { source: "github", repo: "anthropics/claude-plugins-official" } });
139 assert.deepEqual(parseMarketplaceAddress("https://github.com/acme/tools/tree/main"), { ok: true, value: { source: "github", repo: "acme/tools", ref: "main" } });
140 assert.deepEqual(parseMarketplaceAddress("https://x.dev/plugins/marketplace.json"), { ok: true, value: { source: "url", url: "https://x.dev/plugins/marketplace.json" } });
141 assert.deepEqual(parseMarketplaceAddress("https://gitlab.com/acme/tools.git#main"), { ok: true, value: { source: "git", url: "https://gitlab.com/acme/tools.git", ref: "main" } });
142 assert.deepEqual(parseMarketplaceAddress("flagon-io/plugins", { here: (repo) => repo === "flagon-io/plugins" }), { ok: true, value: { source: "g1t", repo: "flagon-io/plugins" } });
143 assert.deepEqual(parseMarketplaceAddress("https://g1t.sh/flagon-io/plugins", { host: "g1t.sh" }), { ok: true, value: { source: "g1t", repo: "flagon-io/plugins" } });
144 assert.equal(parseMarketplaceAddress("").ok, false);
145 assert.equal(parseMarketplaceAddress("just words").ok, false);
146 assert.deepEqual(githubRepoOf("git@github.com:acme/tools.git"), { repo: "acme/tools" });
147 assert.equal(githubRepoOf("https://example.com/acme/tools"), null);
148});
149
150test("names: marketplaces, reserved ones, plugins and plugin@marketplace", () => {
151 assert.equal(marketplaceNameProblem("my-plugins"), null);
152 assert.ok(marketplaceNameProblem("-x"));
153 assert.ok(marketplaceNameProblem("a..b"));
154 assert.ok(isReservedMarketplaceName("claude-plugins-official"));
155 assert.ok(isReservedMarketplaceName("claudeai-foo"));
156 assert.ok(isReservedMarketplaceName("g1t"));
157 assert.equal(isReservedMarketplaceName("acme"), false);
158 assert.equal(pluginNameProblem("pr-review-toolkit"), null);
159 assert.ok(pluginNameProblem("a:b"));
160 assert.ok(pluginNameProblem("a/b"));
161 assert.equal(pluginRef("pdf", "anthropic-agent-skills"), "pdf@anthropic-agent-skills");
162 assert.deepEqual(parsePluginRef("pdf@anthropic-agent-skills"), { name: "pdf", marketplace: "anthropic-agent-skills" });
163 assert.equal(parsePluginRef("pdf"), null);
164 assert.equal(parsePluginRef("@x"), null);
165 assert.equal(namespaced("document-skills", "pdf"), "document-skills:pdf");
166});
167
168test("plugin.json needs only a name; component paths and inline servers are kept", () => {
169 const read = parsePluginManifest({ name: "asana", description: "Asana", author: { name: "Asana" }, skills: ["./extra-skills"], mcpServers: { asana: { type: "http", url: "https://mcp.asana.com/v2/mcp" } }, hooks: "./hooks/hooks.json", other: 1 });
170 assert.ok(read.ok);
171 assert.deepEqual(read.value.skills, ["extra-skills"]);
172 assert.deepEqual(read.value.hooks, ["hooks/hooks.json"]);
173 assert.deepEqual(read.value.extra, { other: 1 });
174 assert.ok(read.value.mcpServers && typeof read.value.mcpServers === "object");
175 assert.equal(parsePluginManifest({ description: "no name" }).ok, false);
176 assert.equal(parsePluginManifest({ name: "claude:x" }).ok, false);
177});
178
179test(".mcp.json: remote servers, stdio ones, and what is skipped", () => {
180 const { servers, problems } = parseMcpServers({
181 mcpServers: {
182 asana: { type: "http", url: "https://mcp.asana.com/v2/mcp", headers: { Authorization: "Bearer ${ASANA_TOKEN}" } },
183 legacy: { type: "sse", url: "https://x.dev/sse" },
184 local: { command: "npx", args: ["-y", "@acme/server", "${HOME}"], env: { KEY: "${ACME_KEY:-none}" } },
185 untyped: { url: "https://x.dev/mcp" },
186 broken: "nope",
187 "bad name!": { type: "http", url: "https://x.dev" },
188 },
189 });
190 assert.deepEqual(
191 servers.map((s) => [s.name, s.type, s.url ?? s.command, s.variables]),
192 [
193 ["asana", "http", "https://mcp.asana.com/v2/mcp", ["ASANA_TOKEN"]],
194 ["legacy", "sse", "https://x.dev/sse", []],
195 ["local", "stdio", "npx", ["ACME_KEY", "HOME"]],
196 ],
197 );
198 assert.equal(problems.length, 3);
199 assert.match(problems[0]!, /untyped: a remote server says its type/);
200 assert.equal(parseMcpServers([]).problems.length, 1);
201});
202
203test("a subagent file: front-matter and the prompt, skipped the way Claude Code skips it", () => {
204 const read = parseSubagentFile(`---
205name: code-reviewer
206description: Reviews pull requests for risk. Use after a change is ready.
207tools: Read, Grep, Bash
208model: sonnet
209skills:
210 - pr-review
211color: green
212---
213
214You are a careful reviewer. Read the diff first.
215`);
216 assert.ok(read.ok);
217 assert.deepEqual(
218 { name: read.value.name, tools: read.value.tools, model: read.value.model, skills: read.value.skills, color: read.value.color },
219 { name: "code-reviewer", tools: ["Read", "Grep", "Bash"], model: "sonnet", skills: ["pr-review"], color: "green" },
220 );
221 assert.equal(read.value.prompt, "You are a careful reviewer. Read the diff first.");
222 assert.equal(parseSubagentFile("no front matter").ok, false);
223 assert.equal(parseSubagentFile("---\nname: x\n---\nbody").ok, false);
224 assert.equal(parseSubagentFile("---\nname: a:b\ndescription: d\n---\nbody").ok, false);
225 assert.equal(parseSubagentFile("---\nname: a\ndescription: d\n---\n\n").ok, false);
226});
227
228test("a plugin's layout is found from its files, its manifest and a strict entry", () => {
229 const files = [
230 ".claude-plugin/plugin.json",
231 "README.md",
232 "skills/pdf/SKILL.md",
233 "skills/pdf/scripts/fill.py",
234 "skills/docx/SKILL.md",
235 "commands/review.md",
236 "agents/reviewer.md",
237 "agents/security/audit.md",
238 "hooks/hooks.json",
239 ".mcp.json",
240 "bin/tool",
241 "extra/one/SKILL.md",
242 ];
243 const manifest = parsePluginManifest({ name: "kit", skills: ["./extra"] });
244 assert.ok(manifest.ok);
245 const layout = pluginLayout(files, manifest.value, null);
246 assert.deepEqual(layout.skills, [
247 { path: "extra/one", name: "one" },
248 { path: "skills/docx", name: "docx" },
249 { path: "skills/pdf", name: "pdf" },
250 ]);
251 assert.deepEqual(layout.commands, [{ path: "commands/review.md", name: "review" }]);
252 assert.deepEqual(layout.agents, [
253 { path: "agents/reviewer.md", name: "reviewer" },
254 { path: "agents/security/audit.md", name: "audit" },
255 ]);
256 assert.deepEqual(layout.hooks, ["hooks/hooks.json"]);
257 assert.equal(layout.mcp, ".mcp.json");
258 assert.equal(layout.bin, true);
259 assert.equal(layout.readme, "README.md");
260 // The skills marketplace: a plugin whose folder is the marketplace root, with its skills named by the entry.
261 const entry = parseMarketplaceManifest(SKILLS);
262 assert.ok(entry.ok);
263 const docs = pluginLayout(["skills/xlsx/SKILL.md", "skills/docx/SKILL.md", "skills/pdf/SKILL.md", "skills/pptx/SKILL.md", "skills/other/SKILL.md", "README.md"], null, entry.value.plugins[0]!);
264 // The entry is not strict: it names its skills, so a sibling folder under skills/ is not its.
265 assert.deepEqual(
266 docs.skills.map((s) => s.name),
267 ["docx", "pdf", "pptx", "xlsx"],
268 );
269 // An entry that names its skills gets exactly those, not every sibling folder under skills/.
270 assert.deepEqual(pluginLayout(["skills/xlsx/SKILL.md", "skills/claude-api/SKILL.md", "skills/other/SKILL.md"], null, entry.value.plugins[1]!).skills, [{ path: "skills/claude-api", name: "claude-api" }]);
271 // A plugin that is one skill at its root.
272 assert.deepEqual(pluginLayout(["SKILL.md", "reference.md"], null, { ...entry.value.plugins[1]!, skills: ["."] }).skills, [{ path: ".", name: "claude-api" }]);
273});
274
275test("the built-in marketplaces are the public ones, under their own names", () => {
276 assert.deepEqual(
277 BUILTIN_MARKETPLACES.map((m) => [m.name, m.source]),
278 [
279 ["claude-plugins-official", { source: "github", repo: "anthropics/claude-plugins-official" }],
280 ["anthropic-agent-skills", { source: "github", repo: "anthropics/skills" }],
281 ],
282 );
283 for (const m of BUILTIN_MARKETPLACES) assert.ok(isReservedMarketplaceName(m.name));
284});