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

* 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

* refactor(bmad-customize): apply QA pass (top 3 recommendations)

Applies the three highest-payoff themes from the quality analysis:

- Labeling + completion contracts: rename ## Purpose to ## Overview,
  add domain framing (what customization means in BMad, typical user
  arrival shapes), add an explicit Completion block with testable
  conditions for "skill run is done"
- Hostile-environment robustness: add On-Activation preflight that
  classifies no-BMad / BMad-without-resolver / full-install states,
  instruct Step 2 to surface scanner errors[] and scanned_roots on
  empty results, add resolver-missing fallback to Step 6.4, add a
  re-enter-Step-4 recovery loop when verify shows the override didn't
  take effect
- Returning-user and iteration experience: add "Audit / iterate"
  intent class in Step 1, lead discovery with already-overridden
  skills for that intent, read existing overrides in Step 3 before
  composing, frame Step 4 as additive-on-top rather than fresh
  authoring, give Cross-cutting intent an explicit Step 3 branch
  that walks agent-vs-workflow with the user

Resolves 12 of 18 observations from the quality report. Lint clean
(scan-path-standards and scan-scripts both 0 findings). Unit tests
still 10/10.

* 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

* docs(customization): point users at bmad-customize as the guided path

Surface the new bmad-customize skill across the three customization
docs so users know they don't need to hand-author TOML to benefit
from the surface:

- customize-bmad.md: prominent tip at the top introducing the skill
  as the guided authoring helper; updated the "Need to see what's
  customizable?" troubleshooting tip to recommend the skill first
- expand-bmad-for-your-org.md: tip under prereqs noting every recipe
  can be applied via the skill, with the recipes remaining the
  reference for what to override
- named-agents.md: short paragraph in the customization section and a
  link entry under the references list

Hand-authoring still works the same way; the skill is additive.
Central-config overrides are flagged as the current exception.

* docs(bmad-customize): steer users at bmad-builder instead of 'forking'

* fix(bmad-customize): reword description to pass file-ref validator

* refactor(bmad-customize): tighten description and expand module-help entry

- SKILL.md description: drop the catch-all 'or asks how to change the
  behavior of a specific BMad skill' trigger clause that would fire in
  casual discussion; keep the four explicit phrase triggers.
- module-help.csv: rewrite the description so bmad-help has real
  routing material — names the concrete capabilities (persistent
  facts, template swaps, activation hooks, menus), the scope routing,
  and the value prop (no TOML hand-authoring). Matches the 'Use
  when...' pattern other Core entries use.

* fix(module-help): quote bmad-customize description field that contains commas

* fix(bmad-customize): address PR #2289 review findings

- SKILL.md preflight: load root config from _bmad/config.toml and
  config.user.toml (not .yaml) — the installer emits TOML; the YAML
  references would have made the skill silently miss real user config
- SKILL.md resolver fallback (Step 6.4): read all three merge layers
  when present (base / team / user) and describe the merge in
  base → team → user order; the prior wording could describe the wrong
  effective merge when the user wrote .user.toml on top of an existing
  team .toml
- SKILL.md: replace bare 'docs/how-to/customize-bmad.md' references
  (3 locations) with the public docs URL so users installing the skill
  aren't pointed at a path they don't have locally
- list_customizable_skills.py: catch UnicodeDecodeError in
  read_frontmatter_description so a non-UTF-8 SKILL.md can't abort
  the whole scan
- list_customizable_skills.py: clarify exit-code contract in the
  module docstring — errors[] is non-fatal by design, exit 2 is
  reserved for invocation errors
- customize-bmad.md: tighten the tip to scope bmad-customize to the
  per-skill surface; central-config is out of scope v1
- expand-bmad-for-your-org.md: same scoping — Recipes 1-4 can be
  applied by the skill; Recipe 5 (central config) stays hand-authored

* fix(bmad-customize): markdownlint MD034 and validate-file-refs

- Wrap the three docs.bmad-method.org references as
  [text](url) markdown links instead of bare URLs (MD034)
- Drop the {project-root}/ prefix on line 41's config.toml
  references. validate-file-refs strips the template prefix and
  tries to resolve 'config.toml' as 'src/config.toml'; sibling
  skills (party-mode, retrospective, advanced-elicitation) all
  reference '_bmad/config.toml' bare and pass CI — match that
  pattern. The '(root level under {project-root}, installer-owned)'
  parenthetical preserves the disambiguation.

* refactor(bmad-customize): cut token-wasting prose from SKILL.md

Down from 175 lines to 110. Removed:
- 'What customization means in BMad' architecture backgrounder — the
  LLM reads the live customize.toml in Step 3; doesn't need the lore
- 'Desired Outcomes' section — retrospective narration of what the
  6 steps already instruct
- 'Role' section — fluff; the flow itself defines the role
- 'Notes' section — sparse-override rule already in Step 4, IDE-path
  note is commentary, docs link duplicates the out-of-scope section
- 'The scanner derives its skills directory from...' and 'returns JSON
  with...' — commentary the LLM doesn't need; it runs the script and
  sees the output
- 'that file IS the schema' and similar editorial asides throughout
- Explanatory clauses like 'silently drifts on every release' and
  'trust the user's domain knowledge'

Kept everything that's load-bearing: preflight conditionals, intent
classification, routing heuristic, merge semantics, template-swap
subroutine, team-vs-user defaults, verify fallback and recovery loop,
completion conditions, out-of-scope list.
This commit is contained in:
Brian 2026-04-20 22:14:54 -05:00 committed by GitHub
parent ffdd9bc69e
commit b63086f22e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 605 additions and 1 deletions

View File

@ -75,6 +75,8 @@ The customization model is what lets this scale beyond a single developer.
Every agent ships a `customize.toml` with sensible defaults. Teams commit overrides to `_bmad/custom/bmad-agent-{role}.toml`. Individuals can layer personal preferences in `.user.toml` (gitignored). The resolver merges all three at activation time with predictable structural rules. Every agent ships a `customize.toml` with sensible defaults. Teams commit overrides to `_bmad/custom/bmad-agent-{role}.toml`. Individuals can layer personal preferences in `.user.toml` (gitignored). The resolver merges all three at activation time with predictable structural rules.
Most users never hand-author these files. The `bmad-customize` skill walks through picking the target, choosing agent vs workflow scope, authoring the override, and verifying the merge — so the customization surface stays accessible to anyone who understands their intent, not just those fluent in TOML.
Concrete example: a team commits a single file telling Amelia to always use the Context7 MCP tool for library docs and to fall back to Linear when a story isn't in the local epics list. Every dev workflow Amelia dispatches (dev-story, quick-dev, create-story, code-review) inherits that behavior, with no source edits or per-workflow duplication required. Concrete example: a team commits a single file telling Amelia to always use the Context7 MCP tool for library docs and to fall back to Linear when a story isn't in the local epics list. Every dev workflow Amelia dispatches (dev-story, quick-dev, create-story, code-review) inherits that behavior, with no source edits or per-workflow duplication required.
There's also a second customization surface for *cross-cutting* concerns: the central `_bmad/config.toml` and `_bmad/config.user.toml` (both installer-owned, rebuilt from each module's `module.yaml`) plus `_bmad/custom/config.toml` (team, committed) and `_bmad/custom/config.user.toml` (personal, gitignored) for overrides. This is where the **agent roster** lives — the lightweight descriptors that roster consumers like `bmad-party-mode`, `bmad-retrospective`, and `bmad-advanced-elicitation` read to know who's available and how to embody them. Rebrand an agent org-wide with a team override; add fictional voices (Kirk, Spock, a domain expert persona) as personal experiments via the `.user.toml` override — without touching any skill folder. The per-skill file shapes how Mary *behaves* when she activates; the central config shapes how other skills *see* her when they look at the field. There's also a second customization surface for *cross-cutting* concerns: the central `_bmad/config.toml` and `_bmad/config.user.toml` (both installer-owned, rebuilt from each module's `module.yaml`) plus `_bmad/custom/config.toml` (team, committed) and `_bmad/custom/config.user.toml` (personal, gitignored) for overrides. This is where the **agent roster** lives — the lightweight descriptors that roster consumers like `bmad-party-mode`, `bmad-retrospective`, and `bmad-advanced-elicitation` read to know who's available and how to embody them. Rebrand an agent org-wide with a team override; add fictional voices (Kirk, Spock, a domain expert persona) as personal experiments via the `.user.toml` override — without touching any skill folder. The per-skill file shapes how Mary *behaves* when she activates; the central config shapes how other skills *see* her when they look at the field.
@ -83,6 +85,7 @@ For the full customization surface and worked examples, see:
- [How to Customize BMad](../how-to/customize-bmad.md) — the reference for what's customizable and how merge works - [How to Customize BMad](../how-to/customize-bmad.md) — the reference for what's customizable and how merge works
- [How to Expand BMad for Your Organization](../how-to/expand-bmad-for-your-org.md) — five worked recipes spanning agent-wide rules, workflow conventions, external publishing, template swaps, and agent roster customization - [How to Expand BMad for Your Organization](../how-to/expand-bmad-for-your-org.md) — five worked recipes spanning agent-wide rules, workflow conventions, external publishing, template swaps, and agent roster customization
- `bmad-customize` skill — the guided authoring helper that turns intent into a correctly-placed, verified override file
## The Bigger Idea ## The Bigger Idea

View File

@ -7,6 +7,10 @@ sidebar:
Tailor agent personas, inject domain context, add capabilities, and configure workflow behavior -- all without modifying installed files. Your customizations survive every update. Tailor agent personas, inject domain context, add capabilities, and configure workflow behavior -- all without modifying installed files. Your customizations survive every update.
:::tip[Don't want to hand-author TOML? Use `bmad-customize`]
The `bmad-customize` skill is a guided authoring helper for the **per-skill agent/workflow override surface** described in this doc. It scans what's customizable in your installation, helps you choose the right surface (agent vs workflow) for your intent, writes the override file for you, and verifies the merge landed. Central-config overrides (`_bmad/custom/config.toml`) are out of scope for v1 — hand-author those per the Central Configuration section below. Run the skill whenever you want to make a per-skill change; this doc is the reference for *what* each surface exposes and how merging works.
:::
## When to Use This ## When to Use This
- You want to change an agent's personality or communication style - You want to change an agent's personality or communication style
@ -383,7 +387,8 @@ For enterprise-oriented recipes (shaping an agent across every workflow it dispa
**Need to see what's customizable?** **Need to see what's customizable?**
- Read the skill's `customize.toml` -- every field there is customizable (except `name` and `title`) - Run the `bmad-customize` skill — it enumerates every customizable skill installed in your project, shows which ones already have overrides, and walks you through adding or updating one
- Or read the skill's `customize.toml` directly — every field there is customizable (except `name` and `title`)
**Need to reset?** **Need to reset?**

View File

@ -14,6 +14,10 @@ BMad's customization surface lets an organization reshape behavior without editi
- Python 3.11+ on PATH (for the resolver — stdlib only, no `pip install`) - Python 3.11+ on PATH (for the resolver — stdlib only, no `pip install`)
::: :::
:::tip[Applying these recipes]
The **per-skill recipes** below (Recipes 14) can be applied by running the `bmad-customize` skill and describing the intent — it will pick the right surface, author the override file, and verify the merge. Recipe 5 (central-config overrides to the agent roster) is out of scope for v1 of the skill and remains hand-authored. The recipes here are the source of truth for *what* to override; `bmad-customize` handles the *how* for the agent/workflow surface.
:::
## The Three-Layer Mental Model ## The Three-Layer Mental Model
Before picking a recipe, know where your override lands: Before picking a recipe, know where your override lands:

View File

@ -0,0 +1,111 @@
---
name: bmad-customize
description: Authors and updates customization overrides for installed BMad skills. Use when the user says 'customize bmad', 'override a skill', 'change agent behavior', or 'customize a workflow'.
---
# BMad Customize
Translate the user's intent into a correctly-placed TOML override file under `{project-root}/_bmad/custom/` for a customizable agent or workflow skill. Discover, route, author, write, verify.
Scope v1: 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 — point users at the [How to Customize BMad guide](https://docs.bmad-method.org/how-to/customize-bmad/).
When the target's `customize.toml` doesn't expose what the user wants, say so plainly. Don't invent fields.
## Preflight
- No `{project-root}/_bmad/` → BMad isn't installed. Say so, stop.
- `{project-root}/_bmad/scripts/resolve_customization.py` missing → continue, but Step 6 verify falls back to manual merge.
- Both present → proceed.
## Activation
Load `_bmad/config.toml` and `_bmad/config.user.toml` from `{project-root}` for `user_name` (default `BMad`) and `communication_language` (default `English`). Greet. If the user's invocation already names a target skill AND a specific change, jump to Step 3.
## Step 1: Classify intent
- **Directed** — specific skill + specific change → Step 3.
- **Exploratory** — "what can I customize?" → Step 2.
- **Audit/iterate** — wants to review or change something already customized → Step 2, lead with skills that have existing overrides; read the existing override in Step 3 before composing.
- **Cross-cutting** — could live on multiple surfaces → Step 3, choose agent vs workflow explicitly with the user.
## Step 2: Discovery
```
python3 {skill-root}/scripts/list_customizable_skills.py --project-root {project-root}
```
Use `--extra-root <path>` (repeatable) if the user has skills installed in additional locations.
Group the returned `agents` and `workflows` for the user; for each show name, description, whether `has_team_override` or `has_user_override` is true. Surface any `errors[]`. For audit/iterate intents, lead with already-overridden entries.
Empty list: show `scanned_roots`, ask whether skills live elsewhere (offer `--extra-root`); otherwise stop.
## Step 3: Determine the right surface
Read the target's `customize.toml`. Top-level `[agent]` or `[workflow]` block defines the surface.
If a team or user override already exists, read it first and summarize what's already overridden before composing.
**Cross-cutting intent — walk both surfaces with the user:**
- Every workflow a given agent runs → agent surface (e.g. `bmad-agent-pm.toml` with `persistent_facts`, `principles`).
- One workflow only → workflow surface (e.g. `bmad-create-prd.toml` with `activation_steps_prepend`).
- Several specific workflows → multiple workflow overrides in sequence, not an agent override.
**Single-surface heuristic:**
- Workflow-level: template swap, output path, step-specific behavior, or a named scalar already exposed (`*_template`, `on_complete`). Surgical, reliable.
- Agent-level: persona, communication style, org-wide facts, menu changes, behavior that should apply to every workflow the agent dispatches.
When ambiguous, present both with tradeoff, recommend one, let the user decide.
Intent outside the exposed surface (step logic, ordering, anything not in `customize.toml`): say so; offer `activation_steps_prepend`/`append` or `persistent_facts` as approximations, or recommend `bmad-builder` to create a custom skill.
## Step 4: Compose the override
Translate plain-English into TOML against the target's `customize.toml` fields. If an existing override was read, frame the change as additive.
Merge semantics:
- **Scalars** (`icon`, `role`, `*_template`, `on_complete`) — override wins.
- **Append arrays** (`persistent_facts`, `activation_steps_prepend`/`append`, `principles`) — team/user entries append in order.
- **Keyed arrays of tables** (menu items with `code` or `id`) — matching keys replace, new keys append.
Overrides are sparse: only the fields being changed. Never copy the whole `customize.toml`.
**Template swap** (`*_template` scalar): offer to copy the default template to `{project-root}/_bmad/custom/{skill-name}-{purpose}-template.md`, point the override at the new path, offer to help edit it.
## Step 5: Team or user placement
Under `{project-root}/_bmad/custom/`:
- `{skill-name}.toml` — team, committed. Policies, org conventions, compliance.
- `{skill-name}.user.toml` — user, gitignored. Personal tone, private facts, shortcuts.
Default by character (policy → team, personal → user), confirm before writing.
## Step 6: Show, confirm, write, verify
1. Show the full TOML. If the file exists, show a diff. Never silently overwrite.
2. Wait for explicit yes.
3. Write. Create `{project-root}/_bmad/custom/` if needed.
4. Verify:
```
python3 {project-root}/_bmad/scripts/resolve_customization.py --skill <install-path> --key <agent-or-workflow>
```
Show the merged output, point out the changed fields.
**Resolver missing or fails:** read whichever layers exist — `<install-path>/customize.toml` (base), `{project-root}/_bmad/custom/{skill-name}.toml` (team), `{project-root}/_bmad/custom/{skill-name}.user.toml` (user) — apply base → team → user with the same merge rules (scalars override, tables deep-merge, `code`/`id`-keyed arrays merge by key, all other arrays append), describe how the changed fields resolve.
**Verify shows override didn't land** (field unchanged, merge conflict, file not picked up): re-enter Step 4 with the verify output as context. Usually wrong field name, wrong merge mode (scalar vs array), or wrong scope.
5. Summarize what changed, where the file lives, how to iterate. Remind the user to commit team overrides.
## Complete when
- Override file written (or user explicitly aborted).
- User has seen resolver output (or manual fallback merge summary).
- User has acknowledged the summary.
Otherwise the skill isn't done — finish or tell the user they're exiting incomplete.
## When this skill can't help
- **Central config** (`{project-root}/_bmad/custom/config.toml`) — see the [How to Customize BMad guide](https://docs.bmad-method.org/how-to/customize-bmad/).
- **Step logic, ordering, behavior not in `customize.toml`** — open a feature request, or use `bmad-builder` to create a custom skill. Offer to help with either.
- **Skills without a `customize.toml`** — not customizable.

View File

@ -0,0 +1,231 @@
#!/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:]))

View File

@ -0,0 +1,249 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Unit tests for list_customizable_skills.py.
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
"""
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.skills = self.root / "skills"
self.skills.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.skills,
"bmad-agent-pm",
"[agent]\nicon = \"🧠\"\n",
"---\nname: bmad-agent-pm\ndescription: Product manager.\n---\n",
)
result = MODULE.scan_skills([self.skills], 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.skills,
"bmad-create-prd",
"[workflow]\npersistent_facts = []\n",
"---\nname: bmad-create-prd\ndescription: 'Create a PRD.'\n---\n",
)
result = MODULE.scan_skills([self.skills], 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.skills,
"bmad-dual",
"[agent]\nicon = \"x\"\n\n[workflow]\npersistent_facts = []\n",
"---\nname: bmad-dual\ndescription: Dual.\n---\n",
)
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.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.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_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.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.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.skills,
"bmad-good",
"[agent]\nicon = \"x\"\n",
"---\nname: bmad-good\ndescription: Good.\n---\n",
)
result = MODULE.scan_skills([self.skills], self.root)
self.assertEqual(len(result["agents"]), 1)
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.skills,
"bmad-q",
"[agent]\nicon = \"x\"\n",
'---\nname: bmad-q\ndescription: "Double-quoted desc."\n---\n',
)
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.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),
"--skills-root",
str(self.skills),
],
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"),
"--skills-root",
str(self.skills),
],
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 when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,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.