Skills › BMAD Method › bmad-toolsmith › scan_legacy_module.py
scan_legacy_module.py
210 lines · skills/bmad-toolsmith/scripts/scan_legacy_module.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# dependencies = ["pyyaml>=6.0.2,<7"]
5# ///
6"""scan_legacy_module: parse an old-format BMad module so the migrate mode can convert it.
8The old format described a module with a `module.yaml` (code, module_version, module_greeting, an optional
9`agents` roster, and one block per config key with `prompt`, `default`, `result`, `user_setting`,
10`single-select` or `multi-select`), a `module-help.csv` registry, and a `<code>-setup` skill (or an
11`assets/module-setup.md` in a single-skill module) that merged both into `_bmad/config.yaml`,
12`config.user.yaml` and `module-help.csv` with merge scripts. bmod.toml replaces all of it.
14Usage:
15 scan_legacy_module.py <module-folder>
17Output, one JSON object on stdout; paths are relative to the module folder:
18 {"module": {"code", "name", "version", "greeting", "agents": [...]},
19 "config_keys": [{"key", "prompt", "default", "user_setting", "kind", "unconvertible": [...]}],
20 "help_rows": [{"skill", ...the other CSV columns}],
21 "skills": [folder names that hold a SKILL.md],
22 "legacy_reads": [{"skill", "path", "line", "text"}],
23 "setup_skill": name or null,
24 "files_to_delete": [...]}
26kind is text, single-select, multi-select or confirm. unconvertible lists what a bmod [[bmod.config_questions]]
27entry (key, prompt, default, scope) cannot carry: select options, a result template, regex, required, example,
28a boolean default. legacy_reads lists every line in a skill that reads _bmad/config.yaml, config.user.yaml,
29_bmad/<code>/config.yaml or names a bmad-*-setup skill, outside the files slated for deletion.
30Exit 0 on success, 1 when no module.yaml is found, 2 on a usage error.
31"""
33from __future__ import annotations
35import argparse
36import csv
37import json
38import re
39import sys
40from pathlib import Path
42import yaml
44SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", ".pytest_cache"}
45MODULE_META_KEYS = {
46 "code",
47 "name",
48 "header",
49 "subheader",
50 "description",
51 "module_version",
52 "default_selected",
53 "module_greeting",
54 "agents",
55 "directories",
56 "post-install-notes",
57}
58LEGACY_FILE_NAMES = {"module.yaml", "module-help.csv", "merge-config.py", "merge-help-csv.py", "cleanup-legacy.py"}
59LEGACY_READ_RE = re.compile(r"_bmad/config\.yaml|config\.user\.yaml|\bbmad-[a-z0-9-]+-setup\b|module-setup\.md")
62def walk(root: Path):
63 for path in sorted(root.rglob("*")):
64 if path.is_file() and not any(part in SKIP_DIRS for part in path.relative_to(root).parts):
65 yield path
68def find_one(root: Path, name: str) -> Path | None:
69 """The shallowest file with this name, or None."""
70 matches = sorted((p for p in walk(root) if p.name == name), key=lambda p: (len(p.parts), str(p)))
71 return matches[0] if matches else None
74def rel(root: Path, path: Path) -> str:
75 return path.relative_to(root).as_posix()
78def load_module_yaml(path: Path) -> dict:
79 data = yaml.safe_load(path.read_text(encoding="utf-8"))
80 return data if isinstance(data, dict) else {}
83def config_key(key: str, spec: dict) -> dict:
84 reasons = []
85 kind = "text"
86 if "single-select" in spec:
87 kind = "single-select"
88 reasons.append("single-select options; ask as free text and name the choices in the prompt")
89 elif "multi-select" in spec:
90 kind = "multi-select"
91 reasons.append("multi-select options; a bmod answer is one scalar")
92 elif isinstance(spec.get("default"), bool):
93 kind = "confirm"
94 reasons.append("boolean default; store the answer as a string or drop the question")
95 result = spec.get("result")
96 if isinstance(result, str) and result not in ("{value}", ""):
97 reasons.append(f"result template {result!r}; fold it into the default and the skill that reads it")
98 for extra in ("regex", "required", "example"):
99 if extra in spec:
100 reasons.append(f"{extra} has no bmod equivalent")
101 return {
102 "key": key,
103 "prompt": spec.get("prompt", ""),
104 "default": spec.get("default"),
105 "user_setting": spec.get("user_setting") is True,
106 "kind": kind,
107 "unconvertible": reasons,
108 }
111def read_help_rows(path: Path | None) -> list[dict]:
112 if path is None:
113 return []
114 with path.open(encoding="utf-8", newline="") as handle:
115 rows = list(csv.DictReader(handle))
116 return [
117 {"skill": row.get("skill", ""), **{k: v for k, v in row.items() if k != "skill" and k is not None}}
118 for row in rows
119 ]
122def skill_dirs(root: Path) -> list[Path]:
123 return sorted({p.parent for p in walk(root) if p.name == "SKILL.md"})
126def skill_of(root: Path, path: Path, skills: list[Path]) -> str:
127 for skill in sorted(skills, key=lambda p: -len(p.parts)):
128 if skill == path.parent or skill in path.parents:
129 return skill.name
130 return root.name
133def scan(root: Path) -> dict | None:
134 module_yaml = find_one(root, "module.yaml")
135 if module_yaml is None:
136 return None
137 meta = load_module_yaml(module_yaml)
138 help_csv = find_one(root, "module-help.csv")
139 skills = skill_dirs(root)
141 setup_skill = next((s.name for s in skills if s.name.endswith("-setup")), None)
142 module_setup = find_one(root, "module-setup.md")
144 to_delete: set[str] = set()
145 for path in walk(root):
146 if path.name in LEGACY_FILE_NAMES:
147 to_delete.add(rel(root, path))
148 if setup_skill:
149 setup_dir = next(s for s in skills if s.name == setup_skill)
150 to_delete = {p for p in to_delete if not p.startswith(rel(root, setup_dir) + "/")}
151 to_delete.add(rel(root, setup_dir) + "/")
152 if module_setup is not None:
153 to_delete.add(rel(root, module_setup))
155 reads = []
156 for path in walk(root):
157 if path.suffix != ".md":
158 continue
159 relative = rel(root, path)
160 if relative in to_delete or any(relative.startswith(d) for d in to_delete if d.endswith("/")):
161 continue
162 for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
163 if LEGACY_READ_RE.search(line):
164 reads.append(
165 {
166 "skill": skill_of(root, path, skills),
167 "path": relative,
168 "line": number,
169 "text": line.strip()[:200],
170 }
171 )
173 greeting = meta.get("module_greeting", "")
174 return {
175 "module": {
176 "code": meta.get("code", ""),
177 "name": meta.get("name", ""),
178 "version": str(meta.get("module_version", "")),
179 "greeting": greeting.strip() if isinstance(greeting, str) else "",
180 "agents": meta.get("agents") if isinstance(meta.get("agents"), list) else [],
181 },
182 "config_keys": [
183 config_key(k, v)
184 for k, v in meta.items()
185 if k not in MODULE_META_KEYS and isinstance(v, dict) and "prompt" in v
186 ],
187 "help_rows": read_help_rows(help_csv),
188 "skills": [s.name for s in skills if s.name != setup_skill],
189 "legacy_reads": reads,
190 "setup_skill": setup_skill,
191 "files_to_delete": sorted(to_delete),
192 }
195def main(argv: list[str] | None = None) -> int:
196 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
197 p.add_argument("module", type=Path, help="old-format module folder")
198 args = p.parse_args(argv)
199 if not args.module.is_dir():
200 p.error(f"not a directory: {args.module}")
201 result = scan(args.module)
202 if result is None:
203 print(f"scan_legacy_module: no module.yaml under {args.module}", file=sys.stderr)
204 return 1
205 print(json.dumps(result, indent=2))
206 return 0
209if __name__ == "__main__":
210 sys.exit(main())