Skill Tracer
Skills › BMAD Method › bmad-customize › list_customizable_skills.py

list_customizable_skills.py

226 lines · skills/bmad-customize/scripts/list_customizable_skills.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Enumerate customizable BMad skills installed alongside this one.
6
7Scans a skills directory (by default: the directory this script's own skill
8lives in, derived from __file__), finds every sibling directory containing a
9`customize.toml`, classifies each as agent and/or workflow based on its
10top-level blocks, reads the skill's SKILL.md frontmatter description for a
11one-liner, and checks whether override files already exist in
12`{project-root}/_bmad/custom/`.
14Skills in BMad are loaded either from a project-local location (e.g. the
15project's `.claude/skills/` or `.cursor/skills/`) or from a user-global
16location (e.g. `~/.claude/skills/`). We do not hardcode those paths — the
17running skill's own location is the source of truth for sibling discovery.
18`--extra-root` is available for the rare case where skills live in multiple
19locations on the same machine.
21Output: JSON to stdout. Non-empty `errors[]` in the payload is non-fatal
22by contract — the scanner surfaces malformed TOML, missing roots, and
23skills with no customization block as data for the caller to display,
24and still exits 0. Exit 2 is reserved for invocation errors (e.g.
25missing or unreadable `--project-root`) where no useful payload can be
26produced.
27"""
29from __future__ import annotations
31import argparse
32import json
33import re
34import sys
35import tomllib
36from pathlib import Path
38# Top-level TOML blocks that indicate a customization surface.
39SURFACE_KEYS = ("agent", "workflow")
41FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
44def default_skills_root() -> Path:
45 """Derive the skills root from this script's location.
47 Layout assumption: {skills_root}/bmad-customize/scripts/list_customizable_skills.py.
48 So the skills root is three parents up from this file.
49 """
50 return Path(__file__).resolve().parent.parent.parent
53def read_frontmatter_description(skill_md: Path) -> str:
54 """Extract the `description:` value from a SKILL.md YAML frontmatter block.
56 Returns an empty string if the file is missing, unreadable, or has no
57 description field. Intentionally permissive — this is metadata for a
58 human-facing list, not a validation target.
59 """
60 if not skill_md.is_file():
61 return ""
62 try:
63 text = skill_md.read_text(encoding="utf-8")
64 except (OSError, UnicodeDecodeError):
65 return ""
66 m = FRONTMATTER_RE.match(text)
67 if not m:
68 return ""
69 for line in m.group(1).splitlines():
70 stripped = line.strip()
71 if stripped.startswith("description:"):
72 value = stripped[len("description:") :].strip()
73 # Strip surrounding quotes if present.
74 if (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')):
75 value = value[1:-1]
76 return value
77 return ""
80def load_customize(toml_path: Path) -> dict | None:
81 """Return the parsed TOML, or None if unreadable."""
82 try:
83 with toml_path.open("rb") as f:
84 return tomllib.load(f)
85 except (OSError, tomllib.TOMLDecodeError):
86 return None
89def scan_skills(
90 skills_roots: list[Path],
91 project_root: Path,
92) -> dict:
93 """Scan each skills root for directories that contain a customize.toml."""
94 agents: list[dict] = []
95 workflows: list[dict] = []
96 errors: list[str] = []
97 scanned_roots: list[str] = []
98 seen_names: set[str] = set()
99 custom_dir = project_root / "_bmad" / "custom"
101 for root in skills_roots:
102 if not root.is_dir():
103 errors.append(f"skills root does not exist: {root}")
104 continue
105 scanned_roots.append(str(root))
107 for skill_dir in sorted(p for p in root.iterdir() if p.is_dir()):
108 customize_toml = skill_dir / "customize.toml"
109 if not customize_toml.is_file():
110 continue
112 data = load_customize(customize_toml)
113 if data is None:
114 errors.append(f"failed to parse {customize_toml}")
115 continue
117 skill_name = skill_dir.name
118 # If a skill with this name was already found in an earlier
119 # root, skip it — roots are scanned in the order provided, so
120 # the first occurrence wins.
121 if skill_name in seen_names:
122 continue
123 seen_names.add(skill_name)
125 description = read_frontmatter_description(skill_dir / "SKILL.md")
126 team_override = custom_dir / f"{skill_name}.toml"
127 user_override = custom_dir / f"{skill_name}.user.toml"
129 entry_base = {
130 "name": skill_name,
131 "install_path": str(skill_dir),
132 "skills_root": str(root),
133 "description": description,
134 "has_team_override": team_override.is_file(),
135 "has_user_override": user_override.is_file(),
136 "team_override_path": str(team_override),
137 "user_override_path": str(user_override),
138 }
140 # A skill may expose an agent surface, a workflow surface, or
141 # both. Emit one entry per surface so the caller can group cleanly.
142 surfaces_found = [k for k in SURFACE_KEYS if k in data]
143 if not surfaces_found:
144 errors.append(f"no [agent] or [workflow] block in {customize_toml}")
145 continue
146 for surface in surfaces_found:
147 entry = dict(entry_base)
148 entry["surface"] = surface
149 if surface == "agent":
150 agents.append(entry)
151 else:
152 workflows.append(entry)
154 return {
155 "project_root": str(project_root),
156 "scanned_roots": scanned_roots,
157 "custom_dir": str(custom_dir),
158 "agents": agents,
159 "workflows": workflows,
160 "errors": errors,
161 }
164def parse_args(argv: list[str]) -> argparse.Namespace:
165 parser = argparse.ArgumentParser(
166 description=(
167 "List customizable BMad skills installed alongside this one, "
168 "grouped by surface (agent vs workflow), with override status "
169 "looked up against {project-root}/_bmad/custom/."
170 )
171 )
172 parser.add_argument(
173 "--project-root",
174 required=True,
175 help="Absolute path to the project root (the folder containing _bmad/).",
176 )
177 parser.add_argument(
178 "--skills-root",
179 default=None,
180 help=(
181 "Override the primary skills directory to scan. Defaults to the directory this script's own skill lives in."
182 ),
183 )
184 parser.add_argument(
185 "--extra-root",
186 action="append",
187 default=[],
188 metavar="PATH",
189 help=(
190 "Additional skills directory to include (repeatable). Useful "
191 "when skills live in multiple locations on the same machine "
192 "(e.g. project-local plus a user-global install)."
193 ),
194 )
195 return parser.parse_args(argv)
198def main(argv: list[str]) -> int:
199 args = parse_args(argv)
200 project_root = Path(args.project_root).expanduser().resolve()
201 if not project_root.is_dir():
202 print(
203 f"error: project-root does not exist or is not a directory: {project_root}",
204 file=sys.stderr,
205 )
206 return 2
208 primary = Path(args.skills_root).expanduser().resolve() if args.skills_root else default_skills_root()
209 extras = [Path(p).expanduser().resolve() for p in args.extra_root]
210 # Deduplicate in order of appearance.
211 roots: list[Path] = []
212 for root in [primary, *extras]:
213 if root not in roots:
214 roots.append(root)
216 result = scan_skills(roots, project_root)
217 print(json.dumps(result, indent=2, sort_keys=True))
218 return 0
221if __name__ == "__main__":
222 if sys.platform == "win32":
223 # Piped output on Windows defaults to a legacy code page, not UTF-8.
224 sys.stdout.reconfigure(encoding="utf-8")
225 sys.stderr.reconfigure(encoding="utf-8")
226 sys.exit(main(sys.argv[1:]))