您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

resolve_customization.py 7.7KB

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