diff --git a/src/core-skills/bmad-customize/SKILL.md b/src/core-skills/bmad-customize/SKILL.md index fe37ee780..d905d91b2 100644 --- a/src/core-skills/bmad-customize/SKILL.md +++ b/src/core-skills/bmad-customize/SKILL.md @@ -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 ` 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`. diff --git a/src/core-skills/bmad-customize/scripts/list_customizable_skills.py b/src/core-skills/bmad-customize/scripts/list_customizable_skills.py index 37436b5b6..358266627 100644 --- a/src/core-skills/bmad-customize/scripts/list_customizable_skills.py +++ b/src/core-skills/bmad-customize/scripts/list_customizable_skills.py @@ -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 diff --git a/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py b/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py index ed2ffdbe4..a7be22ece 100644 --- a/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py +++ b/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py @@ -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,