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/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. 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) 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 new file mode 100644 index 0000000000..7d256a9242 --- /dev/null +++ b/a0-cli/src/a0/banner.py @@ -0,0 +1,168 @@ +"""Animated ANSI banner for the a0 CLI. + +Renders the Agent Zero logo with "AGENT ZERO" text below, +aligned to the left side with cyan-to-blue gradient. +""" + +from __future__ import annotations + +import shutil +import sys +import time + +_BLOCK = "\u2588" # Full block + +# Animation tuning +_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: + return 1.0 - (1.0 - t) ** 3 + + +def _rgb(r: int, g: int, b: int) -> str: + return f"\033[38;2;{r};{g};{b}m" + + +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: + left, gap1, center, gap2, right = row_def + return _BLOCK * left + " " * gap1 + _BLOCK * center + " " * gap2 + _BLOCK * right + + +def _logo_width(row_def: tuple) -> int: + return sum(row_def) + + +def show_banner() -> None: + """Display the banner with logo on top, text below, left-aligned.""" + 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" + reset = "\033[0m" + + sys.stdout.write(hide_cursor) + sys.stdout.write("\n") + sys.stdout.flush() + + 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: + 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 + + # 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) + + 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) + + 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 new file mode 100644 index 0000000000..f62dc52318 --- /dev/null +++ b/a0-cli/src/a0/cli.py @@ -0,0 +1,332 @@ +"""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 +import sys +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: + # Check if we're in a TTY - menu requires interactive input + if not sys.stdin.isatty(): + 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_repl, + show_status, + toggle_docker, + open_settings, + ) + + resolved_url, resolved_key = _resolve_config(url, api_key) + + # Show animated banner + show_banner() + + # Run menu loop + while True: + action = run_menu() + + if action is None: + # User pressed Escape - quit + 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/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 new file mode 100644 index 0000000000..897c9d0251 --- /dev/null +++ b/a0-cli/src/a0/launcher/__init__.py @@ -0,0 +1,22 @@ +"""Launcher module for a0 CLI. + +Provides an interactive menu that appears after the banner animation. +""" + +from .menu import run_menu +from .actions import ( + launch_repl, + show_status, + toggle_docker, + open_settings, + check_health, +) + +__all__ = [ + "run_menu", + "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..1a9f202ada --- /dev/null +++ b/a0-cli/src/a0/launcher/actions.py @@ -0,0 +1,354 @@ +"""Action handlers for the launcher menu. + +Each action corresponds to a menu option: Chat, 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_repl( + url: str, + api_key: str | None, + project: str | None, + compose_file: Path | None = None, +) -> None: + """Launch the REPL interface with real-time progress. + + 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 + + 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 + + # 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 cyan]>[/bold cyan] ") + except (EOFError, KeyboardInterrupt): + console.print() + break + + stripped = user_input.strip() + if not stripped: + continue + if stripped in ("/exit", "/quit", "/menu"): + break + + # 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() + + 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..6bb851fc66 --- /dev/null +++ b/a0-cli/src/a0/launcher/menu.py @@ -0,0 +1,151 @@ +"""Interactive launcher menu for a0 CLI. + +Displays a menu after the banner animation, allowing users to select +between different modes: Chat, Status, Docker, Settings. +""" + +from __future__ import annotations + +import select +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", "repl", "Chat", "Start chatting"), + MenuItem("2", "status", "Status", "Check Agent Zero"), + MenuItem("3", "docker", "Start/Stop", "Docker container"), + MenuItem("4", "settings", "Settings", "Configure a0"), +] + + +def _get_key() -> str: + """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: + tty.setraw(fd) + ch = sys.stdin.read(1) + + # Handle escape sequences (arrow keys) + if ch == "\x1b": + # 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: + 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.""" + import sys + for _ in range(line_count): + 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., "repl", "status") 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 == "escape" 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 "1234": + 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") 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