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
This commit is contained in:
parent
3ba51e1bac
commit
d03ba50a60
|
|
@ -10,6 +10,9 @@ const { RegistryClient } = require('./registry-client');
|
|||
* Validates URLs, fetches .claude-plugin/marketplace.json, clones repos.
|
||||
*/
|
||||
class CustomModuleManager {
|
||||
/** @type {Map<string, Object>} 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<Object>>} 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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 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 };
|
||||
|
|
@ -848,29 +848,26 @@ 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');
|
||||
} 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.',
|
||||
);
|
||||
|
||||
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
|
||||
// Clone the repo so we can resolve plugin structures
|
||||
s.start('Cloning repository...');
|
||||
let repoPath;
|
||||
try {
|
||||
await customMgr.cloneRepo(url.trim());
|
||||
repoPath = await customMgr.cloneRepo(url.trim());
|
||||
s.stop('Repository cloned');
|
||||
} catch (cloneError) {
|
||||
s.error('Failed to clone repository');
|
||||
|
|
@ -879,17 +876,76 @@ class UI {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Resolve each plugin to determine installable modules
|
||||
s.start('Analyzing plugin structure...');
|
||||
const allResolved = [];
|
||||
for (const plugin of plugins) {
|
||||
selectedModules.push(plugin.code);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
s.error('Failed to load module info');
|
||||
await prompts.log.error(` ${error.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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue