diff --git a/tools/cli/installers/lib/core/custom-module-cache.js b/tools/cli/installers/lib/core/custom-module-cache.js new file mode 100644 index 00000000..3ece246d --- /dev/null +++ b/tools/cli/installers/lib/core/custom-module-cache.js @@ -0,0 +1,239 @@ +/** + * Custom Module Source Cache + * Caches custom module sources under _cfg/custom/ to ensure they're never lost + * and can be checked into source control + */ + +const fs = require('fs-extra'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +class CustomModuleCache { + constructor(bmadDir) { + this.bmadDir = bmadDir; + this.customCacheDir = path.join(bmadDir, '_cfg', 'custom'); + this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml'); + } + + /** + * Ensure the custom cache directory exists + */ + async ensureCacheDir() { + await fs.ensureDir(this.customCacheDir); + } + + /** + * Get cache manifest + */ + async getCacheManifest() { + if (!(await fs.pathExists(this.manifestPath))) { + return {}; + } + + const content = await fs.readFile(this.manifestPath, 'utf8'); + const yaml = require('js-yaml'); + return yaml.load(content) || {}; + } + + /** + * Update cache manifest + */ + async updateCacheManifest(manifest) { + const yaml = require('js-yaml'); + const content = yaml.dump(manifest, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(this.manifestPath, content); + } + + /** + * Calculate hash of a file or directory + */ + async calculateHash(sourcePath) { + const hash = crypto.createHash('sha256'); + + const isDir = (await fs.stat(sourcePath)).isDirectory(); + + if (isDir) { + // For directories, hash all files + const files = []; + async function collectFiles(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isFile()) { + files.push(path.join(dir, entry.name)); + } else if (entry.isDirectory() && !entry.name.startsWith('.')) { + await collectFiles(path.join(dir, entry.name)); + } + } + } + + await collectFiles(sourcePath); + files.sort(); // Ensure consistent order + + for (const file of files) { + const content = await fs.readFile(file); + const relativePath = path.relative(sourcePath, file); + hash.update(relativePath + '|' + content.toString('base64')); + } + } else { + // For single files + const content = await fs.readFile(sourcePath); + hash.update(content); + } + + return hash.digest('hex'); + } + + /** + * Cache a custom module source + * @param {string} moduleId - Module ID + * @param {string} sourcePath - Original source path + * @param {Object} metadata - Additional metadata to store + * @returns {Object} Cached module info + */ + async cacheModule(moduleId, sourcePath, metadata = {}) { + await this.ensureCacheDir(); + + const cacheDir = path.join(this.customCacheDir, moduleId); + const cacheManifest = await this.getCacheManifest(); + + // Check if already cached and unchanged + if (cacheManifest[moduleId]) { + const cached = cacheManifest[moduleId]; + if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) { + // Source unchanged, return existing cache info + return { + moduleId, + cachePath: cacheDir, + ...cached, + }; + } + } + + // Remove existing cache if it exists + if (await fs.pathExists(cacheDir)) { + await fs.remove(cacheDir); + } + + // Copy module to cache + await fs.copy(sourcePath, cacheDir, { + filter: (src) => { + const relative = path.relative(sourcePath, src); + // Skip node_modules, .git, and other common ignore patterns + return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store'); + }, + }); + + // Calculate hash of the source + const sourceHash = await this.calculateHash(sourcePath); + const cacheHash = await this.calculateHash(cacheDir); + + // Update manifest - don't store originalPath for source control friendliness + cacheManifest[moduleId] = { + originalHash: sourceHash, + cacheHash: cacheHash, + cachedAt: new Date().toISOString(), + ...metadata, + }; + + await this.updateCacheManifest(cacheManifest); + + return { + moduleId, + cachePath: cacheDir, + ...cacheManifest[moduleId], + }; + } + + /** + * Get cached module info + * @param {string} moduleId - Module ID + * @returns {Object|null} Cached module info or null + */ + async getCachedModule(moduleId) { + const cacheManifest = await this.getCacheManifest(); + const cached = cacheManifest[moduleId]; + + if (!cached) { + return null; + } + + const cacheDir = path.join(this.customCacheDir, moduleId); + + if (!(await fs.pathExists(cacheDir))) { + // Cache dir missing, remove from manifest + delete cacheManifest[moduleId]; + await this.updateCacheManifest(cacheManifest); + return null; + } + + // Verify cache integrity + const currentCacheHash = await this.calculateHash(cacheDir); + if (currentCacheHash !== cached.cacheHash) { + console.warn(`Warning: Cache integrity check failed for ${moduleId}`); + } + + return { + moduleId, + cachePath: cacheDir, + ...cached, + }; + } + + /** + * Get all cached modules + * @returns {Array} Array of cached module info + */ + async getAllCachedModules() { + const cacheManifest = await this.getCacheManifest(); + const cached = []; + + for (const [moduleId, info] of Object.entries(cacheManifest)) { + const cachedModule = await this.getCachedModule(moduleId); + if (cachedModule) { + cached.push(cachedModule); + } + } + + return cached; + } + + /** + * Remove a cached module + * @param {string} moduleId - Module ID to remove + */ + async removeCachedModule(moduleId) { + const cacheManifest = await this.getCacheManifest(); + const cacheDir = path.join(this.customCacheDir, moduleId); + + // Remove cache directory + if (await fs.pathExists(cacheDir)) { + await fs.remove(cacheDir); + } + + // Remove from manifest + delete cacheManifest[moduleId]; + await this.updateCacheManifest(cacheManifest); + } + + /** + * Sync cached modules with a list of module IDs + * @param {Array} moduleIds - Module IDs to keep + */ + async syncCache(moduleIds) { + const cached = await this.getAllCachedModules(); + + for (const cachedModule of cached) { + if (!moduleIds.includes(cachedModule.moduleId)) { + await this.removeCachedModule(cachedModule.moduleId); + } + } + } +} + +module.exports = { CustomModuleCache }; diff --git a/tools/cli/installers/lib/core/detector.js b/tools/cli/installers/lib/core/detector.js index 4217ecbc..28a91de7 100644 --- a/tools/cli/installers/lib/core/detector.js +++ b/tools/cli/installers/lib/core/detector.js @@ -17,6 +17,7 @@ class Detector { hasCore: false, modules: [], ides: [], + customModules: [], manifest: null, }; @@ -32,6 +33,10 @@ class Detector { result.manifest = manifestData; result.version = manifestData.version; result.installed = true; + // Copy custom modules if they exist + if (manifestData.customModules) { + result.customModules = manifestData.customModules; + } } // Check for core diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 48110f34..c913ee56 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -22,6 +22,7 @@ const path = require('node:path'); const fs = require('fs-extra'); const chalk = require('chalk'); const ora = require('ora'); +const inquirer = require('inquirer'); const { Detector } = require('./detector'); const { Manifest } = require('./manifest'); const { ModuleManager } = require('../modules/manager'); @@ -750,15 +751,48 @@ 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 - spinner.text = 'Resolving dependencies...'; + // Get project root const projectRoot = getProjectRoot(); - // Add custom content modules to the modules list for installation + // Step 1: Install core module first (if requested) + if (config.installCore) { + spinner.start('Installing BMAD core...'); + await this.installCoreWithDependencies(bmadDir, { core: {} }); + spinner.succeed('Core installed'); + + // Generate core config file + await this.generateModuleConfigs(bmadDir, { core: config.coreConfig || {} }); + } + + // Custom content is already handled in UI before module selection + let finalCustomContent = config.customContent; + + // Step 3: Prepare modules list including cached custom modules let allModules = [...(config.modules || [])]; - if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { + + // During quick update, we might have custom module sources from the manifest + if (config._customModuleSources) { + // Add custom modules from stored sources + for (const [moduleId, customInfo] of config._customModuleSources) { + if (!allModules.includes(moduleId) && (await fs.pathExists(customInfo.sourcePath))) { + allModules.push(moduleId); + } + } + } + + // Add cached custom modules + if (finalCustomContent && finalCustomContent.cachedModules) { + for (const cachedModule of finalCustomContent.cachedModules) { + if (!allModules.includes(cachedModule.id)) { + allModules.push(cachedModule.id); + } + } + } + + // Regular custom content from user input (non-cached) + if (finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { // Add custom modules to the installation list - for (const customFile of config.customContent.selectedFiles) { + for (const customFile of finalCustomContent.selectedFiles) { const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); const customInfo = await customHandler.getCustomInfo(customFile, projectDir); @@ -768,12 +802,18 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: } } - const modulesToInstall = config.installCore ? ['core', ...allModules] : allModules; + // Don't include core again if already installed + if (config.installCore) { + allModules = allModules.filter((m) => m !== 'core'); + } + + const modulesToInstall = allModules; // For dependency resolution, we need to pass the project root // Create a temporary module manager that knows about custom content locations const tempModuleManager = new ModuleManager({ scanProjectForModules: true, + bmadDir: bmadDir, // Pass bmadDir so we can check cache }); // Make sure custom modules are discoverable @@ -793,12 +833,7 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: spinner.succeed('Dependencies resolved'); } - // Install core if requested or if dependencies require it - if (config.installCore || resolution.byModule.core) { - spinner.start('Installing BMAD core...'); - await this.installCoreWithDependencies(bmadDir, resolution.byModule.core); - spinner.succeed('Core installed'); - } + // Core is already installed above, skip if included in resolution // Install modules with their dependencies if (allModules && allModules.length > 0) { @@ -816,10 +851,42 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: // Check if this is a custom module let isCustomModule = false; let customInfo = null; - if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { + let useCache = false; + + // First check if we have a cached version + if (finalCustomContent && finalCustomContent.cachedModules) { + const cachedModule = finalCustomContent.cachedModules.find((m) => m.id === moduleName); + if (cachedModule) { + isCustomModule = true; + customInfo = { + id: moduleName, + path: cachedModule.cachePath, + config: {}, + }; + useCache = true; + } + } + + // Then check if we have custom module sources from the manifest (for quick update) + if (!isCustomModule && config._customModuleSources && config._customModuleSources.has(moduleName)) { + customInfo = config._customModuleSources.get(moduleName); + isCustomModule = true; + + // Check if this is a cached module (source path starts with _cfg) + if (customInfo.sourcePath && (customInfo.sourcePath.startsWith('_cfg') || customInfo.sourcePath.includes('_cfg/custom'))) { + useCache = true; + // Make sure we have the right path structure + if (!customInfo.path) { + customInfo.path = customInfo.sourcePath; + } + } + } + + // Finally check regular custom content + if (!isCustomModule && finalCustomContent && finalCustomContent.selected && finalCustomContent.selectedFiles) { const { CustomHandler } = require('../custom/handler'); const customHandler = new CustomHandler(); - for (const customFile of config.customContent.selectedFiles) { + for (const customFile of finalCustomContent.selectedFiles) { const info = await customHandler.getCustomInfo(customFile, projectDir); if (info && info.id === moduleName) { isCustomModule = true; @@ -874,9 +941,43 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: // Create module config await this.generateModuleConfigs(bmadDir, { [moduleName]: { ...config.coreConfig, ...customInfo.config } }); + + // 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 _cfg + sourcePath = + customInfo.sourcePath && customInfo.sourcePath.startsWith('_cfg') + ? 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: sourcePath, + installDate: new Date().toISOString(), + }); } else { // Regular module installation - await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); + // Special case for core module + if (moduleName === 'core') { + await this.installCoreWithDependencies(bmadDir, resolution.byModule[moduleName]); + } else { + await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]); + } } spinner.succeed(`Module installed: ${moduleName}`); @@ -989,14 +1090,37 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: spinner.start('Generating workflow and agent manifests...'); const manifestGen = new ManifestGenerator(); - // Include preserved modules (from quick update) and custom modules in the manifest - const allModulesToList = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules || []; + // For quick update, we need ALL installed modules in the manifest + // Not just the ones being updated + const allModulesForManifest = config._quickUpdate + ? config._existingModules || allModules || [] + : config._preserveModules + ? [...allModules, ...config._preserveModules] + : allModules || []; - const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesToList, this.installedFiles, { + // For regular installs (including when called from quick update), use what we have + let modulesForCsvPreserve; + if (config._quickUpdate) { + // Quick update - use existing modules or fall back to modules being updated + modulesForCsvPreserve = config._existingModules || allModules || []; + } else { + // Regular install - use the modules we're installing plus any preserved ones + modulesForCsvPreserve = config._preserveModules ? [...allModules, ...config._preserveModules] : allModules; + } + + const manifestStats = await manifestGen.generateManifests(bmadDir, allModulesForManifest, this.installedFiles, { ides: config.ides || [], - preservedModules: config._preserveModules || [], // Scan these from installed bmad/ dir + preservedModules: modulesForCsvPreserve, // Scan these from installed bmad/ dir }); + // Add custom modules to manifest (now that it exists) + 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( `Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.tools} tools, ${manifestStats.files} files`, ); @@ -1259,6 +1383,30 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: const currentVersion = existingInstall.version; const newVersion = require(path.join(getProjectRoot(), 'package.json')).version; + // Check for custom modules with missing sources before update + const customModuleSources = new Map(); + if (existingInstall.customModules) { + for (const customModule of existingInstall.customModules) { + customModuleSources.set(customModule.id, customModule); + } + } + + if (customModuleSources.size > 0) { + spinner.stop(); + console.log(chalk.yellow('\nChecking custom module sources before update...')); + + const projectRoot = getProjectRoot(); + await this.handleMissingCustomSources( + customModuleSources, + bmadDir, + projectRoot, + 'update', + existingInstall.modules.map((m) => m.id), + ); + + spinner.start('Preparing update...'); + } + if (config.dryRun) { spinner.stop(); console.log(chalk.cyan('\nšŸ” Update Preview (Dry Run)\n')); @@ -2027,6 +2175,24 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: throw new Error(`BMAD not installed at ${bmadDir}`); } + // Check for custom modules with missing sources + const manifest = await this.manifest.read(bmadDir); + if (manifest && manifest.customModules && manifest.customModules.length > 0) { + spinner.stop(); + console.log(chalk.yellow('\nChecking custom module sources before compilation...')); + + const customModuleSources = new Map(); + for (const customModule of manifest.customModules) { + customModuleSources.set(customModule.id, customModule); + } + + const projectRoot = getProjectRoot(); + const installedModules = manifest.modules || []; + await this.handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, 'compile-agents', installedModules); + + spinner.start('Rebuilding agent files...'); + } + let agentCount = 0; let taskCount = 0; @@ -2171,17 +2337,245 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: const existingInstall = await this.detector.detect(bmadDir); const installedModules = existingInstall.modules.map((m) => m.id); const configuredIdes = existingInstall.ides || []; + const projectRoot = path.dirname(bmadDir); + + // Get custom module sources from manifest + const customModuleSources = new Map(); + if (existingInstall.customModules) { + for (const customModule of existingInstall.customModules) { + // Ensure we have an absolute sourcePath + let absoluteSourcePath = customModule.sourcePath; + + // Check if sourcePath is a cache-relative path (starts with _cfg/) + if (absoluteSourcePath && absoluteSourcePath.startsWith('_cfg')) { + // 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(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 updatedModule = { + ...customModule, + sourcePath: absoluteSourcePath, + }; + + customModuleSources.set(customModule.id, updatedModule); + } + } // Load saved IDE configurations const savedIdeConfigs = await this.ideConfigManager.loadAllIdeConfigs(bmadDir); // Get available modules (what we have source for) - const availableModules = await this.moduleManager.listAvailable(); - const availableModuleIds = new Set(availableModules.map((m) => m.id)); + const availableModulesData = await this.moduleManager.listAvailable(); + const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; + + // Add custom modules from manifest if their sources exist + for (const [moduleId, customModule] of customModuleSources) { + // Use the absolute sourcePath + const sourcePath = customModule.sourcePath; + + // Check if source exists at the recorded path + if ( + sourcePath && + (await fs.pathExists(sourcePath)) && // Add to available modules if not already there + !availableModules.some((m) => m.id === moduleId) + ) { + availableModules.push({ + id: moduleId, + name: customModule.name || moduleId, + path: sourcePath, + isCustom: true, + fromManifest: true, + }); + } + } + + // 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 + const customModuleResult = await this.handleMissingCustomSources( + customModuleSources, + bmadDir, + projectRoot, + 'update', + installedModules, + ); + + // Handle both old return format (array) and new format (object) + let validCustomModules = []; + let keptModulesWithoutSources = []; + + if (Array.isArray(customModuleResult)) { + // Old format - just an array + validCustomModules = customModuleResult; + } else if (customModuleResult && typeof customModuleResult === 'object') { + // New format - object with two arrays + validCustomModules = customModuleResult.validCustomModules || []; + keptModulesWithoutSources = customModuleResult.keptModulesWithoutSources || []; + } + + const customModulesFromManifest = validCustomModules.map((m) => ({ + ...m, + isCustom: 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 availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); + + // Core module is special - never include it in update flow + const nonCoreInstalledModules = installedModules.filter((id) => id !== 'core'); // Only update modules that are BOTH installed AND available (we have source for) - const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id)); - const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id)); + const modulesToUpdate = nonCoreInstalledModules.filter((id) => availableModuleIds.has(id)); + const skippedModules = nonCoreInstalledModules.filter((id) => !availableModuleIds.has(id)); + + // Add custom modules that were kept without sources to the skipped modules + // This ensures their agents are preserved in the manifest + for (const keptModule of keptModulesWithoutSources) { + if (!skippedModules.includes(keptModule)) { + skippedModules.push(keptModule); + } + } spinner.succeed(`Found ${modulesToUpdate.length} module(s) to update and ${configuredIdes.length} configured tool(s)`); @@ -2246,6 +2640,8 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: _quickUpdate: true, // Flag to skip certain prompts _preserveModules: skippedModules, // Preserve these in manifest even though we didn't update them _savedIdeConfigs: savedIdeConfigs, // Pass saved IDE configs to installer + _customModuleSources: customModuleSources, // Pass custom module sources for updates + _existingModules: installedModules, // Pass all installed modules for manifest generation }; // Call the standard install method @@ -2885,6 +3281,230 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: } } } + + /** + * Handle missing custom module sources interactively + * @param {Map} customModuleSources - Map of custom module ID to info + * @param {string} bmadDir - BMAD directory + * @param {string} projectRoot - Project root directory + * @param {string} operation - Current operation ('update', 'compile', etc.) + * @param {Array} installedModules - Array of installed module IDs (will be modified) + * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array + */ + async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules) { + const validCustomModules = []; + const keptModulesWithoutSources = []; // Track modules kept without sources + const customModulesWithMissingSources = []; + + // Check which sources exist + for (const [moduleId, customInfo] of customModuleSources) { + if (await fs.pathExists(customInfo.sourcePath)) { + validCustomModules.push({ + id: moduleId, + name: customInfo.name, + path: customInfo.sourcePath, + info: customInfo, + }); + } else { + customModulesWithMissingSources.push({ + id: moduleId, + name: customInfo.name, + sourcePath: customInfo.sourcePath, + relativePath: customInfo.relativePath, + info: customInfo, + }); + } + } + + // If no missing sources, return immediately + if (customModulesWithMissingSources.length === 0) { + return validCustomModules; + } + + // Stop any spinner for interactive prompts + const currentSpinner = ora(); + if (currentSpinner.isSpinning) { + currentSpinner.stop(); + } + + console.log(chalk.yellow(`\nāš ļø Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`)); + + const inquirer = require('inquirer'); + let keptCount = 0; + let updatedCount = 0; + let removedCount = 0; + + for (const missing of customModulesWithMissingSources) { + console.log(chalk.dim(` • ${missing.name} (${missing.id})`)); + console.log(chalk.dim(` Original source: ${missing.relativePath}`)); + console.log(chalk.dim(` Full path: ${missing.sourcePath}`)); + + const choices = [ + { + name: 'Keep installed (will not be processed)', + value: 'keep', + short: 'Keep', + }, + { + name: 'Specify new source location', + value: 'update', + short: 'Update', + }, + ]; + + // Only add remove option if not just compiling agents + if (operation !== 'compile-agents') { + choices.push({ + name: 'āš ļø REMOVE module completely (destructive!)', + value: 'remove', + short: 'Remove', + }); + } + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: `How would you like to handle "${missing.name}"?`, + choices, + }, + ]); + + switch (action) { + case 'update': { + const { newSourcePath } = await inquirer.prompt([ + { + type: 'input', + name: 'newSourcePath', + message: 'Enter the new path to the custom module:', + default: missing.sourcePath, + validate: async (input) => { + if (!input || input.trim() === '') { + return 'Please enter a path'; + } + const expandedPath = path.resolve(input.trim()); + if (!(await fs.pathExists(expandedPath))) { + return 'Path does not exist'; + } + // Check if it looks like a valid module + const moduleYamlPath = path.join(expandedPath, 'module.yaml'); + const agentsPath = path.join(expandedPath, 'agents'); + const workflowsPath = path.join(expandedPath, 'workflows'); + + if (!(await fs.pathExists(moduleYamlPath)) && !(await fs.pathExists(agentsPath)) && !(await fs.pathExists(workflowsPath))) { + return 'Path does not appear to contain a valid custom module'; + } + return true; + }, + }, + ]); + + // Update the source in manifest + const resolvedPath = path.resolve(newSourcePath.trim()); + missing.info.sourcePath = resolvedPath; + // Remove relativePath - we only store absolute sourcePath now + delete missing.info.relativePath; + await this.manifest.addCustomModule(bmadDir, missing.info); + + validCustomModules.push({ + id: moduleId, + name: missing.name, + path: resolvedPath, + info: missing.info, + }); + + updatedCount++; + console.log(chalk.green(`āœ“ Updated source location`)); + + break; + } + case 'remove': { + // Extra confirmation for destructive remove + console.log(chalk.red.bold(`\nāš ļø WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!`)); + console.log(chalk.red(` Module location: ${path.join(bmadDir, moduleId)}`)); + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: chalk.red.bold('Are you absolutely sure you want to delete this module?'), + default: false, + }, + ]); + + if (confirm) { + const { typedConfirm } = await inquirer.prompt([ + { + type: 'input', + name: 'typedConfirm', + message: chalk.red.bold('Type "DELETE" to confirm permanent deletion:'), + validate: (input) => { + if (input !== 'DELETE') { + return chalk.red('You must type "DELETE" exactly to proceed'); + } + return true; + }, + }, + ]); + + if (typedConfirm === 'DELETE') { + // Remove the module from filesystem and manifest + const modulePath = path.join(bmadDir, moduleId); + if (await fs.pathExists(modulePath)) { + const fsExtra = require('fs-extra'); + await fsExtra.remove(modulePath); + console.log(chalk.yellow(` āœ“ Deleted module directory: ${path.relative(projectRoot, modulePath)}`)); + } + + await this.manifest.removeModule(bmadDir, moduleId); + await this.manifest.removeCustomModule(bmadDir, moduleId); + console.log(chalk.yellow(` āœ“ Removed from manifest`)); + + // Also remove from installedModules list + if (installedModules && installedModules.includes(moduleId)) { + const index = installedModules.indexOf(moduleId); + if (index !== -1) { + installedModules.splice(index, 1); + } + } + + removedCount++; + console.log(chalk.red.bold(`āœ“ "${missing.name}" has been permanently removed`)); + } else { + console.log(chalk.dim(' Removal cancelled - module will be kept')); + keptCount++; + } + } else { + console.log(chalk.dim(' Removal cancelled - module will be kept')); + keptCount++; + } + + break; + } + case 'keep': { + keptCount++; + keptModulesWithoutSources.push(moduleId); + console.log(chalk.dim(` Module will be kept as-is`)); + + break; + } + // No default + } + } + + // Show summary + if (keptCount > 0 || updatedCount > 0 || removedCount > 0) { + console.log(chalk.dim(`\nSummary for custom modules with missing sources:`)); + if (keptCount > 0) console.log(chalk.dim(` • ${keptCount} module(s) kept as-is`)); + if (updatedCount > 0) console.log(chalk.dim(` • ${updatedCount} module(s) updated with new sources`)); + if (removedCount > 0) console.log(chalk.red(` • ${removedCount} module(s) permanently deleted`)); + } + + return { + validCustomModules, + keptModulesWithoutSources, + }; + } } module.exports = { Installer }; diff --git a/tools/cli/installers/lib/core/manifest-generator.js b/tools/cli/installers/lib/core/manifest-generator.js index 683e1438..71b23605 100644 --- a/tools/cli/installers/lib/core/manifest-generator.js +++ b/tools/cli/installers/lib/core/manifest-generator.js @@ -41,7 +41,11 @@ class ManifestGenerator { // Deduplicate modules list to prevent duplicates this.modules = [...new Set(['core', ...selectedModules, ...preservedModules, ...installedModules])]; this.updatedModules = [...new Set(['core', ...selectedModules, ...installedModules])]; // All installed modules get rescanned - this.preservedModules = preservedModules; // These stay as-is in CSVs + + // 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) + // But all modules should be included in the final manifest + this.preservedModules = [...new Set([...preservedModules, ...selectedModules, ...installedModules])]; // Include all installed modules this.bmadDir = bmadDir; this.bmadFolderName = path.basename(bmadDir); // Get the actual folder name (e.g., '.bmad' or 'bmad') this.allInstalledFiles = installedFiles; @@ -61,14 +65,14 @@ class ManifestGenerator { // Collect workflow data await this.collectWorkflows(selectedModules); - // Collect agent data - await this.collectAgents(selectedModules); + // Collect agent data - use updatedModules which includes all installed modules + await this.collectAgents(this.updatedModules); // Collect task data - await this.collectTasks(selectedModules); + await this.collectTasks(this.updatedModules); // Collect tool data - await this.collectTools(selectedModules); + await this.collectTools(this.updatedModules); // Write manifest files and collect their paths const manifestFiles = [ @@ -450,6 +454,21 @@ class ManifestGenerator { async writeMainManifest(cfgDir) { 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.load(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 = { installation: { version: packageJson.version, @@ -457,6 +476,7 @@ class ManifestGenerator { lastUpdated: new Date().toISOString(), }, modules: this.modules, + customModules: existingCustomModules, // Preserve custom modules ides: this.selectedIdes, }; @@ -562,12 +582,47 @@ class ManifestGenerator { async writeWorkflowManifest(cfgDir) { const csvPath = path.join(cfgDir, 'workflow-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 4) { + const name = parts[0].replace(/^"/, ''); + const module = parts[2]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header - removed standalone column as ALL workflows now generate commands let csv = 'name,description,module,path\n'; - // Add all workflows - no standalone property needed anymore + // Combine existing and new workflows + const allWorkflows = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allWorkflows.set(key, value); + } + + // Add/update new workflows for (const workflow of this.workflows) { - csv += `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"\n`; + const key = `${workflow.module}:${workflow.name}`; + allWorkflows.set(key, `"${workflow.name}","${workflow.description}","${workflow.module}","${workflow.path}"`); + } + + // Write all workflows + for (const [, value] of allWorkflows) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); @@ -581,12 +636,50 @@ class ManifestGenerator { async writeAgentManifest(cfgDir) { const csvPath = path.join(cfgDir, 'agent-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 11) { + const name = parts[0].replace(/^"/, ''); + const module = parts[8]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header with persona fields let csv = 'name,displayName,title,icon,role,identity,communicationStyle,principles,module,path\n'; - // Add all agents + // Combine existing and new agents, preferring new data for duplicates + const allAgents = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allAgents.set(key, value); + } + + // Add/update new agents for (const agent of this.agents) { - csv += `"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"\n`; + const key = `${agent.module}:${agent.name}`; + allAgents.set( + key, + `"${agent.name}","${agent.displayName}","${agent.title}","${agent.icon}","${agent.role}","${agent.identity}","${agent.communicationStyle}","${agent.principles}","${agent.module}","${agent.path}"`, + ); + } + + // Write all agents + for (const [, value] of allAgents) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); @@ -600,12 +693,47 @@ class ManifestGenerator { async writeTaskManifest(cfgDir) { const csvPath = path.join(cfgDir, 'task-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 6) { + const name = parts[0].replace(/^"/, ''); + const module = parts[3]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header with standalone column let csv = 'name,displayName,description,module,path,standalone\n'; - // Add all tasks + // Combine existing and new tasks + const allTasks = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allTasks.set(key, value); + } + + // Add/update new tasks for (const task of this.tasks) { - csv += `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"\n`; + const key = `${task.module}:${task.name}`; + allTasks.set(key, `"${task.name}","${task.displayName}","${task.description}","${task.module}","${task.path}","${task.standalone}"`); + } + + // Write all tasks + for (const [, value] of allTasks) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); @@ -619,12 +747,47 @@ class ManifestGenerator { async writeToolManifest(cfgDir) { const csvPath = path.join(cfgDir, 'tool-manifest.csv'); + // Read existing manifest to preserve entries + const existingEntries = new Map(); + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const lines = content.split('\n').filter((line) => line.trim()); + + // Skip header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (line) { + // Parse CSV (simple parsing assuming no commas in quoted fields) + const parts = line.split('","'); + if (parts.length >= 6) { + const name = parts[0].replace(/^"/, ''); + const module = parts[3]; + existingEntries.set(`${module}:${name}`, line); + } + } + } + } + // Create CSV header with standalone column let csv = 'name,displayName,description,module,path,standalone\n'; - // Add all tools + // Combine existing and new tools + const allTools = new Map(); + + // Add existing entries + for (const [key, value] of existingEntries) { + allTools.set(key, value); + } + + // Add/update new tools for (const tool of this.tools) { - csv += `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"\n`; + const key = `${tool.module}:${tool.name}`; + allTools.set(key, `"${tool.name}","${tool.displayName}","${tool.description}","${tool.module}","${tool.path}","${tool.standalone}"`); + } + + // Write all tools + for (const [, value] of allTools) { + csv += value + '\n'; } await fs.writeFile(csvPath, csv); diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js index e0cf1cd8..ce12304f 100644 --- a/tools/cli/installers/lib/core/manifest.js +++ b/tools/cli/installers/lib/core/manifest.js @@ -61,6 +61,7 @@ class Manifest { installDate: manifestData.installation?.installDate, lastUpdated: manifestData.installation?.lastUpdated, modules: manifestData.modules || [], + customModules: manifestData.customModules || [], ides: manifestData.ides || [], }; } catch (error) { @@ -93,6 +94,7 @@ class Manifest { lastUpdated: manifest.lastUpdated, }, modules: manifest.modules || [], + customModules: manifest.customModules || [], ides: manifest.ides || [], }; @@ -535,6 +537,51 @@ class Manifest { return configs; } + /** + * Add a custom module to the manifest with its source path + * @param {string} bmadDir - Path to bmad directory + * @param {Object} customModule - Custom module info + */ + async addCustomModule(bmadDir, customModule) { + const manifest = await this.read(bmadDir); + if (!manifest) { + throw new Error('No manifest found'); + } + + if (!manifest.customModules) { + manifest.customModules = []; + } + + // Check if custom module already exists + const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id); + if (existingIndex === -1) { + // Add new entry + manifest.customModules.push(customModule); + } else { + // Update existing entry + manifest.customModules[existingIndex] = customModule; + } + + await this.update(bmadDir, { customModules: manifest.customModules }); + } + + /** + * Remove a custom module from the manifest + * @param {string} bmadDir - Path to bmad directory + * @param {string} moduleId - Module ID to remove + */ + async removeCustomModule(bmadDir, moduleId) { + const manifest = await this.read(bmadDir); + if (!manifest || !manifest.customModules) { + return; + } + + const index = manifest.customModules.findIndex((m) => m.id === moduleId); + if (index !== -1) { + manifest.customModules.splice(index, 1); + await this.update(bmadDir, { customModules: manifest.customModules }); + } + } } module.exports = { Manifest }; diff --git a/tools/cli/installers/lib/modules/manager.js b/tools/cli/installers/lib/modules/manager.js index 9c89813a..9fc63caa 100644 --- a/tools/cli/installers/lib/modules/manager.js +++ b/tools/cli/installers/lib/modules/manager.js @@ -240,6 +240,25 @@ class ModuleManager { } } } + + // Also check for cached custom modules in _cfg/custom/ + if (this.bmadDir) { + const customCacheDir = path.join(this.bmadDir, '_cfg', 'custom'); + if (await fs.pathExists(customCacheDir)) { + const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true }); + for (const entry of cacheEntries) { + if (entry.isDirectory()) { + const cachePath = path.join(customCacheDir, entry.name); + const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_cfg/custom'); + if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) { + moduleInfo.isCustom = true; + moduleInfo.fromCache = true; + customModules.push(moduleInfo); + } + } + } + } + } } return { modules, customModules }; diff --git a/tools/cli/lib/cli-utils.js b/tools/cli/lib/cli-utils.js index 313d49a2..da193363 100644 --- a/tools/cli/lib/cli-utils.js +++ b/tools/cli/lib/cli-utils.js @@ -3,6 +3,7 @@ const boxen = require('boxen'); const wrapAnsi = require('wrap-ansi'); const figlet = require('figlet'); const path = require('node:path'); +const os = require('node:os'); const CLIUtils = { /** @@ -205,6 +206,22 @@ const CLIUtils = { // No longer clear screen or show boxes - just a simple completion message // This is deprecated but kept for backwards compatibility }, + + /** + * Expand path with ~ expansion + * @param {string} inputPath - Path to expand + * @returns {string} Expanded path + */ + expandPath(inputPath) { + if (!inputPath) return inputPath; + + // Expand ~ to home directory + if (inputPath.startsWith('~')) { + return path.join(os.homedir(), inputPath.slice(1)); + } + + return inputPath; + }, }; module.exports = { CLIUtils }; diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 2de47d59..79523a0a 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -59,11 +59,15 @@ class UI { const bmadDir = await installer.findBmadDir(confirmedDirectory); const hasExistingInstall = await fs.pathExists(bmadDir); - // Only ask for custom content if it's a NEW installation + // Always ask for custom content, but we'll handle it differently for new installs let customContentConfig = { hasCustomContent: false }; - if (!hasExistingInstall) { - // Prompt for custom content location (separate from installation directory) - customContentConfig = await this.promptCustomContentLocation(); + if (hasExistingInstall) { + // Existing installation - prompt to add/update custom content + customContentConfig = await this.promptCustomContentForExisting(); + } else { + // New installation - we'll prompt after creating the directory structure + // For now, set a flag to indicate we should ask later + customContentConfig._shouldAsk = true; } // Track action type (only set if there's an existing installation) @@ -126,6 +130,64 @@ class UI { const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory); const coreConfig = await this.collectCoreConfig(confirmedDirectory); + // For new installations, create the directory structure first so we can cache custom content + if (!hasExistingInstall && customContentConfig._shouldAsk) { + // Create the bmad directory based on core config + const path = require('node:path'); + const fs = require('fs-extra'); + const bmadFolderName = coreConfig.bmad_folder || 'bmad'; + const bmadDir = path.join(confirmedDirectory, bmadFolderName); + + await fs.ensureDir(bmadDir); + await fs.ensureDir(path.join(bmadDir, '_cfg')); + await fs.ensureDir(path.join(bmadDir, '_cfg', 'custom')); + + // Now prompt for custom content + customContentConfig = await this.promptCustomContentLocation(); + + // If custom content found, cache it + if (customContentConfig.hasCustomContent) { + const { CustomModuleCache } = require('../installers/lib/core/custom-module-cache'); + const cache = new CustomModuleCache(bmadDir); + + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); + + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo && customInfo.id) { + // Cache the module source + await cache.cacheModule(customInfo.id, customInfo.path, { + name: customInfo.name, + type: 'custom', + }); + + console.log(chalk.dim(` Cached ${customInfo.name} to _cfg/custom/${customInfo.id}`)); + } + } + + // Update config to use cached modules + customContentConfig.cachedModules = []; + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo && customInfo.id) { + customContentConfig.cachedModules.push({ + id: customInfo.id, + cachePath: path.join(bmadDir, '_cfg', 'custom', customInfo.id), + // Store relative path from cache for the manifest + relativePath: path.join('_cfg', 'custom', customInfo.id), + }); + } + } + + console.log(chalk.green(`āœ“ Cached ${customFiles.length} custom module(s)`)); + } + + // Clear the flag + delete customContentConfig._shouldAsk; + } + // Skip module selection during update/reinstall - keep existing modules let selectedModules; if (actionType === 'update' || actionType === 'reinstall') { @@ -139,26 +201,46 @@ class UI { // Check which custom content items were selected const selectedCustomContent = selectedModules.filter((mod) => mod.startsWith('__CUSTOM_CONTENT__')); - if (selectedCustomContent.length > 0) { + + // For cached modules (new installs), check if any cached modules were selected + let selectedCachedModules = []; + if (customContentConfig.cachedModules) { + selectedCachedModules = selectedModules.filter( + (mod) => !mod.startsWith('__CUSTOM_CONTENT__') && customContentConfig.cachedModules.some((cm) => cm.id === mod), + ); + } + + if (selectedCustomContent.length > 0 || selectedCachedModules.length > 0) { customContentConfig.selected = true; - customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); - // Convert custom content to module IDs for installation - const customContentModuleIds = []; - const { CustomHandler } = require('../installers/lib/custom/handler'); - const customHandler = new CustomHandler(); - for (const customFile of customContentConfig.selectedFiles) { - // Get the module info to extract the ID - const customInfo = await customHandler.getCustomInfo(customFile); - if (customInfo) { - customContentModuleIds.push(customInfo.id); + + // Handle directory-based custom content (existing installs) + if (selectedCustomContent.length > 0) { + customContentConfig.selectedFiles = selectedCustomContent.map((mod) => mod.replace('__CUSTOM_CONTENT__', '')); + // Convert custom content to module IDs for installation + const customContentModuleIds = []; + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + for (const customFile of customContentConfig.selectedFiles) { + // Get the module info to extract the ID + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo) { + customContentModuleIds.push(customInfo.id); + } } + // Filter out custom content markers and add module IDs + selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds]; + } + + // For cached modules, they're already module IDs, just mark as selected + if (selectedCachedModules.length > 0) { + customContentConfig.selectedCachedModules = selectedCachedModules; + // No need to filter since they're already proper module IDs } - // Filter out custom content markers and add module IDs - selectedModules = [...selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')), ...customContentModuleIds]; } else if (customContentConfig.hasCustomContent) { // User provided custom content but didn't select any customContentConfig.selected = false; customContentConfig.selectedFiles = []; + customContentConfig.selectedCachedModules = []; } } @@ -528,31 +610,56 @@ class UI { const customContentItems = []; const hasCustomContentItems = false; - // Add custom content items from directory - if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) { - // Get the custom content info to display proper names - const { CustomHandler } = require('../installers/lib/custom/handler'); - const customHandler = new CustomHandler(); - const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); + // Add custom content items + if (customContentConfig && customContentConfig.hasCustomContent) { + if (customContentConfig.cachedModules) { + // New installation - show cached modules + for (const cachedModule of customContentConfig.cachedModules) { + // Get the module info from cache + const yaml = require('js-yaml'); + const fs = require('fs-extra'); + const moduleYamlPath = path.join(cachedModule.cachePath, 'module.yaml'); - for (const customFile of customFiles) { - const customInfo = await customHandler.getCustomInfo(customFile); - if (customInfo) { - customContentItems.push({ - name: `${chalk.cyan('āœ“')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, - value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content - checked: true, // Default to selected since user chose to provide custom content - path: customInfo.path, // Track path to avoid duplicates - }); + if (await fs.pathExists(moduleYamlPath)) { + const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); + const moduleData = yaml.load(yamlContent); + + customContentItems.push({ + name: `${chalk.cyan('āœ“')} ${moduleData.name || cachedModule.id} ${chalk.gray('(cached)')}`, + value: cachedModule.id, // Use module ID directly + checked: true, // Default to selected + cached: true, + }); + } + } + } else if (customContentConfig.customPath) { + // Existing installation - show from directory + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); + + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo) { + customContentItems.push({ + name: `${chalk.cyan('āœ“')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, + value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content + checked: true, // Default to selected since user chose to provide custom content + path: customInfo.path, // Track path to avoid duplicates + }); + } } } } // Add official modules const { ModuleManager } = require('../installers/lib/modules/manager'); - // Only scan project for modules if user selected custom content + // For new installations, don't scan project yet (will do after custom content is discovered) + // For existing installations, scan if user selected custom content + const shouldScanProject = + !isNewInstallation && customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected; const moduleManager = new ModuleManager({ - scanProjectForModules: customContentConfig && customContentConfig.hasCustomContent && customContentConfig.selected, + scanProjectForModules: shouldScanProject, }); const { modules: availableModules, customModules: customModulesFromProject } = await moduleManager.listAvailable(); @@ -1069,6 +1176,144 @@ class UI { return (await fs.pathExists(hookPath)) && (await fs.pathExists(playTtsPath)); } + + /** + * Prompt for custom content for existing installations + * @returns {Object} Custom content configuration + */ + async promptCustomContentForExisting() { + try { + CLIUtils.displaySection('Custom Content', 'Add new custom agents, workflows, or modules to your installation'); + + const { hasCustomContent } = await inquirer.prompt([ + { + type: 'list', + name: 'hasCustomContent', + message: 'Do you want to add or update custom content?', + choices: [ + { + name: 'No, continue with current installation only', + value: false, + }, + { + name: 'Yes, I have custom content to add or update', + value: true, + }, + ], + default: false, + }, + ]); + + if (!hasCustomContent) { + return { hasCustomContent: false }; + } + + // Get directory path + const { customPath } = await inquirer.prompt([ + { + type: 'input', + name: 'customPath', + message: 'Enter directory to search for custom content (will scan subfolders):', + default: process.cwd(), + validate: async (input) => { + if (!input || input.trim() === '') { + return 'Please enter a directory path'; + } + + // Normalize and check if path exists + const expandedPath = CLIUtils.expandPath(input.trim()); + const pathExists = await fs.pathExists(expandedPath); + if (!pathExists) { + return 'Directory does not exist'; + } + + // Check if it's actually a directory + const stats = await fs.stat(expandedPath); + if (!stats.isDirectory()) { + return 'Path must be a directory'; + } + + return true; + }, + transformer: (input) => { + return CLIUtils.expandPath(input); + }, + }, + ]); + + const resolvedPath = CLIUtils.expandPath(customPath); + + // Find custom content + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(resolvedPath); + + if (customFiles.length === 0) { + console.log(chalk.yellow(`\nNo custom content found in ${resolvedPath}`)); + + const { tryDifferent } = await inquirer.prompt([ + { + type: 'confirm', + name: 'tryDifferent', + message: 'Try a different directory?', + default: true, + }, + ]); + + if (tryDifferent) { + return await this.promptCustomContentForExisting(); + } + + return { hasCustomContent: false }; + } + + // Display found items + console.log(chalk.cyan(`\nFound ${customFiles.length} custom content file(s):`)); + const { CustomHandler: CustomHandler2 } = require('../installers/lib/custom/handler'); + const customHandler2 = new CustomHandler2(); + const customContentItems = []; + + for (const customFile of customFiles) { + const customInfo = await customHandler2.getCustomInfo(customFile); + if (customInfo) { + customContentItems.push({ + name: `${chalk.cyan('āœ“')} ${customInfo.name} ${chalk.gray(`(${customInfo.relativePath})`)}`, + value: `__CUSTOM_CONTENT__${customFile}`, + checked: true, + }); + } + } + + // Add option to keep existing custom content + console.log(chalk.yellow('\nExisting custom modules will be preserved unless you remove them')); + + const { selectedFiles } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedFiles', + message: 'Select custom content to add:', + choices: customContentItems, + pageSize: 15, + validate: (answer) => { + if (answer.length === 0) { + return 'You must select at least one item'; + } + return true; + }, + }, + ]); + + return { + hasCustomContent: true, + customPath: resolvedPath, + selected: true, + selectedFiles: selectedFiles, + }; + } catch (error) { + console.error(chalk.red('Error configuring custom content:'), error); + return { hasCustomContent: false }; + } + } } module.exports = { UI }; diff --git a/tools/migrate-custom-module-paths.js b/tools/migrate-custom-module-paths.js new file mode 100755 index 00000000..ad82e981 --- /dev/null +++ b/tools/migrate-custom-module-paths.js @@ -0,0 +1,124 @@ +/** + * Migration script to convert relative paths to absolute paths in custom module manifests + * This should be run once to update existing installations + */ + +const fs = require('fs-extra'); +const path = require('node:path'); +const yaml = require('yaml'); +const chalk = require('chalk'); + +/** + * Find BMAD directory in project + */ +function findBmadDir(projectDir = process.cwd()) { + const possibleNames = ['bmad', '.bmad']; + + for (const name of possibleNames) { + const bmadDir = path.join(projectDir, name); + if (fs.existsSync(bmadDir)) { + return bmadDir; + } + } + + return null; +} + +/** + * Update manifest to use absolute paths + */ +async function updateManifest(manifestPath, projectRoot) { + console.log(chalk.cyan(`\nUpdating manifest: ${manifestPath}`)); + + const content = await fs.readFile(manifestPath, 'utf8'); + const manifest = yaml.parse(content); + + if (!manifest.customModules || manifest.customModules.length === 0) { + console.log(chalk.dim(' No custom modules found')); + return false; + } + + let updated = false; + + for (const customModule of manifest.customModules) { + if (customModule.relativePath && !customModule.sourcePath) { + // Convert relative path to absolute + const absolutePath = path.resolve(projectRoot, customModule.relativePath); + customModule.sourcePath = absolutePath; + + // Remove the old relativePath + delete customModule.relativePath; + + console.log(chalk.green(` āœ“ Updated ${customModule.id}: ${customModule.relativePath} → ${absolutePath}`)); + updated = true; + } else if (customModule.sourcePath && !path.isAbsolute(customModule.sourcePath)) { + // Source path exists but is not absolute + const absolutePath = path.resolve(customModule.sourcePath); + customModule.sourcePath = absolutePath; + + console.log(chalk.green(` āœ“ Updated ${customModule.id}: ${customModule.sourcePath} → ${absolutePath}`)); + updated = true; + } + } + + if (updated) { + // Write back the updated manifest + const yamlStr = yaml.dump(manifest, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(manifestPath, yamlStr); + console.log(chalk.green(' Manifest updated successfully')); + } else { + console.log(chalk.dim(' All paths already absolute')); + } + + return updated; +} + +/** + * Main migration function + */ +async function migrate(directory) { + const projectRoot = path.resolve(directory || process.cwd()); + const bmadDir = findBmadDir(projectRoot); + + if (!bmadDir) { + console.error(chalk.red('āœ— No BMAD installation found in directory')); + process.exit(1); + } + + console.log(chalk.blue.bold('šŸ”„ BMAD Custom Module Path Migration')); + console.log(chalk.dim(`Project: ${projectRoot}`)); + console.log(chalk.dim(`BMAD Directory: ${bmadDir}`)); + + const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml'); + + if (!fs.existsSync(manifestPath)) { + console.error(chalk.red('āœ— No manifest.yaml found')); + process.exit(1); + } + + const updated = await updateManifest(manifestPath, projectRoot); + + if (updated) { + console.log(chalk.green.bold('\n✨ Migration completed successfully!')); + console.log(chalk.dim('Custom modules now use absolute source paths.')); + } else { + console.log(chalk.yellow('\n⚠ No migration needed - paths already absolute')); + } +} + +// Run migration if called directly +if (require.main === module) { + const directory = process.argv[2]; + migrate(directory).catch((error) => { + console.error(chalk.red('\nāœ— Migration failed:'), error.message); + process.exit(1); + }); +} + +module.exports = { migrate };