From c46502f64049797ba1f04bcc18c5f6ca219a250a Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 7 Apr 2026 02:31:36 -0500 Subject: [PATCH 1/4] feat(installer): overhaul branding, versioning, and skill cleanup (#2223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(installer): overhaul branding, versioning, and skill cleanup Logo and branding: - Responsive logo: full "BMAD METHOD" at >=95 cols, "BMAD" for narrower terminals - Color scheme updated from yellow to blue (matching bmadcode.com brand) - Added copyright notice and tagline in white for contrast - Removed version number from logo (individual module versions shown in summary) - Added ™ to both wide and narrow logo variants Installer start message: - Replaced outdated V6 launch announcement with clean welcome - Consolidated redundant module/platform messaging into single intro - Tightened open source manifesto (same spirit, fewer words) - Merged speaking/media into support section with contact email - Added full social links: Website, Discord, YouTube, X, Facebook - Replaced docs.bmad-method.org and changelog links with bmadcode.com hub Install summary improvements: - Module names now show full display names from module.yaml (not abbreviations) - All module versions sourced from .claude-plugin/marketplace.json exclusively - Summary shows version transitions: "v6.2.2 -> v6.3.0", "v6.3.0, no change", or "v6.3.0, installed" for fresh installs - Switched summary from clack note() to box() for full-brightness text - Removed dim/gray styling that was hard to read on dark terminals - Links styled with color.blue instead of color.dim - Get started section leads with actionable steps (launch agent, run bmad-help) - Removed redundant social links (already shown in start message) Version source unification: - All module versions now come from .claude-plugin/marketplace.json only - Removed package.json as version source for core/bmm modules - Updated manifest.js getModuleVersionInfo() to use marketplace.json - Updated installer.js _getMarketplaceVersion() helper - Updated ui.js getMarketplaceVersion() for module selection display - Quick Update menu no longer shows misleading version (was using package.json) - Module selection list now shows versions next to each module name Skill cleanup overhaul: - Replaced blunt-force bmad-* prefix deletion with surgical removal system - Added removals.txt support: optional per-project file listing skills to remove - Created initial removals.txt with all skills removed since v6.2.0 - Install/update: captures previously installed skill IDs from skill-manifest.csv before manifest regeneration, then removes those + removals.txt entries - Uninstall: removes all installed skills via skill-manifest.csv + removals.txt - Deselecting modules now correctly removes their skills from IDE directories - User-created bmad-* skills in IDE directories are no longer destroyed - Legacy directory cleanup retains prefix matching (those dirs are abandoned) Bug fixes: - Fixed duplicate "CORE module already up to date" during quick update - Fixed version display showing package.json version instead of actual module version - Updated test fixture for bmad-os-* preservation test to use skill-manifest.csv * fix(installer): address Augment review findings - Fix plugins[0] fragility: extract highest version across all plugins in marketplace.json instead of assuming first entry (ui.js, installer.js, manifest.js) - Fix _readMarketplaceVersion ignoring moduleSourcePath: custom modules can now source their own marketplace.json by walking up from source path - Hard-exclude bmad-os-* utility skills in both surgical and legacy cleanup modes, preventing accidental deletion if tracked in manifests - Distinguish missing file vs parse error in skill-manifest.csv reading: warn on corrupt CSV instead of silently skipping cleanup * fix(installer): resolve module source before reading marketplace version Move _readMarketplaceVersion call after source type resolution so custom modules use their own source path instead of falling back to the external module cache, which could match a different module with the same code. --- removals.txt | 17 +++ test/test-installation-components.js | 8 ++ tools/installer/cli-utils.js | 25 +++-- tools/installer/core/installer.js | 145 ++++++++++++++++++------ tools/installer/core/manifest.js | 96 ++++++++++------ tools/installer/ide/_config-driven.js | 156 ++++++++++++++++++++++---- tools/installer/install-messages.yaml | 37 +++--- tools/installer/ui.js | 65 +++++++++-- 8 files changed, 420 insertions(+), 129 deletions(-) create mode 100644 removals.txt diff --git a/removals.txt b/removals.txt new file mode 100644 index 000000000..81a2b5dce --- /dev/null +++ b/removals.txt @@ -0,0 +1,17 @@ +# BMad Method - Skill Removal List +# Entries listed here will be removed from IDE skill directories during install/update. +# One entry per line. Lines starting with # are comments. +# Each entry is a skill directory name (canonicalId) that was removed or renamed. + +# Removed agents (v6.2.0 - v6.2.2) +bmad-agent-sm +bmad-agent-qa +bmad-agent-quick-flow-solo-dev + +# Removed skills (v6.2.0 - v6.2.2) +bmad-create-product-brief +bmad-product-brief-preview +bmad-quick-spec +bmad-quick-flow +bmad-quick-dev-new-preview +bmad-init diff --git a/test/test-installation-components.js b/test/test-installation-components.js index b548cbabe..1ac4b386d 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -1301,6 +1301,14 @@ async function runTests() { '---\nname: bmad-architect\ndescription: Architect\n---\nOld skill content\n', ); + // Add bmad-architect to the existing skill-manifest.csv so cleanup knows it was previously installed + const configDir27 = path.join(installedBmadDir27, '_config'); + const existingCsv27 = await fs.readFile(path.join(configDir27, 'skill-manifest.csv'), 'utf8'); + await fs.writeFile( + path.join(configDir27, 'skill-manifest.csv'), + existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md","true"\n', + ); + // Run Claude Code setup (which triggers cleanup then install) const ideManager27 = new IdeManager(); await ideManager27.ensureInitialized(); diff --git a/tools/installer/cli-utils.js b/tools/installer/cli-utils.js index 6ca615534..a0efdbe06 100644 --- a/tools/installer/cli-utils.js +++ b/tools/installer/cli-utils.js @@ -19,24 +19,33 @@ const CLIUtils = { * Display BMAD logo and version using @clack intro + box */ async displayLogo() { - const version = this.getVersion(); const color = await prompts.getColor(); + const termWidth = process.stdout.columns || 80; - // ASCII art logo - const logo = [ + // Full "BMad Method" logo for wide terminals, "BMad" only for narrow + const logoWide = [ + ' ██████╗ ███╗ ███╗ █████╗ ██████╗ ███╗ ███╗███████╗████████╗██╗ ██╗ ██████╗ ██████╗ ™', + '██╔══██╗████╗ ████║██╔══██╗██╔══██╗ ████╗ ████║██╔════╝╚══██╔══╝██║ ██║██╔═══██╗██╔══██╗', + '██████╔╝██╔████╔██║███████║██║ ██║ ██╔████╔██║█████╗ ██║ ███████║██║ ██║██║ ██║', + '██╔══██╗██║╚██╔╝██║██╔══██║██║ ██║ ██║╚██╔╝██║██╔══╝ ██║ ██╔══██║██║ ██║██║ ██║', + '██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝ ██║ ╚═╝ ██║███████╗ ██║ ██║ ██║╚██████╔╝██████╔╝', + '╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ', + ]; + + const logoNarrow = [ ' ██████╗ ███╗ ███╗ █████╗ ██████╗ ™', ' ██╔══██╗████╗ ████║██╔══██╗██╔══██╗', ' ██████╔╝██╔████╔██║███████║██║ ██║', ' ██╔══██╗██║╚██╔╝██║██╔══██║██║ ██║', ' ██████╔╝██║ ╚═╝ ██║██║ ██║██████╔╝', ' ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝', - ] - .map((line) => color.yellow(line)) - .join('\n'); + ]; - const tagline = ' Build More, Architect Dreams'; + const logoLines = termWidth >= 95 ? logoWide : logoNarrow; + const logo = logoLines.map((line) => color.blue(line)).join('\n'); + const tagline = color.white(' Build More, Architect Dreams\n © BMad Code'); - await prompts.box(`${logo}\n${tagline}`, `v${version}`, { + await prompts.box(`${logo}\n${tagline}`, '', { contentAlign: 'center', rounded: true, formatBorder: color.blue, diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index a0ea9a66e..d75355d72 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -26,6 +26,44 @@ class Installer { this.bmadFolderName = BMAD_FOLDER_NAME; } + /** + * Read the module version from .claude-plugin/marketplace.json + * Walks up from sourcePath looking for .claude-plugin/marketplace.json + * @param {string} sourcePath - Module source directory + * @returns {string} Version string or empty string + */ + async _getMarketplaceVersion(sourcePath) { + let dir = sourcePath; + for (let i = 0; i < 5; i++) { + const marketplacePath = path.join(dir, '.claude-plugin', 'marketplace.json'); + if (await fs.pathExists(marketplacePath)) { + try { + const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); + return this._extractMarketplaceVersion(data); + } catch { + return ''; + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return ''; + } + + /** + * Extract the highest version from marketplace.json plugins array + */ + _extractMarketplaceVersion(data) { + const plugins = data?.plugins; + if (!Array.isArray(plugins) || plugins.length === 0) return ''; + let best = ''; + for (const p of plugins) { + if (p.version && (!best || p.version > best)) best = p.version; + } + return best; + } + /** * Main installation method * @param {Object} config - Installation configuration @@ -52,9 +90,36 @@ class Installer { await this._validateIdeSelection(config); + // Capture pre-install module versions for from→to display + const preInstallVersions = new Map(); + if (existingInstall.installed) { + const existingModules = await this.manifest.getAllModuleVersions(paths.bmadDir); + for (const mod of existingModules) { + if (mod.name && mod.version) { + preInstallVersions.set(mod.name, mod.version); + } + } + } + // Results collector for consolidated summary const results = []; - const addResult = (step, status, detail = '') => results.push({ step, status, detail }); + const addResult = (step, status, detail = '', meta = {}) => results.push({ step, status, detail, ...meta }); + + // Capture previously installed skill IDs before they get overwritten + const previousSkillIds = new Set(); + const prevCsvPath = path.join(paths.bmadDir, '_config', 'skill-manifest.csv'); + if (await fs.pathExists(prevCsvPath)) { + try { + const csvParse = require('csv-parse/sync'); + const content = await fs.readFile(prevCsvPath, 'utf8'); + const records = csvParse.parse(content, { columns: true, skip_empty_lines: true }); + for (const r of records) { + if (r.canonicalId) previousSkillIds.add(r.canonicalId); + } + } catch (error) { + await prompts.log.warn(`Failed to parse skill-manifest.csv: ${error.message}`); + } + } await this._cacheCustomModules(paths, addResult); @@ -65,7 +130,7 @@ class Installer { await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules); - await this._setupIdes(config, allModules, paths, addResult); + await this._setupIdes(config, allModules, paths, addResult, previousSkillIds); const restoreResult = await this._restoreUserFiles(paths, updateState); @@ -76,6 +141,7 @@ class Installer { ides: config.ides, customFiles: restoreResult.customFiles.length > 0 ? restoreResult.customFiles : undefined, modifiedFiles: restoreResult.modifiedFiles.length > 0 ? restoreResult.modifiedFiles : undefined, + preInstallVersions, }); return { @@ -321,7 +387,7 @@ class Installer { /** * Set up IDE integrations for each selected IDE. */ - async _setupIdes(config, allModules, paths, addResult) { + async _setupIdes(config, allModules, paths, addResult, previousSkillIds = new Set()) { if (config.skipIde || !config.ides || config.ides.length === 0) return; await this.ideManager.ensureInitialized(); @@ -336,6 +402,7 @@ class Installer { const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, { selectedModules: allModules || [], verbose: config.verbose, + previousSkillIds, }); if (setupResult.success) { @@ -556,7 +623,7 @@ class Installer { message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); const moduleConfig = officialModules.moduleConfigs[moduleName] || {}; - await officialModules.install( + const installResult = await officialModules.install( moduleName, paths.bmadDir, (filePath) => { @@ -570,7 +637,12 @@ class Installer { }, ); - addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + // Get display name from source module.yaml; version from marketplace.json + const sourcePath = await officialModules.findModuleSource(moduleName, { silent: true }); + const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null; + const displayName = moduleInfo?.name || moduleName; + const version = sourcePath ? await this._getMarketplaceVersion(sourcePath) : ''; + addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); } } @@ -598,7 +670,11 @@ class Installer { [moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig }, }); - addResult(`Module: ${moduleName}`, 'ok', isQuickUpdate ? 'updated' : 'installed'); + // Get display name from source module.yaml; version from marketplace.json + const moduleInfo = await officialModules.getModuleInfo(sourcePath, moduleName, ''); + const displayName = moduleInfo?.name || moduleName; + const version = await this._getMarketplaceVersion(sourcePath); + addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); } } @@ -1062,23 +1138,10 @@ class Installer { const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase())); // Build step lines with status indicators + const preVersions = context.preInstallVersions || new Map(); const lines = []; for (const r of results) { - let stepLabel = null; - - if (r.status !== 'ok') { - stepLabel = r.step; - } else if (r.step === 'Core') { - stepLabel = 'BMAD'; - } else if (r.step.startsWith('Module: ')) { - stepLabel = r.step; - } else if (selectedIdes.has(String(r.step).toLowerCase())) { - stepLabel = r.step; - } - - if (!stepLabel) { - continue; - } + const stepLabel = r.step; let icon; if (r.status === 'ok') { @@ -1088,18 +1151,32 @@ class Installer { } else { icon = color.red('\u2717'); } - const detail = r.detail ? color.dim(` (${r.detail})`) : ''; + + // Build version detail for module results + let detail = ''; + if (r.moduleCode && r.newVersion) { + const oldVersion = preVersions.get(r.moduleCode); + if (oldVersion && oldVersion === r.newVersion) { + detail = ` (v${r.newVersion}, no change)`; + } else if (oldVersion) { + detail = ` (v${oldVersion} → v${r.newVersion})`; + } else { + detail = ` (v${r.newVersion}, installed)`; + } + } else if (r.detail) { + detail = ` (${r.detail})`; + } lines.push(` ${icon} ${stepLabel}${detail}`); } if ((context.ides || []).length === 0) { - lines.push(` ${color.green('\u2713')} No IDE selected ${color.dim('(installed in _bmad only)')}`); + lines.push(` ${color.green('\u2713')} No IDE selected (installed in _bmad only)`); } // Context and warnings lines.push(''); if (context.bmadDir) { - lines.push(` Installed to: ${color.dim(context.bmadDir)}`); + lines.push(` Installed to: ${context.bmadDir}`); } if (context.customFiles && context.customFiles.length > 0) { lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`); @@ -1111,17 +1188,18 @@ class Installer { // Next steps lines.push( '', - ' Next steps:', - ` Read our new Docs Site: ${color.dim('https://docs.bmad-method.org/')}`, - ` Join our Discord: ${color.dim('https://discord.gg/gk8jAdXWmj')}`, - ` Star us on GitHub: ${color.dim('https://github.com/bmad-code-org/BMAD-METHOD/')}`, - ` Subscribe on YouTube: ${color.dim('https://www.youtube.com/@BMadCode')}`, + ' Get started:', + ` 1. Launch your AI agent from your project folder`, + ` 2. Not sure what to do? Invoke the ${color.cyan('bmad-help')} skill and ask it what to do!`, + '', + ` Blog, Docs and Guides: ${color.blue('https://bmadcode.com/')}`, + ` Community: ${color.blue('https://discord.gg/gk8jAdXWmj')}`, ); - if (context.ides && context.ides.length > 0) { - lines.push(` Invoke the ${color.cyan('bmad-help')} skill in your IDE Agent to get started`); - } - await prompts.note(lines.join('\n'), 'BMAD is ready to use!'); + await prompts.box(lines.join('\n'), 'BMAD is ready to use!', { + rounded: true, + formatBorder: color.green, + }); } /** @@ -1231,6 +1309,7 @@ class Installer { } for (const moduleName of modulesToUpdate) { + if (moduleName === 'core') continue; // Already collected above const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true); if (modulePrompted) { promptedForNewFields = true; diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index d6eade648..287b38918 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -837,14 +837,13 @@ class Manifest { * @returns {Object} Version info object with version, source, npmPackage, repoUrl */ async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) { - const os = require('node:os'); const yaml = require('yaml'); - // Built-in modules use BMad version (only core and bmm are in BMAD-METHOD repo) + // Resolve source type first, then read version with the correct path context if (['core', 'bmm'].includes(moduleName)) { - const bmadVersion = require(path.join(getProjectRoot(), 'package.json')).version; + const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); return { - version: bmadVersion, + version, source: 'built-in', npmPackage: null, repoUrl: null, @@ -857,42 +856,20 @@ class Manifest { const moduleInfo = await extMgr.getModuleByCode(moduleName); if (moduleInfo) { - // External module - try to get version from npm registry first, then fall back to cache - let version = null; - - if (moduleInfo.npmPackage) { - // Fetch version from npm registry - try { - version = await this.fetchNpmVersion(moduleInfo.npmPackage); - } catch { - // npm fetch failed, try cache as fallback - } - } - - // If npm didn't work, try reading from cached repo's package.json - if (!version) { - const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName); - const packageJsonPath = path.join(cacheDir, 'package.json'); - - if (await fs.pathExists(packageJsonPath)) { - try { - const pkg = require(packageJsonPath); - version = pkg.version; - } catch (error) { - await prompts.log.warn(`Failed to read package.json for ${moduleName}: ${error.message}`); - } - } - } - + // External module: use moduleSourcePath if provided, otherwise fall back to cache + const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); return { - version: version, + version, source: 'external', npmPackage: moduleInfo.npmPackage || null, repoUrl: moduleInfo.url || null, }; } - // Custom module - check cache directory + // Custom module: resolve path from source or cache before reading version + const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName); + const version = await this._readMarketplaceVersion(moduleName, customSourcePath); + const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName); const moduleYamlPath = path.join(cacheDir, 'module.yaml'); @@ -901,7 +878,7 @@ class Manifest { const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); const moduleConfig = yaml.parse(yamlContent); return { - version: moduleConfig.version || null, + version: version || moduleConfig.version || null, source: 'custom', npmPackage: moduleConfig.npmPackage || null, repoUrl: moduleConfig.repoUrl || null, @@ -913,13 +890,62 @@ class Manifest { // Unknown module return { - version: null, + version, source: 'unknown', npmPackage: null, repoUrl: null, }; } + /** + * Read version from .claude-plugin/marketplace.json for a module + * @param {string} moduleName - Module code + * @returns {string|null} Version or null + */ + async _readMarketplaceVersion(moduleName, moduleSourcePath = null) { + const os = require('node:os'); + let marketplacePath; + + if (['core', 'bmm'].includes(moduleName)) { + marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json'); + } else if (moduleSourcePath) { + // Walk up from source path to find marketplace.json + let dir = moduleSourcePath; + for (let i = 0; i < 5; i++) { + const candidate = path.join(dir, '.claude-plugin', 'marketplace.json'); + if (await fs.pathExists(candidate)) { + marketplacePath = candidate; + break; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + } + + // Fallback to external module cache + if (!marketplacePath) { + const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleName); + marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json'); + } + + try { + if (await fs.pathExists(marketplacePath)) { + const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); + const plugins = data?.plugins; + if (!Array.isArray(plugins) || plugins.length === 0) return null; + let best = null; + for (const p of plugins) { + if (p.version && (!best || p.version > best)) best = p.version; + } + return best; + } + } catch { + // ignore + } + return null; + } + /** * Fetch latest version from npm for a package * @param {string} packageName - npm package name diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index 603ffc7a4..ec7dcaad6 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -86,7 +86,7 @@ class ConfigDrivenIdeSetup { if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); // Clean up any old BMAD installation first - await this.cleanup(projectDir, options); + await this.cleanup(projectDir, options, bmadDir); if (!this.installerConfig) { return { success: false, reason: 'no-config' }; @@ -215,15 +215,34 @@ class ConfigDrivenIdeSetup { * Cleanup IDE configuration * @param {string} projectDir - Project directory */ - async cleanup(projectDir, options = {}) { + async cleanup(projectDir, options = {}, bmadDir = null) { + const resolvedBmadDir = bmadDir || (await this._findBmadDir(projectDir)); + + // Build removal set: previously installed skills + removals.txt entries + let removalSet; + if (options.previousSkillIds && options.previousSkillIds.size > 0) { + // Install/update flow: use pre-captured skill IDs (before manifest was overwritten) + removalSet = new Set(options.previousSkillIds); + if (resolvedBmadDir) { + const removals = await this.loadRemovalLists(resolvedBmadDir); + for (const entry of removals) removalSet.add(entry); + } + } else if (resolvedBmadDir) { + // Uninstall flow: read from current skill-manifest.csv + removals.txt + removalSet = await this._buildUninstallSet(resolvedBmadDir); + } else { + removalSet = new Set(); + } + // Migrate legacy target directories (e.g. .opencode/agent → .opencode/agents) + // Legacy dirs are abandoned entirely, so use prefix matching (null removalSet) if (this.installerConfig?.legacy_targets) { if (!options.silent) await prompts.log.message(' Migrating legacy directories...'); for (const legacyDir of this.installerConfig.legacy_targets) { if (this.isGlobalPath(legacyDir)) { await this.warnGlobalLegacy(legacyDir, options); } else { - await this.cleanupTarget(projectDir, legacyDir, options); + await this.cleanupTarget(projectDir, legacyDir, options, null); await this.removeEmptyParents(projectDir, legacyDir); } } @@ -244,9 +263,9 @@ class ConfigDrivenIdeSetup { await this.cleanupRovoDevPrompts(projectDir, options); } - // Clean target directory + // Clean current target directory if (this.installerConfig?.target_dir) { - await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options); + await this.cleanupTarget(projectDir, this.installerConfig.target_dir, options, removalSet); } } @@ -286,23 +305,117 @@ class ConfigDrivenIdeSetup { } /** - * Cleanup a specific target directory + * Find the _bmad directory in a project + * @param {string} projectDir - Project directory + * @returns {string|null} Path to bmad dir or null + */ + async _findBmadDir(projectDir) { + const bmadDir = path.join(projectDir, BMAD_FOLDER_NAME); + return (await fs.pathExists(bmadDir)) ? bmadDir : null; + } + + /** + * Build the full set of entries to remove for uninstall. + * Reads skill-manifest.csv to know exactly what was installed, plus removal lists. + * @param {string} bmadDir - BMAD installation directory + * @returns {Set} Set of entries to remove + */ + async _buildUninstallSet(bmadDir) { + const removals = await this.loadRemovalLists(bmadDir); + + // Also add all currently installed skills from skill-manifest.csv + const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); + try { + if (await fs.pathExists(csvPath)) { + const content = await fs.readFile(csvPath, 'utf8'); + const records = csv.parse(content, { columns: true, skip_empty_lines: true }); + for (const record of records) { + if (record.canonicalId) { + removals.add(record.canonicalId); + } + } + } + } catch { + // If we can't read the manifest, we still have the removal lists + } + + return removals; + } + + /** + * Load removal lists from all module sources in the bmad directory. + * Each module can have an optional removals.txt listing entries to remove. + * @param {string} bmadDir - BMAD installation directory + * @returns {Set} Set of entries to remove + */ + async loadRemovalLists(bmadDir) { + const removals = new Set(); + const { getProjectRoot } = require('../project-root'); + + // Read project-level removals.txt (covers core and bmm) + const projectRemovalsPath = path.join(getProjectRoot(), 'removals.txt'); + await this._readRemovalFile(projectRemovalsPath, removals); + + // Read per-module removals.txt from installed module directories + try { + const entries = await fs.readdir(bmadDir); + for (const entry of entries) { + if (entry.startsWith('_')) continue; + const removalPath = path.join(bmadDir, entry, 'removals.txt'); + await this._readRemovalFile(removalPath, removals); + } + } catch { + // bmadDir may not exist yet on fresh install + } + + return removals; + } + + /** + * Read a removals.txt file and add entries to the set + * @param {string} filePath - Path to removals.txt + * @param {Set} removals - Set to add entries to + */ + async _readRemovalFile(filePath, removals) { + try { + if (await fs.pathExists(filePath)) { + const content = await fs.readFile(filePath, 'utf8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + removals.add(trimmed); + } + } + } + } catch { + // Optional file — ignore errors + } + } + + /** + * Cleanup a specific target directory. + * When removalSet is provided, only removes entries in that set. + * When removalSet is null (legacy dirs), removes all bmad-prefixed entries. * @param {string} projectDir - Project directory * @param {string} targetDir - Target directory to clean + * @param {Object} options - Cleanup options + * @param {Set|null} removalSet - Entries to remove, or null for legacy prefix matching */ - async cleanupTarget(projectDir, targetDir, options = {}) { + async cleanupTarget(projectDir, targetDir, options = {}, removalSet = new Set()) { const targetPath = path.join(projectDir, targetDir); if (!(await fs.pathExists(targetPath))) { return; } - // Remove all bmad* files + if (removalSet && removalSet.size === 0) { + return; + } + let entries; try { entries = await fs.readdir(targetPath); } catch { - // Directory exists but can't be read - skip cleanup return; } @@ -313,23 +426,26 @@ class ConfigDrivenIdeSetup { let removedCount = 0; for (const entry of entries) { - if (!entry || typeof entry !== 'string') { - continue; - } - if (entry.startsWith('bmad') && !entry.startsWith('bmad-os-')) { - const entryPath = path.join(targetPath, entry); + if (!entry || typeof entry !== 'string') continue; + + // Always preserve bmad-os-* utility skills regardless of cleanup mode + if (entry.startsWith('bmad-os-')) continue; + + // Surgical removal from set, or legacy prefix matching when set is null + const shouldRemove = removalSet ? removalSet.has(entry) : entry.startsWith('bmad'); + + if (shouldRemove) { try { - await fs.remove(entryPath); + await fs.remove(path.join(targetPath, entry)); removedCount++; } catch { - // Skip entries that can't be removed (broken symlinks, permission errors) + // Skip entries that can't be removed } } } - if (removedCount > 0 && !options.silent) { - await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`); - } + // Only log cleanup when it's not a routine reinstall (legacy dir cleanup or actual removals) + // Suppress for current target_dir since it's always cleaned before a fresh write // Remove empty directory after cleanup if (removedCount > 0) { @@ -339,7 +455,7 @@ class ConfigDrivenIdeSetup { await fs.remove(targetPath); } } catch { - // Directory may already be gone or in use — skip + // Directory may already be gone or in use } } } diff --git a/tools/installer/install-messages.yaml b/tools/installer/install-messages.yaml index 0fc32cc82..4aff87a95 100644 --- a/tools/installer/install-messages.yaml +++ b/tools/installer/install-messages.yaml @@ -6,32 +6,25 @@ startMessage: | ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - 🎉 V6 IS HERE! Welcome to BMad Method V6 - Official Stable Release! + Agile AI-Driven Development. Powered by BMad Core and a growing module ecosystem. + Install official and community modules during setup to customize your experience. - The BMad Method is now a Platform powered by the BMad Method Core and Module Ecosystem! - - Select and install modules during setup - customize your experience - - New BMad Method for Agile AI-Driven Development (the evolution of V4) - - Exciting new modules available during installation, with community modules coming soon - - Documentation: https://docs.bmad-method.org + 🌟 100% free. 100% open source. Always. + No paywalls. No gated content. Knowledge shared, not sold. - 🌟 BMad is 100% free and open source. - - No gated Discord. No paywalls. No gated content. - - We believe in empowering everyone, not just those who can pay. - - Knowledge should be shared, not sold. + 🌐 CONNECT: + Website: https://bmadcode.com/ + Discord: https://discord.gg/gk8jAdXWmj + YouTube: https://www.youtube.com/@BMadCode + X: https://x.com/BMadCode + Facebook: https://facebook.com/@BMadCode - 🎤 SPEAKING & MEDIA: - - Available for conferences, podcasts, and media appearances - - Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method - - For speaking inquiries or interviews, reach out to BMad on Discord! + ⭐ SUPPORT THE PROJECT: + Star us: https://github.com/bmad-code-org/BMAD-METHOD/ + Donate: https://buymeacoffee.com/bmad + Corporate sponsorship and speaking inquiries: contact@bmadcode.com - ⭐ HELP US GROW: - - Star us on GitHub: https://github.com/bmad-code-org/BMAD-METHOD/ - - Subscribe on YouTube: https://www.youtube.com/@BMadCode - - Free Community and Support: https://discord.gg/gk8jAdXWmj - - Donate: https://buymeacoffee.com/bmad - - Corporate Sponsorship available - - Latest updates: https://github.com/bmad-code-org/BMAD-METHOD/blob/main/CHANGELOG.md + Docs, blog, and latest updates: https://bmadcode.com/ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tools/installer/ui.js b/tools/installer/ui.js index 03d38e4da..cccf219cc 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -4,8 +4,50 @@ const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); const { CustomHandler } = require('./custom-handler'); const { ExternalModuleManager } = require('./modules/external-manager'); +const { getProjectRoot } = require('./project-root'); const prompts = require('./prompts'); +/** + * Read module version from .claude-plugin/marketplace.json + * @param {string} moduleCode - Module code (e.g., 'core', 'bmm', 'cis') + * @returns {string} Version string or empty string + */ +async function getMarketplaceVersion(moduleCode) { + let marketplacePath; + if (moduleCode === 'core' || moduleCode === 'bmm') { + marketplacePath = path.join(getProjectRoot(), '.claude-plugin', 'marketplace.json'); + } else { + const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules', moduleCode); + marketplacePath = path.join(cacheDir, '.claude-plugin', 'marketplace.json'); + } + try { + if (await fs.pathExists(marketplacePath)) { + const data = JSON.parse(await fs.readFile(marketplacePath, 'utf8')); + return _extractMarketplaceVersion(data); + } + } catch { + // ignore + } + return ''; +} + +/** + * Extract the highest version from marketplace.json plugins array. + * Handles multiple plugins per file safely. + * @param {Object} data - Parsed marketplace.json + * @returns {string} Version string or empty string + */ +function _extractMarketplaceVersion(data) { + const plugins = data?.plugins; + if (!Array.isArray(plugins) || plugins.length === 0) return ''; + // Use the highest version across all plugins in the file + let best = ''; + for (const p of plugins) { + if (p.version && (!best || p.version > best)) best = p.version; + } + return best; +} + // Separator class for visual grouping in select/multiselect prompts // Note: @clack/prompts doesn't support separators natively, they are filtered out class Separator { @@ -70,17 +112,14 @@ class UI { if (hasExistingInstall) { // Get version information const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory); - const packageJsonPath = path.join(__dirname, '../../package.json'); - const currentVersion = require(packageJsonPath).version; - const installedVersion = existingInstall.installed ? existingInstall.version || 'unknown' : 'unknown'; // Build menu choices dynamically const choices = []; // Always show Quick Update first (allows refreshing installation even on same version) - if (installedVersion !== 'unknown') { + if (existingInstall.installed) { choices.push({ - name: `Quick Update (v${installedVersion} → v${currentVersion})`, + name: 'Quick Update', value: 'quick-update', }); } @@ -880,14 +919,18 @@ class UI { const lockedValues = ['core']; // Core module is always installed — show it locked at the top - allOptions.push({ label: 'BMad Core Module', value: 'core', hint: 'Core configuration and shared resources' }); + const coreVersion = await getMarketplaceVersion('core'); + const coreLabel = coreVersion ? `BMad Core Module (v${coreVersion})` : 'BMad Core Module'; + allOptions.push({ label: coreLabel, value: 'core', hint: 'Core configuration and shared resources' }); initialValues.push('core'); // Helper to build module entry with proper sorting and selection - const buildModuleEntry = (mod, value, group) => { + const buildModuleEntry = async (mod, value, group) => { const isInstalled = installedModuleIds.has(value); + const version = await getMarketplaceVersion(value); + const label = version ? `${mod.name} (v${version})` : mod.name; return { - label: mod.name, + label, value, hint: mod.description || group, // Pre-select only if already installed (not on fresh install) @@ -899,7 +942,7 @@ class UI { const localEntries = []; for (const mod of localModules) { if (!mod.isCustom && mod.id !== 'core') { - const entry = buildModuleEntry(mod, mod.id, 'Local'); + const entry = await buildModuleEntry(mod, mod.id, 'Local'); localEntries.push(entry); if (entry.selected) { initialValues.push(mod.id); @@ -912,7 +955,7 @@ class UI { const officialModules = []; for (const mod of externalModules) { if (mod.type === 'bmad-org') { - const entry = buildModuleEntry(mod, mod.code, 'Official'); + const entry = await buildModuleEntry(mod, mod.code, 'Official'); officialModules.push(entry); if (entry.selected) { initialValues.push(mod.code); @@ -925,7 +968,7 @@ class UI { const communityModules = []; for (const mod of externalModules) { if (mod.type === 'community') { - const entry = buildModuleEntry(mod, mod.code, 'Community'); + const entry = await buildModuleEntry(mod, mod.code, 'Community'); communityModules.push(entry); if (entry.selected) { initialValues.push(mod.code); From 6cecab2626bc0eb65286c312ddcc95f3d6569b88 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 7 Apr 2026 10:02:59 -0700 Subject: [PATCH 2/4] chore(install): stop copying skill prompts to _bmad by default (#2182) * chore(install): stop copying skill prompts to _bmad by default Flip install_to_bmad default from true to false so skill directories are cleaned from _bmad/ after IDE install. Skills are self-contained in their IDE directories (.claude/skills/, etc.) and no longer need duplicate copies in _bmad/. Two skills (bmad-create-prd, bmad-validate-prd) opt back in via explicit manifests because bmad-edit-prd cross-references their data files. Also fixes broken bmm-skills/ path references and corrects the file-ref validator module-to-source mapping. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(install): make edit-prd self-contained and remove install_to_bmad Give bmad-edit-prd its own copy of prd-purpose.md and replace the cross-skill validation workflow reference with a skill invocation, so all three PRD skills are fully self-contained. With no remaining consumers, remove the install_to_bmad flag from manifests, CSV output, the post-install cleanup loop, and the dedicated test file. * feat(install): clean up skill directories from _bmad after IDE install Skills are self-contained in IDE directories, so _bmad/ only needs module-level files (config.yaml, _config/). After all IDE setups complete, remove skill directories from _bmad/ via skill-manifest.csv. Also cleans up skill dirs left by older installer versions. * test(install): drop stale install_to_bmad column from suite 27 CSV row --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../bmad-edit-prd/data/prd-purpose.md | 197 ++++++++++++++++++ .../steps-e/step-e-01-discovery.md | 2 +- .../steps-e/step-e-01b-legacy-conversion.md | 2 +- .../bmad-edit-prd/steps-e/step-e-02-review.md | 2 +- .../bmad-edit-prd/steps-e/step-e-03-edit.md | 2 +- .../steps-e/step-e-04-complete.md | 4 +- test/test-install-to-bmad.js | 154 -------------- test/test-installation-components.js | 10 +- tools/installer/core/installer.js | 31 +++ tools/installer/core/manifest-generator.js | 14 +- tools/installer/ide/_config-driven.js | 12 -- tools/installer/ide/shared/skill-manifest.js | 17 +- tools/validate-file-refs.js | 11 +- 13 files changed, 251 insertions(+), 207 deletions(-) create mode 100644 src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md delete mode 100644 test/test-install-to-bmad.js diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md new file mode 100644 index 000000000..755230be7 --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/data/prd-purpose.md @@ -0,0 +1,197 @@ +# BMAD PRD Purpose + +**The PRD is the top of the required funnel that feeds all subsequent product development work in rhw BMad Method.** + +--- + +## What is a BMAD PRD? + +A dual-audience document serving: +1. **Human Product Managers and builders** - Vision, strategy, stakeholder communication +2. **LLM Downstream Consumption** - UX Design → Architecture → Epics → Development AI Agents + +Each successive document becomes more AI-tailored and granular. + +--- + +## Core Philosophy: Information Density + +**High Signal-to-Noise Ratio** + +Every sentence must carry information weight. LLMs consume precise, dense content efficiently. + +**Anti-Patterns (Eliminate These):** +- ❌ "The system will allow users to..." → ✅ "Users can..." +- ❌ "It is important to note that..." → ✅ State the fact directly +- ❌ "In order to..." → ✅ "To..." +- ❌ Conversational filler and padding → ✅ Direct, concise statements + +**Goal:** Maximum information per word. Zero fluff. + +--- + +## The Traceability Chain + +**PRD starts the chain:** +``` +Vision → Success Criteria → User Journeys → Functional Requirements → (future: User Stories) +``` + +**In the PRD, establish:** +- Vision → Success Criteria alignment +- Success Criteria → User Journey coverage +- User Journey → Functional Requirement mapping +- All requirements traceable to user needs + +**Why:** Each downstream artifact (UX, Architecture, Epics, Stories) must trace back to documented user needs and business objectives. This chain ensures we build the right thing. + +--- + +## What Makes Great Functional Requirements? + +### FRs are Capabilities, Not Implementation + +**Good FR:** "Users can reset their password via email link" +**Bad FR:** "System sends JWT via email and validates with database" (implementation leakage) + +**Good FR:** "Dashboard loads in under 2 seconds for 95th percentile" +**Bad FR:** "Fast loading time" (subjective, unmeasurable) + +### SMART Quality Criteria + +**Specific:** Clear, precisely defined capability +**Measurable:** Quantifiable with test criteria +**Attainable:** Realistic within constraints +**Relevant:** Aligns with business objectives +**Traceable:** Links to source (executive summary or user journey) + +### FR Anti-Patterns + +**Subjective Adjectives:** +- ❌ "easy to use", "intuitive", "user-friendly", "fast", "responsive" +- ✅ Use metrics: "completes task in under 3 clicks", "loads in under 2 seconds" + +**Implementation Leakage:** +- ❌ Technology names, specific libraries, implementation details +- ✅ Focus on capability and measurable outcomes + +**Vague Quantifiers:** +- ❌ "multiple users", "several options", "various formats" +- ✅ "up to 100 concurrent users", "3-5 options", "PDF, DOCX, TXT formats" + +**Missing Test Criteria:** +- ❌ "The system shall provide notifications" +- ✅ "The system shall send email notifications within 30 seconds of trigger event" + +--- + +## What Makes Great Non-Functional Requirements? + +### NFRs Must Be Measurable + +**Template:** +``` +"The system shall [metric] [condition] [measurement method]" +``` + +**Examples:** +- ✅ "The system shall respond to API requests in under 200ms for 95th percentile as measured by APM monitoring" +- ✅ "The system shall maintain 99.9% uptime during business hours as measured by cloud provider SLA" +- ✅ "The system shall support 10,000 concurrent users as measured by load testing" + +### NFR Anti-Patterns + +**Unmeasurable Claims:** +- ❌ "The system shall be scalable" → ✅ "The system shall handle 10x load growth through horizontal scaling" +- ❌ "High availability required" → ✅ "99.9% uptime as measured by cloud provider SLA" + +**Missing Context:** +- ❌ "Response time under 1 second" → ✅ "API response time under 1 second for 95th percentile under normal load" + +--- + +## Domain-Specific Requirements + +**Auto-Detect and Enforce Based on Project Context** + +Certain industries have mandatory requirements that must be present: + +- **Healthcare:** HIPAA Privacy & Security Rules, PHI encryption, audit logging, MFA +- **Fintech:** PCI-DSS Level 1, AML/KYC compliance, SOX controls, financial audit trails +- **GovTech:** NIST framework, Section 508 accessibility (WCAG 2.1 AA), FedRAMP, data residency +- **E-Commerce:** PCI-DSS for payments, inventory accuracy, tax calculation by jurisdiction + +**Why:** Missing these requirements in the PRD means they'll be missed in architecture and implementation, creating expensive rework. During PRD creation there is a step to cover this - during validation we want to make sure it was covered. For this purpose steps will utilize a domain-complexity.csv and project-types.csv. + +--- + +## Document Structure (Markdown, Human-Readable) + +### Required Sections +1. **Executive Summary** - Vision, differentiator, target users +2. **Success Criteria** - Measurable outcomes (SMART) +3. **Product Scope** - MVP, Growth, Vision phases +4. **User Journeys** - Comprehensive coverage +5. **Domain Requirements** - Industry-specific compliance (if applicable) +6. **Innovation Analysis** - Competitive differentiation (if applicable) +7. **Project-Type Requirements** - Platform-specific needs +8. **Functional Requirements** - Capability contract (FRs) +9. **Non-Functional Requirements** - Quality attributes (NFRs) + +### Formatting for Dual Consumption + +**For Humans:** +- Clear, professional language +- Logical flow from vision to requirements +- Easy for stakeholders to review and approve + +**For LLMs:** +- ## Level 2 headers for all main sections (enables extraction) +- Consistent structure and patterns +- Precise, testable language +- High information density + +--- + +## Downstream Impact + +**How the PRD Feeds Next Artifacts:** + +**UX Design:** +- User journeys → interaction flows +- FRs → design requirements +- Success criteria → UX metrics + +**Architecture:** +- FRs → system capabilities +- NFRs → architecture decisions +- Domain requirements → compliance architecture +- Project-type requirements → platform choices + +**Epics & Stories (created after architecture):** +- FRs → user stories (1 FR could map to 1-3 stories potentially) +- Acceptance criteria → story acceptance tests +- Priority → sprint sequencing +- Traceability → stories map back to vision + +**Development AI Agents:** +- Precise requirements → implementation clarity +- Test criteria → automated test generation +- Domain requirements → compliance enforcement +- Measurable NFRs → performance targets + +--- + +## Summary: What Makes a Great BMAD PRD? + +✅ **High Information Density** - Every sentence carries weight, zero fluff +✅ **Measurable Requirements** - All FRs and NFRs are testable with specific criteria +✅ **Clear Traceability** - Each requirement links to user need and business objective +✅ **Domain Awareness** - Industry-specific requirements auto-detected and included +✅ **Zero Anti-Patterns** - No subjective adjectives, implementation leakage, or vague quantifiers +✅ **Dual Audience Optimized** - Human-readable AND LLM-consumable +✅ **Markdown Format** - Professional, clean, accessible to all stakeholders + +--- + +**Remember:** The PRD is the foundation. Quality here ripples through every subsequent phase. A dense, precise, well-traced PRD makes UX design, architecture, epic breakdown, and AI development dramatically more effective. diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md index ed9381338..39e344946 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01-discovery.md @@ -1,6 +1,6 @@ --- # File references (ONLY variables used in this step) -prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md' +prdPurpose: '../data/prd-purpose.md' --- # Step E-1: Discovery & Understanding diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md index 55948f378..54f82525b 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-01b-legacy-conversion.md @@ -1,7 +1,7 @@ --- # File references (ONLY variables used in this step) prdFile: '{prd_file_path}' -prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md' +prdPurpose: '../data/prd-purpose.md' --- # Step E-1B: Legacy PRD Conversion Assessment diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md index 22706b4c7..c01a0adb9 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-02-review.md @@ -2,7 +2,7 @@ # File references (ONLY variables used in this step) prdFile: '{prd_file_path}' validationReport: '{validation_report_path}' # If provided -prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md' +prdPurpose: '../data/prd-purpose.md' --- # Step E-2: Deep Review & Analysis diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md index 1f7e595a0..5b5e66902 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-03-edit.md @@ -1,7 +1,7 @@ --- # File references (ONLY variables used in this step) prdFile: '{prd_file_path}' -prdPurpose: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-create-prd/data/prd-purpose.md' +prdPurpose: '../data/prd-purpose.md' --- # Step E-3: Edit & Update diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md index 4ab9d05ea..1406e631c 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/steps-e/step-e-04-complete.md @@ -1,7 +1,6 @@ --- # File references (ONLY variables used in this step) prdFile: '{prd_file_path}' -validationWorkflow: '{project-root}/_bmad/bmm-skills/2-plan-workflows/bmad-validate-prd/steps-v/step-v-01-discovery.md' --- # Step E-4: Complete & Validate @@ -117,8 +116,7 @@ Display: - Display: "This will run all 13 validation checks on the updated PRD." - Display: "Preparing to validate: {prd_file_path}" - Display: "**Proceeding to validation...**" - - Read fully and follow: {validationWorkflow} (steps-v/step-v-01-discovery.md) - - Note: This hands off to the validation workflow which will run its complete 13-step process + - Invoke the `bmad-validate-prd` skill to run the complete validation workflow - **IF E (Edit More):** - Display: "**Additional Edits**" diff --git a/test/test-install-to-bmad.js b/test/test-install-to-bmad.js deleted file mode 100644 index d33218eb8..000000000 --- a/test/test-install-to-bmad.js +++ /dev/null @@ -1,154 +0,0 @@ -/** - * install_to_bmad Flag — Design Contract Tests - * - * Unit tests against the functions that implement the install_to_bmad flag. - * These nail down the 4 core design decisions: - * - * 1. true/omitted → skill stays in _bmad/ (default behavior) - * 2. false → skill removed from _bmad/ after IDE install - * 3. No platform → no cleanup runs (cleanup lives in installVerbatimSkills) - * 4. Mixed flags → each skill evaluated independently - * - * Usage: node test/test-install-to-bmad.js - */ - -const path = require('node:path'); -const os = require('node:os'); -const fs = require('fs-extra'); -const { loadSkillManifest, getInstallToBmad } = require('../tools/installer/ide/shared/skill-manifest'); - -// ANSI colors -const colors = { - reset: '\u001B[0m', - green: '\u001B[32m', - red: '\u001B[31m', - yellow: '\u001B[33m', - cyan: '\u001B[36m', - dim: '\u001B[2m', -}; - -let passed = 0; -let failed = 0; - -function assert(condition, testName, errorMessage = '') { - if (condition) { - console.log(`${colors.green}✓${colors.reset} ${testName}`); - passed++; - } else { - console.log(`${colors.red}✗${colors.reset} ${testName}`); - if (errorMessage) { - console.log(` ${colors.dim}${errorMessage}${colors.reset}`); - } - failed++; - } -} - -async function runTests() { - console.log(`${colors.cyan}========================================`); - console.log('install_to_bmad — Design Contract Tests'); - console.log(`========================================${colors.reset}\n`); - - // ============================================================ - // 1. true/omitted → getInstallToBmad returns true (keep in _bmad/) - // ============================================================ - console.log(`${colors.yellow}Design decision 1: true or omitted → skill stays in _bmad/${colors.reset}\n`); - - // Null manifest (no bmad-skill-manifest.yaml) → true - assert(getInstallToBmad(null, 'workflow.md') === true, 'null manifest defaults to true'); - - // Single-entry, flag omitted → true - assert( - getInstallToBmad({ __single: { type: 'skill' } }, 'workflow.md') === true, - 'single-entry manifest with flag omitted defaults to true', - ); - - // Single-entry, explicit true → true - assert( - getInstallToBmad({ __single: { type: 'skill', install_to_bmad: true } }, 'workflow.md') === true, - 'single-entry manifest with explicit true returns true', - ); - - console.log(''); - - // ============================================================ - // 2. false → getInstallToBmad returns false (remove from _bmad/) - // ============================================================ - console.log(`${colors.yellow}Design decision 2: false → skill removed from _bmad/${colors.reset}\n`); - - // Single-entry, explicit false → false - assert( - getInstallToBmad({ __single: { type: 'skill', install_to_bmad: false } }, 'workflow.md') === false, - 'single-entry manifest with explicit false returns false', - ); - - // loadSkillManifest round-trip: YAML with false is preserved through load - { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-itb-')); - await fs.writeFile(path.join(tmpDir, 'bmad-skill-manifest.yaml'), 'type: skill\ninstall_to_bmad: false\n'); - const loaded = await loadSkillManifest(tmpDir); - assert(getInstallToBmad(loaded, 'workflow.md') === false, 'loadSkillManifest preserves install_to_bmad: false through round-trip'); - await fs.remove(tmpDir); - } - - console.log(''); - - // ============================================================ - // 3. No platform → cleanup only runs inside installVerbatimSkills - // (This is a design invariant: getInstallToBmad is only consulted - // during IDE install. Without a platform, the flag has no effect.) - // ============================================================ - console.log(`${colors.yellow}Design decision 3: flag is a per-skill property, not a pipeline gate${colors.reset}\n`); - - // The flag value is stored but doesn't trigger any side effects by itself. - // Cleanup is driven by reading the CSV column inside installVerbatimSkills. - // We verify the flag is just data — getInstallToBmad doesn't touch the filesystem. - { - const manifest = { __single: { type: 'skill', install_to_bmad: false } }; - const result = getInstallToBmad(manifest, 'workflow.md'); - assert(typeof result === 'boolean', 'getInstallToBmad returns a boolean (pure data, no side effects)'); - assert(result === false, 'false value is faithfully returned for consumer to act on'); - } - - console.log(''); - - // ============================================================ - // 4. Mixed flags → each skill evaluated independently - // ============================================================ - console.log(`${colors.yellow}Design decision 4: mixed flags — each skill independent${colors.reset}\n`); - - // Multi-entry manifest: different files can have different flags - { - const manifest = { - 'workflow.md': { type: 'skill', install_to_bmad: false }, - 'other.md': { type: 'skill', install_to_bmad: true }, - }; - assert(getInstallToBmad(manifest, 'workflow.md') === false, 'multi-entry: workflow.md with false returns false'); - assert(getInstallToBmad(manifest, 'other.md') === true, 'multi-entry: other.md with true returns true'); - assert(getInstallToBmad(manifest, 'unknown.md') === true, 'multi-entry: unknown file defaults to true'); - } - - console.log(''); - - // ============================================================ - // Summary - // ============================================================ - console.log(`${colors.cyan}========================================`); - console.log('Results:'); - console.log(` Passed: ${colors.green}${passed}${colors.reset}`); - console.log(` Failed: ${colors.red}${failed}${colors.reset}`); - console.log(`========================================${colors.reset}\n`); - - if (failed === 0) { - console.log(`${colors.green}All install_to_bmad contract tests passed!${colors.reset}\n`); - process.exit(0); - } else { - console.log(`${colors.red}Some install_to_bmad contract tests failed${colors.reset}\n`); - process.exit(1); - } -} - -runTests().catch((error) => { - console.error(`${colors.red}Test runner failed:${colors.reset}`, error.message); - console.error(error.stack); - process.exit(1); -}); diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 1ac4b386d..6913a6bf5 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -59,8 +59,8 @@ async function createTestBmadFixture() { await fs.writeFile( path.join(fixtureDir, '_config', 'skill-manifest.csv'), [ - 'canonicalId,name,description,module,path,install_to_bmad', - '"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md","true"', + 'canonicalId,name,description,module,path', + '"bmad-master","bmad-master","Minimal test agent fixture","core","_bmad/core/bmad-master/SKILL.md"', '', ].join('\n'), ); @@ -103,8 +103,8 @@ async function createSkillCollisionFixture() { await fs.writeFile( path.join(configDir, 'skill-manifest.csv'), [ - 'canonicalId,name,description,module,path,install_to_bmad', - '"bmad-help","bmad-help","Native help skill","core","_bmad/core/tasks/bmad-help/SKILL.md","true"', + 'canonicalId,name,description,module,path', + '"bmad-help","bmad-help","Native help skill","core","_bmad/core/tasks/bmad-help/SKILL.md"', '', ].join('\n'), ); @@ -1306,7 +1306,7 @@ async function runTests() { const existingCsv27 = await fs.readFile(path.join(configDir27, 'skill-manifest.csv'), 'utf8'); await fs.writeFile( path.join(configDir27, 'skill-manifest.csv'), - existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md","true"\n', + existingCsv27.trimEnd() + '\n"bmad-architect","bmad-architect","Architect","bmm","_bmad/bmm/agents/bmad-architect/SKILL.md"\n', ); // Run Claude Code setup (which triggers cleanup then install) diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index d75355d72..bc3b3ec20 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -132,6 +132,10 @@ class Installer { await this._setupIdes(config, allModules, paths, addResult, previousSkillIds); + // Skills are now in IDE directories — remove redundant copies from _bmad/. + // Also cleans up skill dirs left by older installer versions. + await this._cleanupSkillDirs(paths.bmadDir); + const restoreResult = await this._restoreUserFiles(paths, updateState); // Render consolidated summary @@ -413,6 +417,33 @@ class Installer { } } + /** + * Remove skill directories from _bmad/ after IDE installation. + * Skills are self-contained in IDE directories, so _bmad/ only needs + * module-level files (config.yaml, _config/, etc.). + * Also cleans up skill dirs left by older installer versions. + * @param {string} bmadDir - BMAD installation directory + */ + async _cleanupSkillDirs(bmadDir) { + const csv = require('csv-parse/sync'); + const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv'); + if (!(await fs.pathExists(csvPath))) return; + + const csvContent = await fs.readFile(csvPath, 'utf8'); + const records = csv.parse(csvContent, { columns: true, skip_empty_lines: true }); + const bmadFolderName = path.basename(bmadDir); + const bmadPrefix = bmadFolderName + '/'; + + for (const record of records) { + if (!record.path) continue; + const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path; + const sourceDir = path.dirname(path.join(bmadDir, relativePath)); + if (await fs.pathExists(sourceDir)) { + await fs.remove(sourceDir); + } + } + } + /** * Restore custom and modified files that were backed up before the update. * No-op for fresh installs (updateState is null). diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index bef6f2d23..74972d36e 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -9,7 +9,6 @@ const { loadSkillManifest: loadSkillManifestShared, getCanonicalId: getCanonicalIdShared, getArtifactType: getArtifactTypeShared, - getInstallToBmad: getInstallToBmadShared, } = require('../ide/shared/skill-manifest'); // Load package.json for version info @@ -42,11 +41,6 @@ class ManifestGenerator { return getArtifactTypeShared(manifest, filename); } - /** Delegate to shared skill-manifest module */ - getInstallToBmad(manifest, filename) { - return getInstallToBmadShared(manifest, filename); - } - /** * Clean text for CSV output by normalizing whitespace. * Note: Quote escaping is handled by escapeCsv() at write time. @@ -127,7 +121,7 @@ class ManifestGenerator { * Recursively walk a module directory tree, collecting native SKILL.md entrypoints. * A directory is discovered as a skill when it contains a SKILL.md file with * valid name/description frontmatter (name must match directory name). - * Manifest YAML is loaded only when present — for install_to_bmad and agent metadata. + * Manifest YAML is loaded only when present — for agent metadata. * Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths). */ async collectSkills() { @@ -156,7 +150,7 @@ class ManifestGenerator { const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug); if (skillMeta) { - // Load manifest when present (for install_to_bmad and agent metadata) + // Load manifest when present (for agent metadata) const manifest = await this.loadSkillManifest(dir); const artifactType = this.getArtifactType(manifest, skillFile); @@ -182,7 +176,6 @@ class ManifestGenerator { module: moduleName, path: installPath, canonicalId, - install_to_bmad: this.getInstallToBmad(manifest, skillFile), }); // Add to files list @@ -472,7 +465,7 @@ class ManifestGenerator { const csvPath = path.join(cfgDir, 'skill-manifest.csv'); const escapeCsv = (value) => `"${String(value ?? '').replaceAll('"', '""')}"`; - let csvContent = 'canonicalId,name,description,module,path,install_to_bmad\n'; + let csvContent = 'canonicalId,name,description,module,path\n'; for (const skill of this.skills) { const row = [ @@ -481,7 +474,6 @@ class ManifestGenerator { escapeCsv(skill.description), escapeCsv(skill.module), escapeCsv(skill.path), - escapeCsv(skill.install_to_bmad), ].join(','); csvContent += row + '\n'; } diff --git a/tools/installer/ide/_config-driven.js b/tools/installer/ide/_config-driven.js index ec7dcaad6..15791e112 100644 --- a/tools/installer/ide/_config-driven.js +++ b/tools/installer/ide/_config-driven.js @@ -183,18 +183,6 @@ class ConfigDrivenIdeSetup { count++; } - // Post-install cleanup: remove _bmad/ directories for skills with install_to_bmad === "false" - for (const record of records) { - if (record.install_to_bmad === 'false') { - const relativePath = record.path.startsWith(bmadPrefix) ? record.path.slice(bmadPrefix.length) : record.path; - const sourceFile = path.join(bmadDir, relativePath); - const sourceDir = path.dirname(sourceFile); - if (await fs.pathExists(sourceDir)) { - await fs.remove(sourceDir); - } - } - } - return count; } diff --git a/tools/installer/ide/shared/skill-manifest.js b/tools/installer/ide/shared/skill-manifest.js index c5ae4aed8..746d5d16f 100644 --- a/tools/installer/ide/shared/skill-manifest.js +++ b/tools/installer/ide/shared/skill-manifest.js @@ -54,19 +54,4 @@ function getArtifactType(manifest, filename) { return null; } -/** - * Get the install_to_bmad flag for a specific file from a loaded skill manifest. - * @param {Object|null} manifest - Loaded manifest (from loadSkillManifest) - * @param {string} filename - Source filename to look up - * @returns {boolean} install_to_bmad value (defaults to true) - */ -function getInstallToBmad(manifest, filename) { - if (!manifest) return true; - // Single-entry manifest applies to all files in the directory - if (manifest.__single) return manifest.__single.install_to_bmad !== false; - // Multi-entry: look up by filename directly - if (manifest[filename]) return manifest[filename].install_to_bmad !== false; - return true; -} - -module.exports = { loadSkillManifest, getCanonicalId, getArtifactType, getInstallToBmad }; +module.exports = { loadSkillManifest, getCanonicalId, getArtifactType }; diff --git a/tools/validate-file-refs.js b/tools/validate-file-refs.js index 5f412eb88..75a802967 100644 --- a/tools/validate-file-refs.js +++ b/tools/validate-file-refs.js @@ -156,8 +156,15 @@ function mapInstalledToSource(refPath) { // Skip install-only paths (generated at install time, not in source) if (isInstallOnly(cleaned)) return null; - // core/, bmm/, and utility/ are directly under src/ - if (cleaned.startsWith('core/') || cleaned.startsWith('bmm/') || cleaned.startsWith('utility/')) { + // Map installed module names to their source directory names + // _bmad/core/ → src/core-skills/, _bmad/bmm/ → src/bmm-skills/ + if (cleaned.startsWith('core/')) { + return path.join(SRC_DIR, 'core-skills', cleaned.slice('core/'.length)); + } + if (cleaned.startsWith('bmm/')) { + return path.join(SRC_DIR, 'bmm-skills', cleaned.slice('bmm/'.length)); + } + if (cleaned.startsWith('utility/')) { return path.join(SRC_DIR, cleaned); } From 9ca0316674b7284fede4324d335e30a55b0d5406 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 7 Apr 2026 10:14:24 -0700 Subject: [PATCH 3/4] refactor(quick-dev): eliminate spec-wip.md singleton (#2214) * refactor(quick-dev): eliminate spec-wip.md singleton Write directly to spec-{slug}.md with status: draft instead of using a shared spec-wip.md file. Use draft status for resume detection in step-01. Removes wipFile variable from all step frontmatter and workflow initialization. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(quick-dev): address PR review findings - step-02: preserve Intent block on draft resume instead of regenerating from template (F1) - step-01: resume existing draft on slug collision rather than creating -2 duplicate (F3) - step-01: recognize `done` status and ingest as context instead of silently re-implementing (F4) - step-oneshot: remove unused spec_file frontmatter declaration (F6) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../bmad-quick-dev/step-01-clarify-and-route.md | 9 ++++----- .../bmad-quick-dev/step-02-plan.md | 14 +++++++------- .../bmad-quick-dev/step-oneshot.md | 1 - .../4-implementation/bmad-quick-dev/workflow.md | 6 +----- 4 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md index da55cb9a0..5e04d8545 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md @@ -1,5 +1,4 @@ --- -wipFile: '{implementation_artifacts}/spec-wip.md' deferred_work_file: '{implementation_artifacts}/deferred-work.md' spec_file: '' # set at runtime for both routes before leaving this step --- @@ -21,7 +20,7 @@ Before listing artifacts or prompting the user, check whether you already know t 1. Explicit argument Did the user pass a specific file path, spec name, or clear instruction this message? - - If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: ready-for-dev, in-progress, or in-review) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-03 for ready/in-progress, step-04 for review). + - If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file` and **EARLY EXIT** to the appropriate step (step-02 for draft, step-03 for ready/in-progress, step-04 for review). For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume. - Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it. 2. Recent conversation @@ -29,8 +28,8 @@ Before listing artifacts or prompting the user, check whether you already know t Use the same routing as above. 3. Otherwise — scan artifacts and ask - - `{wipFile}` exists? → Offer resume or archive. - - Active specs (`ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new). + - Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new). + - If `draft` selected: Set `spec_file`. **EARLY EXIT** → `./step-02-plan.md` (resume planning from the draft) - If `ready-for-dev` or `in-progress` selected: Set `spec_file`. **EARLY EXIT** → `./step-03-implement.md` - If `in-review` selected: Set `spec_file`. **EARLY EXIT** → `./step-04-review.md` - Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it. @@ -65,7 +64,7 @@ Never ask extra questions if you already understand what the user intends. - On **K**: Proceed as-is. 5. Route — choose exactly one: - Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists, append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`. + Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `./step-02-plan.md`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`. **a) One-shot** — zero blast radius: no plausible path by which this change causes unintended consequences elsewhere. Clear intent, no architectural decisions. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md index 361d4c566..2ab75284c 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md @@ -1,5 +1,4 @@ --- -wipFile: '{implementation_artifacts}/spec-wip.md' deferred_work_file: '{implementation_artifacts}/deferred-work.md' --- @@ -12,11 +11,12 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md' ## INSTRUCTIONS -1. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._ -2. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, and write the result to `{wipFile}`. -3. Self-review against READY FOR DEVELOPMENT standard. -4. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human. -5. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens: +1. Draft resume check. If `{spec_file}` exists with `status: draft`, read it and capture the verbatim `...` block as `preserved_intent`. Otherwise `preserved_intent` is empty. +2. Investigate codebase. _Isolate deep exploration in sub-agents/tasks where available. To prevent context snowballing, instruct subagents to give you distilled summaries only._ +3. Read `./spec-template.md` fully. Fill it out based on the intent and investigation. If `{preserved_intent}` is non-empty, substitute it for the `` block in your filled spec before writing. Write the result to `{spec_file}`. +4. Self-review against READY FOR DEVELOPMENT standard. +5. If intent gaps exist, do not fantasize, do not leave open questions, HALT and ask the human. +6. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens: - Show user the token count. - HALT and ask human: `[S] Split — carve off secondary goals` | `[K] Keep full spec — accept the risks` - On **S**: Propose the split — name each secondary goal. Append deferred goals to `{deferred_work_file}`. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint. @@ -26,7 +26,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md' Present summary. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem. HALT and ask human: `[A] Approve` | `[E] Edit` -- **A**: Rename `{wipFile}` to `{spec_file}`, set status `ready-for-dev`. Everything inside `` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3. +- **A**: Set status `ready-for-dev` in `{spec_file}`. Everything inside `` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3. - **E**: Apply changes, then return to CHECKPOINT 1. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md index b6384159a..0c52d4328 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md @@ -1,6 +1,5 @@ --- deferred_work_file: '{implementation_artifacts}/deferred-work.md' -spec_file: '' # set by step-01 before entering this step --- # Step One-Shot: Implement, Review, Present diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md index f842532bf..55b8fda72 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md @@ -70,10 +70,6 @@ Load and read full config from `{main_config}` and resolve: YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`. -### 2. Paths - -- `wipFile` = `{implementation_artifacts}/spec-wip.md` - -### 3. First Step Execution +### 2. First Step Execution Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow. From 5dbfb588ee87341ad8906967c2cd9d2ee683a569 Mon Sep 17 00:00:00 2001 From: Brian Date: Tue, 7 Apr 2026 21:41:03 -0500 Subject: [PATCH 4/4] refactor(installer): remove custom content installation feature (#2227) * refactor(installer): remove custom content installation feature Remove the entire local filesystem custom content feature from the installer to make way for marketplace-based plugin installation. Deleted: custom-handler.js, custom-module-cache.js, custom-modules.js Removed: --custom-content CLI flag, interactive custom content prompts, custom module caching, manifest tracking, missing-source resolution, and related test suites. Updated docs across all translations. * fix: address review findings from Augment Fix admonition syntax (remove accidental space in :::note) across 4 translated docs files, and update stale JSDoc on listAvailable(). --- .../cs/how-to/non-interactive-installation.md | 18 - docs/fr/how-to/install-bmad.md | 2 +- .../fr/how-to/non-interactive-installation.md | 21 +- docs/how-to/install-bmad.md | 2 +- docs/how-to/non-interactive-installation.md | 21 +- docs/vi-vn/how-to/install-bmad.md | 2 +- .../how-to/non-interactive-installation.md | 21 +- docs/zh-cn/how-to/install-bmad.md | 2 +- .../how-to/non-interactive-installation.md | 21 +- test/test-installation-components.js | 151 ----- tools/installer/commands/install.js | 1 - tools/installer/core/custom-module-cache.js | 260 -------- tools/installer/core/existing-install.js | 10 +- tools/installer/core/install-paths.js | 3 - tools/installer/core/installer.js | 401 +----------- tools/installer/core/manifest-generator.js | 9 - tools/installer/core/manifest.js | 71 +- tools/installer/custom-handler.js | 112 ---- tools/installer/modules/custom-modules.js | 302 --------- tools/installer/modules/official-modules.js | 78 +-- tools/installer/ui.js | 614 +----------------- 21 files changed, 31 insertions(+), 2091 deletions(-) delete mode 100644 tools/installer/core/custom-module-cache.js delete mode 100644 tools/installer/custom-handler.js delete mode 100644 tools/installer/modules/custom-modules.js diff --git a/docs/cs/how-to/non-interactive-installation.md b/docs/cs/how-to/non-interactive-installation.md index f6b46c5e2..12ea31eb3 100644 --- a/docs/cs/how-to/non-interactive-installation.md +++ b/docs/cs/how-to/non-interactive-installation.md @@ -27,7 +27,6 @@ Vyžaduje [Node.js](https://nodejs.org) v20+ a `npx` (součástí npm). | `--directory ` | Instalační adresář | `--directory ~/projects/myapp` | | `--modules ` | Čárkou oddělená ID modulů | `--modules bmm,bmb` | | `--tools ` | Čárkou oddělená ID nástrojů/IDE (použijte `none` pro přeskočení) | `--tools claude-code,cursor` nebo `--tools none` | -| `--custom-content ` | Čárkou oddělené cesty k vlastním modulům | `--custom-content ~/my-module,~/another-module` | | `--action ` | Akce pro existující instalace: `install` (výchozí), `update` nebo `quick-update` | `--action quick-update` | ### Základní konfigurace @@ -108,16 +107,6 @@ npx bmad-method install \ --action quick-update ``` -### Instalace s vlastním obsahem - -```bash -npx bmad-method install \ - --directory ~/projects/myapp \ - --modules bmm \ - --custom-content ~/my-custom-module,~/another-module \ - --tools claude-code -``` - ## Co získáte - Plně nakonfigurovaný adresář `_bmad/` ve vašem projektu @@ -159,13 +148,6 @@ Neplatné hodnoty buď: - Ověřte, že ID modulu je správné - Externí moduly musí být dostupné v registru -### Neplatná cesta k vlastnímu obsahu - -Ujistěte se, že každá cesta k vlastnímu obsahu: -- Ukazuje na adresář -- Obsahuje soubor `module.yaml` v kořeni -- Má pole `code` v `module.yaml` - :::note[Stále jste uvízli?] Spusťte s `--debug` pro detailní výstup, zkuste interaktivní režim pro izolaci problému, nebo nahlaste na . ::: diff --git a/docs/fr/how-to/install-bmad.md b/docs/fr/how-to/install-bmad.md index 4f79743ea..c58f00c23 100644 --- a/docs/fr/how-to/install-bmad.md +++ b/docs/fr/how-to/install-bmad.md @@ -72,7 +72,7 @@ L'installateur affiche les modules disponibles. Sélectionnez ceux dont vous ave ### 5. Suivre les instructions -L'installateur vous guide pour le reste — contenu personnalisé, paramètres, etc. +L'installateur vous guide pour le reste — paramètres, intégrations d'outils, etc. ## Ce que vous obtenez diff --git a/docs/fr/how-to/non-interactive-installation.md b/docs/fr/how-to/non-interactive-installation.md index ee6ddad1c..87498285b 100644 --- a/docs/fr/how-to/non-interactive-installation.md +++ b/docs/fr/how-to/non-interactive-installation.md @@ -27,7 +27,6 @@ Nécessite [Node.js](https://nodejs.org) v20+ et `npx` (inclus avec npm). | `--directory ` | Répertoire d'installation | `--directory ~/projects/myapp` | | `--modules ` | IDs de modules séparés par des virgules | `--modules bmm,bmb` | | `--tools ` | IDs d'outils/IDE séparés par des virgules (utilisez `none` pour ignorer) | `--tools claude-code,cursor` ou `--tools none` | -| `--custom-content ` | Chemins vers des modules personnalisés séparés par des virgules | `--custom-content ~/my-module,~/another-module` | | `--action ` | Action pour les installations existantes : `install` (par défaut), `update`, ou `quick-update` | `--action quick-update` | ### Configuration principale @@ -120,16 +119,6 @@ npx bmad-method install \ --action quick-update ``` -### Installation avec du contenu personnalisé - -```bash -npx bmad-method install \ - --directory ~/projects/myapp \ - --modules bmm \ - --custom-content ~/my-custom-module,~/another-module \ - --tools claude-code -``` - ## Ce que vous obtenez - Un répertoire `_bmad/` entièrement configuré dans votre projet @@ -143,12 +132,11 @@ BMad valide toutes les options fournis : - **Directory** — Doit être un chemin valide avec des permissions d'écriture - **Modules** — Avertit des IDs de modules invalides (mais n'échoue pas) - **Tools** — Avertit des IDs d'outils invalides (mais n'échoue pas) -- **Custom Content** — Chaque chemin doit contenir un fichier `module.yaml` valide - **Action** — Doit être l'une des suivantes : `install`, `update`, `quick-update` Les valeurs invalides entraîneront soit : 1. L’affichage d’un message d'erreur suivi d’un exit (pour les options critiques comme le répertoire) -2. Un avertissement puis la continuation de l’installation (pour les éléments optionnels comme le contenu personnalisé) +2. Un avertissement puis la continuation de l’installation (pour les éléments optionnels) 3. Un retour aux invites interactives (pour les valeurs requises manquantes) :::tip[Bonnes pratiques] @@ -172,13 +160,6 @@ Les valeurs invalides entraîneront soit : - Vérifiez que l'ID du module est correct - Les modules externes doivent être disponibles dans le registre -### Chemin de contenu personnalisé invalide - -Assurez-vous que chaque chemin de contenu personnalisé : -- Pointe vers un répertoire -- Contient un fichier `module.yaml` à la racine -- Possède un champ `code` dans `module.yaml` - :::note[Toujours bloqué ?] Exécutez avec `--debug` pour une sortie détaillée, essayez le mode interactif pour isoler le problème, ou signalez-le à . ::: diff --git a/docs/how-to/install-bmad.md b/docs/how-to/install-bmad.md index 3789c6fa9..0913d1540 100644 --- a/docs/how-to/install-bmad.md +++ b/docs/how-to/install-bmad.md @@ -72,7 +72,7 @@ The installer shows available modules. Select whichever ones you need — most u ### 5. Follow the Prompts -The installer guides you through the rest — custom content, settings, etc. +The installer guides you through the rest — settings, tool integrations, etc. ## What You Get diff --git a/docs/how-to/non-interactive-installation.md b/docs/how-to/non-interactive-installation.md index 64687c0a1..07b4e9d21 100644 --- a/docs/how-to/non-interactive-installation.md +++ b/docs/how-to/non-interactive-installation.md @@ -27,7 +27,6 @@ Requires [Node.js](https://nodejs.org) v20+ and `npx` (included with npm). | `--directory ` | Installation directory | `--directory ~/projects/myapp` | | `--modules ` | Comma-separated module IDs | `--modules bmm,bmb` | | `--tools ` | Comma-separated tool/IDE IDs (use `none` to skip) | `--tools claude-code,cursor` or `--tools none` | -| `--custom-content ` | Comma-separated paths to custom modules | `--custom-content ~/my-module,~/another-module` | | `--action ` | Action for existing installations: `install` (default), `update`, or `quick-update` | `--action quick-update` | ### Core Configuration @@ -120,16 +119,6 @@ npx bmad-method install \ --action quick-update ``` -### Installation with Custom Content - -```bash -npx bmad-method install \ - --directory ~/projects/myapp \ - --modules bmm \ - --custom-content ~/my-custom-module,~/another-module \ - --tools claude-code -``` - ## What You Get - A fully configured `_bmad/` directory in your project @@ -143,12 +132,11 @@ BMad validates all provided flags: - **Directory** — Must be a valid path with write permissions - **Modules** — Warns about invalid module IDs (but won't fail) - **Tools** — Warns about invalid tool IDs (but won't fail) -- **Custom Content** — Each path must contain a valid `module.yaml` file - **Action** — Must be one of: `install`, `update`, `quick-update` Invalid values will either: 1. Show an error and exit (for critical options like directory) -2. Show a warning and skip (for optional items like custom content) +2. Show a warning and skip (for optional items) 3. Fall back to interactive prompts (for missing required values) :::tip[Best Practices] @@ -172,13 +160,6 @@ Invalid values will either: - Verify the module ID is correct - External modules must be available in the registry -### Custom content path invalid - -Ensure each custom content path: -- Points to a directory -- Contains a `module.yaml` file in the root -- Has a `code` field in the `module.yaml` - :::note[Still stuck?] Run with `--debug` for detailed output, try interactive mode to isolate the issue, or report at . ::: diff --git a/docs/vi-vn/how-to/install-bmad.md b/docs/vi-vn/how-to/install-bmad.md index 57105864c..c73e89388 100644 --- a/docs/vi-vn/how-to/install-bmad.md +++ b/docs/vi-vn/how-to/install-bmad.md @@ -72,7 +72,7 @@ Trình cài đặt sẽ hiện các module có sẵn. Chọn những module bạ ### 5. Làm theo các prompt -Trình cài đặt sẽ hướng dẫn các bước còn lại - nội dung tùy chỉnh, cài đặt, và các tùy chọn khác. +Trình cài đặt sẽ hướng dẫn các bước còn lại - cài đặt, tích hợp công cụ, và các tùy chọn khác. ## Bạn nhận được gì diff --git a/docs/vi-vn/how-to/non-interactive-installation.md b/docs/vi-vn/how-to/non-interactive-installation.md index 2ba75b7ec..968de3618 100644 --- a/docs/vi-vn/how-to/non-interactive-installation.md +++ b/docs/vi-vn/how-to/non-interactive-installation.md @@ -27,7 +27,6 @@ Yêu cầu [Node.js](https://nodejs.org) v20+ và `npx` (đi kèm với npm). | `--directory ` | Thư mục cài đặt | `--directory ~/projects/myapp` | | `--modules ` | Danh sách ID module, cách nhau bởi dấu phẩy | `--modules bmm,bmb` | | `--tools ` | Danh sách ID công cụ/IDE, cách nhau bởi dấu phẩy (dùng `none` để bỏ qua) | `--tools claude-code,cursor` hoặc `--tools none` | -| `--custom-content ` | Danh sách đường dẫn đến module tùy chỉnh, cách nhau bởi dấu phẩy | `--custom-content ~/my-module,~/another-module` | | `--action ` | Hành động cho bản cài đặt hiện có: `install` (mặc định), `update`, hoặc `quick-update` | `--action quick-update` | ### Cấu hình cốt lõi @@ -120,16 +119,6 @@ npx bmad-method install \ --action quick-update ``` -### Cài đặt với nội dung tùy chỉnh - -```bash -npx bmad-method install \ - --directory ~/projects/myapp \ - --modules bmm \ - --custom-content ~/my-custom-module,~/another-module \ - --tools claude-code -``` - ## Bạn nhận được gì - Thư mục `_bmad/` đã được cấu hình đầy đủ trong dự án của bạn @@ -143,12 +132,11 @@ BMad sẽ kiểm tra tất cả các cờ được cung cấp: - **Directory** - Phải là đường dẫn hợp lệ và có quyền ghi - **Modules** - Cảnh báo nếu ID module không hợp lệ (nhưng không thất bại) - **Tools** - Cảnh báo nếu ID công cụ không hợp lệ (nhưng không thất bại) -- **Custom Content** - Mỗi đường dẫn phải chứa tệp `module.yaml` hợp lệ - **Action** - Phải là một trong: `install`, `update`, `quick-update` Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp sau: 1. Hiện lỗi và thoát (với các tùy chọn quan trọng như directory) -2. Hiện cảnh báo và bỏ qua (với mục tùy chọn như custom content) +2. Hiện cảnh báo và bỏ qua (với mục tùy chọn) 3. Quay lại hỏi interactive (với giá trị bắt buộc bị thiếu) :::tip[Thực hành tốt] @@ -172,13 +160,6 @@ Giá trị không hợp lệ sẽ dẫn đến một trong các trường hợp - Xác minh ID module có đúng không - Module bên ngoài phải có sẵn trong registry -### Đường dẫn custom content không hợp lệ - -Đảm bảo mỗi đường dẫn custom content: -- Trỏ tới một thư mục -- Chứa tệp `module.yaml` ở cấp gốc -- Có trường `code` trong tệp `module.yaml` - :::note[Vẫn bị mắc?] Chạy với `--debug` để xem output chi tiết, thử chế độ interactive để cô lập vấn đề, hoặc báo cáo tại . ::: diff --git a/docs/zh-cn/how-to/install-bmad.md b/docs/zh-cn/how-to/install-bmad.md index e9fc1af9a..3c5ceff44 100644 --- a/docs/zh-cn/how-to/install-bmad.md +++ b/docs/zh-cn/how-to/install-bmad.md @@ -72,7 +72,7 @@ npx github:bmad-code-org/BMAD-METHOD install ### 5. 按照提示操作 -安装程序会引导你完成剩余步骤——自定义内容、设置等。 +安装程序会引导你完成剩余步骤——设置、工具集成等。 ## 你将获得 diff --git a/docs/zh-cn/how-to/non-interactive-installation.md b/docs/zh-cn/how-to/non-interactive-installation.md index df7259d97..788c18d52 100644 --- a/docs/zh-cn/how-to/non-interactive-installation.md +++ b/docs/zh-cn/how-to/non-interactive-installation.md @@ -27,7 +27,6 @@ sidebar: | `--directory ` | 安装目录 | `--directory ~/projects/myapp` | | `--modules ` | 逗号分隔的模块 ID | `--modules bmm,bmb` | | `--tools ` | 逗号分隔的工具/IDE ID(使用 `none` 跳过) | `--tools claude-code,cursor` 或 `--tools none` | -| `--custom-content ` | 逗号分隔的自定义模块路径 | `--custom-content ~/my-module,~/another-module` | | `--action ` | 对现有安装的操作:`install`(默认)、`update` 或 `quick-update` | `--action quick-update` | ### 核心配置 @@ -108,16 +107,6 @@ npx bmad-method install \ --action quick-update ``` -### 使用自定义内容安装 - -```bash -npx bmad-method install \ - --directory ~/projects/myapp \ - --modules bmm \ - --custom-content ~/my-custom-module,~/another-module \ - --tools claude-code -``` - ## 安装结果 - 项目中完全配置的 `_bmad/` 目录 @@ -131,12 +120,11 @@ BMad 会验证你提供的所有参数: - **目录** — 必须是具有写入权限的有效路径 - **模块** — 对无效的模块 ID 发出警告(但不会失败) - **工具** — 对无效的工具 ID 发出警告(但不会失败) -- **自定义内容** — 每个路径必须包含有效的 `module.yaml` 文件 - **操作** — 必须是以下之一:`install`、`update`、`quick-update` 无效值将: 1. 显示错误并退出(对于目录等关键选项) -2. 显示警告并跳过(对于自定义内容等可选项目) +2. 显示警告并跳过(对于可选项目) 3. 回退到交互式提示(对于缺失的必需值) :::tip[最佳实践] @@ -159,13 +147,6 @@ BMad 会验证你提供的所有参数: - 验证模块 ID 是否正确 - 外部模块必须在注册表中可用 -### 自定义内容路径无效 - -确保每个自定义内容路径: -- 指向一个目录 -- 在根目录中包含 `module.yaml` 文件 -- 在 `module.yaml` 中有 `code` 字段 - :::note[仍然卡住了?] 使用 `--debug` 获取详细输出,尝试交互模式定位问题,或在 提交反馈。 ::: diff --git a/test/test-installation-components.js b/test/test-installation-components.js index 6913a6bf5..82094165a 100644 --- a/test/test-installation-components.js +++ b/test/test-installation-components.js @@ -128,56 +128,6 @@ async function createSkillCollisionFixture() { return { root: fixtureRoot, bmadDir: fixtureDir }; } -async function createCustomModuleManifestFixture() { - const fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'bmad-custom-manifest-')); - const bmadDir = path.join(fixtureRoot, '_bmad'); - const configDir = path.join(bmadDir, '_config'); - const moduleSourceDir = path.join(fixtureRoot, 'test-module-source'); - await fs.ensureDir(configDir); - await fs.ensureDir(moduleSourceDir); - - const minimalAgent = 'p'; - await fs.ensureDir(path.join(bmadDir, 'core', 'agents')); - await fs.writeFile(path.join(bmadDir, 'core', 'agents', 'test.md'), minimalAgent); - await fs.ensureDir(path.join(bmadDir, 'test-module', 'agents')); - await fs.writeFile(path.join(bmadDir, 'test-module', 'agents', 'test.md'), minimalAgent); - await fs.writeFile(path.join(moduleSourceDir, 'module.yaml'), ['code: test-module', 'name: Test Module', ''].join('\n')); - - await fs.writeFile( - path.join(configDir, 'manifest.yaml'), - [ - 'installation:', - ' version: 6.2.2', - ' installDate: 2026-03-30T00:00:00.000Z', - ' lastUpdated: 2026-03-30T00:00:00.000Z', - 'modules:', - ' - name: core', - ' version: 6.2.2', - ' installDate: 2026-03-30T00:00:00.000Z', - ' lastUpdated: 2026-03-30T00:00:00.000Z', - ' source: built-in', - ' npmPackage: null', - ' repoUrl: null', - ' - name: test-module', - ' version: null', - ' installDate: 2026-03-30T00:00:00.000Z', - ' lastUpdated: 2026-03-30T00:00:00.000Z', - ' source: custom', - ' npmPackage: null', - ' repoUrl: null', - 'customModules:', - ' - id: test-module', - ' name: "Test Module"', - ` sourcePath: ${JSON.stringify(moduleSourceDir)}`, - 'ides:', - ' - codex', - '', - ].join('\n'), - ); - - return { root: fixtureRoot, bmadDir, manifestPath: path.join(configDir, 'manifest.yaml'), moduleSourceDir }; -} - /** * Test Suite */ @@ -1773,107 +1723,6 @@ async function runTests() { console.log(''); - // ============================================================ - // Suite 33: Main manifest preserves active customModules only - // ============================================================ - console.log(`${colors.yellow}Test Suite 33: Preserve active customModules in main manifest${colors.reset}\n`); - - let customManifestFixture = null; - try { - customManifestFixture = await createCustomModuleManifestFixture(); - const yaml = require('yaml'); - const originalManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8')); - originalManifest.customModules.push({ - id: 'removed-module', - name: 'Removed Module', - sourcePath: path.join(customManifestFixture.root, 'removed-module-source'), - }); - await fs.writeFile(customManifestFixture.manifestPath, yaml.stringify(originalManifest), 'utf8'); - - const generator33 = new ManifestGenerator(); - await generator33.generateManifests(customManifestFixture.bmadDir, ['core', 'test-module'], [], { ides: ['codex'] }); - - const updatedManifest = yaml.parse(await fs.readFile(customManifestFixture.manifestPath, 'utf8')); - const customModule = updatedManifest.customModules?.find((entry) => entry.id === 'test-module'); - - assert(Array.isArray(updatedManifest.customModules), 'Main manifest keeps customModules array'); - assert(customModule !== undefined, 'Main manifest preserves existing custom module entry'); - assert( - customModule && customModule.sourcePath === customManifestFixture.moduleSourceDir, - 'Main manifest preserves custom module sourcePath', - ); - assert( - !updatedManifest.customModules?.some((entry) => entry.id === 'removed-module'), - 'Main manifest drops stale custom module entries', - ); - } catch (error) { - assert(false, 'Main manifest preserves customModules test succeeds', error.message); - } finally { - if (customManifestFixture?.root) await fs.remove(customManifestFixture.root).catch(() => {}); - } - - console.log(''); - - // ============================================================ - // Suite 34: Quick update uses manifest-backed custom sources - // ============================================================ - console.log(`${colors.yellow}Test Suite 34: Quick update uses manifest-backed custom module sources${colors.reset}\n`); - - let quickUpdateFixture = null; - const originalListAvailable34 = OfficialModules.prototype.listAvailable; - const originalLoadExistingConfig34 = OfficialModules.prototype.loadExistingConfig; - const originalCollectModuleConfigQuick34 = OfficialModules.prototype.collectModuleConfigQuick; - try { - quickUpdateFixture = await createCustomModuleManifestFixture(); - const installer34 = new Installer(); - installer34.externalModuleManager.hasModule = async () => false; - installer34.externalModuleManager.listAvailable = async () => []; - - let capturedInstallConfig34 = null; - installer34.install = async (config) => { - capturedInstallConfig34 = config; - return { success: true }; - }; - - OfficialModules.prototype.listAvailable = async function () { - return { modules: [], customModules: [] }; - }; - OfficialModules.prototype.loadExistingConfig = async function () { - this.collectedConfig = this.collectedConfig || {}; - }; - OfficialModules.prototype.collectModuleConfigQuick = async function (moduleName) { - this.collectedConfig = this.collectedConfig || {}; - if (!this.collectedConfig[moduleName]) { - this.collectedConfig[moduleName] = {}; - } - return false; - }; - - await installer34.quickUpdate({ - directory: quickUpdateFixture.root, - skipPrompts: true, - }); - - const customModule34 = capturedInstallConfig34?._customModuleSources?.get('test-module'); - - assert(capturedInstallConfig34 !== null, 'Quick update forwards config to install'); - assert(customModule34 !== undefined, 'Quick update keeps manifest-backed custom module updateable'); - assert(customModule34 && customModule34.cached === false, 'Quick update uses manifest-backed source before cache'); - assert( - customModule34 && customModule34.sourcePath === quickUpdateFixture.moduleSourceDir, - 'Quick update uses preserved manifest sourcePath for custom modules', - ); - } catch (error) { - assert(false, 'Quick update manifest-backed custom source test succeeds', error.message); - } finally { - OfficialModules.prototype.listAvailable = originalListAvailable34; - OfficialModules.prototype.loadExistingConfig = originalLoadExistingConfig34; - OfficialModules.prototype.collectModuleConfigQuick = originalCollectModuleConfigQuick34; - if (quickUpdateFixture?.root) await fs.remove(quickUpdateFixture.root).catch(() => {}); - } - - console.log(''); - // ============================================================ // Summary // ============================================================ diff --git a/tools/installer/commands/install.js b/tools/installer/commands/install.js index 96f536ef4..fcac0b72d 100644 --- a/tools/installer/commands/install.js +++ b/tools/installer/commands/install.js @@ -17,7 +17,6 @@ module.exports = { '--tools ', 'Comma-separated list of tool/IDE IDs to configure (e.g., "claude-code,cursor"). Use "none" to skip tool configuration.', ], - ['--custom-content ', 'Comma-separated list of paths to custom modules/agents/workflows'], ['--action ', 'Action type for existing installations: install, update, or quick-update'], ['--user-name ', 'Name for agents to use (default: system username)'], ['--communication-language ', 'Language for agent communication (default: English)'], diff --git a/tools/installer/core/custom-module-cache.js b/tools/installer/core/custom-module-cache.js deleted file mode 100644 index 4afe77884..000000000 --- a/tools/installer/core/custom-module-cache.js +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Custom Module Source Cache - * Caches custom module sources under _config/custom/ to ensure they're never lost - * and can be checked into source control - */ - -const fs = require('fs-extra'); -const path = require('node:path'); -const crypto = require('node:crypto'); -const prompts = require('../prompts'); - -class CustomModuleCache { - constructor(bmadDir) { - this.bmadDir = bmadDir; - this.customCacheDir = path.join(bmadDir, '_config', 'custom'); - this.manifestPath = path.join(this.customCacheDir, 'cache-manifest.yaml'); - } - - /** - * Ensure the custom cache directory exists - */ - async ensureCacheDir() { - await fs.ensureDir(this.customCacheDir); - } - - /** - * Get cache manifest - */ - async getCacheManifest() { - if (!(await fs.pathExists(this.manifestPath))) { - return {}; - } - - const content = await fs.readFile(this.manifestPath, 'utf8'); - const yaml = require('yaml'); - return yaml.parse(content) || {}; - } - - /** - * Update cache manifest - */ - async updateCacheManifest(manifest) { - const yaml = require('yaml'); - // Clean the manifest to remove any non-serializable values - const cleanManifest = structuredClone(manifest); - - const content = yaml.stringify(cleanManifest, { - indent: 2, - lineWidth: 0, - sortKeys: false, - }); - - await fs.writeFile(this.manifestPath, content); - } - - /** - * Stream a file into the hash to avoid loading entire file into memory - */ - async hashFileStream(filePath, hash) { - return new Promise((resolve, reject) => { - const stream = require('node:fs').createReadStream(filePath); - stream.on('data', (chunk) => hash.update(chunk)); - stream.on('end', resolve); - stream.on('error', reject); - }); - } - - /** - * Calculate hash of a file or directory using streaming to minimize memory usage - */ - async calculateHash(sourcePath) { - const hash = crypto.createHash('sha256'); - - const isDir = (await fs.stat(sourcePath)).isDirectory(); - - if (isDir) { - // For directories, hash all files - const files = []; - async function collectFiles(dir) { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isFile()) { - files.push(path.join(dir, entry.name)); - } else if (entry.isDirectory() && !entry.name.startsWith('.')) { - await collectFiles(path.join(dir, entry.name)); - } - } - } - - await collectFiles(sourcePath); - files.sort(); // Ensure consistent order - - for (const file of files) { - const relativePath = path.relative(sourcePath, file); - // Hash the path first, then stream file contents - hash.update(relativePath + '|'); - await this.hashFileStream(file, hash); - } - } else { - // For single files, stream directly into hash - await this.hashFileStream(sourcePath, hash); - } - - return hash.digest('hex'); - } - - /** - * Cache a custom module source - * @param {string} moduleId - Module ID - * @param {string} sourcePath - Original source path - * @param {Object} metadata - Additional metadata to store - * @returns {Object} Cached module info - */ - async cacheModule(moduleId, sourcePath, metadata = {}) { - await this.ensureCacheDir(); - - const cacheDir = path.join(this.customCacheDir, moduleId); - const cacheManifest = await this.getCacheManifest(); - - // Check if already cached and unchanged - if (cacheManifest[moduleId]) { - const cached = cacheManifest[moduleId]; - if (cached.originalHash && cached.originalHash === (await this.calculateHash(sourcePath))) { - // Source unchanged, return existing cache info - return { - moduleId, - cachePath: cacheDir, - ...cached, - }; - } - } - - // Remove existing cache if it exists - if (await fs.pathExists(cacheDir)) { - await fs.remove(cacheDir); - } - - // Copy module to cache - await fs.copy(sourcePath, cacheDir, { - filter: (src) => { - const relative = path.relative(sourcePath, src); - // Skip node_modules, .git, and other common ignore patterns - return !relative.includes('node_modules') && !relative.startsWith('.git') && !relative.startsWith('.DS_Store'); - }, - }); - - // Calculate hash of the source - const sourceHash = await this.calculateHash(sourcePath); - const cacheHash = await this.calculateHash(cacheDir); - - // Update manifest - don't store absolute paths for portability - // Clean metadata to remove absolute paths - const cleanMetadata = { ...metadata }; - if (cleanMetadata.sourcePath) { - delete cleanMetadata.sourcePath; - } - - cacheManifest[moduleId] = { - originalHash: sourceHash, - cacheHash: cacheHash, - cachedAt: new Date().toISOString(), - ...cleanMetadata, - }; - - await this.updateCacheManifest(cacheManifest); - - return { - moduleId, - cachePath: cacheDir, - ...cacheManifest[moduleId], - }; - } - - /** - * Get cached module info - * @param {string} moduleId - Module ID - * @returns {Object|null} Cached module info or null - */ - async getCachedModule(moduleId) { - const cacheManifest = await this.getCacheManifest(); - const cached = cacheManifest[moduleId]; - - if (!cached) { - return null; - } - - const cacheDir = path.join(this.customCacheDir, moduleId); - - if (!(await fs.pathExists(cacheDir))) { - // Cache dir missing, remove from manifest - delete cacheManifest[moduleId]; - await this.updateCacheManifest(cacheManifest); - return null; - } - - // Verify cache integrity - const currentCacheHash = await this.calculateHash(cacheDir); - if (currentCacheHash !== cached.cacheHash) { - await prompts.log.warn(`Cache integrity check failed for ${moduleId}`); - } - - return { - moduleId, - cachePath: cacheDir, - ...cached, - }; - } - - /** - * Get all cached modules - * @returns {Array} Array of cached module info - */ - async getAllCachedModules() { - const cacheManifest = await this.getCacheManifest(); - const cached = []; - - for (const [moduleId, info] of Object.entries(cacheManifest)) { - const cachedModule = await this.getCachedModule(moduleId); - if (cachedModule) { - cached.push(cachedModule); - } - } - - return cached; - } - - /** - * Remove a cached module - * @param {string} moduleId - Module ID to remove - */ - async removeCachedModule(moduleId) { - const cacheManifest = await this.getCacheManifest(); - const cacheDir = path.join(this.customCacheDir, moduleId); - - // Remove cache directory - if (await fs.pathExists(cacheDir)) { - await fs.remove(cacheDir); - } - - // Remove from manifest - delete cacheManifest[moduleId]; - await this.updateCacheManifest(cacheManifest); - } - - /** - * Sync cached modules with a list of module IDs - * @param {Array} moduleIds - Module IDs to keep - */ - async syncCache(moduleIds) { - const cached = await this.getAllCachedModules(); - - for (const cachedModule of cached) { - if (!moduleIds.includes(cachedModule.moduleId)) { - await this.removeCachedModule(cachedModule.moduleId); - } - } - } -} - -module.exports = { CustomModuleCache }; diff --git a/tools/installer/core/existing-install.js b/tools/installer/core/existing-install.js index 8e86f4b03..643f1d946 100644 --- a/tools/installer/core/existing-install.js +++ b/tools/installer/core/existing-install.js @@ -10,14 +10,13 @@ const { Manifest } = require('./manifest'); class ExistingInstall { #version; - constructor({ installed, version, hasCore, modules, ides, customModules }) { + constructor({ installed, version, hasCore, modules, ides }) { this.installed = installed; this.#version = version; this.hasCore = hasCore; this.modules = Object.freeze(modules.map((m) => Object.freeze({ ...m }))); this.moduleIds = Object.freeze(this.modules.map((m) => m.id)); this.ides = Object.freeze([...ides]); - this.customModules = Object.freeze([...customModules]); Object.freeze(this); } @@ -35,7 +34,6 @@ class ExistingInstall { hasCore: false, modules: [], ides: [], - customModules: [], }); } @@ -53,15 +51,11 @@ class ExistingInstall { let hasCore = false; const modules = []; let ides = []; - let customModules = []; const manifest = new Manifest(); const manifestData = await manifest.read(bmadDir); if (manifestData) { version = manifestData.version; - if (manifestData.customModules) { - customModules = manifestData.customModules; - } if (manifestData.ides) { ides = manifestData.ides.filter((ide) => ide && typeof ide === 'string'); } @@ -120,7 +114,7 @@ class ExistingInstall { return ExistingInstall.empty(); } - return new ExistingInstall({ installed, version, hasCore, modules, ides, customModules }); + return new ExistingInstall({ installed, version, hasCore, modules, ides }); } } diff --git a/tools/installer/core/install-paths.js b/tools/installer/core/install-paths.js index 7383f9bfd..f1c50ee43 100644 --- a/tools/installer/core/install-paths.js +++ b/tools/installer/core/install-paths.js @@ -20,14 +20,12 @@ class InstallPaths { const configDir = path.join(bmadDir, '_config'); const agentsDir = path.join(configDir, 'agents'); - const customCacheDir = path.join(configDir, 'custom'); const coreDir = path.join(bmadDir, 'core'); for (const [dir, label] of [ [bmadDir, 'bmad directory'], [configDir, 'config directory'], [agentsDir, 'agents config directory'], - [customCacheDir, 'custom modules cache'], [coreDir, 'core module directory'], ]) { await ensureWritableDir(dir, label); @@ -40,7 +38,6 @@ class InstallPaths { bmadDir, configDir, agentsDir, - customCacheDir, coreDir, isUpdate, }); diff --git a/tools/installer/core/installer.js b/tools/installer/core/installer.js index bc3b3ec20..60245ce1d 100644 --- a/tools/installer/core/installer.js +++ b/tools/installer/core/installer.js @@ -2,7 +2,6 @@ const path = require('node:path'); const fs = require('fs-extra'); const { Manifest } = require('./manifest'); const { OfficialModules } = require('../modules/official-modules'); -const { CustomModules } = require('../modules/custom-modules'); const { IdeManager } = require('../ide/manager'); const { FileOps } = require('../file-ops'); const { Config } = require('./config'); @@ -19,7 +18,6 @@ class Installer { constructor() { this.externalModuleManager = new ExternalModuleManager(); this.manifest = new Manifest(); - this.customModules = new CustomModules(); this.ideManager = new IdeManager(); this.fileOps = new FileOps(); this.installedFiles = new Set(); // Track all installed files @@ -80,8 +78,6 @@ class Installer { const officialModules = await OfficialModules.build(config, paths); const existingInstall = await ExistingInstall.detect(paths.bmadDir); - await this.customModules.discoverPaths(originalConfig, paths); - if (existingInstall.installed) { await this._removeDeselectedModules(existingInstall, config, paths); updateState = await this._prepareUpdateState(paths, config, existingInstall, officialModules); @@ -121,14 +117,9 @@ class Installer { } } - await this._cacheCustomModules(paths, addResult); + const allModules = config.modules || []; - // Compute module lists: official = selected minus custom, all = both - const customModuleIds = new Set(this.customModules.paths.keys()); - const officialModuleIds = (config.modules || []).filter((m) => !customModuleIds.has(m)); - const allModules = [...officialModuleIds, ...[...customModuleIds].filter((id) => !officialModuleIds.includes(id))]; - - await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules); + await this._installAndConfigure(config, originalConfig, paths, allModules, allModules, addResult, officialModules); await this._setupIdes(config, allModules, paths, addResult, previousSkillIds); @@ -242,26 +233,6 @@ class Installer { } } - /** - * Cache custom modules into the local cache directory. - * Updates this.customModules.paths in place with cached locations. - */ - async _cacheCustomModules(paths, addResult) { - if (!this.customModules.paths || this.customModules.paths.size === 0) return; - - const { CustomModuleCache } = require('./custom-module-cache'); - const customCache = new CustomModuleCache(paths.bmadDir); - - for (const [moduleId, sourcePath] of this.customModules.paths) { - const cachedInfo = await customCache.cacheModule(moduleId, sourcePath, { - sourcePath: sourcePath, - }); - this.customModules.paths.set(moduleId, cachedInfo.cachePath); - } - - addResult('Custom modules cached', 'ok'); - } - /** * Install modules, create directories, generate configs and manifests. */ @@ -284,11 +255,6 @@ class Installer { installedModuleNames, }); - await this._installCustomModules(config, paths, addResult, officialModules, { - message, - installedModuleNames, - }); - return `${allModules.length} module(s) ${isQuickUpdate ? 'updated' : 'installed'}`; }, }); @@ -515,48 +481,7 @@ class Installer { } /** - * Scan the custom module cache directory and register any cached custom modules - * that aren't already known from the manifest or external module list. - * @param {Object} paths - InstallPaths instance - */ - async _scanCachedCustomModules(paths) { - const cacheDir = paths.customCacheDir; - if (!(await fs.pathExists(cacheDir))) { - return; - } - - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - - for (const cachedModule of cachedModules) { - const moduleId = cachedModule.name; - const cachedPath = path.join(cacheDir, moduleId); - - // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT - if (!(await fs.pathExists(cachedPath)) || !cachedModule.isDirectory()) { - continue; - } - - // Skip if we already have this module from manifest - if (this.customModules.paths.has(moduleId)) { - continue; - } - - // Check if this is an external official module - skip cache for those - const isExternal = await this.externalModuleManager.hasModule(moduleId); - if (isExternal) { - continue; - } - - // Check if this is actually a custom module (has module.yaml) - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - this.customModules.paths.set(moduleId, cachedPath); - } - } - } - - /** - * Common update preparation: detect files, preserve core config, scan cache, back up. + * Common update preparation: detect files, preserve core config, back up. * @param {Object} paths - InstallPaths instance * @param {Object} config - Clean config (may have coreConfig updated) * @param {Object} existingInstall - Detection result @@ -584,8 +509,6 @@ class Installer { } } - await this._scanCachedCustomModules(paths); - const backupDirs = await this._backupUserFiles(paths, customFiles, modifiedFiles); return { @@ -677,38 +600,6 @@ class Installer { } } - /** - * Install custom modules using CustomModules.install(). - * Source paths come from this.customModules.paths (populated by discoverPaths). - */ - async _installCustomModules(config, paths, addResult, officialModules, ctx) { - const { message, installedModuleNames } = ctx; - const isQuickUpdate = config.isQuickUpdate(); - - for (const [moduleName, sourcePath] of this.customModules.paths) { - if (installedModuleNames.has(moduleName)) continue; - installedModuleNames.add(moduleName); - - message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); - - const collectedModuleConfig = officialModules.moduleConfigs[moduleName] || {}; - const result = await this.customModules.install(moduleName, paths.bmadDir, (filePath) => this.installedFiles.add(filePath), { - moduleConfig: collectedModuleConfig, - }); - - // Generate runtime config.yaml with merged values - await this.generateModuleConfigs(paths.bmadDir, { - [moduleName]: { ...config.coreConfig, ...result.moduleConfig, ...collectedModuleConfig }, - }); - - // Get display name from source module.yaml; version from marketplace.json - const moduleInfo = await officialModules.getModuleInfo(sourcePath, moduleName, ''); - const displayName = moduleInfo?.name || moduleName; - const version = await this._getMarketplaceVersion(sourcePath); - addResult(displayName, 'ok', '', { moduleCode: moduleName, newVersion: version }); - } - } - /** * Read files-manifest.csv * @param {string} bmadDir - BMAD installation directory @@ -1253,16 +1144,9 @@ class Installer { const configuredIdes = existingInstall.ides; const projectRoot = path.dirname(bmadDir); - const customModuleSources = await this.customModules.assembleQuickUpdateSources( - config, - existingInstall, - bmadDir, - this.externalModuleManager, - ); - // Get available modules (what we have source for) const availableModulesData = await new OfficialModules().listAvailable(); - const availableModules = [...availableModulesData.modules, ...availableModulesData.customModules]; + const availableModules = [...availableModulesData.modules]; // Add external official modules to available modules const externalModules = await this.externalModuleManager.listAvailable(); @@ -1277,52 +1161,12 @@ class Installer { } } - // Add custom modules from manifest if their sources exist - for (const [moduleId, customModule] of customModuleSources) { - const sourcePath = customModule.sourcePath; - if (sourcePath && (await fs.pathExists(sourcePath)) && !availableModules.some((m) => m.id === moduleId)) { - availableModules.push({ - id: moduleId, - name: customModule.name || moduleId, - path: sourcePath, - isCustom: true, - fromManifest: true, - }); - } - } - - // Handle missing custom module sources - const customModuleResult = await this.handleMissingCustomSources( - customModuleSources, - bmadDir, - projectRoot, - 'update', - installedModules, - config.skipPrompts || false, - ); - - const { validCustomModules, keptModulesWithoutSources } = customModuleResult; - - const customModulesFromManifest = validCustomModules.map((m) => ({ - ...m, - isCustom: true, - hasUpdate: true, - })); - - const allAvailableModules = [...availableModules, ...customModulesFromManifest]; - const availableModuleIds = new Set(allAvailableModules.map((m) => m.id)); + const availableModuleIds = new Set(availableModules.map((m) => m.id)); // Only update modules that are BOTH installed AND available (we have source for) const modulesToUpdate = installedModules.filter((id) => availableModuleIds.has(id)); const skippedModules = installedModules.filter((id) => !availableModuleIds.has(id)); - // Add custom modules that were kept without sources to the skipped modules - for (const keptModule of keptModulesWithoutSources) { - if (!skippedModules.includes(keptModule)) { - skippedModules.push(keptModule); - } - } - if (skippedModules.length > 0) { await prompts.log.warn(`Skipping ${skippedModules.length} module(s) - no source available: ${skippedModules.join(', ')}`); } @@ -1367,9 +1211,7 @@ class Installer { actionType: 'install', _quickUpdate: true, _preserveModules: skippedModules, - _customModuleSources: customModuleSources, _existingModules: installedModules, - customContent: config.customContent, }; await this.install(installConfig); @@ -1504,239 +1346,6 @@ class Installer { return this._readOutputFolder(bmadDir); } - /** - * Handle missing custom module sources interactively - * @param {Map} customModuleSources - Map of custom module ID to info - * @param {string} bmadDir - BMAD directory - * @param {string} projectRoot - Project root directory - * @param {string} operation - Current operation ('update', 'compile', etc.) - * @param {Array} installedModules - Array of installed module IDs (will be modified) - * @param {boolean} [skipPrompts=false] - Skip interactive prompts and keep all modules with missing sources - * @returns {Object} Object with validCustomModules array and keptModulesWithoutSources array - */ - async handleMissingCustomSources(customModuleSources, bmadDir, projectRoot, operation, installedModules, skipPrompts = false) { - const validCustomModules = []; - const keptModulesWithoutSources = []; // Track modules kept without sources - const customModulesWithMissingSources = []; - - // Check which sources exist - for (const [moduleId, customInfo] of customModuleSources) { - if (await fs.pathExists(customInfo.sourcePath)) { - validCustomModules.push({ - id: moduleId, - name: customInfo.name, - path: customInfo.sourcePath, - info: customInfo, - }); - } else { - // For cached modules that are missing, we just skip them without prompting - if (customInfo.cached) { - // Skip cached modules without prompting - keptModulesWithoutSources.push({ - id: moduleId, - name: customInfo.name, - cached: true, - }); - } else { - customModulesWithMissingSources.push({ - id: moduleId, - name: customInfo.name, - sourcePath: customInfo.sourcePath, - relativePath: customInfo.relativePath, - info: customInfo, - }); - } - } - } - - // If no missing sources, return immediately - if (customModulesWithMissingSources.length === 0) { - return { - validCustomModules, - keptModulesWithoutSources: [], - }; - } - - // Non-interactive mode: keep all modules with missing sources - if (skipPrompts) { - for (const missing of customModulesWithMissingSources) { - keptModulesWithoutSources.push(missing.id); - } - return { validCustomModules, keptModulesWithoutSources }; - } - - await prompts.log.warn(`Found ${customModulesWithMissingSources.length} custom module(s) with missing sources:`); - - let keptCount = 0; - let updatedCount = 0; - let removedCount = 0; - - for (const missing of customModulesWithMissingSources) { - await prompts.log.message( - `${missing.name} (${missing.id})\n Original source: ${missing.relativePath}\n Full path: ${missing.sourcePath}`, - ); - - const choices = [ - { - name: 'Keep installed (will not be processed)', - value: 'keep', - hint: 'Keep', - }, - { - name: 'Specify new source location', - value: 'update', - hint: 'Update', - }, - ]; - - // Only add remove option if not just compiling agents - if (operation !== 'compile-agents') { - choices.push({ - name: '⚠️ REMOVE module completely (destructive!)', - value: 'remove', - hint: 'Remove', - }); - } - - const action = await prompts.select({ - message: `How would you like to handle "${missing.name}"?`, - choices, - }); - - switch (action) { - case 'update': { - // Use sync validation because @clack/prompts doesn't support async validate - const newSourcePath = await prompts.text({ - message: 'Enter the new path to the custom module:', - default: missing.sourcePath, - validate: (input) => { - if (!input || input.trim() === '') { - return 'Please enter a path'; - } - const expandedPath = path.resolve(input.trim()); - if (!fs.pathExistsSync(expandedPath)) { - return 'Path does not exist'; - } - // Check if it looks like a valid module - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - const agentsPath = path.join(expandedPath, 'agents'); - const workflowsPath = path.join(expandedPath, 'workflows'); - - if (!fs.pathExistsSync(moduleYamlPath) && !fs.pathExistsSync(agentsPath) && !fs.pathExistsSync(workflowsPath)) { - return 'Path does not appear to contain a valid custom module'; - } - return; // clack expects undefined for valid input - }, - }); - - // Defensive: handleCancel should have exited, but guard against symbol propagation - if (typeof newSourcePath !== 'string') { - keptCount++; - keptModulesWithoutSources.push(missing.id); - continue; - } - - // Update the source in manifest - const resolvedPath = path.resolve(newSourcePath.trim()); - missing.info.sourcePath = resolvedPath; - // Remove relativePath - we only store absolute sourcePath now - delete missing.info.relativePath; - await this.manifest.addCustomModule(bmadDir, missing.info); - - validCustomModules.push({ - id: missing.id, - name: missing.name, - path: resolvedPath, - info: missing.info, - }); - - updatedCount++; - await prompts.log.success('Updated source location'); - - break; - } - case 'remove': { - // Extra confirmation for destructive remove - await prompts.log.error( - `WARNING: This will PERMANENTLY DELETE "${missing.name}" and all its files!\n Module location: ${path.join(bmadDir, missing.id)}`, - ); - - const confirmDelete = await prompts.confirm({ - message: 'Are you absolutely sure you want to delete this module?', - default: false, - }); - - if (confirmDelete) { - const typedConfirm = await prompts.text({ - message: 'Type "DELETE" to confirm permanent deletion:', - validate: (input) => { - if (input !== 'DELETE') { - return 'You must type "DELETE" exactly to proceed'; - } - return; // clack expects undefined for valid input - }, - }); - - if (typedConfirm === 'DELETE') { - // Remove the module from filesystem and manifest - const modulePath = path.join(bmadDir, missing.id); - if (await fs.pathExists(modulePath)) { - const fsExtra = require('fs-extra'); - await fsExtra.remove(modulePath); - await prompts.log.warn(`Deleted module directory: ${path.relative(projectRoot, modulePath)}`); - } - - await this.manifest.removeModule(bmadDir, missing.id); - await this.manifest.removeCustomModule(bmadDir, missing.id); - await prompts.log.warn('Removed from manifest'); - - // Also remove from installedModules list - if (installedModules && installedModules.includes(missing.id)) { - const index = installedModules.indexOf(missing.id); - if (index !== -1) { - installedModules.splice(index, 1); - } - } - - removedCount++; - await prompts.log.error(`"${missing.name}" has been permanently removed`); - } else { - await prompts.log.message('Removal cancelled - module will be kept'); - keptCount++; - } - } else { - await prompts.log.message('Removal cancelled - module will be kept'); - keptCount++; - } - - break; - } - case 'keep': { - keptCount++; - keptModulesWithoutSources.push(missing.id); - await prompts.log.message('Module will be kept as-is'); - - break; - } - // No default - } - } - - // Show summary - if (keptCount > 0 || updatedCount > 0 || removedCount > 0) { - let summary = 'Summary for custom modules with missing sources:'; - if (keptCount > 0) summary += `\n • ${keptCount} module(s) kept as-is`; - if (updatedCount > 0) summary += `\n • ${updatedCount} module(s) updated with new sources`; - if (removedCount > 0) summary += `\n • ${removedCount} module(s) permanently deleted`; - await prompts.log.message(summary); - } - - return { - validCustomModules, - keptModulesWithoutSources, - }; - } - /** * Find the bmad installation directory in a project * Always uses the standard _bmad folder name diff --git a/tools/installer/core/manifest-generator.js b/tools/installer/core/manifest-generator.js index 74972d36e..28ede065e 100644 --- a/tools/installer/core/manifest-generator.js +++ b/tools/installer/core/manifest-generator.js @@ -375,8 +375,6 @@ class ManifestGenerator { // Read existing manifest to preserve install date let existingInstallDate = null; const existingModulesMap = new Map(); - let existingCustomModules = []; - if (await fs.pathExists(manifestPath)) { try { const existingContent = await fs.readFile(manifestPath, 'utf8'); @@ -397,12 +395,6 @@ class ManifestGenerator { } } } - - if (existingManifest.customModules && Array.isArray(existingManifest.customModules)) { - // We filter here so manifest regeneration preserves source metadata only for custom modules that - // are still installed. Without that, customModules can retain stale entries for modules that were removed. - existingCustomModules = existingManifest.customModules.filter((customModule) => installedModuleSet.has(customModule?.id)); - } } catch { // If we can't read existing manifest, continue with defaults } @@ -438,7 +430,6 @@ class ManifestGenerator { lastUpdated: new Date().toISOString(), }, modules: updatedModules, - customModules: existingCustomModules, ides: this.selectedIdes, }; diff --git a/tools/installer/core/manifest.js b/tools/installer/core/manifest.js index 287b38918..f70482f43 100644 --- a/tools/installer/core/manifest.js +++ b/tools/installer/core/manifest.js @@ -97,7 +97,6 @@ class Manifest { lastUpdated: manifestData.installation?.lastUpdated, modules: moduleNames, // Simple array of module names for backward compatibility modulesDetailed: hasDetailedModules ? modules : null, // New detailed format - customModules: manifestData.customModules || [], // Keep for backward compatibility ides: manifestData.ides || [], }; } catch (error) { @@ -254,7 +253,6 @@ class Manifest { lastUpdated: manifest.installation?.lastUpdated, modules: moduleNames, modulesDetailed: hasDetailedModules ? modules : null, - customModules: manifest.customModules || [], ides: manifest.ides || [], }; } @@ -783,52 +781,6 @@ class Manifest { return configs; } - /** - * Add a custom module to the manifest with its source path - * @param {string} bmadDir - Path to bmad directory - * @param {Object} customModule - Custom module info - */ - async addCustomModule(bmadDir, customModule) { - const manifest = await this.read(bmadDir); - if (!manifest) { - throw new Error('No manifest found'); - } - - if (!manifest.customModules) { - manifest.customModules = []; - } - - // Check if custom module already exists - const existingIndex = manifest.customModules.findIndex((m) => m.id === customModule.id); - if (existingIndex === -1) { - // Add new entry - manifest.customModules.push(customModule); - } else { - // Update existing entry - manifest.customModules[existingIndex] = customModule; - } - - await this.update(bmadDir, { customModules: manifest.customModules }); - } - - /** - * Remove a custom module from the manifest - * @param {string} bmadDir - Path to bmad directory - * @param {string} moduleId - Module ID to remove - */ - async removeCustomModule(bmadDir, moduleId) { - const manifest = await this.read(bmadDir); - if (!manifest || !manifest.customModules) { - return; - } - - const index = manifest.customModules.findIndex((m) => m.id === moduleId); - if (index !== -1) { - manifest.customModules.splice(index, 1); - await this.update(bmadDir, { customModules: manifest.customModules }); - } - } - /** * Get module version info from source * @param {string} moduleName - Module name/code @@ -866,29 +818,8 @@ class Manifest { }; } - // Custom module: resolve path from source or cache before reading version - const customSourcePath = moduleSourcePath || path.join(bmadDir, '_config', 'custom', moduleName); - const version = await this._readMarketplaceVersion(moduleName, customSourcePath); - - const cacheDir = path.join(bmadDir, '_config', 'custom', moduleName); - const moduleYamlPath = path.join(cacheDir, 'module.yaml'); - - if (await fs.pathExists(moduleYamlPath)) { - try { - const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); - const moduleConfig = yaml.parse(yamlContent); - return { - version: version || moduleConfig.version || null, - source: 'custom', - npmPackage: moduleConfig.npmPackage || null, - repoUrl: moduleConfig.repoUrl || null, - }; - } catch (error) { - await prompts.log.warn(`Failed to read module.yaml for ${moduleName}: ${error.message}`); - } - } - // Unknown module + const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath); return { version, source: 'unknown', diff --git a/tools/installer/custom-handler.js b/tools/installer/custom-handler.js deleted file mode 100644 index a1966b7e7..000000000 --- a/tools/installer/custom-handler.js +++ /dev/null @@ -1,112 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const prompts = require('./prompts'); -/** - * Handler for custom content (custom.yaml) - * Discovers custom agents and workflows in the project - */ -class CustomHandler { - /** - * Find all custom.yaml files in the project - * @param {string} projectRoot - Project root directory - * @returns {Array} List of custom content paths - */ - async findCustomContent(projectRoot) { - const customPaths = []; - - // Helper function to recursively scan directories - async function scanDirectory(dir, excludePaths = []) { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - - // Skip hidden directories and common exclusions - if ( - entry.name.startsWith('.') || - entry.name === 'node_modules' || - entry.name === 'dist' || - entry.name === 'build' || - entry.name === '.git' || - entry.name === 'bmad' - ) { - continue; - } - - // Skip excluded paths - if (excludePaths.some((exclude) => fullPath.startsWith(exclude))) { - continue; - } - - if (entry.isDirectory()) { - // Recursively scan subdirectories - await scanDirectory(fullPath, excludePaths); - } else if (entry.name === 'custom.yaml') { - // Found a custom.yaml file - customPaths.push(fullPath); - } else if ( - entry.name === 'module.yaml' && // Check if this is a custom module (in root directory) - // Skip if it's in src/modules (those are standard modules) - !fullPath.includes(path.join('src', 'modules')) - ) { - customPaths.push(fullPath); - } - } - } catch { - // Ignore errors (e.g., permission denied) - } - } - - // Scan the entire project, but exclude source directories - await scanDirectory(projectRoot, [path.join(projectRoot, 'src'), path.join(projectRoot, 'tools'), path.join(projectRoot, 'test')]); - - return customPaths; - } - - /** - * Get custom content info from a custom.yaml or module.yaml file - * @param {string} configPath - Path to config file - * @param {string} projectRoot - Project root directory for calculating relative paths - * @returns {Object|null} Custom content info - */ - async getCustomInfo(configPath, projectRoot = null) { - try { - const configContent = await fs.readFile(configPath, 'utf8'); - - // Try to parse YAML with error handling - let config; - try { - config = yaml.parse(configContent); - } catch (parseError) { - await prompts.log.warn('YAML parse error in ' + configPath + ': ' + parseError.message); - return null; - } - - // Check if this is an module.yaml (module) or custom.yaml (custom content) - const isInstallConfig = configPath.endsWith('module.yaml'); - const configDir = path.dirname(configPath); - - // Use provided projectRoot or fall back to process.cwd() - const basePath = projectRoot || process.cwd(); - const relativePath = path.relative(basePath, configDir); - - return { - id: config.code || 'unknown-code', - name: config.name, - description: config.description || '', - path: configDir, - relativePath: relativePath, - defaultSelected: config.default_selected === true, - config: config, - isInstallConfig: isInstallConfig, // Track which type this is - }; - } catch (error) { - await prompts.log.warn('Failed to read ' + configPath + ': ' + error.message); - return null; - } - } -} - -module.exports = { CustomHandler }; diff --git a/tools/installer/modules/custom-modules.js b/tools/installer/modules/custom-modules.js deleted file mode 100644 index 3f8b793be..000000000 --- a/tools/installer/modules/custom-modules.js +++ /dev/null @@ -1,302 +0,0 @@ -const path = require('node:path'); -const fs = require('fs-extra'); -const yaml = require('yaml'); -const { CustomHandler } = require('../custom-handler'); -const { Manifest } = require('../core/manifest'); -const prompts = require('../prompts'); - -class CustomModules { - constructor() { - this.paths = new Map(); - } - - has(moduleCode) { - return this.paths.has(moduleCode); - } - - get(moduleCode) { - return this.paths.get(moduleCode); - } - - set(moduleId, sourcePath) { - this.paths.set(moduleId, sourcePath); - } - - /** - * Install a custom module from its source path. - * @param {string} moduleName - Module identifier - * @param {string} bmadDir - Target bmad directory - * @param {Function} fileTrackingCallback - Optional callback to track installed files - * @param {Object} options - Install options - * @param {Object} options.moduleConfig - Pre-collected module configuration - * @returns {Object} Install result - */ - async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) { - const sourcePath = this.paths.get(moduleName); - if (!sourcePath) { - throw new Error(`No source path for custom module '${moduleName}'`); - } - - if (!(await fs.pathExists(sourcePath))) { - throw new Error(`Source for custom module '${moduleName}' not found at: ${sourcePath}`); - } - - const targetPath = path.join(bmadDir, moduleName); - - // Read custom.yaml and merge into module config - let moduleConfig = options.moduleConfig ? { ...options.moduleConfig } : {}; - const customConfigPath = path.join(sourcePath, 'custom.yaml'); - if (await fs.pathExists(customConfigPath)) { - try { - const content = await fs.readFile(customConfigPath, 'utf8'); - const customConfig = yaml.parse(content); - if (customConfig) { - moduleConfig = { ...moduleConfig, ...customConfig }; - } - } catch (error) { - await prompts.log.warn(`Failed to read custom.yaml for ${moduleName}: ${error.message}`); - } - } - - // Remove existing installation - if (await fs.pathExists(targetPath)) { - await fs.remove(targetPath); - } - - // Copy files with filtering - await this._copyWithFiltering(sourcePath, targetPath, fileTrackingCallback); - - // Add to manifest - const manifest = new Manifest(); - const versionInfo = await manifest.getModuleVersionInfo(moduleName, bmadDir, sourcePath); - await manifest.addModule(bmadDir, moduleName, { - version: versionInfo.version, - source: versionInfo.source, - npmPackage: versionInfo.npmPackage, - repoUrl: versionInfo.repoUrl, - }); - - return { success: true, module: moduleName, path: targetPath, moduleConfig }; - } - - /** - * Copy module files, filtering out install-time-only artifacts. - * @param {string} sourcePath - Source module directory - * @param {string} targetPath - Target module directory - * @param {Function} fileTrackingCallback - Optional callback to track installed files - */ - async _copyWithFiltering(sourcePath, targetPath, fileTrackingCallback = null) { - const files = await this._getFileList(sourcePath); - - for (const file of files) { - if (file.startsWith('sub-modules/')) continue; - - const isInSidecar = path - .dirname(file) - .split('/') - .some((dir) => dir.toLowerCase().endsWith('-sidecar')); - if (isInSidecar) continue; - - if (file === 'module.yaml') continue; - if (file === 'config.yaml') continue; - - const sourceFile = path.join(sourcePath, file); - const targetFile = path.join(targetPath, file); - - // Skip web-only agents - if (file.startsWith('agents/') && file.endsWith('.md')) { - const content = await fs.readFile(sourceFile, 'utf8'); - if (/]*\slocalskip="true"[^>]*>/.test(content)) { - continue; - } - } - - await fs.ensureDir(path.dirname(targetFile)); - await fs.copy(sourceFile, targetFile, { overwrite: true }); - - if (fileTrackingCallback) { - fileTrackingCallback(targetFile); - } - } - } - - /** - * Recursively list all files in a directory. - * @param {string} dir - Directory to scan - * @param {string} baseDir - Base directory for relative paths - * @returns {string[]} 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()) { - files.push(...(await this._getFileList(fullPath, baseDir))); - } else { - files.push(path.relative(baseDir, fullPath)); - } - } - - return files; - } - - /** - * Discover custom module source paths from all available sources. - * @param {Object} config - Installation configuration - * @param {Object} paths - InstallPaths instance - * @returns {Map} Map of module ID to source path - */ - async discoverPaths(config, paths) { - this.paths = new Map(); - - if (config._quickUpdate) { - if (config._customModuleSources) { - for (const [moduleId, customInfo] of config._customModuleSources) { - this.paths.set(moduleId, customInfo.sourcePath); - } - } - return this.paths; - } - - // From UI: selectedFiles - if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) { - const customHandler = new CustomHandler(); - for (const customFile of config.customContent.selectedFiles) { - const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot); - if (customInfo && customInfo.id) { - this.paths.set(customInfo.id, customInfo.path); - } - } - } - - // From UI: sources - if (config.customContent && config.customContent.sources) { - for (const source of config.customContent.sources) { - this.paths.set(source.id, source.path); - } - } - - // From UI: cachedModules - if (config.customContent && config.customContent.cachedModules) { - const selectedCachedIds = config.customContent.selectedCachedModules || []; - const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected; - - for (const cachedModule of config.customContent.cachedModules) { - if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) { - this.paths.set(cachedModule.id, cachedModule.cachePath); - } - } - } - - return this.paths; - } - - /** - * Assemble quick-update source candidates before install() hands them to discoverPaths(). - * This exists because discoverPaths() consumes already-prepared quick-update sources, - * while quickUpdate() still has to build that source map from manifest, explicit inputs, - * and cache conventions. - * Precedence: manifest-backed paths, explicit sources override them, then cached modules. - * @param {Object} config - Quick update configuration - * @param {Object} existingInstall - Existing installation snapshot - * @param {string} bmadDir - BMAD directory - * @param {Object} externalModuleManager - External module manager - * @returns {Promise>} Map of custom module ID to source info - */ - async assembleQuickUpdateSources(config, existingInstall, bmadDir, externalModuleManager) { - const projectRoot = path.dirname(bmadDir); - const customModuleSources = new Map(); - - if (existingInstall.customModules) { - for (const customModule of existingInstall.customModules) { - // Skip if no ID - can't reliably track or re-cache without it - if (!customModule?.id) continue; - - let sourcePath = customModule.sourcePath; - if (sourcePath && sourcePath.startsWith('_config')) { - // Paths are relative to BMAD dir, but we want absolute paths for install - sourcePath = path.join(bmadDir, sourcePath); - } else if (!sourcePath && customModule.relativePath) { - // Fall back to relativePath - sourcePath = path.resolve(projectRoot, customModule.relativePath); - } else if (sourcePath && !path.isAbsolute(sourcePath)) { - // If we have a sourcePath but it's not absolute, resolve it relative to project root - sourcePath = path.resolve(projectRoot, sourcePath); - } - - // If we still don't have a valid source path, skip this module - if (!sourcePath || !(await fs.pathExists(sourcePath))) { - continue; - } - - customModuleSources.set(customModule.id, { - id: customModule.id, - name: customModule.name || customModule.id, - sourcePath, - relativePath: customModule.relativePath, - cached: false, - }); - } - } - - if (config.customContent?.sources?.length > 0) { - for (const source of config.customContent.sources) { - if (source.id && source.path) { - customModuleSources.set(source.id, { - id: source.id, - name: source.name || source.id, - sourcePath: source.path, - cached: false, // From CLI, will be re-cached - }); - } - } - } - - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (!(await fs.pathExists(cacheDir))) { - return customModuleSources; - } - - const cachedModules = await fs.readdir(cacheDir, { withFileTypes: true }); - for (const cachedModule of cachedModules) { - const moduleId = cachedModule.name; - const cachedPath = path.join(cacheDir, moduleId); - - // Skip if path doesn't exist (broken symlink, deleted dir) - avoids lstat ENOENT - if (!(await fs.pathExists(cachedPath))) { - continue; - } - if (!cachedModule.isDirectory()) { - continue; - } - - // Skip if we already have this module from manifest - if (customModuleSources.has(moduleId)) { - continue; - } - - // Check if this is an external official module - skip cache for those - const isExternal = await externalModuleManager.hasModule(moduleId); - if (isExternal) { - continue; - } - - // Check if this is actually a custom module (has module.yaml) - const moduleYamlPath = path.join(cachedPath, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - customModuleSources.set(moduleId, { - id: moduleId, - name: moduleId, - sourcePath: cachedPath, - cached: true, - }); - } - } - - return customModuleSources; - } -} - -module.exports = { CustomModules }; diff --git a/tools/installer/modules/official-modules.js b/tools/installer/modules/official-modules.js index 5b67fc4dd..0effc86b8 100644 --- a/tools/installer/modules/official-modules.js +++ b/tools/installer/modules/official-modules.js @@ -98,11 +98,10 @@ class OfficialModules { /** * List all available built-in modules (core and bmm). * All other modules come from external-official-modules.yaml - * @returns {Object} Object with modules array and customModules array + * @returns {Object} Object with modules array */ async listAvailable() { const modules = []; - const customModules = []; // Add built-in core module (directly under src/core-skills) const corePath = getSourcePath('core-skills'); @@ -122,7 +121,7 @@ class OfficialModules { } } - return { modules, customModules }; + return { modules }; } /** @@ -133,25 +132,12 @@ class OfficialModules { * @returns {Object|null} Module info or null if not a valid module */ async getModuleInfo(modulePath, defaultName, sourceDescription) { - // Check for module structure (module.yaml OR custom.yaml) const moduleConfigPath = path.join(modulePath, 'module.yaml'); - const rootCustomConfigPath = path.join(modulePath, 'custom.yaml'); - let configPath = null; - if (await fs.pathExists(moduleConfigPath)) { - configPath = moduleConfigPath; - } else if (await fs.pathExists(rootCustomConfigPath)) { - configPath = rootCustomConfigPath; - } - - // Skip if this doesn't look like a module - if (!configPath) { + if (!(await fs.pathExists(moduleConfigPath))) { return null; } - // Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core - const isCustomSource = - sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules'; const moduleInfo = { id: defaultName, path: modulePath, @@ -162,12 +148,11 @@ class OfficialModules { description: 'BMAD Module', version: '5.0.0', source: sourceDescription, - isCustom: configPath === rootCustomConfigPath || isCustomSource, }; // Read module config for metadata try { - const configContent = await fs.readFile(configPath, 'utf8'); + const configContent = await fs.readFile(moduleConfigPath, 'utf8'); const config = yaml.parse(configContent); // Use the code property as the id if available @@ -824,20 +809,15 @@ class OfficialModules { const results = []; for (const moduleName of modules) { - // Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search + // Resolve module.yaml path - standard location first, then OfficialModules search let moduleConfigPath = null; - const customPath = this.customModulePaths?.get(moduleName); - if (customPath) { - moduleConfigPath = path.join(customPath, 'module.yaml'); + const standardPath = path.join(getModulePath(moduleName), 'module.yaml'); + if (await fs.pathExists(standardPath)) { + moduleConfigPath = standardPath; } else { - const standardPath = path.join(getModulePath(moduleName), 'module.yaml'); - if (await fs.pathExists(standardPath)) { - moduleConfigPath = standardPath; - } else { - const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); - if (moduleSourcePath) { - moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); - } + const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); + if (moduleSourcePath) { + moduleConfigPath = path.join(moduleSourcePath, 'module.yaml'); } } @@ -882,12 +862,9 @@ class OfficialModules { * @param {Array} modules - List of modules to configure (including 'core') * @param {string} projectDir - Target project directory * @param {Object} options - Additional options - * @param {Map} options.customModulePaths - Map of module ID to source path for custom modules * @param {boolean} options.skipPrompts - Skip prompts and use defaults (for --yes flag) */ async collectAllConfigurations(modules, projectDir, options = {}) { - // Store custom module paths for use in collectModuleConfig - this.customModulePaths = options.customModulePaths || new Map(); this.skipPrompts = options.skipPrompts || false; this.modulesToCustomize = undefined; await this.loadExistingConfig(projectDir); @@ -1042,25 +1019,7 @@ class OfficialModules { } } - let configPath = null; - let isCustomModule = false; - - if (await fs.pathExists(moduleConfigPath)) { - configPath = moduleConfigPath; - } else { - // Check if this is a custom module with custom.yaml - const moduleSourcePath = await this.findModuleSource(moduleName, { silent: true }); - - if (moduleSourcePath) { - const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml'); - - if (await fs.pathExists(rootCustomConfigPath)) { - isCustomModule = true; - // For custom modules, we don't have an install-config schema, so just use existing values - // The custom.yaml values will be loaded and merged during installation - } - } - + if (!(await fs.pathExists(moduleConfigPath))) { // No config schema for this module - use existing values if (this._existingConfig && this._existingConfig[moduleName]) { if (!this.collectedConfig[moduleName]) { @@ -1071,7 +1030,7 @@ class OfficialModules { return false; } - const configContent = await fs.readFile(configPath, 'utf8'); + const configContent = await fs.readFile(moduleConfigPath, 'utf8'); const moduleConfig = yaml.parse(configContent); if (!moduleConfig) { @@ -1332,16 +1291,7 @@ class OfficialModules { this.allAnswers = {}; } // Load module's config - // First, check if we have a custom module path for this module - let moduleConfigPath = null; - - if (this.customModulePaths && this.customModulePaths.has(moduleName)) { - const customPath = this.customModulePaths.get(moduleName); - moduleConfigPath = path.join(customPath, 'module.yaml'); - } else { - // Try the standard src/modules location - moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); - } + let moduleConfigPath = path.join(getModulePath(moduleName), 'module.yaml'); // If not found in src/modules or custom paths, search the project if (!(await fs.pathExists(moduleConfigPath))) { diff --git a/tools/installer/ui.js b/tools/installer/ui.js index cccf219cc..9b8812f8a 100644 --- a/tools/installer/ui.js +++ b/tools/installer/ui.js @@ -2,7 +2,6 @@ const path = require('node:path'); const os = require('node:os'); const fs = require('fs-extra'); const { CLIUtils } = require('./cli-utils'); -const { CustomHandler } = require('./custom-handler'); const { ExternalModuleManager } = require('./modules/external-manager'); const { getProjectRoot } = require('./project-root'); const prompts = require('./prompts'); @@ -48,19 +47,6 @@ function _extractMarketplaceVersion(data) { return best; } -// Separator class for visual grouping in select/multiselect prompts -// Note: @clack/prompts doesn't support separators natively, they are filtered out -class Separator { - constructor(text = '────────') { - this.line = text; - this.name = text; - } - type = 'separator'; -} - -// Separator for choice lists (compatible interface) -const choiceUtils = { Separator }; - /** * UI utilities for the installer */ @@ -100,11 +86,6 @@ class UI { // Check if there's an existing BMAD installation const hasExistingInstall = await fs.pathExists(bmadDir); - let customContentConfig = { hasCustomContent: false }; - if (!hasExistingInstall) { - customContentConfig._shouldAsk = true; - } - // Track action type (only set if there's an existing installation) let actionType; @@ -153,48 +134,9 @@ class UI { // Handle quick update separately if (actionType === 'quick-update') { - // Pass --custom-content through so installer can re-cache if cache is missing - let customContentForQuickUpdate = { hasCustomContent: false }; - if (options.customContent) { - const paths = options.customContent - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - if (paths.length > 0) { - const customPaths = []; - const selectedModuleIds = []; - const sources = []; - for (const customPath of paths) { - const expandedPath = this.expandUserPath(customPath); - const validation = this.validateCustomContentPathSync(expandedPath); - if (validation) continue; - let moduleMeta; - try { - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - moduleMeta = require('yaml').parse(await fs.readFile(moduleYamlPath, 'utf-8')); - } catch { - continue; - } - if (!moduleMeta?.code) continue; - customPaths.push(expandedPath); - selectedModuleIds.push(moduleMeta.code); - sources.push({ path: expandedPath, id: moduleMeta.code, name: moduleMeta.name || moduleMeta.code }); - } - if (customPaths.length > 0) { - customContentForQuickUpdate = { - hasCustomContent: true, - selected: true, - sources, - selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')), - selectedModuleIds, - }; - } - } - } return { actionType: 'quick-update', directory: confirmedDirectory, - customContent: customContentForQuickUpdate, skipPrompts: options.yes || false, }; } @@ -225,120 +167,6 @@ class UI { selectedModules = await this.selectAllModules(installedModuleIds); } - // After module selection, ask about custom modules - let customModuleResult = { selectedCustomModules: [], customContentConfig: { hasCustomContent: false } }; - - if (options.customContent) { - // Use custom content from command-line - const paths = options.customContent - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`); - - // Build custom content config similar to promptCustomContentSource - const customPaths = []; - const selectedModuleIds = []; - const sources = []; - - for (const customPath of paths) { - const expandedPath = this.expandUserPath(customPath); - const validation = this.validateCustomContentPathSync(expandedPath); - if (validation) { - await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`); - continue; - } - - // Read module metadata - let moduleMeta; - try { - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8'); - const yaml = require('yaml'); - moduleMeta = yaml.parse(moduleYaml); - } catch (error) { - await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`); - continue; - } - - if (!moduleMeta) { - await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`); - continue; - } - - if (!moduleMeta.code) { - await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`); - continue; - } - - customPaths.push(expandedPath); - selectedModuleIds.push(moduleMeta.code); - sources.push({ - path: expandedPath, - id: moduleMeta.code, - name: moduleMeta.name || moduleMeta.code, - }); - } - - if (customPaths.length > 0) { - customModuleResult = { - selectedCustomModules: selectedModuleIds, - customContentConfig: { - hasCustomContent: true, - selected: true, - sources, - selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')), - selectedModuleIds: selectedModuleIds, - }, - }; - } - } else if (options.yes) { - // Non-interactive mode: preserve existing custom modules (matches default: false) - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const entries = await fs.readdir(cacheDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - customModuleResult.selectedCustomModules.push(entry.name); - } - } - await prompts.log.info( - `Non-interactive mode (--yes): preserving ${customModuleResult.selectedCustomModules.length} existing custom module(s)`, - ); - } else { - await prompts.log.info('Non-interactive mode (--yes): no existing custom modules found'); - } - } else { - const changeCustomModules = await prompts.confirm({ - message: 'Modify custom modules, agents, or workflows?', - default: false, - }); - - if (changeCustomModules) { - customModuleResult = await this.handleCustomModulesInModifyFlow(confirmedDirectory, selectedModules); - } else { - // Preserve existing custom modules if user doesn't want to modify them - const { Installer } = require('./core/installer'); - const installer = new Installer(); - const { bmadDir } = await installer.findBmadDir(confirmedDirectory); - - const cacheDir = path.join(bmadDir, '_config', 'custom'); - if (await fs.pathExists(cacheDir)) { - const entries = await fs.readdir(cacheDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - customModuleResult.selectedCustomModules.push(entry.name); - } - } - } - } - } - - // Merge any selected custom modules - if (customModuleResult.selectedCustomModules.length > 0) { - selectedModules.push(...customModuleResult.selectedCustomModules); - } - // Ensure core is in the modules list if (!selectedModules.includes('core')) { selectedModules.unshift('core'); @@ -357,7 +185,6 @@ class UI { skipIde: toolSelection.skipIde, coreConfig: moduleConfigs.core || {}, moduleConfigs: moduleConfigs, - customContent: customModuleResult.customContentConfig, skipPrompts: options.yes || false, }; } @@ -383,84 +210,6 @@ class UI { selectedModules = await this.selectAllModules(installedModuleIds); } - // Ask about custom content (local modules/agents/workflows) - if (options.customContent) { - // Use custom content from command-line - const paths = options.customContent - .split(',') - .map((p) => p.trim()) - .filter(Boolean); - await prompts.log.info(`Using custom content from command-line: ${paths.join(', ')}`); - - // Build custom content config similar to promptCustomContentSource - const customPaths = []; - const selectedModuleIds = []; - const sources = []; - - for (const customPath of paths) { - const expandedPath = this.expandUserPath(customPath); - const validation = this.validateCustomContentPathSync(expandedPath); - if (validation) { - await prompts.log.warn(`Skipping invalid custom content path: ${customPath} - ${validation}`); - continue; - } - - // Read module metadata - let moduleMeta; - try { - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - const moduleYaml = await fs.readFile(moduleYamlPath, 'utf-8'); - const yaml = require('yaml'); - moduleMeta = yaml.parse(moduleYaml); - } catch (error) { - await prompts.log.warn(`Skipping custom content path: ${customPath} - failed to read module.yaml: ${error.message}`); - continue; - } - - if (!moduleMeta) { - await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml is empty`); - continue; - } - - if (!moduleMeta.code) { - await prompts.log.warn(`Skipping custom content path: ${customPath} - module.yaml missing 'code' field`); - continue; - } - - customPaths.push(expandedPath); - selectedModuleIds.push(moduleMeta.code); - sources.push({ - path: expandedPath, - id: moduleMeta.code, - name: moduleMeta.name || moduleMeta.code, - }); - } - - if (customPaths.length > 0) { - customContentConfig = { - hasCustomContent: true, - selected: true, - sources, - selectedFiles: customPaths.map((p) => path.join(p, 'module.yaml')), - selectedModuleIds: selectedModuleIds, - }; - } - } else if (!options.yes) { - const wantsCustomContent = await prompts.confirm({ - message: 'Add custom modules, agents, or workflows from your computer?', - default: false, - }); - - if (wantsCustomContent) { - customContentConfig = await this.promptCustomContentSource(); - } - } - - // Add custom content modules if any were selected - if (customContentConfig && customContentConfig.selectedModuleIds) { - selectedModules.push(...customContentConfig.selectedModuleIds); - } - // Ensure core is in the modules list if (!selectedModules.includes('core')) { selectedModules.unshift('core'); @@ -476,7 +225,6 @@ class UI { skipIde: toolSelection.skipIde, coreConfig: moduleConfigs.core || {}, moduleConfigs: moduleConfigs, - customContent: customContentConfig, skipPrompts: options.yes || false, }; } @@ -814,90 +562,6 @@ class UI { return configCollector.collectedConfig; } - /** - * Get module choices for selection - * @param {Set} installedModuleIds - Currently installed module IDs - * @param {Object} customContentConfig - Custom content configuration - * @returns {Array} Module choices for prompt - */ - async getModuleChoices(installedModuleIds, customContentConfig = null) { - const color = await prompts.getColor(); - const moduleChoices = []; - const isNewInstallation = installedModuleIds.size === 0; - - const customContentItems = []; - - // Add custom content items - if (customContentConfig && customContentConfig.hasCustomContent && customContentConfig.customPath) { - // Existing installation - show from directory - const customHandler = new CustomHandler(); - const customFiles = await customHandler.findCustomContent(customContentConfig.customPath); - - for (const customFile of customFiles) { - const customInfo = await customHandler.getCustomInfo(customFile); - if (customInfo) { - customContentItems.push({ - name: `${color.cyan('\u2713')} ${customInfo.name} ${color.dim(`(${customInfo.relativePath})`)}`, - value: `__CUSTOM_CONTENT__${customFile}`, // Unique value for each custom content - checked: true, // Default to selected since user chose to provide custom content - path: customInfo.path, // Track path to avoid duplicates - hint: customInfo.description || undefined, - }); - } - } - } - - // Add official modules - const { OfficialModules } = require('./modules/official-modules'); - const officialModules = new OfficialModules(); - const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable(); - - // First, add all items to appropriate sections - const allCustomModules = []; - - // Add custom content items from directory - allCustomModules.push(...customContentItems); - - // Add custom modules from cache - for (const mod of customModulesFromCache) { - // Skip if this module is already in customContentItems (by path) - const isDuplicate = allCustomModules.some((item) => item.path && mod.path && path.resolve(item.path) === path.resolve(mod.path)); - - if (!isDuplicate) { - allCustomModules.push({ - name: `${color.cyan('\u2713')} ${mod.name} ${color.dim('(cached)')}`, - value: mod.id, - checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), - hint: mod.description || undefined, - }); - } - } - - // Add separators and modules in correct order - if (allCustomModules.length > 0) { - // Add separator for custom content, all custom modules, and official content separator - moduleChoices.push( - new choiceUtils.Separator('── Custom Content ──'), - ...allCustomModules, - new choiceUtils.Separator('── Official Content ──'), - ); - } - - // Add official modules (only non-custom ones) - for (const mod of availableModules) { - if (!mod.isCustom) { - moduleChoices.push({ - name: mod.name, - value: mod.id, - checked: isNewInstallation ? mod.defaultSelected || false : installedModuleIds.has(mod.id), - hint: mod.description || undefined, - }); - } - } - - return moduleChoices; - } - /** * Select all modules (official + community) using grouped multiselect. * Core is shown as locked but filtered from the result since it's always installed separately. @@ -941,7 +605,7 @@ class UI { // Local modules (BMM, BMB, etc.) const localEntries = []; for (const mod of localModules) { - if (!mod.isCustom && mod.id !== 'core') { + if (mod.id !== 'core') { const entry = await buildModuleEntry(mod, mod.id, 'Local'); localEntries.push(entry); if (entry.selected) { @@ -1316,282 +980,6 @@ class UI { return existingInstall.ides; } - /** - * Validate custom content path synchronously - * @param {string} input - User input path - * @returns {string|undefined} Error message or undefined if valid - */ - validateCustomContentPathSync(input) { - // Allow empty input to cancel - if (!input || input.trim() === '') { - return; // Allow empty to exit - } - - try { - // Expand the path - const expandedPath = this.expandUserPath(input.trim()); - - // Check if path exists - if (!fs.pathExistsSync(expandedPath)) { - return 'Path does not exist'; - } - - // Check if it's a directory - const stat = fs.statSync(expandedPath); - if (!stat.isDirectory()) { - return 'Path must be a directory'; - } - - // Check for module.yaml in the root - const moduleYamlPath = path.join(expandedPath, 'module.yaml'); - if (!fs.pathExistsSync(moduleYamlPath)) { - return 'Directory must contain a module.yaml file in the root'; - } - - // Try to parse the module.yaml to get the module ID - try { - const yaml = require('yaml'); - const content = fs.readFileSync(moduleYamlPath, 'utf8'); - const moduleData = yaml.parse(content); - if (!moduleData.code) { - return 'module.yaml must contain a "code" field for the module ID'; - } - } catch (error) { - return 'Invalid module.yaml file: ' + error.message; - } - - return; // Valid - } catch (error) { - return 'Error validating path: ' + error.message; - } - } - - /** - * Prompt user for custom content source location - * @returns {Object} Custom content configuration - */ - async promptCustomContentSource() { - const customContentConfig = { hasCustomContent: true, sources: [] }; - - // Keep asking for more sources until user is done - while (true) { - // First ask if user wants to add another module or continue - if (customContentConfig.sources.length > 0) { - const action = await prompts.select({ - message: 'Would you like to:', - choices: [ - { name: 'Add another custom module', value: 'add' }, - { name: 'Continue with installation', value: 'continue' }, - ], - default: 'continue', - }); - - if (action === 'continue') { - break; - } - } - - let sourcePath; - let isValid = false; - - while (!isValid) { - // Use sync validation because @clack/prompts doesn't support async validate - const inputPath = await prompts.text({ - message: 'Path to custom module folder (press Enter to skip):', - validate: (input) => this.validateCustomContentPathSync(input), - }); - - // If user pressed Enter without typing anything, exit the loop - if (!inputPath || inputPath.trim() === '') { - // If we have no modules yet, return false for no custom content - if (customContentConfig.sources.length === 0) { - return { hasCustomContent: false }; - } - return customContentConfig; - } - - sourcePath = this.expandUserPath(inputPath); - isValid = true; - } - - // Read module.yaml to get module info - const yaml = require('yaml'); - const moduleYamlPath = path.join(sourcePath, 'module.yaml'); - const moduleContent = await fs.readFile(moduleYamlPath, 'utf8'); - const moduleData = yaml.parse(moduleContent); - - // Add to sources - customContentConfig.sources.push({ - path: sourcePath, - id: moduleData.code, - name: moduleData.name || moduleData.code, - }); - - await prompts.log.success(`Confirmed local custom module: ${moduleData.name || moduleData.code}`); - } - - // Ask if user wants to add these to the installation - const shouldInstall = await prompts.confirm({ - message: `Install these ${customContentConfig.sources.length} custom modules?`, - default: true, - }); - - if (shouldInstall) { - customContentConfig.selected = true; - // Store paths to module.yaml files, not directories - customContentConfig.selectedFiles = customContentConfig.sources.map((s) => path.join(s.path, 'module.yaml')); - // Also include module IDs for installation - customContentConfig.selectedModuleIds = customContentConfig.sources.map((s) => s.id); - } - - return customContentConfig; - } - - /** - * Handle custom modules in the modify flow - * @param {string} directory - Installation directory - * @param {Array} selectedModules - Currently selected modules - * @returns {Object} Result with selected custom modules and custom content config - */ - async handleCustomModulesInModifyFlow(directory, selectedModules) { - // Get existing installation to find custom modules - const { existingInstall } = await this.getExistingInstallation(directory); - - // Check if there are any custom modules in cache - const { Installer } = require('./core/installer'); - const installer = new Installer(); - const { bmadDir } = await installer.findBmadDir(directory); - - const cacheDir = path.join(bmadDir, '_config', 'custom'); - const cachedCustomModules = []; - - if (await fs.pathExists(cacheDir)) { - const entries = await fs.readdir(cacheDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory()) { - const moduleYamlPath = path.join(cacheDir, entry.name, 'module.yaml'); - if (await fs.pathExists(moduleYamlPath)) { - const yaml = require('yaml'); - const content = await fs.readFile(moduleYamlPath, 'utf8'); - const moduleData = yaml.parse(content); - - cachedCustomModules.push({ - id: entry.name, - name: moduleData.name || entry.name, - description: moduleData.description || 'Custom module from cache', - checked: selectedModules.includes(entry.name), - fromCache: true, - }); - } - } - } - } - - const result = { - selectedCustomModules: [], - customContentConfig: { hasCustomContent: false }, - }; - - // Ask user about custom modules - await prompts.log.info('Custom Modules'); - if (cachedCustomModules.length > 0) { - await prompts.log.message('Found custom modules in your installation:'); - } else { - await prompts.log.message('No custom modules currently installed.'); - } - - // Build choices dynamically based on whether we have existing modules - const choices = []; - if (cachedCustomModules.length > 0) { - choices.push( - { name: 'Keep all existing custom modules', value: 'keep' }, - { name: 'Select which custom modules to keep', value: 'select' }, - { name: 'Add new custom modules', value: 'add' }, - { name: 'Remove all custom modules', value: 'remove' }, - ); - } else { - choices.push({ name: 'Add new custom modules', value: 'add' }, { name: 'Cancel (no custom modules)', value: 'cancel' }); - } - - const customAction = await prompts.select({ - message: cachedCustomModules.length > 0 ? 'Manage custom modules?' : 'Add custom modules?', - choices: choices, - default: cachedCustomModules.length > 0 ? 'keep' : 'add', - }); - - switch (customAction) { - case 'keep': { - // Keep all existing custom modules - result.selectedCustomModules = cachedCustomModules.map((m) => m.id); - await prompts.log.message(`Keeping ${result.selectedCustomModules.length} custom module(s)`); - break; - } - - case 'select': { - // Let user choose which to keep - const selectChoices = cachedCustomModules.map((m) => ({ - name: `${m.name} (${m.id})`, - value: m.id, - checked: m.checked, - })); - - // Add "None / I changed my mind" option at the end - const choicesWithSkip = [ - ...selectChoices, - { - name: '⚠ None / I changed my mind - keep no custom modules', - value: '__NONE__', - checked: false, - }, - ]; - - const keepModules = await prompts.multiselect({ - message: 'Select custom modules to keep (use arrow keys, space to toggle):', - choices: choicesWithSkip, - required: true, - }); - - // If user selected both "__NONE__" and other modules, honor the "None" choice - if (keepModules && keepModules.includes('__NONE__') && keepModules.length > 1) { - await prompts.log.warn('"None / I changed my mind" was selected, so no custom modules will be kept.'); - result.selectedCustomModules = []; - } else { - // Filter out the special '__NONE__' value - result.selectedCustomModules = keepModules ? keepModules.filter((m) => m !== '__NONE__') : []; - } - break; - } - - case 'add': { - // By default, keep existing modules when adding new ones - // User chose "Add new" not "Replace", so we assume they want to keep existing - result.selectedCustomModules = cachedCustomModules.map((m) => m.id); - - // Then prompt for new ones (reuse existing method) - const newCustomContent = await this.promptCustomContentSource(); - if (newCustomContent.hasCustomContent && newCustomContent.selected) { - result.selectedCustomModules.push(...newCustomContent.selectedModuleIds); - result.customContentConfig = newCustomContent; - } - break; - } - - case 'remove': { - // Remove all custom modules - await prompts.log.warn('All custom modules will be removed from the installation'); - break; - } - - case 'cancel': { - // User cancelled - no custom modules - await prompts.log.message('No custom modules will be added'); - break; - } - } - - return result; - } - /** * Display module versions with update availability * @param {Array} modules - Array of module info objects with version info