diff --git a/src/core-skills/bmad-customize/SKILL.md b/src/core-skills/bmad-customize/SKILL.md new file mode 100644 index 000000000..ada4bac99 --- /dev/null +++ b/src/core-skills/bmad-customize/SKILL.md @@ -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-.toml` / `.user.toml`) and per-skill **workflow** overrides (`bmad-.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 --key + ``` + + 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`. 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..37436b5b6 --- /dev/null +++ b/src/core-skills/bmad-customize/scripts/list_customizable_skills.py @@ -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:])) 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..ed2ffdbe4 --- /dev/null +++ b/src/core-skills/bmad-customize/scripts/tests/test_list_customizable_skills.py @@ -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() diff --git a/src/core-skills/module-help.csv b/src/core-skills/module-help.csv index efa081372..e0eb5e5e7 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 to author or update _bmad/custom TOML overrides for customizable agents and workflows.,,anytime,,,false,{project-root}/_bmad/custom,TOML override files