Compare commits

...

4 Commits

Author SHA1 Message Date
don-petry c33ca0d95c
Merge 9924dc6344 into c46502f640 2026-04-07 18:59:23 +02:00
Brian c46502f640
feat(installer): overhaul branding, versioning, and skill cleanup (#2223)
* 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.
2026-04-07 02:31:36 -05:00
DJ 9924dc6344 fix: address CodeRabbit review feedback on cleanup-legacy.py
- Fix config restoration for zero-byte files by using `is not None`
  instead of truthiness checks on `config_backup` (empty bytes `b""` is
  falsy, causing silent loss of empty config.yaml files)
- Move config restore into the try/except block so mkdir/write_bytes
  errors are caught and reported as structured JSON instead of tracebacks
- Add logging.error() call on failure for observability
- Replace rglob("SKILL.md") with targeted glob() calls to avoid
  unnecessary deep traversal — only the two canonical installable
  layouts are checked
- Add docstring note explaining why find_skill_dirs() is intentionally
  stricter than the installer's recursive collectSkills()
- Add path traversal validation rejecting "..", "/", "\\" in dir names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:24:27 -07:00
DJ db7b497eeb fix: scope find_skill_dirs to installable positions and preserve config.yaml
The cleanup-legacy.py script used an overly broad rglob("SKILL.md") that
matched template and asset files nested deep in the directory tree (e.g.
bmad-module-builder/assets/setup-skill-template/SKILL.md). This caused
cleanup to abort when it couldn't verify non-installable templates at the
skills directory.

Scopes find_skill_dirs() to only match SKILL.md at recognized installable
positions: direct children ({name}/SKILL.md) and skills subfolder
(skills/{name}/SKILL.md). Also adds config.yaml backup/restore around
shutil.rmtree() so per-module configs needed by bmad-init are preserved.

Fixes #2175

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:31:04 -07:00
9 changed files with 736 additions and 129 deletions

17
removals.txt Normal file
View File

@ -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

View File

@ -1301,6 +1301,14 @@ async function runTests() {
'---\nname: bmad-architect\ndescription: Architect\n---\nOld skill content\n', '---\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) // Run Claude Code setup (which triggers cleanup then install)
const ideManager27 = new IdeManager(); const ideManager27 = new IdeManager();
await ideManager27.ensureInitialized(); await ideManager27.ensureInitialized();

View File

@ -19,24 +19,33 @@ const CLIUtils = {
* Display BMAD logo and version using @clack intro + box * Display BMAD logo and version using @clack intro + box
*/ */
async displayLogo() { async displayLogo() {
const version = this.getVersion();
const color = await prompts.getColor(); const color = await prompts.getColor();
const termWidth = process.stdout.columns || 80;
// ASCII art logo // Full "BMad Method" logo for wide terminals, "BMad" only for narrow
const logo = [ 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', contentAlign: 'center',
rounded: true, rounded: true,
formatBorder: color.blue, formatBorder: color.blue,

View File

@ -26,6 +26,44 @@ class Installer {
this.bmadFolderName = BMAD_FOLDER_NAME; 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 * Main installation method
* @param {Object} config - Installation configuration * @param {Object} config - Installation configuration
@ -52,9 +90,36 @@ class Installer {
await this._validateIdeSelection(config); 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 // Results collector for consolidated summary
const results = []; 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); await this._cacheCustomModules(paths, addResult);
@ -65,7 +130,7 @@ class Installer {
await this._installAndConfigure(config, originalConfig, paths, officialModuleIds, allModules, addResult, officialModules); 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); const restoreResult = await this._restoreUserFiles(paths, updateState);
@ -76,6 +141,7 @@ class Installer {
ides: config.ides, ides: config.ides,
customFiles: restoreResult.customFiles.length > 0 ? restoreResult.customFiles : undefined, customFiles: restoreResult.customFiles.length > 0 ? restoreResult.customFiles : undefined,
modifiedFiles: restoreResult.modifiedFiles.length > 0 ? restoreResult.modifiedFiles : undefined, modifiedFiles: restoreResult.modifiedFiles.length > 0 ? restoreResult.modifiedFiles : undefined,
preInstallVersions,
}); });
return { return {
@ -321,7 +387,7 @@ class Installer {
/** /**
* Set up IDE integrations for each selected IDE. * 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; if (config.skipIde || !config.ides || config.ides.length === 0) return;
await this.ideManager.ensureInitialized(); await this.ideManager.ensureInitialized();
@ -336,6 +402,7 @@ class Installer {
const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, { const setupResult = await this.ideManager.setup(ide, paths.projectRoot, paths.bmadDir, {
selectedModules: allModules || [], selectedModules: allModules || [],
verbose: config.verbose, verbose: config.verbose,
previousSkillIds,
}); });
if (setupResult.success) { if (setupResult.success) {
@ -556,7 +623,7 @@ class Installer {
message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`); message(`${isQuickUpdate ? 'Updating' : 'Installing'} ${moduleName}...`);
const moduleConfig = officialModules.moduleConfigs[moduleName] || {}; const moduleConfig = officialModules.moduleConfigs[moduleName] || {};
await officialModules.install( const installResult = await officialModules.install(
moduleName, moduleName,
paths.bmadDir, paths.bmadDir,
(filePath) => { (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 }, [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())); const selectedIdes = new Set((context.ides || []).map((ide) => String(ide).toLowerCase()));
// Build step lines with status indicators // Build step lines with status indicators
const preVersions = context.preInstallVersions || new Map();
const lines = []; const lines = [];
for (const r of results) { for (const r of results) {
let stepLabel = null; const stepLabel = r.step;
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;
}
let icon; let icon;
if (r.status === 'ok') { if (r.status === 'ok') {
@ -1088,18 +1151,32 @@ class Installer {
} else { } else {
icon = color.red('\u2717'); 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}`); lines.push(` ${icon} ${stepLabel}${detail}`);
} }
if ((context.ides || []).length === 0) { 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 // Context and warnings
lines.push(''); lines.push('');
if (context.bmadDir) { if (context.bmadDir) {
lines.push(` Installed to: ${color.dim(context.bmadDir)}`); lines.push(` Installed to: ${context.bmadDir}`);
} }
if (context.customFiles && context.customFiles.length > 0) { if (context.customFiles && context.customFiles.length > 0) {
lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`); lines.push(` ${color.cyan(`Custom files preserved: ${context.customFiles.length}`)}`);
@ -1111,17 +1188,18 @@ class Installer {
// Next steps // Next steps
lines.push( lines.push(
'', '',
' Next steps:', ' Get started:',
` Read our new Docs Site: ${color.dim('https://docs.bmad-method.org/')}`, ` 1. Launch your AI agent from your project folder`,
` Join our Discord: ${color.dim('https://discord.gg/gk8jAdXWmj')}`, ` 2. Not sure what to do? Invoke the ${color.cyan('bmad-help')} skill and ask it what to do!`,
` Star us on GitHub: ${color.dim('https://github.com/bmad-code-org/BMAD-METHOD/')}`, '',
` Subscribe on YouTube: ${color.dim('https://www.youtube.com/@BMadCode')}`, ` 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) { for (const moduleName of modulesToUpdate) {
if (moduleName === 'core') continue; // Already collected above
const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true); const modulePrompted = await quickModules.collectModuleConfigQuick(moduleName, projectDir, true);
if (modulePrompted) { if (modulePrompted) {
promptedForNewFields = true; promptedForNewFields = true;

View File

@ -837,14 +837,13 @@ class Manifest {
* @returns {Object} Version info object with version, source, npmPackage, repoUrl * @returns {Object} Version info object with version, source, npmPackage, repoUrl
*/ */
async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) { async getModuleVersionInfo(moduleName, bmadDir, moduleSourcePath = null) {
const os = require('node:os');
const yaml = require('yaml'); 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)) { if (['core', 'bmm'].includes(moduleName)) {
const bmadVersion = require(path.join(getProjectRoot(), 'package.json')).version; const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
return { return {
version: bmadVersion, version,
source: 'built-in', source: 'built-in',
npmPackage: null, npmPackage: null,
repoUrl: null, repoUrl: null,
@ -857,42 +856,20 @@ class Manifest {
const moduleInfo = await extMgr.getModuleByCode(moduleName); const moduleInfo = await extMgr.getModuleByCode(moduleName);
if (moduleInfo) { if (moduleInfo) {
// External module - try to get version from npm registry first, then fall back to cache // External module: use moduleSourcePath if provided, otherwise fall back to cache
let version = null; const version = await this._readMarketplaceVersion(moduleName, moduleSourcePath);
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}`);
}
}
}
return { return {
version: version, version,
source: 'external', source: 'external',
npmPackage: moduleInfo.npmPackage || null, npmPackage: moduleInfo.npmPackage || null,
repoUrl: moduleInfo.url || 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 cacheDir = path.join(bmadDir, '_config', 'custom', moduleName);
const moduleYamlPath = path.join(cacheDir, 'module.yaml'); const moduleYamlPath = path.join(cacheDir, 'module.yaml');
@ -901,7 +878,7 @@ class Manifest {
const yamlContent = await fs.readFile(moduleYamlPath, 'utf8'); const yamlContent = await fs.readFile(moduleYamlPath, 'utf8');
const moduleConfig = yaml.parse(yamlContent); const moduleConfig = yaml.parse(yamlContent);
return { return {
version: moduleConfig.version || null, version: version || moduleConfig.version || null,
source: 'custom', source: 'custom',
npmPackage: moduleConfig.npmPackage || null, npmPackage: moduleConfig.npmPackage || null,
repoUrl: moduleConfig.repoUrl || null, repoUrl: moduleConfig.repoUrl || null,
@ -913,13 +890,62 @@ class Manifest {
// Unknown module // Unknown module
return { return {
version: null, version,
source: 'unknown', source: 'unknown',
npmPackage: null, npmPackage: null,
repoUrl: 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 * Fetch latest version from npm for a package
* @param {string} packageName - npm package name * @param {string} packageName - npm package name

View File

@ -86,7 +86,7 @@ class ConfigDrivenIdeSetup {
if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`); if (!options.silent) await prompts.log.info(`Setting up ${this.name}...`);
// Clean up any old BMAD installation first // Clean up any old BMAD installation first
await this.cleanup(projectDir, options); await this.cleanup(projectDir, options, bmadDir);
if (!this.installerConfig) { if (!this.installerConfig) {
return { success: false, reason: 'no-config' }; return { success: false, reason: 'no-config' };
@ -215,15 +215,34 @@ class ConfigDrivenIdeSetup {
* Cleanup IDE configuration * Cleanup IDE configuration
* @param {string} projectDir - Project directory * @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) // 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 (this.installerConfig?.legacy_targets) {
if (!options.silent) await prompts.log.message(' Migrating legacy directories...'); if (!options.silent) await prompts.log.message(' Migrating legacy directories...');
for (const legacyDir of this.installerConfig.legacy_targets) { for (const legacyDir of this.installerConfig.legacy_targets) {
if (this.isGlobalPath(legacyDir)) { if (this.isGlobalPath(legacyDir)) {
await this.warnGlobalLegacy(legacyDir, options); await this.warnGlobalLegacy(legacyDir, options);
} else { } else {
await this.cleanupTarget(projectDir, legacyDir, options); await this.cleanupTarget(projectDir, legacyDir, options, null);
await this.removeEmptyParents(projectDir, legacyDir); await this.removeEmptyParents(projectDir, legacyDir);
} }
} }
@ -244,9 +263,9 @@ class ConfigDrivenIdeSetup {
await this.cleanupRovoDevPrompts(projectDir, options); await this.cleanupRovoDevPrompts(projectDir, options);
} }
// Clean target directory // Clean current target directory
if (this.installerConfig?.target_dir) { 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<string>} 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<string>} 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<string>} 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} projectDir - Project directory
* @param {string} targetDir - Target directory to clean * @param {string} targetDir - Target directory to clean
* @param {Object} options - Cleanup options
* @param {Set<string>|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); const targetPath = path.join(projectDir, targetDir);
if (!(await fs.pathExists(targetPath))) { if (!(await fs.pathExists(targetPath))) {
return; return;
} }
// Remove all bmad* files if (removalSet && removalSet.size === 0) {
return;
}
let entries; let entries;
try { try {
entries = await fs.readdir(targetPath); entries = await fs.readdir(targetPath);
} catch { } catch {
// Directory exists but can't be read - skip cleanup
return; return;
} }
@ -313,23 +426,26 @@ class ConfigDrivenIdeSetup {
let removedCount = 0; let removedCount = 0;
for (const entry of entries) { for (const entry of entries) {
if (!entry || typeof entry !== 'string') { if (!entry || typeof entry !== 'string') continue;
continue;
} // Always preserve bmad-os-* utility skills regardless of cleanup mode
if (entry.startsWith('bmad') && !entry.startsWith('bmad-os-')) { if (entry.startsWith('bmad-os-')) continue;
const entryPath = path.join(targetPath, entry);
// Surgical removal from set, or legacy prefix matching when set is null
const shouldRemove = removalSet ? removalSet.has(entry) : entry.startsWith('bmad');
if (shouldRemove) {
try { try {
await fs.remove(entryPath); await fs.remove(path.join(targetPath, entry));
removedCount++; removedCount++;
} catch { } catch {
// Skip entries that can't be removed (broken symlinks, permission errors) // Skip entries that can't be removed
} }
} }
} }
if (removedCount > 0 && !options.silent) { // Only log cleanup when it's not a routine reinstall (legacy dir cleanup or actual removals)
await prompts.log.message(` Cleaned ${removedCount} BMAD files from ${targetDir}`); // Suppress for current target_dir since it's always cleaned before a fresh write
}
// Remove empty directory after cleanup // Remove empty directory after cleanup
if (removedCount > 0) { if (removedCount > 0) {
@ -339,7 +455,7 @@ class ConfigDrivenIdeSetup {
await fs.remove(targetPath); await fs.remove(targetPath);
} }
} catch { } catch {
// Directory may already be gone or in use — skip // Directory may already be gone or in use
} }
} }
} }

View File

@ -6,32 +6,25 @@
startMessage: | 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! 🌟 100% free. 100% open source. Always.
- Select and install modules during setup - customize your experience No paywalls. No gated content. Knowledge shared, not sold.
- 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
🌟 BMad is 100% free and open source. 🌐 CONNECT:
- No gated Discord. No paywalls. No gated content. Website: https://bmadcode.com/
- We believe in empowering everyone, not just those who can pay. Discord: https://discord.gg/gk8jAdXWmj
- Knowledge should be shared, not sold. YouTube: https://www.youtube.com/@BMadCode
X: https://x.com/BMadCode
Facebook: https://facebook.com/@BMadCode
🎤 SPEAKING & MEDIA: ⭐ SUPPORT THE PROJECT:
- Available for conferences, podcasts, and media appearances Star us: https://github.com/bmad-code-org/BMAD-METHOD/
- Topics: AI-Native Transformation, Spec and Context Engineering, BMad Method Donate: https://buymeacoffee.com/bmad
- For speaking inquiries or interviews, reach out to BMad on Discord! Corporate sponsorship and speaking inquiries: contact@bmadcode.com
⭐ HELP US GROW: Docs, blog, and latest updates: https://bmadcode.com/
- 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
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@ -4,8 +4,50 @@ const fs = require('fs-extra');
const { CLIUtils } = require('./cli-utils'); const { CLIUtils } = require('./cli-utils');
const { CustomHandler } = require('./custom-handler'); const { CustomHandler } = require('./custom-handler');
const { ExternalModuleManager } = require('./modules/external-manager'); const { ExternalModuleManager } = require('./modules/external-manager');
const { getProjectRoot } = require('./project-root');
const prompts = require('./prompts'); 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 // Separator class for visual grouping in select/multiselect prompts
// Note: @clack/prompts doesn't support separators natively, they are filtered out // Note: @clack/prompts doesn't support separators natively, they are filtered out
class Separator { class Separator {
@ -70,17 +112,14 @@ class UI {
if (hasExistingInstall) { if (hasExistingInstall) {
// Get version information // Get version information
const { existingInstall, bmadDir } = await this.getExistingInstallation(confirmedDirectory); 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 // Build menu choices dynamically
const choices = []; const choices = [];
// Always show Quick Update first (allows refreshing installation even on same version) // Always show Quick Update first (allows refreshing installation even on same version)
if (installedVersion !== 'unknown') { if (existingInstall.installed) {
choices.push({ choices.push({
name: `Quick Update (v${installedVersion} → v${currentVersion})`, name: 'Quick Update',
value: 'quick-update', value: 'quick-update',
}); });
} }
@ -880,14 +919,18 @@ class UI {
const lockedValues = ['core']; const lockedValues = ['core'];
// Core module is always installed — show it locked at the top // 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'); initialValues.push('core');
// Helper to build module entry with proper sorting and selection // 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 isInstalled = installedModuleIds.has(value);
const version = await getMarketplaceVersion(value);
const label = version ? `${mod.name} (v${version})` : mod.name;
return { return {
label: mod.name, label,
value, value,
hint: mod.description || group, hint: mod.description || group,
// Pre-select only if already installed (not on fresh install) // Pre-select only if already installed (not on fresh install)
@ -899,7 +942,7 @@ class UI {
const localEntries = []; const localEntries = [];
for (const mod of localModules) { for (const mod of localModules) {
if (!mod.isCustom && mod.id !== 'core') { if (!mod.isCustom && mod.id !== 'core') {
const entry = buildModuleEntry(mod, mod.id, 'Local'); const entry = await buildModuleEntry(mod, mod.id, 'Local');
localEntries.push(entry); localEntries.push(entry);
if (entry.selected) { if (entry.selected) {
initialValues.push(mod.id); initialValues.push(mod.id);
@ -912,7 +955,7 @@ class UI {
const officialModules = []; const officialModules = [];
for (const mod of externalModules) { for (const mod of externalModules) {
if (mod.type === 'bmad-org') { if (mod.type === 'bmad-org') {
const entry = buildModuleEntry(mod, mod.code, 'Official'); const entry = await buildModuleEntry(mod, mod.code, 'Official');
officialModules.push(entry); officialModules.push(entry);
if (entry.selected) { if (entry.selected) {
initialValues.push(mod.code); initialValues.push(mod.code);
@ -925,7 +968,7 @@ class UI {
const communityModules = []; const communityModules = [];
for (const mod of externalModules) { for (const mod of externalModules) {
if (mod.type === 'community') { if (mod.type === 'community') {
const entry = buildModuleEntry(mod, mod.code, 'Community'); const entry = await buildModuleEntry(mod, mod.code, 'Community');
communityModules.push(entry); communityModules.push(entry);
if (entry.selected) { if (entry.selected) {
initialValues.push(mod.code); initialValues.push(mod.code);

View File

@ -0,0 +1,316 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = []
# ///
"""Remove legacy module directories from _bmad/ after config migration.
After merge-config.py and merge-help-csv.py have migrated config data and
deleted individual legacy files, this script removes the now-redundant
directory trees. These directories contain skill files that are already
installed at .claude/skills/ (or equivalent) only the config files at
_bmad/ root need to persist.
When --skills-dir is provided, the script verifies that every skill found
in the legacy directories exists at the installed location before removing
anything. Directories without skills (like _config/) are removed directly.
Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error
"""
import argparse
import json
import logging
import shutil
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
def parse_args():
parser = argparse.ArgumentParser(
description="Remove legacy module directories from _bmad/ after config migration."
)
parser.add_argument(
"--bmad-dir",
required=True,
help="Path to the _bmad/ directory",
)
parser.add_argument(
"--module-code",
required=True,
help="Module code being cleaned up (e.g. 'bmb')",
)
parser.add_argument(
"--also-remove",
action="append",
default=[],
help="Additional directory names under _bmad/ to remove (repeatable)",
)
parser.add_argument(
"--skills-dir",
help="Path to .claude/skills/ — enables safety verification that skills "
"are installed before removing legacy copies",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print detailed progress to stderr",
)
return parser.parse_args()
def find_skill_dirs(base_path: str) -> list:
"""Find installable skill directories under base_path.
Only considers SKILL.md files at recognized installable positions:
- Direct children: base_path/{name}/SKILL.md (legacy flat layout)
- Skills subfolder: base_path/skills/{name}/SKILL.md (current layout)
SKILL.md files nested deeper (e.g. in tasks/, assets/, or within a
skill's own subdirectories) are not installable skills and are skipped.
NOTE: These discovery rules are intentionally stricter than the installer's
recursive collectSkills() behavior. The installer is permissive it walks
the entire tree to find all SKILL.md files for installation. Cleanup must
be conservative: we only match the two canonical installable layouts so we
never accidentally validate a SKILL.md buried in tasks/, assets/, or other
non-installable subdirectories as proof that a skill is present.
Returns:
List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup'])
"""
skills = []
root = Path(base_path)
if not root.exists():
return skills
# Direct child: {name}/SKILL.md
for skill_md in root.glob("*/SKILL.md"):
skills.append(skill_md.parent.name)
# Skills subfolder: skills/{name}/SKILL.md
skills_root = root / "skills"
if skills_root.exists():
for skill_md in skills_root.glob("*/SKILL.md"):
skills.append(skill_md.parent.name)
return sorted(set(skills))
def verify_skills_installed(
bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False
) -> list:
"""Verify that skills in legacy directories exist at the installed location.
Scans each directory in dirs_to_check for skill folders (containing SKILL.md),
then checks that a matching directory exists under skills_dir. Directories
that contain no skills (like _config/) are silently skipped.
Returns:
List of verified skill names.
Raises SystemExit(1) if any skills are missing from skills_dir.
"""
all_verified = []
missing = []
for dirname in dirs_to_check:
legacy_path = Path(bmad_dir) / dirname
if not legacy_path.exists():
continue
skill_names = find_skill_dirs(str(legacy_path))
if not skill_names:
if verbose:
print(
f"No skills found in {dirname}/ — skipping verification",
file=sys.stderr,
)
continue
for skill_name in skill_names:
installed_path = Path(skills_dir) / skill_name
if installed_path.is_dir():
all_verified.append(skill_name)
if verbose:
print(
f"Verified: {skill_name} exists at {installed_path}",
file=sys.stderr,
)
else:
missing.append(skill_name)
if verbose:
print(
f"MISSING: {skill_name} not found at {installed_path}",
file=sys.stderr,
)
if missing:
error_result = {
"status": "error",
"error": "Skills not found at installed location",
"missing_skills": missing,
"skills_dir": str(Path(skills_dir).resolve()),
}
print(json.dumps(error_result, indent=2))
sys.exit(1)
return sorted(set(all_verified))
def count_files(path: Path) -> int:
"""Count all files recursively in a directory."""
count = 0
for item in path.rglob("*"):
if item.is_file():
count += 1
return count
def cleanup_directories(
bmad_dir: str, dirs_to_remove: list, verbose: bool = False
) -> tuple:
"""Remove specified directories under bmad_dir.
Preserves config.yaml files if present (needed by bmad-init at runtime).
Returns:
(removed, not_found, total_files_removed) tuple
"""
removed = []
not_found = []
total_files = 0
for dirname in dirs_to_remove:
target = Path(bmad_dir) / dirname
if not target.exists():
not_found.append(dirname)
if verbose:
print(f"Not found (skipping): {target}", file=sys.stderr)
continue
if not target.is_dir():
if verbose:
print(f"Not a directory (skipping): {target}", file=sys.stderr)
not_found.append(dirname)
continue
# Validate directory name to prevent path traversal
if ".." in dirname or "/" in dirname or "\\" in dirname:
error_result = {
"status": "error",
"error": f"Invalid directory name (path traversal rejected): {dirname}",
"directories_removed": removed,
"directories_failed": dirname,
}
print(json.dumps(error_result, indent=2))
sys.exit(2)
# Preserve config.yaml if present (bmad-init needs per-module configs)
config_path = target / "config.yaml"
config_backup = None
if config_path.exists():
config_backup = config_path.read_bytes()
if verbose:
print(f"Preserving config.yaml in {dirname}/", file=sys.stderr)
file_count = count_files(target)
if config_backup is not None:
file_count -= 1 # Don't count the preserved file
if verbose:
print(
f"Removing {target} ({file_count} files)",
file=sys.stderr,
)
try:
shutil.rmtree(target)
# Restore preserved config.yaml
if config_backup is not None:
target.mkdir(parents=True, exist_ok=True)
config_path.write_bytes(config_backup)
if verbose:
print(
f"Restored config.yaml in {dirname}/",
file=sys.stderr,
)
except OSError as e:
logger.error("Failed during cleanup of %s: %s", target, e)
error_result = {
"status": "error",
"error": f"Failed to remove {target}: {e}",
"directories_removed": removed,
"directories_failed": dirname,
}
print(json.dumps(error_result, indent=2))
sys.exit(2)
removed.append(dirname)
total_files += file_count
return removed, not_found, total_files
def main():
args = parse_args()
bmad_dir = args.bmad_dir
module_code = args.module_code
# Build the list of directories to remove
dirs_to_remove = [module_code, "core"] + args.also_remove
# Deduplicate while preserving order
seen = set()
unique_dirs = []
for d in dirs_to_remove:
if d not in seen:
seen.add(d)
unique_dirs.append(d)
dirs_to_remove = unique_dirs
if args.verbose:
print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr)
# Safety check: verify skills are installed before removing
verified_skills = None
if args.skills_dir:
if args.verbose:
print(
f"Verifying skills installed at {args.skills_dir}",
file=sys.stderr,
)
verified_skills = verify_skills_installed(
bmad_dir, dirs_to_remove, args.skills_dir, args.verbose
)
# Remove directories
removed, not_found, total_files = cleanup_directories(
bmad_dir, dirs_to_remove, args.verbose
)
# Build result
result = {
"status": "success",
"bmad_dir": str(Path(bmad_dir).resolve()),
"directories_removed": removed,
"directories_not_found": not_found,
"files_removed_count": total_files,
}
if args.skills_dir:
result["safety_checks"] = {
"skills_verified": True,
"skills_dir": str(Path(args.skills_dir).resolve()),
"verified_skills": verified_skills,
}
else:
result["safety_checks"] = None
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()