'use strict'; /** * Marketplace Drift Validator * * Verifies .claude-plugin/marketplace.json stays in sync with src/**\/SKILL.md. * The marketplace.json is what Claude Code (and Claude Cowork) consume when a * user runs `/plugin marketplace add bmad-code-org/BMAD-METHOD` — every skill * shipped to other IDEs through the regular installer must also be reachable * through the marketplace, or Cowork users silently miss skills. * * Checks: * - Every src/**\/SKILL.md path is declared in some plugin's `skills` array. * - Every declared skill path resolves to an existing src/.../SKILL.md. * - No skill path is declared by more than one plugin. * * Usage: * node tools/validate-marketplace.js human-readable report * node tools/validate-marketplace.js --strict exit 1 on any drift (CI) * node tools/validate-marketplace.js --json JSON output */ const fs = require('node:fs'); const path = require('node:path'); const PROJECT_ROOT = path.resolve(__dirname, '..'); const SRC_DIR = path.join(PROJECT_ROOT, 'src'); const MARKETPLACE_PATH = path.join(PROJECT_ROOT, '.claude-plugin', 'marketplace.json'); const args = new Set(process.argv.slice(2)); const STRICT = args.has('--strict'); const JSON_OUTPUT = args.has('--json'); function findSkillPaths(dir, acc = []) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { findSkillPaths(full, acc); } else if (entry.name === 'SKILL.md') { // Normalize to forward slashes so the string-equal comparison against // marketplace.json paths works on Windows (where path.relative uses '\'). const rel = path.relative(PROJECT_ROOT, path.dirname(full)).split(path.sep).join('/'); acc.push('./' + rel); } } return acc; } function suggestPlugin(skillPath, plugins) { // Score by shared path-segment depth (not raw character prefix), so a new // module like ./src/cis-skills/foo doesn't get falsely suggested under // bmad-pro-skills just because both share './src/'. // Suggest only when the match goes beyond the './src//' boundary. const skillSegments = skillPath.split('/'); let best = null; let bestScore = 0; for (const plugin of plugins) { for (const declared of plugin.skills || []) { const declaredSegments = declared.split('/'); let i = 0; while (i < skillSegments.length && i < declaredSegments.length && skillSegments[i] === declaredSegments[i]) { i++; } if (i > bestScore) { bestScore = i; best = plugin.name; } } } // Two skills in the same module family (e.g., both under ./src/core-skills/) // share exactly 3 segments: '.', 'src', ''. A skill in a brand-new // family (e.g., ./src/cis-skills/) only shares 2 segments. Require >= 3 // so suggestions stay within the same family and don't leak across modules. return bestScore >= 3 ? best : null; } function validate() { if (!fs.existsSync(MARKETPLACE_PATH)) { return { ok: false, fatal: `marketplace.json not found at ${MARKETPLACE_PATH}` }; } let marketplace; try { marketplace = JSON.parse(fs.readFileSync(MARKETPLACE_PATH, 'utf8')); } catch (error) { return { ok: false, fatal: `marketplace.json is not valid JSON: ${error.message}` }; } if (!fs.existsSync(SRC_DIR)) { return { ok: false, fatal: `src directory not found at ${SRC_DIR}` }; } const plugins = Array.isArray(marketplace.plugins) ? marketplace.plugins : []; const declaredBy = new Map(); // skillPath -> [pluginName] for (const plugin of plugins) { const skills = Array.isArray(plugin.skills) ? plugin.skills : []; for (const skillPath of skills) { if (!declaredBy.has(skillPath)) declaredBy.set(skillPath, []); declaredBy.get(skillPath).push(plugin.name); } } const onDisk = new Set(findSkillPaths(SRC_DIR)); const missing = []; // SKILL.md exists in src/ but no plugin declares it for (const skillPath of [...onDisk].sort()) { if (!declaredBy.has(skillPath)) { missing.push({ path: skillPath, suggestedPlugin: suggestPlugin(skillPath, plugins) }); } } const orphans = []; // plugin declares a path that has no SKILL.md for (const skillPath of declaredBy.keys()) { if (!onDisk.has(skillPath)) { orphans.push({ path: skillPath, declaredBy: declaredBy.get(skillPath) }); } } const duplicates = []; // same path declared more than once (within or across plugins) for (const [skillPath, names] of declaredBy) { if (names.length > 1) { const uniquePlugins = [...new Set(names)]; duplicates.push({ path: skillPath, declaredBy: uniquePlugins, withinSamePlugin: uniquePlugins.length === 1, }); } } return { ok: missing.length === 0 && orphans.length === 0 && duplicates.length === 0, totals: { onDisk: onDisk.size, declared: declaredBy.size, plugins: plugins.length }, missing, orphans, duplicates, }; } function reportHuman(result) { if (result.fatal) { console.error(`✗ ${result.fatal}`); return; } const { totals, missing, orphans, duplicates, ok } = result; console.log(`Marketplace coverage: ${totals.declared} declared / ${totals.onDisk} on disk across ${totals.plugins} plugin(s)`); if (missing.length > 0) { console.log(`\n✗ ${missing.length} skill(s) on disk are not declared in marketplace.json:`); for (const m of missing) { const hint = m.suggestedPlugin ? ` → likely belongs in "${m.suggestedPlugin}"` : ''; console.log(` ${m.path}${hint}`); } } if (orphans.length > 0) { console.log(`\n✗ ${orphans.length} declared skill path(s) do not exist on disk:`); for (const o of orphans) { console.log(` ${o.path} (declared by: ${o.declaredBy.join(', ')})`); } } if (duplicates.length > 0) { console.log(`\n✗ ${duplicates.length} skill path(s) declared more than once:`); for (const d of duplicates) { const where = d.withinSamePlugin ? `listed multiple times in "${d.declaredBy[0]}"` : `in multiple plugins: ${d.declaredBy.join(', ')}`; console.log(` ${d.path} (${where})`); } } if (ok) console.log('\n✓ marketplace.json is in sync with src/'); } const result = validate(); if (JSON_OUTPUT) { console.log(JSON.stringify(result, null, 2)); } else { reportHuman(result); } if (!result.ok && (STRICT || result.fatal)) { process.exit(1); }