Skill Tracer
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.
6
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())