From b63086f22e78d2a2222b30d3bfe531b708994ea9 Mon Sep 17 00:00:00 2001 From: Brian Date: Mon, 20 Apr 2026 22:14:54 -0500 Subject: [PATCH] =?UTF-8?q?feat(core-skills):=20add=20bmad-customize=20?= =?UTF-8?q?=E2=80=94=20guided=20authoring=20for=20=5Fbmad/custom=20overrid?= =?UTF-8?q?es=20(#2289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- docs/explanation/named-agents.md | 3 + docs/how-to/customize-bmad.md | 7 +- docs/how-to/expand-bmad-for-your-org.md | 4 + src/core-skills/bmad-customize/SKILL.md | 111 ++++++++ .../scripts/list_customizable_skills.py | 231 ++++++++++++++++ .../tests/test_list_customizable_skills.py | 249 ++++++++++++++++++ src/core-skills/module-help.csv | 1 + 7 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 src/core-skills/bmad-customize/SKILL.md create mode 100644 src/core-skills/bmad-customize/scripts/list_customizable_skills.py create mode 100644 src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py diff --git a/docs/explanation/named-agents.md b/docs/explanation/named-agents.md index 5f8a96774..e5a92511c 100644 --- a/docs/explanation/named-agents.md +++ b/docs/explanation/named-agents.md @@ -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. +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. 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 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 diff --git a/docs/how-to/customize-bmad.md b/docs/how-to/customize-bmad.md index 18a3a0bbb..9433a8820 100644 --- a/docs/how-to/customize-bmad.md +++ b/docs/how-to/customize-bmad.md @@ -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. +:::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 - 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?** -- 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?** diff --git a/docs/how-to/expand-bmad-for-your-org.md b/docs/how-to/expand-bmad-for-your-org.md index ec3b571f9..14485c97a 100644 --- a/docs/how-to/expand-bmad-for-your-org.md +++ b/docs/how-to/expand-bmad-for-your-org.md @@ -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`) ::: +:::tip[Applying these recipes] +The **per-skill recipes** below (Recipes 1–4) 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 Before picking a recipe, know where your override lands: diff --git a/src/core-skills/bmad-customize/SKILL.md b/src/core-skills/bmad-customize/SKILL.md new file mode 100644 index 000000000..0a0212bc8 --- /dev/null +++ b/src/core-skills/bmad-customize/SKILL.md @@ -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-.toml` / `.user.toml`) and per-skill `[workflow]` overrides (`bmad-.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 ` (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 --key + ``` + Show the merged output, point out the changed fields. + + **Resolver missing or fails:** read whichever layers exist — `/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. diff --git a/src/core-skills/bmad-customize/scripts/list_customizable_skills.py b/src/core-skills/bmad-customize/scripts/list_customizable_skills.py new file mode 100644 index 000000000..86fd82a54 --- /dev/null +++ b/src/core-skills/bmad-customize/scripts/list_customizable_skills.py @@ -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:])) 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 new file mode 100644 index 000000000..a7be22ece --- /dev/null +++ b/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py @@ -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() diff --git a/src/core-skills/module-help.csv b/src/core-skills/module-help.csv index efa081372..f3521c743 100644 --- a/src/core-skills/module-help.csv +++ b/src/core-skills/module-help.csv @@ -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-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-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