183 lines
5.9 KiB
Python
Executable File
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 Exception 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()
|