basic BMAD setup
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""registry: what BMad knows about in a project, so a new skill's registration can be guessed.
|
||||
|
||||
Scans the skills roots for bmod.toml files and reports the installed module records, their
|
||||
members, and the skills that are wired to BMad (a customize.toml or an _bmad/scripts call) but
|
||||
registered with no module. Also says whether _bmad/ is set up and whether the project is a skills
|
||||
repository with a record of its own.
|
||||
|
||||
Usage:
|
||||
registry.py --project-root <folder> [--root <skills folder> ...]
|
||||
|
||||
Without --root the roots are <project>/.agents/skills, <project>/.claude/skills, <project>/skills,
|
||||
~/.agents/skills and ~/.claude/skills; folders reached twice through symlinks are read once.
|
||||
|
||||
Output, one JSON object on stdout:
|
||||
{"project_root": str,
|
||||
"bmad": {"present": bool, "version": str|null},
|
||||
"skills_repo": bool,
|
||||
"records": [{"code", "folder", "path", "scope", "version", "update_source", "prefix",
|
||||
"skills": [...], "has_help", "has_roster", "single_skill"}, ...],
|
||||
"unregistered": [{"skill", "path"}, ...]}
|
||||
Exit 0; 2 on a usage error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
RUNTIME_RE = re.compile(r"_bmad/scripts/")
|
||||
|
||||
|
||||
def default_roots(project_root: Path) -> list[Path]:
|
||||
home = Path.home()
|
||||
return [
|
||||
project_root / ".agents" / "skills",
|
||||
project_root / ".claude" / "skills",
|
||||
project_root / "skills",
|
||||
home / ".agents" / "skills",
|
||||
home / ".claude" / "skills",
|
||||
]
|
||||
|
||||
|
||||
def load_toml(path: Path) -> dict | None:
|
||||
try:
|
||||
return tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def common_prefix(names: list[str]) -> str:
|
||||
"""The shared leading `word-` run of the member names, or an empty string."""
|
||||
if not names:
|
||||
return ""
|
||||
parts = [n.split("-") for n in names]
|
||||
shared = []
|
||||
for group in zip(*parts, strict=False):
|
||||
if len(set(group)) != 1:
|
||||
break
|
||||
shared.append(group[0])
|
||||
if len(parts) == 1:
|
||||
# One member: its name minus the last word, or the whole name when it is one word (`bmad-`).
|
||||
shared = parts[0][:-1] or parts[0]
|
||||
return "-".join(shared) + "-" if shared else ""
|
||||
|
||||
|
||||
def scan(project_root: Path, roots: list[Path]) -> dict:
|
||||
seen: set[Path] = set()
|
||||
records: dict[str, dict] = {}
|
||||
members: dict[str, list[str]] = {}
|
||||
unregistered: list[dict] = []
|
||||
home = Path.home().resolve()
|
||||
|
||||
for root in roots:
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for folder in sorted(root.iterdir()):
|
||||
if not folder.is_dir() or not (folder / "SKILL.md").is_file():
|
||||
continue
|
||||
real = folder.resolve()
|
||||
if real in seen:
|
||||
continue
|
||||
seen.add(real)
|
||||
scope = (
|
||||
"user" if real.is_relative_to(home) and not real.is_relative_to(project_root.resolve()) else "project"
|
||||
)
|
||||
manifest = folder / "bmod.toml"
|
||||
if not manifest.is_file():
|
||||
skill_text = (folder / "SKILL.md").read_text(encoding="utf-8", errors="replace")
|
||||
if (folder / "customize.toml").is_file() or RUNTIME_RE.search(skill_text):
|
||||
unregistered.append({"skill": folder.name, "path": str(folder)})
|
||||
continue
|
||||
data = load_toml(manifest) or {}
|
||||
bmod = data.get("bmod")
|
||||
if isinstance(bmod, dict) and bmod.get("code"):
|
||||
skills = bmod.get("skills") if isinstance(bmod.get("skills"), list) else []
|
||||
records[folder.name] = {
|
||||
"code": str(bmod["code"]),
|
||||
"folder": folder.name,
|
||||
"path": str(folder),
|
||||
"scope": scope,
|
||||
"version": bmod.get("version"),
|
||||
"update_source": bmod.get("update_source"),
|
||||
"skills": [str(s) for s in skills],
|
||||
"has_help": (folder / "help" / "help.md").is_file(),
|
||||
"has_roster": (folder / "roster.toml").is_file(),
|
||||
"single_skill": "skill" in data,
|
||||
}
|
||||
skill = data.get("skill")
|
||||
if isinstance(skill, dict) and skill.get("bmod"):
|
||||
members.setdefault(str(skill["bmod"]), []).append(folder.name)
|
||||
|
||||
out = []
|
||||
for folder, rec in sorted(records.items()):
|
||||
names = sorted(set(rec["skills"]) | set(members.get(folder, [])))
|
||||
rec["skills"] = names
|
||||
rec["prefix"] = common_prefix(names) if names else f"{rec['code']}-"
|
||||
out.append(rec)
|
||||
|
||||
bmad_dir = project_root / "_bmad"
|
||||
core = next((r for r in out if r["code"] == "core-tools"), None)
|
||||
skills_dir = project_root / "skills"
|
||||
skills_repo = skills_dir.is_dir() and any(
|
||||
(p / "SKILL.md").is_file() and (p / "bmod.toml").is_file() for p in skills_dir.iterdir() if p.is_dir()
|
||||
)
|
||||
return {
|
||||
"project_root": str(project_root),
|
||||
"bmad": {"present": bmad_dir.is_dir(), "version": core["version"] if core else None},
|
||||
"skills_repo": skills_repo,
|
||||
"records": out,
|
||||
"unregistered": unregistered,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--project-root", type=Path, required=True, help="the folder holding _bmad/ (or that would)")
|
||||
p.add_argument("--root", type=Path, action="append", help="a skills folder to scan; repeatable")
|
||||
args = p.parse_args(argv)
|
||||
project_root = args.project_root.resolve()
|
||||
if not project_root.is_dir():
|
||||
p.error(f"not a directory: {project_root}")
|
||||
roots = args.root or default_roots(project_root)
|
||||
print(json.dumps(scan(project_root, roots), indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user