Compare commits

..

7 Commits

Author SHA1 Message Date
Alex Verkhovsky fc44c079ca
Merge bba2c99d13 into c24821b6ed 2025-12-15 10:44:03 -07:00
Alex Verkhovsky bba2c99d13
Merge branch 'main' into fix/sprint-status-issues-1116-1122 2025-12-15 10:44:01 -07:00
Brian Madison c24821b6ed menu wording updates 2025-12-16 01:25:49 +08:00
Brian Madison 2c4c2d9717 reduce installer log output 2025-12-15 23:53:26 +08:00
Brian Madison 901b39de9a fixed duplicate entry in files manfest issue 2025-12-15 20:47:21 +08:00
Brian Madison 4d8d1f84f7 quick update works and retains custom content also 2025-12-15 19:54:40 +08:00
Brian Madison 48795d46de core and custom modules all install through the same flow now 2025-12-15 19:16:03 +08:00
7 changed files with 431 additions and 652 deletions

View File

@ -21,15 +21,6 @@ module.exports = {
return; return;
} }
// Handle agent compilation separately
if (config.actionType === 'compile') {
const result = await installer.compileAgents(config);
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 // Handle quick update separately
if (config.actionType === 'quick-update') { if (config.actionType === 'quick-update') {
const result = await installer.quickUpdate(config); const result = await installer.quickUpdate(config);

View File

@ -9,7 +9,9 @@ module.exports = {
options: [], options: [],
action: async () => { action: async () => {
try { try {
const modules = await installer.getAvailableModules(); const result = await installer.getAvailableModules();
const { modules, customModules } = result;
console.log(chalk.cyan('\n📦 Available BMAD Modules:\n')); console.log(chalk.cyan('\n📦 Available BMAD Modules:\n'));
for (const module of modules) { for (const module of modules) {
@ -19,6 +21,16 @@ module.exports = {
console.log(); console.log();
} }
if (customModules && customModules.length > 0) {
console.log(chalk.cyan('\n🔧 Custom Modules:\n'));
for (const module of customModules) {
console.log(chalk.bold(` ${module.id}`));
console.log(chalk.dim(` ${module.description}`));
console.log(chalk.dim(` Version: ${module.version}`));
console.log();
}
}
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error(chalk.red('Error:'), error.message); console.error(chalk.red('Error:'), error.message);

View File

@ -32,7 +32,7 @@ class Installer {
this.dependencyResolver = new DependencyResolver(); this.dependencyResolver = new DependencyResolver();
this.configCollector = new ConfigCollector(); this.configCollector = new ConfigCollector();
this.ideConfigManager = new IdeConfigManager(); this.ideConfigManager = new IdeConfigManager();
this.installedFiles = []; // Track all installed files this.installedFiles = new Set(); // Track all installed files
this.ttsInjectedFiles = []; // Track files with TTS injection applied this.ttsInjectedFiles = []; // Track files with TTS injection applied
} }
@ -431,7 +431,41 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (config._quickUpdate) { if (config._quickUpdate) {
// Quick update already collected all configs, use them directly // Quick update already collected all configs, use them directly
moduleConfigs = this.configCollector.collectedConfig; moduleConfigs = this.configCollector.collectedConfig;
// For quick update, populate customModulePaths from _customModuleSources
if (config._customModuleSources) {
for (const [moduleId, customInfo] of config._customModuleSources) {
customModulePaths.set(moduleId, customInfo.sourcePath);
}
}
} else { } else {
// For regular updates (modify flow), check manifest for custom module sources
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
for (const customModule of config._existingInstall.customModules) {
// Ensure we have an absolute sourcePath
let absoluteSourcePath = customModule.sourcePath;
// Check if sourcePath is a cache-relative path (starts with _config)
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) {
// Convert cache-relative path to absolute path
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
}
// If no sourcePath but we have relativePath, convert it
else if (!absoluteSourcePath && customModule.relativePath) {
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectDir, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
absoluteSourcePath = path.resolve(absoluteSourcePath);
}
if (absoluteSourcePath) {
customModulePaths.set(customModule.id, absoluteSourcePath);
}
}
}
// Build custom module paths map from customContent // Build custom module paths map from customContent
// Handle selectedFiles (from existing install path or manual directory input) // Handle selectedFiles (from existing install path or manual directory input)
@ -582,20 +616,39 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv) // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
const existingFilesManifest = await this.readFilesManifest(bmadDir); const existingFilesManifest = await this.readFilesManifest(bmadDir);
console.log(chalk.dim(`DEBUG: Read ${existingFilesManifest.length} files from manifest`));
console.log(chalk.dim(`DEBUG: Manifest has hashes: ${existingFilesManifest.some((f) => f.hash)}`));
const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest); const { customFiles, modifiedFiles } = await this.detectCustomFiles(bmadDir, existingFilesManifest);
console.log(chalk.dim(`DEBUG: Found ${customFiles.length} custom files, ${modifiedFiles.length} modified files`));
if (modifiedFiles.length > 0) {
console.log(chalk.yellow('DEBUG: Modified files:'));
for (const f of modifiedFiles) console.log(chalk.dim(` - ${f.path}`));
}
config._customFiles = customFiles; config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles; config._modifiedFiles = modifiedFiles;
// Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) {
const moduleId = cachedModule.name;
// Skip if we already have this module from manifest
if (customModulePaths.has(moduleId)) {
continue;
}
const cachedPath = path.join(cacheDir, moduleId);
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModulePaths.set(moduleId, cachedPath);
}
}
}
// Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// If there are custom files, back them up temporarily // If there are custom files, back them up temporarily
if (customFiles.length > 0) { if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp'); const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp');
@ -618,20 +671,16 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp'); const tempModifiedBackupDir = path.join(projectDir, '_bmad-modified-backup-temp');
await fs.ensureDir(tempModifiedBackupDir); await fs.ensureDir(tempModifiedBackupDir);
console.log(chalk.yellow(`\nDEBUG: Backing up ${modifiedFiles.length} modified files to temp location`));
spinner.start(`Backing up ${modifiedFiles.length} modified files...`); spinner.start(`Backing up ${modifiedFiles.length} modified files...`);
for (const modifiedFile of modifiedFiles) { for (const modifiedFile of modifiedFiles) {
const relativePath = path.relative(bmadDir, modifiedFile.path); const relativePath = path.relative(bmadDir, modifiedFile.path);
const tempBackupPath = path.join(tempModifiedBackupDir, relativePath); const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
console.log(chalk.dim(`DEBUG: Backing up ${relativePath} to temp`));
await fs.ensureDir(path.dirname(tempBackupPath)); await fs.ensureDir(path.dirname(tempBackupPath));
await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true }); await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
} }
spinner.succeed(`Backed up ${modifiedFiles.length} modified files`); spinner.succeed(`Backed up ${modifiedFiles.length} modified files`);
config._tempModifiedBackupDir = tempModifiedBackupDir; config._tempModifiedBackupDir = tempModifiedBackupDir;
} else {
console.log(chalk.dim('DEBUG: No modified files detected'));
} }
} }
} else if (existingInstall.installed && config._quickUpdate) { } else if (existingInstall.installed && config._quickUpdate) {
@ -647,6 +696,34 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
config._customFiles = customFiles; config._customFiles = customFiles;
config._modifiedFiles = modifiedFiles; config._modifiedFiles = modifiedFiles;
// Also check cache directory for custom modules (like quick update does)
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) {
const moduleId = cachedModule.name;
// Skip if we already have this module from manifest
if (customModulePaths.has(moduleId)) {
continue;
}
const cachedPath = path.join(cacheDir, moduleId);
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModulePaths.set(moduleId, cachedPath);
}
}
}
// Update module manager with the new custom module paths from cache
this.moduleManager.setCustomModulePaths(customModulePaths);
}
// Back up custom files // Back up custom files
if (customFiles.length > 0) { if (customFiles.length > 0) {
const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp'); const tempBackupDir = path.join(projectDir, '_bmad-custom-backup-temp');
@ -825,7 +902,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// For dependency resolution, we need to pass the project root // For dependency resolution, we need to pass the project root
// Create a temporary module manager that knows about custom content locations // Create a temporary module manager that knows about custom content locations
const tempModuleManager = new ModuleManager({ const tempModuleManager = new ModuleManager({
scanProjectForModules: true,
bmadDir: bmadDir, // Pass bmadDir so we can check cache bmadDir: bmadDir, // Pass bmadDir so we can check cache
}); });
@ -847,7 +923,9 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
installedModuleNames.add(moduleName); installedModuleNames.add(moduleName);
spinner.start(`Installing module: ${moduleName}...`); // Show appropriate message based on whether this is a quick update
const isQuickUpdate = config._quickUpdate || false;
spinner.start(`${isQuickUpdate ? 'Updating' : 'Installing'} module: ${moduleName}...`);
// Check if this is a custom module // Check if this is a custom module
let isCustomModule = false; let isCustomModule = false;
@ -900,103 +978,36 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
if (isCustomModule && customInfo) { if (isCustomModule && customInfo) {
// Install custom module using CustomHandler but as a proper module // Custom modules are now installed via ModuleManager just like standard modules
const customHandler = new CustomHandler(); // The custom module path should already be in customModulePaths from earlier setup
if (!customModulePaths.has(moduleName) && customInfo.path) {
// Install to module directory instead of custom directory customModulePaths.set(moduleName, customInfo.path);
const moduleTargetPath = path.join(bmadDir, moduleName); this.moduleManager.setCustomModulePaths(customModulePaths);
await fs.ensureDir(moduleTargetPath); }
// Get collected config for this custom module (from module.yaml prompts) // Get collected config for this custom module (from module.yaml prompts)
const collectedModuleConfig = moduleConfigs[moduleName] || {}; const collectedModuleConfig = moduleConfigs[moduleName] || {};
const result = await customHandler.install( // Use ModuleManager to install the custom module
customInfo.path, await this.moduleManager.install(
path.join(bmadDir, 'temp-custom'), moduleName,
{ ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig, _bmadDir: bmadDir }, bmadDir,
(filePath) => { (filePath) => {
// Track installed files with correct path this.installedFiles.add(filePath);
const relativePath = path.relative(path.join(bmadDir, 'temp-custom'), filePath); },
const finalPath = path.join(moduleTargetPath, relativePath); {
this.installedFiles.push(finalPath); isCustom: true,
moduleConfig: collectedModuleConfig,
isQuickUpdate: config._quickUpdate || false,
}, },
); );
// Move from temp-custom to actual module directory // ModuleManager installs directly to the target directory, no need to move files
const tempCustomPath = path.join(bmadDir, 'temp-custom');
if (await fs.pathExists(tempCustomPath)) {
const customDir = path.join(tempCustomPath, 'custom');
if (await fs.pathExists(customDir)) {
// Move contents to module directory
const items = await fs.readdir(customDir);
const movedItems = [];
try {
for (const item of items) {
const srcPath = path.join(customDir, item);
const destPath = path.join(moduleTargetPath, item);
// If destination exists, remove it first (or we could merge)
if (await fs.pathExists(destPath)) {
await fs.remove(destPath);
}
await fs.move(srcPath, destPath);
movedItems.push({ src: srcPath, dest: destPath });
}
} catch (moveError) {
// Rollback: restore any successfully moved items
for (const moved of movedItems) {
try {
await fs.move(moved.dest, moved.src);
} catch {
// Best-effort rollback - log if it fails
console.error(`Failed to rollback ${moved.dest} during cleanup`);
}
}
throw new Error(`Failed to move custom module files: ${moveError.message}`);
}
}
try {
await fs.remove(tempCustomPath);
} catch (cleanupError) {
// Non-fatal: temp directory cleanup failed but files were moved successfully
console.warn(`Warning: Could not clean up temp directory: ${cleanupError.message}`);
}
}
// Create module config (include collected config from module.yaml prompts) // Create module config (include collected config from module.yaml prompts)
await this.generateModuleConfigs(bmadDir, { await this.generateModuleConfigs(bmadDir, {
[moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig }, [moduleName]: { ...config.coreConfig, ...customInfo.config, ...collectedModuleConfig },
}); });
// Store custom module info for later manifest update
if (!config._customModulesToTrack) {
config._customModulesToTrack = [];
}
// For cached modules, use appropriate path handling
let sourcePath;
if (useCache) {
// Check if we have cached modules info (from initial install)
if (finalCustomContent && finalCustomContent.cachedModules) {
sourcePath = finalCustomContent.cachedModules.find((m) => m.id === moduleName)?.relativePath;
} else {
// During update, the sourcePath is already cache-relative if it starts with _config
sourcePath =
customInfo.sourcePath && customInfo.sourcePath.startsWith('_config')
? customInfo.sourcePath
: path.relative(bmadDir, customInfo.path || customInfo.sourcePath);
}
} else {
sourcePath = path.resolve(customInfo.path || customInfo.sourcePath);
}
config._customModulesToTrack.push({
id: customInfo.id,
name: customInfo.name,
sourcePath: useCache ? `_config/custom/${customInfo.id}` : sourcePath,
installDate: new Date().toISOString(),
});
} else { } else {
// Regular module installation // Regular module installation
// Special case for core module // Special case for core module
@ -1007,7 +1018,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
} }
spinner.succeed(`Module installed: ${moduleName}`); spinner.succeed(`Module ${isQuickUpdate ? 'updated' : 'installed'}: ${moduleName}`);
} }
// Install partial modules (only dependencies) // Install partial modules (only dependencies)
@ -1029,69 +1040,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
} }
// Install custom content if provided AND selected // All content is now installed as modules - no separate custom content handling needed
// Process custom content that wasn't installed as modules
// This is now handled in the module installation loop above
// This section is kept for backward compatibility with any custom content
// that doesn't have a module structure
const remainingCustomContent = [];
if (
config.customContent &&
config.customContent.hasCustomContent &&
config.customContent.customPath &&
config.customContent.selected &&
config.customContent.selectedFiles
) {
// Filter out custom modules that were already installed
const customHandler = new CustomHandler();
for (const customFile of config.customContent.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
// Skip if this was installed as a module
if (!customInfo || !customInfo.id || !allModules.includes(customInfo.id)) {
remainingCustomContent.push(customFile);
}
}
}
if (remainingCustomContent.length > 0) {
spinner.start('Installing remaining custom content...');
const customHandler = new CustomHandler();
// Use the remaining files
const customFiles = remainingCustomContent;
if (customFiles.length > 0) {
console.log(chalk.cyan(`\n Found ${customFiles.length} custom content file(s):`));
for (const customFile of customFiles) {
const customInfo = await customHandler.getCustomInfo(customFile, projectDir);
if (customInfo) {
console.log(chalk.dim(`${customInfo.name} (${customInfo.relativePath})`));
// Install the custom content
const result = await customHandler.install(
customInfo.path,
bmadDir,
{ ...config.coreConfig, ...customInfo.config },
(filePath) => {
// Track installed files
this.installedFiles.push(filePath);
},
);
if (result.errors.length > 0) {
console.log(chalk.yellow(` ⚠️ ${result.errors.length} error(s) occurred`));
for (const error of result.errors) {
console.log(chalk.dim(` - ${error}`));
}
} else {
console.log(chalk.green(` ✓ Installed ${result.agentsInstalled} agents, ${result.workflowsInstalled} workflows`));
}
}
}
}
spinner.succeed('Custom content installed');
}
// Generate clean config.yaml files for each installed module // Generate clean config.yaml files for each installed module
spinner.start('Generating module configurations...'); spinner.start('Generating module configurations...');
@ -1104,12 +1053,10 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion) // Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion)
const cfgDir = path.join(bmadDir, '_config'); const cfgDir = path.join(bmadDir, '_config');
this.installedFiles.push( this.installedFiles.add(path.join(cfgDir, 'manifest.yaml'));
path.join(cfgDir, 'manifest.yaml'), this.installedFiles.add(path.join(cfgDir, 'workflow-manifest.csv'));
path.join(cfgDir, 'workflow-manifest.csv'), this.installedFiles.add(path.join(cfgDir, 'agent-manifest.csv'));
path.join(cfgDir, 'agent-manifest.csv'), this.installedFiles.add(path.join(cfgDir, 'task-manifest.csv'));
path.join(cfgDir, 'task-manifest.csv'),
);
// Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
spinner.start('Generating workflow and agent manifests...'); spinner.start('Generating workflow and agent manifests...');
@ -1133,19 +1080,12 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules; modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules;
} }
const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, this.installedFiles, { const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, [...this.installedFiles], {
ides: config.ides || [], ides: config.ides || [],
preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir
customModules: config._customModulesToTrack || [], // Custom modules to exclude from regular modules list
}); });
// Add custom modules to manifest (now that it exists) // Custom modules are now included in the main modules list - no separate tracking needed
if (config._customModulesToTrack && config._customModulesToTrack.length > 0) {
spinner.text = 'Storing custom module sources...';
for (const customModule of config._customModulesToTrack) {
await this.manifest.addCustomModule(bmadDir, customModule);
}
}
spinner.succeed( spinner.succeed(
`Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`, `Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`,
@ -1186,7 +1126,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Pass pre-collected configuration to avoid re-prompting // Pass pre-collected configuration to avoid re-prompting
await this.ideManager.setup(ide, projectDir, bmadDir, { await this.ideManager.setup(ide, projectDir, bmadDir, {
selectedModules: config.modules || [], selectedModules: allModules || [],
preCollectedConfig: ideConfigurations[ide] || null, preCollectedConfig: ideConfigurations[ide] || null,
verbose: config.verbose, verbose: config.verbose,
}); });
@ -1313,11 +1253,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Report custom and modified files if any were found // Report custom and modified files if any were found
if (customFiles.length > 0) { if (customFiles.length > 0) {
console.log(chalk.cyan(`\n📁 Custom files preserved: ${customFiles.length}`)); console.log(chalk.cyan(`\n📁 Custom files preserved: ${customFiles.length}`));
console.log(chalk.dim('The following custom files were found and restored:\n'));
for (const customFile of customFiles) {
const relativePath = path.relative(projectDir, customFile);
console.log(chalk.dim(`${relativePath}`));
}
} }
if (modifiedFiles.length > 0) { if (modifiedFiles.length > 0) {
@ -1380,12 +1315,44 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
// Check for custom modules with missing sources before update // Check for custom modules with missing sources before update
const customModuleSources = new Map(); const customModuleSources = new Map();
// Check manifest for backward compatibility
if (existingInstall.customModules) { if (existingInstall.customModules) {
for (const customModule of existingInstall.customModules) { for (const customModule of existingInstall.customModules) {
customModuleSources.set(customModule.id, customModule); customModuleSources.set(customModule.id, customModule);
} }
} }
// Also check cache directory
const cacheDir = path.join(bmadDir, '_config', 'custom');
if (await fs.pathExists(cacheDir)) {
const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
for (const cachedModule of cachedModules) {
if (cachedModule.isDirectory()) {
const moduleId = cachedModule.name;
// Skip if we already have this module
if (customModuleSources.has(moduleId)) {
continue;
}
const cachedPath = path.join(cacheDir, moduleId);
// Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
customModuleSources.set(moduleId, {
id: moduleId,
name: moduleId,
sourcePath: path.join('_config', 'custom', moduleId), // Relative path
cached: true,
});
}
}
}
}
if (customModuleSources.size > 0) { if (customModuleSources.size > 0) {
spinner.stop(); spinner.stop();
console.log(chalk.yellow('\nChecking custom module sources before update...')); console.log(chalk.yellow('\nChecking custom module sources before update...'));
@ -1578,7 +1545,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8'); await fs.writeFile(configPath, content.endsWith('\n') ? content : content + '\n', 'utf8');
// Track the config file in installedFiles // Track the config file in installedFiles
this.installedFiles.push(configPath); this.installedFiles.add(configPath);
} }
} }
} }
@ -1617,7 +1584,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
moduleName, moduleName,
bmadDir, bmadDir,
(filePath) => { (filePath) => {
this.installedFiles.push(filePath); this.installedFiles.add(filePath);
}, },
{ {
skipModuleInstaller: true, // We'll run it later after IDE setup skipModuleInstaller: true, // We'll run it later after IDE setup
@ -1654,7 +1621,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (await fs.pathExists(sourcePath)) { if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad');
this.installedFiles.push(targetPath); this.installedFiles.add(targetPath);
} }
} }
} }
@ -1670,7 +1637,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (await fs.pathExists(sourcePath)) { if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad');
this.installedFiles.push(targetPath); this.installedFiles.add(targetPath);
} }
} }
} }
@ -1686,7 +1653,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (await fs.pathExists(sourcePath)) { if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad');
this.installedFiles.push(targetPath); this.installedFiles.add(targetPath);
} }
} }
} }
@ -1702,7 +1669,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (await fs.pathExists(sourcePath)) { if (await fs.pathExists(sourcePath)) {
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad'); await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, this.bmadFolderName || 'bmad');
this.installedFiles.push(targetPath); this.installedFiles.add(targetPath);
} }
} }
} }
@ -1717,7 +1684,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
if (await fs.pathExists(dataPath)) { if (await fs.pathExists(dataPath)) {
await this.copyFileWithPlaceholderReplacement(dataPath, targetPath, this.bmadFolderName || 'bmad'); await this.copyFileWithPlaceholderReplacement(dataPath, targetPath, this.bmadFolderName || 'bmad');
this.installedFiles.push(targetPath); this.installedFiles.add(targetPath);
} }
} }
} }
@ -1816,7 +1783,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
// Track the installed file // Track the installed file
this.installedFiles.push(targetFile); this.installedFiles.add(targetFile);
} }
} }
@ -2281,35 +2248,35 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const configuredIdes = existingInstall.ides || []; const configuredIdes = existingInstall.ides || [];
const projectRoot = path.dirname(bmadDir); const projectRoot = path.dirname(bmadDir);
// Get custom module sources from manifest // Get custom module sources from cache
const customModuleSources = new Map(); const customModuleSources = new Map();
if (existingInstall.customModules) { const cacheDir = path.join(bmadDir, '_config', 'custom');
for (const customModule of existingInstall.customModules) { if (await fs.pathExists(cacheDir)) {
// Ensure we have an absolute sourcePath const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true });
let absoluteSourcePath = customModule.sourcePath;
// Check if sourcePath is a cache-relative path (starts with _config/) for (const cachedModule of cachedModules) {
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) { if (cachedModule.isDirectory()) {
// Convert cache-relative path to absolute path const moduleId = cachedModule.name;
absoluteSourcePath = path.join(bmadDir, absoluteSourcePath);
} // Skip if we already have this module from manifest
// If no sourcePath but we have relativePath, convert it if (customModuleSources.has(moduleId)) {
else if (!absoluteSourcePath && customModule.relativePath) { continue;
// relativePath is relative to the project root (parent of bmad dir)
absoluteSourcePath = path.resolve(projectRoot, customModule.relativePath);
}
// Ensure sourcePath is absolute for anything else
else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
absoluteSourcePath = path.resolve(absoluteSourcePath);
} }
// Update the custom module object with the absolute path const cachedPath = path.join(cacheDir, moduleId);
const updatedModule = {
...customModule,
sourcePath: absoluteSourcePath,
};
customModuleSources.set(customModule.id, updatedModule); // Check if this is actually a custom module (has module.yaml)
const moduleYamlPath = path.join(cachedPath, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
// For quick update, we always rebuild from cache
customModuleSources.set(moduleId, {
id: moduleId,
name: moduleId, // We'll read the actual name if needed
sourcePath: cachedPath,
cached: true, // Flag to indicate this is from cache
});
}
}
} }
} }
@ -2341,126 +2308,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
} }
// Check for untracked custom modules (installed but not in manifest)
const untrackedCustomModules = [];
for (const installedModule of installedModules) {
// Skip standard modules and core
const standardModuleIds = ['bmb', 'bmgd', 'bmm', 'cis', 'core'];
if (standardModuleIds.includes(installedModule)) {
continue;
}
// Check if this installed module is not tracked in customModules
if (!customModuleSources.has(installedModule)) {
const modulePath = path.join(bmadDir, installedModule);
if (await fs.pathExists(modulePath)) {
untrackedCustomModules.push({
id: installedModule,
name: installedModule, // We don't have the original name
path: modulePath,
untracked: true,
});
}
}
}
// If we found untracked custom modules, offer to track them
if (untrackedCustomModules.length > 0) {
spinner.stop();
console.log(chalk.yellow(`\n⚠️ Found ${untrackedCustomModules.length} custom module(s) not tracked in manifest:`));
for (const untracked of untrackedCustomModules) {
console.log(chalk.dim(`${untracked.id} (installed at ${path.relative(projectRoot, untracked.path)})`));
}
const { trackModules } = await inquirer.prompt([
{
type: 'confirm',
name: 'trackModules',
message: chalk.cyan('Would you like to scan for their source locations?'),
default: true,
},
]);
if (trackModules) {
const { scanDirectory } = await inquirer.prompt([
{
type: 'input',
name: 'scanDirectory',
message: 'Enter directory to scan for custom module sources (or leave blank to skip):',
default: projectRoot,
validate: async (input) => {
if (input && input.trim() !== '') {
const expandedPath = path.resolve(input.trim());
if (!(await fs.pathExists(expandedPath))) {
return 'Directory does not exist';
}
const stats = await fs.stat(expandedPath);
if (!stats.isDirectory()) {
return 'Path must be a directory';
}
}
return true;
},
},
]);
if (scanDirectory && scanDirectory.trim() !== '') {
console.log(chalk.dim('\nScanning for custom module sources...'));
// Scan for all module.yaml files
const allModulePaths = await this.moduleManager.findModulesInProject(scanDirectory);
const { ModuleManager } = require('../modules/manager');
const mm = new ModuleManager({ scanProjectForModules: true });
for (const untracked of untrackedCustomModules) {
let foundSource = null;
// Try to find by module ID
for (const modulePath of allModulePaths) {
try {
const moduleInfo = await mm.getModuleInfo(modulePath);
if (moduleInfo && moduleInfo.id === untracked.id) {
foundSource = {
path: modulePath,
info: moduleInfo,
};
break;
}
} catch {
// Continue searching
}
}
if (foundSource) {
console.log(chalk.green(` ✓ Found source for ${untracked.id}: ${path.relative(projectRoot, foundSource.path)}`));
// Add to manifest
await this.manifest.addCustomModule(bmadDir, {
id: untracked.id,
name: foundSource.info.name || untracked.name,
sourcePath: path.resolve(foundSource.path),
installDate: new Date().toISOString(),
tracked: true,
});
// Add to customModuleSources for processing
customModuleSources.set(untracked.id, {
id: untracked.id,
name: foundSource.info.name || untracked.name,
sourcePath: path.resolve(foundSource.path),
});
} else {
console.log(chalk.yellow(` ⚠ Could not find source for ${untracked.id}`));
}
}
}
}
console.log(chalk.dim('\nUntracked custom modules will remain installed but cannot be updated without their source.'));
spinner.start('Preparing update...');
}
// Handle missing custom module sources using shared method // Handle missing custom module sources using shared method
const customModuleResult = await this.handleMissingCustomSources( const customModuleResult = await this.handleMissingCustomSources(
customModuleSources, customModuleSources,
@ -2478,18 +2325,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
hasUpdate: true, hasUpdate: true,
})); }));
// Add untracked modules to the update list but mark them as untrackable
for (const untracked of untrackedCustomModules) {
if (!customModuleSources.has(untracked.id)) {
customModulesFromManifest.push({
...untracked,
isCustom: true,
hasUpdate: false, // Can't update without source
untracked: true,
});
}
}
const allAvailableModules = [...availableModules, ...customModulesFromManifest]; const allAvailableModules = [...availableModules, ...customModulesFromManifest];
const availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); const availableModuleIds = new Set(allAvailableModules.map((m) => m.id));
@ -2762,14 +2597,10 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
const installedFilesMap = new Map(); const installedFilesMap = new Map();
for (const fileEntry of existingFilesManifest) { for (const fileEntry of existingFilesManifest) {
if (fileEntry.path) { if (fileEntry.path) {
// Paths are relative to bmadDir. Legacy manifests incorrectly prefixed 'bmad/' - const absolutePath = path.join(bmadDir, fileEntry.path);
// strip it if present. This is safe because no real path inside bmadDir would
// start with 'bmad/' (you'd never have _bmad/bmad/... as an actual structure).
const relativePath = fileEntry.path.startsWith('bmad/') ? fileEntry.path.slice(5) : fileEntry.path;
const absolutePath = path.join(bmadDir, relativePath);
installedFilesMap.set(path.normalize(absolutePath), { installedFilesMap.set(path.normalize(absolutePath), {
hash: fileEntry.hash, hash: fileEntry.hash,
relativePath: relativePath, relativePath: fileEntry.path,
}); });
} }
} }
@ -2821,7 +2652,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
// Skip config.yaml files - these are regenerated on each install/update // Skip config.yaml files - these are regenerated on each install/update
// Users should use _config/agents/ override files instead
if (fileName === 'config.yaml') { if (fileName === 'config.yaml') {
continue; continue;
} }
@ -2844,8 +2674,6 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}); });
} }
} }
// If manifest doesn't have hashes, we can't detect modifications
// so we just skip files that are in the manifest
} }
} }
} catch { } catch {
@ -2975,7 +2803,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
} }
await fs.writeFile(configPath, configContent, 'utf8'); await fs.writeFile(configPath, configContent, 'utf8');
this.installedFiles.push(configPath); // Track agent config files this.installedFiles.add(configPath); // Track agent config files
createdCount++; createdCount++;
} }
@ -3064,6 +2892,15 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
path: customInfo.sourcePath, path: customInfo.sourcePath,
info: customInfo, info: customInfo,
}); });
} else {
// For cached modules that are missing, we just skip them without prompting
if (customInfo.cached) {
// Skip cached modules without prompting
keptModulesWithoutSources.push({
id: moduleId,
name: customInfo.name,
cached: true,
});
} else { } else {
customModulesWithMissingSources.push({ customModulesWithMissingSources.push({
id: moduleId, id: moduleId,
@ -3074,6 +2911,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice:
}); });
} }
} }
}
// If no missing sources, return immediately // If no missing sources, return immediately
if (customModulesWithMissingSources.length === 0) { if (customModulesWithMissingSources.length === 0) {

View File

@ -34,24 +34,21 @@ class ManifestGenerator {
// Store modules list (all modules including preserved ones) // Store modules list (all modules including preserved ones)
const preservedModules = options.preservedModules || []; const preservedModules = options.preservedModules || [];
const customModules = options.customModules || [];
// Scan the bmad directory to find all actually installed modules // Scan the bmad directory to find all actually installed modules
const installedModules = await this.scanInstalledModules(bmadDir); const installedModules = await this.scanInstalledModules(bmadDir);
// Filter out custom modules from the regular modules list // Since custom modules are now installed the same way as regular modules,
const customModuleIds = new Set(customModules.map((cm) => cm.id)); // we don't need to exclude them from manifest generation
const regularModules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])].filter( const allModules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])];
(module) => !customModuleIds.has(module),
);
this.modules = regularModules; this.modules = allModules;
this.updatedModules = [...new Set(['core', ...selectedModules, ...installedModules])].filter((module) => !customModuleIds.has(module)); // Also exclude custom modules from rescanning this.updatedModules = allModules; // Include ALL modules (including custom) for scanning
// For CSV manifests, we need to include ALL modules that are installed // For CSV manifests, we need to include ALL modules that are installed
// preservedModules controls which modules stay as-is in the CSV (don't get rescanned) // preservedModules controls which modules stay as-is in the CSV (don't get rescanned)
// But all modules should be included in the final manifest // But all modules should be included in the final manifest
this.preservedModules = [...new Set([...preservedModules, ...selectedModules, ...installedModules])]; // Include all installed modules this.preservedModules = allModules; // Include ALL modules (including custom)
this.bmadDir = bmadDir; this.bmadDir = bmadDir;
this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad') this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '_bmad' or 'bmad')
this.allInstalledFiles = installedFiles; this.allInstalledFiles = installedFiles;
@ -460,29 +457,13 @@ class ManifestGenerator {
async writeMainManifest(cfgDir) { async writeMainManifest(cfgDir) {
const manifestPath = path.join(cfgDir, 'manifest.yaml'); const manifestPath = path.join(cfgDir, 'manifest.yaml');
// Read existing manifest to preserve custom modules
let existingCustomModules = [];
if (await fs.pathExists(manifestPath)) {
try {
const existingContent = await fs.readFile(manifestPath, 'utf8');
const existingManifest = yaml.parse(existingContent);
if (existingManifest && existingManifest.customModules) {
existingCustomModules = existingManifest.customModules;
}
} catch {
// If we can't read the existing manifest, continue without preserving custom modules
console.warn('Warning: Could not read existing manifest to preserve custom modules');
}
}
const manifest = { const manifest = {
installation: { installation: {
version: packageJson.version, version: packageJson.version,
installDate: new Date().toISOString(), installDate: new Date().toISOString(),
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}, },
modules: this.modules, modules: this.modules, // Include ALL modules (standard and custom)
customModules: existingCustomModules, // Preserve custom modules
ides: this.selectedIdes, ides: this.selectedIdes,
}; };

View File

@ -62,8 +62,8 @@ class Manifest {
version: manifestData.installation?.version, version: manifestData.installation?.version,
installDate: manifestData.installation?.installDate, installDate: manifestData.installation?.installDate,
lastUpdated: manifestData.installation?.lastUpdated, lastUpdated: manifestData.installation?.lastUpdated,
modules: manifestData.modules || [], modules: manifestData.modules || [], // All modules (standard and custom)
customModules: manifestData.customModules || [], customModules: manifestData.customModules || [], // Keep for backward compatibility
ides: manifestData.ides || [], ides: manifestData.ides || [],
}; };
} catch (error) { } catch (error) {
@ -95,8 +95,7 @@ class Manifest {
installDate: manifest.installDate, installDate: manifest.installDate,
lastUpdated: manifest.lastUpdated, lastUpdated: manifest.lastUpdated,
}, },
modules: manifest.modules || [], modules: manifest.modules || [], // All modules (standard and custom)
customModules: manifest.customModules || [],
ides: manifest.ides || [], ides: manifest.ides || [],
}; };

View File

@ -28,7 +28,6 @@ class ModuleManager {
this.modulesSourcePath = getSourcePath('modules'); this.modulesSourcePath = getSourcePath('modules');
this.xmlHandler = new XmlHandler(); this.xmlHandler = new XmlHandler();
this.bmadFolderName = 'bmad'; // Default, can be overridden this.bmadFolderName = 'bmad'; // Default, can be overridden
this.scanProjectForModules = options.scanProjectForModules !== false; // Default to true for backward compatibility
this.customModulePaths = new Map(); // Initialize custom module paths this.customModulePaths = new Map(); // Initialize custom module paths
} }
@ -116,76 +115,6 @@ class ModuleManager {
} }
} }
/**
* Find all modules in the project by searching for module.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, node_modules, and literal placeholder directories
if (
entry.name.startsWith('.') ||
entry.name === 'node_modules' ||
entry.name === 'dist' ||
entry.name === 'build' ||
entry.name === '{project-root}'
) {
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 (module.yaml OR custom.yaml)
const moduleConfigPath = path.join(fullPath, 'module.yaml');
const installerConfigPath = path.join(fullPath, '_module-installer', 'module.yaml');
const customConfigPath = path.join(fullPath, '_module-installer', 'custom.yaml');
const rootCustomConfigPath = path.join(fullPath, 'custom.yaml');
if (
(await fs.pathExists(moduleConfigPath)) ||
(await fs.pathExists(installerConfigPath)) ||
(await fs.pathExists(customConfigPath)) ||
(await fs.pathExists(rootCustomConfigPath))
) {
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) * List all available modules (excluding core which is always installed)
* @returns {Object} Object with modules array and customModules array * @returns {Object} Object with modules array and customModules array
@ -228,30 +157,7 @@ class ModuleManager {
} }
} }
// Then, find all other modules in the project (only if scanning is enabled) // Check for cached custom modules in _config/custom/
if (this.scanProjectForModules) {
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) && !customModules.some((m) => m.id === moduleInfo.id)) {
// Avoid duplicates - skip if we already have this module ID
if (moduleInfo.isCustom) {
customModules.push(moduleInfo);
} else {
modules.push(moduleInfo);
}
}
}
// Also check for cached custom modules in _config/custom/
if (this.bmadDir) { if (this.bmadDir) {
const customCacheDir = path.join(this.bmadDir, '_config', 'custom'); const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
if (await fs.pathExists(customCacheDir)) { if (await fs.pathExists(customCacheDir)) {
@ -269,7 +175,6 @@ class ModuleManager {
} }
} }
} }
}
return { modules, customModules }; return { modules, customModules };
} }
@ -343,50 +248,28 @@ class ModuleManager {
/** /**
* Find the source path for a module by searching all possible locations * Find the source path for a module by searching all possible locations
* @param {string} moduleName - Name of the module to find * @param {string} moduleCode - Code of the module to find (from module.yaml)
* @returns {string|null} Path to the module source or null if not found * @returns {string|null} Path to the module source or null if not found
*/ */
async findModuleSource(moduleName) { async findModuleSource(moduleCode) {
const projectRoot = getProjectRoot(); const projectRoot = getProjectRoot();
// First check custom module paths if they exist // First check custom module paths if they exist
if (this.customModulePaths && this.customModulePaths.has(moduleName)) { if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
return this.customModulePaths.get(moduleName); return this.customModulePaths.get(moduleCode);
} }
// First, check src/modules // Search in src/modules by READING module.yaml files to match by code
const srcModulePath = path.join(this.modulesSourcePath, moduleName); if (await fs.pathExists(this.modulesSourcePath)) {
if (await fs.pathExists(srcModulePath)) { const entries = await fs.readdir(this.modulesSourcePath, { withFileTypes: true });
// Check if this looks like a module (has module.yaml) for (const entry of entries) {
const moduleConfigPath = path.join(srcModulePath, 'module.yaml'); if (entry.isDirectory()) {
const installerConfigPath = path.join(srcModulePath, '_module-installer', 'module.yaml'); const modulePath = path.join(this.modulesSourcePath, entry.name);
if ((await fs.pathExists(moduleConfigPath)) || (await fs.pathExists(installerConfigPath))) { // Read module.yaml to get the code
return srcModulePath;
}
// Also check for custom.yaml in src/modules/_module-installer
const customConfigPath = path.join(srcModulePath, '_module-installer', 'custom.yaml');
if (await fs.pathExists(customConfigPath)) {
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 moduleConfigPath = path.join(modulePath, 'module.yaml'); const moduleConfigPath = path.join(modulePath, 'module.yaml');
const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml'); const installerConfigPath = path.join(modulePath, '_module-installer', 'module.yaml');
const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml'); const customConfigPath = path.join(modulePath, '_module-installer', 'custom.yaml');
const rootCustomConfigPath = path.join(modulePath, 'custom.yaml');
let configPath = null; let configPath = null;
if (await fs.pathExists(moduleConfigPath)) { if (await fs.pathExists(moduleConfigPath)) {
@ -395,19 +278,20 @@ class ModuleManager {
configPath = installerConfigPath; configPath = installerConfigPath;
} else if (await fs.pathExists(customConfigPath)) { } else if (await fs.pathExists(customConfigPath)) {
configPath = customConfigPath; configPath = customConfigPath;
} else if (await fs.pathExists(rootCustomConfigPath)) {
configPath = rootCustomConfigPath;
} }
if (configPath) { if (configPath) {
try { try {
const configContent = await fs.readFile(configPath, 'utf8'); const configContent = await fs.readFile(configPath, 'utf8');
const config = yaml.parse(configContent); const config = yaml.parse(configContent);
if (config.code === moduleName) { if (config.code === moduleCode) {
return modulePath; return modulePath;
} }
} catch (error) { } catch (error) {
throw new Error(`Failed to parse module.yaml at ${configPath}: ${error.message}`); // Continue to next module if parse fails
console.warn(`Warning: Failed to parse module config at ${configPath}: ${error.message}`);
}
}
} }
} }
} }
@ -417,7 +301,7 @@ class ModuleManager {
/** /**
* Install a module * Install a module
* @param {string} moduleName - Name of the module to install * @param {string} moduleName - Code of the module to install (from module.yaml)
* @param {string} bmadDir - Target bmad directory * @param {string} bmadDir - Target bmad directory
* @param {Function} fileTrackingCallback - Optional callback to track installed files * @param {Function} fileTrackingCallback - Optional callback to track installed files
* @param {Object} options - Additional installation options * @param {Object} options - Additional installation options
@ -431,7 +315,10 @@ class ModuleManager {
// Check if source module exists // Check if source module exists
if (!sourcePath) { if (!sourcePath) {
throw new Error(`Module '${moduleName}' not found in any source location`); // Provide a more user-friendly error message
throw new Error(
`Source for module '${moduleName}' is not available. It will be retained but cannot be updated without its source files.`,
);
} }
// Check if this is a custom module and read its custom.yaml values // Check if this is a custom module and read its custom.yaml values
@ -465,7 +352,6 @@ class ModuleManager {
// Check if already installed // Check if already installed
if (await fs.pathExists(targetPath)) { if (await fs.pathExists(targetPath)) {
console.log(chalk.yellow(`Module '${moduleName}' already installed, updating...`));
await fs.remove(targetPath); await fs.remove(targetPath);
} }

View File

@ -195,11 +195,7 @@ class UI {
} }
// Common actions // Common actions
choices.push( choices.push({ name: 'Modify BMAD Installation', value: 'update' });
{ name: 'Modify BMAD Installation', value: 'update' },
{ name: 'Add / Update Custom Content', value: 'add-custom' },
{ name: 'Rebuild Agents', value: 'compile' },
);
const promptResult = await inquirer.prompt([ const promptResult = await inquirer.prompt([
{ {
@ -224,64 +220,6 @@ class UI {
}; };
} }
// Handle add custom content separately
if (actionType === 'add-custom') {
customContentConfig = await this.promptCustomContentSource();
// After adding custom content, continue to select additional modules
const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
// Ask if user wants to add additional modules
const { wantsMoreModules } = await inquirer.prompt([
{
type: 'confirm',
name: 'wantsMoreModules',
message: 'Do you want to add any additional modules?',
default: false,
},
]);
let selectedModules = [];
if (wantsMoreModules) {
const moduleChoices = await this.getModuleChoices(installedModuleIds, customContentConfig);
selectedModules = await this.selectModules(moduleChoices);
// Process custom content selection
const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__'));
if (selectedCustomContent.length > 0) {
customContentConfig.selected = true;
customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', ''));
// Convert to module IDs
const customContentModuleIds = [];
const customHandler = new CustomHandler();
for (const customFile of customContentConfig.selectedFiles) {
const customInfo = await customHandler.getCustomInfo(customFile);
if (customInfo) {
customContentModuleIds.push(customInfo.id);
}
}
selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds];
}
}
return {
actionType: 'update',
directory: confirmedDirectory,
installCore: false, // Don't reinstall core
modules: selectedModules,
customContent: customContentConfig,
};
}
// Handle agent compilation separately
if (actionType === 'compile') {
return {
actionType: 'compile',
directory: confirmedDirectory,
};
}
// If actionType === 'update', handle it with the new flow // If actionType === 'update', handle it with the new flow
// Return early with modify configuration // Return early with modify configuration
if (actionType === 'update') { if (actionType === 'update') {
@ -293,7 +231,7 @@ class UI {
{ {
type: 'confirm', type: 'confirm',
name: 'changeModuleSelection', name: 'changeModuleSelection',
message: 'Change which modules are installed?', message: 'Modify official module selection (BMad Method, BMad Builder, Creative Innovation Suite)?',
default: false, default: false,
}, },
]); ]);
@ -307,6 +245,14 @@ class UI {
selectedModules = [...installedModuleIds]; selectedModules = [...installedModuleIds];
} }
// After module selection, ask about custom modules
const customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules);
// Merge any selected custom modules
if (customModuleResult.selectedCustomModules.length > 0) {
selectedModules.push(...customModuleResult.selectedCustomModules);
}
// Get tool selection // Get tool selection
const toolSelection = await this.promptToolSelection(confirmedDirectory, selectedModules); const toolSelection = await this.promptToolSelection(confirmedDirectory, selectedModules);
@ -337,7 +283,7 @@ class UI {
ides: toolSelection.ides, ides: toolSelection.ides,
skipIde: toolSelection.skipIde, skipIde: toolSelection.skipIde,
coreConfig: coreConfig, coreConfig: coreConfig,
customContent: { hasCustomContent: false }, customContent: customModuleResult.customContentConfig,
enableAgentVibes: enableTts, enableAgentVibes: enableTts,
agentVibesInstalled: false, agentVibesInstalled: false,
}; };
@ -352,7 +298,7 @@ class UI {
{ {
type: 'confirm', type: 'confirm',
name: 'wantsOfficialModules', name: 'wantsOfficialModules',
message: 'Will you be installing any official modules (BMad Method, BMad Builder, Creative Innovation Suite)?', message: 'Will you be installing any official BMad modules (BMad Method, BMad Builder, Creative Innovation Suite)?',
default: true, default: true,
}, },
]); ]);
@ -368,7 +314,7 @@ class UI {
{ {
type: 'confirm', type: 'confirm',
name: 'wantsCustomContent', name: 'wantsCustomContent',
message: 'Will you be installing any locally stored custom content?', message: 'Would you like to install a local custom module (this includes custom agents and workflows also)?',
default: false, default: false,
}, },
]); ]);
@ -734,14 +680,8 @@ class UI {
// Add official modules // Add official modules
const { ModuleManager } = require('../installers/lib/modules/manager'); const { ModuleManager } = require('../installers/lib/modules/manager');
// For new installations, don't scan project yet (will do after custom content is discovered) const moduleManager = new ModuleManager();
// For existing installations, scan if user selected custom content const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable();
const shouldScanProject =
!isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected;
const moduleManager = new ModuleManager({
scanProjectForModules: shouldScanProject,
});
const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable();
// First, add all items to appropriate sections // First, add all items to appropriate sections
const allCustomModules = []; const allCustomModules = [];
@ -749,14 +689,14 @@ class UI {
// Add custom content items from directory // Add custom content items from directory
allCustomModules.push(...customContentItems); allCustomModules.push(...customContentItems);
// Add custom modules from project scan (if scanning is enabled) // Add custom modules from cache
for (const mod of customModulesFromProject) { for (const mod of customModulesFromCache) {
// Skip if this module is already in customContentItems (by path) // Skip if this module is already in customContentItems (by path)
const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path)); const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path));
if (!isDuplicate) { if (!isDuplicate) {
allCustomModules.push({ allCustomModules.push({
name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(${mod.source})`)}`, name: `${chalk.cyan('✓')} ${mod.name} ${chalk.gray(`(cached)`)}`,
value: mod.id, value: mod.id,
checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
}); });
@ -803,7 +743,9 @@ class UI {
}, },
]); ]);
return moduleAnswer.modules || []; const selected = moduleAnswer.modules || [];
return selected;
} }
/** /**
@ -1472,6 +1414,136 @@ class UI {
return customContentConfig; return customContentConfig;
} }
/**
* Handle custom modules in the modify flow
* @param {string} directory - Installation directory
* @param {Array} selectedModules - Currently selected modules
* @returns {Object} Result with selected custom modules and custom content config
*/
async handleCustomModulesInModifyFlow(directory, selectedModules) {
// Get existing installation to find custom modules
const { existingInstall } = await this.getExistingInstallation(directory);
// Check if there are any custom modules in cache
const { Installer } = require('../installers/lib/core/installer');
const installer = new Installer();
const { bmadDir } = await installer.findBmadDir(directory);
const cacheDir = path.join(bmadDir, '_config', 'custom');
const cachedCustomModules = [];
if (await fs.pathExists(cacheDir)) {
const entries = await fs.readdir(cacheDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const moduleYamlPath = path.join(cacheDir, entry.name, 'module.yaml');
if (await fs.pathExists(moduleYamlPath)) {
const yaml = require('yaml');
const content = await fs.readFile(moduleYamlPath, 'utf8');
const moduleData = yaml.parse(content);
cachedCustomModules.push({
id: entry.name,
name: moduleData.name || entry.name,
description: moduleData.description || 'Custom module from cache',
checked: selectedModules.includes(entry.name),
fromCache: true,
});
}
}
}
}
const result = {
selectedCustomModules: [],
customContentConfig: { hasCustomContent: false },
};
if (cachedCustomModules.length === 0) {
return result;
}
// Ask user about custom modules
console.log(chalk.cyan('\n⚙ Custom Modules'));
console.log(chalk.dim('Found custom modules in your installation:'));
const { customAction } = await inquirer.prompt([
{
type: 'list',
name: 'customAction',
message: 'What would you like to do with custom modules?',
choices: [
{ name: 'Keep all existing custom modules', value: 'keep' },
{ name: 'Select which custom modules to keep', value: 'select' },
{ name: 'Add new custom modules', value: 'add' },
{ name: 'Remove all custom modules', value: 'remove' },
],
default: 'keep',
},
]);
switch (customAction) {
case 'keep': {
// Keep all existing custom modules
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
console.log(chalk.dim(`Keeping ${result.selectedCustomModules.length} custom module(s)`));
break;
}
case 'select': {
// Let user choose which to keep
const choices = cachedCustomModules.map((m) => ({
name: `${m.name} ${chalk.gray(`(${m.id})`)}`,
value: m.id,
}));
const { keepModules } = await inquirer.prompt([
{
type: 'checkbox',
name: 'keepModules',
message: 'Select custom modules to keep:',
choices: choices,
default: cachedCustomModules.filter((m) => m.checked).map((m) => m.id),
},
]);
result.selectedCustomModules = keepModules;
break;
}
case 'add': {
// First ask to keep existing ones
const { keepExisting } = await inquirer.prompt([
{
type: 'confirm',
name: 'keepExisting',
message: 'Keep existing custom modules?',
default: true,
},
]);
if (keepExisting) {
result.selectedCustomModules = cachedCustomModules.map((m) => m.id);
}
// Then prompt for new ones (reuse existing method)
const newCustomContent = await this.promptCustomContentSource();
if (newCustomContent.hasCustomContent && newCustomContent.selected) {
result.selectedCustomModules.push(...newCustomContent.selectedModuleIds);
result.customContentConfig = newCustomContent;
}
break;
}
case 'remove': {
// Remove all custom modules
console.log(chalk.yellow('All custom modules will be removed from the installation'));
break;
}
}
return result;
}
} }
module.exports = { UI }; module.exports = { UI };