methodology-framework 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ """methodology-framework — portable process tooling for agent-driven delivery."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from methodology_framework import (
6
+ bootstrap_jira,
7
+ build_playbook,
8
+ register_playbook_with_devin,
9
+ sync_stories_to_jira,
10
+ )
11
+
12
+ __all__ = [
13
+ "bootstrap_jira",
14
+ "build_playbook",
15
+ "register_playbook_with_devin",
16
+ "sync_stories_to_jira",
17
+ ]
@@ -0,0 +1,28 @@
1
+ """Entry point for ``python -m methodology_framework <subcommand>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main() -> None:
9
+ """Route subcommands to their implementations."""
10
+ if len(sys.argv) < 2:
11
+ print("Usage: python -m methodology_framework <subcommand>", file=sys.stderr)
12
+ print("Available subcommands: bootstrap-jira", file=sys.stderr)
13
+ sys.exit(1)
14
+
15
+ subcommand = sys.argv[1]
16
+
17
+ if subcommand == "bootstrap-jira":
18
+ from methodology_framework.bootstrap_jira import main as bootstrap_main
19
+
20
+ sys.exit(bootstrap_main(sys.argv[2:]))
21
+ else:
22
+ print(f"Unknown subcommand: {subcommand}", file=sys.stderr)
23
+ print("Available subcommands: bootstrap-jira", file=sys.stderr)
24
+ sys.exit(1)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,427 @@
1
+ """Bootstrap a Jira project with the methodology-framework's canonical shape.
2
+
3
+ CLI entry point: ``python -m methodology_framework bootstrap-jira``
4
+
5
+ Three operating modes:
6
+ --dry-run (default) Print what would be applied; no side-effects.
7
+ --apply POST/PUT to Jira admin API (needs JIRA_ADMIN_TOKEN).
8
+ --export <path> Emit an importable config bundle at <path>; no API calls.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import importlib.resources
15
+ import logging
16
+ import os
17
+ import re
18
+ import sys
19
+ from typing import Any
20
+
21
+ import requests
22
+ import yaml
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Shape-def loading
28
+ # ---------------------------------------------------------------------------
29
+
30
+ _SHAPE_FILES = ("workflow.yaml", "custom_fields.yaml", "automation_rules.yaml")
31
+
32
+
33
+ def _load_shape_file(name: str) -> dict[str, Any]:
34
+ """Load a YAML shape-def from package data and return as a dict."""
35
+ ref = importlib.resources.files("methodology_framework") / "jira_shapes" / name
36
+ text = ref.read_text(encoding="utf-8")
37
+ data: dict[str, Any] = yaml.safe_load(text)
38
+ return data
39
+
40
+
41
+ def load_all_shapes() -> dict[str, dict[str, Any]]:
42
+ """Load all three shape-def files and return keyed by stem name."""
43
+ result: dict[str, dict[str, Any]] = {}
44
+ for name in _SHAPE_FILES:
45
+ stem = name.removesuffix(".yaml")
46
+ result[stem] = _load_shape_file(name)
47
+ return result
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # {{PROJECT_KEY}} substitution
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _PLACEHOLDER_RE = re.compile(r"\{\{PROJECT_KEY\}\}")
55
+
56
+
57
+ def substitute_project_key(
58
+ shapes: dict[str, dict[str, Any]], project_key: str
59
+ ) -> dict[str, dict[str, Any]]:
60
+ """Deep-substitute ``{{PROJECT_KEY}}`` with *project_key* in all shapes."""
61
+
62
+ def _walk(obj: Any) -> Any:
63
+ if isinstance(obj, str):
64
+ return _PLACEHOLDER_RE.sub(project_key, obj)
65
+ if isinstance(obj, dict):
66
+ return {k: _walk(v) for k, v in obj.items()}
67
+ if isinstance(obj, list):
68
+ return [_walk(item) for item in obj]
69
+ return obj
70
+
71
+ substituted: dict[str, dict[str, Any]] = {}
72
+ for key, val in shapes.items():
73
+ walked = _walk(val)
74
+ assert isinstance(walked, dict)
75
+ substituted[key] = walked
76
+ return substituted
77
+
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # JiraAdminClient — thin wrapper for Jira Cloud REST API v3
81
+ # ---------------------------------------------------------------------------
82
+
83
+
84
+ class JiraAdminClient:
85
+ """Minimal Jira Cloud admin client for bootstrap operations."""
86
+
87
+ def __init__(self, jira_host: str, token: str) -> None:
88
+ self._base = f"https://{jira_host}/rest/api/3"
89
+ self._session = requests.Session()
90
+ self._session.headers.update(
91
+ {
92
+ "Authorization": f"Bearer {token}",
93
+ "Content-Type": "application/json",
94
+ "Accept": "application/json",
95
+ }
96
+ )
97
+
98
+ # -- connectivity check --------------------------------------------------
99
+
100
+ def check_connectivity(self) -> None:
101
+ """Validate reachability via GET /rest/api/3/myself."""
102
+ resp = self._session.get(f"{self._base}/myself")
103
+ resp.raise_for_status()
104
+ logger.info("Connected as %s", resp.json().get("displayName", "unknown"))
105
+
106
+ # -- workflow operations -------------------------------------------------
107
+
108
+ def get_statuses(self, project_key: str) -> list[dict[str, Any]]:
109
+ """Return the current statuses for *project_key*."""
110
+ resp = self._session.get(f"{self._base}/project/{project_key}/statuses")
111
+ resp.raise_for_status()
112
+ raw: list[dict[str, Any]] = resp.json()
113
+ statuses: list[dict[str, Any]] = []
114
+ for issue_type in raw:
115
+ for s in issue_type.get("statuses", []):
116
+ if not any(existing["id"] == s["id"] for existing in statuses):
117
+ statuses.append(s)
118
+ return statuses
119
+
120
+ def create_status(self, spec: dict[str, Any]) -> None:
121
+ """Create a workflow status from *spec*."""
122
+ resp = self._session.post(f"{self._base}/statuses", json=spec)
123
+ resp.raise_for_status()
124
+
125
+ # -- custom field operations ---------------------------------------------
126
+
127
+ def get_fields(self) -> list[dict[str, Any]]:
128
+ """Return all fields (system + custom)."""
129
+ resp = self._session.get(f"{self._base}/field")
130
+ resp.raise_for_status()
131
+ result: list[dict[str, Any]] = resp.json()
132
+ return result
133
+
134
+ def create_custom_field(self, spec: dict[str, Any]) -> dict[str, Any]:
135
+ """Create a custom field from *spec*."""
136
+ resp = self._session.post(f"{self._base}/field", json=spec)
137
+ resp.raise_for_status()
138
+ result: dict[str, Any] = resp.json()
139
+ return result
140
+
141
+ # -- automation rule operations ------------------------------------------
142
+
143
+ def get_automation_rules(self, project_key: str) -> list[dict[str, Any]]:
144
+ """Return automation rules for *project_key*."""
145
+ resp = self._session.get(f"{self._base}/project/{project_key}/automation/rule")
146
+ if resp.status_code == 404:
147
+ logger.warning(
148
+ "Automation rules API not available (404). "
149
+ "Use --export mode and import rules via the Jira admin UI."
150
+ )
151
+ return []
152
+ resp.raise_for_status()
153
+ result: list[dict[str, Any]] = resp.json()
154
+ return result
155
+
156
+ def create_automation_rule(self, project_key: str, spec: dict[str, Any]) -> dict[str, Any]:
157
+ """Create an automation rule from *spec*."""
158
+ resp = self._session.post(
159
+ f"{self._base}/project/{project_key}/automation/rule",
160
+ json=spec,
161
+ )
162
+ resp.raise_for_status()
163
+ result: dict[str, Any] = resp.json()
164
+ return result
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Diff / display helpers
169
+ # ---------------------------------------------------------------------------
170
+
171
+
172
+ def _format_diff(shapes: dict[str, dict[str, Any]]) -> str:
173
+ """Return a human-readable summary of the shape definitions."""
174
+ lines: list[str] = []
175
+ workflow = shapes.get("workflow", {})
176
+ for status in workflow.get("statuses", []):
177
+ name = status.get("name", "?")
178
+ desc = status.get("description", "").strip().split("\n")[0]
179
+ transitions = status.get("transitions", [])
180
+ lines.append(f" + Status: {name}")
181
+ lines.append(f" {desc}")
182
+ for t in transitions:
183
+ actors = ", ".join(t.get("allowed_actors", []))
184
+ lines.append(f" -> {t['target']} (actors: {actors})")
185
+ lines.append("")
186
+
187
+ custom_fields = shapes.get("custom_fields", {})
188
+ for field in custom_fields.get("custom_fields", []):
189
+ name = field.get("name", "?")
190
+ ftype = field.get("type", "?")
191
+ lines.append(f" + Custom field: {name} (type: {ftype})")
192
+ lines.append("")
193
+
194
+ automation = shapes.get("automation_rules", {})
195
+ for rule in automation.get("automation_rules", []):
196
+ name = rule.get("name", "?")
197
+ lines.append(f" + Automation rule: {name}")
198
+ return "\n".join(lines)
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ # Mode handlers
203
+ # ---------------------------------------------------------------------------
204
+
205
+
206
+ def _handle_dry_run(shapes: dict[str, dict[str, Any]]) -> int:
207
+ """Print what would be applied; no side-effects."""
208
+ print("=== Methodology Framework — Jira Bootstrap (dry-run) ===\n")
209
+ print("The following Jira shape would be applied:\n")
210
+ print("[Workflow statuses + transitions]")
211
+ print(_format_diff(shapes))
212
+ print("[Custom fields]")
213
+ for field in shapes.get("custom_fields", {}).get("custom_fields", []):
214
+ print(f" {field['name']} ({field['type']}): {field.get('description', '')[:80]}")
215
+ print()
216
+ print("[Automation rules]")
217
+ for rule in shapes.get("automation_rules", {}).get("automation_rules", []):
218
+ print(f" {rule['name']}: {rule.get('description', '')[:80]}")
219
+ print("\nNo changes applied (dry-run mode).")
220
+ return 0
221
+
222
+
223
+ def _handle_export(shapes: dict[str, dict[str, Any]], export_path: str) -> int:
224
+ """Emit an importable config bundle at *export_path*."""
225
+ bundle = {
226
+ "schema_version": "1.0",
227
+ "description": "Jira project bootstrap bundle generated by methodology-framework.",
228
+ "workflow": shapes.get("workflow", {}),
229
+ "custom_fields": shapes.get("custom_fields", {}),
230
+ "automation_rules": shapes.get("automation_rules", {}),
231
+ }
232
+ with open(export_path, "w", encoding="utf-8") as fh:
233
+ yaml.dump(bundle, fh, default_flow_style=False, sort_keys=False, allow_unicode=True)
234
+ print(export_path)
235
+ return 0
236
+
237
+
238
+ def _handle_apply(
239
+ shapes: dict[str, dict[str, Any]],
240
+ project_key: str,
241
+ jira_host: str,
242
+ token: str,
243
+ force: bool = False,
244
+ ) -> int:
245
+ """Apply the shape definitions to a Jira project via the admin API."""
246
+ client = JiraAdminClient(jira_host, token)
247
+
248
+ logger.info("Validating connectivity to %s ...", jira_host)
249
+ try:
250
+ client.check_connectivity()
251
+ except requests.HTTPError as exc:
252
+ logger.error("Connectivity check failed: %s", exc)
253
+ return 1
254
+
255
+ # -- statuses -----------------------------------------------------------
256
+ logger.info("Checking workflow statuses ...")
257
+ existing_statuses = client.get_statuses(project_key)
258
+ existing_names = {s.get("name", "").lower() for s in existing_statuses}
259
+ desired = shapes.get("workflow", {}).get("statuses", [])
260
+ statuses_created = 0
261
+ for status_spec in desired:
262
+ if status_spec["name"].lower() in existing_names:
263
+ logger.info(" Status '%s' already exists — skipping.", status_spec["name"])
264
+ else:
265
+ if not force:
266
+ answer = input(f" Create status '{status_spec['name']}'? [y/N] ").strip().lower()
267
+ if answer != "y":
268
+ logger.info(" Skipped '%s'.", status_spec["name"])
269
+ continue
270
+ logger.info(" Creating status '%s' ...", status_spec["name"])
271
+ client.create_status(
272
+ {"name": status_spec["name"], "description": status_spec.get("description", "")}
273
+ )
274
+ statuses_created += 1
275
+
276
+ # -- custom fields ------------------------------------------------------
277
+ logger.info("Checking custom fields ...")
278
+ existing_fields = client.get_fields()
279
+ existing_field_names = {f.get("name", "").lower() for f in existing_fields}
280
+ desired_fields = shapes.get("custom_fields", {}).get("custom_fields", [])
281
+ fields_created = 0
282
+ for field_spec in desired_fields:
283
+ if field_spec["name"].lower() in existing_field_names:
284
+ logger.info(" Field '%s' already exists — skipping.", field_spec["name"])
285
+ else:
286
+ if not force:
287
+ answer = (
288
+ input(f" Create custom field '{field_spec['name']}'? [y/N] ").strip().lower()
289
+ )
290
+ if answer != "y":
291
+ logger.info(" Skipped '%s'.", field_spec["name"])
292
+ continue
293
+ logger.info(" Creating field '%s' ...", field_spec["name"])
294
+ client.create_custom_field(
295
+ {
296
+ "name": field_spec["name"],
297
+ "type": field_spec.get("type", "string"),
298
+ "description": field_spec.get("description", ""),
299
+ }
300
+ )
301
+ fields_created += 1
302
+
303
+ # -- automation rules ---------------------------------------------------
304
+ logger.info("Checking automation rules ...")
305
+ existing_rules = client.get_automation_rules(project_key)
306
+ existing_rule_names = {r.get("name", "").lower() for r in existing_rules}
307
+ desired_rules = shapes.get("automation_rules", {}).get("automation_rules", [])
308
+ rules_created = 0
309
+ for rule_spec in desired_rules:
310
+ if rule_spec["name"].lower() in existing_rule_names:
311
+ logger.info(" Rule '%s' already exists — skipping.", rule_spec["name"])
312
+ else:
313
+ if not force:
314
+ answer = (
315
+ input(f" Create automation rule '{rule_spec['name']}'? [y/N] ")
316
+ .strip()
317
+ .lower()
318
+ )
319
+ if answer != "y":
320
+ logger.info(" Skipped '%s'.", rule_spec["name"])
321
+ continue
322
+ logger.info(" Creating rule '%s' ...", rule_spec["name"])
323
+ client.create_automation_rule(project_key, rule_spec)
324
+ rules_created += 1
325
+
326
+ logger.info(
327
+ "Apply complete: %d statuses, %d fields, %d rules created.",
328
+ statuses_created,
329
+ fields_created,
330
+ rules_created,
331
+ )
332
+ if statuses_created == 0 and fields_created == 0 and rules_created == 0:
333
+ logger.info("Already up to date.")
334
+ return 0
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # CLI entry point
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ def build_parser() -> argparse.ArgumentParser:
343
+ """Construct the argument parser for ``bootstrap-jira``."""
344
+ parser = argparse.ArgumentParser(
345
+ prog="bootstrap-jira",
346
+ description="Bootstrap a Jira project with methodology-framework's canonical shape.",
347
+ )
348
+ parser.add_argument(
349
+ "--project-key",
350
+ required=True,
351
+ help="Jira project key (e.g. SCRUM). Used for {{PROJECT_KEY}} substitution.",
352
+ )
353
+ parser.add_argument(
354
+ "--jira-host",
355
+ required=True,
356
+ help="Jira Cloud host (e.g. myorg.atlassian.net). No https:// prefix.",
357
+ )
358
+
359
+ mode_group = parser.add_mutually_exclusive_group()
360
+ mode_group.add_argument(
361
+ "--dry-run",
362
+ action="store_true",
363
+ default=False,
364
+ help="Print what would be applied; no side-effects (default).",
365
+ )
366
+ mode_group.add_argument(
367
+ "--apply",
368
+ action="store_true",
369
+ default=False,
370
+ help="Apply shape defs to the Jira project via admin API.",
371
+ )
372
+ mode_group.add_argument(
373
+ "--export",
374
+ metavar="PATH",
375
+ default=None,
376
+ help="Emit an importable config bundle at PATH; no API calls.",
377
+ )
378
+
379
+ parser.add_argument(
380
+ "--force",
381
+ action="store_true",
382
+ default=False,
383
+ help="Skip confirmation prompts during --apply.",
384
+ )
385
+ return parser
386
+
387
+
388
+ def main(argv: list[str] | None = None) -> int:
389
+ """CLI entry point. Returns exit code."""
390
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
391
+
392
+ parser = build_parser()
393
+ args = parser.parse_args(argv)
394
+
395
+ # Load and substitute shape defs
396
+ try:
397
+ shapes = load_all_shapes()
398
+ except (yaml.YAMLError, FileNotFoundError) as exc:
399
+ logger.error("Failed to load shape definitions: %s", exc)
400
+ return 1
401
+
402
+ shapes = substitute_project_key(shapes, args.project_key)
403
+
404
+ # Determine mode (default to dry-run)
405
+ if args.apply:
406
+ token = os.environ.get("JIRA_ADMIN_TOKEN", "")
407
+ if not token:
408
+ logger.error(
409
+ "JIRA_ADMIN_TOKEN env var required for --apply mode. "
410
+ "Set it to a Jira admin API token or OAuth Bearer token."
411
+ )
412
+ return 1
413
+ return _handle_apply(
414
+ shapes,
415
+ project_key=args.project_key,
416
+ jira_host=args.jira_host,
417
+ token=token,
418
+ force=args.force,
419
+ )
420
+ elif args.export is not None:
421
+ return _handle_export(shapes, args.export)
422
+ else:
423
+ return _handle_dry_run(shapes)
424
+
425
+
426
+ if __name__ == "__main__":
427
+ sys.exit(main())
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Build a concrete, project-bound playbook from the parameterized body + bindings.
3
+
4
+ Reads the parameterized playbook body from methodology/playbooks/scrum-router.body.md
5
+ and substitutes {{PLACEHOLDER}} tokens with the project-specific bindings defined in
6
+ docs/jira-pickup-config.md § 5.1 (the playbook_bindings YAML block).
7
+
8
+ The '## Variables required' section is preserved verbatim so placeholder
9
+ documentation remains legible in the rendered output. The frontmatter
10
+ ``version:`` value is injected as the ``{{VERSION}}`` binding automatically.
11
+
12
+ Usage:
13
+ python scripts/build_playbook.py [--output PATH] [--check]
14
+
15
+ Options:
16
+ --output PATH Write the built playbook to PATH (default: stdout).
17
+ --check Validate only: exit 0 if no unreplaced tokens, exit 1 otherwise.
18
+ Does not write output.
19
+
20
+ The script is designed to be run from the repo root. It uses relative paths to locate
21
+ the body and bindings files.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ REPO_ROOT = Path(__file__).resolve().parent.parent
32
+ BODY_PATH = REPO_ROOT / "methodology" / "playbooks" / "scrum-router.body.md"
33
+ CONFIG_PATH = REPO_ROOT / "docs" / "jira-pickup-config.md"
34
+
35
+ PLACEHOLDER_RE = re.compile(r"\{\{(\w+)\}\}")
36
+ VARS_SECTION_RE = re.compile(
37
+ r"(^## Variables required$\n.*?)(?=^## |\Z)",
38
+ re.MULTILINE | re.DOTALL,
39
+ )
40
+ FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
41
+
42
+
43
+ def extract_bindings(config_text: str) -> dict[str, str]:
44
+ """Extract playbook_bindings from the config doc's YAML code block."""
45
+ in_block = False
46
+ bindings: dict[str, str] = {}
47
+ for line in config_text.splitlines():
48
+ if line.strip() == "playbook_bindings:":
49
+ in_block = True
50
+ continue
51
+ if in_block:
52
+ if line.strip() == "```" or (not line.startswith(" ") and line.strip()):
53
+ break
54
+ match = re.match(r'\s+(\w+):\s*"([^"]*)"', line)
55
+ if match:
56
+ bindings[match.group(1)] = match.group(2)
57
+ return bindings
58
+
59
+
60
+ def extract_version(body: str) -> str:
61
+ """Extract the ``version:`` value from the body's YAML frontmatter."""
62
+ fm_match = FRONTMATTER_RE.match(body)
63
+ if not fm_match:
64
+ return ""
65
+ for line in fm_match.group(1).splitlines():
66
+ stripped = line.strip()
67
+ if stripped.startswith("version:"):
68
+ return stripped.split(":", 1)[1].strip().strip('"').strip("'")
69
+ return ""
70
+
71
+
72
+ def substitute(body: str, bindings: dict[str, str]) -> str:
73
+ """Replace all {{PLACEHOLDER}} tokens with their bound values.
74
+
75
+ The ``## Variables required`` section is excluded from substitution so
76
+ that the placeholder-documentation table remains legible in the rendered
77
+ output.
78
+ """
79
+
80
+ def replacer(m: re.Match[str]) -> str:
81
+ key = m.group(1)
82
+ if key in bindings:
83
+ return bindings[key]
84
+ return m.group(0)
85
+
86
+ preserved_sections: list[tuple[int, int, str]] = []
87
+ for sec_match in VARS_SECTION_RE.finditer(body):
88
+ preserved_sections.append((sec_match.start(), sec_match.end(), sec_match.group(0)))
89
+
90
+ if not preserved_sections:
91
+ return PLACEHOLDER_RE.sub(replacer, body)
92
+
93
+ parts: list[str] = []
94
+ prev_end = 0
95
+ for start, end, original in preserved_sections:
96
+ parts.append(PLACEHOLDER_RE.sub(replacer, body[prev_end:start]))
97
+ parts.append(original)
98
+ prev_end = end
99
+ parts.append(PLACEHOLDER_RE.sub(replacer, body[prev_end:]))
100
+ return "".join(parts)
101
+
102
+
103
+ def find_unreplaced(text: str) -> list[str]:
104
+ """Return a list of any remaining {{PLACEHOLDER}} tokens.
105
+
106
+ Tokens inside the preserved ``## Variables required`` section are
107
+ expected and excluded from the check.
108
+ """
109
+ stripped = VARS_SECTION_RE.sub("", text)
110
+ return PLACEHOLDER_RE.findall(stripped)
111
+
112
+
113
+ def main() -> int:
114
+ parser = argparse.ArgumentParser(description=__doc__)
115
+ parser.add_argument(
116
+ "--output",
117
+ type=str,
118
+ default=None,
119
+ help="Write built playbook to this path (default: stdout)",
120
+ )
121
+ parser.add_argument(
122
+ "--check", action="store_true", help="Validate only; exit 1 if unreplaced tokens remain"
123
+ )
124
+ args = parser.parse_args()
125
+
126
+ if not BODY_PATH.exists():
127
+ print(f"ERROR: Body file not found: {BODY_PATH}", file=sys.stderr)
128
+ return 1
129
+
130
+ if not CONFIG_PATH.exists():
131
+ print(f"ERROR: Config file not found: {CONFIG_PATH}", file=sys.stderr)
132
+ return 1
133
+
134
+ body = BODY_PATH.read_text()
135
+ config = CONFIG_PATH.read_text()
136
+
137
+ bindings = extract_bindings(config)
138
+ if not bindings:
139
+ print("ERROR: No playbook_bindings found in config doc.", file=sys.stderr)
140
+ return 1
141
+
142
+ version = extract_version(body)
143
+ if version:
144
+ bindings["VERSION"] = version
145
+ print(f"Version from frontmatter: {version}", file=sys.stderr)
146
+ else:
147
+ print("WARNING: No version found in body frontmatter.", file=sys.stderr)
148
+
149
+ print(f"Bindings loaded: {bindings}", file=sys.stderr)
150
+
151
+ result = substitute(body, bindings)
152
+
153
+ unreplaced = find_unreplaced(result)
154
+ if unreplaced:
155
+ print(f"ERROR: Unreplaced tokens: {unreplaced}", file=sys.stderr)
156
+ return 1
157
+
158
+ if args.check:
159
+ print("OK: All tokens substituted.", file=sys.stderr)
160
+ return 0
161
+
162
+ if args.output:
163
+ Path(args.output).write_text(result)
164
+ print(f"Built playbook written to: {args.output}", file=sys.stderr)
165
+ else:
166
+ sys.stdout.write(result)
167
+
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())
File without changes