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

2
.gitignore vendored
View File

@ -62,7 +62,7 @@ src/modules/bmm/sub-modules/
src/modules/bmb/sub-modules/
src/modules/cis/sub-modules/
src/modules/bmgd/sub-modules/
shared-modules
z*/
.bmad

View File

@ -1,513 +1,11 @@
const chalk = require('chalk');
const path = require('node:path');
const fs = require('fs-extra');
const { Installer } = require('../installers/lib/core/installer');
const { UI } = require('../lib/ui');
const installer = new Installer();
const ui = new UI();
/**
* Install custom content (agents, workflows, modules)
* @param {Object} config - Installation configuration
* @param {Object} result - Installation result
* @param {string} projectDirectory - Project directory path
*/
async function installCustomContent(config, result, projectDirectory) {
const { customContent } = config;
const { selectedItems } = customContent;
const projectDir = projectDirectory;
const bmadDir = result.path;
console.log(chalk.dim(`Project: ${projectDir}`));
console.log(chalk.dim(`BMAD: ${bmadDir}`));
// Install custom agents - use agent-install logic
if (selectedItems.agents.length > 0) {
console.log(chalk.blue(`\n👥 Installing ${selectedItems.agents.length} custom agent(s)...`));
for (const agent of selectedItems.agents) {
await installCustomAgentWithPrompts(agent, projectDir, bmadDir, config);
}
}
// Install custom workflows - copy and register with IDEs
if (selectedItems.workflows.length > 0) {
console.log(chalk.blue(`\n📋 Installing ${selectedItems.workflows.length} custom workflow(s)...`));
for (const workflow of selectedItems.workflows) {
await installCustomWorkflowWithIDE(workflow, projectDir, bmadDir, config);
}
}
// Install custom modules - treat like regular modules
if (selectedItems.modules.length > 0) {
console.log(chalk.blue(`\n🔧 Installing ${selectedItems.modules.length} custom module(s)...`));
for (const module of selectedItems.modules) {
await installCustomModuleAsRegular(module, projectDir, bmadDir, config);
}
}
console.log(chalk.green('\n✓ Custom content installation complete!'));
}
/**
* Install a custom agent with proper prompts (mirrors agent-install.js)
*/
async function installCustomAgentWithPrompts(agent, projectDir, bmadDir, config) {
const {
discoverAgents,
loadAgentConfig,
addToManifest,
extractManifestData,
promptInstallQuestions,
createIdeSlashCommands,
updateManifestYaml,
saveAgentSource,
} = require('../lib/agent/installer');
const { compileAgent } = require('../lib/agent/compiler');
const inquirer = require('inquirer');
const readline = require('node:readline');
const yaml = require('js-yaml');
console.log(chalk.cyan(` Installing agent: ${agent.name}`));
// Load agent config
const agentConfig = loadAgentConfig(agent.yamlPath);
const agentType = agent.name; // e.g., "toolsmith"
// Confirm/customize agent persona name (mirrors agent-install.js)
const rl1 = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const defaultPersonaName = agentConfig.metadata.name || agentType;
console.log(chalk.cyan(`\n 📛 Agent Persona Name`));
console.log(chalk.dim(` Agent type: ${agentType}`));
console.log(chalk.dim(` Default persona: ${defaultPersonaName}`));
console.log(chalk.dim(' Leave blank to use default, or provide a custom name.'));
console.log(chalk.dim(' Examples:'));
console.log(chalk.dim(` - (blank) → "${defaultPersonaName}" as ${agentType}.md`));
console.log(chalk.dim(` - "Fred" → "Fred" as fred-${agentType}.md`));
console.log(chalk.dim(` - "Captain Code" → "Captain Code" as captain-code-${agentType}.md`));
const customPersonaName = await new Promise((resolve) => {
rl1.question(`\n Custom name (or Enter for default): `, resolve);
});
rl1.close();
// Determine final agent file name based on persona name
let finalAgentName;
let personaName;
if (customPersonaName.trim()) {
personaName = customPersonaName.trim();
const namePrefix = personaName.toLowerCase().replaceAll(/\s+/g, '-');
finalAgentName = `${namePrefix}-${agentType}`;
} else {
personaName = defaultPersonaName;
finalAgentName = agentType;
}
console.log(chalk.dim(` Persona: ${personaName}`));
console.log(chalk.dim(` File: ${finalAgentName}.md`));
// Get answers (prompt or use defaults)
let presetAnswers = {};
// If custom persona name provided, inject it as custom_name for template processing
if (customPersonaName.trim()) {
presetAnswers.custom_name = personaName;
}
let answers;
if (agentConfig.installConfig) {
answers = await promptInstallQuestions(agentConfig.installConfig, agentConfig.defaults, presetAnswers);
} else {
answers = { ...agentConfig.defaults, ...presetAnswers };
}
// Create target directory
const targetDir = path.join(bmadDir, 'custom', 'agents', finalAgentName);
await fs.ensureDir(targetDir);
// Compile agent with answers
const { xml, metadata } = compileAgent(
agentConfig.yamlContent,
answers,
finalAgentName,
`.bmad/custom/agents/${finalAgentName}/${finalAgentName}.md`,
);
// Write compiled agent
const compiledPath = path.join(targetDir, `${finalAgentName}.md`);
await fs.writeFile(compiledPath, xml, 'utf8');
// Copy sidecar files if exists
if (agent.hasSidecar) {
const entries = await fs.readdir(agent.path, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && !entry.name.endsWith('.agent.yaml')) {
await fs.copy(path.join(agent.path, entry.name), path.join(targetDir, entry.name));
}
}
}
// Save source YAML for reinstallation
const cfgAgentsBackupDir = path.join(bmadDir, '_cfg', 'custom', 'agents');
await fs.ensureDir(cfgAgentsBackupDir);
const backupYamlPath = path.join(cfgAgentsBackupDir, `${finalAgentName}.agent.yaml`);
await fs.copy(agent.yamlPath, backupYamlPath);
// Add to agent manifest
const manifestFile = path.join(bmadDir, '_cfg', 'agent-manifest.csv');
const relativePath = `.bmad/custom/agents/${finalAgentName}/${finalAgentName}.md`;
const manifestData = extractManifestData(xml, { ...metadata, name: finalAgentName }, relativePath, 'custom');
manifestData.name = finalAgentName;
manifestData.displayName = metadata.name || finalAgentName;
addToManifest(manifestFile, manifestData);
// Update manifest.yaml
const manifestYamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
updateManifestYaml(manifestYamlPath, finalAgentName, finalAgentName);
// Create IDE slash commands using existing IDEs from config
const ideResults = await createIdeSlashCommands(projectDir, finalAgentName, relativePath, metadata, config.ides || []);
const ideCount = Object.keys(ideResults).length;
console.log(chalk.green(`${finalAgentName} (registered with ${ideCount} IDE${ideCount === 1 ? '' : 's'})`));
}
/**
* Install a custom workflow and register with all IDEs
*/
async function installCustomWorkflowWithIDE(workflow, projectDir, bmadDir, config) {
const targetDir = path.join(bmadDir, 'custom', 'workflows');
// Check if workflow is a directory or just a file
// workflow.path might be a file (workflow.md) or a directory
let sourcePath = workflow.path;
let isDirectory = false;
try {
const stats = await fs.stat(workflow.path);
isDirectory = stats.isDirectory();
} catch {
console.log(chalk.red(` ERROR: Cannot access workflow path: ${workflow.path}`));
return;
}
// If it's a file ending in workflow.md, use the parent directory
if (!isDirectory && workflow.path.endsWith('workflow.md')) {
sourcePath = path.dirname(workflow.path);
isDirectory = true;
}
if (isDirectory) {
// Copy the entire workflow directory
const workflowName = path.basename(sourcePath);
const targetWorkflowDir = path.join(targetDir, workflowName);
await fs.copy(sourcePath, targetWorkflowDir);
// Update manifest with the main workflow.md file
const relativePath = `.bmad/custom/workflows/${workflowName}/workflow.md`;
await addWorkflowToManifest(bmadDir, workflow.name, workflow.description, relativePath, 'custom');
} else {
// Single file workflow
const targetFileName = path.basename(sourcePath);
const targetPath = path.join(targetDir, targetFileName);
await fs.copy(sourcePath, targetPath);
// Update manifest
const relativePath = `.bmad/custom/workflows/${targetFileName}`;
await addWorkflowToManifest(bmadDir, workflow.name, workflow.description, relativePath, 'custom');
}
// Register workflow with all configured IDEs
const relativePath = `.bmad/custom/workflows/${path.basename(workflow.path)}`;
if (config.ides && config.ides.length > 0) {
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
for (const ide of config.ides) {
try {
// IdeManager uses a Map, not getHandler method
const ideHandler = ideManager.handlers.get(ide.toLowerCase());
if (ideHandler && typeof ideHandler.registerWorkflow === 'function') {
await ideHandler.registerWorkflow(projectDir, bmadDir, workflow.name, relativePath);
console.log(chalk.dim(` ✓ Registered with ${ide}`));
}
} catch (error) {
console.log(chalk.yellow(` ⚠️ Could not register with ${ide}: ${error.message}`));
}
}
}
console.log(chalk.green(`${workflow.name} (copied to custom workflows and registered with IDEs)`));
}
/**
* Helper to add workflow to manifest
*/
async function addWorkflowToManifest(bmadDir, name, description, relativePath, moduleType = 'custom') {
const workflowManifestPath = path.join(bmadDir, '_cfg', 'workflow-manifest.csv');
console.log(chalk.dim(`[DEBUG] Adding workflow to manifest: ${name} -> ${relativePath} (module: ${moduleType})`));
// Read existing manifest
let manifestContent = '';
if (await fs.pathExists(workflowManifestPath)) {
manifestContent = await fs.readFile(workflowManifestPath, 'utf8');
}
// Ensure header exists
if (!manifestContent.includes('name,description,module,path')) {
manifestContent = 'name,description,module,path\n';
}
// Add workflow entry
const csvLine = `"${name}","${description}","${moduleType}","${relativePath}"\n`;
// Check if workflow already exists in manifest
if (manifestContent.includes(`"${name}",`)) {
console.log(chalk.dim(`[DEBUG] Workflow already exists in manifest: ${name}`));
} else {
try {
await fs.writeFile(workflowManifestPath, manifestContent + csvLine);
console.log(chalk.dim(`[DEBUG] Successfully added to manifest`));
} catch (error) {
console.log(chalk.red(`[ERROR] Failed to write to manifest: ${error.message}`));
}
}
}
/**
* Install a custom module like a regular module
*/
async function installCustomModuleAsRegular(module, projectDir, bmadDir, config) {
const yaml = require('js-yaml');
const path = require('node:path');
// The custom module path should be the source location
const customSrcPath = module.path;
// Install the custom module by copying it to the custom modules directory
const targetDir = path.join(bmadDir, 'custom', 'modules', module.name);
await fs.copy(customSrcPath, targetDir);
// Check if module has an installer and run it from the ORIGINAL source location
const installerPath = path.join(customSrcPath, '_module-installer', 'installer.js');
if (await fs.pathExists(installerPath)) {
try {
// Clear require cache to ensure fresh import
delete require.cache[require.resolve(installerPath)];
// Load and run the module installer
const moduleInstaller = require(installerPath);
await moduleInstaller.install({
projectRoot: projectDir,
config: config.coreConfig || {},
installedIDEs: config.ides || [],
logger: {
log: (msg) => console.log(chalk.dim(` ${msg}`)),
error: (msg) => console.log(chalk.red(` ERROR: ${msg}`)),
},
});
console.log(chalk.green(`${module.name} (custom installer executed)`));
} catch (error) {
console.log(chalk.yellow(` ⚠️ ${module.name} installer failed: ${error.message}`));
console.log(chalk.dim(` Module copied but not configured`));
}
} else {
// No installer - check if module has agents/workflows to install
console.log(chalk.dim(` Processing module agents and workflows...`));
// Install agents from the module
const agentsPath = path.join(customSrcPath, 'agents');
if (await fs.pathExists(agentsPath)) {
const agentFiles = await fs.readdir(agentsPath);
for (const agentFile of agentFiles) {
if (agentFile.endsWith('.yaml')) {
const agentPath = path.join(agentsPath, agentFile);
await installModuleAgent(agentPath, module.name, projectDir, bmadDir, config);
}
}
}
// Install workflows from the module
const workflowsPath = path.join(customSrcPath, 'workflows');
if (await fs.pathExists(workflowsPath)) {
const workflowDirs = await fs.readdir(workflowsPath, { withFileTypes: true });
for (const workflowDir of workflowDirs) {
if (workflowDir.isDirectory()) {
const workflowPath = path.join(workflowsPath, workflowDir.name);
await installModuleWorkflow(workflowPath, module.name, projectDir, bmadDir, config);
}
}
}
console.log(chalk.green(`${module.name}`));
}
// Update manifest.yaml to include custom module with proper prefix
const manifestYamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
if (await fs.pathExists(manifestYamlPath)) {
const manifest = yaml.load(await fs.readFile(manifestYamlPath, 'utf8'));
// Remove any old entries without custom- prefix for this module
const oldModuleName = module.name;
if (manifest.modules.includes(oldModuleName)) {
manifest.modules = manifest.modules.filter((m) => m !== oldModuleName);
console.log(chalk.dim(` Removed old entry: ${oldModuleName}`));
}
// Custom modules should be stored with custom- prefix
const moduleNameWithPrefix = `custom-${module.name}`;
if (!manifest.modules.includes(moduleNameWithPrefix)) {
manifest.modules.push(moduleNameWithPrefix);
console.log(chalk.dim(` Added to manifest.yaml as ${moduleNameWithPrefix}`));
}
// Write back the cleaned manifest
await fs.writeFile(manifestYamlPath, yaml.dump(manifest), 'utf8');
}
// Register module with IDEs (like regular modules do)
if (config.ides && config.ides.length > 0) {
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
for (const ide of config.ides) {
try {
// IdeManager uses a Map, not direct property access
const handler = ideManager.handlers.get(ide.toLowerCase());
if (handler && handler.moduleInjector) {
// Check if module has IDE-specific customizations
const subModulePath = path.join(customSrcPath, 'sub-modules', ide);
if (await fs.pathExists(subModulePath)) {
console.log(chalk.dim(` ✓ Found ${ide} customizations for ${module.name}`));
}
}
} catch (error) {
console.log(chalk.yellow(` ⚠️ Could not configure ${ide} for ${module.name}: ${error.message}`));
}
}
}
}
/**
* Install an agent from a module
*/
async function installModuleAgent(agentPath, moduleName, projectDir, bmadDir, config) {
const {
loadAgentConfig,
addToManifest,
extractManifestData,
createIdeSlashCommands,
updateManifestYaml,
} = require('../lib/agent/installer');
const { compileAgent } = require('../lib/agent/compiler');
const agentName = path.basename(agentPath, '.yaml');
console.log(chalk.dim(` Installing agent: ${agentName} (from ${moduleName})`));
// Load agent config
const agentConfig = loadAgentConfig(agentPath);
// Compile agent with defaults (no prompts for module agents)
const { xml, metadata } = compileAgent(
agentConfig.yamlContent,
agentConfig.defaults || {},
agentName,
`.bmad/custom/modules/${moduleName}/agents/${agentName}.md`,
);
// Create target directory
const targetDir = path.join(bmadDir, 'custom', 'modules', moduleName, 'agents');
await fs.ensureDir(targetDir);
// Write compiled agent
const compiledPath = path.join(targetDir, `${agentName}.md`);
await fs.writeFile(compiledPath, xml, 'utf8');
// Remove the raw YAML file after compilation
const yamlPath = path.join(targetDir, `${agentName}.yaml`);
if (await fs.pathExists(yamlPath)) {
await fs.remove(yamlPath);
}
// Add to agent manifest
const manifestFile = path.join(bmadDir, '_cfg', 'agent-manifest.csv');
const relativePath = `.bmad/custom/modules/${moduleName}/agents/${agentName}.md`;
const manifestData = extractManifestData(xml, { ...metadata, name: agentName }, relativePath, 'custom');
manifestData.name = `${moduleName}-${agentName}`;
manifestData.displayName = metadata.name || agentName;
addToManifest(manifestFile, manifestData);
// Update manifest.yaml
const manifestYamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
updateManifestYaml(manifestYamlPath, `${moduleName}-${agentName}`, agentName);
// Create IDE slash commands
const ideResults = await createIdeSlashCommands(projectDir, `${moduleName}-${agentName}`, relativePath, metadata, config.ides || []);
const ideCount = Object.keys(ideResults).length;
console.log(chalk.dim(`${agentName} (registered with ${ideCount} IDE${ideCount === 1 ? '' : 's'})`));
}
/**
* Install a workflow from a module
*/
async function installModuleWorkflow(workflowPath, moduleName, projectDir, bmadDir, config) {
const workflowName = path.basename(workflowPath);
// Copy the workflow directory
const targetDir = path.join(bmadDir, 'custom', 'modules', moduleName, 'workflows', workflowName);
await fs.copy(workflowPath, targetDir);
// Add to workflow manifest
const workflowManifestPath = path.join(bmadDir, '_cfg', 'workflow-manifest.csv');
const relativePath = `.bmad/custom/modules/${moduleName}/workflows/${workflowName}/README.md`;
// Read existing manifest
let manifestContent = '';
if (await fs.pathExists(workflowManifestPath)) {
manifestContent = await fs.readFile(workflowManifestPath, 'utf8');
}
// Ensure header exists
if (!manifestContent.includes('name,description,module,path')) {
manifestContent = 'name,description,module,path\n';
}
// Add workflow entry
const csvLine = `"${moduleName}-${workflowName}","Workflow from ${moduleName} module","${moduleName}","${relativePath}"\n`;
// Check if workflow already exists in manifest
if (!manifestContent.includes(`"${moduleName}-${workflowName}",`)) {
await fs.writeFile(workflowManifestPath, manifestContent + csvLine);
}
// Register with IDEs
if (config.ides && config.ides.length > 0) {
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
for (const ide of config.ides) {
try {
const ideHandler = ideManager.handlers.get(ide.toLowerCase());
if (ideHandler && typeof ideHandler.registerWorkflow === 'function') {
await ideHandler.registerWorkflow(projectDir, bmadDir, `${moduleName}-${workflowName}`, relativePath);
console.log(chalk.dim(` ✓ Registered with ${ide}`));
}
} catch (error) {
console.log(chalk.yellow(` ⚠️ Could not register with ${ide}: ${error.message}`));
}
}
}
console.log(chalk.dim(`${workflowName} workflow added and registered`));
}
module.exports = {
command: 'install',
description: 'Install BMAD Core agents and tools',
@ -520,6 +18,7 @@ module.exports = {
if (config.actionType === 'cancel') {
console.log(chalk.yellow('Installation cancelled.'));
process.exit(0);
return;
}
// Handle agent compilation separately
@ -528,6 +27,7 @@ module.exports = {
console.log(chalk.green('\n✨ Agent compilation complete!'));
console.log(chalk.cyan(`Rebuilt ${result.agentCount} agents and ${result.taskCount} tasks`));
process.exit(0);
return;
}
// Handle quick update separately
@ -535,71 +35,8 @@ module.exports = {
const result = await installer.quickUpdate(config);
console.log(chalk.green('\n✨ Quick update complete!'));
console.log(chalk.cyan(`Updated ${result.moduleCount} modules with preserved settings`));
// After quick update, check for existing custom content and re-install to regenerate IDE commands
const { UI } = require('../lib/ui');
const ui = new UI();
const customPath = path.join(config.directory, 'bmad-custom-src');
// Check if custom content exists
if (await fs.pathExists(customPath)) {
console.log(chalk.cyan('\n📦 Detecting custom content to update IDE commands...'));
// Get existing custom content selections (default to all for updates)
const existingCustom = {
agents: (await fs.pathExists(path.join(customPath, 'agents'))) ? true : false,
workflows: (await fs.pathExists(path.join(customPath, 'workflows'))) ? true : false,
modules: (await fs.pathExists(path.join(customPath, 'modules'))) ? true : false,
};
// Auto-select all existing custom content for update
if (existingCustom.agents || existingCustom.workflows || existingCustom.modules) {
const customContent = await ui.discoverCustomContent(customPath);
config.customContent = {
path: customPath,
selectedItems: {
agents: existingCustom.agents ? customContent.agents.map((a) => ({ ...a, selected: true })) : [],
workflows: existingCustom.workflows ? customContent.workflows.map((w) => ({ ...w, selected: true })) : [],
modules: existingCustom.modules ? customContent.modules.map((m) => ({ ...m, selected: true })) : [],
},
};
await installCustomContent(config, result, config.directory);
// Re-run IDE setup to register custom workflows with IDEs
if (config.ides && config.ides.length > 0) {
console.log(chalk.cyan('\n🔧 Updating IDE configurations for custom content...'));
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
for (const ide of config.ides) {
try {
const ideResult = await ideManager.setup(ide, config.directory, result.path, {
selectedModules: [...(config.modules || []), 'custom'], // Include 'custom' for custom agents/workflows
skipModuleInstall: true, // Don't install modules again
verbose: false,
preCollectedConfig: {
...config.coreConfig,
_alreadyConfigured: true, // Skip reconfiguration that might add duplicates
},
});
if (ideResult.success) {
console.log(chalk.dim(` ✓ Updated ${ide} with custom workflows`));
}
} catch (error) {
console.log(chalk.yellow(` ⚠️ Could not update ${ide}: ${error.message}`));
}
}
}
} else {
console.log(chalk.dim(' No custom content found to update'));
}
}
console.log(chalk.green('\n✨ Update complete with custom content!'));
process.exit(0);
return;
}
// Handle reinstall by setting force flag
@ -618,43 +55,11 @@ module.exports = {
// Check if installation was cancelled
if (result && result.cancelled) {
process.exit(0);
return;
}
// Check if installation succeeded
if (result && result.success) {
// Install custom content if selected
if (config.customContent && config.customContent.selectedItems) {
console.log(chalk.cyan('\n📦 Installing Custom Content...'));
await installCustomContent(config, result, config.directory);
// Re-run IDE setup to register custom workflows with IDEs
if (config.ides && config.ides.length > 0) {
console.log(chalk.cyan('\n🔧 Updating IDE configurations for custom content...'));
const { IdeManager } = require('../installers/lib/ide/manager');
const ideManager = new IdeManager();
for (const ide of config.ides) {
try {
const ideResult = await ideManager.setup(ide, config.directory, result.path, {
selectedModules: [...(config.modules || []), 'custom'], // Include 'custom' for custom agents/workflows
skipModuleInstall: true, // Don't install modules again
verbose: false,
preCollectedConfig: {
...config.coreConfig,
_alreadyConfigured: true, // Skip reconfiguration that might add duplicates
},
});
if (ideResult.success) {
console.log(chalk.dim(` ✓ Updated ${ide} with custom workflows`));
}
} catch (error) {
console.log(chalk.yellow(` ⚠️ Could not update ${ide}: ${error.message}`));
}
}
}
}
console.log(chalk.green('\n✨ Installation complete!'));
console.log(chalk.cyan('BMAD Core and Selected Modules have been installed to:'), chalk.bold(result.path));
console.log(chalk.yellow('\nThank you for helping test the early release version of the new BMad Core and BMad Method!'));

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');

View File

@ -23,7 +23,6 @@ const inquirer = require('inquirer');
const path = require('node:path');
const os = require('node:os');
const fs = require('fs-extra');
const yaml = require('js-yaml');
const { CLIUtils } = require('./cli-utils');
/**
@ -120,27 +119,6 @@ class UI {
const moduleChoices = await this.getModuleChoices(installedModuleIds);
const selectedModules = await this.selectModules(moduleChoices);
// Check if custom module was selected
let customContent = null;
if (selectedModules.includes('custom')) {
// Remove 'custom' from selectedModules since it's not a real module
const customIndex = selectedModules.indexOf('custom');
selectedModules.splice(customIndex, 1);
// Handle custom content selection
customContent = await this.handleCustomContentSelection(confirmedDirectory);
// Add custom modules to the selected modules list for proper installation
if (customContent && customContent.selectedItems && customContent.selectedItems.modules) {
for (const customModule of customContent.selectedItems.modules) {
selectedModules.push(`custom-${customModule.name}`);
}
}
}
// NOW collect module configurations (including custom modules that were just added)
const moduleConfig = await this.collectModuleConfigs(confirmedDirectory, selectedModules, coreConfig);
// Prompt for AgentVibes TTS integration
const agentVibesConfig = await this.promptAgentVibes(confirmedDirectory);
@ -159,488 +137,11 @@ class UI {
ides: toolSelection.ides,
skipIde: toolSelection.skipIde,
coreConfig: coreConfig, // Pass collected core config to installer
moduleConfig: moduleConfig, // Pass collected module configs (including custom modules)
enableAgentVibes: agentVibesConfig.enabled, // AgentVibes TTS integration
agentVibesInstalled: agentVibesConfig.alreadyInstalled,
customContent: customContent, // Custom content to install
};
}
/**
* Handle custom content selection in module phase
* @param {string} projectDir - Project directory
* @returns {Object} Custom content info with selected items
*/
async handleCustomContentSelection(projectDir) {
const defaultPath = path.join(projectDir, 'bmad-custom-src');
const hasDefaultFolder = await fs.pathExists(defaultPath);
let customPath;
if (hasDefaultFolder) {
console.log(chalk.cyan('\n📁 Custom Content Detected'));
console.log(chalk.dim(`Found custom folder at: ${defaultPath}`));
const { useDetected } = await inquirer.prompt([
{
type: 'confirm',
name: 'useDetected',
message: 'Install from detected custom folder?',
default: true,
},
]);
if (useDetected) {
customPath = defaultPath;
}
}
if (!customPath) {
console.log(chalk.cyan('\n📁 Custom Content Selection'));
const { specifiedPath } = await inquirer.prompt([
{
type: 'input',
name: 'specifiedPath',
message: 'Enter path to custom content folder:',
default: './bmad-custom-src',
validate: async (input) => {
if (!input.trim()) {
return 'Path is required';
}
const resolvedPath = path.resolve(input.trim());
if (!(await fs.pathExists(resolvedPath))) {
return `Path does not exist: ${resolvedPath}`;
}
return true;
},
},
]);
customPath = path.resolve(specifiedPath.trim());
}
// Discover and categorize custom content
const customContent = await this.discoverAndSelectCustomContent(customPath);
return {
path: customPath,
selectedItems: customContent,
};
}
/**
* Discover and allow selection of custom content
* @param {string} customPath - Path to custom content
* @returns {Object} Selected items by type
*/
async discoverAndSelectCustomContent(customPath) {
CLIUtils.displaySection('Custom Content', 'Discovering agents, workflows, and modules');
// Discover each type
const agents = await this.discoverCustomAgents(path.join(customPath, 'agents'));
const workflows = await this.discoverCustomWorkflows(path.join(customPath, 'workflows'));
const modules = await this.discoverCustomModules(path.join(customPath, 'modules'));
// Build choices for selection
const choices = [];
if (agents.length > 0) {
choices.push({ name: '--- 👥 Custom Agents ---', value: 'sep-agents', disabled: true });
for (const agent of agents) {
const shortDesc = agent.description.length > 50 ? agent.description.slice(0, 47) + '...' : agent.description;
choices.push({
name: ` ${agent.name} - ${shortDesc}`,
value: { type: 'agent', ...agent },
checked: true,
});
}
}
if (workflows.length > 0) {
choices.push({ name: '--- 📋 Custom Workflows ---', value: 'sep-workflows', disabled: true });
for (const workflow of workflows) {
const shortDesc = workflow.description.length > 50 ? workflow.description.slice(0, 47) + '...' : workflow.description;
choices.push({
name: ` ${workflow.name} - ${shortDesc}`,
value: { type: 'workflow', ...workflow },
checked: true,
});
}
}
if (modules.length > 0) {
choices.push({ name: '--- 🔧 Custom Modules ---', value: 'sep-modules', disabled: true });
for (const module of modules) {
const shortDesc = module.description.length > 50 ? module.description.slice(0, 47) + '...' : module.description;
choices.push({
name: ` ${module.name} - ${shortDesc}`,
value: { type: 'module', ...module },
checked: true,
});
}
}
if (choices.length === 0) {
console.log(chalk.yellow('⚠️ No custom content found'));
return { agents: [], workflows: [], modules: [] };
}
// Ask for selection
const { selectedItems } = await inquirer.prompt([
{
type: 'checkbox',
name: 'selectedItems',
message: 'Select custom items to install:',
choices: choices,
pageSize: 15,
},
]);
// Organize by type
const result = { agents: [], workflows: [], modules: [] };
for (const item of selectedItems) {
switch (item.type) {
case 'agent': {
result.agents.push(item);
break;
}
case 'workflow': {
result.workflows.push(item);
break;
}
case 'module': {
result.modules.push(item);
break;
}
}
}
console.log(
chalk.green(`\n✓ Selected: ${result.agents.length} agents, ${result.workflows.length} workflows, ${result.modules.length} modules`),
);
return result;
}
/**
* Discover custom agents
*/
async discoverCustomAgents(agentsPath) {
const agents = [];
if (!(await fs.pathExists(agentsPath))) return agents;
const entries = await fs.readdir(agentsPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const agentPath = path.join(agentsPath, entry.name);
const yamlFiles = await fs.readdir(agentPath).then((files) => files.filter((f) => f.endsWith('.agent.yaml')));
if (yamlFiles.length > 0) {
const yamlPath = path.join(agentPath, yamlFiles[0]);
const yamlData = yaml.load(await fs.readFile(yamlPath, 'utf8'));
agents.push({
name: entry.name,
path: agentPath,
yamlPath: yamlPath,
description: yamlData.metadata?.description || yamlData.description || 'Custom agent',
hasSidecar: true,
});
}
} else if (entry.isFile() && entry.name.endsWith('.agent.yaml')) {
const yamlData = yaml.load(await fs.readFile(path.join(agentsPath, entry.name), 'utf8'));
agents.push({
name: path.basename(entry.name, '.agent.yaml'),
path: agentsPath,
yamlPath: path.join(agentsPath, entry.name),
description: yamlData.metadata?.description || yamlData.description || 'Custom agent',
hasSidecar: false,
});
}
}
return agents;
}
/**
* Discover custom workflows
*/
async discoverCustomWorkflows(workflowsPath) {
const workflows = [];
if (!(await fs.pathExists(workflowsPath))) return workflows;
const entries = await fs.readdir(workflowsPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.md')) {
const filePath = path.join(workflowsPath, entry.name);
const content = await fs.readFile(filePath, 'utf8');
// Extract YAML frontmatter
let title = path.basename(entry.name, '.md');
let description = '';
let yamlMetadata = {};
// Check for YAML frontmatter
if (content.startsWith('---\n')) {
const frontmatterEnd = content.indexOf('\n---\n', 4);
if (frontmatterEnd !== -1) {
const yamlContent = content.slice(4, frontmatterEnd);
try {
yamlMetadata = yaml.load(yamlContent);
title = yamlMetadata.name || yamlMetadata.title || title;
description = yamlMetadata.description || yamlMetadata.summary || '';
} catch {
// If YAML parsing fails, fall back to markdown parsing
}
}
}
// If no YAML frontmatter or no metadata, parse from markdown
if (!title || !description) {
const lines = content.split('\n');
for (const line of lines) {
if (line.startsWith('# ')) {
title = line.slice(2).trim();
} else if (line.startsWith('## Description:')) {
description = line.replace('## Description:', '').trim();
}
if (title && description) break;
}
}
workflows.push({
name: title,
path: filePath,
description: description || 'Custom workflow',
metadata: yamlMetadata,
});
} else if (entry.isDirectory()) {
// Check for workflow.md in subdirectories
const workflowMdPath = path.join(workflowsPath, entry.name, 'workflow.md');
if (await fs.pathExists(workflowMdPath)) {
const content = await fs.readFile(workflowMdPath, 'utf8');
// Extract YAML frontmatter
let title = entry.name;
let description = '';
let yamlMetadata = {};
// Check for YAML frontmatter
if (content.startsWith('---\n')) {
const frontmatterEnd = content.indexOf('\n---\n', 4);
if (frontmatterEnd !== -1) {
const yamlContent = content.slice(4, frontmatterEnd);
try {
yamlMetadata = yaml.load(yamlContent);
title = yamlMetadata.name || yamlMetadata.title || title;
description = yamlMetadata.description || yamlMetadata.summary || '';
} catch {
// If YAML parsing fails, fall back to markdown parsing
}
}
}
// If no YAML frontmatter or no metadata, parse from markdown
if (!title || !description) {
const lines = content.split('\n');
for (const line of lines) {
if (line.startsWith('# ')) {
title = line.slice(2).trim();
} else if (line.startsWith('## Description:')) {
description = line.replace('## Description:', '').trim();
}
if (title && description) break;
}
}
workflows.push({
name: title,
path: path.join(workflowsPath, entry.name), // Store the DIRECTORY path, not the file
description: description || 'Custom workflow',
metadata: yamlMetadata,
});
}
}
}
return workflows;
}
/**
* Discover custom modules
*/
async discoverCustomModules(modulesPath) {
const modules = [];
if (!(await fs.pathExists(modulesPath))) return modules;
const entries = await fs.readdir(modulesPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const modulePath = path.join(modulesPath, entry.name);
const installerPath = path.join(modulePath, '_module-installer');
if (await fs.pathExists(installerPath)) {
// Check for install-config.yaml
const configPath = path.join(installerPath, 'install-config.yaml');
let description = 'Custom module';
if (await fs.pathExists(configPath)) {
const configData = yaml.load(await fs.readFile(configPath, 'utf8'));
description = configData.header || configData.description || description;
}
modules.push({
name: entry.name,
path: modulePath,
description: description,
});
}
}
}
return modules;
}
/**
* Handle custom content installation
* @param {string} projectDir - Project directory
*/
async handleCustomContent(projectDir) {
const defaultPath = path.join(projectDir, 'bmad-custom-src');
const hasDefaultFolder = await fs.pathExists(defaultPath);
let customPath;
if (hasDefaultFolder) {
console.log(chalk.cyan('\n📁 Custom Content Detected'));
console.log(chalk.dim(`Found custom folder at: ${defaultPath}`));
const { useDetected } = await inquirer.prompt([
{
type: 'confirm',
name: 'useDetected',
message: 'Install from detected custom folder?',
default: true,
},
]);
if (useDetected) {
customPath = defaultPath;
}
}
if (!customPath) {
console.log(chalk.cyan('\n📁 Custom Content Installation'));
const { specifiedPath } = await inquirer.prompt([
{
type: 'input',
name: 'specifiedPath',
message: 'Enter path to custom content folder:',
default: './bmad-custom-src',
validate: async (input) => {
if (!input.trim()) {
return 'Path is required';
}
const resolvedPath = path.resolve(input.trim());
if (!(await fs.pathExists(resolvedPath))) {
return `Path does not exist: ${resolvedPath}`;
}
return true;
},
},
]);
customPath = path.resolve(specifiedPath.trim());
}
// Discover custom content
const customContent = {
agents: await this.discoverCustomAgents(path.join(customPath, 'agents')),
modules: await this.discoverCustomModules(path.join(customPath, 'modules')),
workflows: await this.discoverCustomWorkflows(path.join(customPath, 'workflows')),
};
// Show discovery results
console.log(chalk.cyan('\n🔍 Custom Content Discovery'));
console.log(chalk.dim(`Scanning: ${customPath}`));
if (customContent.agents.length > 0) {
console.log(chalk.green(` ✓ Found ${customContent.agents.length} custom agent(s)`));
}
if (customContent.modules.length > 0) {
console.log(chalk.green(` ✓ Found ${customContent.modules.length} custom module(s)`));
}
if (customContent.workflows.length > 0) {
console.log(chalk.green(` ✓ Found ${customContent.workflows.length} custom workflow(s)`));
}
if (customContent.agents.length === 0 && customContent.modules.length === 0 && customContent.workflows.length === 0) {
console.log(chalk.yellow(' ⚠️ No custom content found in the specified folder'));
return;
}
// Confirm installation
const { confirmInstall } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmInstall',
message: 'Install discovered custom content?',
default: true,
},
]);
if (confirmInstall) {
console.log(chalk.green('\n🚀 Installing Custom Content...'));
// Store custom content for later installation
this._customContent = {
path: customPath,
items: customContent,
};
console.log(chalk.dim(` Custom content queued for installation`));
}
}
/**
* Discover custom content in a directory
* @param {string} dirPath - Directory path to scan
* @returns {Promise<Array>} List of discovered items
*/
async discoverCustomContent(dirPath) {
const items = [];
if (!(await fs.pathExists(dirPath))) {
return items;
}
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
items.push({
name: entry.name,
path: path.join(dirPath, entry.name),
type: 'directory',
});
} else if (entry.isFile() && (entry.name.endsWith('.agent.yaml') || entry.name.endsWith('.md'))) {
items.push({
name: entry.name,
path: path.join(dirPath, entry.name),
type: 'file',
});
}
}
} catch {
// Silently ignore errors during discovery
}
return items;
}
/**
* Prompt for tool/IDE selection (called after module configuration)
* @param {string} projectDir - Project directory to check for existing IDEs
@ -723,8 +224,6 @@ class UI {
}
}
// Custom option moved to module selection
CLIUtils.displaySection('Tool Integration', 'Select AI coding assistants and IDEs to configure');
let answers;
@ -742,8 +241,6 @@ class UI {
},
]);
// Custom selection moved to module phase
// If tools were selected, we're done
if (answers.ides && answers.ides.length > 0) {
break;
@ -778,7 +275,6 @@ class UI {
return {
ides: answers.ides || [],
skipIde: !answers.ides || answers.ides.length === 0,
customContent: this._customContent || null,
};
}
@ -974,35 +470,6 @@ class UI {
return configCollector.collectedConfig.core;
}
/**
* Collect module configurations
* @param {string} directory - Installation directory
* @param {Array} modules - Selected modules
* @param {Object} existingCoreConfig - Core config already collected
* @returns {Object} Module configurations
*/
async collectModuleConfigs(directory, modules, existingCoreConfig = null) {
const { ConfigCollector } = require('../installers/lib/core/config-collector');
const configCollector = new ConfigCollector();
// Load existing configs first if they exist
await configCollector.loadExistingConfig(directory);
// If core config was already collected, use it
if (existingCoreConfig) {
configCollector.collectedConfig.core = existingCoreConfig;
}
// Collect configurations for all modules except core (already collected earlier)
// ConfigCollector now handles custom modules properly
const modulesWithoutCore = modules.filter((m) => m !== 'core');
if (modulesWithoutCore.length > 0) {
await configCollector.collectAllConfigurations(modulesWithoutCore, directory);
}
return configCollector.collectedConfig;
}
/**
* Get module choices for selection
* @param {Set} installedModuleIds - Currently installed module IDs
@ -1014,32 +481,11 @@ class UI {
const availableModules = await moduleManager.listAvailable();
const isNewInstallation = installedModuleIds.size === 0;
const moduleChoices = availableModules.map((mod) => ({
return availableModules.map((mod) => ({
name: mod.name,
value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
}));
// Check for custom source folder
const customPath = path.join(process.cwd(), 'bmad-custom-src');
const hasCustomFolder = await fs.pathExists(customPath);
// Add custom option at the beginning
if (hasCustomFolder) {
moduleChoices.unshift({
name: '📁 Custom: Agents, Workflows, Modules',
value: 'custom',
checked: false,
});
} else {
moduleChoices.unshift({
name: '📁 Custom: Agents, Workflows, Modules (specify path)',
value: 'custom',
checked: false,
});
}
return moduleChoices;
}
/**