refactor: simplify module discovery to scan entire project

- Module discovery now scans entire project recursively for install-config.yaml
- Removed hardcoded module locations (bmad-custom-src, etc.)
- Modules can exist anywhere with _module-installer/install-config.yaml
- All modules treated equally regardless of location
- No special UI handling for 'custom' modules
- Core module excluded from selection list (always installed first)
- Only install-config.yaml is valid (removed support for legacy config.yaml)

Modules are now discovered by structure, not location.
This commit is contained in:
Brian Madison
2025-12-06 15:28:37 -06:00
parent 7c5c97a914
commit 0d83799ecf
7 changed files with 243 additions and 1285 deletions

View File

@@ -182,14 +182,24 @@ class ConfigCollector {
}
// Load module's install config schema
const installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml');
const legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
// First, try the standard src/modules location
let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml');
// If not found in src/modules, we need to find it by searching the project
if (!(await fs.pathExists(installerConfigPath))) {
// Use the module manager to find the module source
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
if (moduleSourcePath) {
installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'install-config.yaml');
}
}
let configPath = null;
if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(legacyConfigPath)) {
configPath = legacyConfigPath;
} else {
// No config schema for this module - use existing values
if (this.existingConfig && this.existingConfig[moduleName]) {
@@ -396,32 +406,25 @@ class ConfigCollector {
if (!this.allAnswers) {
this.allAnswers = {};
}
// Load module's config.yaml (check custom modules first, then regular modules)
let installerConfigPath;
let legacyConfigPath;
// Load module's config
// First, try the standard src/modules location
let installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml');
if (moduleName.startsWith('custom-')) {
// Handle custom modules
const actualModuleName = moduleName.replace('custom-', '');
// If not found in src/modules, we need to find it by searching the project
if (!(await fs.pathExists(installerConfigPath))) {
// Use the module manager to find the module source
const { ModuleManager } = require('../modules/manager');
const moduleManager = new ModuleManager();
const moduleSourcePath = await moduleManager.findModuleSource(moduleName);
// Custom modules are in the BMAD-METHOD source directory, not the installation directory
const bmadMethodRoot = getProjectRoot(); // This gets the BMAD-METHOD root
const customSrcPath = path.join(bmadMethodRoot, 'bmad-custom-src', 'modules', actualModuleName);
installerConfigPath = path.join(customSrcPath, '_module-installer', 'install-config.yaml');
legacyConfigPath = path.join(customSrcPath, 'config.yaml');
console.log(chalk.dim(`[DEBUG] Looking for custom module config in: ${installerConfigPath}`));
} else {
// Regular modules
installerConfigPath = path.join(getModulePath(moduleName), '_module-installer', 'install-config.yaml');
legacyConfigPath = path.join(getModulePath(moduleName), 'config.yaml');
if (moduleSourcePath) {
installerConfigPath = path.join(moduleSourcePath, '_module-installer', 'install-config.yaml');
}
}
let configPath = null;
if (await fs.pathExists(installerConfigPath)) {
configPath = installerConfigPath;
} else if (await fs.pathExists(legacyConfigPath)) {
configPath = legacyConfigPath;
} else {
// No config for this module
return;

View File

@@ -418,7 +418,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const projectDir = path.resolve(config.directory);
// If core config was pre-collected (from interactive mode), use it
if (config.coreConfig && !this.configCollector.collectedConfig.core) {
if (config.coreConfig) {
this.configCollector.collectedConfig.core = config.coreConfig;
// Also store in allAnswers for cross-referencing
this.configCollector.allAnswers = {};
@@ -427,16 +427,11 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}
}
// Collect configurations for modules (skip if quick update already collected them or if pre-collected)
// Collect configurations for modules (skip if quick update already collected them)
let moduleConfigs;
if (config._quickUpdate) {
// Quick update already collected all configs, use them directly
moduleConfigs = this.configCollector.collectedConfig;
} else if (config.moduleConfig) {
// Use pre-collected configs from UI (includes custom modules)
moduleConfigs = config.moduleConfig;
// Also need to load them into configCollector for later use
this.configCollector.collectedConfig = moduleConfigs;
} else {
// Regular install - collect configurations (core was already collected in UI.promptInstall if interactive)
moduleConfigs = await this.configCollector.collectAllConfigurations(config.modules || [], path.resolve(config.directory));
@@ -753,14 +748,13 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
spinner.text = 'Creating directory structure...';
await this.createDirectoryStructure(bmadDir);
// Resolve dependencies for selected modules (skip custom modules)
// Resolve dependencies for selected modules
spinner.text = 'Resolving dependencies...';
const projectRoot = getProjectRoot();
const regularModules = (config.modules || []).filter((m) => !m.startsWith('custom-'));
const modulesToInstall = config.installCore ? ['core', ...regularModules] : regularModules;
const modulesToInstall = config.installCore ? ['core', ...config.modules] : config.modules;
// For dependency resolution, we need to pass the project root
const resolution = await this.dependencyResolver.resolve(projectRoot, regularModules, { verbose: config.verbose });
const resolution = await this.dependencyResolver.resolve(projectRoot, config.modules || [], { verbose: config.verbose });
if (config.verbose) {
spinner.succeed('Dependencies resolved');
@@ -775,17 +769,17 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
spinner.succeed('Core installed');
}
// Install modules with their dependencies (skip custom modules - they're handled by install.js)
if (regularModules.length > 0) {
for (const moduleName of regularModules) {
// Install modules with their dependencies
if (config.modules && config.modules.length > 0) {
for (const moduleName of config.modules) {
spinner.start(`Installing module: ${moduleName}...`);
await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
spinner.succeed(`Module installed: ${moduleName}`);
}
// Install partial modules (only dependencies) - skip custom modules
// Install partial modules (only dependencies)
for (const [module, files] of Object.entries(resolution.byModule)) {
if (!regularModules.includes(module) && module !== 'core') {
if (!config.modules.includes(module) && module !== 'core') {
const totalFiles =
files.agents.length +
files.tasks.length +

View File

@@ -24,51 +24,6 @@ async function getAgentsFromBmad(bmadDir, selectedModules = []) {
}
}
// Get custom module agents (from bmad/custom/modules/*/agents/)
const customModulesDir = path.join(bmadDir, 'custom', 'modules');
if (await fs.pathExists(customModulesDir)) {
const moduleDirs = await fs.readdir(customModulesDir, { withFileTypes: true });
for (const moduleDir of moduleDirs) {
if (!moduleDir.isDirectory()) continue;
const moduleAgentsPath = path.join(customModulesDir, moduleDir.name, 'agents');
if (await fs.pathExists(moduleAgentsPath)) {
const moduleAgents = await getAgentsFromDir(moduleAgentsPath, moduleDir.name);
agents.push(...moduleAgents);
}
}
}
// Get custom agents from bmad/custom/agents/ directory
const customAgentsDir = path.join(bmadDir, 'custom', 'agents');
if (await fs.pathExists(customAgentsDir)) {
const agentDirs = await fs.readdir(customAgentsDir, { withFileTypes: true });
for (const agentDir of agentDirs) {
if (!agentDir.isDirectory()) continue;
const agentDirPath = path.join(customAgentsDir, agentDir.name);
const agentFiles = await fs.readdir(agentDirPath);
for (const file of agentFiles) {
if (!file.endsWith('.md')) continue;
if (file.includes('.customize.')) continue;
const filePath = path.join(agentDirPath, file);
const content = await fs.readFile(filePath, 'utf8');
if (content.includes('localskip="true"')) continue;
agents.push({
path: filePath,
name: file.replace('.md', ''),
module: 'custom', // Mark as custom agent
});
}
}
}
// Get standalone agents from bmad/agents/ directory
const standaloneAgentsDir = path.join(bmadDir, 'agents');
if (await fs.pathExists(standaloneAgentsDir)) {

View File

@@ -98,57 +98,110 @@ class ModuleManager {
}
/**
* List all available modules
* Find all modules in the project by searching for install-config.yaml files
* @returns {Array} List of module paths
*/
async findModulesInProject() {
const projectRoot = getProjectRoot();
const modulePaths = new Set();
// Helper function to recursively scan directories
async function scanDirectory(dir, excludePaths = []) {
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
// Skip hidden directories and node_modules
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'build') {
continue;
}
// Skip excluded paths
if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) {
continue;
}
if (entry.isDirectory()) {
// Skip core module - it's always installed first and not selectable
if (entry.name === 'core') {
continue;
}
// Check if this directory contains a module (only install-config.yaml is valid now)
const installerConfigPath = path.join(fullPath, '_module-installer', 'install-config.yaml');
if (await fs.pathExists(installerConfigPath)) {
modulePaths.add(fullPath);
// Don't scan inside modules - they might have their own nested structures
continue;
}
// Recursively scan subdirectories
await scanDirectory(fullPath, excludePaths);
}
}
} catch {
// Ignore errors (e.g., permission denied)
}
}
// Scan the entire project, but exclude src/modules since we handle it separately
await scanDirectory(projectRoot, [this.modulesSourcePath]);
return [...modulePaths];
}
/**
* List all available modules (excluding core which is always installed)
* @returns {Array} List of available modules with metadata
*/
async listAvailable() {
const modules = [];
if (!(await fs.pathExists(this.modulesSourcePath))) {
console.warn(chalk.yellow('Warning: src/modules directory not found'));
return modules;
}
// First, scan src/modules (the standard location)
if (await fs.pathExists(this.modulesSourcePath)) {
const entries = await fs.readdir(this.modulesSourcePath, { withFileTypes: true });
const entries = await fs.readdir(this.modulesSourcePath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const modulePath = path.join(this.modulesSourcePath, entry.name);
// Check for module structure (only install-config.yaml is valid now)
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml');
for (const entry of entries) {
if (entry.isDirectory()) {
const modulePath = path.join(this.modulesSourcePath, entry.name);
// Check for new structure first
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml');
// Fallback to old structure
const configPath = path.join(modulePath, 'config.yaml');
// Skip if this doesn't look like a module
if (!(await fs.pathExists(installerConfigPath))) {
continue;
}
const moduleInfo = {
id: entry.name,
path: modulePath,
name: entry.name.toUpperCase(),
description: 'BMAD Module',
version: '5.0.0',
};
// Skip core module - it's always installed first and not selectable
if (entry.name === 'core') {
continue;
}
// Try to read module config for metadata (prefer new location)
const configToRead = (await fs.pathExists(installerConfigPath)) ? installerConfigPath : configPath;
if (await fs.pathExists(configToRead)) {
try {
const configContent = await fs.readFile(configToRead, 'utf8');
const config = yaml.load(configContent);
// Use the code property as the id if available
if (config.code) {
moduleInfo.id = config.code;
}
moduleInfo.name = config.name || moduleInfo.name;
moduleInfo.description = config.description || moduleInfo.description;
moduleInfo.version = config.version || moduleInfo.version;
moduleInfo.dependencies = config.dependencies || [];
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
} catch (error) {
console.warn(`Failed to read config for ${entry.name}:`, error.message);
const moduleInfo = await this.getModuleInfo(modulePath, entry.name, 'src/modules');
if (moduleInfo) {
modules.push(moduleInfo);
}
}
}
}
// Then, find all other modules in the project
const otherModulePaths = await this.findModulesInProject();
for (const modulePath of otherModulePaths) {
const moduleName = path.basename(modulePath);
const relativePath = path.relative(getProjectRoot(), modulePath);
// Skip core module - it's always installed first and not selectable
if (moduleName === 'core') {
continue;
}
const moduleInfo = await this.getModuleInfo(modulePath, moduleName, relativePath);
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id)) {
// Avoid duplicates - skip if we already have this module ID
modules.push(moduleInfo);
}
}
@@ -156,6 +209,104 @@ class ModuleManager {
return modules;
}
/**
* Get module information from a module path
* @param {string} modulePath - Path to the module directory
* @param {string} defaultName - Default name for the module
* @param {string} sourceDescription - Description of where the module was found
* @returns {Object|null} Module info or null if not a valid module
*/
async getModuleInfo(modulePath, defaultName, sourceDescription) {
// Check for module structure (only install-config.yaml is valid now)
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml');
// Skip if this doesn't look like a module
if (!(await fs.pathExists(installerConfigPath))) {
return null;
}
const moduleInfo = {
id: defaultName,
path: modulePath,
name: defaultName
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' '),
description: 'BMAD Module',
version: '5.0.0',
source: sourceDescription,
};
// Read module config for metadata
try {
const configContent = await fs.readFile(installerConfigPath, 'utf8');
const config = yaml.load(configContent);
// Use the code property as the id if available
if (config.code) {
moduleInfo.id = config.code;
}
moduleInfo.name = config.name || moduleInfo.name;
moduleInfo.description = config.description || moduleInfo.description;
moduleInfo.version = config.version || moduleInfo.version;
moduleInfo.dependencies = config.dependencies || [];
moduleInfo.defaultSelected = config.default_selected === undefined ? false : config.default_selected;
} catch (error) {
console.warn(`Failed to read config for ${defaultName}:`, error.message);
}
return moduleInfo;
}
/**
* Find the source path for a module by searching all possible locations
* @param {string} moduleName - Name of the module to find
* @returns {string|null} Path to the module source or null if not found
*/
async findModuleSource(moduleName) {
const projectRoot = getProjectRoot();
// First, check src/modules
const srcModulePath = path.join(this.modulesSourcePath, moduleName);
if (await fs.pathExists(srcModulePath)) {
// Check if this looks like a module (has install-config.yaml)
const installerConfigPath = path.join(srcModulePath, '_module-installer', 'install-config.yaml');
if (await fs.pathExists(installerConfigPath)) {
return srcModulePath;
}
}
// If not found in src/modules, search the entire project
const allModulePaths = await this.findModulesInProject();
for (const modulePath of allModulePaths) {
if (path.basename(modulePath) === moduleName) {
return modulePath;
}
}
// Also check by module ID (not just folder name)
// Need to read configs to match by ID
for (const modulePath of allModulePaths) {
const installerConfigPath = path.join(modulePath, '_module-installer', 'install-config.yaml');
if (await fs.pathExists(installerConfigPath)) {
try {
const configContent = await fs.readFile(installerConfigPath, 'utf8');
const config = yaml.load(configContent);
if (config.code === moduleName) {
return modulePath;
}
} catch {
// Skip if can't read config
}
}
}
return null;
}
/**
* Install a module
* @param {string} moduleName - Name of the module to install
@@ -167,12 +318,12 @@ class ModuleManager {
* @param {Object} options.logger - Logger instance for output
*/
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
const sourcePath = path.join(this.modulesSourcePath, moduleName);
const sourcePath = await this.findModuleSource(moduleName);
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Module '${moduleName}' not found in ${this.modulesSourcePath}`);
if (!sourcePath) {
throw new Error(`Module '${moduleName}' not found in any source location`);
}
// Check if already installed
@@ -210,12 +361,12 @@ class ModuleManager {
* @param {boolean} force - Force update (overwrite modifications)
*/
async update(moduleName, bmadDir, force = false) {
const sourcePath = path.join(this.modulesSourcePath, moduleName);
const sourcePath = await this.findModuleSource(moduleName);
const targetPath = path.join(bmadDir, moduleName);
// Check if source module exists
if (!(await fs.pathExists(sourcePath))) {
throw new Error(`Module '${moduleName}' not found in source`);
if (!sourcePath) {
throw new Error(`Module '${moduleName}' not found in any source location`);
}
// Check if module is installed
@@ -654,7 +805,11 @@ class ModuleManager {
if (moduleName === 'core') {
sourcePath = getSourcePath('core');
} else {
sourcePath = path.join(this.modulesSourcePath, moduleName);
sourcePath = await this.findModuleSource(moduleName);
if (!sourcePath) {
// No source found, skip module installer
return;
}
}
const installerPath = path.join(sourcePath, '_module-installer', 'installer.js');