mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-30 17:14:59 +08:00
97d32405d0
* 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 * 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 * 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. * 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. * 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. * feat(installer): universal source support for custom modules Replace GitHub-only custom module installation with support for any Git host (GitHub, GitLab, Bitbucket, self-hosted) and local file paths. - Add parseSource() universal input parser (local paths, SSH, HTTPS with deep path/subdir extraction for GitHub, GitLab, Gitea) - Add resolveSource() coordinator: parse -> clone if URL -> detect discovery vs direct mode (marketplace.json present or not) - Clone-first approach eliminates host-specific raw URL fetching - 3-level cache structure (host/owner/repo) with .bmad-source.json metadata for URL reconstruction - Local paths install directly without caching; localPath persisted in manifest for quick-update source lookup - Direct mode scans target directory for SKILL.md when no marketplace.json - Fix version display bug where walk-up found parent repo marketplace.json and reported wrong version for custom modules * fix(installer): harden readMarketplaceJsonFromDisk and hoist require - Add try/catch to readMarketplaceJsonFromDisk so malformed JSON returns null instead of throwing an unhandled parse error - Hoist CustomModuleManager require outside the per-module loop in _installOfficialModules * fix(installer): restore validateGitHubUrl strictness and fix prettier - Restore original GitHub-only regex in deprecated validateGitHubUrl wrapper so existing tests pass (rejects non-GitHub URLs, trailing slashes) - Run prettier to fix formatting in custom-module-manager.js * feat(installer): add --custom-source CLI flag for non-interactive installs Allows installing custom modules from Git URLs or local paths directly from the command line without interactive prompts: npx bmad-method install --custom-source /path/to/module npx bmad-method install --custom-source https://gitlab.com/org/repo npx bmad-method install --custom-source /path/one,https://host/org/repo Works alongside --modules and --yes flags. All discovered modules from each source are auto-selected. * docs: add custom and community module installation guide New how-to page covering community module browsing, custom sources (any Git host, local paths), discovery vs direct mode, local development workflow, and the --custom-source CLI flag. Clarifies that .claude-plugin/ is a cross-tool convention, not Claude-specific. Also updates non-interactive installation docs with the new flag and examples, bumps sidebar ordering, and fixes --custom-source to install only core + custom modules when --modules is not specified.
399 lines
14 KiB
JavaScript
399 lines
14 KiB
JavaScript
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<ResolvedModule[]>} 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,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.replaceAll('"', '""')}"`;
|
|
}
|
|
return value;
|
|
}
|
|
}
|
|
|
|
module.exports = { PluginResolver };
|