#!/usr/bin/env python3 """ Resolve customization for a BMad skill using a layered TOML merge. Reads from (lowest → highest priority): 1. {skill-root}/customize.toml (skill author defaults) 2. [skills.X] sections inside four customize layers (lowest→highest): a. {global-dir}/customize.toml (global team / machine) b. {global-dir}/customize.user.toml (global personal) c. {project-root}/_bmad/custom/customize.toml (project team, committed) d. {project-root}/_bmad/custom/customize.user.toml (project personal, gitignored) 3. {project-root}/_bmad/custom/{name}.toml (per-skill team override) 4. {project-root}/_bmad/custom/{name}.user.toml (per-skill personal override) config.toml is NOT consulted by this resolver — identity/agents live there, skill behavior overrides live in customize.toml. Clean split. There is no installer-tier customize.toml — the installer manages identity (config.toml), not skill behavior. customize.{,user.}toml is purely human or `bmad-customize`-skill-authored. Skill name is derived from the basename of the skill directory. If the skill lives under `{module}-skills/...`, a qualified name `{module}/{skill}` is also computed and used for pattern matching. Inside a customize layer, [skills.X] sections cascade by specificity (most specific wins within the layer): [skills."*"] # catchall [skills."bmad-*"] # bare-name glob [skills."bmm/*"] # module-scoped glob [skills.bmad-prd] # bare exact [skills."bmm/bmad-prd"] # qualified exact (most specific) Patterns are matched against both the bare skill name and (if available) the qualified `module/skill` name. Specificity scoring: exact > wildcard, longer-pattern > shorter, `*` is the lowest. {global-dir} = $BMAD_HOME if set, otherwise ~/.bmad (cross-platform). Outputs merged JSON to stdout. Errors go to stderr. Requires Python 3.11+ (uses stdlib `tomllib`). No `uv`, no `pip install`, no virtualenv — plain `python3` is sufficient. python3 resolve_customization.py --skill /abs/path/to/skill-dir python3 resolve_customization.py --skill ... --key agent python3 resolve_customization.py --skill ... --key agent.menu Merge rules (purely structural — no field-name special-casing): - Scalars (string, int, bool, float): override wins - Tables: deep merge (recursively apply these rules) - Arrays of tables where every item shares the *same* identifier field (every item has `code`, or every item has `id`): merge by that key (matching keys replace, new keys append) - All other arrays — including arrays where only some items have `code` or `id`, or where items mix the two keys: append (base items followed by override items) No removal mechanism — overrides cannot delete base items. To suppress a default, fork the skill or override the item by code with a no-op description/prompt. """ import argparse import fnmatch import json import os import re import sys from pathlib import Path try: import tomllib except ImportError: sys.stderr.write( "error: Python 3.11+ is required (stdlib `tomllib` not found).\n" "Install a newer Python or run the resolution manually per the\n" "fallback instructions in the skill's SKILL.md.\n" ) sys.exit(3) _MISSING = object() _KEYED_MERGE_FIELDS = ("code", "id") def find_project_root(start: Path): current = start.resolve() while True: if (current / "_bmad").exists() or (current / ".git").exists(): return current parent = current.parent if parent == current: return None current = parent def load_toml(file_path: Path, required: bool = False) -> dict: if not file_path.exists(): if required: sys.stderr.write(f"error: required customization file not found: {file_path}\n") sys.exit(1) return {} try: with file_path.open("rb") as f: parsed = tomllib.load(f) if not isinstance(parsed, dict): if required: sys.stderr.write(f"error: {file_path} did not parse to a table\n") sys.exit(1) return {} return parsed except tomllib.TOMLDecodeError as error: level = "error" if required else "warning" sys.stderr.write(f"{level}: failed to parse {file_path}: {error}\n") if required: sys.exit(1) return {} except OSError as error: level = "error" if required else "warning" sys.stderr.write(f"{level}: failed to read {file_path}: {error}\n") if required: sys.exit(1) return {} def _detect_keyed_merge_field(items): """Return 'code' or 'id' if every table item carries that *same* field. All items must share the same identifier (all `code`, or all `id`). Mixed arrays — where some items use `code` and others use `id` — return None and fall through to append semantics. This is intentional: mixing identifier keys within one array is a schema smell, and append-fallback is safer than guessing which key should merge. """ if not items or not all(isinstance(item, dict) for item in items): return None for candidate in _KEYED_MERGE_FIELDS: if all(item.get(candidate) is not None for item in items): return candidate return None def _merge_by_key(base, override, key_name): result = [] index_by_key = {} for item in base: if not isinstance(item, dict): continue if item.get(key_name) is not None: index_by_key[item[key_name]] = len(result) result.append(dict(item)) for item in override: if not isinstance(item, dict): result.append(item) continue key = item.get(key_name) if key is not None and key in index_by_key: result[index_by_key[key]] = dict(item) else: if key is not None: index_by_key[key] = len(result) result.append(dict(item)) return result def _merge_arrays(base, override): """Shape-aware array merge. Base + override combined tables may opt into keyed merge if every item has `code` or `id`. Otherwise: append.""" base_arr = base if isinstance(base, list) else [] override_arr = override if isinstance(override, list) else [] keyed_field = _detect_keyed_merge_field(base_arr + override_arr) if keyed_field: return _merge_by_key(base_arr, override_arr, keyed_field) return base_arr + override_arr def deep_merge(base, override): """Recursively merge override into base using structural rules. - Table + table: deep merge - Array + array: shape-aware (keyed merge if all items have code/id, else append) - Anything else: override wins """ if isinstance(base, dict) and isinstance(override, dict): result = dict(base) for key, over_val in override.items(): if key in result: result[key] = deep_merge(result[key], over_val) else: result[key] = over_val return result if isinstance(base, list) and isinstance(override, list): return _merge_arrays(base, override) return override _MODULE_SKILLS_RE = re.compile(r"^(?P[A-Za-z0-9_-]+)-skills$") def detect_skill_module(skill_dir: Path) -> str | None: """Walk up from the skill dir looking for an ancestor named `{module}-skills`. Returns the module slug (e.g. 'bmm', 'core') or None if the skill isn't inside a recognizable module tree (standalone skill, test fixture, etc.). """ for ancestor in skill_dir.parents: match = _MODULE_SKILLS_RE.match(ancestor.name) if match: return match.group("module") return None def resolve_global_dir() -> Path: """Locate the cross-platform global BMad config directory ($BMAD_HOME or ~/.bmad).""" override = os.environ.get("BMAD_HOME") if override: return Path(override).expanduser().resolve() return Path.home() / ".bmad" def collect_customize_layers(project_root: Path | None, global_dir: Path) -> list[Path]: """Return customize.toml file paths in lowest→highest priority order. Four layers (all optional): 1. {global-dir}/customize.toml — global team / machine 2. {global-dir}/customize.user.toml — global personal 3. _bmad/custom/customize.toml — project team, committed 4. _bmad/custom/customize.user.toml — project personal, gitignored Installer-managed _bmad/config*.toml is NOT scanned: customize is a separate concern from identity, and the installer does not author it. """ layers: list[Path] = [ global_dir / "customize.toml", global_dir / "customize.user.toml", ] if project_root is not None: custom_dir = project_root / "_bmad" / "custom" layers.extend([ custom_dir / "customize.toml", custom_dir / "customize.user.toml", ]) return layers def _pattern_specificity(pattern: str) -> tuple[int, int]: """Score a [skills.X] pattern for specificity ordering (ascending = less specific). Tier 2 (exact, no wildcards): most specific. Tie-break by pattern length. Tier 1 (wildcard, not pure '*'): mid. Longer patterns are more specific. Tier 0 (bare '*'): catchall. """ if pattern == "*": return (0, 0) if "*" in pattern or "?" in pattern or "[" in pattern: return (1, len(pattern)) return (2, len(pattern)) def extract_skill_overrides(layer_data: dict, qualified: str | None, bare: str) -> dict: """Build a single-skill override dict from a layer's [skills.X] table. Matches every pattern that fnmatches against the bare or qualified name, then deep-merges them in ascending specificity so the most specific wins. """ skills_table = layer_data.get("skills") if not isinstance(skills_table, dict): return {} matched: list[tuple[tuple[int, int], dict]] = [] for pattern, override in skills_table.items(): if not isinstance(override, dict): continue if fnmatch.fnmatchcase(bare, pattern) or ( qualified is not None and fnmatch.fnmatchcase(qualified, pattern) ): matched.append((_pattern_specificity(pattern), override)) matched.sort(key=lambda item: item[0]) result: dict = {} for _, override in matched: result = deep_merge(result, override) return result def extract_key(data, dotted_key: str): parts = dotted_key.split(".") current = data for part in parts: if isinstance(current, dict) and part in current: current = current[part] else: return _MISSING return current def write_json_stdout(output): """Write JSON as UTF-8 so Windows cp1252 stdout can carry emoji icons.""" reconfigure = getattr(sys.stdout, "reconfigure", None) if reconfigure is not None: reconfigure(encoding="utf-8") sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n") def main(): parser = argparse.ArgumentParser( description="Resolve customization for a BMad skill using a layered TOML merge with [skills.X] cascade.", add_help=True, ) parser.add_argument( "--skill", "-s", required=True, help="Absolute path to the skill directory (must contain customize.toml)", ) parser.add_argument( "--key", "-k", action="append", default=[], help="Dotted field path to resolve (repeatable). Omit for full dump.", ) args = parser.parse_args() skill_dir = Path(args.skill).resolve() skill_name = skill_dir.name module = detect_skill_module(skill_dir) qualified = f"{module}/{skill_name}" if module else None defaults_path = skill_dir / "customize.toml" defaults = load_toml(defaults_path, required=True) # Prefer the project that contains this skill. Only fall back to cwd if # the skill isn't inside a recognizable project tree (unusual but possible # for standalone skills invoked directly). Using cwd first is unsafe when # an ancestor of cwd happens to have a stray _bmad/ from another project. project_root = find_project_root(skill_dir) or find_project_root(Path.cwd()) global_dir = resolve_global_dir() merged = defaults # Walk the customize layers low→high, applying any [skills.X] sections # that match this skill. This is the cross-cutting override surface: # users can cascade values across all skills, all skills in a module, or # one specific skill without editing per-skill files. for layer_path in collect_customize_layers(project_root, global_dir): layer_data = load_toml(layer_path) skill_override = extract_skill_overrides(layer_data, qualified, skill_name) if skill_override: merged = deep_merge(merged, skill_override) # Per-skill override files (highest priority — most explicit). These keep # working for back-compat and remain the right tool when an override is # large or wholly skill-specific. if project_root: custom_dir = project_root / "_bmad" / "custom" merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.toml")) merged = deep_merge(merged, load_toml(custom_dir / f"{skill_name}.user.toml")) if args.key: output = {} for key in args.key: value = extract_key(merged, key) if value is not _MISSING: output[key] = value else: output = merged write_json_stdout(output) if __name__ == "__main__": main()