BMAD-METHOD/.patch/477/implementation-all.477.diff

2904 lines
103 KiB
Diff
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

diff --git a/tools/cli/installers/lib/core/installer.js b/tools/cli/installers/lib/core/installer.js
new file mode 100644
index 00000000..6df7b66a
--- /dev/null
+++ b/tools/cli/installers/lib/core/installer.js
@@ -0,0 +1,1803 @@
+const path = require('node:path');
+const fs = require('fs-extra');
+const chalk = require('chalk');
+const ora = require('ora');
+const { Detector } = require('./detector');
+const { Manifest } = require('./manifest');
+const { ModuleManager } = require('../modules/manager');
+const { IdeManager } = require('../ide/manager');
+const { FileOps } = require('../../../lib/file-ops');
+const { Config } = require('../../../lib/config');
+const { XmlHandler } = require('../../../lib/xml-handler');
+const { DependencyResolver } = require('./dependency-resolver');
+const { ConfigCollector } = require('./config-collector');
+// processInstallation no longer needed - LLMs understand {project-root}
+const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
+const { AgentPartyGenerator } = require('../../../lib/agent-party-generator');
+const { CLIUtils } = require('../../../lib/cli-utils');
+const { ManifestGenerator } = require('./manifest-generator');
+
+class Installer {
+ constructor() {
+ this.detector = new Detector();
+ this.manifest = new Manifest();
+ this.moduleManager = new ModuleManager();
+ this.ideManager = new IdeManager();
+ this.fileOps = new FileOps();
+ this.config = new Config();
+ this.xmlHandler = new XmlHandler();
+ this.dependencyResolver = new DependencyResolver();
+ this.configCollector = new ConfigCollector();
+ this.installedFiles = []; // Track all installed files
+ }
+
+ /**
+ * Collect Tool/IDE configurations after module configuration
+ * @param {string} projectDir - Project directory
+ * @param {Array} selectedModules - Selected modules from configuration
+ * @returns {Object} Tool/IDE selection and configurations
+ */
+ async collectToolConfigurations(projectDir, selectedModules, isFullReinstall = false, previousIdes = []) {
+ // Prompt for tool selection
+ const { UI } = require('../../../lib/ui');
+ const ui = new UI();
+ const toolConfig = await ui.promptToolSelection(projectDir, selectedModules);
+
+ // Check for already configured IDEs
+ const { Detector } = require('./detector');
+ const detector = new Detector();
+ const bmadDir = path.join(projectDir, 'bmad');
+
+ // During full reinstall, use the saved previous IDEs since bmad dir was deleted
+ // Otherwise detect from existing installation
+ let previouslyConfiguredIdes;
+ if (isFullReinstall) {
+ // During reinstall, treat all IDEs as new (need configuration)
+ previouslyConfiguredIdes = [];
+ } else {
+ const existingInstall = await detector.detect(bmadDir);
+ previouslyConfiguredIdes = existingInstall.ides || [];
+ }
+
+ // Collect IDE-specific configurations if any were selected
+ const ideConfigurations = {};
+
+ if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) {
+ // Determine which IDEs are newly selected (not previously configured)
+ const newlySelectedIdes = toolConfig.ides.filter((ide) => !previouslyConfiguredIdes.includes(ide));
+
+ if (newlySelectedIdes.length > 0) {
+ console.log('\n'); // Add spacing before IDE questions
+
+ for (const ide of newlySelectedIdes) {
+ // List of IDEs that have interactive prompts
+ const needsPrompts = ['claude-code', 'github-copilot', 'roo', 'cline', 'auggie', 'codex', 'qwen', 'gemini'].includes(ide);
+
+ if (needsPrompts) {
+ // Get IDE handler and collect configuration
+ try {
+ // Dynamically load the IDE setup module
+ const ideModule = require(`../ide/${ide}`);
+
+ // Get the setup class (handle different export formats)
+ let SetupClass;
+ const className =
+ ide
+ .split('-')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('') + 'Setup';
+
+ if (ideModule[className]) {
+ SetupClass = ideModule[className];
+ } else if (ideModule.default) {
+ SetupClass = ideModule.default;
+ } else {
+ // Skip if no setup class found
+ continue;
+ }
+
+ const ideSetup = new SetupClass();
+
+ // Check if this IDE has a collectConfiguration method
+ if (typeof ideSetup.collectConfiguration === 'function') {
+ console.log(chalk.cyan(`\nConfiguring ${ide}...`));
+ ideConfigurations[ide] = await ideSetup.collectConfiguration({
+ selectedModules: selectedModules || [],
+ projectDir,
+ bmadDir,
+ });
+ }
+ } catch {
+ // IDE doesn't have a setup file or collectConfiguration method
+ console.warn(chalk.yellow(`Warning: Could not load configuration for ${ide}`));
+ }
+ }
+ }
+ }
+
+ // Log which IDEs are already configured and being kept
+ const keptIdes = toolConfig.ides.filter((ide) => previouslyConfiguredIdes.includes(ide));
+ if (keptIdes.length > 0) {
+ console.log(chalk.dim(`\nKeeping existing configuration for: ${keptIdes.join(', ')}`));
+ }
+ }
+
+ return {
+ ides: toolConfig.ides,
+ skipIde: toolConfig.skipIde,
+ configurations: ideConfigurations,
+ };
+ }
+
+ /**
+ * Main installation method
+ * @param {Object} config - Installation configuration
+ * @param {string} config.directory - Target directory
+ * @param {boolean} config.installCore - Whether to install core
+ * @param {string[]} config.modules - Modules to install
+ * @param {string[]} config.ides - IDEs to configure
+ * @param {boolean} config.skipIde - Skip IDE configuration
+ */
+ async install(config) {
+ // Display BMAD logo
+ CLIUtils.displayLogo();
+
+ // Display welcome message
+ CLIUtils.displaySection('BMADÔäó Installation', 'Version ' + require(path.join(getProjectRoot(), 'package.json')).version);
+
+ // Preflight: Handle legacy BMAD v4 footprints before any prompts/writes
+ const projectDir = path.resolve(config.directory);
+ const legacyV4 = await this.detector.detectLegacyV4(projectDir);
+ if (legacyV4.hasLegacyV4) {
+ await this.handleLegacyV4Migration(projectDir, legacyV4);
+ }
+
+ // If core config was pre-collected (from interactive mode), use it
+ if (config.coreConfig) {
+ this.configCollector.collectedConfig.core = config.coreConfig;
+ // Also store in allAnswers for cross-referencing
+ this.configCollector.allAnswers = {};
+ for (const [key, value] of Object.entries(config.coreConfig)) {
+ this.configCollector.allAnswers[`core_${key}`] = value;
+ }
+ }
+
+ // Collect configurations for modules (core was already collected in UI.promptInstall if interactive)
+ const moduleConfigs = await this.configCollector.collectAllConfigurations(config.modules || [], path.resolve(config.directory));
+
+ // Tool selection will be collected after we determine if it's a reinstall/update/new install
+
+ const spinner = ora('Preparing installation...').start();
+
+ try {
+ // Resolve target directory (path.resolve handles platform differences)
+ const projectDir = path.resolve(config.directory);
+
+ // Create a project directory if it doesn't exist (user already confirmed)
+ if (!(await fs.pathExists(projectDir))) {
+ spinner.text = 'Creating installation directory...';
+ try {
+ // fs.ensureDir handles platform-specific directory creation
+ // It will recursively create all necessary parent directories
+ await fs.ensureDir(projectDir);
+ } catch (error) {
+ spinner.fail('Failed to create installation directory');
+ console.error(chalk.red(`Error: ${error.message}`));
+ // More detailed error for common issues
+ if (error.code === 'EACCES') {
+ console.error(chalk.red('Permission denied. Check parent directory permissions.'));
+ } else if (error.code === 'ENOSPC') {
+ console.error(chalk.red('No space left on device.'));
+ }
+ throw new Error(`Cannot create directory: ${projectDir}`);
+ }
+ }
+
+ const bmadDir = path.join(projectDir, 'bmad');
+
+ // Check existing installation
+ spinner.text = 'Checking for existing installation...';
+ const existingInstall = await this.detector.detect(bmadDir);
+
+ if (existingInstall.installed && !config.force) {
+ spinner.stop();
+
+ console.log(chalk.yellow('\nÔÜá´©Å Existing BMAD installation detected'));
+ console.log(chalk.dim(` Location: ${bmadDir}`));
+ console.log(chalk.dim(` Version: ${existingInstall.version}`));
+
+ const { action } = await this.promptUpdateAction();
+ if (action === 'cancel') {
+ console.log('Installation cancelled.');
+ return { success: false, cancelled: true };
+ }
+
+ if (action === 'reinstall') {
+ // Warn about destructive operation
+ console.log(chalk.red.bold('\nÔÜá´©Å WARNING: This is a destructive operation!'));
+ console.log(chalk.red('All custom files and modifications in the bmad directory will be lost.'));
+
+ const inquirer = require('inquirer');
+ const { confirmReinstall } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'confirmReinstall',
+ message: chalk.yellow('Are you sure you want to delete and reinstall?'),
+ default: false,
+ },
+ ]);
+
+ if (!confirmReinstall) {
+ console.log('Installation cancelled.');
+ return { success: false, cancelled: true };
+ }
+
+ // Remember previously configured IDEs before deleting
+ config._previouslyConfiguredIdes = existingInstall.ides || [];
+
+ // Remove existing installation
+ await fs.remove(bmadDir);
+ console.log(chalk.green('Ô£ô Removed existing installation\n'));
+
+ // Mark this as a full reinstall so we re-collect IDE configurations
+ config._isFullReinstall = true;
+ } else if (action === 'update') {
+ // Store that we're updating for later processing
+ config._isUpdate = true;
+ config._existingInstall = existingInstall;
+
+ // Detect custom and modified files BEFORE updating (compare current files vs files-manifest.csv)
+ 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);
+
+ 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._modifiedFiles = modifiedFiles;
+
+ // If there are custom files, back them up temporarily
+ if (customFiles.length > 0) {
+ const tempBackupDir = path.join(projectDir, '.bmad-custom-backup-temp');
+ await fs.ensureDir(tempBackupDir);
+
+ spinner.start(`Backing up ${customFiles.length} custom files...`);
+ for (const customFile of customFiles) {
+ const relativePath = path.relative(bmadDir, customFile);
+ const backupPath = path.join(tempBackupDir, relativePath);
+ await fs.ensureDir(path.dirname(backupPath));
+ await fs.copy(customFile, backupPath);
+ }
+ spinner.succeed(`Backed up ${customFiles.length} custom files`);
+
+ config._tempBackupDir = tempBackupDir;
+ }
+
+ // For modified files, back them up to temp directory (will be restored as .bak files after install)
+ if (modifiedFiles.length > 0) {
+ const tempModifiedBackupDir = path.join(projectDir, '.bmad-modified-backup-temp');
+ 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...`);
+ for (const modifiedFile of modifiedFiles) {
+ const relativePath = path.relative(bmadDir, modifiedFile.path);
+ const tempBackupPath = path.join(tempModifiedBackupDir, relativePath);
+ console.log(chalk.dim(`DEBUG: Backing up ${relativePath} to temp`));
+ await fs.ensureDir(path.dirname(tempBackupPath));
+ await fs.copy(modifiedFile.path, tempBackupPath, { overwrite: true });
+ }
+ spinner.succeed(`Backed up ${modifiedFiles.length} modified files`);
+
+ config._tempModifiedBackupDir = tempModifiedBackupDir;
+ } else {
+ console.log(chalk.dim('DEBUG: No modified files detected'));
+ }
+ }
+ }
+
+ // Now collect tool configurations after we know if it's a reinstall
+ spinner.stop();
+ const toolSelection = await this.collectToolConfigurations(
+ path.resolve(config.directory),
+ config.modules,
+ config._isFullReinstall || false,
+ config._previouslyConfiguredIdes || [],
+ );
+
+ // Merge tool selection into config
+ config.ides = toolSelection.ides;
+ config.skipIde = toolSelection.skipIde;
+ const ideConfigurations = toolSelection.configurations;
+
+ spinner.start('Continuing installation...');
+
+ // Create bmad directory structure
+ spinner.text = 'Creating directory structure...';
+ await this.createDirectoryStructure(bmadDir);
+
+ // Resolve dependencies for selected modules
+ spinner.text = 'Resolving dependencies...';
+ const projectRoot = getProjectRoot();
+ const modulesToInstall = config.installCore ? ['core', ...config.modules] : config.modules;
+
+ // For dependency resolution, we need to pass the project root
+ const resolution = await this.dependencyResolver.resolve(projectRoot, config.modules || [], { verbose: config.verbose });
+
+ if (config.verbose) {
+ spinner.succeed('Dependencies resolved');
+ } else {
+ 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');
+ }
+
+ // Install modules with their dependencies
+ if (config.modules && config.modules.length > 0) {
+ for (const moduleName of config.modules) {
+ spinner.start(`Installing module: ${moduleName}...`);
+ await this.installModuleWithDependencies(moduleName, bmadDir, resolution.byModule[moduleName]);
+ spinner.succeed(`Module installed: ${moduleName}`);
+ }
+
+ // Install partial modules (only dependencies)
+ for (const [module, files] of Object.entries(resolution.byModule)) {
+ if (!config.modules.includes(module) && module !== 'core') {
+ const totalFiles = files.agents.length + files.tasks.length + files.templates.length + files.data.length + files.other.length;
+ if (totalFiles > 0) {
+ spinner.start(`Installing ${module} dependencies...`);
+ await this.installPartialModule(module, bmadDir, files);
+ spinner.succeed(`${module} dependencies installed`);
+ }
+ }
+ }
+ }
+
+ // Generate clean config.yaml files for each installed module
+ spinner.start('Generating module configurations...');
+ await this.generateModuleConfigs(bmadDir, moduleConfigs);
+ spinner.succeed('Module configurations generated');
+
+ // Create agent configuration files
+ // Note: Legacy createAgentConfigs removed - using YAML customize system instead
+ // Customize templates are now created in processAgentFiles when building YAML agents
+
+ // Pre-register manifest files that will be created (except files-manifest.csv to avoid recursion)
+ const cfgDir = path.join(bmadDir, '_cfg');
+ this.installedFiles.push(
+ path.join(cfgDir, 'manifest.yaml'),
+ path.join(cfgDir, 'workflow-manifest.csv'),
+ path.join(cfgDir, 'agent-manifest.csv'),
+ path.join(cfgDir, 'task-manifest.csv'),
+ );
+
+ // Generate CSV manifests for workflows, agents, tasks AND ALL FILES with hashes BEFORE IDE setup
+ spinner.start('Generating workflow and agent manifests...');
+ const manifestGen = new ManifestGenerator();
+ const manifestStats = await manifestGen.generateManifests(bmadDir, config.modules || [], this.installedFiles, {
+ ides: config.ides || [],
+ });
+
+ spinner.succeed(
+ `Manifests generated: ${manifestStats.workflows} workflows, ${manifestStats.agents} agents, ${manifestStats.tasks} tasks, ${manifestStats.files} files`,
+ );
+
+ // Configure IDEs and copy documentation
+ if (!config.skipIde && config.ides && config.ides.length > 0) {
+ spinner.start('Configuring IDEs...');
+
+ // Temporarily suppress console output if not verbose
+ const originalLog = console.log;
+ if (!config.verbose) {
+ console.log = () => {};
+ }
+
+ for (const ide of config.ides) {
+ spinner.text = `Configuring ${ide}...`;
+
+ // Pass pre-collected configuration to avoid re-prompting
+ await this.ideManager.setup(ide, projectDir, bmadDir, {
+ selectedModules: config.modules || [],
+ preCollectedConfig: ideConfigurations[ide] || null,
+ verbose: config.verbose,
+ });
+ }
+
+ // Restore console.log
+ console.log = originalLog;
+
+ spinner.succeed(`Configured ${config.ides.length} IDE${config.ides.length > 1 ? 's' : ''}`);
+
+ // Copy IDE-specific documentation
+ spinner.start('Copying IDE documentation...');
+ await this.copyIdeDocumentation(config.ides, bmadDir);
+ spinner.succeed('IDE documentation copied');
+ }
+
+ // Run module-specific installers after IDE setup
+ spinner.start('Running module-specific installers...');
+
+ // Run core module installer if core was installed
+ if (config.installCore || resolution.byModule.core) {
+ spinner.text = 'Running core module installer...';
+
+ await this.moduleManager.runModuleInstaller('core', bmadDir, {
+ installedIDEs: config.ides || [],
+ moduleConfig: moduleConfigs.core || {},
+ logger: {
+ log: (msg) => console.log(msg),
+ error: (msg) => console.error(msg),
+ warn: (msg) => console.warn(msg),
+ },
+ });
+ }
+
+ // Run installers for user-selected modules
+ if (config.modules && config.modules.length > 0) {
+ for (const moduleName of config.modules) {
+ spinner.text = `Running ${moduleName} module installer...`;
+
+ // Pass installed IDEs and module config to module installer
+ await this.moduleManager.runModuleInstaller(moduleName, bmadDir, {
+ installedIDEs: config.ides || [],
+ moduleConfig: moduleConfigs[moduleName] || {},
+ logger: {
+ log: (msg) => console.log(msg),
+ error: (msg) => console.error(msg),
+ warn: (msg) => console.warn(msg),
+ },
+ });
+ }
+ }
+
+ spinner.succeed('Module-specific installers completed');
+
+ // Note: Manifest files are already created by ManifestGenerator above
+ // No need to create legacy manifest.csv anymore
+
+ // If this was an update, restore custom files
+ let customFiles = [];
+ let modifiedFiles = [];
+ if (config._isUpdate) {
+ if (config._customFiles && config._customFiles.length > 0) {
+ spinner.start(`Restoring ${config._customFiles.length} custom files...`);
+
+ for (const originalPath of config._customFiles) {
+ const relativePath = path.relative(bmadDir, originalPath);
+ const backupPath = path.join(config._tempBackupDir, relativePath);
+
+ if (await fs.pathExists(backupPath)) {
+ await fs.ensureDir(path.dirname(originalPath));
+ await fs.copy(backupPath, originalPath, { overwrite: true });
+ }
+ }
+
+ // Clean up temp backup
+ if (config._tempBackupDir && (await fs.pathExists(config._tempBackupDir))) {
+ await fs.remove(config._tempBackupDir);
+ }
+
+ spinner.succeed(`Restored ${config._customFiles.length} custom files`);
+ customFiles = config._customFiles;
+ }
+
+ if (config._modifiedFiles && config._modifiedFiles.length > 0) {
+ modifiedFiles = config._modifiedFiles;
+
+ // Restore modified files as .bak files
+ if (config._tempModifiedBackupDir && (await fs.pathExists(config._tempModifiedBackupDir))) {
+ spinner.start(`Restoring ${modifiedFiles.length} modified files as .bak...`);
+
+ for (const modifiedFile of modifiedFiles) {
+ const relativePath = path.relative(bmadDir, modifiedFile.path);
+ const tempBackupPath = path.join(config._tempModifiedBackupDir, relativePath);
+ const bakPath = modifiedFile.path + '.bak';
+
+ if (await fs.pathExists(tempBackupPath)) {
+ await fs.ensureDir(path.dirname(bakPath));
+ await fs.copy(tempBackupPath, bakPath, { overwrite: true });
+ }
+ }
+
+ // Clean up temp backup
+ await fs.remove(config._tempModifiedBackupDir);
+
+ spinner.succeed(`Restored ${modifiedFiles.length} modified files as .bak`);
+ }
+ }
+ }
+
+ spinner.stop();
+
+ // Report custom and modified files if any were found
+ if (customFiles.length > 0) {
+ 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 file of customFiles) {
+ console.log(chalk.dim(` - ${path.relative(bmadDir, file)}`));
+ }
+ console.log('');
+ }
+
+ if (modifiedFiles.length > 0) {
+ console.log(chalk.yellow(`\nÔÜá´©Å Modified files detected: ${modifiedFiles.length}`));
+ console.log(chalk.dim('The following files were modified and backed up with .bak extension:\n'));
+ for (const file of modifiedFiles) {
+ console.log(chalk.dim(` - ${file.relativePath}  ${file.relativePath}.bak`));
+ }
+ console.log(chalk.dim('\nThese files have been updated with the new version.'));
+ console.log(chalk.dim('Review the .bak files to see your changes and merge if needed.\n'));
+ }
+
+ // Display completion message
+ const { UI } = require('../../../lib/ui');
+ const ui = new UI();
+ ui.showInstallSummary({
+ path: bmadDir,
+ modules: config.modules,
+ ides: config.ides,
+ customFiles: customFiles.length > 0 ? customFiles : undefined,
+ });
+
+ return { success: true, path: bmadDir, modules: config.modules, ides: config.ides };
+ } catch (error) {
+ spinner.fail('Installation failed');
+ throw error;
+ }
+ }
+
+ /**
+ * Update existing installation
+ */
+ async update(config) {
+ const spinner = ora('Checking installation...').start();
+
+ try {
+ const bmadDir = path.join(path.resolve(config.directory), 'bmad');
+ const existingInstall = await this.detector.detect(bmadDir);
+
+ if (!existingInstall.installed) {
+ spinner.fail('No BMAD installation found');
+ throw new Error(`No BMAD installation found at ${bmadDir}`);
+ }
+
+ spinner.text = 'Analyzing update requirements...';
+
+ // Compare versions and determine what needs updating
+ const currentVersion = existingInstall.version;
+ const newVersion = require(path.join(getProjectRoot(), 'package.json')).version;
+
+ if (config.dryRun) {
+ spinner.stop();
+ console.log(chalk.cyan('\n­ƒöì Update Preview (Dry Run)\n'));
+ console.log(chalk.bold('Current version:'), currentVersion);
+ console.log(chalk.bold('New version:'), newVersion);
+ console.log(chalk.bold('Core:'), existingInstall.hasCore ? 'Will be updated' : 'Not installed');
+
+ if (existingInstall.modules.length > 0) {
+ console.log(chalk.bold('\nModules to update:'));
+ for (const mod of existingInstall.modules) {
+ console.log(` - ${mod.id}`);
+ }
+ }
+ return;
+ }
+
+ // Perform actual update
+ if (existingInstall.hasCore) {
+ spinner.text = 'Updating core...';
+ await this.updateCore(bmadDir, config.force);
+ }
+
+ for (const module of existingInstall.modules) {
+ spinner.text = `Updating module: ${module.id}...`;
+ await this.moduleManager.update(module.id, bmadDir, config.force);
+ }
+
+ // Update manifest
+ spinner.text = 'Updating manifest...';
+ await this.manifest.update(bmadDir, {
+ version: newVersion,
+ updateDate: new Date().toISOString(),
+ });
+
+ spinner.succeed('Update complete');
+ return { success: true };
+ } catch (error) {
+ spinner.fail('Update failed');
+ throw error;
+ }
+ }
+
+ /**
+ * Get installation status
+ */
+ async getStatus(directory) {
+ const bmadDir = path.join(path.resolve(directory), 'bmad');
+ return await this.detector.detect(bmadDir);
+ }
+
+ /**
+ * Get available modules
+ */
+ async getAvailableModules() {
+ return await this.moduleManager.listAvailable();
+ }
+
+ /**
+ * Uninstall BMAD
+ */
+ async uninstall(directory) {
+ const bmadDir = path.join(path.resolve(directory), 'bmad');
+
+ if (await fs.pathExists(bmadDir)) {
+ await fs.remove(bmadDir);
+ }
+
+ // Clean up IDE configurations
+ await this.ideManager.cleanup(path.resolve(directory));
+
+ return { success: true };
+ }
+
+ /**
+ * Private: Create directory structure
+ */
+ async createDirectoryStructure(bmadDir) {
+ await fs.ensureDir(bmadDir);
+ await fs.ensureDir(path.join(bmadDir, '_cfg'));
+ await fs.ensureDir(path.join(bmadDir, '_cfg', 'agents'));
+ }
+
+ /**
+ * Generate clean config.yaml files for each installed module
+ * @param {string} bmadDir - BMAD installation directory
+ * @param {Object} moduleConfigs - Collected configuration values
+ */
+ async generateModuleConfigs(bmadDir, moduleConfigs) {
+ const yaml = require('js-yaml');
+
+ // Extract core config values to share with other modules
+ const coreConfig = moduleConfigs.core || {};
+
+ // Get all installed module directories
+ const entries = await fs.readdir(bmadDir, { withFileTypes: true });
+ const installedModules = entries
+ .filter((entry) => entry.isDirectory() && entry.name !== '_cfg' && entry.name !== 'docs')
+ .map((entry) => entry.name);
+
+ // Generate config.yaml for each installed module
+ for (const moduleName of installedModules) {
+ const modulePath = path.join(bmadDir, moduleName);
+
+ // Get module-specific config or use empty object if none
+ const config = moduleConfigs[moduleName] || {};
+
+ if (await fs.pathExists(modulePath)) {
+ const configPath = path.join(modulePath, 'config.yaml');
+
+ // Create header
+ const packageJson = require(path.join(getProjectRoot(), 'package.json'));
+ const header = `# ${moduleName.toUpperCase()} Module Configuration
+# Generated by BMAD installer
+# Version: ${packageJson.version}
+# Date: ${new Date().toISOString()}
+
+`;
+
+ // For non-core modules, add core config values directly
+ let finalConfig = { ...config };
+ let coreSection = '';
+
+ if (moduleName !== 'core' && coreConfig && Object.keys(coreConfig).length > 0) {
+ // Add core values directly to the module config
+ // These will be available for reference in the module
+ finalConfig = {
+ ...config,
+ ...coreConfig, // Spread core config values directly into the module config
+ };
+
+ // Create a comment section to identify core values
+ coreSection = '\n# Core Configuration Values\n';
+ }
+
+ // Convert config to YAML
+ let yamlContent = yaml.dump(finalConfig, {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false,
+ });
+
+ // If we have core values, reorganize the YAML to group them with their comment
+ if (coreSection && moduleName !== 'core') {
+ // Split the YAML into lines
+ const lines = yamlContent.split('\n');
+ const moduleConfigLines = [];
+ const coreConfigLines = [];
+
+ // Separate module-specific and core config lines
+ for (const line of lines) {
+ const key = line.split(':')[0].trim();
+ if (Object.prototype.hasOwnProperty.call(coreConfig, key)) {
+ coreConfigLines.push(line);
+ } else {
+ moduleConfigLines.push(line);
+ }
+ }
+
+ // Rebuild YAML with module config first, then core config with comment
+ yamlContent = moduleConfigLines.join('\n');
+ if (coreConfigLines.length > 0) {
+ yamlContent += coreSection + coreConfigLines.join('\n');
+ }
+ }
+
+ // Write the clean config file
+ await fs.writeFile(configPath, header + yamlContent, 'utf8');
+
+ // Track the config file in installedFiles
+ this.installedFiles.push(configPath);
+ }
+ }
+ }
+
+ /**
+ * Install core with resolved dependencies
+ * @param {string} bmadDir - BMAD installation directory
+ * @param {Object} coreFiles - Core files to install
+ */
+ async installCoreWithDependencies(bmadDir, coreFiles) {
+ const sourcePath = getModulePath('core');
+ const targetPath = path.join(bmadDir, 'core');
+
+ // Install full core
+ await this.installCore(bmadDir);
+
+ // If there are specific dependency files, ensure they're included
+ if (coreFiles) {
+ // Already handled by installCore for core module
+ }
+ }
+
+ /**
+ * Install module with resolved dependencies
+ * @param {string} moduleName - Module name
+ * @param {string} bmadDir - BMAD installation directory
+ * @param {Object} moduleFiles - Module files to install
+ */
+ async installModuleWithDependencies(moduleName, bmadDir, moduleFiles) {
+ // Use existing module manager for full installation with file tracking
+ // Note: Module-specific installers are called separately after IDE setup
+ await this.moduleManager.install(
+ moduleName,
+ bmadDir,
+ (filePath) => {
+ this.installedFiles.push(filePath);
+ },
+ {
+ skipModuleInstaller: true, // We'll run it later after IDE setup
+ },
+ );
+
+ // Process agent files to build YAML agents and create customize templates
+ const modulePath = path.join(bmadDir, moduleName);
+ await this.processAgentFiles(modulePath, moduleName);
+
+ // Dependencies are already included in full module install
+ }
+
+ /**
+ * Install partial module (only dependencies needed by other modules)
+ */
+ async installPartialModule(moduleName, bmadDir, files) {
+ const sourceBase = getModulePath(moduleName);
+ const targetBase = path.join(bmadDir, moduleName);
+
+ // Create module directory
+ await fs.ensureDir(targetBase);
+
+ // Copy only the required dependency files
+ if (files.agents && files.agents.length > 0) {
+ const agentsDir = path.join(targetBase, 'agents');
+ await fs.ensureDir(agentsDir);
+
+ for (const agentPath of files.agents) {
+ const fileName = path.basename(agentPath);
+ const sourcePath = path.join(sourceBase, 'agents', fileName);
+ const targetPath = path.join(agentsDir, fileName);
+
+ if (await fs.pathExists(sourcePath)) {
+ await fs.copy(sourcePath, targetPath);
+ this.installedFiles.push(targetPath);
+ }
+ }
+ }
+
+ if (files.tasks && files.tasks.length > 0) {
+ const tasksDir = path.join(targetBase, 'tasks');
+ await fs.ensureDir(tasksDir);
+
+ for (const taskPath of files.tasks) {
+ const fileName = path.basename(taskPath);
+ const sourcePath = path.join(sourceBase, 'tasks', fileName);
+ const targetPath = path.join(tasksDir, fileName);
+
+ if (await fs.pathExists(sourcePath)) {
+ await fs.copy(sourcePath, targetPath);
+ this.installedFiles.push(targetPath);
+ }
+ }
+ }
+
+ if (files.templates && files.templates.length > 0) {
+ const templatesDir = path.join(targetBase, 'templates');
+ await fs.ensureDir(templatesDir);
+
+ for (const templatePath of files.templates) {
+ const fileName = path.basename(templatePath);
+ const sourcePath = path.join(sourceBase, 'templates', fileName);
+ const targetPath = path.join(templatesDir, fileName);
+
+ if (await fs.pathExists(sourcePath)) {
+ await fs.copy(sourcePath, targetPath);
+ this.installedFiles.push(targetPath);
+ }
+ }
+ }
+
+ if (files.data && files.data.length > 0) {
+ for (const dataPath of files.data) {
+ // Preserve directory structure for data files
+ const relative = path.relative(sourceBase, dataPath);
+ const targetPath = path.join(targetBase, relative);
+
+ await fs.ensureDir(path.dirname(targetPath));
+
+ if (await fs.pathExists(dataPath)) {
+ await fs.copy(dataPath, targetPath);
+ this.installedFiles.push(targetPath);
+ }
+ }
+ }
+
+ // Create a marker file to indicate this is a partial installation
+ const markerPath = path.join(targetBase, '.partial');
+ await fs.writeFile(
+ markerPath,
+ `This module contains only dependencies required by other modules.\nInstalled: ${new Date().toISOString()}\n`,
+ );
+ }
+
+ /**
+ * Private: Install core
+ * @param {string} bmadDir - BMAD installation directory
+ */
+ async installCore(bmadDir) {
+ const sourcePath = getModulePath('core');
+ const targetPath = path.join(bmadDir, 'core');
+
+ // Copy core files with filtering for localskip agents
+ await this.copyDirectoryWithFiltering(sourcePath, targetPath);
+
+ // Process agent files to inject activation block
+ await this.processAgentFiles(targetPath, 'core');
+ }
+
+ /**
+ * Copy directory with filtering for localskip agents
+ * @param {string} sourcePath - Source directory path
+ * @param {string} targetPath - Target directory path
+ */
+ async copyDirectoryWithFiltering(sourcePath, targetPath) {
+ // Get all files in source directory
+ const files = await this.getFileList(sourcePath);
+
+ for (const file of files) {
+ // Skip config.yaml templates - we'll generate clean ones with actual values
+ if (file === 'config.yaml' || file.endsWith('/config.yaml')) {
+ continue;
+ }
+
+ const sourceFile = path.join(sourcePath, file);
+ const targetFile = path.join(targetPath, file);
+
+ // Check if this is an agent file
+ if (file.includes('agents/') && file.endsWith('.md')) {
+ // Read the file to check for localskip
+ const content = await fs.readFile(sourceFile, 'utf8');
+
+ // Check for localskip="true" in the agent tag
+ const agentMatch = content.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
+ if (agentMatch) {
+ console.log(chalk.dim(` Skipping web-only agent: ${path.basename(file)}`));
+ continue; // Skip this agent
+ }
+ }
+
+ // Copy the file
+ await fs.ensureDir(path.dirname(targetFile));
+ await fs.copy(sourceFile, targetFile, { overwrite: true });
+
+ // Track the installed file
+ this.installedFiles.push(targetFile);
+ }
+ }
+
+ /**
+ * Get list of all files in a directory recursively
+ * @param {string} dir - Directory path
+ * @param {string} baseDir - Base directory for relative paths
+ * @returns {Array} List of relative file paths
+ */
+ async getFileList(dir, baseDir = dir) {
+ const files = [];
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ // Skip _module-installer directories
+ if (entry.name === '_module-installer') {
+ continue;
+ }
+ const subFiles = await this.getFileList(fullPath, baseDir);
+ files.push(...subFiles);
+ } else {
+ files.push(path.relative(baseDir, fullPath));
+ }
+ }
+
+ return files;
+ }
+
+ /**
+ * Process agent files to build YAML agents and inject activation blocks
+ * @param {string} modulePath - Path to module in bmad/ installation
+ * @param {string} moduleName - Module name
+ */
+ async processAgentFiles(modulePath, moduleName) {
+ const agentsPath = path.join(modulePath, 'agents');
+
+ // Check if agents directory exists
+ if (!(await fs.pathExists(agentsPath))) {
+ return; // No agents to process
+ }
+
+ // Determine project directory (parent of bmad/ directory)
+ const bmadDir = path.dirname(modulePath);
+ const projectDir = path.dirname(bmadDir);
+ const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
+
+ // Ensure _cfg/agents directory exists
+ await fs.ensureDir(cfgAgentsDir);
+
+ // Get all agent files
+ const agentFiles = await fs.readdir(agentsPath);
+
+ for (const agentFile of agentFiles) {
+ // Handle YAML agents - build them to .md
+ if (agentFile.endsWith('.agent.yaml')) {
+ const agentName = agentFile.replace('.agent.yaml', '');
+ const yamlPath = path.join(agentsPath, agentFile);
+ const mdPath = path.join(agentsPath, `${agentName}.md`);
+ const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
+
+ // Create customize template if it doesn't exist
+ if (!(await fs.pathExists(customizePath))) {
+ const genericTemplatePath = getSourcePath('utility', 'templates', 'agent.customize.template.yaml');
+ if (await fs.pathExists(genericTemplatePath)) {
+ await fs.copy(genericTemplatePath, customizePath);
+ console.log(chalk.dim(` Created customize: ${moduleName}-${agentName}.customize.yaml`));
+ }
+ }
+
+ // Build YAML + customize to .md
+ const customizeExists = await fs.pathExists(customizePath);
+ const xmlContent = await this.xmlHandler.buildFromYaml(yamlPath, customizeExists ? customizePath : null, {
+ includeMetadata: true,
+ });
+
+ // DO NOT replace {project-root} - LLMs understand this placeholder at runtime
+ // const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
+
+ // Write the built .md file to bmad/{module}/agents/
+ await fs.writeFile(mdPath, xmlContent, 'utf8');
+ this.installedFiles.push(mdPath);
+
+ // Remove the source YAML file - we can regenerate from installer source if needed
+ await fs.remove(yamlPath);
+
+ console.log(chalk.dim(` Built agent: ${agentName}.md`));
+ }
+ // Handle legacy .md agents - inject activation if needed
+ else if (agentFile.endsWith('.md')) {
+ const agentPath = path.join(agentsPath, agentFile);
+ let content = await fs.readFile(agentPath, 'utf8');
+
+ // Check if content has agent XML and no activation block
+ if (content.includes('<agent') && !content.includes('<activation')) {
+ // Inject the activation block using XML handler
+ content = this.xmlHandler.injectActivationSimple(content);
+ await fs.writeFile(agentPath, content, 'utf8');
+ }
+ }
+ }
+ }
+
+ /**
+ * Build standalone agents in bmad/agents/ directory
+ * @param {string} bmadDir - Path to bmad directory
+ * @param {string} projectDir - Path to project directory
+ */
+ async buildStandaloneAgents(bmadDir, projectDir) {
+ const standaloneAgentsPath = path.join(bmadDir, 'agents');
+ const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
+
+ // Check if standalone agents directory exists
+ if (!(await fs.pathExists(standaloneAgentsPath))) {
+ return;
+ }
+
+ // Get all subdirectories in agents/
+ const agentDirs = await fs.readdir(standaloneAgentsPath, { withFileTypes: true });
+
+ for (const agentDir of agentDirs) {
+ if (!agentDir.isDirectory()) continue;
+
+ const agentDirPath = path.join(standaloneAgentsPath, agentDir.name);
+
+ // Find any .agent.yaml file in the directory
+ const files = await fs.readdir(agentDirPath);
+ const yamlFile = files.find((f) => f.endsWith('.agent.yaml'));
+
+ if (!yamlFile) continue;
+
+ const agentName = path.basename(yamlFile, '.agent.yaml');
+ const sourceYamlPath = path.join(agentDirPath, yamlFile);
+ const targetMdPath = path.join(agentDirPath, `${agentName}.md`);
+ const customizePath = path.join(cfgAgentsDir, `${agentName}.customize.yaml`);
+
+ // Check for customizations
+ const customizeExists = await fs.pathExists(customizePath);
+ let customizedFields = [];
+
+ if (customizeExists) {
+ const customizeContent = await fs.readFile(customizePath, 'utf8');
+ const yaml = require('js-yaml');
+ const customizeYaml = yaml.load(customizeContent);
+
+ // Detect what fields are customized (similar to rebuildAgentFiles)
+ if (customizeYaml) {
+ if (customizeYaml.persona) {
+ for (const [key, value] of Object.entries(customizeYaml.persona)) {
+ if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
+ customizedFields.push(`persona.${key}`);
+ }
+ }
+ }
+ if (customizeYaml.agent?.metadata) {
+ for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
+ if (value !== '' && value !== null) {
+ customizedFields.push(`metadata.${key}`);
+ }
+ }
+ }
+ if (customizeYaml.critical_actions && customizeYaml.critical_actions.length > 0) {
+ customizedFields.push('critical_actions');
+ }
+ if (customizeYaml.menu && customizeYaml.menu.length > 0) {
+ customizedFields.push('menu');
+ }
+ }
+ }
+
+ // Build YAML to XML .md
+ const xmlContent = await this.xmlHandler.buildFromYaml(sourceYamlPath, customizeExists ? customizePath : null, {
+ includeMetadata: true,
+ });
+
+ // DO NOT replace {project-root} - LLMs understand this placeholder at runtime
+ // const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
+
+ // Write the built .md file
+ await fs.writeFile(targetMdPath, xmlContent, 'utf8');
+
+ // Display result
+ if (customizedFields.length > 0) {
+ console.log(chalk.dim(` Built standalone agent: ${agentName}.md `) + chalk.yellow(`(customized: ${customizedFields.join(', ')})`));
+ } else {
+ console.log(chalk.dim(` Built standalone agent: ${agentName}.md`));
+ }
+ }
+ }
+
+ /**
+ * Rebuild agent files from installer source (for compile command)
+ * @param {string} modulePath - Path to module in bmad/ installation
+ * @param {string} moduleName - Module name
+ */
+ async rebuildAgentFiles(modulePath, moduleName) {
+ // Get source agents directory from installer
+ const sourceAgentsPath =
+ moduleName === 'core' ? path.join(getModulePath('core'), 'agents') : path.join(getSourcePath(`modules/${moduleName}`), 'agents');
+
+ if (!(await fs.pathExists(sourceAgentsPath))) {
+ return; // No source agents to rebuild
+ }
+
+ // Determine project directory (parent of bmad/ directory)
+ const bmadDir = path.dirname(modulePath);
+ const projectDir = path.dirname(bmadDir);
+ const cfgAgentsDir = path.join(bmadDir, '_cfg', 'agents');
+ const targetAgentsPath = path.join(modulePath, 'agents');
+
+ // Ensure target directory exists
+ await fs.ensureDir(targetAgentsPath);
+
+ // Get all YAML agent files from source
+ const sourceFiles = await fs.readdir(sourceAgentsPath);
+
+ for (const file of sourceFiles) {
+ if (file.endsWith('.agent.yaml')) {
+ const agentName = file.replace('.agent.yaml', '');
+ const sourceYamlPath = path.join(sourceAgentsPath, file);
+ const targetMdPath = path.join(targetAgentsPath, `${agentName}.md`);
+ const customizePath = path.join(cfgAgentsDir, `${moduleName}-${agentName}.customize.yaml`);
+
+ // Check for customizations
+ const customizeExists = await fs.pathExists(customizePath);
+ let customizedFields = [];
+
+ if (customizeExists) {
+ const customizeContent = await fs.readFile(customizePath, 'utf8');
+ const yaml = require('js-yaml');
+ const customizeYaml = yaml.load(customizeContent);
+
+ // Detect what fields are customized
+ if (customizeYaml) {
+ if (customizeYaml.persona) {
+ for (const [key, value] of Object.entries(customizeYaml.persona)) {
+ if (value !== '' && value !== null && !(Array.isArray(value) && value.length === 0)) {
+ customizedFields.push(`persona.${key}`);
+ }
+ }
+ }
+ if (customizeYaml.agent?.metadata) {
+ for (const [key, value] of Object.entries(customizeYaml.agent.metadata)) {
+ if (value !== '' && value !== null) {
+ customizedFields.push(`metadata.${key}`);
+ }
+ }
+ }
+ if (customizeYaml.critical_actions && customizeYaml.critical_actions.length > 0) {
+ customizedFields.push('critical_actions');
+ }
+ if (customizeYaml.memories && customizeYaml.memories.length > 0) {
+ customizedFields.push('memories');
+ }
+ if (customizeYaml.menu && customizeYaml.menu.length > 0) {
+ customizedFields.push('menu');
+ }
+ if (customizeYaml.prompts && customizeYaml.prompts.length > 0) {
+ customizedFields.push('prompts');
+ }
+ }
+ }
+
+ // Build YAML + customize to .md
+ const xmlContent = await this.xmlHandler.buildFromYaml(sourceYamlPath, customizeExists ? customizePath : null, {
+ includeMetadata: true,
+ });
+
+ // DO NOT replace {project-root} - LLMs understand this placeholder at runtime
+ // const processedContent = xmlContent.replaceAll('{project-root}', projectDir);
+
+ // Write the rebuilt .md file
+ await fs.writeFile(targetMdPath, xmlContent, 'utf8');
+
+ // Display result with customizations if any
+ if (customizedFields.length > 0) {
+ console.log(chalk.dim(` Rebuilt agent: ${agentName}.md `) + chalk.yellow(`(customized: ${customizedFields.join(', ')})`));
+ } else {
+ console.log(chalk.dim(` Rebuilt agent: ${agentName}.md`));
+ }
+ }
+ }
+ }
+
+ /**
+ * Compile/rebuild all agents and tasks for quick updates
+ * @param {Object} config - Compilation configuration
+ * @returns {Object} Compilation results
+ */
+ async compileAgents(config) {
+ const ora = require('ora');
+ const spinner = ora('Starting agent compilation...').start();
+
+ try {
+ const projectDir = path.resolve(config.directory);
+ const bmadDir = path.join(projectDir, 'bmad');
+
+ // Check if bmad directory exists
+ if (!(await fs.pathExists(bmadDir))) {
+ spinner.fail('No BMAD installation found');
+ throw new Error(`BMAD not installed at ${bmadDir}`);
+ }
+
+ let agentCount = 0;
+ let taskCount = 0;
+
+ // Process all modules in bmad directory
+ spinner.text = 'Rebuilding agent files...';
+ const entries = await fs.readdir(bmadDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ if (entry.isDirectory() && entry.name !== '_cfg' && entry.name !== 'docs') {
+ const modulePath = path.join(bmadDir, entry.name);
+
+ // Special handling for standalone agents in bmad/agents/ directory
+ if (entry.name === 'agents') {
+ spinner.text = 'Building standalone agents...';
+ await this.buildStandaloneAgents(bmadDir, projectDir);
+
+ // Count standalone agents
+ const standaloneAgentsPath = path.join(bmadDir, 'agents');
+ const standaloneAgentDirs = await fs.readdir(standaloneAgentsPath, { withFileTypes: true });
+ for (const agentDir of standaloneAgentDirs) {
+ if (agentDir.isDirectory()) {
+ const agentDirPath = path.join(standaloneAgentsPath, agentDir.name);
+ const agentFiles = await fs.readdir(agentDirPath);
+ agentCount += agentFiles.filter((f) => f.endsWith('.md') && !f.endsWith('.agent.yaml')).length;
+ }
+ }
+ } else {
+ // Rebuild module agents from installer source
+ const agentsPath = path.join(modulePath, 'agents');
+ if (await fs.pathExists(agentsPath)) {
+ await this.rebuildAgentFiles(modulePath, entry.name);
+ const agentFiles = await fs.readdir(agentsPath);
+ agentCount += agentFiles.filter((f) => f.endsWith('.md')).length;
+ }
+
+ // Count tasks (already built)
+ const tasksPath = path.join(modulePath, 'tasks');
+ if (await fs.pathExists(tasksPath)) {
+ const taskFiles = await fs.readdir(tasksPath);
+ taskCount += taskFiles.filter((f) => f.endsWith('.md')).length;
+ }
+ }
+ }
+ }
+
+ // Regenerate manifests after compilation
+ spinner.start('Regenerating manifests...');
+ const installedModules = entries
+ .filter((e) => e.isDirectory() && e.name !== '_cfg' && e.name !== 'docs' && e.name !== 'agents' && e.name !== 'core')
+ .map((e) => e.name);
+ const manifestGen = new ManifestGenerator();
+
+ // Get existing IDE list from manifest
+ const existingManifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
+ let existingIdes = [];
+ if (await fs.pathExists(existingManifestPath)) {
+ const manifestContent = await fs.readFile(existingManifestPath, 'utf8');
+ const yaml = require('js-yaml');
+ const manifest = yaml.load(manifestContent);
+ existingIdes = manifest.ides || [];
+ }
+
+ await manifestGen.generateManifests(bmadDir, installedModules, [], {
+ ides: existingIdes,
+ });
+ spinner.succeed('Manifests regenerated');
+
+ // Ask for IDE to update
+ spinner.stop();
+ // Note: UI lives in tools/cli/lib/ui.js; from installers/lib/core use '../../../lib/ui'
+ const { UI } = require('../../../lib/ui');
+ const ui = new UI();
+ const toolConfig = await ui.promptToolSelection(projectDir, []);
+
+ if (!toolConfig.skipIde && toolConfig.ides && toolConfig.ides.length > 0) {
+ spinner.start('Updating IDE configurations...');
+
+ for (const ide of toolConfig.ides) {
+ spinner.text = `Updating ${ide}...`;
+ await this.ideManager.setup(ide, projectDir, bmadDir, {
+ selectedModules: installedModules,
+ skipModuleInstall: true, // Skip module installation, just update IDE files
+ verbose: config.verbose,
+ });
+ }
+
+ spinner.succeed('IDE configurations updated');
+ }
+
+ return { agentCount, taskCount };
+ } catch (error) {
+ spinner.fail('Compilation failed');
+ throw error;
+ }
+ }
+
+ /**
+ * Private: Update core
+ */
+ async updateCore(bmadDir, force = false) {
+ const sourcePath = getModulePath('core');
+ const targetPath = path.join(bmadDir, 'core');
+
+ if (force) {
+ await fs.remove(targetPath);
+ await this.installCore(bmadDir);
+ } else {
+ // Selective update - preserve user modifications
+ await this.fileOps.syncDirectory(sourcePath, targetPath);
+ }
+ }
+
+ /**
+ * Private: Prompt for update action
+ */
+ async promptUpdateAction() {
+ const inquirer = require('inquirer');
+ return await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'action',
+ message: 'What would you like to do?',
+ choices: [
+ { name: 'Update existing installation', value: 'update' },
+ { name: 'Remove and reinstall', value: 'reinstall' },
+ { name: 'Cancel', value: 'cancel' },
+ ],
+ },
+ ]);
+ }
+
+ /**
+ * Handle legacy BMAD v4 migration with automatic backup
+ * @param {string} projectDir - Project directory
+ * @param {Object} legacyV4 - Legacy V4 detection result with offenders array
+ */
+ async handleLegacyV4Migration(projectDir, legacyV4) {
+ console.log(chalk.yellow.bold('\nÔÜá´©Å Legacy BMAD v4 detected'));
+ console.log(chalk.dim('The installer found legacy artefacts in your project.\n'));
+
+ // Separate .bmad* folders (auto-backup) from other offending paths (manual cleanup)
+ const bmadFolders = legacyV4.offenders.filter((p) => {
+ const name = path.basename(p);
+ return name.startsWith('.bmad'); // Only dot-prefixed folders get auto-backed up
+ });
+ const otherOffenders = legacyV4.offenders.filter((p) => {
+ const name = path.basename(p);
+ return !name.startsWith('.bmad'); // Everything else is manual cleanup
+ });
+
+ const inquirer = require('inquirer');
+
+ // Show warning for other offending paths FIRST
+ if (otherOffenders.length > 0) {
+ console.log(chalk.yellow('ÔÜá´©Å Recommended cleanup:'));
+ console.log(chalk.dim('It is recommended to remove the following items before proceeding:\n'));
+ for (const p of otherOffenders) console.log(chalk.dim(` - ${p}`));
+
+ console.log(chalk.cyan('\nCleanup commands you can copy/paste:'));
+ console.log(chalk.dim('macOS/Linux:'));
+ for (const p of otherOffenders) console.log(chalk.dim(` rm -rf '${p}'`));
+ console.log(chalk.dim('Windows:'));
+ for (const p of otherOffenders) console.log(chalk.dim(` rmdir /S /Q "${p}"`));
+
+ const { cleanedUp } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'cleanedUp',
+ message: 'Have you completed the recommended cleanup? (You can proceed without it, but it is recommended)',
+ default: false,
+ },
+ ]);
+
+ if (cleanedUp) {
+ console.log(chalk.green('Ô£ô Cleanup acknowledged\n'));
+ } else {
+ console.log(chalk.yellow('ÔÜá´©Å Proceeding without recommended cleanup\n'));
+ }
+ }
+
+ // Handle .bmad* folders with automatic backup
+ if (bmadFolders.length > 0) {
+ console.log(chalk.cyan('The following legacy folders will be moved to v4-backup:'));
+ for (const p of bmadFolders) console.log(chalk.dim(` - ${p}`));
+
+ const { proceed } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'proceed',
+ message: 'Proceed with backing up legacy v4 folders?',
+ default: true,
+ },
+ ]);
+
+ if (proceed) {
+ const backupDir = path.join(projectDir, 'v4-backup');
+ await fs.ensureDir(backupDir);
+
+ for (const folder of bmadFolders) {
+ const folderName = path.basename(folder);
+ const backupPath = path.join(backupDir, folderName);
+
+ // If backup already exists, add timestamp
+ let finalBackupPath = backupPath;
+ if (await fs.pathExists(backupPath)) {
+ const timestamp = new Date().toISOString().replaceAll(/[:.]/g, '-').split('T')[0];
+ finalBackupPath = path.join(backupDir, `${folderName}-${timestamp}`);
+ }
+
+ await fs.move(folder, finalBackupPath, { overwrite: false });
+ console.log(chalk.green(`Ô£ô Moved ${folderName} to ${path.relative(projectDir, finalBackupPath)}`));
+ }
+ } else {
+ throw new Error('Installation cancelled by user');
+ }
+ }
+ }
+
+ /**
+ * Read files-manifest.csv
+ * @param {string} bmadDir - BMAD installation directory
+ * @returns {Array} Array of file entries from files-manifest.csv
+ */
+ async readFilesManifest(bmadDir) {
+ const filesManifestPath = path.join(bmadDir, '_cfg', 'files-manifest.csv');
+ if (!(await fs.pathExists(filesManifestPath))) {
+ return [];
+ }
+
+ try {
+ const content = await fs.readFile(filesManifestPath, 'utf8');
+ const lines = content.split('\n');
+ const files = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ // Skip header
+ const line = lines[i].trim();
+ if (!line) continue;
+
+ // Parse CSV line properly handling quoted values
+ const parts = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (const char of line) {
+ if (char === '"') {
+ inQuotes = !inQuotes;
+ } else if (char === ',' && !inQuotes) {
+ parts.push(current);
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+ parts.push(current); // Add last part
+
+ if (parts.length >= 4) {
+ files.push({
+ type: parts[0],
+ name: parts[1],
+ module: parts[2],
+ path: parts[3],
+ hash: parts[4] || null, // Hash may not exist in old manifests
+ });
+ }
+ }
+
+ return files;
+ } catch (error) {
+ console.warn('Warning: Could not read files-manifest.csv:', error.message);
+ return [];
+ }
+ }
+
+ /**
+ * Detect custom and modified files
+ * @param {string} bmadDir - BMAD installation directory
+ * @param {Array} existingFilesManifest - Previous files from files-manifest.csv
+ * @returns {Object} Object with customFiles and modifiedFiles arrays
+ */
+ async detectCustomFiles(bmadDir, existingFilesManifest) {
+ const customFiles = [];
+ const modifiedFiles = [];
+
+ // Check if the manifest has hashes - if not, we can't detect modifications
+ let manifestHasHashes = false;
+ if (existingFilesManifest && existingFilesManifest.length > 0) {
+ manifestHasHashes = existingFilesManifest.some((f) => f.hash);
+ }
+
+ // Build map of previously installed files from files-manifest.csv with their hashes
+ const installedFilesMap = new Map();
+ for (const fileEntry of existingFilesManifest) {
+ if (fileEntry.path) {
+ // Files in manifest are stored as relative paths starting with 'bmad/'
+ // Convert to absolute path
+ const relativePath = fileEntry.path.startsWith('bmad/') ? fileEntry.path.slice(5) : fileEntry.path;
+ const absolutePath = path.join(bmadDir, relativePath);
+ installedFilesMap.set(path.normalize(absolutePath), {
+ hash: fileEntry.hash,
+ relativePath: relativePath,
+ });
+ }
+ }
+
+ // Recursively scan bmadDir for all files
+ const scanDirectory = async (dir) => {
+ try {
+ const entries = await fs.readdir(dir, { withFileTypes: true });
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+
+ if (entry.isDirectory()) {
+ // Skip certain directories
+ if (entry.name === 'node_modules' || entry.name === '.git') {
+ continue;
+ }
+ await scanDirectory(fullPath);
+ } else if (entry.isFile()) {
+ const normalizedPath = path.normalize(fullPath);
+ const fileInfo = installedFilesMap.get(normalizedPath);
+
+ // Skip certain system files that are auto-generated
+ const relativePath = path.relative(bmadDir, fullPath);
+ const fileName = path.basename(fullPath);
+
+ // Skip _cfg directory - system files
+ if (relativePath.startsWith('_cfg/') || relativePath.startsWith('_cfg\\')) {
+ continue;
+ }
+
+ // Skip config.yaml files - these are regenerated on each install/update
+ // Users should use _cfg/agents/ override files instead
+ if (fileName === 'config.yaml') {
+ continue;
+ }
+
+ if (!fileInfo) {
+ // File not in manifest = custom file
+ customFiles.push(fullPath);
+ } else if (manifestHasHashes && fileInfo.hash) {
+ // File in manifest with hash - check if it was modified
+ const currentHash = await this.manifest.calculateFileHash(fullPath);
+ if (currentHash && currentHash !== fileInfo.hash) {
+ // Hash changed = file was modified
+ modifiedFiles.push({
+ path: fullPath,
+ relativePath: fileInfo.relativePath,
+ });
+ }
+ }
+ // If manifest doesn't have hashes, we can't detect modifications
+ // so we just skip files that are in the manifest
+ }
+ }
+ } catch {
+ // Ignore errors scanning directories
+ }
+ };
+
+ await scanDirectory(bmadDir);
+ return { customFiles, modifiedFiles };
+ }
+
+ /**
+ * Private: Create agent configuration files
+ * @param {string} bmadDir - BMAD installation directory
+ * @param {Object} userInfo - User information including name and language
+ */
+ async createAgentConfigs(bmadDir, userInfo = null) {
+ const agentConfigDir = path.join(bmadDir, '_cfg', 'agents');
+ await fs.ensureDir(agentConfigDir);
+
+ // Get all agents from all modules
+ const agents = [];
+ const agentDetails = []; // For manifest generation
+
+ // Check modules for agents (including core)
+ const entries = await fs.readdir(bmadDir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isDirectory() && entry.name !== '_cfg') {
+ const moduleAgentsPath = path.join(bmadDir, entry.name, 'agents');
+ if (await fs.pathExists(moduleAgentsPath)) {
+ const agentFiles = await fs.readdir(moduleAgentsPath);
+ for (const agentFile of agentFiles) {
+ if (agentFile.endsWith('.md')) {
+ const agentPath = path.join(moduleAgentsPath, agentFile);
+ const agentContent = await fs.readFile(agentPath, 'utf8');
+
+ // Skip agents with localskip="true"
+ const hasLocalSkip = agentContent.match(/<agent[^>]*\slocalskip="true"[^>]*>/);
+ if (hasLocalSkip) {
+ continue; // Skip this agent - it should not have been installed
+ }
+
+ const agentName = path.basename(agentFile, '.md');
+
+ // Extract any nodes with agentConfig="true"
+ const agentConfigNodes = this.extractAgentConfigNodes(agentContent);
+
+ agents.push({
+ name: agentName,
+ module: entry.name,
+ agentConfigNodes: agentConfigNodes,
+ });
+
+ // Use shared AgentPartyGenerator to extract details
+ let details = AgentPartyGenerator.extractAgentDetails(agentContent, entry.name, agentName);
+
+ // Apply config overrides if they exist
+ if (details) {
+ const configPath = path.join(agentConfigDir, `${entry.name}-${agentName}.md`);
+ if (await fs.pathExists(configPath)) {
+ const configContent = await fs.readFile(configPath, 'utf8');
+ details = AgentPartyGenerator.applyConfigOverrides(details, configContent);
+ }
+ agentDetails.push(details);
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Create config file for each agent
+ let createdCount = 0;
+ let skippedCount = 0;
+
+ // Load agent config template
+ const templatePath = getSourcePath('utility', 'models', 'agent-config-template.md');
+ const templateContent = await fs.readFile(templatePath, 'utf8');
+
+ for (const agent of agents) {
+ const configPath = path.join(agentConfigDir, `${agent.module}-${agent.name}.md`);
+
+ // Skip if config file already exists (preserve custom configurations)
+ if (await fs.pathExists(configPath)) {
+ skippedCount++;
+ continue;
+ }
+
+ // Build config content header
+ let configContent = `# Agent Config: ${agent.name}\n\n`;
+
+ // Process template and add agent-specific config nodes
+ let processedTemplate = templateContent;
+
+ // Replace {core:user_name} placeholder with actual user name if available
+ if (userInfo && userInfo.userName) {
+ processedTemplate = processedTemplate.replaceAll('{core:user_name}', userInfo.userName);
+ }
+
+ // Replace {core:communication_language} placeholder with actual language if available
+ if (userInfo && userInfo.responseLanguage) {
+ processedTemplate = processedTemplate.replaceAll('{core:communication_language}', userInfo.responseLanguage);
+ }
+
+ // If this agent has agentConfig nodes, add them after the existing comment
+ if (agent.agentConfigNodes && agent.agentConfigNodes.length > 0) {
+ // Find the agent-specific configuration nodes comment
+ const commentPattern = /(\s*<!-- Agent-specific configuration nodes -->)/;
+ const commentMatch = processedTemplate.match(commentPattern);
+
+ if (commentMatch) {
+ // Add nodes right after the comment
+ let agentSpecificNodes = '';
+ for (const node of agent.agentConfigNodes) {
+ agentSpecificNodes += `\n ${node}`;
+ }
+
+ processedTemplate = processedTemplate.replace(commentPattern, `$1${agentSpecificNodes}`);
+ }
+ }
+
+ configContent += processedTemplate;
+
+ await fs.writeFile(configPath, configContent, 'utf8');
+ this.installedFiles.push(configPath); // Track agent config files
+ createdCount++;
+ }
+
+ // Generate agent manifest with overrides applied
+ await this.generateAgentManifest(bmadDir, agentDetails);
+
+ return { total: agents.length, created: createdCount, skipped: skippedCount };
+ }
+
+ /**
+ * Generate agent manifest XML file
+ * @param {string} bmadDir - BMAD installation directory
+ * @param {Array} agentDetails - Array of agent details
+ */
+ async generateAgentManifest(bmadDir, agentDetails) {
+ const manifestPath = path.join(bmadDir, '_cfg', 'agent-party.xml');
+ await AgentPartyGenerator.writeAgentParty(manifestPath, agentDetails, { forWeb: false });
+ }
+
+ /**
+ * Extract nodes with agentConfig="true" from agent content
+ * @param {string} content - Agent file content
+ * @returns {Array} Array of XML nodes that should be added to agent config
+ */
+ extractAgentConfigNodes(content) {
+ const nodes = [];
+
+ try {
+ // Find all XML nodes with agentConfig="true"
+ // Match self-closing tags and tags with content
+ const selfClosingPattern = /<([a-zA-Z][a-zA-Z0-9_-]*)\s+[^>]*agentConfig="true"[^>]*\/>/g;
+ const withContentPattern = /<([a-zA-Z][a-zA-Z0-9_-]*)\s+[^>]*agentConfig="true"[^>]*>([\s\S]*?)<\/\1>/g;
+
+ // Extract self-closing tags
+ let match;
+ while ((match = selfClosingPattern.exec(content)) !== null) {
+ // Extract just the tag without children (structure only)
+ const tagMatch = match[0].match(/<([a-zA-Z][a-zA-Z0-9_-]*)([^>]*)\/>/);
+ if (tagMatch) {
+ const tagName = tagMatch[1];
+ const attributes = tagMatch[2].replace(/\s*agentConfig="true"/, ''); // Remove agentConfig attribute
+ nodes.push(`<${tagName}${attributes}></${tagName}>`);
+ }
+ }
+
+ // Extract tags with content
+ while ((match = withContentPattern.exec(content)) !== null) {
+ const fullMatch = match[0];
+ const tagName = match[1];
+
+ // Extract opening tag with attributes (removing agentConfig="true")
+ const openingTagMatch = fullMatch.match(new RegExp(`<${tagName}([^>]*)>`));
+ if (openingTagMatch) {
+ const attributes = openingTagMatch[1].replace(/\s*agentConfig="true"/, '');
+ // Add empty node structure (no children)
+ nodes.push(`<${tagName}${attributes}></${tagName}>`);
+ }
+ }
+ } catch (error) {
+ console.error('Error extracting agentConfig nodes:', error);
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Copy IDE-specific documentation to BMAD docs
+ * @param {Array} ides - List of selected IDEs
+ * @param {string} bmadDir - BMAD installation directory
+ */
+ async copyIdeDocumentation(ides, bmadDir) {
+ const docsDir = path.join(bmadDir, 'docs');
+ await fs.ensureDir(docsDir);
+
+ for (const ide of ides) {
+ const sourceDocPath = path.join(getProjectRoot(), 'docs', 'ide-info', `${ide}.md`);
+ const targetDocPath = path.join(docsDir, `${ide}-instructions.md`);
+
+ if (await fs.pathExists(sourceDocPath)) {
+ await fs.copy(sourceDocPath, targetDocPath, { overwrite: true });
+ }
+ }
+ }
+}
+
+module.exports = { Installer };
diff --git a/tools/cli/installers/lib/core/manifest.js b/tools/cli/installers/lib/core/manifest.js
new file mode 100644
index 00000000..7410450f
--- /dev/null
+++ b/tools/cli/installers/lib/core/manifest.js
@@ -0,0 +1,536 @@
+const path = require('node:path');
+const fs = require('fs-extra');
+const crypto = require('node:crypto');
+
+class Manifest {
+ /**
+ * Create a new manifest
+ * @param {string} bmadDir - Path to bmad directory
+ * @param {Object} data - Manifest data
+ * @param {Array} installedFiles - List of installed files (no longer used, files tracked in files-manifest.csv)
+ */
+ async create(bmadDir, data, installedFiles = []) {
+ const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
+ const yaml = require('js-yaml');
+
+ // Ensure _cfg directory exists
+ await fs.ensureDir(path.dirname(manifestPath));
+
+ // Structure the manifest data
+ const manifestData = {
+ installation: {
+ version: data.version || require(path.join(process.cwd(), 'package.json')).version,
+ installDate: data.installDate || new Date().toISOString(),
+ lastUpdated: data.lastUpdated || new Date().toISOString(),
+ },
+ modules: data.modules || [],
+ ides: data.ides || [],
+ };
+
+ // Write YAML manifest
+ const yamlContent = yaml.dump(manifestData, {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false,
+ });
+
+ await fs.writeFile(manifestPath, yamlContent, 'utf8');
+ return { success: true, path: manifestPath, filesTracked: 0 };
+ }
+
+ /**
+ * Read existing manifest
+ * @param {string} bmadDir - Path to bmad directory
+ * @returns {Object|null} Manifest data or null if not found
+ */
+ async read(bmadDir) {
+ const yamlPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
+ const yaml = require('js-yaml');
+
+ if (await fs.pathExists(yamlPath)) {
+ try {
+ const content = await fs.readFile(yamlPath, 'utf8');
+ const manifestData = yaml.load(content);
+
+ // Flatten the structure for compatibility with existing code
+ return {
+ version: manifestData.installation?.version,
+ installDate: manifestData.installation?.installDate,
+ lastUpdated: manifestData.installation?.lastUpdated,
+ modules: manifestData.modules || [],
+ ides: manifestData.ides || [],
+ };
+ } catch (error) {
+ console.error('Failed to read YAML manifest:', error.message);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Update existing manifest
+ * @param {string} bmadDir - Path to bmad directory
+ * @param {Object} updates - Fields to update
+ * @param {Array} installedFiles - Updated list of installed files
+ */
+ async update(bmadDir, updates, installedFiles = null) {
+ const yaml = require('js-yaml');
+ const manifest = (await this.read(bmadDir)) || {};
+
+ // Merge updates
+ Object.assign(manifest, updates);
+ manifest.lastUpdated = new Date().toISOString();
+
+ // Convert back to structured format for YAML
+ const manifestData = {
+ installation: {
+ version: manifest.version,
+ installDate: manifest.installDate,
+ lastUpdated: manifest.lastUpdated,
+ },
+ modules: manifest.modules || [],
+ ides: manifest.ides || [],
+ };
+
+ const manifestPath = path.join(bmadDir, '_cfg', 'manifest.yaml');
+ await fs.ensureDir(path.dirname(manifestPath));
+
+ const yamlContent = yaml.dump(manifestData, {
+ indent: 2,
+ lineWidth: -1,
+ noRefs: true,
+ sortKeys: false,
+ });
+
+ await fs.writeFile(manifestPath, yamlContent, 'utf8');
+
+ return manifest;
+ }
+
+ /**
+ * Add a module to the manifest
+ * @param {string} bmadDir - Path to bmad directory
+ * @param {string} moduleName - Module name to add
+ */
+ async addModule(bmadDir, moduleName) {
+ const manifest = await this.read(bmadDir);
+ if (!manifest) {
+ throw new Error('No manifest found');
+ }
+
+ if (!manifest.modules) {
+ manifest.modules = [];
+ }
+
+ if (!manifest.modules.includes(moduleName)) {
+ manifest.modules.push(moduleName);
+ await this.update(bmadDir, { modules: manifest.modules });
+ }
+ }
+
+ /**
+ * Remove a module from the manifest
+ * @param {string} bmadDir - Path to bmad directory
+ * @param {string} moduleName - Module name to remove
+ */
+ async removeModule(bmadDir, moduleName) {
+ const manifest = await this.read(bmadDir);
+ if (!manifest || !manifest.modules) {
+ return;
+ }
+
+ const index = manifest.modules.indexOf(moduleName);
+ if (index !== -1) {
+ manifest.modules.splice(index, 1);
+ await this.update(bmadDir, { modules: manifest.modules });
+ }
+ }
+
+ /**
+ * Add an IDE configuration to the manifest
+ * @param {string} bmadDir - Path to bmad directory
+ * @param {string} ideName - IDE name to add
+ */
+ async addIde(bmadDir, ideName) {
+ const manifest = await this.read(bmadDir);
+ if (!manifest) {
+ throw new Error('No manifest found');
+ }
+
+ if (!manifest.ides) {
+ manifest.ides = [];
+ }
+
+ if (!manifest.ides.includes(ideName)) {
+ manifest.ides.push(ideName);
+ await this.update(bmadDir, { ides: manifest.ides });
+ }
+ }
+
+ /**
+ * Calculate SHA256 hash of a file
+ * @param {string} filePath - Path to file
+ * @returns {string} SHA256 hash
+ */
+ async calculateFileHash(filePath) {
+ try {
+ const content = await fs.readFile(filePath);
+ return crypto.createHash('sha256').update(content).digest('hex');
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * Parse installed files to extract metadata
+ * @param {Array} installedFiles - List of installed file paths
+ * @param {string} bmadDir - Path to bmad directory for relative paths
+ * @returns {Array} Array of file metadata objects
+ */
+ async parseInstalledFiles(installedFiles, bmadDir) {
+ const fileMetadata = [];
+
+ for (const filePath of installedFiles) {
+ const fileExt = path.extname(filePath).toLowerCase();
+ // Make path relative to parent of bmad directory, starting with 'bmad/'
+ const relativePath = 'bmad' + filePath.replace(bmadDir, '').replaceAll('\\', '/');
+
+ // Calculate file hash
+ const hash = await this.calculateFileHash(filePath);
+
+ // Handle markdown files - extract XML metadata if present
+ if (fileExt === '.md') {
+ try {
+ if (await fs.pathExists(filePath)) {
+ const content = await fs.readFile(filePath, 'utf8');
+ const metadata = this.extractXmlNodeAttributes(content, filePath, relativePath);
+
+ if (metadata) {
+ // Has XML metadata
+ metadata.hash = hash;
+ fileMetadata.push(metadata);
+ } else {
+ // No XML metadata - still track the file
+ fileMetadata.push({
+ file: relativePath,
+ type: 'md',
+ name: path.basename(filePath, fileExt),
+ title: null,
+ hash: hash,
+ });
+ }
+ }
+ } catch (error) {
+ console.warn(`Warning: Could not parse ${filePath}:`, error.message);
+ }
+ }
+ // Handle other file types (CSV, JSON, YAML, etc.)
+ else {
+ fileMetadata.push({
+ file: relativePath,
+ type: fileExt.slice(1), // Remove the dot
+ name: path.basename(filePath, fileExt),
+ title: null,
+ hash: hash,
+ });
+ }
+ }
+
+ return fileMetadata;
+ }
+
+ /**
+ * Extract XML node attributes from MD file content
+ * @param {string} content - File content
+ * @param {string} filePath - File path for context
+ * @param {string} relativePath - Relative path starting with 'bmad/'
+ * @returns {Object|null} Extracted metadata or null
+ */
+ extractXmlNodeAttributes(content, filePath, relativePath) {
+ // Look for XML blocks in code fences
+ const xmlBlockMatch = content.match(/```xml\s*([\s\S]*?)```/);
+ if (!xmlBlockMatch) {
+ return null;
+ }
+
+ const xmlContent = xmlBlockMatch[1];
+
+ // Extract root XML node (agent, task, template, etc.)
+ const rootNodeMatch = xmlContent.match(/<(\w+)([^>]*)>/);
+ if (!rootNodeMatch) {
+ return null;
+ }
+
+ const nodeType = rootNodeMatch[1];
+ const attributes = rootNodeMatch[2];
+
+ // Extract name and title attributes (id not needed since we have path)
+ const nameMatch = attributes.match(/name="([^"]*)"/);
+ const titleMatch = attributes.match(/title="([^"]*)"/);
+
+ return {
+ file: relativePath,
+ type: nodeType,
+ name: nameMatch ? nameMatch[1] : null,
+ title: titleMatch ? titleMatch[1] : null,
+ };
+ }
+
+ /**
+ * Generate CSV manifest content
+ * @param {Object} data - Manifest data
+ * @param {Array} fileMetadata - File metadata array
+ * @param {Object} moduleConfigs - Module configuration data
+ * @returns {string} CSV content
+ */
+ generateManifestCsv(data, fileMetadata, moduleConfigs = {}) {
+ const timestamp = new Date().toISOString();
+ let csv = [];
+
+ // Header section
+ csv.push(
+ '# BMAD Manifest',
+ `# Generated: ${timestamp}`,
+ '',
+ '## Installation Info',
+ 'Property,Value',
+ `Version,${data.version}`,
+ `InstallDate,${data.installDate || timestamp}`,
+ `LastUpdated,${data.lastUpdated || timestamp}`,
+ );
+ if (data.language) {
+ csv.push(`Language,${data.language}`);
+ }
+ csv.push('');
+
+ // Modules section
+ if (data.modules && data.modules.length > 0) {
+ csv.push('## Modules', 'Name,Version,ShortTitle');
+ for (const moduleName of data.modules) {
+ const config = moduleConfigs[moduleName] || {};
+ csv.push([moduleName, config.version || '', config['short-title'] || ''].map((v) => this.escapeCsv(v)).join(','));
+ }
+ csv.push('');
+ }
+
+ // IDEs section
+ if (data.ides && data.ides.length > 0) {
+ csv.push('## IDEs', 'IDE');
+ for (const ide of data.ides) {
+ csv.push(this.escapeCsv(ide));
+ }
+ csv.push('');
+ }
+
+ // Files section - NO LONGER USED
+ // Files are now tracked in files-manifest.csv by ManifestGenerator
+
+ return csv.join('\n');
+ }
+
+ /**
+ * Parse CSV manifest content back to object
+ * @param {string} csvContent - CSV content to parse
+ * @returns {Object} Parsed manifest data
+ */
+ parseManifestCsv(csvContent) {
+ const result = {
+ modules: [],
+ ides: [],
+ files: [],
+ };
+
+ const lines = csvContent.split('\n');
+ let section = '';
+
+ for (const line_ of lines) {
+ const line = line_.trim();
+
+ // Skip empty lines and comments
+ if (!line || line.startsWith('#')) {
+ // Check for section headers
+ if (line.startsWith('## ')) {
+ section = line.slice(3).toLowerCase();
+ }
+ continue;
+ }
+
+ // Parse based on current section
+ switch (section) {
+ case 'installation info': {
+ // Skip header row
+ if (line === 'Property,Value') continue;
+
+ const [property, ...valueParts] = line.split(',');
+ const value = this.unescapeCsv(valueParts.join(','));
+
+ switch (property) {
+ // Path no longer stored in manifest
+ case 'Version': {
+ result.version = value;
+ break;
+ }
+ case 'InstallDate': {
+ result.installDate = value;
+ break;
+ }
+ case 'LastUpdated': {
+ result.lastUpdated = value;
+ break;
+ }
+ case 'Language': {
+ result.language = value;
+ break;
+ }
+ }
+
+ break;
+ }
+ case 'modules': {
+ // Skip header row
+ if (line === 'Name,Version,ShortTitle') continue;
+
+ const parts = this.parseCsvLine(line);
+ if (parts[0]) {
+ result.modules.push(parts[0]);
+ }
+
+ break;
+ }
+ case 'ides': {
+ // Skip header row
+ if (line === 'IDE') continue;
+
+ result.ides.push(this.unescapeCsv(line));
+
+ break;
+ }
+ case 'files': {
+ // Skip header rows (support both old and new format)
+ if (line === 'Type,Path,Name,Title' || line === 'Type,Path,Name,Title,Hash') continue;
+
+ const parts = this.parseCsvLine(line);
+ if (parts.length >= 2) {
+ result.files.push({
+ type: parts[0] || '',
+ file: parts[1] || '',
+ name: parts[2] || null,
+ title: parts[3] || null,
+ hash: parts[4] || null, // Hash column (may not exist in old manifests)
+ });
+ }
+
+ break;
+ }
+ // No default
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Parse a CSV line handling quotes and commas
+ * @param {string} line - CSV line to parse
+ * @returns {Array} Array of values
+ */
+ parseCsvLine(line) {
+ const result = [];
+ let current = '';
+ let inQuotes = false;
+
+ for (let i = 0; i < line.length; i++) {
+ const char = line[i];
+
+ if (char === '"') {
+ if (inQuotes && line[i + 1] === '"') {
+ // Escaped quote
+ current += '"';
+ i++;
+ } else {
+ // Toggle quote state
+ inQuotes = !inQuotes;
+ }
+ } else if (char === ',' && !inQuotes) {
+ // Field separator
+ result.push(this.unescapeCsv(current));
+ current = '';
+ } else {
+ current += char;
+ }
+ }
+
+ // Add the last field
+ result.push(this.unescapeCsv(current));
+
+ return result;
+ }
+
+ /**
+ * Escape CSV special characters
+ * @param {string} text - Text to escape
+ * @returns {string} Escaped text
+ */
+ escapeCsv(text) {
+ if (!text) return '';
+ const str = String(text);
+
+ // If contains comma, newline, or quote, wrap in quotes and escape quotes
+ if (str.includes(',') || str.includes('\n') || str.includes('"')) {
+ return '"' + str.replaceAll('"', '""') + '"';
+ }
+
+ return str;
+ }
+
+ /**
+ * Unescape CSV field
+ * @param {string} text - Text to unescape
+ * @returns {string} Unescaped text
+ */
+ unescapeCsv(text) {
+ if (!text) return '';
+
+ // Remove surrounding quotes if present
+ if (text.startsWith('"') && text.endsWith('"')) {
+ text = text.slice(1, -1);
+ // Unescape doubled quotes
+ text = text.replaceAll('""', '"');
+ }
+
+ return text;
+ }
+
+ /**
+ * Load module configuration files
+ * @param {Array} modules - List of module names
+ * @returns {Object} Module configurations indexed by name
+ */
+ async loadModuleConfigs(modules) {
+ const configs = {};
+
+ for (const moduleName of modules) {
+ // Handle core module differently - it's in src/core not src/modules/core
+ const configPath =
+ moduleName === 'core'
+ ? path.join(process.cwd(), 'src', 'core', 'config.yaml')
+ : path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
+
+ try {
+ if (await fs.pathExists(configPath)) {
+ const yaml = require('js-yaml');
+ const content = await fs.readFile(configPath, 'utf8');
+ configs[moduleName] = yaml.load(content);
+ }
+ } catch (error) {
+ console.warn(`Could not load config for module ${moduleName}:`, error.message);
+ }
+ }
+
+ return configs;
+ }
+}
+
+module.exports = { Manifest };
diff --git a/tools/cli/lib/ui.js b/tools/cli/lib/ui.js
new file mode 100644
index 00000000..de576aa0
--- /dev/null
+++ b/tools/cli/lib/ui.js
@@ -0,0 +1,546 @@
+const chalk = require('chalk');
+const inquirer = require('inquirer');
+const path = require('node:path');
+const os = require('node:os');
+const fs = require('fs-extra');
+const { CLIUtils } = require('./cli-utils');
+
+/**
+ * UI utilities for the installer
+ */
+class UI {
+ constructor() {}
+
+ /**
+ * Prompt for installation configuration
+ * @returns {Object} Installation configuration
+ */
+ async promptInstall() {
+ CLIUtils.displayLogo();
+ CLIUtils.displaySection('BMADÔäó Setup', 'Build More, Architect Dreams');
+
+ const confirmedDirectory = await this.getConfirmedDirectory();
+
+ // Check if there's an existing BMAD installation
+ const fs = require('fs-extra');
+ const path = require('node:path');
+ const bmadDir = path.join(confirmedDirectory, 'bmad');
+ const hasExistingInstall = await fs.pathExists(bmadDir);
+
+ // Only show action menu if there's an existing installation
+ if (hasExistingInstall) {
+ const { actionType } = await inquirer.prompt([
+ {
+ type: 'list',
+ name: 'actionType',
+ message: 'What would you like to do?',
+ choices: [
+ { name: 'Update BMAD Installation', value: 'install' },
+ { name: 'Compile Agents (Quick rebuild of all agent .md files)', value: 'compile' },
+ ],
+ },
+ ]);
+
+ // Handle agent compilation separately
+ if (actionType === 'compile') {
+ return {
+ actionType: 'compile',
+ directory: confirmedDirectory,
+ };
+ }
+ }
+ const { installedModuleIds } = await this.getExistingInstallation(confirmedDirectory);
+ const coreConfig = await this.collectCoreConfig(confirmedDirectory);
+ const moduleChoices = await this.getModuleChoices(installedModuleIds);
+ const selectedModules = await this.selectModules(moduleChoices);
+
+ console.clear();
+ CLIUtils.displayLogo();
+ CLIUtils.displayModuleComplete('core', false); // false = don't clear the screen again
+
+ return {
+ actionType: 'install', // Explicitly set action type
+ directory: confirmedDirectory,
+ installCore: true, // Always install core
+ modules: selectedModules,
+ // IDE selection moved to after module configuration
+ ides: [],
+ skipIde: true, // Will be handled later
+ coreConfig: coreConfig, // Pass collected core config to installer
+ };
+ }
+
+ /**
+ * Prompt for tool/IDE selection (called after module configuration)
+ * @param {string} projectDir - Project directory to check for existing IDEs
+ * @param {Array} selectedModules - Selected modules from configuration
+ * @returns {Object} Tool configuration
+ */
+ async promptToolSelection(projectDir, selectedModules) {
+ // Check for existing configured IDEs
+ const { Detector } = require('../installers/lib/core/detector');
+ const detector = new Detector();
+ const bmadDir = path.join(projectDir || process.cwd(), 'bmad');
+ const existingInstall = await detector.detect(bmadDir);
+ const configuredIdes = existingInstall.ides || [];
+
+ // Get IDE manager to fetch available IDEs dynamically
+ const { IdeManager } = require('../installers/lib/ide/manager');
+ const ideManager = new IdeManager();
+
+ const preferredIdes = ideManager.getPreferredIdes();
+ const otherIdes = ideManager.getOtherIdes();
+
+ // Build IDE choices array with separators
+ const ideChoices = [];
+ const processedIdes = new Set();
+
+ // First, add previously configured IDEs at the top, marked with 
+ if (configuredIdes.length > 0) {
+ ideChoices.push(new inquirer.Separator('ÔöÇÔöÇ Previously Configured ÔöÇÔöÇ'));
+ for (const ideValue of configuredIdes) {
+ // Find the IDE in either preferred or other lists
+ const preferredIde = preferredIdes.find((ide) => ide.value === ideValue);
+ const otherIde = otherIdes.find((ide) => ide.value === ideValue);
+ const ide = preferredIde || otherIde;
+
+ if (ide) {
+ ideChoices.push({
+ name: `${ide.name} `,
+ value: ide.value,
+ checked: true, // Previously configured IDEs are checked by default
+ });
+ processedIdes.add(ide.value);
+ }
+ }
+ }
+
+ // Add preferred tools (excluding already processed)
+ const remainingPreferred = preferredIdes.filter((ide) => !processedIdes.has(ide.value));
+ if (remainingPreferred.length > 0) {
+ ideChoices.push(new inquirer.Separator('ÔöÇÔöÇ Recommended Tools ÔöÇÔöÇ'));
+ for (const ide of remainingPreferred) {
+ ideChoices.push({
+ name: `${ide.name} Ô¡É`,
+ value: ide.value,
+ checked: false,
+ });
+ processedIdes.add(ide.value);
+ }
+ }
+
+ // Add other tools (excluding already processed)
+ const remainingOther = otherIdes.filter((ide) => !processedIdes.has(ide.value));
+ if (remainingOther.length > 0) {
+ ideChoices.push(new inquirer.Separator('ÔöÇÔöÇ Additional Tools ÔöÇÔöÇ'));
+ for (const ide of remainingOther) {
+ ideChoices.push({
+ name: ide.name,
+ value: ide.value,
+ checked: false,
+ });
+ }
+ }
+
+ CLIUtils.displaySection('Tool Integration', 'Select AI coding assistants and IDEs to configure');
+
+ const answers = await inquirer.prompt([
+ {
+ type: 'checkbox',
+ name: 'ides',
+ message: 'Select tools to configure:',
+ choices: ideChoices,
+ pageSize: 15,
+ },
+ ]);
+
+ return {
+ ides: answers.ides || [],
+ skipIde: !answers.ides || answers.ides.length === 0,
+ };
+ }
+
+ /**
+ * Prompt for update configuration
+ * @returns {Object} Update configuration
+ */
+ async promptUpdate() {
+ const answers = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'backupFirst',
+ message: 'Create backup before updating?',
+ default: true,
+ },
+ {
+ type: 'confirm',
+ name: 'preserveCustomizations',
+ message: 'Preserve local customizations?',
+ default: true,
+ },
+ ]);
+
+ return answers;
+ }
+
+ /**
+ * Prompt for module selection
+ * @param {Array} modules - Available modules
+ * @returns {Array} Selected modules
+ */
+ async promptModules(modules) {
+ const choices = modules.map((mod) => ({
+ name: `${mod.name} - ${mod.description}`,
+ value: mod.id,
+ checked: false,
+ }));
+
+ const { selectedModules } = await inquirer.prompt([
+ {
+ type: 'checkbox',
+ name: 'selectedModules',
+ message: 'Select modules to add:',
+ choices,
+ validate: (answer) => {
+ if (answer.length === 0) {
+ return 'You must choose at least one module.';
+ }
+ return true;
+ },
+ },
+ ]);
+
+ return selectedModules;
+ }
+
+ /**
+ * Confirm action
+ * @param {string} message - Confirmation message
+ * @param {boolean} defaultValue - Default value
+ * @returns {boolean} User confirmation
+ */
+ async confirm(message, defaultValue = false) {
+ const { confirmed } = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'confirmed',
+ message,
+ default: defaultValue,
+ },
+ ]);
+
+ return confirmed;
+ }
+
+ /**
+ * Display installation summary
+ * @param {Object} result - Installation result
+ */
+ showInstallSummary(result) {
+ CLIUtils.displaySection('Installation Complete', 'BMADÔäó has been successfully installed');
+
+ const summary = [
+ `­ƒôü Installation Path: ${result.path}`,
+ `­ƒôª Modules Installed: ${result.modules?.length > 0 ? result.modules.join(', ') : 'core only'}`,
+ `­ƒöº Tools Configured: ${result.ides?.length > 0 ? result.ides.join(', ') : 'none'}`,
+ ];
+
+ CLIUtils.displayBox(summary.join('\n\n'), {
+ borderColor: 'green',
+ borderStyle: 'round',
+ });
+
+ console.log('\n' + chalk.green.bold('Ô£¿ BMAD is ready to use!'));
+ }
+
+ /**
+ * Get confirmed directory from user
+ * @returns {string} Confirmed directory path
+ */
+ async getConfirmedDirectory() {
+ let confirmedDirectory = null;
+ while (!confirmedDirectory) {
+ const directoryAnswer = await this.promptForDirectory();
+ await this.displayDirectoryInfo(directoryAnswer.directory);
+
+ if (await this.confirmDirectory(directoryAnswer.directory)) {
+ confirmedDirectory = directoryAnswer.directory;
+ }
+ }
+ return confirmedDirectory;
+ }
+
+ /**
+ * Get existing installation info and installed modules
+ * @param {string} directory - Installation directory
+ * @returns {Object} Object with existingInstall and installedModuleIds
+ */
+ async getExistingInstallation(directory) {
+ const { Detector } = require('../installers/lib/core/detector');
+ const detector = new Detector();
+ const bmadDir = path.join(directory, 'bmad');
+ const existingInstall = await detector.detect(bmadDir);
+ const installedModuleIds = new Set(existingInstall.modules.map((mod) => mod.id));
+
+ return { existingInstall, installedModuleIds };
+ }
+
+ /**
+ * Collect core configuration
+ * @param {string} directory - Installation directory
+ * @returns {Object} Core configuration
+ */
+ async collectCoreConfig(directory) {
+ const { ConfigCollector } = require('../installers/lib/core/config-collector');
+ const configCollector = new ConfigCollector();
+ // Load existing configs first if they exist
+ await configCollector.loadExistingConfig(directory);
+ // Now collect with existing values as defaults (false = don't skip loading, true = skip completion message)
+ await configCollector.collectModuleConfig('core', directory, false, true);
+
+ return configCollector.collectedConfig.core;
+ }
+
+ /**
+ * Get module choices for selection
+ * @param {Set} installedModuleIds - Currently installed module IDs
+ * @returns {Array} Module choices for inquirer
+ */
+ async getModuleChoices(installedModuleIds) {
+ const { ModuleManager } = require('../installers/lib/modules/manager');
+ const moduleManager = new ModuleManager();
+ const availableModules = await moduleManager.listAvailable();
+
+ const isNewInstallation = installedModuleIds.size === 0;
+ return availableModules.map((mod) => ({
+ name: mod.name,
+ value: mod.id,
+ checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id),
+ }));
+ }
+
+ /**
+ * Prompt for module selection
+ * @param {Array} moduleChoices - Available module choices
+ * @returns {Array} Selected module IDs
+ */
+ async selectModules(moduleChoices) {
+ CLIUtils.displaySection('Module Selection', 'Choose the BMAD modules to install');
+
+ const moduleAnswer = await inquirer.prompt([
+ {
+ type: 'checkbox',
+ name: 'modules',
+ message: 'Select modules to install:',
+ choices: moduleChoices,
+ },
+ ]);
+
+ return moduleAnswer.modules || [];
+ }
+
+ /**
+ * Prompt for directory selection
+ * @returns {Object} Directory answer from inquirer
+ */
+ async promptForDirectory() {
+ return await inquirer.prompt([
+ {
+ type: 'input',
+ name: 'directory',
+ message: `Installation directory:`,
+ default: process.cwd(),
+ validate: async (input) => this.validateDirectory(input),
+ filter: (input) => {
+ // If empty, use the default
+ if (!input || input.trim() === '') {
+ return process.cwd();
+ }
+ return this.expandUserPath(input);
+ },
+ },
+ ]);
+ }
+
+ /**
+ * Display directory information
+ * @param {string} directory - The directory path
+ */
+ async displayDirectoryInfo(directory) {
+ console.log(chalk.cyan('\nResolved installation path:'), chalk.bold(directory));
+
+ const dirExists = await fs.pathExists(directory);
+ if (dirExists) {
+ // Show helpful context about the existing path
+ const stats = await fs.stat(directory);
+ if (stats.isDirectory()) {
+ const files = await fs.readdir(directory);
+ if (files.length > 0) {
+ console.log(
+ chalk.gray(`Directory exists and contains ${files.length} item(s)`) +
+ (files.includes('bmad') ? chalk.yellow(' including existing bmad installation') : ''),
+ );
+ } else {
+ console.log(chalk.gray('Directory exists and is empty'));
+ }
+ }
+ } else {
+ const existingParent = await this.findExistingParent(directory);
+ console.log(chalk.gray(`Will create in: ${existingParent}`));
+ }
+ }
+
+ /**
+ * Confirm directory selection
+ * @param {string} directory - The directory path
+ * @returns {boolean} Whether user confirmed
+ */
+ async confirmDirectory(directory) {
+ const dirExists = await fs.pathExists(directory);
+
+ if (dirExists) {
+ const confirmAnswer = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'proceed',
+ message: `Install to this directory?`,
+ default: true,
+ },
+ ]);
+
+ if (!confirmAnswer.proceed) {
+ console.log(chalk.yellow("\nLet's try again with a different path.\n"));
+ }
+
+ return confirmAnswer.proceed;
+ } else {
+ // Ask for confirmation to create the directory
+ const createConfirm = await inquirer.prompt([
+ {
+ type: 'confirm',
+ name: 'create',
+ message: `The directory '${directory}' doesn't exist. Would you like to create it?`,
+ default: false,
+ },
+ ]);
+
+ if (!createConfirm.create) {
+ console.log(chalk.yellow("\nLet's try again with a different path.\n"));
+ }
+
+ return createConfirm.create;
+ }
+ }
+
+ /**
+ * Validate directory path for installation
+ * @param {string} input - User input path
+ * @returns {string|true} Error message or true if valid
+ */
+ async validateDirectory(input) {
+ // Allow empty input to use the default
+ if (!input || input.trim() === '') {
+ return true; // Empty means use default
+ }
+
+ let expandedPath;
+ try {
+ expandedPath = this.expandUserPath(input.trim());
+ } catch (error) {
+ return error.message;
+ }
+
+ // Check if the path exists
+ const pathExists = await fs.pathExists(expandedPath);
+
+ if (!pathExists) {
+ // Find the first existing parent directory
+ const existingParent = await this.findExistingParent(expandedPath);
+
+ if (!existingParent) {
+ return 'Cannot create directory: no existing parent directory found';
+ }
+
+ // Check if the existing parent is writable
+ try {
+ await fs.access(existingParent, fs.constants.W_OK);
+ // Path doesn't exist but can be created - will prompt for confirmation later
+ return true;
+ } catch {
+ // Provide a detailed error message explaining both issues
+ return `Directory '${expandedPath}' does not exist and cannot be created: parent directory '${existingParent}' is not writable`;
+ }
+ }
+
+ // If it exists, validate it's a directory and writable
+ const stat = await fs.stat(expandedPath);
+ if (!stat.isDirectory()) {
+ return `Path exists but is not a directory: ${expandedPath}`;
+ }
+
+ // Check write permissions
+ try {
+ await fs.access(expandedPath, fs.constants.W_OK);
+ } catch {
+ return `Directory is not writable: ${expandedPath}`;
+ }
+
+ return true;
+ }
+
+ /**
+ * Find the first existing parent directory
+ * @param {string} targetPath - The path to check
+ * @returns {string|null} The first existing parent directory, or null if none found
+ */
+ async findExistingParent(targetPath) {
+ let currentPath = path.resolve(targetPath);
+
+ // Walk up the directory tree until we find an existing directory
+ while (currentPath !== path.dirname(currentPath)) {
+ // Stop at root
+ const parent = path.dirname(currentPath);
+ if (await fs.pathExists(parent)) {
+ return parent;
+ }
+ currentPath = parent;
+ }
+
+ return null; // No existing parent found (shouldn't happen in practice)
+ }
+
+ /**
+ * Expands the user-provided path: handles ~ and resolves to absolute.
+ * @param {string} inputPath - User input path.
+ * @returns {string} Absolute expanded path.
+ */
+ expandUserPath(inputPath) {
+ if (typeof inputPath !== 'string') {
+ throw new TypeError('Path must be a string.');
+ }
+
+ let expanded = inputPath.trim();
+
+ // Handle tilde expansion
+ if (expanded.startsWith('~')) {
+ if (expanded === '~') {
+ expanded = os.homedir();
+ } else if (expanded.startsWith('~' + path.sep)) {
+ const pathAfterHome = expanded.slice(2); // Remove ~/ or ~\
+ expanded = path.join(os.homedir(), pathAfterHome);
+ } else {
+ const restOfPath = expanded.slice(1);
+ const separatorIndex = restOfPath.indexOf(path.sep);
+ const username = separatorIndex === -1 ? restOfPath : restOfPath.slice(0, separatorIndex);
+ if (username) {
+ throw new Error(`Path expansion for ~${username} is not supported. Please use an absolute path or ~${path.sep}`);
+ }
+ }
+ }
+
+ // Resolve to the absolute path relative to the current working directory
+ return path.resolve(expanded);
+ }
+}
+
+module.exports = { UI };