From 7036c4597d8d5c4e3f29c228cc25c3cd83aae89f Mon Sep 17 00:00:00 2001 From: Musa Date: Tue, 17 Feb 2026 09:39:30 -0800 Subject: [PATCH] feat: add template synchronization utility and CI integration --- .github/workflows/ci.yml | 3 + cli/README.md | 17 ++ cli/planoai/template_sync.py | 166 +++++++++++++++++++ cli/planoai/templates/template_sync_map.yaml | 29 ++++ 4 files changed, 215 insertions(+) create mode 100644 cli/planoai/template_sync.py create mode 100644 cli/planoai/templates/template_sync_map.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d07452b..765315bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,9 @@ jobs: - name: Install plano tools run: uv sync --extra dev + - name: Run CLI template/demo sync check + run: uv run python -m planoai.template_sync --check + - name: Run tests run: uv run pytest diff --git a/cli/README.md b/cli/README.md index 19567824..faaebd1d 100644 --- a/cli/README.md +++ b/cli/README.md @@ -71,6 +71,23 @@ uv run planoai logs --follow uv run planoai [options] ``` +### CI: Keep CLI templates and demos in sync + +The CLI templates in `cli/planoai/templates/` are the source of truth for mapped +demo `config.yaml` files. + +Use the sync utility to check drift: + +```bash +uv run python -m planoai.template_sync --check +``` + +Auto-fix mapped demo configs: + +```bash +uv run python -m planoai.template_sync --write +``` + ### Optional: Manual Virtual Environment Activation While `uv run` handles the virtual environment automatically, you can activate it manually if needed: diff --git a/cli/planoai/template_sync.py b/cli/planoai/template_sync.py new file mode 100644 index 00000000..8a6fe472 --- /dev/null +++ b/cli/planoai/template_sync.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from planoai.init_cmd import BUILTIN_TEMPLATES + + +@dataclass(frozen=True) +class SyncEntry: + template_id: str + template_file: str + demo_configs: tuple[str, ...] + transform: str = "none" + + +REPO_ROOT = Path(__file__).resolve().parents[2] +TEMPLATES_DIR = REPO_ROOT / "cli" / "planoai" / "templates" +SYNC_MAP_PATH = TEMPLATES_DIR / "template_sync_map.yaml" + + +def _load_sync_entries() -> list[SyncEntry]: + payload = yaml.safe_load(SYNC_MAP_PATH.read_text(encoding="utf-8")) or {} + rows = payload.get("templates", []) + entries: list[SyncEntry] = [] + for row in rows: + entries.append( + SyncEntry( + template_id=row["template_id"], + template_file=row["template_file"], + demo_configs=tuple(row.get("demo_configs", [])), + transform=row.get("transform", "none"), + ) + ) + return entries + + +def _normalize_yaml(text: str) -> Any: + return yaml.safe_load(text) if text.strip() else None + + +def _render_for_demo(template_text: str, transform: str) -> str: + if transform == "none": + rendered = template_text + else: + raise ValueError(f"Unknown transform profile: {transform}") + + return rendered if rendered.endswith("\n") else f"{rendered}\n" + + +def _validate_manifest(entries: list[SyncEntry]) -> list[str]: + errors: list[str] = [] + builtin_ids = {t.id for t in BUILTIN_TEMPLATES} + manifest_ids = {entry.template_id for entry in entries} + + missing = sorted(builtin_ids - manifest_ids) + extra = sorted(manifest_ids - builtin_ids) + if missing: + errors.append(f"Missing template IDs in sync map: {', '.join(missing)}") + if extra: + errors.append(f"Unknown template IDs in sync map: {', '.join(extra)}") + + for entry in entries: + template_path = TEMPLATES_DIR / entry.template_file + if not template_path.exists(): + errors.append( + f"template_file does not exist for '{entry.template_id}': {template_path}" + ) + for demo_rel_path in entry.demo_configs: + demo_path = REPO_ROOT / demo_rel_path + if not demo_path.exists(): + errors.append( + f"demo config does not exist for '{entry.template_id}': {demo_path}" + ) + + return errors + + +def run_sync(*, write: bool, verbose: bool = False) -> int: + entries = _load_sync_entries() + manifest_errors = _validate_manifest(entries) + if manifest_errors: + for error in manifest_errors: + print(f"[manifest] {error}") + return 2 + + drift_count = 0 + for entry in entries: + template_text = (TEMPLATES_DIR / entry.template_file).read_text( + encoding="utf-8" + ) + expected_text = _render_for_demo(template_text, entry.transform) + expected_yaml = _normalize_yaml(expected_text) + + for demo_rel_path in entry.demo_configs: + demo_path = REPO_ROOT / demo_rel_path + actual_text = demo_path.read_text(encoding="utf-8") + actual_yaml = _normalize_yaml(actual_text) + + if actual_yaml == expected_yaml: + if verbose: + print(f"[ok] {demo_rel_path}") + continue + + drift_count += 1 + print( + f"[drift] {demo_rel_path} differs from template '{entry.template_id}' " + f"({entry.template_file})" + ) + + if write: + demo_path.write_text(expected_text, encoding="utf-8") + print(f"[fixed] wrote {demo_rel_path}") + elif verbose: + actual_repr = json.dumps(actual_yaml, indent=2, sort_keys=True) + expected_repr = json.dumps(expected_yaml, indent=2, sort_keys=True) + print(f"[actual]\n{actual_repr}\n[expected]\n{expected_repr}") + + if drift_count == 0: + print("All mapped demo configs are in sync with CLI templates.") + return 0 + + if write: + print(f"Updated {drift_count} out-of-sync demo config(s).") + return 0 + + print( + f"Found {drift_count} out-of-sync demo config(s). " + "Run `python -m planoai.template_sync --write` to update." + ) + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Check or sync CLI templates to demo config.yaml files." + ) + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument( + "--write", + action="store_true", + help="Write template content to mapped demo configs when drift is found.", + ) + mode_group.add_argument( + "--check", + action="store_true", + help="Check for drift and return non-zero if any mapped demos are out of sync.", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print per-file status and parsed YAML when drift is detected.", + ) + args = parser.parse_args() + + write_mode = bool(args.write) + return run_sync(write=write_mode, verbose=bool(args.verbose)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/cli/planoai/templates/template_sync_map.yaml b/cli/planoai/templates/template_sync_map.yaml new file mode 100644 index 00000000..4d601f6c --- /dev/null +++ b/cli/planoai/templates/template_sync_map.yaml @@ -0,0 +1,29 @@ +templates: + - template_id: sub_agent_orchestration + template_file: sub_agent_orchestration.yaml + demo_configs: + - demos/agent_orchestration/multi_agent_crewai_langchain/config.yaml + transform: none + + - template_id: coding_agent_routing + template_file: coding_agent_routing.yaml + demo_configs: + - demos/llm_routing/claude_code_router/config.yaml + transform: none + + - template_id: preference_aware_routing + template_file: preference_aware_routing.yaml + demo_configs: + - demos/llm_routing/preference_based_routing/config.yaml + transform: none + + - template_id: filter_chain_guardrails + template_file: filter_chain_guardrails.yaml + demo_configs: + - demos/filter_chains/http_filter/config.yaml + transform: none + + - template_id: conversational_state_v1_responses + template_file: conversational_state_v1_responses.yaml + demo_configs: [] + transform: none