| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- #!/usr/bin/env python3
- # /// script
- # requires-python = ">=3.11"
- # ///
- """Enumerate customizable BMad skills installed alongside this one.
-
- Scans a skills directory (by default: the directory this script's own skill
- lives in, derived from __file__), finds every sibling directory containing a
- `customize.toml`, classifies each as agent and/or workflow based on its
- top-level blocks, reads the skill's SKILL.md frontmatter description for a
- one-liner, and checks whether override files already exist in
- `{project-root}/_bmad/custom/`.
-
- Skills in BMad are loaded either from a project-local location (e.g. the
- project's `.claude/skills/` or `.cursor/skills/`) or from a user-global
- location (e.g. `~/.claude/skills/`). We do not hardcode those paths — the
- running skill's own location is the source of truth for sibling discovery.
- `--extra-root` is available for the rare case where skills live in multiple
- locations on the same machine.
-
- Output: JSON to stdout. Non-empty `errors[]` in the payload is non-fatal
- by contract — the scanner surfaces malformed TOML, missing roots, and
- skills with no customization block as data for the caller to display,
- and still exits 0. Exit 2 is reserved for invocation errors (e.g.
- missing or unreadable `--project-root`) where no useful payload can be
- produced.
- """
-
- from __future__ import annotations
-
- import argparse
- import json
- import re
- import sys
- import tomllib
- from pathlib import Path
-
- # Top-level TOML blocks that indicate a customization surface.
- SURFACE_KEYS = ("agent", "workflow")
-
- FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
-
-
- def default_skills_root() -> Path:
- """Derive the skills root from this script's location.
-
- Layout assumption: {skills_root}/bmad-customize/scripts/list_customizable_skills.py.
- So the skills root is three parents up from this file.
- """
- return Path(__file__).resolve().parent.parent.parent
-
-
- def read_frontmatter_description(skill_md: Path) -> str:
- """Extract the `description:` value from a SKILL.md YAML frontmatter block.
-
- Returns an empty string if the file is missing, unreadable, or has no
- description field. Intentionally permissive — this is metadata for a
- human-facing list, not a validation target.
- """
- if not skill_md.is_file():
- return ""
- try:
- text = skill_md.read_text(encoding="utf-8")
- except (OSError, UnicodeDecodeError):
- return ""
- m = FRONTMATTER_RE.match(text)
- if not m:
- return ""
- for line in m.group(1).splitlines():
- stripped = line.strip()
- if stripped.startswith("description:"):
- value = stripped[len("description:") :].strip()
- # Strip surrounding quotes if present.
- if (value.startswith("'") and value.endswith("'")) or (
- value.startswith('"') and value.endswith('"')
- ):
- value = value[1:-1]
- return value
- return ""
-
-
- def load_customize(toml_path: Path) -> dict | None:
- """Return the parsed TOML, or None if unreadable."""
- try:
- with toml_path.open("rb") as f:
- return tomllib.load(f)
- except (OSError, tomllib.TOMLDecodeError):
- return None
-
-
- def scan_skills(
- skills_roots: list[Path],
- project_root: Path,
- ) -> dict:
- """Scan each skills root for directories that contain a customize.toml."""
- agents: list[dict] = []
- workflows: list[dict] = []
- errors: list[str] = []
- scanned_roots: list[str] = []
- seen_names: set[str] = set()
- custom_dir = project_root / "_bmad" / "custom"
-
- for root in skills_roots:
- if not root.is_dir():
- errors.append(f"skills root does not exist: {root}")
- continue
- scanned_roots.append(str(root))
-
- for skill_dir in sorted(p for p in root.iterdir() if p.is_dir()):
- customize_toml = skill_dir / "customize.toml"
- if not customize_toml.is_file():
- continue
-
- data = load_customize(customize_toml)
- if data is None:
- errors.append(f"failed to parse {customize_toml}")
- continue
-
- skill_name = skill_dir.name
- # If a skill with this name was already found in an earlier
- # root, skip it — roots are scanned in the order provided, so
- # the first occurrence wins.
- if skill_name in seen_names:
- continue
- seen_names.add(skill_name)
-
- description = read_frontmatter_description(skill_dir / "SKILL.md")
- team_override = custom_dir / f"{skill_name}.toml"
- user_override = custom_dir / f"{skill_name}.user.toml"
-
- entry_base = {
- "name": skill_name,
- "install_path": str(skill_dir),
- "skills_root": str(root),
- "description": description,
- "has_team_override": team_override.is_file(),
- "has_user_override": user_override.is_file(),
- "team_override_path": str(team_override),
- "user_override_path": str(user_override),
- }
-
- # A skill may expose an agent surface, a workflow surface, or
- # both. Emit one entry per surface so the caller can group cleanly.
- surfaces_found = [k for k in SURFACE_KEYS if k in data]
- if not surfaces_found:
- errors.append(
- f"no [agent] or [workflow] block in {customize_toml}"
- )
- continue
- for surface in surfaces_found:
- entry = dict(entry_base)
- entry["surface"] = surface
- if surface == "agent":
- agents.append(entry)
- else:
- workflows.append(entry)
-
- return {
- "project_root": str(project_root),
- "scanned_roots": scanned_roots,
- "custom_dir": str(custom_dir),
- "agents": agents,
- "workflows": workflows,
- "errors": errors,
- }
-
-
- def parse_args(argv: list[str]) -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description=(
- "List customizable BMad skills installed alongside this one, "
- "grouped by surface (agent vs workflow), with override status "
- "looked up against {project-root}/_bmad/custom/."
- )
- )
- parser.add_argument(
- "--project-root",
- required=True,
- help="Absolute path to the project root (the folder containing _bmad/).",
- )
- parser.add_argument(
- "--skills-root",
- default=None,
- help=(
- "Override the primary skills directory to scan. Defaults to the "
- "directory this script's own skill lives in."
- ),
- )
- parser.add_argument(
- "--extra-root",
- action="append",
- default=[],
- metavar="PATH",
- help=(
- "Additional skills directory to include (repeatable). Useful "
- "when skills live in multiple locations on the same machine "
- "(e.g. project-local plus a user-global install)."
- ),
- )
- return parser.parse_args(argv)
-
-
- def main(argv: list[str]) -> int:
- args = parse_args(argv)
- project_root = Path(args.project_root).expanduser().resolve()
- if not project_root.is_dir():
- print(
- f"error: project-root does not exist or is not a directory: {project_root}",
- file=sys.stderr,
- )
- return 2
-
- primary = (
- Path(args.skills_root).expanduser().resolve()
- if args.skills_root
- else default_skills_root()
- )
- extras = [Path(p).expanduser().resolve() for p in args.extra_root]
- # Deduplicate in order of appearance.
- roots: list[Path] = []
- for root in [primary, *extras]:
- if root not in roots:
- roots.append(root)
-
- result = scan_skills(roots, project_root)
- print(json.dumps(result, indent=2, sort_keys=True))
- return 0
-
-
- if __name__ == "__main__":
- sys.exit(main(sys.argv[1:]))
|