diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d07452b..9070ee20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,10 @@ jobs: - name: Install plano tools run: uv sync --extra dev + - name: Sync CLI templates to demos + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: uv run python -m planoai.template_sync + - name: Run tests run: uv run pytest diff --git a/cli/README.md b/cli/README.md index 19567824..4bd769bc 100644 --- a/cli/README.md +++ b/cli/README.md @@ -71,6 +71,17 @@ 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 write mapped demo configs from templates: + +```bash +uv run python -m planoai.template_sync +``` + ### Optional: Manual Virtual Environment Activation While `uv run` handles the virtual environment automatically, you can activate it manually if needed: @@ -80,4 +91,4 @@ source .venv/bin/activate planoai build # No need for 'uv run' when activated ``` -**Note:** For end-user installation instructions, see the [plano documentation](https://docs.planoai.dev). +**Note:** For end-user installation instructions, see the [Plano documentation](https://docs.planoai.dev). diff --git a/cli/planoai/template_sync.py b/cli/planoai/template_sync.py new file mode 100644 index 00000000..f4f2e44e --- /dev/null +++ b/cli/planoai/template_sync.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import argparse +from dataclasses import dataclass +from pathlib import Path + +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 _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 write_mapped_demo_configs(*, 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 + + write_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) + + for demo_rel_path in entry.demo_configs: + demo_path = REPO_ROOT / demo_rel_path + # Keep this as a write-only sync step so CI behavior is deterministic. + demo_path.write_text(expected_text, encoding="utf-8") + write_count += 1 + if verbose: + print( + f"[wrote] {demo_rel_path} <- {entry.template_id} ({entry.template_file})" + ) + + print(f"Wrote {write_count} mapped demo config(s) from CLI templates.") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Sync CLI templates to mapped demo config.yaml files (write-only)." + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Print each file written during sync.", + ) + args = parser.parse_args() + + return write_mapped_demo_configs(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