Skills › BMAD Method › bmad-toolsmith › registry.py
registry.py
156 lines · skills/bmad-toolsmith/scripts/registry.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""registry: what BMad knows about in a project, so a new skill's registration can be guessed.
7Scans the skills roots for bmod.toml files and reports the installed module records, their
8members, and the skills that are wired to BMad (a customize.toml or an _bmad/scripts call) but
9registered with no module. Also says whether _bmad/ is set up and whether the project is a skills
10repository with a record of its own.
12Usage:
13 registry.py --project-root <folder> [--root <skills folder> ...]
15Without --root the roots are <project>/.agents/skills, <project>/.claude/skills, <project>/skills,
16~/.agents/skills and ~/.claude/skills; folders reached twice through symlinks are read once.
18Output, one JSON object on stdout:
19 {"project_root": str,
20 "bmad": {"present": bool, "version": str|null},
21 "skills_repo": bool,
22 "records": [{"code", "folder", "path", "scope", "version", "update_source", "prefix",
23 "skills": [...], "has_help", "has_roster", "single_skill"}, ...],
24 "unregistered": [{"skill", "path"}, ...]}
25Exit 0; 2 on a usage error.
26"""
28from __future__ import annotations
30import argparse
31import json
32import re
33import sys
34import tomllib
35from pathlib import Path
37RUNTIME_RE = re.compile(r"_bmad/scripts/")
40def default_roots(project_root: Path) -> list[Path]:
41 home = Path.home()
42 return [
43 project_root / ".agents" / "skills",
44 project_root / ".claude" / "skills",
45 project_root / "skills",
46 home / ".agents" / "skills",
47 home / ".claude" / "skills",
48 ]
51def load_toml(path: Path) -> dict | None:
52 try:
53 return tomllib.loads(path.read_text(encoding="utf-8"))
54 except (OSError, tomllib.TOMLDecodeError):
55 return None
58def common_prefix(names: list[str]) -> str:
59 """The shared leading `word-` run of the member names, or an empty string."""
60 if not names:
61 return ""
62 parts = [n.split("-") for n in names]
63 shared = []
64 for group in zip(*parts, strict=False):
65 if len(set(group)) != 1:
66 break
67 shared.append(group[0])
68 if len(parts) == 1:
69 # One member: its name minus the last word, or the whole name when it is one word (`bmad-`).
70 shared = parts[0][:-1] or parts[0]
71 return "-".join(shared) + "-" if shared else ""
74def scan(project_root: Path, roots: list[Path]) -> dict:
75 seen: set[Path] = set()
76 records: dict[str, dict] = {}
77 members: dict[str, list[str]] = {}
78 unregistered: list[dict] = []
79 home = Path.home().resolve()
81 for root in roots:
82 if not root.is_dir():
83 continue
84 for folder in sorted(root.iterdir()):
85 if not folder.is_dir() or not (folder / "SKILL.md").is_file():
86 continue
87 real = folder.resolve()
88 if real in seen:
89 continue
90 seen.add(real)
91 scope = (
92 "user" if real.is_relative_to(home) and not real.is_relative_to(project_root.resolve()) else "project"
93 )
94 manifest = folder / "bmod.toml"
95 if not manifest.is_file():
96 skill_text = (folder / "SKILL.md").read_text(encoding="utf-8", errors="replace")
97 if (folder / "customize.toml").is_file() or RUNTIME_RE.search(skill_text):
98 unregistered.append({"skill": folder.name, "path": str(folder)})
99 continue
100 data = load_toml(manifest) or {}
101 bmod = data.get("bmod")
102 if isinstance(bmod, dict) and bmod.get("code"):
103 skills = bmod.get("skills") if isinstance(bmod.get("skills"), list) else []
104 records[folder.name] = {
105 "code": str(bmod["code"]),
106 "folder": folder.name,
107 "path": str(folder),
108 "scope": scope,
109 "version": bmod.get("version"),
110 "update_source": bmod.get("update_source"),
111 "skills": [str(s) for s in skills],
112 "has_help": (folder / "help" / "help.md").is_file(),
113 "has_roster": (folder / "roster.toml").is_file(),
114 "single_skill": "skill" in data,
115 }
116 skill = data.get("skill")
117 if isinstance(skill, dict) and skill.get("bmod"):
118 members.setdefault(str(skill["bmod"]), []).append(folder.name)
120 out = []
121 for folder, rec in sorted(records.items()):
122 names = sorted(set(rec["skills"]) | set(members.get(folder, [])))
123 rec["skills"] = names
124 rec["prefix"] = common_prefix(names) if names else f"{rec['code']}-"
125 out.append(rec)
127 bmad_dir = project_root / "_bmad"
128 core = next((r for r in out if r["code"] == "core-tools"), None)
129 skills_dir = project_root / "skills"
130 skills_repo = skills_dir.is_dir() and any(
131 (p / "SKILL.md").is_file() and (p / "bmod.toml").is_file() for p in skills_dir.iterdir() if p.is_dir()
132 )
133 return {
134 "project_root": str(project_root),
135 "bmad": {"present": bmad_dir.is_dir(), "version": core["version"] if core else None},
136 "skills_repo": skills_repo,
137 "records": out,
138 "unregistered": unregistered,
139 }
142def main(argv: list[str] | None = None) -> int:
143 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
144 p.add_argument("--project-root", type=Path, required=True, help="the folder holding _bmad/ (or that would)")
145 p.add_argument("--root", type=Path, action="append", help="a skills folder to scan; repeatable")
146 args = p.parse_args(argv)
147 project_root = args.project_root.resolve()
148 if not project_root.is_dir():
149 p.error(f"not a directory: {project_root}")
150 roots = args.root or default_roots(project_root)
151 print(json.dumps(scan(project_root, roots), indent=2))
152 return 0
155if __name__ == "__main__":
156 sys.exit(main())