basic BMAD setup
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["pyyaml>=6.0.2,<7"]
|
||||
# ///
|
||||
"""scan_legacy_module: parse an old-format BMad module so the migrate mode can convert it.
|
||||
|
||||
The old format described a module with a `module.yaml` (code, module_version, module_greeting, an optional
|
||||
`agents` roster, and one block per config key with `prompt`, `default`, `result`, `user_setting`,
|
||||
`single-select` or `multi-select`), a `module-help.csv` registry, and a `<code>-setup` skill (or an
|
||||
`assets/module-setup.md` in a single-skill module) that merged both into `_bmad/config.yaml`,
|
||||
`config.user.yaml` and `module-help.csv` with merge scripts. bmod.toml replaces all of it.
|
||||
|
||||
Usage:
|
||||
scan_legacy_module.py <module-folder>
|
||||
|
||||
Output, one JSON object on stdout; paths are relative to the module folder:
|
||||
{"module": {"code", "name", "version", "greeting", "agents": [...]},
|
||||
"config_keys": [{"key", "prompt", "default", "user_setting", "kind", "unconvertible": [...]}],
|
||||
"help_rows": [{"skill", ...the other CSV columns}],
|
||||
"skills": [folder names that hold a SKILL.md],
|
||||
"legacy_reads": [{"skill", "path", "line", "text"}],
|
||||
"setup_skill": name or null,
|
||||
"files_to_delete": [...]}
|
||||
|
||||
kind is text, single-select, multi-select or confirm. unconvertible lists what a bmod [[bmod.config_questions]]
|
||||
entry (key, prompt, default, scope) cannot carry: select options, a result template, regex, required, example,
|
||||
a boolean default. legacy_reads lists every line in a skill that reads _bmad/config.yaml, config.user.yaml,
|
||||
_bmad/<code>/config.yaml or names a bmad-*-setup skill, outside the files slated for deletion.
|
||||
Exit 0 on success, 1 when no module.yaml is found, 2 on a usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKIP_DIRS = {".git", "__pycache__", "node_modules", ".venv", "venv", ".pytest_cache"}
|
||||
MODULE_META_KEYS = {
|
||||
"code",
|
||||
"name",
|
||||
"header",
|
||||
"subheader",
|
||||
"description",
|
||||
"module_version",
|
||||
"default_selected",
|
||||
"module_greeting",
|
||||
"agents",
|
||||
"directories",
|
||||
"post-install-notes",
|
||||
}
|
||||
LEGACY_FILE_NAMES = {"module.yaml", "module-help.csv", "merge-config.py", "merge-help-csv.py", "cleanup-legacy.py"}
|
||||
LEGACY_READ_RE = re.compile(r"_bmad/config\.yaml|config\.user\.yaml|\bbmad-[a-z0-9-]+-setup\b|module-setup\.md")
|
||||
|
||||
|
||||
def walk(root: Path):
|
||||
for path in sorted(root.rglob("*")):
|
||||
if path.is_file() and not any(part in SKIP_DIRS for part in path.relative_to(root).parts):
|
||||
yield path
|
||||
|
||||
|
||||
def find_one(root: Path, name: str) -> Path | None:
|
||||
"""The shallowest file with this name, or None."""
|
||||
matches = sorted((p for p in walk(root) if p.name == name), key=lambda p: (len(p.parts), str(p)))
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def rel(root: Path, path: Path) -> str:
|
||||
return path.relative_to(root).as_posix()
|
||||
|
||||
|
||||
def load_module_yaml(path: Path) -> dict:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def config_key(key: str, spec: dict) -> dict:
|
||||
reasons = []
|
||||
kind = "text"
|
||||
if "single-select" in spec:
|
||||
kind = "single-select"
|
||||
reasons.append("single-select options; ask as free text and name the choices in the prompt")
|
||||
elif "multi-select" in spec:
|
||||
kind = "multi-select"
|
||||
reasons.append("multi-select options; a bmod answer is one scalar")
|
||||
elif isinstance(spec.get("default"), bool):
|
||||
kind = "confirm"
|
||||
reasons.append("boolean default; store the answer as a string or drop the question")
|
||||
result = spec.get("result")
|
||||
if isinstance(result, str) and result not in ("{value}", ""):
|
||||
reasons.append(f"result template {result!r}; fold it into the default and the skill that reads it")
|
||||
for extra in ("regex", "required", "example"):
|
||||
if extra in spec:
|
||||
reasons.append(f"{extra} has no bmod equivalent")
|
||||
return {
|
||||
"key": key,
|
||||
"prompt": spec.get("prompt", ""),
|
||||
"default": spec.get("default"),
|
||||
"user_setting": spec.get("user_setting") is True,
|
||||
"kind": kind,
|
||||
"unconvertible": reasons,
|
||||
}
|
||||
|
||||
|
||||
def read_help_rows(path: Path | None) -> list[dict]:
|
||||
if path is None:
|
||||
return []
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
return [
|
||||
{"skill": row.get("skill", ""), **{k: v for k, v in row.items() if k != "skill" and k is not None}}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def skill_dirs(root: Path) -> list[Path]:
|
||||
return sorted({p.parent for p in walk(root) if p.name == "SKILL.md"})
|
||||
|
||||
|
||||
def skill_of(root: Path, path: Path, skills: list[Path]) -> str:
|
||||
for skill in sorted(skills, key=lambda p: -len(p.parts)):
|
||||
if skill == path.parent or skill in path.parents:
|
||||
return skill.name
|
||||
return root.name
|
||||
|
||||
|
||||
def scan(root: Path) -> dict | None:
|
||||
module_yaml = find_one(root, "module.yaml")
|
||||
if module_yaml is None:
|
||||
return None
|
||||
meta = load_module_yaml(module_yaml)
|
||||
help_csv = find_one(root, "module-help.csv")
|
||||
skills = skill_dirs(root)
|
||||
|
||||
setup_skill = next((s.name for s in skills if s.name.endswith("-setup")), None)
|
||||
module_setup = find_one(root, "module-setup.md")
|
||||
|
||||
to_delete: set[str] = set()
|
||||
for path in walk(root):
|
||||
if path.name in LEGACY_FILE_NAMES:
|
||||
to_delete.add(rel(root, path))
|
||||
if setup_skill:
|
||||
setup_dir = next(s for s in skills if s.name == setup_skill)
|
||||
to_delete = {p for p in to_delete if not p.startswith(rel(root, setup_dir) + "/")}
|
||||
to_delete.add(rel(root, setup_dir) + "/")
|
||||
if module_setup is not None:
|
||||
to_delete.add(rel(root, module_setup))
|
||||
|
||||
reads = []
|
||||
for path in walk(root):
|
||||
if path.suffix != ".md":
|
||||
continue
|
||||
relative = rel(root, path)
|
||||
if relative in to_delete or any(relative.startswith(d) for d in to_delete if d.endswith("/")):
|
||||
continue
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
|
||||
if LEGACY_READ_RE.search(line):
|
||||
reads.append(
|
||||
{
|
||||
"skill": skill_of(root, path, skills),
|
||||
"path": relative,
|
||||
"line": number,
|
||||
"text": line.strip()[:200],
|
||||
}
|
||||
)
|
||||
|
||||
greeting = meta.get("module_greeting", "")
|
||||
return {
|
||||
"module": {
|
||||
"code": meta.get("code", ""),
|
||||
"name": meta.get("name", ""),
|
||||
"version": str(meta.get("module_version", "")),
|
||||
"greeting": greeting.strip() if isinstance(greeting, str) else "",
|
||||
"agents": meta.get("agents") if isinstance(meta.get("agents"), list) else [],
|
||||
},
|
||||
"config_keys": [
|
||||
config_key(k, v)
|
||||
for k, v in meta.items()
|
||||
if k not in MODULE_META_KEYS and isinstance(v, dict) and "prompt" in v
|
||||
],
|
||||
"help_rows": read_help_rows(help_csv),
|
||||
"skills": [s.name for s in skills if s.name != setup_skill],
|
||||
"legacy_reads": reads,
|
||||
"setup_skill": setup_skill,
|
||||
"files_to_delete": sorted(to_delete),
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("module", type=Path, help="old-format module folder")
|
||||
args = p.parse_args(argv)
|
||||
if not args.module.is_dir():
|
||||
p.error(f"not a directory: {args.module}")
|
||||
result = scan(args.module)
|
||||
if result is None:
|
||||
print(f"scan_legacy_module: no module.yaml under {args.module}", file=sys.stderr)
|
||||
return 1
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user