|
| 1 | +#!/usr/bin/env python3 |
| 2 | +# /// script |
| 3 | +# requires-python = ">=3.9" |
| 4 | +# dependencies = [] |
| 5 | +# /// |
| 6 | +"""Remove legacy module directories from _bmad/ after config migration. |
| 7 | +
|
| 8 | +After merge-config.py and merge-help-csv.py have migrated config data and |
| 9 | +deleted individual legacy files, this script removes the now-redundant |
| 10 | +directory trees. These directories contain skill files that are already |
| 11 | +installed at .claude/skills/ (or equivalent) — only the config files at |
| 12 | +_bmad/ root need to persist. |
| 13 | +
|
| 14 | +When --skills-dir is provided, the script verifies that every skill found |
| 15 | +in the legacy directories exists at the installed location before removing |
| 16 | +anything. Directories without skills (like _config/) are removed directly. |
| 17 | +
|
| 18 | +Exit codes: 0=success (including nothing to remove), 1=validation error, 2=runtime error |
| 19 | +""" |
| 20 | + |
| 21 | +import argparse |
| 22 | +import json |
| 23 | +import shutil |
| 24 | +import sys |
| 25 | +from pathlib import Path |
| 26 | + |
| 27 | + |
| 28 | +def parse_args(): |
| 29 | + parser = argparse.ArgumentParser( |
| 30 | + description="Remove legacy module directories from _bmad/ after config migration." |
| 31 | + ) |
| 32 | + parser.add_argument( |
| 33 | + "--bmad-dir", |
| 34 | + required=True, |
| 35 | + help="Path to the _bmad/ directory", |
| 36 | + ) |
| 37 | + parser.add_argument( |
| 38 | + "--module-code", |
| 39 | + required=True, |
| 40 | + help="Module code being cleaned up (e.g. 'bmb')", |
| 41 | + ) |
| 42 | + parser.add_argument( |
| 43 | + "--also-remove", |
| 44 | + action="append", |
| 45 | + default=[], |
| 46 | + help="Additional directory names under _bmad/ to remove (repeatable)", |
| 47 | + ) |
| 48 | + parser.add_argument( |
| 49 | + "--skills-dir", |
| 50 | + help="Path to .claude/skills/ — enables safety verification that skills " |
| 51 | + "are installed before removing legacy copies", |
| 52 | + ) |
| 53 | + parser.add_argument( |
| 54 | + "--verbose", |
| 55 | + action="store_true", |
| 56 | + help="Print detailed progress to stderr", |
| 57 | + ) |
| 58 | + return parser.parse_args() |
| 59 | + |
| 60 | + |
| 61 | +def find_skill_dirs(base_path: str) -> list: |
| 62 | + """Find directories that contain a SKILL.md file. |
| 63 | +
|
| 64 | + Walks the directory tree and returns the leaf directory name for each |
| 65 | + directory containing a SKILL.md. These are considered skill directories. |
| 66 | +
|
| 67 | + Returns: |
| 68 | + List of skill directory names (e.g. ['bmad-agent-builder', 'bmad-builder-setup']) |
| 69 | + """ |
| 70 | + skills = [] |
| 71 | + root = Path(base_path) |
| 72 | + if not root.exists(): |
| 73 | + return skills |
| 74 | + for skill_md in root.rglob("SKILL.md"): |
| 75 | + skills.append(skill_md.parent.name) |
| 76 | + return sorted(set(skills)) |
| 77 | + |
| 78 | + |
| 79 | +def verify_skills_installed( |
| 80 | + bmad_dir: str, dirs_to_check: list, skills_dir: str, verbose: bool = False |
| 81 | +) -> list: |
| 82 | + """Verify that skills in legacy directories exist at the installed location. |
| 83 | +
|
| 84 | + Scans each directory in dirs_to_check for skill folders (containing SKILL.md), |
| 85 | + then checks that a matching directory exists under skills_dir. Directories |
| 86 | + that contain no skills (like _config/) are silently skipped. |
| 87 | +
|
| 88 | + Returns: |
| 89 | + List of verified skill names. |
| 90 | +
|
| 91 | + Raises SystemExit(1) if any skills are missing from skills_dir. |
| 92 | + """ |
| 93 | + all_verified = [] |
| 94 | + missing = [] |
| 95 | + |
| 96 | + for dirname in dirs_to_check: |
| 97 | + legacy_path = Path(bmad_dir) / dirname |
| 98 | + if not legacy_path.exists(): |
| 99 | + continue |
| 100 | + |
| 101 | + skill_names = find_skill_dirs(str(legacy_path)) |
| 102 | + if not skill_names: |
| 103 | + if verbose: |
| 104 | + print( |
| 105 | + f"No skills found in {dirname}/ — skipping verification", |
| 106 | + file=sys.stderr, |
| 107 | + ) |
| 108 | + continue |
| 109 | + |
| 110 | + for skill_name in skill_names: |
| 111 | + installed_path = Path(skills_dir) / skill_name |
| 112 | + if installed_path.is_dir(): |
| 113 | + all_verified.append(skill_name) |
| 114 | + if verbose: |
| 115 | + print( |
| 116 | + f"Verified: {skill_name} exists at {installed_path}", |
| 117 | + file=sys.stderr, |
| 118 | + ) |
| 119 | + else: |
| 120 | + missing.append(skill_name) |
| 121 | + if verbose: |
| 122 | + print( |
| 123 | + f"MISSING: {skill_name} not found at {installed_path}", |
| 124 | + file=sys.stderr, |
| 125 | + ) |
| 126 | + |
| 127 | + if missing: |
| 128 | + error_result = { |
| 129 | + "status": "error", |
| 130 | + "error": "Skills not found at installed location", |
| 131 | + "missing_skills": missing, |
| 132 | + "skills_dir": str(Path(skills_dir).resolve()), |
| 133 | + } |
| 134 | + print(json.dumps(error_result, indent=2)) |
| 135 | + sys.exit(1) |
| 136 | + |
| 137 | + return sorted(set(all_verified)) |
| 138 | + |
| 139 | + |
| 140 | +def count_files(path: Path) -> int: |
| 141 | + """Count all files recursively in a directory.""" |
| 142 | + count = 0 |
| 143 | + for item in path.rglob("*"): |
| 144 | + if item.is_file(): |
| 145 | + count += 1 |
| 146 | + return count |
| 147 | + |
| 148 | + |
| 149 | +def cleanup_directories( |
| 150 | + bmad_dir: str, dirs_to_remove: list, verbose: bool = False |
| 151 | +) -> tuple: |
| 152 | + """Remove specified directories under bmad_dir. |
| 153 | +
|
| 154 | + Returns: |
| 155 | + (removed, not_found, total_files_removed) tuple |
| 156 | + """ |
| 157 | + removed = [] |
| 158 | + not_found = [] |
| 159 | + total_files = 0 |
| 160 | + |
| 161 | + for dirname in dirs_to_remove: |
| 162 | + target = Path(bmad_dir) / dirname |
| 163 | + if not target.exists(): |
| 164 | + not_found.append(dirname) |
| 165 | + if verbose: |
| 166 | + print(f"Not found (skipping): {target}", file=sys.stderr) |
| 167 | + continue |
| 168 | + |
| 169 | + if not target.is_dir(): |
| 170 | + if verbose: |
| 171 | + print(f"Not a directory (skipping): {target}", file=sys.stderr) |
| 172 | + not_found.append(dirname) |
| 173 | + continue |
| 174 | + |
| 175 | + file_count = count_files(target) |
| 176 | + if verbose: |
| 177 | + print( |
| 178 | + f"Removing {target} ({file_count} files)", |
| 179 | + file=sys.stderr, |
| 180 | + ) |
| 181 | + |
| 182 | + try: |
| 183 | + shutil.rmtree(target) |
| 184 | + except OSError as e: |
| 185 | + error_result = { |
| 186 | + "status": "error", |
| 187 | + "error": f"Failed to remove {target}: {e}", |
| 188 | + "directories_removed": removed, |
| 189 | + "directories_failed": dirname, |
| 190 | + } |
| 191 | + print(json.dumps(error_result, indent=2)) |
| 192 | + sys.exit(2) |
| 193 | + |
| 194 | + removed.append(dirname) |
| 195 | + total_files += file_count |
| 196 | + |
| 197 | + return removed, not_found, total_files |
| 198 | + |
| 199 | + |
| 200 | +def main(): |
| 201 | + args = parse_args() |
| 202 | + |
| 203 | + bmad_dir = args.bmad_dir |
| 204 | + module_code = args.module_code |
| 205 | + |
| 206 | + # Build the list of directories to remove |
| 207 | + dirs_to_remove = [module_code, "core"] + args.also_remove |
| 208 | + # Deduplicate while preserving order |
| 209 | + seen = set() |
| 210 | + unique_dirs = [] |
| 211 | + for d in dirs_to_remove: |
| 212 | + if d not in seen: |
| 213 | + seen.add(d) |
| 214 | + unique_dirs.append(d) |
| 215 | + dirs_to_remove = unique_dirs |
| 216 | + |
| 217 | + if args.verbose: |
| 218 | + print(f"Directories to remove: {dirs_to_remove}", file=sys.stderr) |
| 219 | + |
| 220 | + # Safety check: verify skills are installed before removing |
| 221 | + verified_skills = None |
| 222 | + if args.skills_dir: |
| 223 | + if args.verbose: |
| 224 | + print( |
| 225 | + f"Verifying skills installed at {args.skills_dir}", |
| 226 | + file=sys.stderr, |
| 227 | + ) |
| 228 | + verified_skills = verify_skills_installed( |
| 229 | + bmad_dir, dirs_to_remove, args.skills_dir, args.verbose |
| 230 | + ) |
| 231 | + |
| 232 | + # Remove directories |
| 233 | + removed, not_found, total_files = cleanup_directories( |
| 234 | + bmad_dir, dirs_to_remove, args.verbose |
| 235 | + ) |
| 236 | + |
| 237 | + # Build result |
| 238 | + result = { |
| 239 | + "status": "success", |
| 240 | + "bmad_dir": str(Path(bmad_dir).resolve()), |
| 241 | + "directories_removed": removed, |
| 242 | + "directories_not_found": not_found, |
| 243 | + "files_removed_count": total_files, |
| 244 | + } |
| 245 | + |
| 246 | + if args.skills_dir: |
| 247 | + result["safety_checks"] = { |
| 248 | + "skills_verified": True, |
| 249 | + "skills_dir": str(Path(args.skills_dir).resolve()), |
| 250 | + "verified_skills": verified_skills, |
| 251 | + } |
| 252 | + else: |
| 253 | + result["safety_checks"] = None |
| 254 | + |
| 255 | + print(json.dumps(result, indent=2)) |
| 256 | + |
| 257 | + |
| 258 | +if __name__ == "__main__": |
| 259 | + main() |
0 commit comments