From 18876eb9690344b7318de43c5c19a4643d2299fa Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 6 Feb 2026 21:09:23 -0500 Subject: [PATCH 1/6] docs: add CLI launcher design specification Design for interactive launcher that shows animated banner followed by a menu with Chat, Status, Docker, and Settings options. Includes navigation model, auto-start flow, and one-line installer. Co-Authored-By: Claude Opus 4.5 --- .../plans/2025-02-06-cli-launcher-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 a0-cli/docs/plans/2025-02-06-cli-launcher-design.md diff --git a/a0-cli/docs/plans/2025-02-06-cli-launcher-design.md b/a0-cli/docs/plans/2025-02-06-cli-launcher-design.md new file mode 100644 index 0000000000..ed3d5bc1c0 --- /dev/null +++ b/a0-cli/docs/plans/2025-02-06-cli-launcher-design.md @@ -0,0 +1,181 @@ +# a0 CLI Launcher Design + +## Overview + +Replace the current direct-to-TUI launch with an interactive launcher that shows an animated banner followed by a menu. This provides a clear entry point for users without requiring memorization of subcommands. + +## User Flow + +``` +User types: a0 + │ + ▼ +Animated banner plays (~0.5s) + │ + ▼ +Menu appears: + [1] Chat (TUI) Full terminal interface + [2] Chat (REPL) Simple text chat + [3] Status Check Agent Zero + [4] Start/Stop Docker container + [5] Settings Configure a0 + + ↑↓ Navigate • Enter/Number Select • Esc Quit + │ + ▼ +If Chat selected + Agent Zero not running: + "Agent Zero is not running. Start it now? [Y/n]" + Y → Start Docker, wait for ready, launch chat + N → Return to menu +``` + +## Navigation Model + +| Context | Escape | Ctrl+C / Ctrl+Q | +|---------|--------|-----------------| +| Menu (top level) | Quit app | Quit app | +| TUI main screen | Back to menu | Quit app | +| TUI modal/overlay | Close modal | Quit app | +| REPL mode | Back to menu | Quit app | +| Settings screen | Back to menu | Quit app | + +Key principle: Escape goes "up one level." Ctrl+C/Q always exits entirely. + +When returning from TUI to menu, the session stays alive. Selecting TUI again resumes where you left off. + +## Auto-Start Flow + +When user selects a Chat mode: + +1. Quick health check: `GET /health` (500ms timeout) +2. If running: Launch chat mode normally +3. If not running: Prompt "Agent Zero is not running. Start it now? [Y/n]" +4. If Y: Run `docker compose up -d`, show progress, poll `/health` until ready +5. Launch chat mode + +Timeouts: +- Docker start: 60 seconds +- Health poll: Every 2s for 30s max +- On failure: Show error, return to menu + +## Settings Screen + +``` +┌─────────────────────────────────────────────────────────┐ +│ Settings │ +├─────────────────────────────────────────────────────────┤ +│ Agent Zero URL http://localhost:55080 │ +│ API Key •••••••••••• │ +│ Theme dark │ +│ Docker Compose ~/agent-zero/docker-compose.yml │ +├─────────────────────────────────────────────────────────┤ +│ ↑↓ Navigate • Enter Edit • Esc Back │ +└─────────────────────────────────────────────────────────┘ +``` + +- Arrow keys to navigate fields +- Enter to edit (inline text input) +- Escape saves and returns to menu +- Persists to `~/.config/a0/config.toml` + +## Bug Fixes (Existing TUI) + +### CSS Path +```python +# Current (broken): +CSS_PATH = "src/a0/tui/styles/theme.tcss" + +# Fixed (relative to app.py): +CSS_PATH = "styles/theme.tcss" +``` + +### Quit Binding Visibility +```python +BINDINGS = [ + Binding("ctrl+q", "quit", "Quit", show=True), + Binding("ctrl+d", "toggle_dark", "Theme"), +] +``` + +### Escape Returns to Menu +```python +def action_cancel(self) -> None: + self.app.exit(result="menu") # Signal to re-show menu +``` + +### TUI Exit Handling +```python +# In launcher/actions.py: +def launch_tui(): + tui = AgentZeroTUI(...) + result = tui.run() + if result == "menu": + return True # Show menu again + return False # Quit entirely +``` + +## One-Line Installation + +```bash +curl -sSL https://get.agentzero.dev | sh +``` + +### Installer Steps + +1. Check dependencies (Python 3.11+, Docker) +2. Install `uv` if missing +3. Install a0 CLI: `uv tool install a0-cli` +4. Pull Docker image: `docker pull frdel/agent-zero:latest` +5. Create default config: `~/.config/a0/config.toml` + +### Installer Features + +| Feature | Description | +|---------|-------------| +| Dependency detection | Checks Python, Docker; installs uv if missing | +| Idempotent | Safe to run multiple times (upgrades) | +| Platform support | macOS (Intel/ARM), Linux (x86_64/ARM64) | +| Offline mode | `--offline` skips Docker pull | +| Uninstall | `--uninstall` removes everything | + +## Implementation Architecture + +``` +a0-cli/src/a0/ +├── cli.py # Entry point calls launcher +├── banner.py # Animated A-logo (exists) +├── launcher/ # NEW +│ ├── __init__.py +│ ├── menu.py # Menu renderer (~100 lines) +│ └── actions.py # Menu action handlers (~150 lines) +├── tui/ +│ ├── app.py # Fix CSS path, bindings +│ └── screens/main.py # Fix escape behavior +└── client/ + └── ... +``` + +### Why Separate Launcher from Textual + +The menu uses plain `rich` (no Textual) for instant startup. Textual has ~200ms overhead. This way, banner flows immediately into menu. Textual only loads when user selects TUI mode. + +## Files to Create/Modify + +| File | Action | Purpose | +|------|--------|---------| +| `src/a0/launcher/__init__.py` | Create | Package init | +| `src/a0/launcher/menu.py` | Create | Menu renderer | +| `src/a0/launcher/actions.py` | Create | Menu action handlers | +| `src/a0/cli.py` | Modify | Entry point calls launcher | +| `src/a0/tui/app.py` | Fix | CSS path, quit bindings | +| `src/a0/tui/screens/main.py` | Fix | Escape returns to menu | +| `install.sh` | Create | One-liner installer script | + +## Out of Scope (Future Work) + +- File attachments via CLI +- Session history browsing +- Notification display in terminal +- WebSocket streaming (currently uses polling) + +These can be added incrementally after the launcher ships. From 917afc77b8a7b5376dc0c2a606bced1b958ef373 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 6 Feb 2026 21:12:00 -0500 Subject: [PATCH 2/6] docs: add CLI launcher implementation plan Detailed task breakdown with phases, dependencies, and time estimates. Total estimated time: ~5 hours across 7 phases. Co-Authored-By: Claude Opus 4.5 --- .../plans/2025-02-06-cli-launcher-plan.md | 284 ++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 a0-cli/docs/plans/2025-02-06-cli-launcher-plan.md diff --git a/a0-cli/docs/plans/2025-02-06-cli-launcher-plan.md b/a0-cli/docs/plans/2025-02-06-cli-launcher-plan.md new file mode 100644 index 0000000000..98d0c8e4aa --- /dev/null +++ b/a0-cli/docs/plans/2025-02-06-cli-launcher-plan.md @@ -0,0 +1,284 @@ +# a0 CLI Launcher - Implementation Plan + +Based on: `2025-02-06-cli-launcher-design.md` + +## Phase 1: Fix Existing Bugs (30 min) + +These are blocking issues that prevent the current TUI from working. + +### Task 1.1: Fix CSS Path +**File:** `src/a0/tui/app.py` +**Change:** +```python +# Line 22, change: +CSS_PATH = "src/a0/tui/styles/theme.tcss" +# To: +CSS_PATH = "styles/theme.tcss" +``` +**Test:** Run `a0`, verify TUI renders with proper styling (not brown screen) + +### Task 1.2: Fix Quit Binding +**File:** `src/a0/tui/app.py` +**Change:** Update BINDINGS to show quit in footer +```python +BINDINGS = [ + Binding("ctrl+q", "quit", "Quit", show=True), + Binding("ctrl+d", "toggle_dark", "Theme"), +] +``` +**Test:** Run TUI, verify Footer shows "Ctrl+Q Quit" + +### Task 1.3: Verify Escape Works +**File:** `src/a0/tui/screens/main.py` +**Check:** `action_cancel()` exists and is bound to escape +**Test:** Run TUI, press Escape, verify it responds (currently just cancels poller) + +--- + +## Phase 2: Create Launcher Package (1 hour) + +### Task 2.1: Create Package Structure +```bash +mkdir -p src/a0/launcher +touch src/a0/launcher/__init__.py +``` + +### Task 2.2: Implement Menu Renderer +**File:** `src/a0/launcher/menu.py` + +**Functions to implement:** +- `run_menu() -> str | None` - Main loop, returns selected action or None +- `_render_menu(selected: int)` - Draw menu with highlight +- `_get_key() -> str` - Read single keypress (raw mode) + +**Dependencies:** `rich` (already installed) + +**Key behaviors:** +- Arrow up/down or j/k to navigate +- Enter or 1-5 to select +- Escape to quit (return None) +- Highlight current selection with rich styling + +### Task 2.3: Implement Action Handlers +**File:** `src/a0/launcher/actions.py` + +**Functions to implement:** +- `launch_tui(url, api_key, project, cwd) -> bool` - Returns True to show menu again +- `launch_repl(url, api_key, project)` - Blocking, returns when done +- `show_status(url, api_key)` - Print status, wait for keypress, return +- `toggle_docker(compose_file)` - Start if stopped, stop if running +- `open_settings()` - Interactive config editor + +**Helper functions:** +- `check_health(url, api_key, timeout=0.5) -> bool` +- `prompt_auto_start() -> bool` - "Start Agent Zero? [Y/n]" +- `start_docker_with_progress(compose_file)` - Show progress bar +- `wait_for_ready(url, api_key, timeout=30)` - Poll until healthy + +### Task 2.4: Export from Package +**File:** `src/a0/launcher/__init__.py` +```python +from .menu import run_menu +from .actions import ( + launch_tui, + launch_repl, + show_status, + toggle_docker, + open_settings, +) +``` + +--- + +## Phase 3: Wire Up Entry Point (30 min) + +### Task 3.1: Modify CLI Entry Point +**File:** `src/a0/cli.py` + +**Change the `main()` callback:** +```python +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context, ...): + if ctx.invoked_subcommand is None: + from a0.banner import show_banner + from a0.launcher import run_menu, launch_tui, launch_repl, ... + + show_banner() + + while True: + action = run_menu() + if action is None: # Escape pressed + break + elif action == "tui": + if not launch_tui(...): + break + elif action == "repl": + launch_repl(...) + # ... etc +``` + +### Task 3.2: Pass Config to Actions +Ensure URL, API key, project, cwd are passed from CLI options to action handlers. + +--- + +## Phase 4: TUI Return-to-Menu (30 min) + +### Task 4.1: Modify TUI Exit Behavior +**File:** `src/a0/tui/app.py` + +**Change:** App should return a result indicating whether to show menu +```python +class AgentZeroTUI(App[str | None]): + # Return type is now str | None + + def action_quit(self) -> None: + self.exit(result=None) # Full quit +``` + +### Task 4.2: Modify MainScreen Escape +**File:** `src/a0/tui/screens/main.py` + +**Change:** +```python +def action_cancel(self) -> None: + self.app.exit(result="menu") # Return to menu +``` + +### Task 4.3: Handle in Launcher +**File:** `src/a0/launcher/actions.py` + +```python +def launch_tui(url, api_key, project, cwd) -> bool: + tui = AgentZeroTUI(agent_url=url, ...) + result = tui.run() + return result == "menu" # True = show menu again +``` + +--- + +## Phase 5: Settings Screen (45 min) + +### Task 5.1: Create Settings Editor +**File:** `src/a0/launcher/settings.py` + +**Functions:** +- `run_settings() -> None` - Interactive config editor +- `_render_settings(config, selected)` - Draw fields +- `_edit_field(field_name, current_value) -> str` - Inline edit + +**Fields to edit:** +- `agent_url` - Text input +- `api_key` - Text input (masked display) +- `theme` - Cycle: dark/light +- `docker_compose` - Text input (file path) + +### Task 5.2: Wire to Menu +**File:** `src/a0/launcher/actions.py` +```python +def open_settings(): + from .settings import run_settings + run_settings() +``` + +--- + +## Phase 6: Installer Script (1 hour) + +### Task 6.1: Create Installer +**File:** `install.sh` + +**Structure:** +```bash +#!/bin/bash +set -e + +# Colors and formatting +# Dependency checks (python, docker) +# Install uv if missing +# Install a0-cli via uv +# Pull docker image +# Create default config +# Success message +``` + +### Task 6.2: Add Uninstall Flag +```bash +if [ "$1" = "--uninstall" ]; then + uv tool uninstall a0-cli + # Remove config + exit 0 +fi +``` + +### Task 6.3: Add Offline Flag +```bash +if [ "$1" = "--offline" ]; then + SKIP_DOCKER_PULL=1 +fi +``` + +--- + +## Phase 7: Testing (30 min) + +### Task 7.1: Manual Test Script +Create a checklist for manual testing: +- [ ] `a0` shows banner then menu +- [ ] Arrow keys navigate menu +- [ ] Number keys select directly +- [ ] Escape quits from menu +- [ ] TUI launches and renders correctly +- [ ] Escape in TUI returns to menu +- [ ] Ctrl+Q quits from anywhere +- [ ] REPL works and returns to menu on /exit +- [ ] Status shows connection state +- [ ] Start/Stop toggles Docker +- [ ] Settings edits persist + +### Task 7.2: Add Unit Tests +**File:** `tests/test_launcher.py` +- Test menu key handling +- Test action routing +- Test health check logic + +--- + +## Implementation Order + +``` +Phase 1 (bugs) ████████░░░░░░░░░░░░ 30 min +Phase 2 (launcher) ████████████████░░░░ 1 hour +Phase 3 (wire up) ██████░░░░░░░░░░░░░░ 30 min +Phase 4 (TUI exit) ██████░░░░░░░░░░░░░░ 30 min +Phase 5 (settings) █████████░░░░░░░░░░░ 45 min +Phase 6 (install) ████████████░░░░░░░░ 1 hour +Phase 7 (testing) ██████░░░░░░░░░░░░░░ 30 min + ───────── + ~5 hours +``` + +## Dependencies Between Tasks + +``` +1.1 ─┬─► 3.1 (need working TUI before wiring) +1.2 ─┤ +1.3 ─┘ + +2.1 ─► 2.2 ─► 2.3 ─► 2.4 ─► 3.1 + +3.1 ─► 4.1 ─► 4.2 ─► 4.3 + +5.1 ─► 5.2 (independent of phases 3-4) + +6.1 ─► 6.2 ─► 6.3 (independent, can do anytime) + +7.1 ─► 7.2 (after all features done) +``` + +## Quick Wins (Can Do Now) + +These are independent and can be done immediately: +1. Task 1.1: Fix CSS path (1 line change) +2. Task 1.2: Fix quit binding (1 line change) +3. Task 2.1: Create package structure (mkdir + touch) From ab788e8fb536cbd09c3027454b304750d4893fb6 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 6 Feb 2026 21:16:57 -0500 Subject: [PATCH 3/6] feat(a0-cli): implement interactive launcher menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add launcher package with menu renderer and action handlers - Banner → Menu flow with arrow keys, number keys, escape - Auto-start Agent Zero if not running when Chat selected - Fix CSS path (was breaking TUI with brown screen) - Fix quit binding visibility in footer - Escape in TUI returns to menu instead of just cancelling Menu options: [1] Chat (TUI) - Full terminal interface [2] Chat (REPL) - Simple text chat [3] Status - Check Agent Zero connection [4] Start/Stop - Toggle Docker container [5] Settings - View configuration Co-Authored-By: Claude Opus 4.5 --- a0-cli/src/a0/cli.py | 337 ++++++++++++++++++++++++++++ a0-cli/src/a0/launcher/__init__.py | 24 ++ a0-cli/src/a0/launcher/actions.py | 344 +++++++++++++++++++++++++++++ a0-cli/src/a0/launcher/menu.py | 140 ++++++++++++ a0-cli/src/a0/tui/app.py | 72 ++++++ a0-cli/src/a0/tui/screens/main.py | 113 ++++++++++ 6 files changed, 1030 insertions(+) create mode 100644 a0-cli/src/a0/cli.py create mode 100644 a0-cli/src/a0/launcher/__init__.py create mode 100644 a0-cli/src/a0/launcher/actions.py create mode 100644 a0-cli/src/a0/launcher/menu.py create mode 100644 a0-cli/src/a0/tui/app.py create mode 100644 a0-cli/src/a0/tui/screens/main.py diff --git a/a0-cli/src/a0/cli.py b/a0-cli/src/a0/cli.py new file mode 100644 index 0000000000..9997a11432 --- /dev/null +++ b/a0-cli/src/a0/cli.py @@ -0,0 +1,337 @@ +"""Agent Zero CLI. + +Commands: + a0 Launch interactive menu (default) + a0 chat "message" Send single message + a0 chat Interactive REPL + a0 start Start Agent Zero container + a0 stop Stop container + a0 status Check status + a0 acp Run as ACP server (stdio) + a0 config Manage configuration +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Optional + +import typer + +app = typer.Typer( + name="a0", + help="Agent Zero CLI — interact with Agent Zero from your terminal", + no_args_is_help=False, +) + +_DEFAULT_URL = "http://localhost:55080" + + +def _resolve_config( + url: str | None, + api_key: str | None, +) -> tuple[str, str | None]: + """Merge CLI flags with saved config. + + Resolution: CLI flag → env var (handled by typer) → config file → default. + """ + from a0.utils.config import Config + + cfg = Config.load() + resolved_url = url if url != _DEFAULT_URL else cfg.agent_url + resolved_key = api_key if api_key is not None else cfg.api_key + return resolved_url, resolved_key + + +@app.callback(invoke_without_command=True) +def main( + ctx: typer.Context, + project: Optional[str] = typer.Option( + None, "-p", "--project", help="Project directory" + ), + url: str = typer.Option( + _DEFAULT_URL, "-u", "--url", help="Agent Zero URL" + ), + api_key: Optional[str] = typer.Option( + None, + "-k", + "--api-key", + envvar="AGENT_ZERO_API_KEY", + help="API key", + ), +) -> None: + """Launch Agent Zero interactive menu.""" + if ctx.invoked_subcommand is None: + from a0.banner import show_banner + from a0.launcher import ( + run_menu, + launch_tui, + launch_repl, + show_status, + toggle_docker, + open_settings, + ) + + resolved_url, resolved_key = _resolve_config(url, api_key) + cwd = str(Path.cwd()) + + # Show animated banner + show_banner() + + # Run menu loop + while True: + action = run_menu() + + if action is None: + # User pressed Escape - quit + break + + elif action == "tui": + show_menu_again = launch_tui( + url=resolved_url, + api_key=resolved_key, + project=project, + cwd=cwd, + ) + if not show_menu_again: + break + + elif action == "repl": + launch_repl( + url=resolved_url, + api_key=resolved_key, + project=project, + ) + # After REPL exits, show menu again + + elif action == "status": + show_status(resolved_url, resolved_key) + # After status, show menu again + + elif action == "docker": + toggle_docker() + # After docker toggle, show menu again + + elif action == "settings": + open_settings() + # After settings, show menu again + + +@app.command() +def chat( + message: Optional[str] = typer.Argument(None, help="Message to send (omit for interactive REPL)"), + project: Optional[str] = typer.Option(None, "-p", "--project"), + context: Optional[str] = typer.Option( + None, "-c", "--context", help="Context ID to continue" + ), + url: str = typer.Option( + _DEFAULT_URL, "-u", "--url", envvar="AGENT_ZERO_URL" + ), + api_key: Optional[str] = typer.Option( + None, "-k", "--api-key", envvar="AGENT_ZERO_API_KEY" + ), + follow: bool = typer.Option( + False, "-f", "--follow", help="Follow agent activity via polling" + ), +) -> None: + """Send a message to Agent Zero, or start an interactive REPL.""" + from rich.console import Console + from rich.markdown import Markdown as RichMarkdown + + from a0.client.api import AgentZeroClient + + resolved_url, resolved_key = _resolve_config(url, api_key) + console = Console() + is_repl = message is None + effective_follow = follow or is_repl # default follow=True in REPL + + async def _follow_activity(client: AgentZeroClient, context_id: str) -> None: + from a0.client.poller import Poller + + poller = Poller(client, interval=0.5) + async for event in poller.stream( + context_id, + stop_when=lambda e: not e.progress_active and not e.logs, + ): + for log in event.logs: + _print_log(console, log) + + async def _send_and_print( + client: AgentZeroClient, + text: str, + context_id: str, + ) -> str: + response = await client.send_message( + message=text, + context_id=context_id, + project_name=project, + ) + console.print(RichMarkdown(response.response)) + if effective_follow: + await _follow_activity(client, response.context_id) + return response.context_id + + async def _single_shot() -> None: + assert message is not None + client = AgentZeroClient(resolved_url, resolved_key) + try: + ctx_id = await _send_and_print(client, message, context or "") + console.print(f"\n[dim]Context: {ctx_id}[/dim]") + finally: + await client.close() + + async def _repl() -> None: + client = AgentZeroClient(resolved_url, resolved_key) + ctx_id = context or "" + from a0.banner import show_banner + + show_banner() + console.print("[dim]Type /exit or Ctrl-D to quit[/dim]") + try: + while True: + try: + user_input = console.input("[bold]> [/bold]") + except (EOFError, KeyboardInterrupt): + console.print() + break + + stripped = user_input.strip() + if not stripped: + continue + if stripped in ("/exit", "/quit"): + break + + ctx_id = await _send_and_print(client, user_input, ctx_id) + finally: + await client.close() + + asyncio.run(_repl() if is_repl else _single_shot()) + + +def _print_log(console: object, log: dict) -> None: + """Print a log item dict to the console.""" + prefix_map = { + "agent": "[blue]GEN[/blue]", + "tool": "[yellow]USE[/yellow]", + "code_exe": "[magenta]RUN[/magenta]", + "browser": "[cyan]BRW[/cyan]", + "response": "[green]RSP[/green]", + "error": "[red]ERR[/red]", + "warning": "[yellow]WRN[/yellow]", + "progress": "[dim]...[/dim]", + "info": "[dim]INF[/dim]", + } + log_type = log.get("type", "") + prefix = prefix_map.get(log_type, f"[dim]{log_type[:3].upper()}[/dim]") + heading = log.get("heading") or log_type + content = (log.get("content") or "")[:200] + + console.print(f" {prefix} {heading}") + if content: + console.print(f" {content}") + + +@app.command() +def acp( + url: str = typer.Option( + _DEFAULT_URL, "-u", "--url", envvar="AGENT_ZERO_URL" + ), + api_key: Optional[str] = typer.Option( + None, "-k", "--api-key", envvar="AGENT_ZERO_API_KEY" + ), +) -> None: + """Run as ACP server over stdio. + + Use this mode to integrate Agent Zero with ACP-compatible + clients like Zed, Toad, or Cursor. + """ + from a0.acp.server import ACPServer + + resolved_url, resolved_key = _resolve_config(url, api_key) + server = ACPServer(agent_url=resolved_url, api_key=resolved_key) + asyncio.run(server.run()) + + +@app.command() +def start( + detach: bool = typer.Option(True, "-d", "--detach", help="Run in background"), + compose_file: Optional[Path] = typer.Option( + None, "-f", "--file", help="Docker compose file" + ), +) -> None: + """Start Agent Zero Docker container.""" + from a0.utils.docker import start_agent_zero + + start_agent_zero(detach=detach, compose_file=compose_file) + + +@app.command() +def stop() -> None: + """Stop Agent Zero Docker container.""" + from a0.utils.docker import stop_agent_zero + + stop_agent_zero() + + +@app.command() +def status( + url: str = typer.Option( + _DEFAULT_URL, "-u", "--url", envvar="AGENT_ZERO_URL" + ), + api_key: Optional[str] = typer.Option( + None, "-k", "--api-key", envvar="AGENT_ZERO_API_KEY" + ), +) -> None: + """Check Agent Zero status.""" + from rich.console import Console + + from a0.client.api import AgentZeroClient + + resolved_url, resolved_key = _resolve_config(url, api_key) + console = Console() + + async def check() -> None: + client = AgentZeroClient(resolved_url, resolved_key) + try: + ok = await client.health() + if ok: + console.print("[green]Agent Zero is running[/green]") + else: + console.print("[red]Agent Zero is not running[/red]") + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + finally: + await client.close() + + asyncio.run(check()) + + +@app.command() +def config( + show: bool = typer.Option(False, "--show", help="Show current config"), + set_key: Optional[str] = typer.Option( + None, "--set", help="Set config key=value" + ), +) -> None: + """Manage CLI configuration.""" + from rich.console import Console + from rich import print_json + + from a0.utils.config import Config + + console = Console() + cfg = Config.load() + + if show: + print_json(data=cfg.model_dump()) + elif set_key: + key, _, value = set_key.partition("=") + if not hasattr(cfg, key): + console.print(f"[red]Unknown config key: {key}[/red]") + raise typer.Exit(1) + setattr(cfg, key, value) + cfg.save() + console.print(f"[green]Set {key}={value}[/green]") + else: + console.print("Use --show to view config or --set key=value to update") diff --git a/a0-cli/src/a0/launcher/__init__.py b/a0-cli/src/a0/launcher/__init__.py new file mode 100644 index 0000000000..df48eff554 --- /dev/null +++ b/a0-cli/src/a0/launcher/__init__.py @@ -0,0 +1,24 @@ +"""Launcher module for a0 CLI. + +Provides an interactive menu that appears after the banner animation. +""" + +from .menu import run_menu +from .actions import ( + launch_tui, + launch_repl, + show_status, + toggle_docker, + open_settings, + check_health, +) + +__all__ = [ + "run_menu", + "launch_tui", + "launch_repl", + "show_status", + "toggle_docker", + "open_settings", + "check_health", +] diff --git a/a0-cli/src/a0/launcher/actions.py b/a0-cli/src/a0/launcher/actions.py new file mode 100644 index 0000000000..b80f0defdc --- /dev/null +++ b/a0-cli/src/a0/launcher/actions.py @@ -0,0 +1,344 @@ +"""Action handlers for the launcher menu. + +Each action corresponds to a menu option: TUI, REPL, Status, Docker, Settings. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import TYPE_CHECKING + +from rich.console import Console +from rich.prompt import Confirm + +if TYPE_CHECKING: + from a0.client.api import AgentZeroClient + + +def check_health(url: str, api_key: str | None, timeout: float = 0.5) -> bool: + """Check if Agent Zero is reachable. + + Args: + url: Agent Zero URL + api_key: Optional API key + timeout: Request timeout in seconds + + Returns: + True if healthy, False otherwise + """ + from a0.client.api import AgentZeroClient + + async def _check() -> bool: + client = AgentZeroClient(url, api_key, timeout=timeout) + try: + return await client.health() + except Exception: + return False + finally: + await client.close() + + return asyncio.run(_check()) + + +def _prompt_auto_start(console: Console) -> bool: + """Prompt user to start Agent Zero.""" + console.print() + console.print("[yellow]Agent Zero is not running.[/yellow]") + return Confirm.ask("Start it now?", default=True) + + +def _start_docker_with_progress( + console: Console, + compose_file: Path | None = None, +) -> bool: + """Start Agent Zero Docker container with progress display. + + Returns: + True if started successfully, False otherwise. + """ + from a0.utils.docker import start_agent_zero + + console.print() + with console.status("[bold blue]Starting Agent Zero..."): + try: + start_agent_zero(detach=True, compose_file=compose_file) + return True + except Exception as e: + console.print(f"[red]Failed to start: {e}[/red]") + return False + + +def _wait_for_ready( + console: Console, + url: str, + api_key: str | None, + timeout: float = 30.0, +) -> bool: + """Poll until Agent Zero is ready. + + Returns: + True if ready within timeout, False otherwise. + """ + import time + + start = time.time() + with console.status("[bold blue]Waiting for Agent Zero to be ready..."): + while time.time() - start < timeout: + if check_health(url, api_key, timeout=1.0): + console.print("[green]Agent Zero is ready![/green]") + return True + time.sleep(2.0) + + console.print("[red]Timed out waiting for Agent Zero.[/red]") + return False + + +def launch_tui( + url: str, + api_key: str | None, + project: str | None, + cwd: str, + compose_file: Path | None = None, +) -> bool: + """Launch the TUI interface. + + Args: + url: Agent Zero URL + api_key: Optional API key + project: Optional project name + cwd: Current working directory + compose_file: Optional Docker compose file path + + Returns: + True if user wants to return to menu, False to quit entirely. + """ + console = Console() + + # Check if Agent Zero is running + if not check_health(url, api_key): + if _prompt_auto_start(console): + if not _start_docker_with_progress(console, compose_file): + return True # Return to menu + if not _wait_for_ready(console, url, api_key): + return True # Return to menu + else: + return True # Return to menu + + # Launch TUI + from a0.tui.app import AgentZeroTUI + + tui = AgentZeroTUI( + agent_url=url, + api_key=api_key, + project=project, + cwd=cwd, + ) + result = tui.run() + + # Return True if user wants to go back to menu + return result == "menu" + + +def launch_repl( + url: str, + api_key: str | None, + project: str | None, + compose_file: Path | None = None, +) -> None: + """Launch the REPL interface. + + Args: + url: Agent Zero URL + api_key: Optional API key + project: Optional project name + compose_file: Optional Docker compose file path + """ + console = Console() + + # Check if Agent Zero is running + if not check_health(url, api_key): + if _prompt_auto_start(console): + if not _start_docker_with_progress(console, compose_file): + return + if not _wait_for_ready(console, url, api_key): + return + else: + return + + # Launch REPL (reuse existing implementation from cli.py) + from rich.markdown import Markdown as RichMarkdown + from a0.client.api import AgentZeroClient + from a0.client.poller import Poller + + async def _repl() -> None: + client = AgentZeroClient(url, api_key) + ctx_id = "" + + console.print("[dim]Type /exit or Ctrl-D to quit[/dim]") + try: + while True: + try: + user_input = console.input("[bold]> [/bold]") + except (EOFError, KeyboardInterrupt): + console.print() + break + + stripped = user_input.strip() + if not stripped: + continue + if stripped in ("/exit", "/quit", "/menu"): + break + + # Send message + response = await client.send_message( + message=user_input, + context_id=ctx_id, + project_name=project, + ) + console.print(RichMarkdown(response.response)) + ctx_id = response.context_id + + # Follow activity + poller = Poller(client, interval=0.5) + async for event in poller.stream( + ctx_id, + stop_when=lambda e: not e.progress_active and not e.logs, + ): + for log in event.logs: + _print_log(console, log) + + finally: + await client.close() + + asyncio.run(_repl()) + + +def _print_log(console: Console, log: dict) -> None: + """Print a log item to the console.""" + prefix_map = { + "agent": "[blue]GEN[/blue]", + "tool": "[yellow]USE[/yellow]", + "code_exe": "[magenta]RUN[/magenta]", + "browser": "[cyan]BRW[/cyan]", + "response": "[green]RSP[/green]", + "error": "[red]ERR[/red]", + "warning": "[yellow]WRN[/yellow]", + "progress": "[dim]...[/dim]", + "info": "[dim]INF[/dim]", + } + log_type = log.get("type", "") + prefix = prefix_map.get(log_type, f"[dim]{log_type[:3].upper()}[/dim]") + heading = log.get("heading") or log_type + content = (log.get("content") or "")[:200] + + console.print(f" {prefix} {heading}") + if content: + console.print(f" {content}") + + +def show_status(url: str, api_key: str | None) -> None: + """Show Agent Zero connection status.""" + console = Console() + console.print() + + if check_health(url, api_key, timeout=2.0): + console.print(f"[green]Agent Zero is running[/green] at {url}") + else: + console.print(f"[red]Agent Zero is not running[/red] at {url}") + + console.print() + console.print("[dim]Press any key to continue...[/dim]") + + # Wait for keypress + import sys + import tty + import termios + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def toggle_docker(compose_file: Path | None = None) -> None: + """Toggle Docker container state (start if stopped, stop if running).""" + from a0.utils.docker import start_agent_zero, stop_agent_zero + + console = Console() + console.print() + + # Check current state by trying to connect + # We use a simple heuristic: if docker ps shows the container, it's running + import subprocess + + result = subprocess.run( + ["docker", "ps", "--filter", "name=agent-zero", "--format", "{{.Names}}"], + capture_output=True, + text=True, + ) + is_running = "agent-zero" in result.stdout + + if is_running: + with console.status("[bold blue]Stopping Agent Zero..."): + try: + stop_agent_zero() + console.print("[green]Agent Zero stopped.[/green]") + except Exception as e: + console.print(f"[red]Failed to stop: {e}[/red]") + else: + with console.status("[bold blue]Starting Agent Zero..."): + try: + start_agent_zero(detach=True, compose_file=compose_file) + console.print("[green]Agent Zero started.[/green]") + except Exception as e: + console.print(f"[red]Failed to start: {e}[/red]") + + console.print() + console.print("[dim]Press any key to continue...[/dim]") + + import sys + import tty + import termios + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def open_settings() -> None: + """Open the settings editor.""" + from a0.utils.config import Config + + console = Console() + config = Config.load() + + console.print() + console.print("[bold]Settings[/bold]") + console.print() + console.print(f" Agent Zero URL: [cyan]{config.agent_url}[/cyan]") + console.print(f" API Key: [cyan]{'••••••••' if config.api_key else '(not set)'}[/cyan]") + console.print(f" Theme: [cyan]{config.theme}[/cyan]") + console.print() + console.print("[dim]Settings editor coming soon. Edit ~/.config/a0/config.toml directly.[/dim]") + console.print() + console.print("[dim]Press any key to continue...[/dim]") + + import sys + import tty + import termios + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) diff --git a/a0-cli/src/a0/launcher/menu.py b/a0-cli/src/a0/launcher/menu.py new file mode 100644 index 0000000000..262d7049be --- /dev/null +++ b/a0-cli/src/a0/launcher/menu.py @@ -0,0 +1,140 @@ +"""Interactive launcher menu for a0 CLI. + +Displays a menu after the banner animation, allowing users to select +between different modes: TUI, REPL, Status, Docker, Settings. +""" + +from __future__ import annotations + +import sys +import tty +import termios +from typing import NamedTuple + +from rich.console import Console +from rich.text import Text + + +class MenuItem(NamedTuple): + """A menu item with key, label, and description.""" + key: str + action: str + label: str + description: str + + +MENU_ITEMS: list[MenuItem] = [ + MenuItem("1", "tui", "Chat (TUI)", "Full terminal interface"), + MenuItem("2", "repl", "Chat (REPL)", "Simple text chat"), + MenuItem("3", "status", "Status", "Check Agent Zero"), + MenuItem("4", "docker", "Start/Stop", "Docker container"), + MenuItem("5", "settings", "Settings", "Configure a0"), +] + + +def _get_key() -> str: + """Read a single keypress from stdin (raw mode).""" + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + + # Handle escape sequences (arrow keys) + if ch == "\x1b": + ch2 = sys.stdin.read(1) + if ch2 == "[": + ch3 = sys.stdin.read(1) + if ch3 == "A": + return "up" + elif ch3 == "B": + return "down" + + return ch + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + + +def _render_menu(console: Console, selected: int) -> None: + """Render the menu with the current selection highlighted.""" + console.print() + + for i, item in enumerate(MENU_ITEMS): + is_selected = i == selected + + # Build the line + prefix = ">" if is_selected else " " + key_style = "bold cyan" if is_selected else "dim" + label_style = "bold white" if is_selected else "white" + desc_style = "italic" if is_selected else "dim" + + line = Text() + line.append(f" {prefix} ", style="bold cyan" if is_selected else "") + line.append(f"[{item.key}] ", style=key_style) + line.append(item.label, style=label_style) + line.append(f" {item.description}", style=desc_style) + + console.print(line) + + console.print() + console.print( + " [dim]↑↓ Navigate • Enter/Number Select • Esc Quit[/dim]" + ) + + +def _clear_menu(console: Console, line_count: int) -> None: + """Move cursor up and clear the menu lines.""" + for _ in range(line_count): + console.print("\033[A\033[2K", end="") + + +def run_menu() -> str | None: + """Display interactive menu and return the selected action. + + Returns: + The action string (e.g., "tui", "repl") or None if user pressed Escape. + """ + console = Console() + selected = 0 + menu_lines = len(MENU_ITEMS) + 3 # items + spacing + help line + + # Initial render + _render_menu(console, selected) + + while True: + key = _get_key() + + # Handle Escape + if key == "\x1b" or key == "q": + _clear_menu(console, menu_lines) + return None + + # Handle Ctrl+C / Ctrl+Q + if key == "\x03" or key == "\x11": + _clear_menu(console, menu_lines) + return None + + # Handle arrow keys + if key == "up" or key == "k": + selected = (selected - 1) % len(MENU_ITEMS) + elif key == "down" or key == "j": + selected = (selected + 1) % len(MENU_ITEMS) + + # Handle Enter + elif key == "\r" or key == "\n": + _clear_menu(console, menu_lines) + return MENU_ITEMS[selected].action + + # Handle number keys + elif key in "12345": + idx = int(key) - 1 + if 0 <= idx < len(MENU_ITEMS): + _clear_menu(console, menu_lines) + return MENU_ITEMS[idx].action + + else: + continue + + # Re-render menu + _clear_menu(console, menu_lines) + _render_menu(console, selected) diff --git a/a0-cli/src/a0/tui/app.py b/a0-cli/src/a0/tui/app.py new file mode 100644 index 0000000000..e3d7b6c2de --- /dev/null +++ b/a0-cli/src/a0/tui/app.py @@ -0,0 +1,72 @@ +"""Agent Zero TUI Application. + +A terminal interface built with Textual featuring: +- Rich markdown rendering +- Real-time agent activity streaming +- Collapsible tool execution panels +- Session management +""" + +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.binding import Binding + +from a0.client.api import AgentZeroClient +from a0.client.poller import Poller + + +class AgentZeroTUI(App[str | None]): + """Main TUI Application. + + Returns: + None: User quit the app entirely (Ctrl+Q) + "menu": User wants to return to launcher menu (Escape) + """ + + CSS_PATH = "styles/theme.tcss" + TITLE = "Agent Zero" + + BINDINGS = [ + Binding("ctrl+q", "quit", "Quit", show=True), + Binding("ctrl+d", "toggle_dark", "Theme"), + ] + + def __init__( + self, + agent_url: str = "http://localhost:55080", # change from 8080 + api_key: str | None = None, + project: str | None = None, + cwd: str = ".", + ) -> None: + super().__init__() + self.agent_url = agent_url + self.api_key = api_key + self.project = project + self.cwd = cwd + + self.client = AgentZeroClient(agent_url, api_key) + self.poller = Poller(self.client, interval=0.5) + self.context_id: str | None = None + + def compose(self) -> ComposeResult: + from a0.tui.screens.main import MainScreen + + yield MainScreen() + + async def on_mount(self) -> None: + """Check connectivity on mount.""" + ok = await self.client.health() + if not ok: + self.notify( + f"Cannot reach Agent Zero at {self.agent_url}", + severity="error", + timeout=5, + ) + + def action_toggle_dark(self) -> None: + self.dark = not self.dark + + async def on_unmount(self) -> None: + self.poller.stop() + await self.client.close() diff --git a/a0-cli/src/a0/tui/screens/main.py b/a0-cli/src/a0/tui/screens/main.py new file mode 100644 index 0000000000..57bc349bc2 --- /dev/null +++ b/a0-cli/src/a0/tui/screens/main.py @@ -0,0 +1,113 @@ +"""Main chat screen for Agent Zero TUI.""" + +from __future__ import annotations + +import asyncio + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Container, Vertical +from textual.screen import Screen +from textual.widgets import Footer, Header + +from a0.tui.widgets.message_view import MessageView +from a0.tui.widgets.prompt_editor import PromptEditor +from a0.tui.widgets.tool_panel import ToolPanel + + +class MainScreen(Screen[None]): + """Main chat interface.""" + + BINDINGS = [ + Binding("ctrl+n", "new_session", "New"), + Binding("escape", "back_to_menu", "Menu"), + ] + + def compose(self) -> ComposeResult: + yield Header() + + with Container(id="main-container"): + with Vertical(id="chat-panel"): + yield MessageView(id="messages") + + with Vertical(id="side-panel"): + yield ToolPanel(id="tools") + + with Container(id="input-area"): + yield PromptEditor(id="prompt") + + yield Footer() + + async def on_prompt_editor_submit(self, event: PromptEditor.Submit) -> None: + """Handle prompt submission.""" + message = event.text + if not message.strip(): + return + + messages = self.query_one("#messages", MessageView) + messages.add_user_message(message) + + app = self.app + # Send to Agent Zero + try: + response = await app.client.send_message( # type: ignore[attr-defined] + message=message, + context_id=app.context_id or "", # type: ignore[attr-defined] + project_name=app.project, # type: ignore[attr-defined] + ) + app.context_id = response.context_id # type: ignore[attr-defined] + messages.add_agent_message(response.response) + + # Start polling for tool activity + self.run_worker(self._poll_activity(response.context_id)) + + except Exception as e: + messages.add_error(str(e)) + + async def _poll_activity(self, context_id: str) -> None: + """Poll for agent activity after sending a message.""" + app = self.app + messages = self.query_one("#messages", MessageView) + tools = self.query_one("#tools", ToolPanel) + + idle_count = 0 + async for event in app.poller.stream(context_id): # type: ignore[attr-defined] + if event.context_reset: + messages.clear() + tools.clear() + break + + for log in event.logs: + log_type = log.get("type", "") + agent_no = log.get("agentno", 0) + content = log.get("content", "") + + if log_type == "response" and agent_no == 0: + messages.add_agent_message(content) + elif log_type == "user": + pass # already shown + elif log_type == "error": + messages.add_error(content) + elif log_type in {"tool", "code_exe", "browser", "mcp"}: + tools.add_tool_step_dict(log) + else: + tools.add_info_step_dict(log) + + if not event.logs and not event.progress_active: + idle_count += 1 + if idle_count > 6: # ~3 seconds of idle + break + else: + idle_count = 0 + + def action_new_session(self) -> None: + """Start a new session.""" + self.app.context_id = None # type: ignore[attr-defined] + self.query_one("#messages", MessageView).clear() + self.query_one("#tools", ToolPanel).clear() + self.notify("New session started") + + def action_back_to_menu(self) -> None: + """Return to the launcher menu.""" + self.app.poller.stop() # type: ignore[attr-defined] + self.app.exit(result="menu") From 619aa3fbff200761b68b2308115fe5ac9c29e8a0 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 6 Feb 2026 21:31:55 -0500 Subject: [PATCH 4/6] fix(a0-cli): use select() for escape key detection The menu was blocking on stdin.read() when user pressed Escape alone, because it was waiting for arrow key sequence characters that never came. Now uses select() with 50ms timeout to distinguish: - Plain Escape: just \x1b, no follow-up - Arrow keys: \x1b[A, \x1b[B sequences Co-Authored-By: Claude Opus 4.5 --- a0-cli/src/a0/launcher/menu.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/a0-cli/src/a0/launcher/menu.py b/a0-cli/src/a0/launcher/menu.py index 262d7049be..33e5d7515e 100644 --- a/a0-cli/src/a0/launcher/menu.py +++ b/a0-cli/src/a0/launcher/menu.py @@ -6,6 +6,7 @@ from __future__ import annotations +import select import sys import tty import termios @@ -33,7 +34,11 @@ class MenuItem(NamedTuple): def _get_key() -> str: - """Read a single keypress from stdin (raw mode).""" + """Read a single keypress from stdin (raw mode). + + Handles escape sequences for arrow keys with a short timeout + to distinguish between plain Escape and arrow key sequences. + """ fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: @@ -42,13 +47,18 @@ def _get_key() -> str: # Handle escape sequences (arrow keys) if ch == "\x1b": - ch2 = sys.stdin.read(1) - if ch2 == "[": - ch3 = sys.stdin.read(1) - if ch3 == "A": - return "up" - elif ch3 == "B": - return "down" + # Check if more input is available (with 50ms timeout) + # Arrow keys send sequences like \x1b[A, plain Escape is just \x1b + if select.select([sys.stdin], [], [], 0.05)[0]: + ch2 = sys.stdin.read(1) + if ch2 == "[" and select.select([sys.stdin], [], [], 0.05)[0]: + ch3 = sys.stdin.read(1) + if ch3 == "A": + return "up" + elif ch3 == "B": + return "down" + # Plain Escape key + return "escape" return ch finally: @@ -105,7 +115,7 @@ def run_menu() -> str | None: key = _get_key() # Handle Escape - if key == "\x1b" or key == "q": + if key == "escape" or key == "q": _clear_menu(console, menu_lines) return None From 0ed623fd02138e0f8ec1c652a26dfd87492ec2e1 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Fri, 6 Feb 2026 21:52:12 -0500 Subject: [PATCH 5/6] feat(a0-cli): redesign banner with slat/blinds effect - New banner matches Agent Zero logo aesthetic - Horizontal slats extend right with fade effect - Proper triangular A shape with inner hole - Gradient coloring (bright center, fading edges) - Smooth slide-in animation from right Also: - Fix escape key detection using select() with timeout - Remove debug output from cli.py - Clean up unused variables Co-Authored-By: Claude Opus 4.5 --- a0-cli/src/a0/banner.py | 224 ++++++++++++++++++++++++++++++++++++++++ a0-cli/src/a0/cli.py | 15 +++ 2 files changed, 239 insertions(+) create mode 100644 a0-cli/src/a0/banner.py diff --git a/a0-cli/src/a0/banner.py b/a0-cli/src/a0/banner.py new file mode 100644 index 0000000000..a7965154c5 --- /dev/null +++ b/a0-cli/src/a0/banner.py @@ -0,0 +1,224 @@ +"""Animated ANSI banner for the a0 CLI. + +Renders the Agent Zero "A" logo with horizontal slats that slide in +from the right, creating a venetian blinds / kinetic motion effect. +""" + +from __future__ import annotations + +import shutil +import sys +import time + +# Each row defines: (left_margin, solid_width, gap_start, gap_width, trail_length) +# - left_margin: spaces from center to start of solid "A" shape +# - solid_width: width of the solid part of the A +# - gap_start: where the internal triangle hole starts (0 = no hole) +# - gap_width: width of the hole +# - trail_length: extra slat length extending right (the "blinds" effect) +# +# The A shape: starts narrow at top, widens, has a triangular hole, then legs + +_ROWS = [ + # Top point of A (narrow) + (19, 2, 0, 0, 35), + (18, 4, 0, 0, 32), + (17, 6, 0, 0, 29), + (16, 8, 0, 0, 26), + (15, 10, 0, 0, 23), + (14, 12, 0, 0, 20), + (13, 14, 0, 0, 17), + (12, 16, 0, 0, 14), + (11, 18, 0, 0, 11), + # Start of inner triangle hole + (10, 20, 6, 8, 8), + (9, 22, 7, 8, 6), + (8, 24, 8, 8, 5), + # Crossbar (solid) + (7, 26, 0, 0, 4), + # Legs (with larger hole) + (6, 28, 10, 8, 3), + (5, 30, 11, 8, 2), + (4, 32, 12, 8, 1), + (3, 34, 13, 8, 0), +] + +_BLOCK = "\u2588" # Full block +_HALF_BLOCK = "\u2592" # Medium shade for trail fade + +# Animation tuning +_FRAME_DELAY = 0.008 +_STAGGER_DELAY = 0.02 +_FINAL_PAUSE = 0.25 +_MIN_WIDTH = 60 + + +def _ease_out_cubic(t: float) -> float: + """Easing function for smooth deceleration.""" + return 1.0 - (1.0 - t) ** 3 + + +def _rgb(r: int, g: int, b: int) -> str: + """Generate ANSI true color escape sequence.""" + return f"\033[38;2;{r};{g};{b}m" + + +def _gradient_color(progress: float, row: int, total_rows: int) -> str: + """Generate color based on position. + + Brighter at the left/center of the A, fading toward edges and trails. + Vertical gradient: brightest in middle rows. + """ + # Vertical brightness (center rows brightest) + center = total_rows / 2 + vert_factor = 1.0 - abs(row - center) / center * 0.4 + + # Horizontal brightness (left = bright, right = dim for trail) + horiz_factor = 1.0 - progress * 0.6 + + brightness = vert_factor * horiz_factor + + # Blue-white color scheme + r = int(180 + 75 * brightness) + g = int(190 + 65 * brightness) + b = int(220 + 35 * brightness) + + return _rgb(min(r, 255), min(g, 255), min(b, 255)) + + +def _build_row( + solid_width: int, + gap_start: int, + gap_width: int, + trail_length: int, + row_idx: int, + total_rows: int, +) -> list[tuple[str, str]]: + """Build a row as list of (character, color) tuples.""" + result = [] + + # The solid A part + if gap_start > 0 and gap_width > 0: + # Row with hole: left side, gap, right side + left_side = gap_start + right_side = solid_width - gap_start - gap_width + + # Left part of A + for i in range(left_side): + progress = i / solid_width + color = _gradient_color(progress, row_idx, total_rows) + result.append((_BLOCK, color)) + + # Gap (internal triangle) + for _ in range(gap_width): + result.append((" ", "")) + + # Right part of A + for i in range(right_side): + progress = (gap_start + gap_width + i) / solid_width + color = _gradient_color(progress, row_idx, total_rows) + result.append((_BLOCK, color)) + else: + # Solid row (no hole) + for i in range(solid_width): + progress = i / solid_width + color = _gradient_color(progress, row_idx, total_rows) + result.append((_BLOCK, color)) + + # Trail extending right (the slat/blinds effect) + for i in range(trail_length): + progress = (solid_width + i) / (solid_width + trail_length) + # Fade out the trail + fade = 1.0 - (i / trail_length) ** 0.5 + color = _gradient_color(progress * 1.5, row_idx, total_rows) + + # Use lighter blocks for trail fade + if fade > 0.7: + result.append((_BLOCK, color)) + elif fade > 0.4: + result.append(("\u2593", color)) # Dark shade + elif fade > 0.2: + result.append(("\u2592", color)) # Medium shade + else: + result.append(("\u2591", color)) # Light shade + + return result + + +def _render_row(chars: list[tuple[str, str]], x_offset: int) -> str: + """Render a row at the given x offset.""" + reset = "\033[0m" + padding = " " * max(0, x_offset) + + parts = [padding] + for char, color in chars: + if color: + parts.append(f"{color}{char}") + else: + parts.append(char) + parts.append(reset) + + return "".join(parts) + + +def show_banner() -> None: + """Display the animated A-logo banner. + + Guards: + - Only runs when stdout is a TTY + - Only runs when terminal is wide enough + """ + if not sys.stdout.isatty(): + return + + term_width = shutil.get_terminal_size((80, 24)).columns + if term_width < _MIN_WIDTH: + return + + clear_line = "\033[2K" + hide_cursor = "\033[?25l" + show_cursor = "\033[?25h" + + sys.stdout.write(hide_cursor) + sys.stdout.write("\n") + sys.stdout.flush() + + center = term_width // 2 + total_rows = len(_ROWS) + + try: + for row_idx, (left_margin, solid_width, gap_start, gap_width, trail_length) in enumerate(_ROWS): + # Build the row content + chars = _build_row( + solid_width, gap_start, gap_width, trail_length, + row_idx, total_rows + ) + + # Calculate final position (centered, offset by left_margin) + final_x = center - left_margin - (solid_width // 2) + start_x = term_width + 10 # Start off-screen right + + # Animate: slide in from right + frames = 6 + for frame in range(frames): + t = (frame + 1) / frames + eased = _ease_out_cubic(t) + cur_x = int(start_x + (final_x - start_x) * eased) + + line = _render_row(chars, cur_x) + sys.stdout.write(f"\r{clear_line}{line}") + sys.stdout.flush() + time.sleep(_FRAME_DELAY) + + # Final position + line = _render_row(chars, final_x) + sys.stdout.write(f"\r{clear_line}{line}\n") + sys.stdout.flush() + time.sleep(_STAGGER_DELAY) + + time.sleep(_FINAL_PAUSE) + + finally: + sys.stdout.write(show_cursor) + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/a0-cli/src/a0/cli.py b/a0-cli/src/a0/cli.py index 9997a11432..a9bb579a8e 100644 --- a/a0-cli/src/a0/cli.py +++ b/a0-cli/src/a0/cli.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio +import sys from pathlib import Path from typing import Optional @@ -63,6 +64,20 @@ def main( ) -> None: """Launch Agent Zero interactive menu.""" if ctx.invoked_subcommand is None: + # Check if we're in a TTY - menu requires interactive input + if not sys.stdin.isatty(): + # Fall back to TUI when not in TTY (e.g., piped input) + from a0.tui.app import AgentZeroTUI + resolved_url, resolved_key = _resolve_config(url, api_key) + tui = AgentZeroTUI( + agent_url=resolved_url, + api_key=resolved_key, + project=project, + cwd=str(Path.cwd()), + ) + tui.run() + return + from a0.banner import show_banner from a0.launcher import ( run_menu, From caf10a5fdbd38a7e1bb3044f158b9c1abee88959 Mon Sep 17 00:00:00 2001 From: TerminallyLazy Date: Sat, 7 Feb 2026 02:06:40 -0500 Subject: [PATCH 6/6] feat(a0-cli): add initial implementation of Agent Zero CLI - Introduced a new CLI tool with ACP support, including a banner and interactive menu. - Added configuration management with defaults and environment variable support. - Implemented core functionalities for sending messages and handling sessions via the Agent Zero API. - Included a log polling mechanism to track updates in real-time. - New dependencies added: textual for enhanced UI and various libraries for API interactions. --- a0-cli/config/default.toml | 11 + a0-cli/pyproject.toml | 31 ++ a0-cli/src/a0/__init__.py | 3 + a0-cli/src/a0/__main__.py | 5 + a0-cli/src/a0/acp/__init__.py | 1 + a0-cli/src/a0/acp/server.py | 286 +++++++++++++++++ a0-cli/src/a0/banner.py | 270 +++++++--------- a0-cli/src/a0/cli.py | 26 +- a0-cli/src/a0/client/__init__.py | 5 + a0-cli/src/a0/client/api.py | 162 ++++++++++ a0-cli/src/a0/client/poller.py | 111 +++++++ a0-cli/src/a0/launcher/__init__.py | 2 - a0-cli/src/a0/launcher/actions.py | 148 +++++---- a0-cli/src/a0/launcher/menu.py | 19 +- a0-cli/src/a0/utils/__init__.py | 1 + a0-cli/src/a0/utils/config.py | 81 +++++ a0-cli/src/a0/utils/docker.py | 79 +++++ a0-cli/tests/__init__.py | 0 a0-cli/tests/test_acp.py | 114 +++++++ a0-cli/tests/test_client.py | 93 ++++++ a0-cli/uv.lock | 488 +++++++++++++++++++++++++++++ requirements.txt | 1 + 22 files changed, 1671 insertions(+), 266 deletions(-) create mode 100644 a0-cli/config/default.toml create mode 100644 a0-cli/pyproject.toml create mode 100644 a0-cli/src/a0/__init__.py create mode 100644 a0-cli/src/a0/__main__.py create mode 100644 a0-cli/src/a0/acp/__init__.py create mode 100644 a0-cli/src/a0/acp/server.py create mode 100644 a0-cli/src/a0/client/__init__.py create mode 100644 a0-cli/src/a0/client/api.py create mode 100644 a0-cli/src/a0/client/poller.py create mode 100644 a0-cli/src/a0/utils/__init__.py create mode 100644 a0-cli/src/a0/utils/config.py create mode 100644 a0-cli/src/a0/utils/docker.py create mode 100644 a0-cli/tests/__init__.py create mode 100644 a0-cli/tests/test_acp.py create mode 100644 a0-cli/tests/test_client.py create mode 100644 a0-cli/uv.lock diff --git a/a0-cli/config/default.toml b/a0-cli/config/default.toml new file mode 100644 index 0000000000..1a0ba0cbdc --- /dev/null +++ b/a0-cli/config/default.toml @@ -0,0 +1,11 @@ +# Agent Zero CLI default configuration + +[connection] +agent_url = "http://localhost:55080" +# api_key = "" + +[display] +theme = "dark" + +[acp] +acp_auto_approve = false diff --git a/a0-cli/pyproject.toml b/a0-cli/pyproject.toml new file mode 100644 index 0000000000..60b9d37eed --- /dev/null +++ b/a0-cli/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "a0-cli" +version = "0.1.0" +description = "Agent Zero CLI with ACP support" +requires-python = ">=3.11" +dependencies = [ + "typer>=0.12.0", + "httpx>=0.27.0", + "pydantic>=2.6.0", + "rich>=13.7.0", + "python-dotenv>=1.0.0", + "tomli>=2.0.0;python_version<'3.11'", + "tomli-w>=1.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "pytest-mock>=3.12.0", +] + +[project.scripts] +a0 = "a0.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/a0"] diff --git a/a0-cli/src/a0/__init__.py b/a0-cli/src/a0/__init__.py new file mode 100644 index 0000000000..b29d1636b7 --- /dev/null +++ b/a0-cli/src/a0/__init__.py @@ -0,0 +1,3 @@ +"""Agent Zero CLI with ACP support.""" + +__version__ = "0.1.0" diff --git a/a0-cli/src/a0/__main__.py b/a0-cli/src/a0/__main__.py new file mode 100644 index 0000000000..12a530c69a --- /dev/null +++ b/a0-cli/src/a0/__main__.py @@ -0,0 +1,5 @@ +"""Entry point for python -m a0.""" + +from a0.cli import app + +app() diff --git a/a0-cli/src/a0/acp/__init__.py b/a0-cli/src/a0/acp/__init__.py new file mode 100644 index 0000000000..c0de0c9dc0 --- /dev/null +++ b/a0-cli/src/a0/acp/__init__.py @@ -0,0 +1 @@ +"""ACP (Agent Client Protocol) adapter for Agent Zero.""" diff --git a/a0-cli/src/a0/acp/server.py b/a0-cli/src/a0/acp/server.py new file mode 100644 index 0000000000..9a86c17d16 --- /dev/null +++ b/a0-cli/src/a0/acp/server.py @@ -0,0 +1,286 @@ +"""ACP Server Implementation. + +Implements Agent Client Protocol over stdio, translating between +ACP JSON-RPC messages and Agent Zero's HTTP API (polling-based). + +Protocol: JSON-RPC 2.0 over stdio (newline-delimited). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import sys +from dataclasses import dataclass, field +from typing import Any + +from a0.client.api import AgentZeroClient, LogItem +from a0.client.poller import Poller, PollEvent + +logger = logging.getLogger(__name__) + +# Log types that map to tool-like ACP updates +TOOL_TYPES = {"tool", "code_exe", "browser", "mcp"} + +# Log types treated as agent thinking/progress +THINKING_TYPES = {"agent", "progress", "info", "hint", "util", "warning", "subagent"} + + +@dataclass +class ACPSession: + """Tracks an active ACP session.""" + + session_id: str + context_id: str + cwd: str + mcp_servers: list[dict[str, Any]] = field(default_factory=list) + poll_task: asyncio.Task[None] | None = None + _tool_counter: int = 0 + + def next_tool_id(self) -> str: + self._tool_counter += 1 + return f"call_{self._tool_counter:04d}" + + +class ACPServer: + """ACP Protocol Server bridging ACP clients to Agent Zero via stdio.""" + + PROTOCOL_VERSION = 1 + + def __init__( + self, + agent_url: str = "http://localhost:55080", # change from 8080 + api_key: str | None = None, + ) -> None: + self.agent_url = agent_url + self.api_key = api_key + self.client = AgentZeroClient(agent_url, api_key) + self.poller = Poller(self.client, interval=0.3) + + self._sessions: dict[str, ACPSession] = {} + self._initialized = False + self._client_caps: dict[str, Any] = {} + + # Track pending prompt requests so we can respond when agent finishes + self._pending_prompts: dict[str, int] = {} # session_id -> request_id + + async def run(self) -> None: + """Main server loop — read from stdin, write to stdout.""" + loop = asyncio.get_event_loop() + reader = asyncio.StreamReader() + protocol = asyncio.StreamReaderProtocol(reader) + await loop.connect_read_pipe(lambda: protocol, sys.stdin) + + while True: + line = await reader.readline() + if not line: + break + + try: + message = json.loads(line.decode().strip()) + await self._handle_message(message) + except json.JSONDecodeError as e: + logger.error("Invalid JSON: %s", e) + except Exception: + logger.exception("Error handling message") + + async def _handle_message(self, message: dict[str, Any]) -> None: + """Route incoming JSON-RPC message to handler.""" + method = message.get("method") + msg_id = message.get("id") + params = message.get("params", {}) + + handlers: dict[str, Any] = { + "initialize": self._handle_initialize, + "session/new": self._handle_session_new, + "session/load": self._handle_session_load, + "session/prompt": self._handle_session_prompt, + "session/cancel": self._handle_session_cancel, + } + + handler = handlers.get(method) # type: ignore[arg-type] + if handler: + try: + result = await handler(params) + if msg_id is not None: + await self._send_response(msg_id, result) + except Exception as e: + if msg_id is not None: + await self._send_error(msg_id, -32603, str(e)) + else: + if msg_id is not None: + await self._send_error(msg_id, -32601, f"Unknown method: {method}") + + # ── ACP Method Handlers ────────────────────────────────────────── + + async def _handle_initialize(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle ACP initialize request.""" + client_version = params.get("protocolVersion", 1) + self._client_caps = params.get("clientCapabilities", {}) + self._initialized = True + + return { + "protocolVersion": min(self.PROTOCOL_VERSION, client_version), + "agentCapabilities": { + "loadSession": False, + "promptCapabilities": { + "image": True, + "audio": False, + "embeddedContext": True, + }, + "mcpCapabilities": { + "http": False, + "sse": False, + }, + }, + "agentInfo": { + "name": "agent-zero", + "title": "Agent Zero", + "version": "1.0.0", + }, + "authMethods": [], + } + + async def _handle_session_new(self, params: dict[str, Any]) -> dict[str, Any]: + """Create a new Agent Zero session. + + We don't call /chat_create here because it requires CSRF/session auth. + Instead we defer context creation to the first prompt — /api_message + auto-creates a context when context_id is empty. + """ + import uuid + + cwd = params.get("cwd", ".") + mcp_servers = params.get("mcpServers", []) + + # Generate a local session ID; the real Agent Zero context_id + # will be assigned on the first prompt via /api_message. + session_id = f"sess_{uuid.uuid4().hex[:12]}" + session = ACPSession( + session_id=session_id, + context_id="", # filled on first prompt + cwd=cwd, + mcp_servers=mcp_servers, + ) + self._sessions[session_id] = session + + return {"sessionId": session_id} + + async def _handle_session_load(self, params: dict[str, Any]) -> dict[str, Any] | None: + """Load existing session.""" + session_id = params.get("sessionId", "") + if session_id not in self._sessions: + raise ValueError(f"Session not found: {session_id}") + return None + + async def _handle_session_prompt(self, params: dict[str, Any]) -> dict[str, Any]: + """Handle user prompt — send to Agent Zero and poll for response.""" + session_id = params.get("sessionId", "") + prompt_content = params.get("prompt", []) + + session = self._sessions.get(session_id) + if not session: + raise ValueError(f"Unknown session: {session_id}") + + # Convert ACP ContentBlocks to Agent Zero format + message_text, attachments = self._convert_prompt(prompt_content) + + # Convert attachment dicts to Attachment models + from a0.client.api import Attachment + + attachment_models = [ + Attachment(filename=a["filename"], base64=a["base64"]) + for a in attachments + ] if attachments else None + + # Send message (this blocks until agent responds) + response = await self.client.send_message( + message=message_text, + context_id=session.context_id, + attachments=attachment_models, + ) + + # Update context_id in case it was auto-created + session.context_id = response.context_id + + # Emit the response as agent_message_chunk + await self._send_notification( + "session/update", + { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": response.response}, + }, + }, + ) + + return {"stopReason": "end_turn"} + + async def _handle_session_cancel(self, params: dict[str, Any]) -> None: + """Handle cancellation (notification, no response).""" + session_id = params.get("sessionId", "") + session = self._sessions.get(session_id) + if session: + await self.client.reset_chat(session.context_id) + + # ── Helpers ─────────────────────────────────────────────────────── + + def _convert_prompt( + self, content_blocks: list[dict[str, Any]] + ) -> tuple[str, list[dict[str, str]]]: + """Convert ACP ContentBlocks to Agent Zero message + attachments.""" + text_parts: list[str] = [] + attachments: list[dict[str, str]] = [] + + for block in content_blocks: + block_type = block.get("type") + + if block_type == "text": + text_parts.append(block.get("text", "")) + + elif block_type == "resource": + resource = block.get("resource", {}) + if "text" in resource: + uri = resource.get("uri", "file") + text_parts.append(f"\n--- {uri} ---\n{resource['text']}\n---\n") + elif "blob" in resource: + attachments.append( + { + "filename": resource.get("uri", "attachment").split("/")[-1], + "base64": resource["blob"], + } + ) + + elif block_type == "image": + attachments.append( + { + "filename": "image.png", + "base64": block.get("data", ""), + } + ) + + return "\n".join(text_parts), attachments + + # ── JSON-RPC Transport ──────────────────────────────────────────── + + async def _send_response(self, msg_id: int, result: Any) -> None: + """Send JSON-RPC response.""" + await self._write({"jsonrpc": "2.0", "id": msg_id, "result": result}) + + async def _send_error(self, msg_id: int, code: int, message: str) -> None: + """Send JSON-RPC error.""" + await self._write( + {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}} + ) + + async def _send_notification(self, method: str, params: dict[str, Any]) -> None: + """Send JSON-RPC notification (no id).""" + await self._write({"jsonrpc": "2.0", "method": method, "params": params}) + + async def _write(self, message: dict[str, Any]) -> None: + """Write JSON-RPC message to stdout.""" + line = json.dumps(message, separators=(",", ":")) + "\n" + sys.stdout.write(line) + sys.stdout.flush() diff --git a/a0-cli/src/a0/banner.py b/a0-cli/src/a0/banner.py index a7965154c5..7d256a9242 100644 --- a/a0-cli/src/a0/banner.py +++ b/a0-cli/src/a0/banner.py @@ -1,7 +1,7 @@ """Animated ANSI banner for the a0 CLI. -Renders the Agent Zero "A" logo with horizontal slats that slide in -from the right, creating a venetian blinds / kinetic motion effect. +Renders the Agent Zero logo with "AGENT ZERO" text below, +aligned to the left side with cyan-to-blue gradient. """ from __future__ import annotations @@ -10,164 +10,76 @@ import sys import time -# Each row defines: (left_margin, solid_width, gap_start, gap_width, trail_length) -# - left_margin: spaces from center to start of solid "A" shape -# - solid_width: width of the solid part of the A -# - gap_start: where the internal triangle hole starts (0 = no hole) -# - gap_width: width of the hole -# - trail_length: extra slat length extending right (the "blinds" effect) -# -# The A shape: starts narrow at top, widens, has a triangular hole, then legs - -_ROWS = [ - # Top point of A (narrow) - (19, 2, 0, 0, 35), - (18, 4, 0, 0, 32), - (17, 6, 0, 0, 29), - (16, 8, 0, 0, 26), - (15, 10, 0, 0, 23), - (14, 12, 0, 0, 20), - (13, 14, 0, 0, 17), - (12, 16, 0, 0, 14), - (11, 18, 0, 0, 11), - # Start of inner triangle hole - (10, 20, 6, 8, 8), - (9, 22, 7, 8, 6), - (8, 24, 8, 8, 5), - # Crossbar (solid) - (7, 26, 0, 0, 4), - # Legs (with larger hole) - (6, 28, 10, 8, 3), - (5, 30, 11, 8, 2), - (4, 32, 12, 8, 1), - (3, 34, 13, 8, 0), -] - _BLOCK = "\u2588" # Full block -_HALF_BLOCK = "\u2592" # Medium shade for trail fade # Animation tuning -_FRAME_DELAY = 0.008 -_STAGGER_DELAY = 0.02 -_FINAL_PAUSE = 0.25 -_MIN_WIDTH = 60 +_FRAME_DELAY = 0.004 +_STAGGER_DELAY = 0.010 +_FINAL_PAUSE = 0.08 +_MIN_WIDTH = 50 +_LEFT_MARGIN = 4 # Distance from left edge + +# Logo shape +_LOGO_ROWS = [ + (2, 0, 2), + (2, 2, 2), + (3, 2, 3), + (3, 4, 3), + (4, 4, 4), + (4, 6, 4), + (5, 6, 5), + (5, 8, 5), + (6, 8, 6), + (6, 10, 6), + (7, 10, 7), + (7, 12, 7), + (8, 12, 8), + (8, 4, 4, 4, 8), +] + +# Clean pixel-art text "AGENT ZERO" using standard 5-high blocky font +_TEXT_ART = [ + " ███ ████ ████ █ █ █████ █████ ████ ████ ███ ", + " █ █ █ █ ██ █ █ █ █ █ █ █ █ ", + " █████ █ █ ████ █ █ █ █ █ ████ ████ █ █ ", + " █ █ █ █ █ █ ██ █ █ █ █ █ █ █ ", + " █ █ ███ ████ █ █ █ █████ ████ █ █ ███ ", +] def _ease_out_cubic(t: float) -> float: - """Easing function for smooth deceleration.""" return 1.0 - (1.0 - t) ** 3 def _rgb(r: int, g: int, b: int) -> str: - """Generate ANSI true color escape sequence.""" return f"\033[38;2;{r};{g};{b}m" -def _gradient_color(progress: float, row: int, total_rows: int) -> str: - """Generate color based on position. - - Brighter at the left/center of the A, fading toward edges and trails. - Vertical gradient: brightest in middle rows. - """ - # Vertical brightness (center rows brightest) - center = total_rows / 2 - vert_factor = 1.0 - abs(row - center) / center * 0.4 - - # Horizontal brightness (left = bright, right = dim for trail) - horiz_factor = 1.0 - progress * 0.6 - - brightness = vert_factor * horiz_factor - - # Blue-white color scheme - r = int(180 + 75 * brightness) - g = int(190 + 65 * brightness) - b = int(220 + 35 * brightness) - - return _rgb(min(r, 255), min(g, 255), min(b, 255)) - - -def _build_row( - solid_width: int, - gap_start: int, - gap_width: int, - trail_length: int, - row_idx: int, - total_rows: int, -) -> list[tuple[str, str]]: - """Build a row as list of (character, color) tuples.""" - result = [] - - # The solid A part - if gap_start > 0 and gap_width > 0: - # Row with hole: left side, gap, right side - left_side = gap_start - right_side = solid_width - gap_start - gap_width - - # Left part of A - for i in range(left_side): - progress = i / solid_width - color = _gradient_color(progress, row_idx, total_rows) - result.append((_BLOCK, color)) - - # Gap (internal triangle) - for _ in range(gap_width): - result.append((" ", "")) - - # Right part of A - for i in range(right_side): - progress = (gap_start + gap_width + i) / solid_width - color = _gradient_color(progress, row_idx, total_rows) - result.append((_BLOCK, color)) +def _get_color(row: int, total_rows: int) -> str: + """Cyan-to-blue gradient.""" + progress = row / max(1, total_rows - 1) + r = int(0 + 30 * progress) + g = int(255 - 155 * progress) + b = int(255 - 55 * progress) + return _rgb(r, g, b) + + +def _build_logo_row(row_def: tuple) -> str: + """Build logo row as string.""" + if len(row_def) == 3: + left, gap, right = row_def + return _BLOCK * left + " " * gap + _BLOCK * right else: - # Solid row (no hole) - for i in range(solid_width): - progress = i / solid_width - color = _gradient_color(progress, row_idx, total_rows) - result.append((_BLOCK, color)) - - # Trail extending right (the slat/blinds effect) - for i in range(trail_length): - progress = (solid_width + i) / (solid_width + trail_length) - # Fade out the trail - fade = 1.0 - (i / trail_length) ** 0.5 - color = _gradient_color(progress * 1.5, row_idx, total_rows) - - # Use lighter blocks for trail fade - if fade > 0.7: - result.append((_BLOCK, color)) - elif fade > 0.4: - result.append(("\u2593", color)) # Dark shade - elif fade > 0.2: - result.append(("\u2592", color)) # Medium shade - else: - result.append(("\u2591", color)) # Light shade - - return result - - -def _render_row(chars: list[tuple[str, str]], x_offset: int) -> str: - """Render a row at the given x offset.""" - reset = "\033[0m" - padding = " " * max(0, x_offset) + left, gap1, center, gap2, right = row_def + return _BLOCK * left + " " * gap1 + _BLOCK * center + " " * gap2 + _BLOCK * right - parts = [padding] - for char, color in chars: - if color: - parts.append(f"{color}{char}") - else: - parts.append(char) - parts.append(reset) - return "".join(parts) +def _logo_width(row_def: tuple) -> int: + return sum(row_def) def show_banner() -> None: - """Display the animated A-logo banner. - - Guards: - - Only runs when stdout is a TTY - - Only runs when terminal is wide enough - """ + """Display the banner with logo on top, text below, left-aligned.""" if not sys.stdout.isatty(): return @@ -178,43 +90,75 @@ def show_banner() -> None: clear_line = "\033[2K" hide_cursor = "\033[?25l" show_cursor = "\033[?25h" + reset = "\033[0m" sys.stdout.write(hide_cursor) sys.stdout.write("\n") sys.stdout.flush() - center = term_width // 2 - total_rows = len(_ROWS) + max_logo_width = max(_logo_width(r) for r in _LOGO_ROWS) + total_rows = len(_LOGO_ROWS) + 1 + len(_TEXT_ART) # logo + gap + text try: - for row_idx, (left_margin, solid_width, gap_start, gap_width, trail_length) in enumerate(_ROWS): - # Build the row content - chars = _build_row( - solid_width, gap_start, gap_width, trail_length, - row_idx, total_rows - ) - - # Calculate final position (centered, offset by left_margin) - final_x = center - left_margin - (solid_width // 2) - start_x = term_width + 10 # Start off-screen right - - # Animate: slide in from right - frames = 6 - for frame in range(frames): - t = (frame + 1) / frames + row_counter = 0 + + # Render logo rows + for row_def in _LOGO_ROWS: + color = _get_color(row_counter, total_rows) + logo_str = _build_logo_row(row_def) + + # Center logo within its max width, then left-align + logo_pad = max_logo_width - _logo_width(row_def) + centered_logo = " " * (logo_pad // 2) + logo_str + + line = f"{color}{centered_logo}{reset}" + + # Animate slide-in + start_x = term_width + 5 + final_x = _LEFT_MARGIN + + for frame in range(4): + t = (frame + 1) / 4 eased = _ease_out_cubic(t) cur_x = int(start_x + (final_x - start_x) * eased) + padding = " " * max(0, cur_x) + sys.stdout.write(f"\r{clear_line}{padding}{line}") + sys.stdout.flush() + time.sleep(_FRAME_DELAY) + + padding = " " * _LEFT_MARGIN + sys.stdout.write(f"\r{clear_line}{padding}{line}\n") + sys.stdout.flush() + time.sleep(_STAGGER_DELAY) + row_counter += 1 - line = _render_row(chars, cur_x) - sys.stdout.write(f"\r{clear_line}{line}") + # Gap between logo and text + sys.stdout.write("\n") + row_counter += 1 + + # Render text rows + for text_row in _TEXT_ART: + color = _get_color(row_counter, total_rows) + line = f"{color}{text_row}{reset}" + + # Animate slide-in + start_x = term_width + 5 + final_x = _LEFT_MARGIN + + for frame in range(4): + t = (frame + 1) / 4 + eased = _ease_out_cubic(t) + cur_x = int(start_x + (final_x - start_x) * eased) + padding = " " * max(0, cur_x) + sys.stdout.write(f"\r{clear_line}{padding}{line}") sys.stdout.flush() time.sleep(_FRAME_DELAY) - # Final position - line = _render_row(chars, final_x) - sys.stdout.write(f"\r{clear_line}{line}\n") + padding = " " * _LEFT_MARGIN + sys.stdout.write(f"\r{clear_line}{padding}{line}\n") sys.stdout.flush() time.sleep(_STAGGER_DELAY) + row_counter += 1 time.sleep(_FINAL_PAUSE) diff --git a/a0-cli/src/a0/cli.py b/a0-cli/src/a0/cli.py index a9bb579a8e..f62dc52318 100644 --- a/a0-cli/src/a0/cli.py +++ b/a0-cli/src/a0/cli.py @@ -66,22 +66,13 @@ def main( if ctx.invoked_subcommand is None: # Check if we're in a TTY - menu requires interactive input if not sys.stdin.isatty(): - # Fall back to TUI when not in TTY (e.g., piped input) - from a0.tui.app import AgentZeroTUI - resolved_url, resolved_key = _resolve_config(url, api_key) - tui = AgentZeroTUI( - agent_url=resolved_url, - api_key=resolved_key, - project=project, - cwd=str(Path.cwd()), - ) - tui.run() - return + print("Error: a0 requires an interactive terminal.") + print("Use 'a0 chat \"message\"' for non-interactive use.") + raise typer.Exit(1) from a0.banner import show_banner from a0.launcher import ( run_menu, - launch_tui, launch_repl, show_status, toggle_docker, @@ -89,7 +80,6 @@ def main( ) resolved_url, resolved_key = _resolve_config(url, api_key) - cwd = str(Path.cwd()) # Show animated banner show_banner() @@ -102,16 +92,6 @@ def main( # User pressed Escape - quit break - elif action == "tui": - show_menu_again = launch_tui( - url=resolved_url, - api_key=resolved_key, - project=project, - cwd=cwd, - ) - if not show_menu_again: - break - elif action == "repl": launch_repl( url=resolved_url, diff --git a/a0-cli/src/a0/client/__init__.py b/a0-cli/src/a0/client/__init__.py new file mode 100644 index 0000000000..ade025df5b --- /dev/null +++ b/a0-cli/src/a0/client/__init__.py @@ -0,0 +1,5 @@ +"""Agent Zero API client library.""" + +from a0.client.api import AgentZeroClient + +__all__ = ["AgentZeroClient"] diff --git a/a0-cli/src/a0/client/api.py b/a0-cli/src/a0/client/api.py new file mode 100644 index 0000000000..2c19e0302c --- /dev/null +++ b/a0-cli/src/a0/client/api.py @@ -0,0 +1,162 @@ +"""Agent Zero HTTP API Client. + +Wraps Agent Zero's REST endpoints with proper error handling +and type-safe responses. Uses the /api_* endpoints (API key auth). + +Available API-key endpoints: + /api_message - Send message (auto-creates context) + /api_log_get - Get log items for a context + /api_files_get - Get files from context + /api_reset_chat - Reset chat history + /api_terminate_chat - Terminate a context +""" + +from __future__ import annotations + +from typing import Any, Optional + +import httpx +from pydantic import BaseModel + + +class Attachment(BaseModel): + filename: str + base64: str + + +class MessageResponse(BaseModel): + context_id: str + response: str + + +class LogItem(BaseModel): + no: int + id: Optional[str] = None + type: str + heading: str = "" + content: str = "" + kvps: dict[str, Any] = {} + timestamp: float = 0.0 + agentno: int = 0 + + +class LogResponse(BaseModel): + """Response from /api_log_get.""" + + context_id: str + log: LogData + + +class LogData(BaseModel): + guid: str = "" + total_items: int = 0 + returned_items: int = 0 + start_position: int = 0 + progress: str | int = 0 + progress_active: bool = False + items: list[dict[str, Any]] = [] + + +class AgentZeroClient: + """HTTP client for Agent Zero API (API-key authenticated endpoints only).""" + + def __init__( + self, + base_url: str = "http://localhost:55080", # change from 8080 + api_key: str | None = None, + timeout: float = 300.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=timeout, + headers=self._headers(), + ) + + def _headers(self) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["X-API-KEY"] = self.api_key + return headers + + async def send_message( + self, + message: str, + *, + context_id: str = "", + attachments: list[Attachment] | None = None, + project_name: str | None = None, + agent_profile: str | None = None, + ) -> MessageResponse: + """Send a message via /api_message. + + Auto-creates a new context if context_id is empty. + Blocks until the agent has finished responding. + """ + payload: dict[str, Any] = { + "message": message, + "context_id": context_id, + "attachments": [a.model_dump() for a in (attachments or [])], + } + if project_name: + payload["project_name"] = project_name + if agent_profile: + payload["agent_profile"] = agent_profile + + response = await self._client.post("/api_message", json=payload) + response.raise_for_status() + return MessageResponse.model_validate(response.json()) + + async def get_logs( + self, + context_id: str, + length: int = 100, + ) -> LogResponse: + """Get log items for a context via /api_log_get.""" + response = await self._client.post( + "/api_log_get", + json={"context_id": context_id, "length": length}, + ) + response.raise_for_status() + return LogResponse.model_validate(response.json()) + + async def get_files( + self, + context_id: str, + filenames: list[str], + ) -> dict[str, str]: + """Retrieve files from context via /api_files_get.""" + response = await self._client.post( + "/api_files_get", + json={"context_id": context_id, "filenames": filenames}, + ) + response.raise_for_status() + return response.json().get("files", {}) + + async def reset_chat(self, context_id: str) -> bool: + """Reset chat history via /api_reset_chat.""" + response = await self._client.post( + "/api_reset_chat", + json={"context_id": context_id}, + ) + return response.is_success + + async def terminate_chat(self, context_id: str) -> bool: + """Terminate a context via /api_terminate_chat.""" + response = await self._client.post( + "/api_terminate_chat", + json={"context_id": context_id}, + ) + return response.is_success + + async def health(self) -> bool: + """Check if Agent Zero is running.""" + try: + response = await self._client.get("/") + return response.is_success + except httpx.ConnectError: + return False + + async def close(self) -> None: + await self._client.aclose() diff --git a/a0-cli/src/a0/client/poller.py b/a0-cli/src/a0/client/poller.py new file mode 100644 index 0000000000..24db2030d6 --- /dev/null +++ b/a0-cli/src/a0/client/poller.py @@ -0,0 +1,111 @@ +"""Agent Zero log poller. + +Uses /api_log_get to fetch log items and track changes +via log guid (detects context resets). +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any, Callable + +from a0.client.api import AgentZeroClient, LogItem, LogResponse + + +@dataclass +class PollState: + """Tracks polling state for a context.""" + + context_id: str + last_total: int = 0 + log_guid: str = "" + + +@dataclass +class PollEvent: + """A single event from the poll loop.""" + + logs: list[dict[str, Any]] = field(default_factory=list) + progress: str | int = 0 + progress_active: bool = False + context_reset: bool = False + + +class Poller: + """Polls /api_log_get for new log items. + + Usage: + async for event in poller.stream(context_id): + for log in event.logs: + print(log["type"], log["content"]) + """ + + def __init__( + self, + client: AgentZeroClient, + interval: float = 0.5, + ) -> None: + self.client = client + self.interval = interval + self._states: dict[str, PollState] = {} + self._stop = False + + def stop(self) -> None: + self._stop = True + + async def poll_once(self, context_id: str) -> PollEvent: + """Execute a single poll and return new items.""" + state = self._states.setdefault(context_id, PollState(context_id=context_id)) + + response = await self.client.get_logs(context_id=context_id, length=200) + log = response.log + + context_reset = False + if log.guid and log.guid != state.log_guid: + if state.log_guid: # not first poll + context_reset = True + state.last_total = 0 + state.log_guid = log.guid + + # Only return items we haven't seen + new_items = [] + if log.total_items > state.last_total: + # Items are returned newest-first from start_position + # We want only items after our last known total + skip = state.last_total - log.start_position + if skip < 0: + skip = 0 + new_items = log.items[skip:] + + state.last_total = log.total_items + + return PollEvent( + logs=new_items, + progress=log.progress, + progress_active=log.progress_active, + context_reset=context_reset, + ) + + async def stream( + self, + context_id: str, + stop_when: Callable[[PollEvent], bool] | None = None, + ) -> AsyncIterator[PollEvent]: + """Stream poll events for a context.""" + self._stop = False + + while not self._stop: + try: + event = await self.poll_once(context_id) + except Exception: + await asyncio.sleep(self.interval) + continue + + yield event + + if stop_when and stop_when(event): + break + + await asyncio.sleep(self.interval) diff --git a/a0-cli/src/a0/launcher/__init__.py b/a0-cli/src/a0/launcher/__init__.py index df48eff554..897c9d0251 100644 --- a/a0-cli/src/a0/launcher/__init__.py +++ b/a0-cli/src/a0/launcher/__init__.py @@ -5,7 +5,6 @@ from .menu import run_menu from .actions import ( - launch_tui, launch_repl, show_status, toggle_docker, @@ -15,7 +14,6 @@ __all__ = [ "run_menu", - "launch_tui", "launch_repl", "show_status", "toggle_docker", diff --git a/a0-cli/src/a0/launcher/actions.py b/a0-cli/src/a0/launcher/actions.py index b80f0defdc..1a9f202ada 100644 --- a/a0-cli/src/a0/launcher/actions.py +++ b/a0-cli/src/a0/launcher/actions.py @@ -1,6 +1,6 @@ """Action handlers for the launcher menu. -Each action corresponds to a menu option: TUI, REPL, Status, Docker, Settings. +Each action corresponds to a menu option: Chat, Status, Docker, Settings. """ from __future__ import annotations @@ -94,59 +94,13 @@ def _wait_for_ready( return False -def launch_tui( - url: str, - api_key: str | None, - project: str | None, - cwd: str, - compose_file: Path | None = None, -) -> bool: - """Launch the TUI interface. - - Args: - url: Agent Zero URL - api_key: Optional API key - project: Optional project name - cwd: Current working directory - compose_file: Optional Docker compose file path - - Returns: - True if user wants to return to menu, False to quit entirely. - """ - console = Console() - - # Check if Agent Zero is running - if not check_health(url, api_key): - if _prompt_auto_start(console): - if not _start_docker_with_progress(console, compose_file): - return True # Return to menu - if not _wait_for_ready(console, url, api_key): - return True # Return to menu - else: - return True # Return to menu - - # Launch TUI - from a0.tui.app import AgentZeroTUI - - tui = AgentZeroTUI( - agent_url=url, - api_key=api_key, - project=project, - cwd=cwd, - ) - result = tui.run() - - # Return True if user wants to go back to menu - return result == "menu" - - def launch_repl( url: str, api_key: str | None, project: str | None, compose_file: Path | None = None, ) -> None: - """Launch the REPL interface. + """Launch the REPL interface with real-time progress. Args: url: Agent Zero URL @@ -166,20 +120,29 @@ def launch_repl( else: return - # Launch REPL (reuse existing implementation from cli.py) - from rich.markdown import Markdown as RichMarkdown + from rich.live import Live + from rich.spinner import Spinner + from rich.markdown import Markdown + from rich.text import Text from a0.client.api import AgentZeroClient - from a0.client.poller import Poller + + # Custom markdown with left-aligned headers + class LeftAlignedMarkdown(Markdown): + """Markdown with left-aligned headers.""" + def __init__(self, markup: str, **kwargs) -> None: + super().__init__(markup, justify="left", **kwargs) async def _repl() -> None: client = AgentZeroClient(url, api_key) ctx_id = "" console.print("[dim]Type /exit or Ctrl-D to quit[/dim]") + console.print() + try: while True: try: - user_input = console.input("[bold]> [/bold]") + user_input = console.input("[bold cyan]>[/bold cyan] ") except (EOFError, KeyboardInterrupt): console.print() break @@ -190,23 +153,70 @@ async def _repl() -> None: if stripped in ("/exit", "/quit", "/menu"): break - # Send message - response = await client.send_message( - message=user_input, - context_id=ctx_id, - project_name=project, - ) - console.print(RichMarkdown(response.response)) - ctx_id = response.context_id - - # Follow activity - poller = Poller(client, interval=0.5) - async for event in poller.stream( - ctx_id, - stop_when=lambda e: not e.progress_active and not e.logs, - ): - for log in event.logs: - _print_log(console, log) + # Send message with real-time progress display + try: + response_task = asyncio.create_task( + client.send_message( + message=user_input, + context_id=ctx_id, + project_name=project, + ) + ) + + # Poll for real-time progress while waiting + status_text = "Thinking..." + last_log_count = 0 + + with Live( + Spinner("dots", text=Text(status_text, style="cyan")), + console=console, + refresh_per_second=10, + transient=True, + ) as live: + while not response_task.done(): + # Try to get current logs if we have a context + if ctx_id: + try: + logs = await client.get_logs(ctx_id, length=50) + if logs.log.items: + new_items = logs.log.items[last_log_count:] + for item in new_items: + log_type = item.get("type", "") + heading = item.get("heading", "") + if log_type == "agent": + status_text = f"Thinking: {heading[:40]}..." if heading else "Thinking..." + elif log_type == "tool": + tool = item.get("kvps", {}).get("tool_name", "tool") + status_text = f"Using {tool}..." + elif log_type == "code_exe": + status_text = "Running code..." + elif log_type == "browser": + status_text = "Browsing web..." + elif log_type == "progress": + status_text = heading or "Working..." + last_log_count = len(logs.log.items) + live.update(Spinner("dots", text=Text(status_text, style="cyan"))) + except Exception: + pass # Ignore polling errors + + await asyncio.sleep(0.3) + + response = await response_task + ctx_id = response.context_id + + # Print response + console.print() + console.print(LeftAlignedMarkdown(response.response)) + console.print() + + except Exception as e: + err_msg = str(e) + if "401" in err_msg or "Unauthorized" in err_msg: + console.print("[red]Authentication required.[/red]") + console.print("[dim]Set API key: a0 config --set api_key=YOUR_KEY[/dim]") + else: + console.print(f"[red]Error: {err_msg}[/red]") + console.print() finally: await client.close() diff --git a/a0-cli/src/a0/launcher/menu.py b/a0-cli/src/a0/launcher/menu.py index 33e5d7515e..6bb851fc66 100644 --- a/a0-cli/src/a0/launcher/menu.py +++ b/a0-cli/src/a0/launcher/menu.py @@ -1,7 +1,7 @@ """Interactive launcher menu for a0 CLI. Displays a menu after the banner animation, allowing users to select -between different modes: TUI, REPL, Status, Docker, Settings. +between different modes: Chat, Status, Docker, Settings. """ from __future__ import annotations @@ -25,11 +25,10 @@ class MenuItem(NamedTuple): MENU_ITEMS: list[MenuItem] = [ - MenuItem("1", "tui", "Chat (TUI)", "Full terminal interface"), - MenuItem("2", "repl", "Chat (REPL)", "Simple text chat"), - MenuItem("3", "status", "Status", "Check Agent Zero"), - MenuItem("4", "docker", "Start/Stop", "Docker container"), - MenuItem("5", "settings", "Settings", "Configure a0"), + MenuItem("1", "repl", "Chat", "Start chatting"), + MenuItem("2", "status", "Status", "Check Agent Zero"), + MenuItem("3", "docker", "Start/Stop", "Docker container"), + MenuItem("4", "settings", "Settings", "Configure a0"), ] @@ -94,15 +93,17 @@ def _render_menu(console: Console, selected: int) -> None: def _clear_menu(console: Console, line_count: int) -> None: """Move cursor up and clear the menu lines.""" + import sys for _ in range(line_count): - console.print("\033[A\033[2K", end="") + sys.stdout.write("\033[A\033[2K") + sys.stdout.flush() def run_menu() -> str | None: """Display interactive menu and return the selected action. Returns: - The action string (e.g., "tui", "repl") or None if user pressed Escape. + The action string (e.g., "repl", "status") or None if user pressed Escape. """ console = Console() selected = 0 @@ -136,7 +137,7 @@ def run_menu() -> str | None: return MENU_ITEMS[selected].action # Handle number keys - elif key in "12345": + elif key in "1234": idx = int(key) - 1 if 0 <= idx < len(MENU_ITEMS): _clear_menu(console, menu_lines) diff --git a/a0-cli/src/a0/utils/__init__.py b/a0-cli/src/a0/utils/__init__.py new file mode 100644 index 0000000000..ae5ae00450 --- /dev/null +++ b/a0-cli/src/a0/utils/__init__.py @@ -0,0 +1 @@ +"""Utility modules for Agent Zero CLI.""" diff --git a/a0-cli/src/a0/utils/config.py b/a0-cli/src/a0/utils/config.py new file mode 100644 index 0000000000..87408c2685 --- /dev/null +++ b/a0-cli/src/a0/utils/config.py @@ -0,0 +1,81 @@ +"""Configuration management. + +Loads config from (in priority order): +1. Environment variables +2. Project-level .a0.toml +3. User config ~/.config/a0/config.toml +4. Defaults +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Optional + +from pydantic import BaseModel, Field + +try: + import tomllib +except ModuleNotFoundError: + import tomli as tomllib # type: ignore[no-redef] + +import tomli_w + + +class Config(BaseModel): + """Application configuration.""" + + # Agent Zero connection + agent_url: str = Field(default="http://localhost:55080") # change from 8080 + api_key: Optional[str] = Field(default=None) + + # Display settings + theme: str = Field(default="dark") + + # Shell settings + shell: str = Field(default_factory=lambda: os.environ.get("SHELL", "/bin/bash")) + + # File settings + max_file_size: int = Field(default=1_000_000) + + # ACP settings + acp_auto_approve: bool = Field(default=False) + + @classmethod + def load(cls) -> Config: + """Load configuration from files and environment.""" + config_data: dict[str, Any] = {} + + # User config + user_path = Path.home() / ".config" / "a0" / "config.toml" + if user_path.exists(): + with open(user_path, "rb") as f: + config_data.update(tomllib.load(f)) + + # Project config + project_path = Path.cwd() / ".a0.toml" + if project_path.exists(): + with open(project_path, "rb") as f: + config_data.update(tomllib.load(f)) + + # Environment overrides + env_mapping = { + "AGENT_ZERO_URL": "agent_url", + "AGENT_ZERO_API_KEY": "api_key", + "A0_THEME": "theme", + } + for env_var, config_key in env_mapping.items(): + val = os.environ.get(env_var) + if val is not None: + config_data[config_key] = val + + return cls(**config_data) + + def save(self) -> None: + """Save configuration to user config file.""" + user_path = Path.home() / ".config" / "a0" / "config.toml" + user_path.parent.mkdir(parents=True, exist_ok=True) + data = self.model_dump(exclude_none=True) + with open(user_path, "wb") as f: + tomli_w.dump(data, f) diff --git a/a0-cli/src/a0/utils/docker.py b/a0-cli/src/a0/utils/docker.py new file mode 100644 index 0000000000..0f9c718bfa --- /dev/null +++ b/a0-cli/src/a0/utils/docker.py @@ -0,0 +1,79 @@ +"""Docker compose helpers for Agent Zero.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from rich.console import Console + +console = Console() + + +def _find_compose_file() -> Path | None: + """Find docker-compose file in common locations.""" + import os + + candidates = [ + # Current directory + Path.cwd() / "docker-compose.yml", + Path.cwd() / "docker-compose.yaml", + Path.cwd() / "docker" / "run" / "docker-compose.yml", + ] + + # Check AGENT_ZERO_DIR env var + az_dir = os.environ.get("AGENT_ZERO_DIR") + if az_dir: + az_path = Path(az_dir) + candidates.append(az_path / "docker" / "run" / "docker-compose.yml") + candidates.append(az_path / "docker-compose.yml") + + # Common install locations + candidates.extend([ + Path.home() / "agent-zero-dev" / "docker" / "run" / "docker-compose.yml", + Path.home() / "agent-zero" / "docker" / "run" / "docker-compose.yml", + ]) + + for path in candidates: + if path.exists(): + return path + return None + + +def start_agent_zero( + detach: bool = True, + compose_file: Path | None = None, +) -> None: + """Start Agent Zero via docker compose.""" + compose_file = compose_file or _find_compose_file() + if not compose_file: + console.print("[red]No docker-compose file found.[/red]") + console.print("Provide one with --file or run from the agent-zero directory.") + return + + cmd = ["docker", "compose", "-f", str(compose_file), "up"] + if detach: + cmd.append("-d") + + console.print(f"Starting Agent Zero from {compose_file}...") + result = subprocess.run(cmd, capture_output=not detach) + if result.returncode == 0 and detach: + console.print("[green]Agent Zero started.[/green]") + elif result.returncode != 0: + console.print(f"[red]Failed to start (exit {result.returncode}).[/red]") + + +def stop_agent_zero(compose_file: Path | None = None) -> None: + """Stop Agent Zero via docker compose.""" + compose_file = compose_file or _find_compose_file() + if not compose_file: + console.print("[red]No docker-compose file found.[/red]") + return + + cmd = ["docker", "compose", "-f", str(compose_file), "down"] + console.print("Stopping Agent Zero...") + result = subprocess.run(cmd, capture_output=True) + if result.returncode == 0: + console.print("[green]Agent Zero stopped.[/green]") + else: + console.print(f"[red]Failed to stop (exit {result.returncode}).[/red]") diff --git a/a0-cli/tests/__init__.py b/a0-cli/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/a0-cli/tests/test_acp.py b/a0-cli/tests/test_acp.py new file mode 100644 index 0000000000..7e0387297b --- /dev/null +++ b/a0-cli/tests/test_acp.py @@ -0,0 +1,114 @@ +"""Tests for the ACP protocol adapter.""" + +import pytest + +from a0.acp.server import ACPServer, ACPSession + + +class TestACPInitialize: + """Test ACP initialization handshake.""" + + @pytest.fixture + def server(self): + return ACPServer(agent_url="http://localhost:55080") + + @pytest.mark.asyncio + async def test_initialize_returns_capabilities(self, server): + result = await server._handle_initialize( + {"protocolVersion": 1, "clientCapabilities": {}} + ) + assert result["protocolVersion"] == 1 + assert "agentCapabilities" in result + assert result["agentInfo"]["name"] == "agent-zero" + + @pytest.mark.asyncio + async def test_version_negotiation_higher(self, server): + result = await server._handle_initialize( + {"protocolVersion": 99, "clientCapabilities": {}} + ) + assert result["protocolVersion"] == ACPServer.PROTOCOL_VERSION + + @pytest.mark.asyncio + async def test_version_negotiation_lower(self, server): + result = await server._handle_initialize( + {"protocolVersion": 0, "clientCapabilities": {}} + ) + assert result["protocolVersion"] == 0 + + +class TestACPSession: + """Test ACP session model.""" + + def test_session_creation(self): + session = ACPSession( + session_id="sess_123", + context_id="ctx_abc", + cwd="/home/user", + ) + assert session.session_id == "sess_123" + assert session.mcp_servers == [] + + def test_tool_id_counter(self): + session = ACPSession( + session_id="s1", context_id="c1", cwd="." + ) + assert session.next_tool_id() == "call_0001" + assert session.next_tool_id() == "call_0002" + + +class TestPromptConversion: + """Test ACP content block conversion.""" + + @pytest.fixture + def server(self): + return ACPServer() + + def test_text_only(self, server): + blocks = [{"type": "text", "text": "Hello world"}] + text, attachments = server._convert_prompt(blocks) + assert text == "Hello world" + assert attachments == [] + + def test_multiple_text(self, server): + blocks = [ + {"type": "text", "text": "Part 1"}, + {"type": "text", "text": "Part 2"}, + ] + text, _ = server._convert_prompt(blocks) + assert "Part 1" in text + assert "Part 2" in text + + def test_resource_text(self, server): + blocks = [ + { + "type": "resource", + "resource": { + "uri": "file:///main.py", + "text": "print('hi')", + }, + } + ] + text, attachments = server._convert_prompt(blocks) + assert "main.py" in text + assert "print('hi')" in text + assert attachments == [] + + def test_resource_blob(self, server): + blocks = [ + { + "type": "resource", + "resource": { + "uri": "file:///image.png", + "blob": "iVBORw0KGgo=", + }, + } + ] + text, attachments = server._convert_prompt(blocks) + assert len(attachments) == 1 + assert attachments[0]["filename"] == "image.png" + + def test_image_block(self, server): + blocks = [{"type": "image", "data": "base64data=="}] + text, attachments = server._convert_prompt(blocks) + assert len(attachments) == 1 + assert attachments[0]["base64"] == "base64data==" diff --git a/a0-cli/tests/test_client.py b/a0-cli/tests/test_client.py new file mode 100644 index 0000000000..5f1fe6bb74 --- /dev/null +++ b/a0-cli/tests/test_client.py @@ -0,0 +1,93 @@ +"""Tests for the Agent Zero client library.""" + +from a0.client.api import ( + AgentZeroClient, + Attachment, + LogData, + LogItem, + LogResponse, + MessageResponse, +) +from a0.client.poller import Poller, PollEvent, PollState + + +class TestModels: + """Test Pydantic model validation.""" + + def test_message_response(self): + r = MessageResponse(context_id="ctx_123", response="Hello!") + assert r.context_id == "ctx_123" + assert r.response == "Hello!" + + def test_log_item_defaults(self): + log = LogItem(no=0, type="agent") + assert log.heading == "" + assert log.content == "" + assert log.kvps == {} + assert log.agentno == 0 + + def test_log_item_full(self): + log = LogItem( + no=1, + id="log_001", + type="tool", + heading="Running code", + content="print('hello')", + kvps={"tool_name": "code_execution", "runtime": "python"}, + timestamp=1700000000.0, + agentno=0, + ) + assert log.kvps["tool_name"] == "code_execution" + + def test_log_response(self): + r = LogResponse( + context_id="ctx_1", + log=LogData(guid="g1", total_items=5, returned_items=5, items=[]), + ) + assert r.log.guid == "g1" + assert r.log.total_items == 5 + + def test_log_data_defaults(self): + d = LogData() + assert d.items == [] + assert d.progress_active is False + + def test_attachment(self): + a = Attachment(filename="test.txt", base64="dGVzdA==") + assert a.filename == "test.txt" + + +class TestPollState: + """Test poll state tracking.""" + + def test_initial_state(self): + state = PollState(context_id="ctx_1") + assert state.last_total == 0 + assert state.log_guid == "" + + def test_poll_event_defaults(self): + event = PollEvent() + assert event.logs == [] + assert event.context_reset is False + + +class TestClientInit: + """Test client initialization.""" + + def test_default_url(self): + client = AgentZeroClient() + assert client.base_url == "http://localhost:55080" + + def test_custom_url(self): + client = AgentZeroClient(base_url="http://example.com:9090/") + assert client.base_url == "http://example.com:9090" + + def test_headers_without_api_key(self): + client = AgentZeroClient() + headers = client._headers() + assert "X-API-KEY" not in headers + + def test_headers_with_api_key(self): + client = AgentZeroClient(api_key="test-key-123") + headers = client._headers() + assert headers["X-API-KEY"] == "test-key-123" diff --git a/a0-cli/uv.lock b/a0-cli/uv.lock new file mode 100644 index 0000000000..4d038b6c20 --- /dev/null +++ b/a0-cli/uv.lock @@ -0,0 +1,488 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "a0-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "textual" }, + { name = "tomli-w" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pydantic", specifier = ">=2.6.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "rich", specifier = ">=13.7.0" }, + { name = "textual", specifier = ">=0.75.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" }, + { name = "tomli-w", specifier = ">=1.0.0" }, + { name = "typer", specifier = ">=0.12.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143, upload-time = "2026-02-01T16:20:47.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963, upload-time = "2026-02-01T16:20:46.078Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "textual" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/38/7d169a765993efde5095c70a668bf4f5831bb7ac099e932f2783e9b71abf/textual-7.5.0.tar.gz", hash = "sha256:c730cba1e3d704e8f1ca915b6a3af01451e3bca380114baacf6abf87e9dac8b6", size = 1592319, upload-time = "2026-01-30T13:46:39.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typer" +version = "0.21.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/bf/8825b5929afd84d0dabd606c67cd57b8388cb3ec385f7ef19c5cc2202069/typer-0.21.1.tar.gz", hash = "sha256:ea835607cd752343b6b2b7ce676893e5a0324082268b48f27aa058bdb7d2145d", size = 110371, upload-time = "2026-01-06T11:21:10.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, +] diff --git a/requirements.txt b/requirements.txt index e1aeb31f70..b56cd512f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ python-dotenv==1.1.0 pytz==2024.2 sentence-transformers==3.0.1 tiktoken==0.8.0 +textual==7.5.0 unstructured[all-docs]==0.16.23 unstructured-client==0.31.0 webcolors==24.6.0