diff --git a/bmad-method-6.0.0-alpha.14.tgz b/bmad-method-6.0.0-alpha.14.tgz new file mode 100644 index 00000000..9a21d9d3 Binary files /dev/null and b/bmad-method-6.0.0-alpha.14.tgz differ diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js index 87b60f71..9390042e 100644 --- a/tools/cli/installers/lib/core/installer.js +++ b/tools/cli/installers/lib/core/installer.js @@ -798,6 +798,53 @@ If AgentVibes party mode is enabled, immediately trigger TTS with agent's voice: } } + // Install custom content if provided AND selected + if ( + config.customContent && + config.customContent.hasCustomContent && + config.customContent.customPath && + config.customContent.selected && + config.customContent.selectedFiles + ) { + spinner.start('Installing custom content...'); + const { CustomHandler } = require('../custom/handler'); + const customHandler = new CustomHandler(); + + // Use the selected files instead of finding all files + const customFiles = config.customContent.selectedFiles; + + 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 spinner.start('Generating module configurations...'); await this.generateModuleConfigs(bmadDir, moduleConfigs); diff --git a/tools/cli/installers/lib/custom/handler.js b/tools/cli/installers/lib/custom/handler.js index 87f37d37..dddec7e5 100644 --- a/tools/cli/installers/lib/custom/handler.js +++ b/tools/cli/installers/lib/custom/handler.js @@ -68,9 +68,10 @@ class CustomHandler { /** * Get custom content info from a custom.yaml file * @param {string} customYamlPath - Path to custom.yaml file + * @param {string} projectRoot - Project root directory for calculating relative paths * @returns {Object|null} Custom content info */ - async getCustomInfo(customYamlPath) { + async getCustomInfo(customYamlPath, projectRoot = null) { try { const configContent = await fs.readFile(customYamlPath, 'utf8'); @@ -84,7 +85,9 @@ class CustomHandler { } const customDir = path.dirname(customYamlPath); - const relativePath = path.relative(process.cwd(), customDir); + // Use provided projectRoot or fall back to process.cwd() + const basePath = projectRoot || process.cwd(); + const relativePath = path.relative(basePath, customDir); return { id: config.code || path.basename(customDir), @@ -236,13 +239,20 @@ class CustomHandler { // Copy with placeholder replacement for text files const textExtensions = ['.md', '.yaml', '.yml', '.txt', '.json']; if (textExtensions.some((ext) => entry.name.endsWith(ext))) { - await this.fileOps.copyFile(sourcePath, targetPath, { - bmadFolder: config.bmad_folder || 'bmad', - userName: config.user_name || 'User', - communicationLanguage: config.communication_language || 'English', - outputFolder: config.output_folder || 'docs', - }); + // Read source content + let content = await fs.readFile(sourcePath, 'utf8'); + + // Replace placeholders + content = content.replaceAll('{bmad_folder}', config.bmad_folder || 'bmad'); + content = content.replaceAll('{user_name}', config.user_name || 'User'); + content = content.replaceAll('{communication_language}', config.communication_language || 'English'); + content = content.replaceAll('{output_folder}', config.output_folder || 'docs'); + + // Write to target + await fs.ensureDir(path.dirname(targetPath)); + await fs.writeFile(targetPath, content, 'utf8'); } else { + // Copy binary files as-is await fs.copy(sourcePath, targetPath); } diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js index 27bea105..9b7078fa 100644 --- a/tools/cli/lib/ui.js +++ b/tools/cli/lib/ui.js @@ -52,6 +52,9 @@ class UI { await installer.handleLegacyV4Migration(confirmedDirectory, legacyV4); } + // Prompt for custom content location (separate from installation directory) + const customContentConfig = await this.promptCustomContentLocation(); + // Check if there's an existing BMAD installation const fs = require('fs-extra'); const path = require('node:path'); @@ -85,9 +88,12 @@ class UI { // Handle quick update separately if (actionType === 'quick-update') { + // Even for quick update, ask about custom content + const customContentConfig = await this.promptCustomContentLocation(); return { actionType: 'quick-update', directory: confirmedDirectory, + customContent: customContentConfig, }; } @@ -125,8 +131,21 @@ class UI { console.log(chalk.cyan('\nšŸ“¦ Keeping existing modules: ') + selectedModules.join(', ')); } else { // Only show module selection for new installs - const moduleChoices = await this.getModuleChoices(installedModuleIds); + const moduleChoices = await this.getModuleChoices(installedModuleIds, customContentConfig); selectedModules = await this.selectModules(moduleChoices); + + // Check which custom content items were selected + 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__', '')); + // Filter out custom content markers since they're not real modules + selectedModules = selectedModules.filter((mod) => !mod.startsWith('__CUSTOM_CONTENT__')); + } else if (customContentConfig.hasCustomContent) { + // User provided custom content but didn't select any + customContentConfig.selected = false; + customContentConfig.selectedFiles = []; + } } // Prompt for AgentVibes TTS integration @@ -147,7 +166,9 @@ class UI { ides: toolSelection.ides, skipIde: toolSelection.skipIde, coreConfig: coreConfig, // Pass collected core config to installer - enableAgentVibes: agentVibesConfig.enabled, // AgentVibes TTS integration + // Custom content configuration + customContent: customContentConfig, + enableAgentVibes: agentVibesConfig.enabled, agentVibesInstalled: agentVibesConfig.alreadyInstalled, }; } @@ -483,19 +504,50 @@ class UI { /** * Get module choices for selection * @param {Set} installedModuleIds - Currently installed module IDs + * @param {Object} customContentConfig - Custom content configuration * @returns {Array} Module choices for inquirer */ - async getModuleChoices(installedModuleIds) { + async getModuleChoices(installedModuleIds, customContentConfig = null) { + const moduleChoices = []; + const isNewInstallation = installedModuleIds.size === 0; + + // Add custom content items first if found + if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) { + // Add separator before custom content + moduleChoices.push(new inquirer.Separator('── Custom Content ──')); + + // 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); + + for (const customFile of customFiles) { + const customInfo = await customHandler.getCustomInfo(customFile); + if (customInfo) { + moduleChoices.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 + }); + } + } + + // Add separator for official content + moduleChoices.push(new inquirer.Separator('── Official Content ──')); + } + + // Add official modules const { ModuleManager } = require('../installers/lib/modules/manager'); const moduleManager = new ModuleManager(); const availableModules = await moduleManager.listAvailable(); - const isNewInstallation = installedModuleIds.size === 0; - const moduleChoices = availableModules.map((mod) => ({ - name: mod.isCustom ? `${mod.name} ${chalk.red('(Custom)')}` : mod.name, - value: mod.id, - checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), - })); + for (const mod of availableModules) { + moduleChoices.push({ + name: mod.name, + value: mod.id, + checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), + }); + } return moduleChoices; } @@ -574,6 +626,111 @@ class UI { } } + /** + * Prompt for custom content location + * @returns {Object} Custom content configuration + */ + async promptCustomContentLocation() { + try { + CLIUtils.displaySection('Custom Content', 'Optional: Add custom agents and workflows'); + + const { hasCustomContent } = await inquirer.prompt([ + { + type: 'list', + name: 'hasCustomContent', + message: 'Do you have custom content to install?', + choices: [ + { name: 'No (skip custom content)', value: 'none' }, + { name: 'Enter a directory path', value: 'directory' }, + { name: 'Enter a URL', value: 'url' }, + ], + default: 'none', + }, + ]); + + if (hasCustomContent === 'none') { + return { hasCustomContent: false }; + } + + if (hasCustomContent === 'url') { + console.log(chalk.yellow('\nURL-based custom content installation is coming soon!')); + console.log(chalk.cyan('For now, please download your custom content and choose "Enter a directory path".\n')); + return { hasCustomContent: false }; + } + + if (hasCustomContent === 'directory') { + let customPath; + while (!customPath) { + let expandedPath; + const { directory } = await inquirer.prompt([ + { + type: 'input', + name: 'directory', + message: 'Enter the path to your custom content directory:', + default: process.cwd(), // Use actual current working directory + validate: async (input) => { + if (!input || input.trim() === '') { + return 'Please enter a directory path'; + } + + try { + expandedPath = this.expandUserPath(input.trim()); + } catch (error) { + return error.message; + } + + // Check if the path exists + const pathExists = await fs.pathExists(expandedPath); + if (!pathExists) { + return 'Directory does not exist'; + } + + return true; + }, + }, + ]); + + // Now expand the path for use after the prompt + expandedPath = this.expandUserPath(directory.trim()); + + // Check if directory has custom content + const { CustomHandler } = require('../installers/lib/custom/handler'); + const customHandler = new CustomHandler(); + const customFiles = await customHandler.findCustomContent(expandedPath); + + if (customFiles.length === 0) { + console.log(chalk.yellow(`\nNo custom.yaml files found in ${expandedPath}`)); + + const { tryAgain } = await inquirer.prompt([ + { + type: 'confirm', + name: 'tryAgain', + message: 'Try a different directory?', + default: true, + }, + ]); + + if (tryAgain) { + continue; + } else { + return { hasCustomContent: false }; + } + } + + customPath = expandedPath; + console.log(chalk.green(`\nāœ“ Found ${customFiles.length} custom content file(s)`)); + } + + return { hasCustomContent: true, customPath }; + } + + return { hasCustomContent: false }; + } catch (error) { + console.error(chalk.red('Error in custom content prompt:'), error); + return { hasCustomContent: false }; + } + } + /** * Confirm directory selection * @param {string} directory - The directory path