| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { |
| 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 | import { parseFrontMatter, splitFrontMatter } from "./skill-format.ts"; |
| 23 | |
| 24 | /** A subagent file read the way the agents service reads one. */ |
| 25 | function 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. |
| 36 | const 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 | |
| 55 | const 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 | |
| 65 | test("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 | |
| 89 | test("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 | |
| 97 | test("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 | |
| 113 | test("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 | |
| 135 | test("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 | |
| 150 | test("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 | |
| 168 | test("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 | |
| 179 | test(".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 | |
| 203 | test("a subagent file: front-matter and the prompt, skipped the way Claude Code skips it", () => { |
| 204 | const read = parseSubagentFile(`--- |
| 205 | name: code-reviewer |
| 206 | description: Reviews pull requests for risk. Use after a change is ready. |
| 207 | tools: Read, Grep, Bash |
| 208 | model: sonnet |
| 209 | skills: |
| 210 | - pr-review |
| 211 | color: green |
| 212 | --- |
| 213 | |
| 214 | You 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 | |
| 228 | test("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 | |
| 275 | test("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 | }); |