#!/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()