您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

list_customizable_skills.py 7.9KB

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