process-template.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. #!/usr/bin/env python3
  2. """Process BMad agent template files.
  3. Performs deterministic variable substitution and conditional block processing
  4. on template files from assets/. Replaces {varName} placeholders with provided
  5. values and evaluates {if-X}...{/if-X} conditional blocks, keeping content
  6. when the condition is in the --true list and removing the entire block otherwise.
  7. Any {if-X} or {/if-X} marker still present after processing is a defect (a
  8. malformed or mismatched block the emitted agent would ship verbatim): the
  9. script exits 3 and names the markers. Remaining {token} placeholders are
  10. reported in the --json metadata as tokens_remaining, not failed, because they
  11. may be runtime-resolution tokens such as {project-root} or {agent.<name>} —
  12. the builder judges that list against the build-time token set.
  13. """
  14. # /// script
  15. # requires-python = ">=3.9"
  16. # ///
  17. from __future__ import annotations
  18. import argparse
  19. import json
  20. import re
  21. import sys
  22. def process_conditionals(text: str, true_conditions: set[str]) -> tuple[str, list[str], list[str]]:
  23. """Process {if-X}...{/if-X} conditional blocks, innermost first.
  24. Returns (processed_text, conditions_true, conditions_false).
  25. """
  26. conditions_true: list[str] = []
  27. conditions_false: list[str] = []
  28. # Process innermost blocks first to handle nesting
  29. pattern = re.compile(
  30. r'\{if-([a-zA-Z0-9_-]+)\}(.*?)\{/if-\1\}',
  31. re.DOTALL,
  32. )
  33. changed = True
  34. while changed:
  35. changed = False
  36. match = pattern.search(text)
  37. if match:
  38. changed = True
  39. condition = match.group(1)
  40. inner = match.group(2)
  41. if condition in true_conditions:
  42. # Keep the inner content, strip the markers
  43. # Remove a leading newline if the opening tag was on its own line
  44. replacement = inner
  45. if condition not in conditions_true:
  46. conditions_true.append(condition)
  47. else:
  48. # Remove the entire block
  49. replacement = ''
  50. if condition not in conditions_false:
  51. conditions_false.append(condition)
  52. text = text[:match.start()] + replacement + text[match.end():]
  53. # Clean up blank lines left by removed blocks: collapse 3+ consecutive
  54. # newlines down to 2 (one blank line)
  55. text = re.sub(r'\n{3,}', '\n\n', text)
  56. return text, conditions_true, conditions_false
  57. def process_variables(text: str, variables: dict[str, str]) -> tuple[str, list[str]]:
  58. """Replace {varName} placeholders with provided values.
  59. Only replaces variables that are in the provided mapping.
  60. Leaves unmatched {variables} untouched (they may be runtime config).
  61. Returns (processed_text, list_of_substituted_var_names).
  62. """
  63. substituted: list[str] = []
  64. for name, value in variables.items():
  65. placeholder = '{' + name + '}'
  66. if placeholder in text:
  67. text = text.replace(placeholder, value)
  68. if name not in substituted:
  69. substituted.append(name)
  70. return text, substituted
  71. def parse_var(s: str) -> tuple[str, str]:
  72. """Parse a key=value string. Raises argparse error on bad format."""
  73. if '=' not in s:
  74. raise argparse.ArgumentTypeError(
  75. f"Invalid variable format: '{s}' (expected key=value)"
  76. )
  77. key, _, value = s.partition('=')
  78. if not key:
  79. raise argparse.ArgumentTypeError(
  80. f"Invalid variable format: '{s}' (empty key)"
  81. )
  82. return key, value
  83. def main() -> int:
  84. parser = argparse.ArgumentParser(
  85. description='Process BMad agent template files with variable substitution and conditional blocks.',
  86. )
  87. parser.add_argument(
  88. 'template',
  89. help='Path to the template file to process',
  90. )
  91. parser.add_argument(
  92. '-o', '--output',
  93. help='Write processed output to file (default: stdout)',
  94. )
  95. parser.add_argument(
  96. '--var',
  97. action='append',
  98. default=[],
  99. metavar='key=value',
  100. help='Variable substitution (repeatable). Example: --var skillName=my-agent',
  101. )
  102. parser.add_argument(
  103. '--true',
  104. action='append',
  105. default=[],
  106. dest='true_conditions',
  107. metavar='CONDITION',
  108. help='Condition name to treat as true (repeatable). Example: --true pulse --true evolvable',
  109. )
  110. parser.add_argument(
  111. '--json',
  112. action='store_true',
  113. dest='json_output',
  114. help='Output processing metadata as JSON to stderr',
  115. )
  116. args = parser.parse_args()
  117. # Parse variables
  118. variables: dict[str, str] = {}
  119. for v in args.var:
  120. try:
  121. key, value = parse_var(v)
  122. except argparse.ArgumentTypeError as e:
  123. print(f"Error: {e}", file=sys.stderr)
  124. return 2
  125. variables[key] = value
  126. true_conditions = set(args.true_conditions)
  127. # Read template
  128. try:
  129. with open(args.template, encoding='utf-8') as f:
  130. content = f.read()
  131. except FileNotFoundError:
  132. print(f"Error: Template file not found: {args.template}", file=sys.stderr)
  133. return 2
  134. except OSError as e:
  135. print(f"Error reading template: {e}", file=sys.stderr)
  136. return 1
  137. # Process: conditionals first, then variables
  138. content, conds_true, conds_false = process_conditionals(content, true_conditions)
  139. content, vars_substituted = process_variables(content, variables)
  140. # Leftover conditional markers mean a malformed/mismatched block that
  141. # would ship verbatim in the emitted agent.
  142. leftover_markers = sorted(set(re.findall(r'\{/?if-[a-zA-Z0-9_-]+\}', content)))
  143. if leftover_markers:
  144. print(
  145. f"Error: leftover conditional markers after processing: {', '.join(leftover_markers)}",
  146. file=sys.stderr,
  147. )
  148. return 3
  149. tokens_remaining = sorted(set(re.findall(r'\{[a-zA-Z][a-zA-Z0-9_.-]*\}', content)))
  150. # Write output
  151. output_file = args.output
  152. try:
  153. if output_file:
  154. with open(output_file, 'w', encoding='utf-8') as f:
  155. f.write(content)
  156. else:
  157. sys.stdout.write(content)
  158. except OSError as e:
  159. print(f"Error writing output: {e}", file=sys.stderr)
  160. return 1
  161. # JSON metadata to stderr
  162. if args.json_output:
  163. metadata = {
  164. 'processed': True,
  165. 'output_file': output_file or '<stdout>',
  166. 'vars_substituted': vars_substituted,
  167. 'conditions_true': conds_true,
  168. 'conditions_false': conds_false,
  169. 'tokens_remaining': tokens_remaining,
  170. }
  171. print(json.dumps(metadata, indent=2), file=sys.stderr)
  172. return 0
  173. if __name__ == '__main__':
  174. sys.exit(main())