From d03ba50a6058dd624cfd91d99aefd6b6317944c0 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 9 Apr 2026 00:32:33 -0500 Subject: [PATCH 1/5] feat(installer): add plugin resolution strategies for custom URL installs When installing from a custom GitHub URL, the installer now analyzes marketplace.json plugin structures to determine how to locate module registration files (module.yaml, module-help.csv). Five strategies are tried in cascade: 1. Root module files at the common parent of listed skills 2. A -setup skill with registration files in its assets/ 3. Single standalone skill with registration files in assets/ 4. Multiple standalone skills, each with their own registration files 5. Fallback: synthesize registration from marketplace.json metadata and SKILL.md frontmatter Also changes the custom URL flow from confirm-all to multiselect, letting users pick which plugins to install. Already-installed modules are pre-checked for update; new modules are unchecked for opt-in. New file: tools/installer/modules/plugin-resolver.js Modified: custom-module-manager.js, official-modules.js, ui.js --- .../modules/custom-module-manager.js | 49 +++ tools/installer/modules/official-modules.js | 75 ++++ tools/installer/modules/plugin-resolver.js | 393 ++++++++++++++++++ tools/installer/ui.js | 124 ++++-- 4 files changed, 607 insertions(+), 34 deletions(-) create mode 100644 tools/installer/modules/plugin-resolver.js diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 18a631a29..5908ecc92 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -10,6 +10,9 @@ const { RegistryClient } = require('./registry-client'); * Validates URLs, fetches .claude-plugin/marketplace.json, clones repos. */ class CustomModuleManager { + /** @type {Map} Shared across all instances: module code -> ResolvedModule */ + static _resolutionCache = new Map(); + constructor() { this._client = new RegistryClient(); } @@ -177,6 +180,37 @@ class CustomModuleManager { return repoCacheDir; } + // ─── Plugin Resolution ──────────────────────────────────────────────────── + + /** + * Resolve a plugin to determine installation strategy and module registration files. + * Results are cached in _resolutionCache keyed by module code. + * @param {string} repoPath - Absolute path to the cloned repository + * @param {Object} plugin - Raw plugin object from marketplace.json + * @returns {Promise>} Array of ResolvedModule objects + */ + async resolvePlugin(repoPath, plugin) { + const { PluginResolver } = require('./plugin-resolver'); + const resolver = new PluginResolver(); + const resolved = await resolver.resolve(repoPath, plugin); + + // Cache each resolved module by its code for lookup during install + for (const mod of resolved) { + CustomModuleManager._resolutionCache.set(mod.code, mod); + } + + return resolved; + } + + /** + * Get a cached resolution result by module code. + * @param {string} moduleCode - Module code to look up + * @returns {Object|null} ResolvedModule or null if not cached + */ + getResolution(moduleCode) { + return CustomModuleManager._resolutionCache.get(moduleCode) || null; + } + // ─── Source Finding ─────────────────────────────────────────────────────── /** @@ -236,6 +270,19 @@ class CustomModuleManager { * @returns {string|null} Path to the module source or null */ async findModuleSourceByCode(moduleCode, options = {}) { + // Check resolution cache first (populated by resolvePlugin) + const resolved = CustomModuleManager._resolutionCache.get(moduleCode); + if (resolved) { + // For strategies 1-2: the common parent or setup skill's parent has the module files + if (resolved.moduleYamlPath) { + return path.dirname(resolved.moduleYamlPath); + } + // For strategy 5 (synthesized): return the first skill's parent as a reference path + if (resolved.skillPaths && resolved.skillPaths.length > 0) { + return path.dirname(resolved.skillPaths[0]); + } + } + const cacheDir = this.getCacheDir(); if (!(await fs.pathExists(cacheDir))) return null; @@ -297,6 +344,8 @@ class CustomModuleManager { author: plugin.author || data.owner || '', url: repoUrl, source: plugin.source || null, + skills: plugin.skills || [], + rawPlugin: plugin, type: 'custom', trustTier: 'unverified', builtIn: false, diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 6b9f76059..8da6e3d1c 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -135,6 +135,22 @@ class OfficialModules { const moduleConfigPath = path.join(modulePath, 'module.yaml'); if (!(await fs.pathExists(moduleConfigPath))) { + // Check resolution cache for strategy 5 modules (no module.yaml on disk) + const { CustomModuleManager } = require('./custom-module-manager'); + const customMgr = new CustomModuleManager(); + const resolved = customMgr.getResolution(defaultName); + if (resolved && resolved.synthesizedModuleYaml) { + return { + id: resolved.code, + path: modulePath, + name: resolved.name, + description: resolved.description, + version: resolved.version || '1.0.0', + source: sourceDescription, + dependencies: [], + defaultSelected: false, + }; + } return null; } @@ -232,6 +248,14 @@ class OfficialModules { * @param {Object} options.logger - Logger instance for output */ async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { + // Check if this module has a plugin resolution (custom marketplace install) + const { CustomModuleManager } = require('./custom-module-manager'); + const customMgr = new CustomModuleManager(); + const resolved = customMgr.getResolution(moduleName); + if (resolved) { + return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options); + } + const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent }); const targetPath = path.join(bmadDir, moduleName); @@ -265,6 +289,57 @@ class OfficialModules { return { success: true, module: moduleName, path: targetPath, versionInfo }; } + /** + * Install a module from a PluginResolver resolution result. + * Copies specific skill directories and places module-help.csv at the target root. + * @param {Object} resolved - ResolvedModule from PluginResolver + * @param {string} bmadDir - Target bmad directory + * @param {Function} fileTrackingCallback - Optional callback to track installed files + * @param {Object} options - Installation options + */ + async installFromResolution(resolved, bmadDir, fileTrackingCallback = null, options = {}) { + const targetPath = path.join(bmadDir, resolved.code); + + if (await fs.pathExists(targetPath)) { + await fs.remove(targetPath); + } + + await fs.ensureDir(targetPath); + + // Copy each skill directory, flattened by leaf name + for (const skillPath of resolved.skillPaths) { + const skillDirName = path.basename(skillPath); + const skillTarget = path.join(targetPath, skillDirName); + await this.copyModuleWithFiltering(skillPath, skillTarget, fileTrackingCallback, options.moduleConfig); + } + + // Place module-help.csv at the module root + if (resolved.moduleHelpCsvPath) { + // Strategies 1-4: copy the existing file + const helpTarget = path.join(targetPath, 'module-help.csv'); + await fs.copy(resolved.moduleHelpCsvPath, helpTarget, { overwrite: true }); + if (fileTrackingCallback) fileTrackingCallback(helpTarget); + } else if (resolved.synthesizedHelpCsv) { + // Strategy 5: write synthesized content + const helpTarget = path.join(targetPath, 'module-help.csv'); + await fs.writeFile(helpTarget, resolved.synthesizedHelpCsv, 'utf8'); + if (fileTrackingCallback) fileTrackingCallback(helpTarget); + } + + // Update manifest + const { Manifest } = require('../core/manifest'); + const manifestObj = new Manifest(); + + await manifestObj.addModule(bmadDir, resolved.code, { + version: resolved.version || '', + source: `custom:${resolved.pluginName}`, + npmPackage: '', + repoUrl: '', + }); + + return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } }; + } + /** * Update an existing module * @param {string} moduleName - Name of the module to update diff --git a/tools/installer/modules/plugin-resolver.js b/tools/installer/modules/plugin-resolver.js new file mode 100644 index 000000000..8606da21a --- /dev/null +++ b/tools/installer/modules/plugin-resolver.js @@ -0,0 +1,393 @@ +const fs = require('fs-extra'); +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 and filter out non-existent + const skillPaths = []; + for (const rel of skillRelPaths) { + const normalized = rel.replace(/^\.\//, ''); + const abs = path.join(repoPath, normalized); + 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,after,before,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.replace(/"/g, '""')}"`; + } + return value; + } +} + +module.exports = { PluginResolver }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index de8783666..b93cd02ce 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -848,48 +848,104 @@ class UI { const s = await prompts.spinner(); s.start('Fetching module info...'); + let plugins; try { - const plugins = await customMgr.discoverModules(url.trim()); + plugins = await customMgr.discoverModules(url.trim()); s.stop('Module info loaded'); - - await prompts.log.warn( - 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', - ); - - for (const plugin of plugins) { - const versionStr = plugin.version ? ` v${plugin.version}` : ''; - await prompts.log.info(` ${plugin.name}${versionStr}\n ${plugin.description}\n Author: ${plugin.author}`); - } - - const confirmInstall = await prompts.confirm({ - message: `Install ${plugins.length} plugin${plugins.length === 1 ? '' : 's'} from ${url.trim()}?`, - default: false, - }); - - if (confirmInstall) { - // Pre-clone the repo so it's cached for the install pipeline - s.start('Cloning repository...'); - try { - await customMgr.cloneRepo(url.trim()); - s.stop('Repository cloned'); - } catch (cloneError) { - s.error('Failed to clone repository'); - await prompts.log.error(` ${cloneError.message}`); - addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); - continue; - } - - for (const plugin of plugins) { - selectedModules.push(plugin.code); - } - } } catch (error) { s.error('Failed to load module info'); await prompts.log.error(` ${error.message}`); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + await prompts.log.warn( + 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', + ); + + // Clone the repo so we can resolve plugin structures + s.start('Cloning repository...'); + let repoPath; + try { + repoPath = await customMgr.cloneRepo(url.trim()); + s.stop('Repository cloned'); + } catch (cloneError) { + s.error('Failed to clone repository'); + await prompts.log.error(` ${cloneError.message}`); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + // Resolve each plugin to determine installable modules + s.start('Analyzing plugin structure...'); + const allResolved = []; + for (const plugin of plugins) { + try { + const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin); + if (resolved.length > 0) { + allResolved.push(...resolved); + } else { + // No skills array or empty - use plugin metadata as-is (legacy) + allResolved.push({ + code: plugin.code, + name: plugin.displayName || plugin.name, + version: plugin.version, + description: plugin.description, + strategy: 0, + pluginName: plugin.name, + skillPaths: [], + }); + } + } catch (resolveError) { + await prompts.log.warn(` Could not resolve ${plugin.name}: ${resolveError.message}`); + } + } + s.stop(`Found ${allResolved.length} installable module${allResolved.length === 1 ? '' : 's'}`); + + if (allResolved.length === 0) { + await prompts.log.warn('No installable modules found in this repository.'); + addMore = await prompts.confirm({ message: 'Try another URL?', default: false }); + continue; + } + + // Build multiselect choices + // Already-installed modules are pre-checked (update). New modules are unchecked (opt-in). + // Unchecking an installed module means "skip update" - removal is handled elsewhere. + const choices = allResolved.map((mod) => { + const versionStr = mod.version ? ` v${mod.version}` : ''; + const skillCount = mod.skillPaths ? mod.skillPaths.length : 0; + const skillStr = skillCount > 0 ? ` (${skillCount} skill${skillCount === 1 ? '' : 's'})` : ''; + const alreadyInstalled = installedModuleIds.has(mod.code); + const hint = alreadyInstalled ? 'update' : undefined; + + return { + name: `${mod.name}${versionStr}${skillStr}`, + value: mod.code, + hint, + checked: alreadyInstalled, + }; + }); + + // Show descriptions before the multiselect + for (const mod of allResolved) { + const versionStr = mod.version ? ` v${mod.version}` : ''; + await prompts.log.info(` ${mod.name}${versionStr}\n ${mod.description}`); + } + + const selected = await prompts.multiselect({ + message: 'Select modules to install:', + choices, + required: false, + }); + + if (selected && selected.length > 0) { + for (const code of selected) { + selectedModules.push(code); + } } addMore = await prompts.confirm({ - message: 'Add another custom module?', + message: 'Add another custom module URL?', default: false, }); } From 489067fddaf072f560e8d296bab2025ab544a0be Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 9 Apr 2026 08:44:17 -0500 Subject: [PATCH 2/5] fix(installer): address PR review findings for plugin resolver - Guard against path traversal in plugin-resolver.js: skill paths from unverified marketplace.json are now constrained to the repo root using path.resolve() + startsWith check - Skip npm install during browsing phase: cloneRepo() accepts skipInstall option, used in ui.js before user confirms selection, preventing arbitrary lifecycle script execution from untrusted repos - Add createModuleDirectories() call to installFromResolution() so modules with declarative directory config are fully set up - Fix ESLint: use replaceAll instead of replace with global regex --- tools/installer/modules/custom-module-manager.js | 5 +++-- tools/installer/modules/official-modules.js | 5 +++++ tools/installer/modules/plugin-resolver.js | 11 ++++++++--- tools/installer/ui.js | 4 ++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 5908ecc92..5e5766ee2 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -104,6 +104,7 @@ class CustomModuleManager { * @param {string} repoUrl - GitHub repository URL * @param {Object} [options] - Clone options * @param {boolean} [options.silent] - Suppress spinner output + * @param {boolean} [options.skipInstall] - Skip npm install (for browsing before user confirms) * @returns {string} Path to the cloned repository */ async cloneRepo(repoUrl, options = {}) { @@ -159,9 +160,9 @@ class CustomModuleManager { } } - // Install dependencies if package.json exists + // Install dependencies if package.json exists (skip during browsing/analysis) const packageJsonPath = path.join(repoCacheDir, 'package.json'); - if (await fs.pathExists(packageJsonPath)) { + if (!options.skipInstall && (await fs.pathExists(packageJsonPath))) { const installSpinner = await createSpinner(); installSpinner.start(`Installing dependencies for ${owner}/${repo}...`); try { diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 8da6e3d1c..a203dd85d 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -326,6 +326,11 @@ class OfficialModules { if (fileTrackingCallback) fileTrackingCallback(helpTarget); } + // Create directories declared in module.yaml (strategies 1-4 may have these) + if (!options.skipModuleInstaller) { + await this.createModuleDirectories(resolved.code, bmadDir, options); + } + // Update manifest const { Manifest } = require('../core/manifest'); const manifestObj = new Manifest(); diff --git a/tools/installer/modules/plugin-resolver.js b/tools/installer/modules/plugin-resolver.js index 8606da21a..9fbf325a2 100644 --- a/tools/installer/modules/plugin-resolver.js +++ b/tools/installer/modules/plugin-resolver.js @@ -33,11 +33,16 @@ class PluginResolver { return []; } - // Resolve skill paths to absolute and filter out non-existent + // 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.join(repoPath, normalized); + 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); } @@ -384,7 +389,7 @@ class PluginResolver { _escapeCSVField(value) { if (!value) return ''; if (value.includes(',') || value.includes('"') || value.includes('\n')) { - return `"${value.replace(/"/g, '""')}"`; + return `"${value.replaceAll('"', '""')}"`; } return value; } diff --git a/tools/installer/ui.js b/tools/installer/ui.js index b93cd02ce..75b704f64 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -863,11 +863,11 @@ class UI { 'UNVERIFIED MODULE: This module has not been reviewed by the BMad team.\n' + ' Only install modules from sources you trust.', ); - // Clone the repo so we can resolve plugin structures + // Clone the repo so we can resolve plugin structures (skip npm install until user confirms) s.start('Cloning repository...'); let repoPath; try { - repoPath = await customMgr.cloneRepo(url.trim()); + repoPath = await customMgr.cloneRepo(url.trim(), { skipInstall: true }); s.stop('Repository cloned'); } catch (cloneError) { s.error('Failed to clone repository'); From ffe84a9f173c71bbb3cbe24ba41061e17f83512d Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 9 Apr 2026 08:51:57 -0500 Subject: [PATCH 3/5] fix(installer): pass version and repoUrl to manifest for custom plugins installFromResolution was passing empty strings for version and repoUrl, which the manifest stores as null. Now threads the repo URL from ui.js through resolvePlugin into each ResolvedModule, and passes the plugin version and URL to the manifest correctly. --- tools/installer/modules/custom-module-manager.js | 6 ++++-- tools/installer/modules/official-modules.js | 8 ++++---- tools/installer/ui.js | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 5e5766ee2..84b250931 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -188,15 +188,17 @@ class CustomModuleManager { * Results are cached in _resolutionCache keyed by module code. * @param {string} repoPath - Absolute path to the cloned repository * @param {Object} plugin - Raw plugin object from marketplace.json + * @param {string} [repoUrl] - Original GitHub URL for manifest tracking * @returns {Promise>} Array of ResolvedModule objects */ - async resolvePlugin(repoPath, plugin) { + async resolvePlugin(repoPath, plugin, repoUrl) { const { PluginResolver } = require('./plugin-resolver'); const resolver = new PluginResolver(); const resolved = await resolver.resolve(repoPath, plugin); - // Cache each resolved module by its code for lookup during install + // Stamp repo URL onto each resolved module for manifest tracking for (const mod of resolved) { + if (repoUrl) mod.repoUrl = repoUrl; CustomModuleManager._resolutionCache.set(mod.code, mod); } diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index a203dd85d..2e18c1a15 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -336,10 +336,10 @@ class OfficialModules { const manifestObj = new Manifest(); await manifestObj.addModule(bmadDir, resolved.code, { - version: resolved.version || '', - source: `custom:${resolved.pluginName}`, - npmPackage: '', - repoUrl: '', + version: resolved.version || null, + source: 'custom', + npmPackage: null, + repoUrl: resolved.repoUrl || null, }); return { success: true, module: resolved.code, path: targetPath, versionInfo: { version: resolved.version || '' } }; diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 75b704f64..9908a462f 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -881,7 +881,7 @@ class UI { const allResolved = []; for (const plugin of plugins) { try { - const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin); + const resolved = await customMgr.resolvePlugin(repoPath, plugin.rawPlugin, url.trim()); if (resolved.length > 0) { allResolved.push(...resolved); } else { From a7f469690ada0f36613707eda90e1b302b18ee9a Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 9 Apr 2026 08:58:31 -0500 Subject: [PATCH 4/5] fix(installer): manifest-generator overwrites custom module version/repoUrl ManifestGenerator rebuilds the entire manifest via getModuleVersionInfo for every module. For custom modules, this returned null for version and repoUrl because it only checked _readMarketplaceVersion (which searches for marketplace.json on disk) and hardcoded repoUrl to null. Now checks the resolution cache first to get the correct version and repo URL. --- tools/installer/core/manifest.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index d810ec1d3..88190ccbf 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -835,14 +835,15 @@ class Manifest { // Check if this is a custom module (from user-provided URL) const { CustomModuleManager } = require('../modules/custom-module-manager'); const customMgr = new CustomModuleManager(); + const resolved = customMgr.getResolution(moduleName); const customSource = await customMgr.findModuleSourceByCode(moduleName); - if (customSource) { - const customVersion = await this._readMarketplaceVersion(moduleName, moduleSourcePath); + if (customSource || resolved) { + const customVersion = resolved?.version || (await this._readMarketplaceVersion(moduleName, moduleSourcePath)); return { version: customVersion, source: 'custom', npmPackage: null, - repoUrl: null, + repoUrl: resolved?.repoUrl || null, }; } From 7302f350b566f8d17393eb688edd7a591aa636ad Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Thu, 9 Apr 2026 09:01:28 -0500 Subject: [PATCH 5/5] fix(installer): resolve custom modules from disk cache on quick update When the resolution cache is empty (fresh CLI process, e.g. quick update), findModuleSourceByCode only matched plugin.name against the module code. This failed for modules like "sam" and "dw" where the code comes from module.yaml inside a setup/standalone skill, not from the plugin name in marketplace.json. Now runs the PluginResolver on cached repos when the direct name match fails, finding the correct module source and re-populating the cache for the install pipeline. --- .../modules/custom-module-manager.js | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/tools/installer/modules/custom-module-manager.js b/tools/installer/modules/custom-module-manager.js index 84b250931..a23505bf1 100644 --- a/tools/installer/modules/custom-module-manager.js +++ b/tools/installer/modules/custom-module-manager.js @@ -291,6 +291,8 @@ class CustomModuleManager { // Search through all custom repo caches try { + const { PluginResolver } = require('./plugin-resolver'); + const resolver = new PluginResolver(); const owners = await fs.readdir(cacheDir, { withFileTypes: true }); for (const ownerEntry of owners) { if (!ownerEntry.isDirectory()) continue; @@ -306,14 +308,37 @@ class CustomModuleManager { try { const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); for (const plugin of data.plugins || []) { + // Direct name match (legacy behavior) if (plugin.name === moduleCode) { - // Found the module - find its source const sourcePath = plugin.source ? path.join(repoPath, plugin.source) : repoPath; const moduleYaml = path.join(sourcePath, 'module.yaml'); if (await fs.pathExists(moduleYaml)) { return sourcePath; } } + + // Resolve plugin to check if any module.yaml code matches + if (plugin.skills && plugin.skills.length > 0) { + try { + const resolved = await resolver.resolve(repoPath, plugin); + for (const mod of resolved) { + if (mod.code === moduleCode) { + // Derive repo URL from cache path for manifest tracking + const repoUrl = `https://github.com/${ownerEntry.name}/${repoEntry.name}`; + mod.repoUrl = repoUrl; + CustomModuleManager._resolutionCache.set(mod.code, mod); + if (mod.moduleYamlPath) { + return path.dirname(mod.moduleYamlPath); + } + if (mod.skillPaths && mod.skillPaths.length > 0) { + return path.dirname(mod.skillPaths[0]); + } + } + } + } catch { + // Skip unresolvable plugins + } + } } } catch { // Skip malformed marketplace.json