#!/usr/bin/env bun /** * Marketplace Validation Script * * Validates: * - marketplace.json schema and structure * - plugin.json for each plugin * - SKILL.md frontmatter (name, version, description) * - File structure conventions * * Usage: bun run shared/scripts/validate-marketplace.ts */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; /** * Result of validating a marketplace component. */ interface ValidationResult { /** Whether validation passed without errors */ passed: boolean; /** Critical validation errors */ errors: string[]; /** Non-critical validation warnings */ warnings: string[]; } /** * Structure of marketplace.json configuration. */ interface MarketplaceJson { /** Marketplace name */ name: string; /** Marketplace owner info */ owner?: { name: string; email?: string }; /** Marketplace metadata */ metadata?: { description?: string; version?: string }; /** Plugins available in this marketplace */ plugins: Array<{ name: string; source: string; description?: string; version?: string; }>; } /** * Structure of plugin.json configuration. */ interface PluginJson { /** Plugin name */ name: string; /** Semantic version */ version: string; /** Plugin description */ description: string; /** Plugin author info */ author?: { name: string; email?: string; url?: string }; /** Keywords for discovery */ keywords?: string[]; } const REPO_ROOT = resolve(import.meta.dirname, "../.."); function log( message: string, type: "info" | "error" | "warning" | "success" = "info", ): void { const prefix = { info: " ", error: "āœ— ", warning: "ā–³ ", success: "āœ“ ", }[type]; console.log(`${prefix}${message}`); } function parseYamlFrontmatter(content: string): Record | null { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return null; const frontmatter: Record = {}; const lines = match[1].split("\n"); for (const line of lines) { const colonIndex = line.indexOf(":"); if (colonIndex > 0) { const key = line.slice(0, colonIndex).trim(); const value = line.slice(colonIndex + 1).trim(); frontmatter[key] = value; } } return frontmatter; } function validateMarketplaceJson(): ValidationResult { const result: ValidationResult = { passed: true, errors: [], warnings: [] }; const marketplacePath = join(REPO_ROOT, ".claude-plugin/marketplace.json"); if (!existsSync(marketplacePath)) { result.passed = false; result.errors.push( "marketplace.json not found at .claude-plugin/marketplace.json", ); return result; } try { const content = readFileSync(marketplacePath, "utf-8"); const marketplace: MarketplaceJson = JSON.parse(content); // Required fields if (!marketplace.name) { result.passed = false; result.errors.push("marketplace.json: missing 'name' field"); } if (!marketplace.plugins || !Array.isArray(marketplace.plugins)) { result.passed = false; result.errors.push( "marketplace.json: missing or invalid 'plugins' array", ); return result; } // Validate each plugin entry for (const plugin of marketplace.plugins) { if (!plugin.name) { result.passed = false; result.errors.push("marketplace.json: plugin entry missing 'name'"); } if (!plugin.source) { result.passed = false; result.errors.push( `marketplace.json: plugin '${plugin.name}' missing 'source'`, ); } if (!plugin.version) { result.warnings.push( `marketplace.json: plugin '${plugin.name}' missing 'version'`, ); } } } catch (error) { result.passed = false; result.errors.push(`marketplace.json: invalid JSON - ${error}`); } return result; } function validatePluginJson( pluginPath: string, pluginName: string, ): ValidationResult { const result: ValidationResult = { passed: true, errors: [], warnings: [] }; const pluginJsonPath = join(pluginPath, ".claude-plugin/plugin.json"); if (!existsSync(pluginJsonPath)) { result.passed = false; result.errors.push(`${pluginName}: missing .claude-plugin/plugin.json`); return result; } try { const content = readFileSync(pluginJsonPath, "utf-8"); const pluginJson: PluginJson = JSON.parse(content); // Required fields if (!pluginJson.name) { result.passed = false; result.errors.push(`${pluginName}/plugin.json: missing 'name'`); } if (!pluginJson.version) { result.passed = false; result.errors.push(`${pluginName}/plugin.json: missing 'version'`); } if (!pluginJson.description) { result.passed = false; result.errors.push(`${pluginName}/plugin.json: missing 'description'`); } // Recommended fields if (!pluginJson.author) { result.warnings.push(`${pluginName}/plugin.json: missing 'author'`); } if (!pluginJson.keywords || pluginJson.keywords.length === 0) { result.warnings.push(`${pluginName}/plugin.json: missing 'keywords'`); } } catch (error) { result.passed = false; result.errors.push(`${pluginName}/plugin.json: invalid JSON - ${error}`); } return result; } function validateSkillFrontmatter( skillPath: string, pluginName: string, skillName: string, ): ValidationResult { const result: ValidationResult = { passed: true, errors: [], warnings: [] }; const skillMdPath = join(skillPath, "SKILL.md"); if (!existsSync(skillMdPath)) { result.passed = false; result.errors.push(`${pluginName}/${skillName}: missing SKILL.md`); return result; } const content = readFileSync(skillMdPath, "utf-8"); const frontmatter = parseYamlFrontmatter(content); if (!frontmatter) { result.passed = false; result.errors.push( `${pluginName}/${skillName}/SKILL.md: missing YAML frontmatter`, ); return result; } // Required frontmatter fields (per agentskills.io spec: name + description only) if (!frontmatter.name) { result.passed = false; result.errors.push( `${pluginName}/${skillName}/SKILL.md: frontmatter missing 'name'`, ); } if (!frontmatter.description) { result.passed = false; result.errors.push( `${pluginName}/${skillName}/SKILL.md: frontmatter missing 'description'`, ); } // Check description quality (should have trigger keywords) if (frontmatter.description && frontmatter.description.length < 50) { result.warnings.push( `${pluginName}/${skillName}/SKILL.md: description seems short (< 50 chars)`, ); } // Check file size (skills should be < 500 lines) const lines = content.split("\n").length; if (lines > 500) { result.warnings.push( `${pluginName}/${skillName}/SKILL.md: ${lines} lines (recommended < 500)`, ); } return result; } function validatePlugin( pluginPath: string, pluginName: string, ): ValidationResult { const result: ValidationResult = { passed: true, errors: [], warnings: [] }; // Validate plugin.json const pluginJsonResult = validatePluginJson(pluginPath, pluginName); result.errors.push(...pluginJsonResult.errors); result.warnings.push(...pluginJsonResult.warnings); if (!pluginJsonResult.passed) result.passed = false; // Validate skills const skillsPath = join(pluginPath, "skills"); if (existsSync(skillsPath)) { const skills = readdirSync(skillsPath, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name); for (const skill of skills) { const skillResult = validateSkillFrontmatter( join(skillsPath, skill), pluginName, skill, ); result.errors.push(...skillResult.errors); result.warnings.push(...skillResult.warnings); if (!skillResult.passed) result.passed = false; } } // Check for README if (!existsSync(join(pluginPath, "README.md"))) { result.warnings.push(`${pluginName}: missing README.md`); } return result; } async function main(): Promise { console.log("\nšŸ” Validating Outfitter Marketplace\n"); let totalErrors = 0; let totalWarnings = 0; // Validate marketplace.json console.log("Checking marketplace.json..."); const marketplaceResult = validateMarketplaceJson(); for (const error of marketplaceResult.errors) log(error, "error"); for (const warning of marketplaceResult.warnings) log(warning, "warning"); totalErrors += marketplaceResult.errors.length; totalWarnings += marketplaceResult.warnings.length; if (!marketplaceResult.passed) { console.log("\nāŒ Marketplace validation failed. Fix errors above.\n"); process.exit(1); } // Load marketplace to get plugin list const marketplacePath = join(REPO_ROOT, ".claude-plugin/marketplace.json"); const marketplace: MarketplaceJson = JSON.parse( readFileSync(marketplacePath, "utf-8"), ); // Validate each plugin for (const plugin of marketplace.plugins) { // Prevent path traversal attacks const normalizedSource = plugin.source.replace(/^\.\//, ""); if ( normalizedSource.includes("..") || isAbsolute(normalizedSource) || normalizedSource.startsWith("/") ) { log(`Invalid plugin source path: ${plugin.source}`, "error"); totalErrors++; continue; } const pluginPath = join(REPO_ROOT, normalizedSource); // Additional check: ensure resolved path stays within repo if (!pluginPath.startsWith(REPO_ROOT)) { log(`Plugin path escapes repository: ${plugin.source}`, "error"); totalErrors++; continue; } console.log(`\nChecking ${plugin.name}...`); if (!existsSync(pluginPath)) { log(`Plugin directory not found: ${plugin.source}`, "error"); totalErrors++; continue; } const pluginResult = validatePlugin(pluginPath, plugin.name); for (const error of pluginResult.errors) log(error, "error"); for (const warning of pluginResult.warnings) log(warning, "warning"); totalErrors += pluginResult.errors.length; totalWarnings += pluginResult.warnings.length; if (pluginResult.passed && pluginResult.errors.length === 0) { log(`${plugin.name} passed`, "success"); } } // Summary console.log(`\n${"─".repeat(50)}`); if (totalErrors === 0) { console.log(`\nāœ… Validation passed with ${totalWarnings} warning(s)\n`); process.exit(0); } else { console.log( `\nāŒ Validation failed: ${totalErrors} error(s), ${totalWarnings} warning(s)\n`, ); process.exit(1); } } main();