BMAD-METHOD/src/bmm-skills/3-solutioning/bmad-create-architecture/scripts/resolve-customization.py

183 lines
5.9 KiB
Python
Executable File

#!/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 os
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
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* looks like a ``[[menu]]`` array of tables with ``code`` keys."""
return (
isinstance(value, list)
and len(value) > 0
and isinstance(value[0], dict)
and "code" in value[0]
)
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}
for item in override:
result_by_code[item["code"]] = dict(item)
return list(result_by_code.values())
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)
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"
# 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()