-
-
Notifications
You must be signed in to change notification settings - Fork 29
feat(skills): add TOML-based skill customization system #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b4a4894
956fd64
5f7faba
b0d43a7
8bc374d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 = "" |
| 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 | ||
| """ | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Prefer a higher If the resolver is run inside a nested repo or submodule, Line 34 returns that inner 🔎 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 |
||
|
|
||
|
|
||
| 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()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Severity: low Other Locations
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate
🛡️ 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 |
||
|
|
||
| # 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() | ||
| 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 = "" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
python3in the usage examples.The file header requires Python 3.11+, and
skills/bmad-agent-builder/SKILL.md:8-18already documentspython3. Keepingpythonhere invites copy/paste failures on hosts wherepythonis not that interpreter.📝 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents