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:
parent
ffdd9bc69e
commit
1e13b75e5b
|
|
@ -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`.
|
||||||
|
|
@ -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:]))
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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.
|
Loading…
Reference in New Issue