feat(core-skills): add bmad-customize for authoring _bmad/custom overrides

A conversational guide skill that helps users author or update TOML overrides
in _bmad/custom/ for customizable BMad agents and workflows. Covers per-skill
agent and workflow surfaces; central config is out of scope for v1.

- SKILL.md: six-step flow (intent, discover, route, compose, team-vs-user,
  show-confirm-write-verify) with baked-in agent-vs-workflow routing heuristic
  and a template-swap subroutine
- scripts/list_customizable_skills.py: stdlib-only scanner that enumerates
  customizable skills across standard IDE install paths, reports surface type
  and override status, PEP 723, 10 unit tests
- Reuses _bmad/scripts/resolve_customization.py for post-write verification
- Registered in core-skills/module-help.csv with menu code BC
This commit is contained in:
Brian Madison 2026-04-20 21:14:45 -05:00
parent ffdd9bc69e
commit 1e13b75e5b
4 changed files with 496 additions and 0 deletions

View File

@ -0,0 +1,130 @@
---
name: bmad-customize
description: Help users author or update {project-root}/_bmad/custom overrides for customizable skills. Use when the user says 'customize bmad', 'override a skill', 'change agent behavior', 'customize a workflow', or asks how to change the behavior of a specific BMad skill.
---
# BMad Customize
## Purpose
Translate a user's intent ("I want X to behave differently") into a correctly-placed TOML override file in `{project-root}/_bmad/custom/`. Walk them through discovery when they're exploring, route them to the right surface (agent vs workflow) when the ask is ambiguous, author the override conversationally, and verify the merge landed.
Scope for this version: per-skill **agent** overrides (`bmad-agent-<role>.toml` / `.user.toml`) and per-skill **workflow** overrides (`bmad-<workflow>.toml` / `.user.toml`). Central config (`{project-root}/_bmad/custom/config.toml`) is out of scope — flag it and point the user at `docs/how-to/customize-bmad.md` if their ask lives there.
## Desired Outcomes
When this skill completes, the user should:
1. **Understand their target's customization surface** — which fields are exposed, what's already overridden, what isn't customizable at all
2. **Know which surface to use** — agent-level (broad, cross-workflow) vs workflow-level (surgical, single workflow), with the tradeoff made explicit when it's ambiguous
3. **End with a written, verified override** — the TOML file exists at the right path and the resolver confirms the merge produced the intended behavior
4. **Feel confident iterating** — know where the file lives, what each field does, and how to adjust it later
## Role
Act as a customization guide. Trust the user's domain knowledge; your job is to map their intent onto the right TOML shape and placement. When the target's `customize.toml` doesn't expose what they need, say so plainly and offer realistic alternatives — don't invent fields that don't exist.
## On Activation
Load available config from `{project-root}/_bmad/config.yaml` and `{project-root}/_bmad/config.user.yaml` (root level). Defaults if missing: `user_name` (BMad), `communication_language` (English). Greet the user and acknowledge the topic.
Treat the user's invoking message as initial intent. Skip discovery if they already named a target skill AND a specific change — go straight to Step 3.
## Flow
### Step 1: Classify intent
Read what the user said on invocation:
- **Directed** — Named a specific skill AND a specific change. Capture the pair, jump to Step 3.
- **Exploratory** — General ask ("what can I customize?"). Go to Step 2.
- **Cross-cutting** — Described a change that could live on multiple surfaces ("I want a compliance check before any planning workflow"). Go to Step 3 with extra routing care.
### Step 2: Discovery (exploratory only)
Run:
```
python3 {skill-root}/scripts/list_customizable_skills.py --project-root {project-root}
```
Present the returned list grouped by type (agents, workflows). For each entry show: skill name, one-line description, whether an override already exists in `{project-root}/_bmad/custom/`. Ask the user which one they want to customize. If their initial ask hints at a target, surface the likely match first.
If the scanner returns nothing, the project has no customizable skills installed — tell the user and stop.
### Step 3: Determine the right surface
Read the target skill's `customize.toml` live — that file IS the schema. The top-level block (`[agent]` or `[workflow]`) tells you the surface type.
When the user's intent could be satisfied at either the agent or the workflow layer, apply this heuristic:
**Workflow-level is better when:**
- The change is a template swap, output path, step toggle, or behavior specific to one workflow
- The user wants the change to ONLY affect that workflow
- The knob they want is already exposed as a named scalar (e.g. `prd_template`, `on_complete`)
- Surgical changes are inherently more reliable than broad ones
**Agent-level is better when:**
- The change should apply to every workflow that agent dispatches (persona, communication style, org-wide persistent facts)
- The user wants menu customization
- Multiple workflows need the same behavior and the same agent runs all of them
When ambiguous, present both options with the tradeoff, recommend one, let the user pick.
If the intent lives outside the exposed surface entirely (core workflow logic, step ordering, behavior not in `customize.toml`), say so plainly. Offer realistic alternatives: approximate via `activation_steps_prepend` / `activation_steps_append` / `persistent_facts`, fork the skill, or open a feature request to expose the knob.
### Step 4: Compose the override
Walk the user through the relevant fields from the target's `customize.toml` and translate their plain-English intent into TOML. Apply the merge semantics correctly:
- **Scalars** (`icon`, `role`, `*_template`, `on_complete`, etc.) — override wins
- **Append-only arrays** (`persistent_facts`, `activation_steps_prepend`, `activation_steps_append`, `principles`) — team/user entries append to base defaults in order
- **Keyed arrays of tables** (menu items with `code` or `id`) — matching keys replace in place; new keys append
The override must be **sparse**: only include fields being changed. Never copy the full `customize.toml` — that locks in old defaults and silently drifts on every release.
**Template-swap subroutine** — when the user wants to replace a `*_template` scalar:
1. Find the default template path from the target's `customize.toml` (bare paths resolve under the skill's installed directory).
2. Offer to scaffold a copy at `{project-root}/_bmad/custom/{skill-name}-{purpose}-template.md`, seeded with the default's contents.
3. Point the override at the new path: `{purpose}_template = "{project-root}/_bmad/custom/{skill-name}-{purpose}-template.md"`.
4. Offer to help the user edit the new template with the changes they described.
### Step 5: Team or user placement
Two destinations are possible under `{project-root}/_bmad/custom/`:
- `{skill-name}.toml`**team** scope, committed to git. Use for policies, org conventions, compliance rules, anything that should apply to everyone on the project.
- `{skill-name}.user.toml`**user** scope, gitignored. Use for personal preferences — tone, private facts, personal workflow shortcuts.
Default the choice based on the change's character (policy → team, personal → user) and confirm with the user before writing.
### Step 6: Show, confirm, write, verify
1. **Show** the full TOML block that will be written. If the target file already exists, present a clear diff of what's being added or changed. Never silently overwrite.
2. **Confirm** explicitly — wait for yes before writing.
3. **Write** to the chosen path. Create `{project-root}/_bmad/custom/` if it doesn't exist.
4. **Verify** by running the resolver against the target skill's installed path:
```
python3 {project-root}/_bmad/scripts/resolve_customization.py --skill <install-path> --key <agent-or-workflow>
```
Display the merged output and point out the fields that changed so the user sees their override took effect.
5. **Close the loop** — summarize what changed, where the file lives, and how to iterate. For team overrides, remind the user to commit the file to git.
## When This Skill Can't Help
Say so clearly:
- **Central config** (`{project-root}/_bmad/custom/config.toml` — agent roster, install answers) is not covered by this version. Point the user at `docs/how-to/customize-bmad.md`.
- **Changes to step logic, step ordering, or behavior not exposed in `customize.toml`** require forking or a feature request.
- **Skills without a `customize.toml`** are not customizable — fork is the only path.
## 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.
- Full reference on the customization surface, merge rules, and central config lives in `docs/how-to/customize-bmad.md`.

View File

@ -0,0 +1,173 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Enumerate customizable BMad skills installed in a project.
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/`.
Output: JSON to stdout. Exit 0 on success (including empty result), 2 on error.
"""
from __future__ import annotations
import argparse
import json
import re
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 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:
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_project(project_root: Path) -> dict:
"""Walk the standard skill locations and collect customizable skills."""
agents: list[dict] = []
workflows: list[dict] = []
errors: list[str] = []
scanned_roots: list[str] = []
custom_dir = project_root / "_bmad" / "custom"
for rel_root in SKILL_ROOTS:
root = project_root / rel_root
if not root.is_dir():
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
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),
"ide_root": rel_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 under a project, grouped "
"by surface (agent vs workflow), with override status."
)
)
parser.add_argument(
"--project-root",
required=True,
help="Absolute path to the project root (the folder containing _bmad/).",
)
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
result = scan_project(project_root)
print(json.dumps(result, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@ -0,0 +1,192 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Unit tests for list_customizable_skills.py.
Exercises the scanner against a synthesized project 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)
Run: python3 scripts/tests/test_list_customizable_skills.py
"""
from __future__ import annotations
import importlib.util
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "list_customizable_skills.py"
def _load_module():
spec = importlib.util.spec_from_file_location("list_customizable_skills", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module) # type: ignore[union-attr]
return module
MODULE = _load_module()
def _make_skill(parent: Path, name: str, body: str, skill_md: str | None = None) -> Path:
skill_dir = parent / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "customize.toml").write_text(body, encoding="utf-8")
if skill_md is not None:
(skill_dir / "SKILL.md").write_text(skill_md, encoding="utf-8")
return skill_dir
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.custom = self.root / "_bmad" / "custom"
self.custom.mkdir(parents=True)
def tearDown(self):
self.tmp.cleanup()
def test_agent_only_skill_detected(self):
_make_skill(
self.claude,
"bmad-agent-pm",
"[agent]\nicon = \"🧠\"\n",
"---\nname: bmad-agent-pm\ndescription: Product manager.\n---\n",
)
result = MODULE.scan_project(self.root)
self.assertEqual(len(result["agents"]), 1)
self.assertEqual(len(result["workflows"]), 0)
entry = result["agents"][0]
self.assertEqual(entry["name"], "bmad-agent-pm")
self.assertEqual(entry["surface"], "agent")
self.assertEqual(entry["description"], "Product manager.")
self.assertFalse(entry["has_team_override"])
self.assertFalse(entry["has_user_override"])
def test_workflow_only_skill_detected(self):
_make_skill(
self.claude,
"bmad-create-prd",
"[workflow]\npersistent_facts = []\n",
"---\nname: bmad-create-prd\ndescription: 'Create a PRD.'\n---\n",
)
result = MODULE.scan_project(self.root)
self.assertEqual(len(result["agents"]), 0)
self.assertEqual(len(result["workflows"]), 1)
entry = result["workflows"][0]
self.assertEqual(entry["description"], "Create a PRD.")
def test_dual_surface_skill_emits_two_entries(self):
_make_skill(
self.claude,
"bmad-dual",
"[agent]\nicon = \"x\"\n\n[workflow]\npersistent_facts = []\n",
"---\nname: bmad-dual\ndescription: Dual.\n---\n",
)
result = MODULE.scan_project(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.assertEqual(len(result["agents"]) + len(result["workflows"]), 0)
self.assertEqual(result["errors"], [])
def test_existing_team_override_flagged(self):
_make_skill(
self.claude,
"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)
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)
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.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,
"bmad-good",
"[agent]\nicon = \"x\"\n",
"---\nname: bmad-good\ndescription: Good.\n---\n",
)
result = MODULE.scan_project(self.root)
self.assertEqual(len(result["agents"]), 1)
self.assertEqual(len(result["agents"][0]["name"]), len("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,
"bmad-q",
"[agent]\nicon = \"x\"\n",
'---\nname: bmad-q\ndescription: "Double-quoted desc."\n---\n',
)
result = MODULE.scan_project(self.root)
self.assertEqual(result["agents"][0]["description"], "Double-quoted desc.")
def test_cli_emits_valid_json_and_exits_zero(self):
_make_skill(
self.claude,
"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)],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(proc.returncode, 0, proc.stderr)
payload = json.loads(proc.stdout)
self.assertEqual(len(payload["agents"]), 1)
def test_cli_exits_two_on_missing_project_root(self):
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--project-root",
str(self.root / "does-not-exist"),
],
capture_output=True,
text=True,
check=False,
)
self.assertEqual(proc.returncode, 2)
self.assertIn("does not exist", proc.stderr)
if __name__ == "__main__":
unittest.main()

View File

@ -10,3 +10,4 @@ Core,bmad-editorial-review-structure,Editorial Review - Structure,ES,Use when do
Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assurance or before finalizing deliverables. Code Review in other modules runs this automatically, but also useful for document reviews.",[path],anytime,,,false,, Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assurance or before finalizing deliverables. Code Review in other modules runs this automatically, but also useful for document reviews.",[path],anytime,,,false,,
Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,[path],anytime,,,false,, Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,[path],anytime,,,false,,
Core,bmad-distillator,Distillator,DG,Use when you need token-efficient distillates that preserve all information for downstream LLM consumption.,[path],anytime,,,false,adjacent to source document or specified output_path,distillate markdown file(s) Core,bmad-distillator,Distillator,DG,Use when you need token-efficient distillates that preserve all information for downstream LLM consumption.,[path],anytime,,,false,adjacent to source document or specified output_path,distillate markdown file(s)
Core,bmad-customize,BMad Customize,BC,Use to author or update _bmad/custom TOML overrides for customizable agents and workflows.,,anytime,,,false,{project-root}/_bmad/custom,TOML override files

Can't render this file because it has a wrong number of fields in line 3.