const fs = require('../fs-native'); const path = require('node:path'); const yaml = require('yaml'); /** * Resolves how to install a plugin from marketplace.json by analyzing * where module.yaml and module-help.csv live relative to the listed skills. * * Five strategies, tried in order: * 1. Root module files at the common parent of all skills * 2. A -setup skill with assets/module.yaml + assets/module-help.csv * 3. Single standalone skill with both files in its assets/ * 4. Multiple standalone skills, each with both files in assets/ * 5. Fallback: synthesize from marketplace.json + SKILL.md frontmatter */ class PluginResolver { /** * Resolve a plugin to one or more installable module definitions. * @param {string} repoPath - Absolute path to the cloned repository root * @param {Object} plugin - Plugin object from marketplace.json * @param {string} plugin.name - Plugin identifier * @param {string} [plugin.source] - Relative path from repo root * @param {string} [plugin.version] - Semantic version * @param {string} [plugin.description] - Plugin description * @param {string[]} [plugin.skills] - Relative paths to skill directories * @returns {Promise} Array of resolved module definitions */ async resolve(repoPath, plugin) { const skillRelPaths = plugin.skills || []; // No skills array: legacy behavior - caller should use existing findModuleSource if (skillRelPaths.length === 0) { return []; } // Resolve skill paths to absolute, constrain to repo root, filter non-existent const repoRoot = path.resolve(repoPath); const skillPaths = []; for (const rel of skillRelPaths) { const normalized = rel.replace(/^\.\//, ''); const abs = path.resolve(repoPath, normalized); // Guard against path traversal (.. segments, absolute paths in marketplace.json) if (!abs.startsWith(repoRoot + path.sep) && abs !== repoRoot) { continue; } if (await fs.pathExists(abs)) { skillPaths.push(abs); } } if (skillPaths.length === 0) { return []; } // Try each strategy in order const result = (await this._tryRootModuleFiles(repoPath, plugin, skillPaths)) || (await this._trySetupSkill(repoPath, plugin, skillPaths)) || (await this._trySingleStandalone(repoPath, plugin, skillPaths)) || (await this._tryMultipleStandalone(repoPath, plugin, skillPaths)) || (await this._synthesizeFallback(repoPath, plugin, skillPaths)); return result; } // ─── Strategy 1: Root Module Files ────────────────────────────────────────── /** * Check if module.yaml + module-help.csv exist at the common parent of all skills. */ async _tryRootModuleFiles(repoPath, plugin, skillPaths) { const commonParent = this._computeCommonParent(skillPaths); const moduleYamlPath = path.join(commonParent, 'module.yaml'); const moduleHelpPath = path.join(commonParent, 'module-help.csv'); if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { return null; } const moduleData = await this._readModuleYaml(moduleYamlPath); if (!moduleData) return null; return [ { code: moduleData.code || plugin.name, name: moduleData.name || plugin.name, version: plugin.version || moduleData.module_version || null, description: moduleData.description || plugin.description || '', strategy: 1, pluginName: plugin.name, moduleYamlPath, moduleHelpCsvPath: moduleHelpPath, skillPaths, synthesizedModuleYaml: null, synthesizedHelpCsv: null, }, ]; } // ─── Strategy 2: Setup Skill ──────────────────────────────────────────────── /** * Search for a skill ending in -setup with assets/module.yaml + assets/module-help.csv. */ async _trySetupSkill(repoPath, plugin, skillPaths) { for (const skillPath of skillPaths) { const dirName = path.basename(skillPath); if (!dirName.endsWith('-setup')) continue; const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml'); const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv'); if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { continue; } const moduleData = await this._readModuleYaml(moduleYamlPath); if (!moduleData) continue; return [ { code: moduleData.code || plugin.name, name: moduleData.name || plugin.name, version: plugin.version || moduleData.module_version || null, description: moduleData.description || plugin.description || '', strategy: 2, pluginName: plugin.name, moduleYamlPath, moduleHelpCsvPath: moduleHelpPath, skillPaths, synthesizedModuleYaml: null, synthesizedHelpCsv: null, }, ]; } return null; } // ─── Strategy 3: Single Standalone Skill ──────────────────────────────────── /** * One skill listed, with assets/module.yaml + assets/module-help.csv. */ async _trySingleStandalone(repoPath, plugin, skillPaths) { if (skillPaths.length !== 1) return null; const skillPath = skillPaths[0]; const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml'); const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv'); if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { return null; } const moduleData = await this._readModuleYaml(moduleYamlPath); if (!moduleData) return null; return [ { code: moduleData.code || plugin.name, name: moduleData.name || plugin.name, version: plugin.version || moduleData.module_version || null, description: moduleData.description || plugin.description || '', strategy: 3, pluginName: plugin.name, moduleYamlPath, moduleHelpCsvPath: moduleHelpPath, skillPaths, synthesizedModuleYaml: null, synthesizedHelpCsv: null, }, ]; } // ─── Strategy 4: Multiple Standalone Skills ───────────────────────────────── /** * Multiple skills, each with assets/module.yaml + assets/module-help.csv. * Each becomes its own installable module. */ async _tryMultipleStandalone(repoPath, plugin, skillPaths) { if (skillPaths.length < 2) return null; const resolved = []; for (const skillPath of skillPaths) { const moduleYamlPath = path.join(skillPath, 'assets', 'module.yaml'); const moduleHelpPath = path.join(skillPath, 'assets', 'module-help.csv'); if (!(await fs.pathExists(moduleYamlPath)) || !(await fs.pathExists(moduleHelpPath))) { continue; } const moduleData = await this._readModuleYaml(moduleYamlPath); if (!moduleData) continue; resolved.push({ code: moduleData.code || path.basename(skillPath), name: moduleData.name || path.basename(skillPath), version: plugin.version || moduleData.module_version || null, description: moduleData.description || '', strategy: 4, pluginName: plugin.name, moduleYamlPath, moduleHelpCsvPath: moduleHelpPath, skillPaths: [skillPath], synthesizedModuleYaml: null, synthesizedHelpCsv: null, }); } // Only use strategy 4 if ALL skills have module files if (resolved.length === skillPaths.length) { return resolved; } // Partial match: fall through to strategy 5 return null; } // ─── Strategy 5: Fallback (Synthesized) ───────────────────────────────────── /** * No module files found anywhere. Synthesize from marketplace.json metadata * and SKILL.md frontmatter. */ async _synthesizeFallback(repoPath, plugin, skillPaths) { const skillInfos = []; for (const skillPath of skillPaths) { const frontmatter = await this._parseSkillFrontmatter(skillPath); skillInfos.push({ dirName: path.basename(skillPath), name: frontmatter.name || path.basename(skillPath), description: frontmatter.description || '', }); } const moduleName = this._formatDisplayName(plugin.name); const code = plugin.name; const synthesizedYaml = { code, name: moduleName, description: plugin.description || '', module_version: plugin.version || '1.0.0', default_selected: false, }; const synthesizedCsv = this._buildSynthesizedHelpCsv(moduleName, skillInfos); return [ { code, name: moduleName, version: plugin.version || null, description: plugin.description || '', strategy: 5, pluginName: plugin.name, moduleYamlPath: null, moduleHelpCsvPath: null, skillPaths, synthesizedModuleYaml: synthesizedYaml, synthesizedHelpCsv: synthesizedCsv, }, ]; } // ─── Helpers ──────────────────────────────────────────────────────────────── /** * Compute the deepest common ancestor directory of an array of absolute paths. * @param {string[]} absPaths - Absolute directory paths * @returns {string} Common parent directory */ _computeCommonParent(absPaths) { if (absPaths.length === 0) return '/'; if (absPaths.length === 1) return path.dirname(absPaths[0]); const segments = absPaths.map((p) => p.split(path.sep)); const minLen = Math.min(...segments.map((s) => s.length)); const common = []; for (let i = 0; i < minLen; i++) { const segment = segments[0][i]; if (segments.every((s) => s[i] === segment)) { common.push(segment); } else { break; } } return common.join(path.sep) || '/'; } /** * Read and parse a module.yaml file. * @param {string} yamlPath - Absolute path to module.yaml * @returns {Object|null} Parsed content or null on failure */ async _readModuleYaml(yamlPath) { try { const content = await fs.readFile(yamlPath, 'utf8'); return yaml.parse(content); } catch { return null; } } /** * Extract name and description from a SKILL.md YAML frontmatter block. * @param {string} skillDirPath - Absolute path to the skill directory * @returns {Object} { name, description } or empty strings */ async _parseSkillFrontmatter(skillDirPath) { const skillMdPath = path.join(skillDirPath, 'SKILL.md'); try { const content = await fs.readFile(skillMdPath, 'utf8'); const match = content.match(/^---\s*\n([\s\S]*?)\n---/); if (!match) return { name: '', description: '' }; const parsed = yaml.parse(match[1]); return { name: parsed.name || '', description: parsed.description || '', }; } catch { return { name: '', description: '' }; } } /** * Build a synthesized module-help.csv from plugin metadata and skill frontmatter. * Uses the standard 13-column format. * @param {string} moduleName - Display name for the module column * @param {Array<{dirName: string, name: string, description: string}>} skillInfos * @returns {string} CSV content */ _buildSynthesizedHelpCsv(moduleName, skillInfos) { const header = 'module,skill,display-name,menu-code,description,action,args,phase,preceded-by,followed-by,required,output-location,outputs'; const rows = [header]; for (const info of skillInfos) { const displayName = this._formatDisplayName(info.name || info.dirName); const menuCode = this._generateMenuCode(info.name || info.dirName); const description = this._escapeCSVField(info.description); rows.push(`${moduleName},${info.dirName},${displayName},${menuCode},${description},activate,,anytime,,,false,,`); } return rows.join('\n') + '\n'; } /** * Format a kebab-case or snake_case name into a display name. * Strips common prefixes like "bmad-" or "bmad-agent-". * @param {string} name - Raw name * @returns {string} Formatted display name */ _formatDisplayName(name) { let cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, ''); return cleaned .split(/[-_]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } /** * Generate a short menu code from a skill name. * Takes first letter of each significant word, uppercased, max 3 chars. * @param {string} name - Skill name (kebab-case) * @returns {string} Menu code (e.g., "CC" for "code-coach") */ _generateMenuCode(name) { const cleaned = name.replace(/^bmad-agent-/, '').replace(/^bmad-/, ''); const words = cleaned.split(/[-_]/).filter((w) => w.length > 0); return words .map((w) => w.charAt(0).toUpperCase()) .join('') .slice(0, 3); } /** * Escape a value for CSV output (wrap in quotes if it contains commas, quotes, or newlines). * @param {string} value * @returns {string} */ _escapeCSVField(value) { if (!value) return ''; if (value.includes(',') || value.includes('"') || value.includes('\n')) { return `"${value.replaceAll('"', '""')}"`; } return value; } } module.exports = { PluginResolver };