Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions skills/bmad-agent-builder/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ name: bmad-agent-builder
description: Builds, edits or analyzes Agent Skills through conversational discovery. Use when the user requests to "Create an Agent", "Analyze an Agent" or "Edit an Agent".
---

## Available Scripts

- **`scripts/resolve-customization.py`** -- Resolves customization from three-layer TOML merge (user > team > defaults). Outputs JSON.

## Resolve Customization

Resolve `inject` and `additional_resources` from customization:
Run: `python3 scripts/resolve-customization.py bmad-agent-builder --key inject --key additional_resources`
Use the JSON output as resolved values.

1. **Inject before** -- If `inject.before` resolved to a non-empty value, prepend it to your active instructions and follow it.
2. **Available resources** -- Note the `additional_resources` list. Do not read these files now; they are available for the injected prompt or workflow steps to reference when needed.

# Agent Builder

## Overview
Expand Down Expand Up @@ -68,3 +81,10 @@ Load `./references/quality-analysis.md` to begin.
Analyze routes to `./references/quality-analysis.md`. Edit routes to `./references/edit-guidance.md`. Rebuild routes to `./references/build-process.md` with the chosen intent.

Regardless of path, respect headless mode if requested.

## Post-Workflow Customization

After the workflow completes, resolve `inject.after` from customization:
Run: `python3 scripts/resolve-customization.py bmad-agent-builder --key inject.after`

If resolved `inject.after` is not empty, append it to your active instructions and follow it.
27 changes: 27 additions & 0 deletions skills/bmad-agent-builder/customize.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-agent-builder
# This file defines all customizable fields for this skill.
# DO NOT EDIT THIS FILE -- it is overwritten on every update.
#
# HOW TO CUSTOMIZE:
# 1. Create an override file with only the fields you want to change:
# _bmad/customizations/bmad-agent-builder.toml (team/org, committed to git)
# _bmad/customizations/bmad-agent-builder.user.toml (personal, gitignored)
# 2. Copy just the fields you want to override into your file.
# Unmentioned fields inherit from this defaults file.
# 3. For array fields (like additional_resources), include the
# complete array you want -- arrays replace, not append.
# ──────────────────────────────────────────────────────────────────

# Additional resource files loaded into workflow context on activation.
# Paths are relative to {project-root}.
additional_resources = []

# ──────────────────────────────────────────────────────────────────
# Injected prompts - content woven into the workflow's context.
# 'before' loads before the workflow begins.
# 'after' loads after the workflow completes (pre-finalize).
# ──────────────────────────────────────────────────────────────────
[inject]
before = ""
after = ""
183 changes: 183 additions & 0 deletions skills/bmad-agent-builder/scripts/resolve-customization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Resolve customization for a BMad skill using three-layer TOML merge.

Reads customization from three layers (highest priority first):
1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored)
2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed)
3. ./customize.toml (skill defaults)

Outputs merged JSON to stdout. Errors go to stderr.

Usage:
python ./scripts/resolve-customization.py {skill-name}
python ./scripts/resolve-customization.py {skill-name} --key persona
python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject
Comment on lines +14 to +17
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use python3 in the usage examples.

The file header requires Python 3.11+, and skills/bmad-agent-builder/SKILL.md:8-18 already documents python3. Keeping python here invites copy/paste failures on hosts where python is not that interpreter.

📝 Proposed fix
-  python ./scripts/resolve-customization.py {skill-name}
-  python ./scripts/resolve-customization.py {skill-name} --key persona
-  python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject
+  python3 ./scripts/resolve-customization.py {skill-name}
+  python3 ./scripts/resolve-customization.py {skill-name} --key persona
+  python3 ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Usage:
python ./scripts/resolve-customization.py {skill-name}
python ./scripts/resolve-customization.py {skill-name} --key persona
python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject
Usage:
python3 ./scripts/resolve-customization.py {skill-name}
python3 ./scripts/resolve-customization.py {skill-name} --key persona
python3 ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/bmad-agent-builder/scripts/resolve-customization.py` around lines 14 -
17, Update the usage examples in the top-of-file help text in
resolve-customization.py to call python3 instead of python (i.e., change "python
./scripts/resolve-customization.py" to "python3
./scripts/resolve-customization.py" in all three example lines) so the
documented invocation matches the file's Python 3.11+ requirement and the
SKILL.md guidance.

"""

from __future__ import annotations

import argparse
import json
import sys
import tomllib
from pathlib import Path
from typing import Any


def find_project_root(start: Path) -> Path | None:
"""Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``."""
current = start.resolve()
while True:
if (current / "_bmad").is_dir() or (current / ".git").exists():
return current
parent = current.parent
if parent == current:
return None
current = parent
Comment on lines +30 to +39
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Prefer a higher _bmad/ over the first .git ancestor.

If the resolver is run inside a nested repo or submodule, Line 34 returns that inner .git directory and stops before checking higher ancestors. The real {project-root}/_bmad/customizations folder is then missed, so team/user overrides never load.

🔎 Proposed fix
 def find_project_root(start: Path) -> Path | None:
-    """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``."""
+    """Walk up from *start*, preferring ``_bmad/`` and falling back to the nearest ``.git``."""
     current = start.resolve()
+    git_fallback: Path | None = None
     while True:
-        if (current / "_bmad").is_dir() or (current / ".git").exists():
+        if (current / "_bmad").is_dir():
             return current
+        if git_fallback is None and (current / ".git").exists():
+            git_fallback = current
         parent = current.parent
         if parent == current:
-            return None
+            return git_fallback
         current = parent
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/bmad-agent-builder/scripts/resolve-customization.py` around lines 30 -
39, The current find_project_root returns the first .git ancestor which can be
an inner repo/submodule and prevents finding a higher _bmad directory; change
the search so we prefer any _bmad directory above the start and only fall back
to a .git if no _bmad exists above it. Update find_project_root to walk
ancestors, track the first .git encountered (e.g., first_git variable) but
continue walking to see if any (current / "_bmad").is_dir() exists higher; if
you find _bmad return it, otherwise return the recorded first_git if present,
else None.



def load_toml(path: Path) -> dict[str, Any]:
"""Return parsed TOML or empty dict if the file doesn't exist."""
if not path.is_file():
return {}
try:
with open(path, "rb") as f:
return tomllib.load(f)
except (tomllib.TOMLDecodeError, OSError) as exc:
print(f"warning: failed to parse {path}: {exc}", file=sys.stderr)
return {}


# ---------------------------------------------------------------------------
# Merge helpers
# ---------------------------------------------------------------------------

def _is_menu_array(value: Any) -> bool:
"""True when *value* is a non-empty list where ALL items are dicts with a ``code`` key."""
return (
isinstance(value, list)
and len(value) > 0
and all(isinstance(item, dict) and "code" in item for item in value)
)


def merge_menu(base: list[dict], override: list[dict]) -> list[dict]:
"""Merge-by-code: matching codes replace; new codes append."""
result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base if "code" in item}
for item in override:
if "code" not in item:
print(f"warning: menu item missing 'code' key, skipping: {item}", file=sys.stderr)
continue
result_by_code[item["code"]] = dict(item)
return list(result_by_code.values())
Copy link
Copy Markdown

@augmentcode augmentcode bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge_menu returns list(result_by_code.values()), so the resulting order depends on dict insertion behavior and may not preserve the original menu ordering (and won’t reflect intended reordering when an override replaces an existing code). If menu order is user-visible, this can produce surprising ordering changes after customization merges.

Severity: low

Other Locations
  • skills/bmad-bmb-setup/scripts/resolve-customization.py:74
  • skills/bmad-module-builder/scripts/resolve-customization.py:74
  • skills/bmad-workflow-builder/scripts/resolve-customization.py:74

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Comment thread
coderabbitai[bot] marked this conversation as resolved.


def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Recursively merge *override* into *base*.

Rules:
- Tables (dicts): sparse override -- recurse, unmentioned keys kept.
- ``[[menu]]`` arrays (items with ``code`` key): merge-by-code.
- All other arrays: atomic replace.
- Scalars: override wins.
"""
merged = dict(base)
for key, over_val in override.items():
base_val = merged.get(key)

if isinstance(over_val, dict) and isinstance(base_val, dict):
merged[key] = deep_merge(base_val, over_val)
elif _is_menu_array(over_val) and _is_menu_array(base_val):
merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type]
else:
merged[key] = over_val

return merged


# ---------------------------------------------------------------------------
# Key extraction
# ---------------------------------------------------------------------------

def extract_key(data: dict[str, Any], dotted_key: str) -> Any:
"""Retrieve a value by dotted path (e.g. ``persona.displayName``)."""
parts = dotted_key.split(".")
current: Any = data
for part in parts:
if isinstance(current, dict) and part in current:
current = current[part]
else:
return None
return current


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> None:
parser = argparse.ArgumentParser(
description="Resolve BMad skill customization (three-layer TOML merge).",
epilog=(
"Resolution priority: user.toml > team.toml > skill defaults.\n"
"Output is JSON. Use --key to request specific fields (JIT resolution)."
),
)
parser.add_argument(
"skill_name",
help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)",
)
parser.add_argument(
"--key",
action="append",
dest="keys",
metavar="FIELD",
help="Dotted field path to resolve (repeatable). Omit for full dump.",
)
args = parser.parse_args()

# Locate the skill's own customize.toml (one level up from scripts/)
script_dir = Path(__file__).resolve().parent
skill_dir = script_dir.parent
defaults_path = skill_dir / "customize.toml"
Comment on lines +130 to +145
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate skill_name before using it in override paths.

defaults_path always comes from this script's parent skill directory, but Lines 160-161 use the raw CLI argument to choose the team/user TOML files. A typo in SKILL.md or a name containing path separators can silently mix another skill's overrides—or escape _bmad/customizations entirely—with this skill's defaults.

🛡️ Proposed fix
     script_dir = Path(__file__).resolve().parent
     skill_dir = script_dir.parent
+    expected_skill_name = skill_dir.name
+    if args.skill_name != expected_skill_name:
+        parser.error(
+            f"this script resolves '{expected_skill_name}', not '{args.skill_name}'"
+        )
     defaults_path = skill_dir / "customize.toml"

Also applies to: 159-161

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@skills/bmad-agent-builder/scripts/resolve-customization.py` around lines 130
- 145, Validate and sanitize the CLI skill name (args.skill_name) before using
it to build override paths: ensure it contains only allowed characters (e.g.,
alphanumerics, hyphen, underscore, dot), disallow path separators or traversal
tokens like '/' or '..', and reject/exit on invalid values; then construct
team/user override paths by joining the sanitized name to a fixed base directory
(derived from script_dir/skill_dir or a constant like "_bmad/customizations")
rather than interpolating the raw input. Update the code around
parser.add_argument("skill_name") and where args.skill_name is used to select
TOML files so it validates the value and only uses the sanitized version to form
override file paths.


# Locate project root for override files
project_root = find_project_root(Path.cwd())
if project_root is None:
# Try from the skill directory as fallback
project_root = find_project_root(skill_dir)

# Load three layers (lowest priority first, then merge upward)
defaults = load_toml(defaults_path)

team: dict[str, Any] = {}
user: dict[str, Any] = {}
if project_root is not None:
customizations_dir = project_root / "_bmad" / "customizations"
team = load_toml(customizations_dir / f"{args.skill_name}.toml")
user = load_toml(customizations_dir / f"{args.skill_name}.user.toml")

# Merge: defaults <- team <- user
merged = deep_merge(defaults, team)
merged = deep_merge(merged, user)

# Output
if args.keys:
result = {}
for key in args.keys:
value = extract_key(merged, key)
if value is not None:
result[key] = value
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
else:
json.dump(merged, sys.stdout, indent=2, ensure_ascii=False)

# Ensure trailing newline for clean terminal output
print()


if __name__ == "__main__":
main()
20 changes: 20 additions & 0 deletions skills/bmad-bmb-setup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ name: bmad-bmb-setup
description: Sets up BMad Builder module in a project. Use when the user requests to 'install bmb module', 'configure BMad Builder', or 'setup BMad Builder'.
---

## Available Scripts

- **`scripts/resolve-customization.py`** -- Resolves customization from three-layer TOML merge (user > team > defaults). Outputs JSON.

## Resolve Customization

Resolve `inject` and `additional_resources` from customization:
Run: `python3 scripts/resolve-customization.py bmad-bmb-setup --key inject --key additional_resources`
Use the JSON output as resolved values.

1. **Inject before** -- If `inject.before` resolved to a non-empty value, prepend it to your active instructions and follow it.
2. **Available resources** -- Note the `additional_resources` list. Do not read these files now; they are available for the injected prompt or workflow steps to reference when needed.

# Module Setup

## Overview
Expand Down Expand Up @@ -74,3 +87,10 @@ Use the script JSON output to display what was written — config values set (wr
## Outcome

Once the user's `user_name` and `communication_language` are known (from collected input, arguments, or existing config), use them consistently for the remainder of the session: address the user by their configured name and communicate in their configured `communication_language`.

## Post-Workflow Customization

After the workflow completes, resolve `inject.after` from customization:
Run: `python3 scripts/resolve-customization.py bmad-bmb-setup --key inject.after`

If resolved `inject.after` is not empty, append it to your active instructions and follow it.
27 changes: 27 additions & 0 deletions skills/bmad-bmb-setup/customize.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-bmb-setup
# This file defines all customizable fields for this skill.
# DO NOT EDIT THIS FILE -- it is overwritten on every update.
#
# HOW TO CUSTOMIZE:
# 1. Create an override file with only the fields you want to change:
# _bmad/customizations/bmad-bmb-setup.toml (team/org, committed to git)
# _bmad/customizations/bmad-bmb-setup.user.toml (personal, gitignored)
# 2. Copy just the fields you want to override into your file.
# Unmentioned fields inherit from this defaults file.
# 3. For array fields (like additional_resources), include the
# complete array you want -- arrays replace, not append.
# ──────────────────────────────────────────────────────────────────

# Additional resource files loaded into workflow context on activation.
# Paths are relative to {project-root}.
additional_resources = []

# ──────────────────────────────────────────────────────────────────
# Injected prompts - content woven into the workflow's context.
# 'before' loads before the workflow begins.
# 'after' loads after the workflow completes (pre-finalize).
# ──────────────────────────────────────────────────────────────────
[inject]
before = ""
after = ""
Loading
Loading