/** * @file plugins-marketplace.test.js * @description Structural validation for the bundled Claude Code plugin * marketplace (.claude-plugin/marketplace.json + plugins/*). Guards that * every marketplace entry resolves to a real plugin dir with a valid * plugin.json, that names line up, and that every agent / skill / command * file carries the frontmatter Claude Code requires. Pure file reads. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { parseFrontmatter } = require("../lib/cc-discovery"); const REPO_ROOT = path.join(__dirname, "..", ".."); const PLUGINS_DIR = path.join(REPO_ROOT, "plugins"); const MARKETPLACE = path.join(REPO_ROOT, ".claude-plugin", "marketplace.json"); function readJson(p) { return JSON.parse(fs.readFileSync(p, "utf8")); } function listDirs(p) { return fs .readdirSync(p, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name); } function listMd(dir) { try { return fs .readdirSync(dir, { withFileTypes: true }) .filter((e) => e.isFile() && e.name.endsWith(".md")) .map((e) => e.name); } catch { return []; } } // The marketplace mixes two shapes: the root-source `ccam` plugin (the whole // repo — hooks, server, CLI, MCP) and ten subdirectory plugins. The root // plugin's commands live in plugins/ccam/, which is therefore NOT a plugin dir. const ROOT_PLUGIN = "ccam"; describe("plugin marketplace", () => { const marketplace = readJson(MARKETPLACE); const pluginDirs = listDirs(PLUGINS_DIR) .filter((d) => d !== ROOT_PLUGIN) .sort(); const subdirEntries = marketplace.plugins.filter((p) => p.name !== ROOT_PLUGIN); const entryNames = subdirEntries.map((p) => p.name).sort(); it("marketplace.json has the required top-level shape", () => { assert.equal(typeof marketplace.name, "string"); assert.ok(marketplace.name.length > 0); assert.equal(typeof marketplace.description, "string"); assert.ok(marketplace.owner && typeof marketplace.owner.name === "string"); assert.ok(Array.isArray(marketplace.plugins)); }); it("ships at least 10 plugins", () => { assert.ok( marketplace.plugins.length >= 10, `expected >=10 marketplace entries, got ${marketplace.plugins.length}` ); assert.ok(pluginDirs.length >= 10, `expected >=10 plugin dirs, got ${pluginDirs.length}`); }); it("marketplace entries and plugin dirs are a bijection", () => { assert.deepEqual( entryNames, pluginDirs, `marketplace entries (${entryNames}) must match plugin dirs (${pluginDirs})` ); }); for (const entry of subdirEntries) { describe(`entry: ${entry.name}`, () => { it("has name, path, description, tags", () => { assert.equal(typeof entry.name, "string"); assert.equal(entry.path, `plugins/${entry.name}`); assert.equal(typeof entry.description, "string"); assert.ok(entry.description.length > 20); assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0); }); it("path exists on disk", () => { assert.ok(fs.existsSync(path.join(REPO_ROOT, entry.path))); }); }); } for (const dir of pluginDirs) { describe(`plugin: ${dir}`, () => { const root = path.join(PLUGINS_DIR, dir); const manifestPath = path.join(root, ".claude-plugin", "plugin.json"); it("has a valid plugin.json whose name matches the dir", () => { assert.ok(fs.existsSync(manifestPath), `${dir} is missing .claude-plugin/plugin.json`); const m = readJson(manifestPath); assert.equal(m.name, dir, `${dir}/plugin.json name must equal the dir name`); assert.equal(typeof m.description, "string"); assert.ok(m.description.length > 20); assert.equal(typeof m.version, "string"); assert.ok(m.author && typeof m.author.name === "string"); assert.equal(typeof m.license, "string"); assert.ok(Array.isArray(m.keywords) && m.keywords.length > 0); }); it("agents carry valid frontmatter (name === filename, description)", () => { const agentsDir = path.join(root, "agents"); for (const f of listMd(agentsDir)) { const { frontmatter } = parseFrontmatter( fs.readFileSync(path.join(agentsDir, f), "utf8") ); assert.ok(frontmatter, `${dir}/agents/${f} has no frontmatter`); assert.equal( frontmatter.name, f.replace(/\.md$/, ""), `${dir}/agents/${f} frontmatter name must equal the filename` ); assert.ok(frontmatter.description, `${dir}/agents/${f} missing description`); } }); it("skills carry a description in SKILL.md frontmatter", () => { const skillsDir = path.join(root, "skills"); let skillDirs = []; try { skillDirs = listDirs(skillsDir); } catch { skillDirs = []; } for (const s of skillDirs) { const file = path.join(skillsDir, s, "SKILL.md"); assert.ok(fs.existsSync(file), `${dir}/skills/${s} is missing SKILL.md`); const { frontmatter } = parseFrontmatter(fs.readFileSync(file, "utf8")); assert.ok(frontmatter, `${dir}/skills/${s}/SKILL.md has no frontmatter`); assert.ok(frontmatter.description, `${dir}/skills/${s}/SKILL.md missing description`); } }); it("commands carry a description in frontmatter", () => { const cmdDir = path.join(root, "commands"); for (const f of listMd(cmdDir)) { const { frontmatter } = parseFrontmatter(fs.readFileSync(path.join(cmdDir, f), "utf8")); assert.ok(frontmatter, `${dir}/commands/${f} has no frontmatter`); assert.ok(frontmatter.description, `${dir}/commands/${f} missing description`); } }); it("hooks.json (if present) is valid JSON with a hooks object", () => { const hooksFile = path.join(root, "hooks", "hooks.json"); if (!fs.existsSync(hooksFile)) return; const h = readJson(hooksFile); assert.ok( h.hooks && typeof h.hooks === "object", `${dir}/hooks/hooks.json needs a hooks object` ); }); it("contributes at least one skill or agent (subdir plugins only)", () => { const skills = (() => { try { return listDirs(path.join(root, "skills")).length; } catch { return 0; } })(); const agents = listMd(path.join(root, "agents")).length; assert.ok(skills + agents > 0, `${dir} contributes no skills or agents`); }); }); } describe(`root plugin: ${ROOT_PLUGIN}`, () => { const entry = marketplace.plugins.find((p) => p.name === ROOT_PLUGIN); const manifest = readJson(path.join(REPO_ROOT, ".claude-plugin", "plugin.json")); it("is declared with the repo root as its source", () => { assert.ok(entry, "the root ccam entry is missing from marketplace.json"); assert.equal(entry.source, "./"); assert.equal(entry.path, undefined, "a root-source entry must not also carry a path"); assert.ok(Array.isArray(entry.tags) && entry.tags.length > 0); }); it("has a manifest whose name matches the entry", () => { assert.equal(manifest.name, ROOT_PLUGIN); assert.ok(manifest.description.length > 20); assert.ok(manifest.author && manifest.author.name); assert.equal(typeof manifest.license, "string"); }); it("wires every hook type through the plugin's own handler path", () => { const withMatcher = ["PreToolUse", "PostToolUse", "Stop", "SubagentStop", "Notification"]; const withoutMatcher = ["SessionStart", "SessionEnd", "UserPromptSubmit"]; for (const type of [...withMatcher, ...withoutMatcher]) { const entries = manifest.hooks[type]; assert.ok(Array.isArray(entries) && entries.length, `hook ${type} is not declared`); const json = JSON.stringify(entries); assert.match(json, /hook-handler\.js/, `hook ${type} does not call the handler`); assert.match( json, /\$\{CLAUDE_PLUGIN_ROOT\}/, `hook ${type} must resolve through CLAUDE_PLUGIN_ROOT, not a checkout path` ); if (withMatcher.includes(type)) { assert.equal(entries[0].matcher, "*", `hook ${type} needs a matcher`); } else { assert.equal(entries[0].matcher, undefined, `hook ${type} takes no matcher`); } } }); it("runs the bootstrap on SessionStart", () => { assert.match(JSON.stringify(manifest.hooks.SessionStart), /plugin-bootstrap\.js/); }); it("points at command files that exist and carry a description", () => { assert.ok(manifest.commands.length >= 3); for (const rel of manifest.commands) { const file = path.join(REPO_ROOT, rel); assert.ok(fs.existsSync(file), `${rel} does not exist`); const { frontmatter } = parseFrontmatter(fs.readFileSync(file, "utf8")); assert.ok(frontmatter && frontmatter.description, `${rel} has no description`); } }); it("declares an MCP config that exists and resolves through CLAUDE_PLUGIN_ROOT", () => { const mcpFile = path.join(REPO_ROOT, manifest.mcpServers); assert.ok(fs.existsSync(mcpFile), `${manifest.mcpServers} does not exist`); const mcp = readJson(mcpFile); const args = JSON.stringify(mcp.mcpServers); assert.match(args, /\$\{CLAUDE_PLUGIN_ROOT\}\/mcp\/build\/index\.js/); }); it("ships the built MCP server the config points at", () => { assert.ok( fs.existsSync(path.join(REPO_ROOT, "mcp", "build", "index.js")), "mcp/build/index.js must be committed — plugin MCP servers start before any bootstrap can build them" ); }); }); });