208 lines
7.1 KiB
Python
208 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""render.py — bmad-quick-dev template renderer.
|
|
|
|
Resolves compile-time {{.variable}} placeholders from BMad's central config,
|
|
bakes absolute paths for {project-root} into derived values, and writes
|
|
rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/.
|
|
|
|
Config sources, tried in order:
|
|
1. Central _bmad/config.toml + config.user.toml + custom/config.toml +
|
|
custom/config.user.toml (four-layer merge; post-#2285 installs).
|
|
Keys surface from [core] and [modules.bmm].
|
|
2. _bmad/bmm/config.yaml (flat-YAML fallback for pre-#2285 installs).
|
|
|
|
Runtime {variable} placeholders (single curly) pass through untouched for
|
|
the LLM to resolve during workflow execution.
|
|
|
|
Every invocation rebuilds from scratch — no hash, no cache.
|
|
Python 3 stdlib only. UTF-8 I/O.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
|
|
def find_project_root():
|
|
"""Walk up from cwd until a _bmad/ directory is found. On failure, print a
|
|
HALT instruction to stdout and exit non-zero."""
|
|
current = os.path.abspath(os.getcwd())
|
|
while True:
|
|
candidate = os.path.join(current, "_bmad")
|
|
if os.path.isdir(candidate):
|
|
return current
|
|
parent = os.path.dirname(current)
|
|
if parent == current:
|
|
print(
|
|
f"HALT and report to the user: no _bmad/ directory found walking up from {os.getcwd()}"
|
|
)
|
|
sys.exit(1)
|
|
current = parent
|
|
|
|
|
|
def _deep_merge(base, override):
|
|
"""Dict-aware deep merge. Lists and scalars: override wins (we don't need
|
|
the full keyed-merge semantics of resolve_config.py — quick-dev only reads
|
|
flat scalars out of [core] and [modules.bmm])."""
|
|
if isinstance(base, dict) and isinstance(override, dict):
|
|
result = dict(base)
|
|
for key, value in override.items():
|
|
result[key] = _deep_merge(result[key], value) if key in result else value
|
|
return result
|
|
return override
|
|
|
|
|
|
def load_central_config(root):
|
|
"""Four-layer merge of _bmad/config.toml and its peers. Returns the merged
|
|
dict, or None if the base _bmad/config.toml is absent (pre-#2285 install)
|
|
or if tomllib is unavailable."""
|
|
bmad_dir = os.path.join(root, "_bmad")
|
|
base = os.path.join(bmad_dir, "config.toml")
|
|
if not os.path.isfile(base):
|
|
return None
|
|
try:
|
|
import tomllib
|
|
except ImportError:
|
|
print(
|
|
"render.py: Python 3.11+ required for central TOML config; falling back",
|
|
file=sys.stderr,
|
|
)
|
|
return None
|
|
|
|
layers = [
|
|
base,
|
|
os.path.join(bmad_dir, "config.user.toml"),
|
|
os.path.join(bmad_dir, "custom", "config.toml"),
|
|
os.path.join(bmad_dir, "custom", "config.user.toml"),
|
|
]
|
|
merged = {}
|
|
for path in layers:
|
|
if not os.path.isfile(path):
|
|
continue
|
|
try:
|
|
with open(path, "rb") as fh:
|
|
data = tomllib.load(fh)
|
|
except (tomllib.TOMLDecodeError, OSError) as error:
|
|
print(f"render.py: skipping {path}: {error}", file=sys.stderr)
|
|
continue
|
|
if isinstance(data, dict):
|
|
merged = _deep_merge(merged, data)
|
|
return merged
|
|
|
|
|
|
def flatten_central_config(merged):
|
|
"""Lift scalar keys from [core] and [modules.bmm] into a single namespace.
|
|
Module keys take precedence on collision (installer strips core keys from
|
|
module buckets, so collisions shouldn't happen in practice)."""
|
|
flat = {}
|
|
for section in (merged.get("core"), merged.get("modules", {}).get("bmm")):
|
|
if not isinstance(section, dict):
|
|
continue
|
|
for key, value in section.items():
|
|
if isinstance(value, bool):
|
|
flat[key] = "true" if value else "false"
|
|
elif isinstance(value, (str, int, float)):
|
|
flat[key] = str(value)
|
|
return flat
|
|
|
|
|
|
def load_flat_yaml(path):
|
|
"""Parse a flat key: value YAML file. Quotes stripped; indented values ignored.
|
|
Returns {} if the file is missing (with a stderr warning)."""
|
|
result = {}
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as fh:
|
|
lines = fh.readlines()
|
|
except FileNotFoundError:
|
|
print(
|
|
f"render.py: config not found at {path}; using smart defaults",
|
|
file=sys.stderr,
|
|
)
|
|
return result
|
|
for line in lines:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#") or stripped.startswith("---"):
|
|
continue
|
|
if line.startswith(" ") or line.startswith("\t"):
|
|
continue
|
|
colon = stripped.find(":")
|
|
if colon < 0:
|
|
continue
|
|
key = stripped[:colon].strip()
|
|
value = stripped[colon + 1 :].strip().strip("'\"")
|
|
if not key or not value:
|
|
continue
|
|
# Skip YAML inline dict/list literals (balanced braces/brackets)
|
|
if (value.startswith("{") and value.endswith("}")) or (
|
|
value.startswith("[") and value.endswith("]")
|
|
):
|
|
continue
|
|
result[key] = value
|
|
return result
|
|
|
|
|
|
def render_template(content, vars_):
|
|
"""Resolve {{.var}} substitutions. Unresolved references emit an empty string
|
|
(Go's missingkey=zero semantics)."""
|
|
return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content)
|
|
|
|
|
|
def main():
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
skill_name = os.path.basename(script_dir)
|
|
root = find_project_root()
|
|
bmad_dir = os.path.join(root, "_bmad")
|
|
|
|
central = load_central_config(root)
|
|
if central is not None:
|
|
vars_ = flatten_central_config(central)
|
|
main_config_path = os.path.join(bmad_dir, "config.toml")
|
|
else:
|
|
legacy_path = os.path.join(bmad_dir, "bmm", "config.yaml")
|
|
vars_ = load_flat_yaml(legacy_path)
|
|
main_config_path = legacy_path
|
|
|
|
vars_.setdefault(
|
|
"planning_artifacts", "{project-root}/_bmad-output/planning-artifacts"
|
|
)
|
|
vars_.setdefault(
|
|
"implementation_artifacts",
|
|
"{project-root}/_bmad-output/implementation-artifacts",
|
|
)
|
|
vars_.setdefault("communication_language", "English")
|
|
|
|
for key in list(vars_.keys()):
|
|
vars_[key] = vars_[key].replace("{project-root}", root)
|
|
|
|
vars_["project_root"] = root
|
|
vars_["main_config"] = main_config_path
|
|
vars_["sprint_status"] = os.path.join(
|
|
vars_["implementation_artifacts"], "sprint-status.yaml"
|
|
)
|
|
vars_["deferred_work_file"] = os.path.join(
|
|
vars_["implementation_artifacts"], "deferred-work.md"
|
|
)
|
|
|
|
out_dir = os.path.join(root, "_bmad", "render", skill_name)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
count = 0
|
|
for fname in sorted(os.listdir(script_dir)):
|
|
if not fname.endswith(".md") or fname == "SKILL.md":
|
|
continue
|
|
src = os.path.join(script_dir, fname)
|
|
dst = os.path.join(out_dir, fname)
|
|
with open(src, "r", encoding="utf-8") as fh:
|
|
content = fh.read()
|
|
with open(dst, "w", encoding="utf-8") as fh:
|
|
fh.write(render_template(content, vars_))
|
|
count += 1
|
|
|
|
print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr)
|
|
workflow_md = os.path.join(out_dir, "workflow.md")
|
|
print(f"read and follow {workflow_md}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|