Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell. | 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 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.