refactor(bmad-customize): derive skills root from install location

Previously the scanner hardcoded a list of IDE skill directories
(.claude/skills, .cursor/skills, .cline/skills, .continue/skills) and
scanned them relative to the project root. That was wrong: skills can
be installed either project-local or user-global, the IDE determines
the convention, and the set of valid locations is open-ended.

The scanner now derives its primary skills root from __file__ — the
running skill's own install directory is the authoritative location
for finding siblings. --skills-root overrides the default; --extra-root
(repeatable) adds additional locations for the rare mixed-install case.

Changes:
- list_customizable_skills.py: remove SKILL_ROOTS constant, add
  default_skills_root() derived from __file__, rename scan_project
  to scan_skills(skills_roots, project_root), add --skills-root and
  --extra-root flags, de-dupe skills when the same name appears in
  multiple roots (first wins)
- SKILL.md: update Step 2 to describe the scanner's derive-from-install
  behavior and when to use --extra-root; drop the hardcoded IDE path
  list from Notes
- tests: refactor setUp to place skills under a generic skills root
  (not .claude/skills), add 3 new tests for multiple-roots merge,
  duplicate-name precedence, and missing-root error reporting
This commit is contained in:
Brian Madison 2026-04-20 21:21:27 -05:00
parent f98977bca2
commit f5b4d66bfd
3 changed files with 160 additions and 48 deletions

View File

@ -61,12 +61,14 @@ Run:
python3 {skill-root}/scripts/list_customizable_skills.py --project-root {project-root}
```
The scanner derives its own skills directory from its install location — whichever directory `bmad-customize` itself was loaded from is where it looks for siblings. That's the same location the user's other skills are loaded from in this session. If the user mentions skills installed in another location as well (e.g. project-local plus a user-global install), re-run the scanner with one or more `--extra-root <path>` flags to include those.
The scanner returns JSON with `agents`, `workflows`, `scanned_roots`, and `errors`.
- **Present the list** grouped by type. For each entry show: skill name, one-line description, whether a team or user override already exists.
- **For audit/iterate intents**, lead with entries where `has_team_override` or `has_user_override` is true.
- **Surface any non-empty `errors[]`** — malformed `customize.toml` files and other scanner issues should be shown to the user, not swallowed.
- **If the list is empty**, show `scanned_roots` so the user can see what was searched. If their IDE stores skills elsewhere, ask for the install path and include it when you read the target's `customize.toml` in Step 3. If the project genuinely has no customizable skills installed, say so and stop.
- **If the list is empty**, show `scanned_roots` so the user can see what was searched. Ask whether they have skills installed in another location; if so, re-run with `--extra-root` pointing there. If they don't, say the project has no customizable skills installed and stop.
Ask the user which one they want to customize. If their initial ask hints at a target, surface the likely match first.
@ -169,5 +171,5 @@ Say so clearly:
## Notes
- Override files are sparse. Everything omitted inherits from the layer below (base → team → user).
- IDE install paths vary (`.claude/skills/`, `.cursor/skills/`, `.cline/skills/`, `.continue/skills/`). The scanner covers these; if a user's IDE stores skills elsewhere, Step 2 falls back to asking for the path.
- The scanner does not hardcode IDE paths. It scans whichever directory this skill itself was loaded from — that's the same place the user's other skills live in this session. For mixed project-local + user-global setups, use `--extra-root`.
- Full reference on the customization surface, merge rules, and central config lives in `docs/how-to/customize-bmad.md`.

View File

@ -2,13 +2,21 @@
# /// script
# requires-python = ">=3.11"
# ///
"""Enumerate customizable BMad skills installed in a project.
"""Enumerate customizable BMad skills installed alongside this one.
Scans the standard IDE skill install locations under a project root, finds
every 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/`.
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. Exit 0 on success (including empty result), 2 on error.
"""
@ -22,20 +30,21 @@ import sys
import tomllib
from pathlib import Path
# IDE skill install locations, relative to project root.
SKILL_ROOTS = (
".claude/skills",
".cursor/skills",
".cline/skills",
".continue/skills",
)
# 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.
@ -74,17 +83,21 @@ def load_customize(toml_path: Path) -> dict | None:
return None
def scan_project(project_root: Path) -> dict:
"""Walk the standard skill locations and collect customizable skills."""
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 rel_root in SKILL_ROOTS:
root = project_root / rel_root
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))
@ -99,6 +112,13 @@ def scan_project(project_root: Path) -> dict:
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"
@ -106,7 +126,7 @@ def scan_project(project_root: Path) -> dict:
entry_base = {
"name": skill_name,
"install_path": str(skill_dir),
"ide_root": rel_root,
"skills_root": str(root),
"description": description,
"has_team_override": team_override.is_file(),
"has_user_override": user_override.is_file(),
@ -143,8 +163,9 @@ def scan_project(project_root: Path) -> dict:
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"List customizable BMad skills installed under a project, grouped "
"by surface (agent vs workflow), with override status."
"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(
@ -152,6 +173,25 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
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)
@ -164,7 +204,20 @@ def main(argv: list[str]) -> int:
file=sys.stderr,
)
return 2
result = scan_project(project_root)
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

View File

@ -4,13 +4,14 @@
# ///
"""Unit tests for list_customizable_skills.py.
Exercises the scanner against a synthesized project tree:
Exercises the scanner against a synthesized install tree:
- an agent-only customize.toml
- a workflow-only customize.toml
- a customize.toml that exposes both surfaces
- a skill directory with no customize.toml (ignored)
- a pre-existing team override in _bmad/custom/
- malformed TOML (surfaces as an error without aborting)
- multiple skills roots (e.g. project-local + user-global mix)
Run: python3 scripts/tests/test_list_customizable_skills.py
"""
@ -51,8 +52,8 @@ class ScannerTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
self.claude = self.root / ".claude" / "skills"
self.claude.mkdir(parents=True)
self.skills = self.root / "skills"
self.skills.mkdir(parents=True)
self.custom = self.root / "_bmad" / "custom"
self.custom.mkdir(parents=True)
@ -61,12 +62,12 @@ class ScannerTest(unittest.TestCase):
def test_agent_only_skill_detected(self):
_make_skill(
self.claude,
self.skills,
"bmad-agent-pm",
"[agent]\nicon = \"🧠\"\n",
"---\nname: bmad-agent-pm\ndescription: Product manager.\n---\n",
)
result = MODULE.scan_project(self.root)
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]), 1)
self.assertEqual(len(result["workflows"]), 0)
entry = result["agents"][0]
@ -78,12 +79,12 @@ class ScannerTest(unittest.TestCase):
def test_workflow_only_skill_detected(self):
_make_skill(
self.claude,
self.skills,
"bmad-create-prd",
"[workflow]\npersistent_facts = []\n",
"---\nname: bmad-create-prd\ndescription: 'Create a PRD.'\n---\n",
)
result = MODULE.scan_project(self.root)
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]), 0)
self.assertEqual(len(result["workflows"]), 1)
entry = result["workflows"][0]
@ -91,79 +92,133 @@ class ScannerTest(unittest.TestCase):
def test_dual_surface_skill_emits_two_entries(self):
_make_skill(
self.claude,
self.skills,
"bmad-dual",
"[agent]\nicon = \"x\"\n\n[workflow]\npersistent_facts = []\n",
"---\nname: bmad-dual\ndescription: Dual.\n---\n",
)
result = MODULE.scan_project(self.root)
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]), 1)
self.assertEqual(len(result["workflows"]), 1)
self.assertEqual(result["agents"][0]["name"], "bmad-dual")
self.assertEqual(result["workflows"][0]["name"], "bmad-dual")
def test_skill_without_customize_toml_ignored(self):
(self.claude / "bmad-plain").mkdir()
(self.claude / "bmad-plain" / "SKILL.md").write_text("# plain\n")
result = MODULE.scan_project(self.root)
(self.skills / "bmad-plain").mkdir()
(self.skills / "bmad-plain" / "SKILL.md").write_text("# plain\n")
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]) + len(result["workflows"]), 0)
self.assertEqual(result["errors"], [])
def test_existing_team_override_flagged(self):
_make_skill(
self.claude,
self.skills,
"bmad-agent-pm",
"[agent]\nicon = \"x\"\n",
"---\nname: bmad-agent-pm\ndescription: PM.\n---\n",
)
(self.custom / "bmad-agent-pm.toml").write_text("[agent]\n")
result = MODULE.scan_project(self.root)
result = MODULE.scan_skills([self.skills], self.root)
entry = result["agents"][0]
self.assertTrue(entry["has_team_override"])
self.assertFalse(entry["has_user_override"])
def test_missing_surface_block_reports_error(self):
_make_skill(self.claude, "bmad-broken", "[not_a_surface]\nfoo = 1\n")
result = MODULE.scan_project(self.root)
_make_skill(self.skills, "bmad-broken", "[not_a_surface]\nfoo = 1\n")
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]) + len(result["workflows"]), 0)
self.assertEqual(len(result["errors"]), 1)
self.assertIn("no [agent] or [workflow] block", result["errors"][0])
def test_malformed_toml_reports_error_without_aborting(self):
skill_dir = self.claude / "bmad-bad"
skill_dir = self.skills / "bmad-bad"
skill_dir.mkdir()
(skill_dir / "customize.toml").write_text("this is not [valid toml\n")
# Plus a good sibling to confirm scanning continues.
_make_skill(
self.claude,
self.skills,
"bmad-good",
"[agent]\nicon = \"x\"\n",
"---\nname: bmad-good\ndescription: Good.\n---\n",
)
result = MODULE.scan_project(self.root)
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]), 1)
self.assertEqual(len(result["agents"][0]["name"]), len("bmad-good"))
self.assertEqual(result["agents"][0]["name"], "bmad-good")
self.assertTrue(any("failed to parse" in e for e in result["errors"]))
def test_description_with_double_quotes_stripped(self):
_make_skill(
self.claude,
self.skills,
"bmad-q",
"[agent]\nicon = \"x\"\n",
'---\nname: bmad-q\ndescription: "Double-quoted desc."\n---\n',
)
result = MODULE.scan_project(self.root)
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(result["agents"][0]["description"], "Double-quoted desc.")
def test_multiple_skills_roots_are_merged(self):
extra_root = self.root / "extra-skills"
extra_root.mkdir()
_make_skill(
self.skills,
"bmad-agent-pm",
"[agent]\nicon = \"x\"\n",
"---\nname: bmad-agent-pm\ndescription: PM.\n---\n",
)
_make_skill(
extra_root,
"bmad-agent-dev",
"[agent]\nicon = \"y\"\n",
"---\nname: bmad-agent-dev\ndescription: Dev.\n---\n",
)
result = MODULE.scan_skills([self.skills, extra_root], self.root)
names = {a["name"] for a in result["agents"]}
self.assertEqual(names, {"bmad-agent-pm", "bmad-agent-dev"})
self.assertEqual(len(result["scanned_roots"]), 2)
def test_duplicate_skill_name_across_roots_first_wins(self):
extra_root = self.root / "extra-skills"
extra_root.mkdir()
_make_skill(
self.skills,
"bmad-agent-pm",
"[agent]\nicon = \"primary\"\n",
"---\nname: bmad-agent-pm\ndescription: Primary.\n---\n",
)
_make_skill(
extra_root,
"bmad-agent-pm",
"[agent]\nicon = \"duplicate\"\n",
"---\nname: bmad-agent-pm\ndescription: Duplicate.\n---\n",
)
result = MODULE.scan_skills([self.skills, extra_root], self.root)
self.assertEqual(len(result["agents"]), 1)
self.assertEqual(result["agents"][0]["description"], "Primary.")
self.assertEqual(result["agents"][0]["skills_root"], str(self.skills))
def test_missing_skills_root_reports_error(self):
result = MODULE.scan_skills(
[self.root / "does-not-exist", self.skills],
self.root,
)
self.assertTrue(any("skills root does not exist" in e for e in result["errors"]))
def test_cli_emits_valid_json_and_exits_zero(self):
_make_skill(
self.claude,
self.skills,
"bmad-agent-pm",
"[agent]\nicon = \"x\"\n",
"---\nname: bmad-agent-pm\ndescription: PM.\n---\n",
)
proc = subprocess.run(
[sys.executable, str(SCRIPT), "--project-root", str(self.root)],
[
sys.executable,
str(SCRIPT),
"--project-root",
str(self.root),
"--skills-root",
str(self.skills),
],
capture_output=True,
text=True,
check=False,
@ -179,6 +234,8 @@ class ScannerTest(unittest.TestCase):
str(SCRIPT),
"--project-root",
str(self.root / "does-not-exist"),
"--skills-root",
str(self.skills),
],
capture_output=True,
text=True,