resolve_customization.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. #!/usr/bin/env python3
  2. """
  3. Resolve customization for a BMad skill using three-layer TOML merge.
  4. Reads customization from three layers (highest priority first):
  5. 1. {project-root}/_bmad/custom/{name}.user.toml (personal, gitignored)
  6. 2. {project-root}/_bmad/custom/{name}.toml (team/org, committed)
  7. 3. {skill-root}/customize.toml (skill defaults)
  8. Skill name is derived from the basename of the skill directory.
  9. Outputs merged JSON to stdout. Errors go to stderr.
  10. Uses only the Python stdlib (`tomllib`) — no third-party dependencies.
  11. BMad is standardizing on `uv run` to invoke scripts (uv provisions a suitable
  12. interpreter for you); a plain `python3` on PATH still works during the
  13. transition. Either runner needs Python 3.11+ for `tomllib`.
  14. uv run resolve_customization.py --skill /abs/path/to/skill-dir
  15. uv run resolve_customization.py --skill ... --key agent
  16. uv run resolve_customization.py --skill ... --key agent.menu
  17. Merge rules (purely structural — no field-name special-casing):
  18. - Scalars (string, int, bool, float): override wins
  19. - Tables: deep merge (recursively apply these rules)
  20. - Arrays of tables where every item shares the *same* identifier
  21. field (every item has `code`, or every item has `id`):
  22. merge by that key (matching keys replace, new keys append)
  23. - All other arrays — including arrays where only some items have
  24. `code` or `id`, or where items mix the two keys:
  25. append (base items followed by override items)
  26. No removal mechanism — overrides cannot delete base items. To suppress
  27. a default, fork the skill or override the item by code with a no-op
  28. description/prompt.
  29. """
  30. import argparse
  31. import json
  32. import sys
  33. from pathlib import Path
  34. try:
  35. import tomllib
  36. except ImportError:
  37. sys.stderr.write(
  38. "error: Python 3.11+ is required (stdlib `tomllib` not found).\n"
  39. "Install a newer Python or run the resolution manually per the\n"
  40. "fallback instructions in the skill's SKILL.md.\n"
  41. )
  42. sys.exit(3)
  43. _MISSING = object()
  44. _KEYED_MERGE_FIELDS = ("code", "id")
  45. def find_project_root(start: Path):
  46. current = start.resolve()
  47. while True:
  48. if (current / "_bmad").exists() or (current / ".git").exists():
  49. return current
  50. parent = current.parent
  51. if parent == current:
  52. return None
  53. current = parent
  54. def load_toml(file_path: Path, required: bool = False) -> dict:
  55. if not file_path.exists():
  56. if required:
  57. sys.stderr.write(f"error: required customization file not found: {file_path}\n")
  58. sys.exit(1)
  59. return {}
  60. try:
  61. with file_path.open("rb") as f:
  62. parsed = tomllib.load(f)
  63. if not isinstance(parsed, dict):
  64. if required:
  65. sys.stderr.write(f"error: {file_path} did not parse to a table\n")
  66. sys.exit(1)
  67. return {}
  68. return parsed
  69. except tomllib.TOMLDecodeError as error:
  70. level = "error" if required else "warning"
  71. sys.stderr.write(f"{level}: failed to parse {file_path}: {error}\n")
  72. if required:
  73. sys.exit(1)
  74. return {}
  75. except OSError as error:
  76. level = "error" if required else "warning"
  77. sys.stderr.write(f"{level}: failed to read {file_path}: {error}\n")
  78. if required:
  79. sys.exit(1)
  80. return {}
  81. def _detect_keyed_merge_field(items):
  82. """Return 'code' or 'id' if every table item carries that *same* field.
  83. All items must share the same identifier (all `code`, or all `id`).
  84. Mixed arrays — where some items use `code` and others use `id` —
  85. return None and fall through to append semantics. This is intentional:
  86. mixing identifier keys within one array is a schema smell, and
  87. append-fallback is safer than guessing which key should merge.
  88. """
  89. if not items or not all(isinstance(item, dict) for item in items):
  90. return None
  91. for candidate in _KEYED_MERGE_FIELDS:
  92. if all(item.get(candidate) is not None for item in items):
  93. return candidate
  94. return None
  95. def _merge_by_key(base, override, key_name):
  96. result = []
  97. index_by_key = {}
  98. for item in base:
  99. if not isinstance(item, dict):
  100. continue
  101. if item.get(key_name) is not None:
  102. index_by_key[item[key_name]] = len(result)
  103. result.append(dict(item))
  104. for item in override:
  105. if not isinstance(item, dict):
  106. result.append(item)
  107. continue
  108. key = item.get(key_name)
  109. if key is not None and key in index_by_key:
  110. result[index_by_key[key]] = dict(item)
  111. else:
  112. if key is not None:
  113. index_by_key[key] = len(result)
  114. result.append(dict(item))
  115. return result
  116. def _merge_arrays(base, override):
  117. """Shape-aware array merge. Base + override combined tables may opt into
  118. keyed merge if every item has `code` or `id`. Otherwise: append."""
  119. base_arr = base if isinstance(base, list) else []
  120. override_arr = override if isinstance(override, list) else []
  121. keyed_field = _detect_keyed_merge_field(base_arr + override_arr)
  122. if keyed_field:
  123. return _merge_by_key(base_arr, override_arr, keyed_field)
  124. return base_arr + override_arr
  125. def deep_merge(base, override):
  126. """Recursively merge override into base using structural rules.
  127. - Table + table: deep merge
  128. - Array + array: shape-aware (keyed merge if all items have code/id, else append)
  129. - Anything else: override wins
  130. """
  131. if isinstance(base, dict) and isinstance(override, dict):
  132. result = dict(base)
  133. for key, over_val in override.items():
  134. if key in result:
  135. result[key] = deep_merge(result[key], over_val)
  136. else:
  137. result[key] = over_val
  138. return result
  139. if isinstance(base, list) and isinstance(override, list):
  140. return _merge_arrays(base, override)
  141. return override
  142. def extract_key(data, dotted_key: str):
  143. parts = dotted_key.split(".")
  144. current = data
  145. for part in parts:
  146. if isinstance(current, dict) and part in current:
  147. current = current[part]
  148. else:
  149. return _MISSING
  150. return current
  151. def write_json_stdout(output):
  152. """Write JSON as UTF-8 so Windows cp1252 stdout can carry emoji icons."""
  153. reconfigure = getattr(sys.stdout, "reconfigure", None)
  154. if reconfigure is not None:
  155. reconfigure(encoding="utf-8")
  156. sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
  157. def main():
  158. parser = argparse.ArgumentParser(
  159. description="Resolve customization for a BMad skill using three-layer TOML merge.",
  160. add_help=True,
  161. )
  162. parser.add_argument(
  163. "--skill", "-s", required=True,
  164. help="Absolute path to the skill directory (must contain customize.toml)",
  165. )
  166. parser.add_argument(
  167. "--key", "-k", action="append", default=[],
  168. help="Dotted field path to resolve (repeatable). Omit for full dump.",
  169. )
  170. args = parser.parse_args()
  171. skill_dir = Path(args.skill).resolve()
  172. skill_name = skill_dir.name
  173. defaults_path = skill_dir / "customize.toml"
  174. defaults = load_toml(defaults_path, required=True)
  175. # Prefer the project that contains this skill. Only fall back to cwd if
  176. # the skill isn't inside a recognizable project tree (unusual but possible
  177. # for standalone skills invoked directly). Using cwd first is unsafe when
  178. # an ancestor of cwd happens to have a stray _bmad/ from another project.
  179. project_root = find_project_root(skill_dir) or find_project_root(Path.cwd())
  180. team = {}
  181. user = {}
  182. if project_root:
  183. custom_dir = project_root / "_bmad" / "custom"
  184. team = load_toml(custom_dir / f"{skill_name}.toml")
  185. user = load_toml(custom_dir / f"{skill_name}.user.toml")
  186. merged = deep_merge(defaults, team)
  187. merged = deep_merge(merged, user)
  188. if args.key:
  189. output = {}
  190. for key in args.key:
  191. value = extract_key(merged, key)
  192. if value is not _MISSING:
  193. output[key] = value
  194. else:
  195. output = merged
  196. write_json_stdout(output)
  197. if __name__ == "__main__":
  198. main()