Skill Tracer
Skills › BMAD Method › bmad-toolsmith › process_template.py

process_template.py

123 lines · skills/bmad-toolsmith/scripts/process_template.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""process_template: fill a template's variables and conditional blocks.
6
7Template syntax:
8 {name} a variable; replaced when --var name=value is given, left alone otherwise
9 (unmatched tokens may be runtime tokens such as {project-root} or {agent.name})
10 {if-X} ... {/if-X} a conditional block; kept when X is in --true, removed whole otherwise.
11 Blocks nest; the innermost is resolved first.
13Conditionals are resolved before variables, so a variable inside a removed block is never
14substituted. A marker left after processing means a malformed or mismatched block that the
15emitted file would carry verbatim: the script exits 3 and names the markers.
17Usage:
18 process_template.py <template> [-o <out>] [--var key=value ...] [--true COND ...] [--json]
20Output: the processed text on stdout, or in the file named by -o. With -o, one JSON object
21describing the run is printed on stdout; with --json and no -o it goes to stderr:
22 {"output_file", "vars_substituted": [...], "conditions_true": [...], "conditions_false": [...],
23 "tokens_remaining": [...]}
24"""
26from __future__ import annotations
28import argparse
29import json
30import re
31import sys
32from pathlib import Path
34BLOCK_RE = re.compile(r"\{if-([a-zA-Z0-9_-]+)\}(.*?)\{/if-\1\}", re.DOTALL)
35MARKER_RE = re.compile(r"\{/?if-[a-zA-Z0-9_-]+\}")
36TOKEN_RE = re.compile(r"\{[a-zA-Z][a-zA-Z0-9_.-]*\}")
39def process_conditionals(text: str, true_conditions: set[str]) -> tuple[str, list[str], list[str]]:
40 """Resolve {if-X}...{/if-X} blocks innermost first; return (text, kept, removed)."""
41 kept: list[str] = []
42 removed: list[str] = []
43 while match := BLOCK_RE.search(text):
44 condition = match.group(1)
45 if condition in true_conditions:
46 replacement = match.group(2)
47 if condition not in kept:
48 kept.append(condition)
49 else:
50 replacement = ""
51 if condition not in removed:
52 removed.append(condition)
53 text = text[: match.start()] + replacement + text[match.end() :]
54 # Removed blocks leave runs of blank lines behind; keep at most one.
55 return re.sub(r"\n{3,}", "\n\n", text), kept, removed
58def process_variables(text: str, variables: dict[str, str]) -> tuple[str, list[str]]:
59 """Replace {name} for every name in variables; return (text, names substituted)."""
60 substituted: list[str] = []
61 for name, value in variables.items():
62 placeholder = "{" + name + "}"
63 if placeholder in text:
64 text = text.replace(placeholder, value)
65 substituted.append(name)
66 return text, substituted
69def parse_var(raw: str) -> tuple[str, str]:
70 key, sep, value = raw.partition("=")
71 if not sep or not key:
72 raise argparse.ArgumentTypeError(f"expected key=value, got {raw!r}")
73 return key, value
76def main(argv: list[str] | None = None) -> int:
77 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
78 p.add_argument("template", type=Path, help="template file to process")
79 p.add_argument("-o", "--output", type=Path, help="write the result here instead of stdout")
80 p.add_argument(
81 "--var", action="append", default=[], type=parse_var, metavar="key=value", help="variable (repeatable)"
82 )
83 p.add_argument(
84 "--true", action="append", default=[], dest="true_conditions", metavar="COND", help="condition to keep"
85 )
86 p.add_argument("--json", action="store_true", help="print run metadata on stderr when writing to stdout")
87 args = p.parse_args(argv)
89 try:
90 content = args.template.read_text(encoding="utf-8")
91 except OSError as err:
92 print(f"process_template: cannot read {args.template}: {err}", file=sys.stderr)
93 return 2
95 content, kept, removed = process_conditionals(content, set(args.true_conditions))
96 content, substituted = process_variables(content, dict(args.var))
98 leftover = sorted(set(MARKER_RE.findall(content)))
99 if leftover:
100 print(f"process_template: leftover conditional markers: {', '.join(leftover)}", file=sys.stderr)
101 return 3
103 metadata = {
104 "output_file": str(args.output) if args.output else "<stdout>",
105 "vars_substituted": substituted,
106 "conditions_true": kept,
107 "conditions_false": removed,
108 "tokens_remaining": sorted(set(TOKEN_RE.findall(content))),
109 }
111 if args.output:
112 args.output.parent.mkdir(parents=True, exist_ok=True)
113 args.output.write_text(content, encoding="utf-8")
114 print(json.dumps(metadata))
115 else:
116 sys.stdout.write(content)
117 if args.json:
118 print(json.dumps(metadata), file=sys.stderr)
119 return 0
122if __name__ == "__main__":
123 sys.exit(main())