diff --git a/examples/file-reader/file-reader.yaml b/examples/file-reader/file-reader.yaml index a2a6e8b..2c83455 100644 --- a/examples/file-reader/file-reader.yaml +++ b/examples/file-reader/file-reader.yaml @@ -28,8 +28,11 @@ tasks: prompts: system: > You are a helpful assistant. When asked to summarise a file, use the - read_file tool to fetch its contents first, then write a clear, concise - summary. Respond with valid JSON matching the output schema. + read_file tool to fetch its contents first, then respond with valid JSON + containing exactly two fields: + "summary" (a concise one-paragraph summary of the file) and + "key_points" (an array of short strings, one per key takeaway). + Return only the JSON object — no markdown fencing, no extra text. user: "Please summarise the file at path: {path}" input: type: object diff --git a/oas_cli/banner.py b/oas_cli/banner.py index 24e7d69..4f6cb2f 100644 --- a/oas_cli/banner.py +++ b/oas_cli/banner.py @@ -1,9 +1,15 @@ -ASCII_TITLE = r""" - _______ _______ _______ ______ _______ _______ _______ ______ _______ _______ _______ _______ _______ - | _ | _ | _ | _ \ | _ | _ | _ | _ \| | | _ | _ | _ | _ | - |. | |. 1 |. 1___|. | | |. 1 |. |___|. 1___|. | |.| | | | 1___|. 1 |. 1___|. 1___| - |. | |. ____|. __)_|. | | |. _ |. | |. __)_|. | `-|. |-' |____ |. ____|. __)_|. |___ - |: 1 |: | |: 1 |: | | |: | |: 1 |: 1 |: | | |: | |: 1 |: | |: 1 |: 1 | - |::.. . |::.| |::.. . |::.| | |::.|:. |::.. . |::.. . |::.| | |::.| |::.. . |::.| |::.. . |::.. . | - `-------`---' `-------`--- ---' `--- ---`-------`-------`--- ---' `---' `-------`---' `-------`-------' +"""OA CLI banner and branding assets.""" + +# Compact bot-face banner. Renders cleanly at 80+ columns in any modern terminal. +# The ◈ glyph is the OA "agent node" icon used consistently across CLI surfaces. + +BANNER = r""" + ╔═══╗ + ║◈ ◈║ Open Agent Spec + ╚═╤═╝ Agents as code. + ╧ """ + +# Keep the old name so existing callers in main.py still work until +# they are migrated to the new ui module. +ASCII_TITLE = BANNER diff --git a/oas_cli/main.py b/oas_cli/main.py index 7360e7d..e93109e 100644 --- a/oas_cli/main.py +++ b/oas_cli/main.py @@ -5,6 +5,7 @@ import logging import sys import tempfile +import time from importlib.metadata import version as _get_version from pathlib import Path from typing import Any @@ -14,12 +15,19 @@ from rich.logging import RichHandler from rich.panel import Panel -from .banner import ASCII_TITLE from .core import generate_files as core_generate_files from .core import validate_spec_file from .exceptions import AgentGenerationError from .runner import OARunError, _choose_task, _load_spec, run_task_from_file from .spec_test import SpecTestError, run_cases_from_file +from .ui import ( + inference_spinner, + print_banner, + print_error_panel, + print_help_panel, + print_result_panel, + print_run_header, +) app = typer.Typer(help="Open Agent (OA) CLI") console = Console() @@ -48,26 +56,13 @@ def main( version: bool = typer.Option(False, "--version", help="Show version and exit"), ): """Main CLI entry point.""" + cli_version = get_version_from_pyproject() if version: - cli_version = get_version_from_pyproject() - console.print( - f"[bold cyan]Open Agent Spec CLI[/] version [green]{cli_version}[/]" - ) + print_banner(console, cli_version) raise typer.Exit() if ctx.invoked_subcommand is None or ctx.invoked_subcommand == "help": - console.print(f"[bold cyan]{ASCII_TITLE}[/]\n") - console.print( - Panel.fit( - "Use [bold magenta]oa init aac[/] for .agents/ agent-as-code layout\n" - "Use [bold magenta]oa init --spec … --output …[/] to scaffold code\n" - "Use [bold magenta]oa update[/] to update existing agent code\n" - "Define it via Open Agent Spec YAML\n" - "Use [bold yellow]--dry-run[/] to preview without writing files.", - title="[bold green]OA CLI[/]", - subtitle="Open Agent Spec Generator", - ) - ) + print_help_panel(console, cli_version) def load_and_validate_spec( @@ -134,13 +129,7 @@ def _run_init_code_gen( if verbose: log.setLevel(logging.DEBUG) - console.print( - Panel( - ASCII_TITLE, - title="[bold cyan]OA CLI[/]", - subtitle="[green]Open Agent Spec Generator[/]", - ) - ) + print_banner(console, get_version_from_pyproject()) spec_path, temp_file_to_delete = resolve_spec_path(spec, template, log) try: @@ -521,13 +510,7 @@ def update( ) raise typer.Exit(2) - console.print( - Panel( - ASCII_TITLE, - title="[bold cyan]OA CLI[/]", - subtitle="[green]Open Agent Spec Updater[/]", - ) - ) + print_banner(console, get_version_from_pyproject()) # Narrow types after validation above. spec = spec @@ -622,13 +605,8 @@ def run( logging.getLogger("oas").setLevel(logging.WARNING) if not quiet: - console.print( - Panel( - ASCII_TITLE, - title="[bold cyan]OA CLI[/]", - subtitle="[green]Open Agent Spec Runner[/]", - ) - ) + cli_version = get_version_from_pyproject() + print_banner(console, cli_version) try: input_data: dict[str, Any] | None = None @@ -688,35 +666,66 @@ def run( raise typer.BadParameter("--input JSON must be an object") input_data = parsed + # Load spec once for the header + spinner labels (reused by the runner). + spec_data_preview = _load_spec(spec) + intel = spec_data_preview.get("intelligence") or {} + _model = intel.get("model") or "model" + # Resolve the actual task name (handles auto-selection when --task omitted) + _task_label, _ = _choose_task(spec_data_preview, task) + + # Show run header (agent name, task, model) before calling the model. + if not quiet: + print_run_header(console, spec_data_preview, _task_label) + # When --quiet, redirect stdout to stderr during the run so logs from # our code or deps (dacp, httpx) don't pollute the pipe to jq. if quiet: real_stdout = sys.stdout sys.stdout = sys.stderr + + t0 = time.monotonic() try: - result = run_task_from_file( - spec, - task_name=task, - input_data=input_data, - override_system=system_prompt, - override_user=user_prompt, - ) + if quiet: + result = run_task_from_file( + spec, + task_name=task, + input_data=input_data, + override_system=system_prompt, + override_user=user_prompt, + ) + else: + with inference_spinner(console, _model, _task_label): + result = run_task_from_file( + spec, + task_name=task, + input_data=input_data, + override_system=system_prompt, + override_user=user_prompt, + ) finally: if quiet: sys.stdout = real_stdout + + elapsed = time.monotonic() - t0 + if quiet: - # Print only the agent's output (e.g. decision + summary) for clean piping + # Print only the agent's output for clean piping. + # dict/list → pretty JSON; plain string → written directly so no + # \n escaping or extra quotes corrupt the output. out = result.get("output", result) - typer.echo(json.dumps(out, indent=2)) + if isinstance(out, (dict, list)): + typer.echo(json.dumps(out, indent=2)) + else: + typer.echo(str(out) if out is not None else "") else: - console.print_json(data=result) + print_result_panel(console, result, elapsed_s=elapsed) + except OARunError as err: if quiet: # Machine-readable structured error on stderr — stdout stays clean. typer.echo(json.dumps(err.to_dict(), indent=2), err=True) else: - log.error(str(err)) - typer.echo(str(err), err=True) + print_error_panel(console, "Run error", str(err)) raise typer.Exit(1) except Exception as err: if quiet: @@ -727,8 +736,7 @@ def run( err=True, ) else: - log.error(str(err)) - typer.echo(str(err), err=True) + print_error_panel(console, "Unexpected error", str(err)) raise typer.Exit(1) diff --git a/oas_cli/ui.py b/oas_cli/ui.py new file mode 100644 index 0000000..6bbef14 --- /dev/null +++ b/oas_cli/ui.py @@ -0,0 +1,296 @@ +"""Terminal UI helpers for the OA CLI. + +Provides a consistent visual identity: + + ┌──────────────────────────────────────────┐ + │ Banner — compact bot-face on startup │ + │ Run header — agent/task/model before run │ + │ Spinner — animated "thinking" state │ + │ Result — styled JSON output panel │ + └──────────────────────────────────────────┘ + +All functions accept a ``console: Console`` argument so callers can swap +in a test-friendly console without affecting global state. + +Nothing here should import from runner.py to avoid circular deps. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any + +from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel +from rich.rule import Rule +from rich.syntax import Syntax +from rich.table import Table +from rich.text import Text + +# ── Colour palette ───────────────────────────────────────────────────────── + +_C_BRAND = "bold cyan" +_C_DIM = "dim" +_C_OK = "bold green" +_C_WARN = "bold yellow" +_C_ERR = "bold red" +_C_KEY = "cyan" +_C_VAL = "white" +_C_SUBTLE = "grey50" + +# ── Banner ────────────────────────────────────────────────────────────────── + + +def _banner_body(version: str = "") -> str: + """Return the bot-face markup string (no Panel wrapper).""" + ver_str = f" [dim]v{version}[/]" if version else "" + return ( + f"[{_C_BRAND}] ╔═══╗[/] [{_C_BRAND}]Open Agent Spec[/]{ver_str}\n" + f"[{_C_BRAND}] ║◈ ◈║[/] [dim]Agents as code.[/]\n" + f"[{_C_BRAND}] ╚═╤═╝[/]\n" + f"[{_C_BRAND}] ╧[/]" + ) + + +def print_banner(console: Console, version: str = "") -> None: + """Print the compact OA bot-face banner before a run.""" + console.print( + Panel.fit( + _banner_body(version), + border_style="cyan", + padding=(0, 2), + ) + ) + + +# ── Run header ────────────────────────────────────────────────────────────── + + +def print_run_header( + console: Console, + spec_data: dict[str, Any], + task_name: str, +) -> None: + """Print a compact metadata header before invoking the model. + + Example output: + ◈ chat-agent · assistant gpt-4o-mini openai + task: reply + """ + agent = spec_data.get("agent") or {} + intel = spec_data.get("intelligence") or {} + + agent_name: str = agent.get("name") or "agent" + agent_role: str = agent.get("role") or "" + model: str = intel.get("model") or "—" + engine: str = intel.get("engine") or "openai" + + # Left: agent identity + left = Text() + left.append("◈ ", style=_C_BRAND) + left.append(agent_name, style="bold white") + if agent_role: + left.append(f" · {agent_role}", style=_C_DIM) + + # Right: intelligence config + right = Text() + right.append(model, style=_C_KEY) + right.append(f" {engine}", style=_C_SUBTLE) + + table = Table.grid(expand=True) + table.add_column(justify="left") + table.add_column(justify="right") + table.add_row(left, right) + + console.print(table) + console.print(f" [dim]task:[/] [{_C_KEY}]{task_name}[/]") + console.print(Rule(style="grey23")) + + +# ── Spinner ───────────────────────────────────────────────────────────────── + + +@contextmanager +def inference_spinner( + console: Console, + model: str, + task_name: str, +) -> Generator[None, None, None]: + """Context manager that shows a live spinner while the model is running. + + Usage:: + + with inference_spinner(console, "gpt-4o-mini", "reply"): + result = run_task_from_file(...) + """ + label = ( + f"[{_C_SUBTLE}]calling[/] [{_C_KEY}]{model}[/] [dim]·[/] [dim]{task_name}[/]" + ) + with console.status(label, spinner="dots", spinner_style=_C_BRAND): + yield + + +# ── Result panel ───────────────────────────────────────────────────────────── + +_FENCED_JSON_RE = re.compile( + r"```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```", re.IGNORECASE +) + + +def _render_output(output: Any) -> Any: + """Return a Rich renderable for the task output. + + • dict / list → monokai JSON syntax block + • str with fenced JSON → strip prose, render the JSON block highlighted + • plain str → Markdown (handles headers, bullets, bold naturally) + """ + if isinstance(output, (dict, list)): + return Syntax( + json.dumps(output, indent=2, ensure_ascii=False), + "json", + theme="monokai", + word_wrap=True, + ) + + if isinstance(output, str): + # Try to pull a JSON block out of a markdown-fenced response. + match = _FENCED_JSON_RE.search(output) + if match: + try: + extracted = json.loads(match.group(1)) + prose = output[: match.start()].strip() + if prose: + # Show any introductory prose above the JSON block. + return _VStack([Markdown(prose), _json_syntax(extracted)]) + return _json_syntax(extracted) + except json.JSONDecodeError: + pass + # Plain text — render via Markdown so headings/bullets/bold work. + return Markdown(output) + + # Fallback: anything else (int, None, …) + return Syntax( + json.dumps(output, indent=2, ensure_ascii=False), + "json", + theme="monokai", + word_wrap=True, + ) + + +def _json_syntax(obj: Any) -> Syntax: + return Syntax( + json.dumps(obj, indent=2, ensure_ascii=False), + "json", + theme="monokai", + word_wrap=True, + ) + + +class _VStack: + """Minimal vertical stack of Rich renderables (no extra deps).""" + + def __init__(self, items: list) -> None: + self._items = items + + def __rich_console__(self, console: Console, options: Any): + yield from self._items + + +def print_result_panel( + console: Console, + result: dict[str, Any], + elapsed_s: float | None = None, +) -> None: + """Print the task result in a styled panel. + + Shows: + • A ✓ header with task name and optional elapsed time + • Output rendered appropriately: JSON dicts highlighted, plain strings + as Markdown, fenced-JSON-in-prose extracted and highlighted + • Chain intermediate results collapsed in a dimmed sub-panel (when present) + """ + task_name: str = result.get("task") or "output" + model: str = result.get("model") or "" + output: Any = result.get("output", result) + chain: dict | None = result.get("chain") + + content = _render_output(output) + + elapsed_str = f" [dim]{elapsed_s:.1f}s[/]" if elapsed_s is not None else "" + model_str = f" [{_C_SUBTLE}]{model}[/]" if model else "" + + console.print( + Panel( + content, + title=f"[{_C_OK}]✓[/] [{_C_KEY}]{task_name}[/]", + title_align="left", + subtitle=f"{elapsed_str}{model_str}", + subtitle_align="right", + border_style="green", + padding=(1, 2), + ) + ) + + # Show chain intermediates in a muted panel + if chain: + rows: list[str] = [] + for dep_task, dep_result in chain.items(): + dep_out = ( + dep_result.get("output") if isinstance(dep_result, dict) else dep_result + ) + dep_json = json.dumps(dep_out, indent=2, ensure_ascii=False) + rows.append(f"[dim]── {dep_task}[/]\n[grey39]{dep_json}[/]") + console.print( + Panel( + "\n\n".join(rows), + title="[dim]chain[/]", + title_align="left", + border_style="grey35", + padding=(0, 2), + ) + ) + + +# ── Error panel ────────────────────────────────────────────────────────────── + + +def print_error_panel(console: Console, title: str, message: str) -> None: + """Print a styled error panel.""" + console.print( + Panel( + f"[{_C_ERR}]{message}[/]", + title=f"[{_C_ERR}]✗ {title}[/]", + title_align="left", + border_style="red", + padding=(0, 2), + ) + ) + + +# ── Help panel ──────────────────────────────────────────────────────────────── + + +def print_help_panel(console: Console, version: str = "") -> None: + """Print the unified startup panel: bot-face + command reference in one box.""" + body = ( + f"{_banner_body(version)}\n" + "\n" + f" [{_C_BRAND}]oa run[/] [white]--spec path.yaml --task name --input '\\{{…\\}}'[/]\n" + f" [{_C_BRAND}]oa init[/] [white]aac | --spec path.yaml --output dir/[/]\n" + f" [{_C_BRAND}]oa test[/] [white]agent.test.yaml[/]\n" + f" [{_C_BRAND}]oa update[/] [white]--spec path.yaml --output dir/[/]\n" + "\n" + f" [dim]--quiet / -q[/] [dim]JSON-only output for pipes and CI[/]" + ) + console.print( + Panel.fit( + body, + title=f"[{_C_BRAND}]oa[/] [dim]Open Agent Spec CLI[/]", + border_style="cyan", + padding=(1, 3), + ) + ) diff --git a/tests/test_main.py b/tests/test_main.py index 65cbaac..af55d57 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -28,9 +28,9 @@ def test_version_flag(): """Test that the --version flag works correctly.""" result = runner.invoke(app, ["--version"]) assert result.exit_code == 0 - assert "Open Agent Spec CLI version" in result.output - version_part = result.output.split("version")[-1].strip() - assert version_part and re.search(r"\d", version_part), ( + # New banner shows "Open Agent Spec" text and a version digit (e.g. 1.4.0) + assert "Open Agent Spec" in result.output + assert re.search(r"\d+\.\d+", result.output), ( "Version output should contain a version-like string" )