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