From d3342ab46667b9a822e0942d206111ea44734c37 Mon Sep 17 00:00:00 2001 From: ajspig Date: Wed, 11 Mar 2026 12:51:39 -0400 Subject: [PATCH] feat: adding honcho-cli package --- honcho-cli/README.md | 90 ++++ honcho-cli/pyproject.toml | 32 ++ honcho-cli/src/honcho_cli/__init__.py | 3 + .../src/honcho_cli/commands/__init__.py | 0 .../src/honcho_cli/commands/conclusion.py | 181 ++++++++ .../src/honcho_cli/commands/config_cmd.py | 54 +++ .../src/honcho_cli/commands/describe.py | 188 ++++++++ honcho-cli/src/honcho_cli/commands/key.py | 115 +++++ honcho-cli/src/honcho_cli/commands/message.py | 92 ++++ honcho-cli/src/honcho_cli/commands/peer.py | 153 +++++++ honcho-cli/src/honcho_cli/commands/session.py | 170 ++++++++ .../src/honcho_cli/commands/workspace.py | 189 ++++++++ honcho-cli/src/honcho_cli/common.py | 42 ++ honcho-cli/src/honcho_cli/config.py | 96 +++++ honcho-cli/src/honcho_cli/main.py | 103 +++++ honcho-cli/src/honcho_cli/output.py | 120 ++++++ honcho-cli/src/honcho_cli/skills/CONTEXT.md | 45 ++ .../src/honcho_cli/skills/honcho-admin.md | 40 ++ .../src/honcho_cli/skills/honcho-debug.md | 55 +++ .../src/honcho_cli/skills/honcho-inspect.md | 53 +++ honcho-cli/src/honcho_cli/validation.py | 61 +++ honcho-cli/tests/__init__.py | 0 honcho-cli/tests/test_config.py | 57 +++ honcho-cli/tests/test_output.py | 55 +++ honcho-cli/tests/test_validation.py | 77 ++++ honcho-cli/uv.lock | 407 ++++++++++++++++++ 26 files changed, 2478 insertions(+) create mode 100644 honcho-cli/README.md create mode 100644 honcho-cli/pyproject.toml create mode 100644 honcho-cli/src/honcho_cli/__init__.py create mode 100644 honcho-cli/src/honcho_cli/commands/__init__.py create mode 100644 honcho-cli/src/honcho_cli/commands/conclusion.py create mode 100644 honcho-cli/src/honcho_cli/commands/config_cmd.py create mode 100644 honcho-cli/src/honcho_cli/commands/describe.py create mode 100644 honcho-cli/src/honcho_cli/commands/key.py create mode 100644 honcho-cli/src/honcho_cli/commands/message.py create mode 100644 honcho-cli/src/honcho_cli/commands/peer.py create mode 100644 honcho-cli/src/honcho_cli/commands/session.py create mode 100644 honcho-cli/src/honcho_cli/commands/workspace.py create mode 100644 honcho-cli/src/honcho_cli/common.py create mode 100644 honcho-cli/src/honcho_cli/config.py create mode 100644 honcho-cli/src/honcho_cli/main.py create mode 100644 honcho-cli/src/honcho_cli/output.py create mode 100644 honcho-cli/src/honcho_cli/skills/CONTEXT.md create mode 100644 honcho-cli/src/honcho_cli/skills/honcho-admin.md create mode 100644 honcho-cli/src/honcho_cli/skills/honcho-debug.md create mode 100644 honcho-cli/src/honcho_cli/skills/honcho-inspect.md create mode 100644 honcho-cli/src/honcho_cli/validation.py create mode 100644 honcho-cli/tests/__init__.py create mode 100644 honcho-cli/tests/test_config.py create mode 100644 honcho-cli/tests/test_output.py create mode 100644 honcho-cli/tests/test_validation.py create mode 100644 honcho-cli/uv.lock diff --git a/honcho-cli/README.md b/honcho-cli/README.md new file mode 100644 index 00000000..57372ee9 --- /dev/null +++ b/honcho-cli/README.md @@ -0,0 +1,90 @@ +# honcho-cli + +Agent-first admin & debugging CLI for [Honcho](https://honcho.dev). + +## Install + +```bash +pip install honcho-cli +# or +uv pip install honcho-cli +``` + +## Quick Start + +```bash +# Configure +honcho config init + +# Or set values directly +honcho config set base_url https://api.honcho.dev +honcho config set api_key +honcho config set workspace_id + +# Inspect +honcho workspace inspect +honcho peer list +honcho peer inspect +honcho session messages --last 20 + +# Debug +honcho peer card +honcho conclusion search "topic" --observer +honcho workspace queue-status + +# Admin +honcho key generate --workspace my-ws --expires 30d +honcho workspace delete --yes +``` + +## Agent Usage + +All commands output JSON when stdout isn't a TTY, or when `--json` is forced: + +```bash +honcho peer list --json +honcho workspace inspect --json | jq '.peers' +``` + +Errors are structured: + +```json +{ + "error": { + "code": "PEER_NOT_FOUND", + "message": "Peer 'abc' not found in workspace 'my-ws'", + "details": {"workspace_id": "my-ws", "peer_id": "abc"} + } +} +``` + +## Context Threading + +Set defaults to avoid repeating IDs: + +```bash +honcho config set peer_id peer_abc123 +honcho peer inspect # uses default +honcho peer card # uses default +honcho peer inspect other_id # positional arg overrides +``` + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `HONCHO_BASE_URL` | API base URL | +| `HONCHO_API_KEY` | Admin JWT | +| `HONCHO_WORKSPACE_ID` | Default workspace | +| `HONCHO_PEER_ID` | Default peer | +| `HONCHO_SESSION_ID` | Default session | + +## Global Flags + +| Flag | Description | +|------|-------------| +| `--json` | Force JSON output | +| `--quiet` / `-q` | Suppress status messages | +| `--workspace` / `-w` | Override workspace ID | +| `--peer` / `-p` | Override peer ID | +| `--session` / `-s` | Override session ID | diff --git a/honcho-cli/pyproject.toml b/honcho-cli/pyproject.toml new file mode 100644 index 00000000..6abcd698 --- /dev/null +++ b/honcho-cli/pyproject.toml @@ -0,0 +1,32 @@ +[project] +name = "honcho-cli" +version = "0.1.0" +description = "Agent-first admin & debugging CLI for Honcho" +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +dependencies = [ + "typer>=0.15.0", + "honcho-ai>=0.1.0", + "rich>=13.0.0", + "httpx>=0.27.0", +] + +[project.scripts] +honcho = "honcho_cli.main:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/honcho_cli"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-mock>=3.14.0", +] diff --git a/honcho-cli/src/honcho_cli/__init__.py b/honcho-cli/src/honcho_cli/__init__.py new file mode 100644 index 00000000..62149650 --- /dev/null +++ b/honcho-cli/src/honcho_cli/__init__.py @@ -0,0 +1,3 @@ +"""Honcho CLI — Agent-first admin & debugging tool.""" + +__version__ = "0.1.0" diff --git a/honcho-cli/src/honcho_cli/commands/__init__.py b/honcho-cli/src/honcho_cli/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/honcho-cli/src/honcho_cli/commands/conclusion.py b/honcho-cli/src/honcho_cli/commands/conclusion.py new file mode 100644 index 00000000..b216821d --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/conclusion.py @@ -0,0 +1,181 @@ +"""Conclusion commands: list, search, create, delete.""" + +from __future__ import annotations + +import json +from typing import Optional + +import typer + +from honcho_cli.commands.workspace import _handle_error +from honcho_cli.output import print_error, print_result, status +from honcho_cli.validation import validate_resource_id + +from honcho_cli.common import add_common_options + +app = typer.Typer(help="Conclusion (observation) operations.") +add_common_options(app) + + +@app.command("list") +def list_conclusions( + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), +) -> None: + """List conclusions.""" + from honcho_cli.main import get_client + + client, config = get_client() + + if not observer: + observer = config.peer_id + if not observer: + print_error("NO_PEER", "Observer peer ID required. Use --observer or set default peer.") + raise typer.Exit(1) + + peer = client.peer(observer) + + try: + if observed: + scope = peer.conclusions_of(observed) + else: + scope = peer.conclusions + + conclusions = list(scope.list()) + items = [ + { + "id": c.id, + "content": c.content[:200], + "observer_id": c.observer_id, + "observed_id": c.observed_id, + "created_at": str(c.created_at), + } + for c in conclusions + ] + print_result(items, columns=["id", "content", "observer_id", "observed_id", "created_at"], title="Conclusions") + except Exception as e: + _handle_error(e, "conclusion", "list") + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + top_k: int = typer.Option(10, help="Max results"), +) -> None: + """Semantic search over conclusions.""" + from honcho_cli.main import get_client + + client, config = get_client() + + if not observer: + observer = config.peer_id + if not observer: + print_error("NO_PEER", "Observer peer ID required. Use --observer or set default peer.") + raise typer.Exit(1) + + peer = client.peer(observer) + + try: + if observed: + scope = peer.conclusions_of(observed) + else: + scope = peer.conclusions + + results = scope.query(query, top_k=top_k) + items = [ + { + "id": c.id, + "content": c.content[:200], + "observer_id": c.observer_id, + "observed_id": c.observed_id, + "created_at": str(c.created_at), + } + for c in results + ] + print_result(items, columns=["id", "content", "created_at"], title=f"Conclusion search: {query}") + except Exception as e: + _handle_error(e, "conclusion", "search") + + +@app.command() +def create( + content: str = typer.Argument(help="Conclusion content or JSON payload"), + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + session_id: Optional[str] = typer.Option(None, "--session", help="Session context"), +) -> None: + """Create a conclusion.""" + from honcho_cli.main import get_client + + client, config = get_client() + + if not observer: + observer = config.peer_id + if not observer: + print_error("NO_PEER", "Observer peer ID required. Use --observer or set default peer.") + raise typer.Exit(1) + + # If content looks like JSON, try to parse it + try: + payload = json.loads(content) + content = payload.get("content", content) + except (json.JSONDecodeError, AttributeError): + pass + + peer = client.peer(observer) + + try: + if observed: + scope = peer.conclusions_of(observed) + else: + scope = peer.conclusions + + result = scope.create(content, session_id=session_id) + print_result({ + "id": result.id, + "content": result.content, + "observer_id": result.observer_id, + "observed_id": result.observed_id, + "created_at": str(result.created_at), + }) + except Exception as e: + _handle_error(e, "conclusion", "create") + + +@app.command() +def delete( + conclusion_id: str = typer.Argument(help="Conclusion ID to delete"), + observer: Optional[str] = typer.Option(None, "--observer", help="Observer peer ID"), + observed: Optional[str] = typer.Option(None, "--observed", help="Observed peer ID"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), +) -> None: + """Delete a conclusion.""" + from honcho_cli.main import get_client + + validate_resource_id(conclusion_id, "conclusion") + client, config = get_client() + + if not observer: + observer = config.peer_id + if not observer: + print_error("NO_PEER", "Observer peer ID required. Use --observer or set default peer.") + raise typer.Exit(1) + + if not yes: + typer.confirm(f"Delete conclusion '{conclusion_id}'?", abort=True) + + peer = client.peer(observer) + + try: + if observed: + scope = peer.conclusions_of(observed) + else: + scope = peer.conclusions + + scope.delete(conclusion_id) + status(f"Conclusion '{conclusion_id}' deleted") + print_result({"deleted": conclusion_id}) + except Exception as e: + _handle_error(e, "conclusion", conclusion_id) diff --git a/honcho-cli/src/honcho_cli/commands/config_cmd.py b/honcho-cli/src/honcho_cli/commands/config_cmd.py new file mode 100644 index 00000000..88c67bfa --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/config_cmd.py @@ -0,0 +1,54 @@ +"""Config management commands: init, set, show.""" + +from __future__ import annotations + +from dataclasses import fields +from typing import Optional + +import typer + +from honcho_cli.config import CLIConfig +from honcho_cli.output import print_error, print_result, status + +app = typer.Typer(help="Manage CLI configuration.") + + +@app.command() +def init( + base_url: str = typer.Option("https://api.honcho.dev", prompt="Base URL"), + api_key: str = typer.Option("", prompt="API key (admin JWT)"), + workspace_id: str = typer.Option("", prompt="Default workspace ID"), +) -> None: + """Interactive setup: set base_url, api_key, default workspace.""" + config = CLIConfig( + base_url=base_url, + api_key=api_key, + workspace_id=workspace_id, + ) + config.save() + status(f"Config saved to {config.save.__func__}") + print_result(config.redacted()) + + +@app.command("set") +def set_value( + key: str = typer.Argument(help="Config key (base_url, api_key, workspace_id, peer_id, session_id)"), + value: str = typer.Argument(help="Config value"), +) -> None: + """Set a config value.""" + valid_keys = {f.name for f in fields(CLIConfig)} + if key not in valid_keys: + print_error("INVALID_KEY", f"Unknown config key: {key}", {"valid_keys": sorted(valid_keys)}) + raise typer.Exit(1) + + config = CLIConfig.load() + setattr(config, key, value) + config.save() + status(f"Set {key}") + + +@app.command() +def show() -> None: + """Show current config (redacted keys).""" + config = CLIConfig.load() + print_result(config.redacted()) diff --git a/honcho-cli/src/honcho_cli/commands/describe.py b/honcho-cli/src/honcho_cli/commands/describe.py new file mode 100644 index 00000000..3869673b --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/describe.py @@ -0,0 +1,188 @@ +"""Schema introspection: describe resource schemas from live OpenAPI spec.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Optional + +import typer + +from honcho_cli.output import print_error, print_result, set_json_mode, set_quiet_mode, status + +app = typer.Typer(help="Schema introspection from live server.") + +# Local cache for OpenAPI spec +_CACHE_DIR = Path.home() / ".honcho" / "cache" +_CACHE_FILE = _CACHE_DIR / "openapi.json" +_CACHE_TTL = 3600 # 1 hour + +# Map CLI resource names to OpenAPI schema names +RESOURCE_SCHEMA_MAP: dict[str, list[str]] = { + "workspace": ["WorkspaceCreate", "WorkspaceResponse", "WorkspaceConfiguration"], + "peer": ["PeerCreate", "PeerResponse", "PeerConfig"], + "session": ["SessionCreate", "SessionResponse", "SessionConfiguration", "SessionPeerConfig"], + "message": ["MessageCreate", "MessageResponse", "MessageConfiguration"], + "conclusion": ["ConclusionCreate", "ConclusionResponse"], + "key": ["JWTParams"], +} + + +def _fetch_openapi(base_url: str, api_key: str | None = None) -> dict: + """Fetch OpenAPI spec from server, with caching.""" + # Check cache + if _CACHE_FILE.exists(): + mtime = _CACHE_FILE.stat().st_mtime + if time.time() - mtime < _CACHE_TTL: + return json.loads(_CACHE_FILE.read_text()) + + import httpx + + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + try: + resp = httpx.get(f"{base_url.rstrip('/')}/openapi.json", headers=headers, timeout=15) + resp.raise_for_status() + spec = resp.json() + except Exception as e: + # Fall back to cache if available + if _CACHE_FILE.exists(): + status("Using cached OpenAPI spec (server unreachable)") + return json.loads(_CACHE_FILE.read_text()) + raise + + # Cache it + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + _CACHE_FILE.write_text(json.dumps(spec)) + + return spec + + +def _resolve_ref(spec: dict, ref: str) -> dict: + """Resolve a $ref in an OpenAPI spec.""" + parts = ref.lstrip("#/").split("/") + node = spec + for part in parts: + node = node[part] + return node + + +def _extract_schema(spec: dict, schema_name: str) -> dict | None: + """Extract a schema from the OpenAPI spec components.""" + schemas = spec.get("components", {}).get("schemas", {}) + + # Try exact match first + if schema_name in schemas: + return _flatten_schema(spec, schemas[schema_name]) + + # Try case-insensitive match + for name, schema in schemas.items(): + if name.lower() == schema_name.lower(): + return _flatten_schema(spec, schema) + + return None + + +def _flatten_schema(spec: dict, schema: dict) -> dict: + """Flatten a schema, resolving $ref and allOf.""" + if "$ref" in schema: + return _flatten_schema(spec, _resolve_ref(spec, schema["$ref"])) + + if "allOf" in schema: + merged: dict = {"type": "object", "properties": {}} + for sub in schema["allOf"]: + resolved = _flatten_schema(spec, sub) + if "properties" in resolved: + merged["properties"].update(resolved["properties"]) + if "required" in resolved: + merged.setdefault("required", []).extend(resolved["required"]) + return merged + + return schema + + +def _format_schema(schema: dict) -> dict: + """Format schema for display.""" + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + + fields = {} + for name, prop in props.items(): + field_type = prop.get("type", prop.get("$ref", "unknown")) + if "anyOf" in prop: + types = [t.get("type", "?") for t in prop["anyOf"] if t.get("type") != "null"] + field_type = " | ".join(types) if types else "unknown" + if any(t.get("type") == "null" for t in prop["anyOf"]): + field_type += " (optional)" + + info: dict = {"type": field_type} + if name in required: + info["required"] = True + if "default" in prop: + info["default"] = prop["default"] + if "description" in prop: + info["description"] = prop["description"] + + fields[name] = info + + return fields + + +@app.command("resource") +def describe_resource( + resource: str = typer.Argument(help="Resource type: workspace, peer, session, message, conclusion, key"), +) -> None: + """Describe a resource schema from the live server.""" + from honcho_cli.main import get_resolved_config + + resource = resource.lower() + if resource not in RESOURCE_SCHEMA_MAP: + print_error( + "UNKNOWN_RESOURCE", + f"Unknown resource: '{resource}'", + {"valid_resources": list(RESOURCE_SCHEMA_MAP.keys())}, + ) + raise typer.Exit(1) + + config = get_resolved_config() + + try: + spec = _fetch_openapi(config.base_url, config.api_key) + except Exception as e: + print_error("OPENAPI_ERROR", f"Failed to fetch OpenAPI spec: {e}", {"base_url": config.base_url}) + raise typer.Exit(1) + + schema_names = RESOURCE_SCHEMA_MAP[resource] + result: dict = {} + + for schema_name in schema_names: + schema = _extract_schema(spec, schema_name) + if schema: + result[schema_name] = _format_schema(schema) + + if not result: + print_error("SCHEMA_NOT_FOUND", f"No schemas found for '{resource}'", {"resource": resource}) + raise typer.Exit(1) + + print_result(result) + + +# Make `honcho describe ` work as the default command +# by aliasing the resource command as the callback +@app.callback(invoke_without_command=True) +def describe_callback( + ctx: typer.Context, + resource: Optional[str] = typer.Argument(None, help="Resource type to describe"), + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"), +) -> None: + """Describe resource schemas from the live server's OpenAPI spec.""" + if json_output: + set_json_mode(True) + if quiet: + set_quiet_mode(True) + if resource and not ctx.invoked_subcommand: + ctx.invoke(describe_resource, resource=resource) diff --git a/honcho-cli/src/honcho_cli/commands/key.py b/honcho-cli/src/honcho_cli/commands/key.py new file mode 100644 index 00000000..828b1374 --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/key.py @@ -0,0 +1,115 @@ +"""Key management commands: generate scoped JWTs.""" + +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone +from typing import Optional + +import typer + +from honcho_cli.output import print_error, print_result, status + +from honcho_cli.common import add_common_options + +app = typer.Typer(help="JWT key management.") +add_common_options(app) + + +def _parse_duration(duration: str) -> datetime: + """Parse duration string like '30d', '24h', '90d' into expiry datetime.""" + match = re.match(r"^(\d+)([dhm])$", duration) + if not match: + print_error( + "INVALID_DURATION", + f"Invalid duration format: '{duration}'. Use format like '30d', '24h', '60m'.", + {"duration": duration}, + ) + raise typer.Exit(1) + + value = int(match.group(1)) + unit = match.group(2) + + delta = { + "d": timedelta(days=value), + "h": timedelta(hours=value), + "m": timedelta(minutes=value), + }[unit] + + return datetime.now(timezone.utc) + delta + + +@app.command() +def generate( + workspace: Optional[str] = typer.Option(None, "--workspace", help="Scope to workspace"), + peer: Optional[str] = typer.Option(None, "--peer", help="Scope to peer"), + session: Optional[str] = typer.Option(None, "--session", help="Scope to session"), + admin: bool = typer.Option(False, "--admin", help="Generate admin key"), + expires: str = typer.Option("90d", "--expires", help="Expiry duration (e.g., 30d, 24h)"), + no_expire: bool = typer.Option(False, "--no-expire", help="No expiration (use with caution)"), +) -> None: + """Generate a scoped JWT key. + + Requires an admin JWT in config. Generated keys are scoped down from admin. + """ + from honcho_cli.main import get_resolved_config + + config = get_resolved_config() + + if not config.api_key: + print_error("NO_API_KEY", "Admin API key required in config. Run `honcho config set api_key `.") + raise typer.Exit(3) + + if not admin and not workspace and not peer and not session: + # Default to current workspace + workspace = config.workspace_id + if not workspace: + print_error( + "NO_SCOPE", + "Must specify at least one scope: --workspace, --peer, --session, or --admin", + ) + raise typer.Exit(1) + + # Build expiry + expires_at = None + if not no_expire: + expires_at = _parse_duration(expires) + + # Use raw HTTP to call the /keys endpoint + import httpx + + base_url = config.base_url.rstrip("/") + params: dict = {} + if workspace: + params["workspace_id"] = workspace + if peer: + params["peer_id"] = peer + if session: + params["session_id"] = session + if expires_at: + params["expires_at"] = expires_at.isoformat() + + if admin: + params = {"admin": "true"} + if expires_at: + params["expires_at"] = expires_at.isoformat() + + try: + resp = httpx.post( + f"{base_url}/v3/keys", + params=params, + headers={"Authorization": f"Bearer {config.api_key}"}, + timeout=30, + ) + resp.raise_for_status() + data = resp.json() + print_result(data) + except httpx.HTTPStatusError as e: + if e.response.status_code == 401 or e.response.status_code == 403: + print_error("AUTH_ERROR", "Admin authentication required for key generation", {}) + raise typer.Exit(3) + print_error("KEY_ERROR", f"Failed to generate key: {e.response.text}", {}) + raise typer.Exit(1) + except Exception as e: + print_error("KEY_ERROR", f"Failed to generate key: {e}", {}) + raise typer.Exit(1) diff --git a/honcho-cli/src/honcho_cli/commands/message.py b/honcho-cli/src/honcho_cli/commands/message.py new file mode 100644 index 00000000..66a5e4a6 --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/message.py @@ -0,0 +1,92 @@ +"""Message commands: list, get.""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho_cli.commands.workspace import _handle_error +from honcho_cli.output import print_result +from honcho_cli.validation import validate_resource_id + +from honcho_cli.common import add_common_options + +app = typer.Typer(help="Message operations.") +add_common_options(app) + + +@app.command("list") +def list_messages( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + last: int = typer.Option(20, "--last", help="Number of recent messages"), + reverse: bool = typer.Option(False, "--reverse", help="Reverse order"), +) -> None: + """List messages in a session.""" + from honcho_cli.commands.session import _get_session_id + from honcho_cli.main import get_client + + sid = _get_session_id(session_id) + client, config = get_client() + session = client.session(sid) + + try: + msgs = list(session.messages()) + if not reverse: + msgs = msgs[-last:] + else: + msgs = msgs[:last] + + items = [ + { + "id": m.id, + "peer_id": m.peer_id, + "content": m.content, + "token_count": m.token_count, + "metadata": m.metadata, + "created_at": str(m.created_at), + } + for m in msgs + ] + print_result(items, columns=["id", "peer_id", "content", "created_at"], title="Messages") + except Exception as e: + _handle_error(e, "message", "list") + + +@app.command("get") +def get_message( + message_id: str = typer.Argument(help="Message ID"), + session_id: Optional[str] = typer.Option(None, "--session", help="Session ID"), +) -> None: + """Get a single message by ID.""" + from honcho_cli.commands.session import _get_session_id + from honcho_cli.main import get_client + + validate_resource_id(message_id, "message") + sid = _get_session_id(session_id) + client, config = get_client() + + try: + # Use raw HTTP to get a single message + session = client.session(sid) + msgs = list(session.messages()) + msg = next((m for m in msgs if m.id == message_id), None) + + if msg is None: + from honcho_cli.output import print_error + + print_error("MESSAGE_NOT_FOUND", f"Message '{message_id}' not found in session '{sid}'", {"message_id": message_id, "session_id": sid}) + raise typer.Exit(1) + + print_result({ + "id": msg.id, + "peer_id": msg.peer_id, + "content": msg.content, + "token_count": msg.token_count, + "metadata": msg.metadata, + "created_at": str(msg.created_at), + }) + except SystemExit: + raise + except Exception as e: + _handle_error(e, "message", message_id) diff --git a/honcho-cli/src/honcho_cli/commands/peer.py b/honcho-cli/src/honcho_cli/commands/peer.py new file mode 100644 index 00000000..42d8facc --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/peer.py @@ -0,0 +1,153 @@ +"""Peer commands: list, inspect, card, chat, search.""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho_cli.commands.workspace import _config_to_dict, _handle_error +from honcho_cli.output import print_result, status +from honcho_cli.validation import validate_resource_id + +from honcho_cli.common import add_common_options + +app = typer.Typer(help="Peer debugging operations.") +add_common_options(app) + + +def _get_peer_id(peer_id: str | None) -> str: + from honcho_cli.main import get_resolved_config + + config = get_resolved_config() + pid = peer_id or config.peer_id + if not pid: + from honcho_cli.output import print_error + + print_error("NO_PEER", "No peer ID provided. Use --peer, set HONCHO_PEER_ID, or run `honcho config set peer_id `.") + raise typer.Exit(1) + return validate_resource_id(pid, "peer") + + +@app.command("list") +def list_peers() -> None: + """List all peers in the workspace.""" + from honcho_cli.main import get_client + + client, config = get_client() + + try: + peers = list(client.peers()) + items = [ + { + "id": p.id, + "metadata": getattr(p, "metadata", {}), + "configuration": _config_to_dict(p.configuration) if getattr(p, "configuration", None) else None, + "created_at": str(getattr(p, "created_at", "")), + } + for p in peers + ] + print_result(items, columns=["id", "metadata", "created_at"], title="Peers") + except Exception as e: + _handle_error(e, "peer", "list") + + +@app.command() +def inspect( + peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"), +) -> None: + """Inspect a peer: card, session count, recent conclusions.""" + from honcho_cli.main import get_client + + pid = _get_peer_id(peer_id) + client, config = get_client() + peer = client.peer(pid) + + try: + card = peer.get_card() + sessions = list(peer.sessions()) + conclusions = list(peer.conclusions.list()) + + result = { + "id": pid, + "card": card, + "session_count": len(sessions), + "conclusion_count": len(conclusions), + "recent_conclusions": [ + {"id": c.id, "content": c.content[:200], "created_at": str(c.created_at)} + for c in conclusions[:10] + ], + "sessions": [{"id": s.id} for s in sessions[:10]], + } + print_result(result) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def card( + peer_id: Optional[str] = typer.Argument(None, help="Peer ID (uses default if omitted)"), + target: Optional[str] = typer.Option(None, help="Target peer for relationship card"), +) -> None: + """Get raw peer card content.""" + from honcho_cli.main import get_client + + pid = _get_peer_id(peer_id) + client, config = get_client() + peer = client.peer(pid) + + try: + result = peer.get_card(target=target) + print_result({"peer_id": pid, "target": target, "card": result}) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def chat( + query: str = typer.Argument(help="Question to ask about the peer"), + peer_id: Optional[str] = typer.Option(None, help="Peer ID (uses default if omitted)"), + target: Optional[str] = typer.Option(None, help="Target peer for perspective"), + session: Optional[str] = typer.Option(None, help="Session context"), +) -> None: + """Query the dialectic about a peer.""" + from honcho_cli.main import get_client + + pid = _get_peer_id(peer_id) + client, config = get_client() + peer = client.peer(pid) + + try: + response = peer.chat(query, target=target, session=session) + print_result({"peer_id": pid, "query": query, "response": response}) + except Exception as e: + _handle_error(e, "peer", pid) + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + peer_id: Optional[str] = typer.Option(None, help="Peer ID (uses default if omitted)"), + limit: int = typer.Option(10, help="Max results"), +) -> None: + """Search a peer's messages.""" + from honcho_cli.main import get_client + + pid = _get_peer_id(peer_id) + client, config = get_client() + peer = client.peer(pid) + + try: + results = peer.search(query, limit=limit) + items = [ + { + "id": m.id, + "content": m.content[:200], + "session_id": m.session_id, + "created_at": str(m.created_at), + } + for m in results + ] + print_result(items, columns=["id", "session_id", "content", "created_at"], title=f"Peer search: {query}") + except Exception as e: + _handle_error(e, "peer", pid) diff --git a/honcho-cli/src/honcho_cli/commands/session.py b/honcho-cli/src/honcho_cli/commands/session.py new file mode 100644 index 00000000..cacc954d --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/session.py @@ -0,0 +1,170 @@ +"""Session commands: list, inspect, messages, context, summaries.""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho_cli.commands.workspace import _config_to_dict, _handle_error +from honcho_cli.output import print_result, status +from honcho_cli.validation import validate_resource_id + +from honcho_cli.common import add_common_options + +app = typer.Typer(help="Session debugging operations.") +add_common_options(app) + + +def _get_session_id(session_id: str | None) -> str: + from honcho_cli.main import get_resolved_config + + config = get_resolved_config() + sid = session_id or config.session_id + if not sid: + from honcho_cli.output import print_error + + print_error("NO_SESSION", "No session ID provided. Use --session, set HONCHO_SESSION_ID, or run `honcho config set session_id `.") + raise typer.Exit(1) + return validate_resource_id(sid, "session") + + +@app.command("list") +def list_sessions( + peer_id: Optional[str] = typer.Option(None, "--peer", help="Filter by peer"), +) -> None: + """List sessions in the workspace.""" + from honcho_cli.main import get_client + + client, config = get_client() + + try: + if peer_id: + peer = client.peer(peer_id) + sessions = list(peer.sessions()) + else: + sessions = list(client.sessions()) + + items = [ + { + "id": s.id, + "is_active": getattr(s, "is_active", None), + "metadata": getattr(s, "metadata", {}), + "created_at": str(getattr(s, "created_at", "")), + } + for s in sessions + ] + print_result(items, columns=["id", "is_active", "metadata", "created_at"], title="Sessions") + except Exception as e: + _handle_error(e, "session", "list") + + +@app.command() +def inspect( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), +) -> None: + """Inspect a session: peers, message count, summaries, config.""" + from honcho_cli.main import get_client + + sid = _get_session_id(session_id) + client, config = get_client() + session = client.session(sid) + + try: + peers = session.peers() + messages = list(session.messages()) + summaries = session.summaries() + sess_config = session.get_configuration() + + result = { + "id": sid, + "peers": [{"id": p.id} for p in peers], + "message_count": len(messages), + "summaries": { + "short": summaries.short_summary if hasattr(summaries, "short_summary") else None, + "long": summaries.long_summary if hasattr(summaries, "long_summary") else None, + }, + "configuration": _config_to_dict(sess_config) if sess_config else None, + } + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def messages( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + last: int = typer.Option(20, "--last", help="Number of recent messages"), + reverse: bool = typer.Option(False, "--reverse", help="Reverse order (oldest first)"), +) -> None: + """List recent messages in a session.""" + from honcho_cli.main import get_client + + sid = _get_session_id(session_id) + client, config = get_client() + session = client.session(sid) + + try: + msgs = list(session.messages()) + if not reverse: + msgs = msgs[-last:] + else: + msgs = msgs[:last] + + items = [ + { + "id": m.id, + "peer_id": m.peer_id, + "content": m.content[:300], + "token_count": m.token_count, + "created_at": str(m.created_at), + } + for m in msgs + ] + print_result(items, columns=["id", "peer_id", "content", "created_at"], title=f"Messages ({sid})") + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def context( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), + tokens: Optional[int] = typer.Option(None, help="Token budget"), + summary: bool = typer.Option(True, help="Include summary"), +) -> None: + """Get session context (what an agent would see).""" + from honcho_cli.main import get_client + + sid = _get_session_id(session_id) + client, config = get_client() + session = client.session(sid) + + try: + ctx = session.context(tokens=tokens, summary=summary) + result = ctx.__dict__ if hasattr(ctx, "__dict__") else ctx + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) + + +@app.command() +def summaries( + session_id: Optional[str] = typer.Argument(None, help="Session ID (uses default if omitted)"), +) -> None: + """Get session summaries (short + long).""" + from honcho_cli.main import get_client + + sid = _get_session_id(session_id) + client, config = get_client() + session = client.session(sid) + + try: + s = session.summaries() + result = { + "session_id": sid, + "short_summary": s.short_summary if hasattr(s, "short_summary") else None, + "long_summary": s.long_summary if hasattr(s, "long_summary") else None, + } + print_result(result) + except Exception as e: + _handle_error(e, "session", sid) diff --git a/honcho-cli/src/honcho_cli/commands/workspace.py b/honcho-cli/src/honcho_cli/commands/workspace.py new file mode 100644 index 00000000..859ad6a2 --- /dev/null +++ b/honcho-cli/src/honcho_cli/commands/workspace.py @@ -0,0 +1,189 @@ +"""Workspace commands: inspect, delete, search, queue-status.""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho_cli.output import print_error, print_result, status +from honcho_cli.validation import validate_resource_id + +from honcho_cli.common import add_common_options + +app = typer.Typer(help="Workspace operations.") +add_common_options(app) + + +def _get_workspace_id(workspace_id: str | None) -> str: + from honcho_cli.main import get_resolved_config + + config = get_resolved_config() + wid = workspace_id or config.workspace_id + if not wid: + print_error("NO_WORKSPACE", "No workspace ID provided. Use --workspace, set HONCHO_WORKSPACE_ID, or run `honcho config set workspace_id `.") + raise typer.Exit(1) + return validate_resource_id(wid, "workspace") + + +@app.command() +def inspect( + workspace_id: Optional[str] = typer.Argument(None, help="Workspace ID (uses default if omitted)"), +) -> None: + """Inspect a workspace: peers, sessions, config.""" + from honcho_cli.main import get_client + + wid = _get_workspace_id(workspace_id) + client, config = get_client() + + # Override workspace if positional arg given + if workspace_id: + client = _with_workspace(client, workspace_id) + + try: + ws_config = client.get_configuration() + ws_metadata = client.get_metadata() + + peers = list(client.peers()) + sessions = list(client.sessions()) + + result = { + "workspace_id": wid, + "metadata": ws_metadata, + "configuration": _config_to_dict(ws_config), + "peer_count": len(peers), + "session_count": len(sessions), + "peers": [{"id": p.id, "metadata": getattr(p, "metadata", {})} for p in peers[:20]], + "sessions": [{"id": s.id, "is_active": getattr(s, "is_active", None), "metadata": getattr(s, "metadata", {})} for s in sessions[:20]], + } + print_result(result) + except Exception as e: + _handle_error(e, "workspace", wid) + + +@app.command() +def delete( + workspace_id: str = typer.Argument(help="Workspace ID to delete"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), + dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be deleted without deleting"), +) -> None: + """Delete a workspace. Destructive — requires --yes or interactive confirm.""" + from honcho_cli.main import get_client + + validate_resource_id(workspace_id, "workspace") + client, config = get_client() + client = _with_workspace(client, workspace_id) + + if dry_run: + sessions = list(client.sessions()) + peers = list(client.peers()) + print_result({ + "dry_run": True, + "workspace_id": workspace_id, + "sessions_to_delete": len(sessions), + "peers_to_delete": len(peers), + }) + return + + if not yes: + typer.confirm(f"Delete workspace '{workspace_id}' and all its data?", abort=True) + + try: + client.delete_workspace(workspace_id) + status(f"Workspace '{workspace_id}' deleted") + print_result({"deleted": workspace_id}) + except Exception as e: + _handle_error(e, "workspace", workspace_id) + + +@app.command() +def search( + query: str = typer.Argument(help="Search query"), + workspace_id: Optional[str] = typer.Option(None, help="Workspace ID"), + limit: int = typer.Option(10, help="Max results"), +) -> None: + """Search messages across workspace.""" + from honcho_cli.main import get_client + + wid = _get_workspace_id(workspace_id) + client, config = get_client() + + try: + results = client.search(query, limit=limit) + items = [ + { + "id": m.id, + "content": m.content[:200], + "peer_id": m.peer_id, + "session_id": m.session_id, + "created_at": str(m.created_at), + } + for m in results + ] + print_result(items, columns=["id", "peer_id", "session_id", "content"], title=f"Search: {query}") + except Exception as e: + _handle_error(e, "workspace", wid) + + +@app.command("queue-status") +def queue_status( + workspace_id: Optional[str] = typer.Option(None, help="Workspace ID"), + observer: Optional[str] = typer.Option(None, help="Filter by observer peer"), + sender: Optional[str] = typer.Option(None, help="Filter by sender peer"), + session: Optional[str] = typer.Option(None, help="Filter by session"), +) -> None: + """Get queue processing status.""" + from honcho_cli.main import get_client + + _get_workspace_id(workspace_id) + client, config = get_client() + + try: + result = client.queue_status(observer=observer, sender=sender, session=session) + print_result(result.__dict__ if hasattr(result, "__dict__") else result) + except Exception as e: + _handle_error(e, "queue", "status") + + +def _with_workspace(client, workspace_id: str): + """Return a new client pointed at a different workspace.""" + from honcho import Honcho + + return Honcho( + base_url=str(client.base_url), + api_key=client._http._api_key if hasattr(client._http, "_api_key") else None, + workspace_id=workspace_id, + ) + + +def _config_to_dict(config) -> dict: + """Convert a config object to a dict, handling nested objects.""" + if hasattr(config, "__dict__"): + result = {} + for k, v in config.__dict__.items(): + if k.startswith("_"): + continue + result[k] = _config_to_dict(v) if hasattr(v, "__dict__") and not isinstance(v, str) else v + return result + return config + + +def _handle_error(e: Exception, resource: str, resource_id: str) -> None: + """Handle SDK exceptions with structured error output.""" + error_str = str(e) + if "404" in error_str or "not found" in error_str.lower(): + print_error( + f"{resource.upper()}_NOT_FOUND", + f"{resource.title()} '{resource_id}' not found", + {resource: resource_id}, + ) + raise typer.Exit(1) + elif "401" in error_str or "403" in error_str or "auth" in error_str.lower(): + print_error("AUTH_ERROR", f"Authentication failed: {error_str}", {}) + raise typer.Exit(3) + elif "500" in error_str or "server" in error_str.lower(): + print_error("SERVER_ERROR", f"Server error: {error_str}", {resource: resource_id}) + raise typer.Exit(2) + else: + print_error("UNKNOWN_ERROR", str(e), {resource: resource_id}) + raise typer.Exit(1) diff --git a/honcho-cli/src/honcho_cli/common.py b/honcho-cli/src/honcho_cli/common.py new file mode 100644 index 00000000..4b940d8f --- /dev/null +++ b/honcho-cli/src/honcho_cli/common.py @@ -0,0 +1,42 @@ +"""Shared callback for subcommand groups to accept global-style flags.""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho_cli.output import set_json_mode, set_quiet_mode + + +def add_common_options(app: typer.Typer) -> None: + """Add a callback to a sub-app that accepts --json, --quiet, -w, -p, -s.""" + # Allow flags like --json after subcommands (e.g., `honcho workspace inspect granola --json`) + app.info.context_settings = {"allow_interspersed_args": True} + + @app.callback(invoke_without_command=True) + def _callback( + ctx: typer.Context, + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", help="Override peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", help="Override session ID"), + ) -> None: + if json_output: + set_json_mode(True) + if quiet: + set_quiet_mode(True) + + # Import here to avoid circular imports + from honcho_cli.main import _global_overrides + + if workspace: + _global_overrides["workspace"] = workspace + if peer: + _global_overrides["peer"] = peer + if session: + _global_overrides["session"] = session + + if ctx.invoked_subcommand is None: + ctx.get_help() diff --git a/honcho-cli/src/honcho_cli/config.py b/honcho-cli/src/honcho_cli/config.py new file mode 100644 index 00000000..235b737b --- /dev/null +++ b/honcho-cli/src/honcho_cli/config.py @@ -0,0 +1,96 @@ +"""Configuration management for Honcho CLI. + +Config stored at ~/.honcho/config.toml with env var overrides. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, field, fields +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomllib + except ImportError: + import tomli as tomllib # type: ignore[no-redef] + + +CONFIG_DIR = Path.home() / ".honcho" +CONFIG_FILE = CONFIG_DIR / "config.toml" + +# Env var mapping: field_name -> env var +ENV_MAP: dict[str, str] = { + "base_url": "HONCHO_BASE_URL", + "api_key": "HONCHO_API_KEY", + "workspace_id": "HONCHO_WORKSPACE_ID", + "peer_id": "HONCHO_PEER_ID", + "session_id": "HONCHO_SESSION_ID", +} + + +@dataclass +class CLIConfig: + """CLI configuration with layered resolution: flag > env > file > default.""" + + base_url: str = "https://api.honcho.dev" + api_key: str = "" + workspace_id: str = "" + peer_id: str = "" + session_id: str = "" + + @classmethod + def load(cls) -> CLIConfig: + """Load config from file, then overlay env vars.""" + config = cls() + + # Layer 1: config file + if CONFIG_FILE.exists(): + with open(CONFIG_FILE, "rb") as f: + data = tomllib.load(f) + for fld in fields(cls): + if fld.name in data: + setattr(config, fld.name, data[fld.name]) + + # Layer 2: env vars + for fld_name, env_var in ENV_MAP.items(): + val = os.environ.get(env_var) + if val: + setattr(config, fld_name, val) + + return config + + def save(self) -> None: + """Write current config to ~/.honcho/config.toml.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + lines = [] + for fld in fields(self): + val = getattr(self, fld.name) + lines.append(f'{fld.name} = "{val}"') + CONFIG_FILE.write_text("\n".join(lines) + "\n") + + def redacted(self) -> dict[str, str]: + """Return config dict with api_key redacted.""" + d: dict[str, str] = {} + for fld in fields(self): + val = getattr(self, fld.name) + if fld.name == "api_key" and val: + d[fld.name] = val[:8] + "..." + val[-4:] if len(val) > 16 else "***" + else: + d[fld.name] = val + return d + + +def get_client_kwargs(config: CLIConfig) -> dict: + """Build kwargs for Honcho client from config.""" + kwargs: dict = {} + if config.base_url: + kwargs["base_url"] = config.base_url + if config.api_key: + kwargs["api_key"] = config.api_key + if config.workspace_id: + kwargs["workspace_id"] = config.workspace_id + return kwargs diff --git a/honcho-cli/src/honcho_cli/main.py b/honcho-cli/src/honcho_cli/main.py new file mode 100644 index 00000000..eb1bd985 --- /dev/null +++ b/honcho-cli/src/honcho_cli/main.py @@ -0,0 +1,103 @@ +"""Honcho CLI — Agent-first admin & debugging tool. + +Entry point and top-level command group. +""" + +from __future__ import annotations + +from typing import Optional + +import typer + +from honcho_cli import __version__ +from honcho_cli.output import set_json_mode, set_quiet_mode + +app = typer.Typer( + name="honcho", + help="Agent-first admin & debugging CLI for Honcho.", + no_args_is_help=True, + pretty_exceptions_enable=False, +) + + +def version_callback(value: bool) -> None: + if value: + print(f"honcho-cli {__version__}") + raise typer.Exit() + + +@app.callback() +def main( + json_output: bool = typer.Option(False, "--json", help="Force JSON output"), + quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress status messages"), + workspace: Optional[str] = typer.Option(None, "--workspace", "-w", envvar="HONCHO_WORKSPACE_ID", help="Override workspace ID"), + peer: Optional[str] = typer.Option(None, "--peer", "-p", envvar="HONCHO_PEER_ID", help="Override peer ID"), + session: Optional[str] = typer.Option(None, "--session", "-s", envvar="HONCHO_SESSION_ID", help="Override session ID"), + version: bool = typer.Option(False, "--version", "-V", callback=version_callback, is_eager=True, help="Show version"), +) -> None: + """Honcho CLI — admin & debugging tool for Honcho workspaces.""" + set_json_mode(json_output) + set_quiet_mode(quiet) + + # Store global overrides for commands to access + _global_overrides["workspace"] = workspace + _global_overrides["peer"] = peer + _global_overrides["session"] = session + + +# Global overrides from flags (commands read these) +_global_overrides: dict[str, str | None] = { + "workspace": None, + "peer": None, + "session": None, +} + + +def get_resolved_config(): + """Get config with global flag overrides applied.""" + from honcho_cli.config import CLIConfig + + config = CLIConfig.load() + + if _global_overrides["workspace"]: + config.workspace_id = _global_overrides["workspace"] + if _global_overrides["peer"]: + config.peer_id = _global_overrides["peer"] + if _global_overrides["session"]: + config.session_id = _global_overrides["session"] + + return config + + +def get_client(): + """Create a Honcho client from resolved config.""" + from honcho import Honcho + + from honcho_cli.config import get_client_kwargs + + config = get_resolved_config() + return Honcho(**get_client_kwargs(config)), config + + +# Register command groups +from honcho_cli.commands.config_cmd import app as config_app +from honcho_cli.commands.conclusion import app as conclusion_app +from honcho_cli.commands.describe import app as describe_app +from honcho_cli.commands.key import app as key_app +from honcho_cli.commands.message import app as message_app +from honcho_cli.commands.peer import app as peer_app +from honcho_cli.commands.session import app as session_app +from honcho_cli.commands.workspace import app as workspace_app + +app.add_typer(config_app, name="config") +app.add_typer(workspace_app, name="workspace") +app.add_typer(peer_app, name="peer") +app.add_typer(session_app, name="session") +app.add_typer(message_app, name="message") +app.add_typer(conclusion_app, name="conclusion") +app.add_typer(key_app, name="key") +app.add_typer(describe_app, name="describe") + + +if __name__ == "__main__": + app() diff --git a/honcho-cli/src/honcho_cli/output.py b/honcho-cli/src/honcho_cli/output.py new file mode 100644 index 00000000..52889e7a --- /dev/null +++ b/honcho-cli/src/honcho_cli/output.py @@ -0,0 +1,120 @@ +"""Output formatting: JSON, NDJSON, tables, and structured errors. + +Detects TTY to auto-switch between human-readable and machine-parseable output. +""" + +from __future__ import annotations + +import json +import sys +from typing import Any + +from rich.console import Console +from rich.table import Table + +console = Console(stderr=True) +stdout_console = Console() + + +def is_tty() -> bool: + """Check if stdout is a TTY.""" + return sys.stdout.isatty() + + +# Global state for --json and --quiet flags +_force_json = False +_quiet = False + + +def set_json_mode(enabled: bool) -> None: + global _force_json + _force_json = enabled + + +def set_quiet_mode(enabled: bool) -> None: + global _quiet + _quiet = enabled + + +def use_json() -> bool: + """Should we output JSON?""" + import os + + return _force_json or os.environ.get("HONCHO_JSON", "").lower() in ("1", "true") or not is_tty() + + +def print_json(data: Any) -> None: + """Print a single JSON object to stdout.""" + print(json.dumps(data, indent=2, default=str)) + + +def print_ndjson(items: list[Any]) -> None: + """Print items as newline-delimited JSON.""" + for item in items: + print(json.dumps(item, default=str)) + + +def print_table(columns: list[str], rows: list[list[str]], title: str | None = None) -> None: + """Print a rich table to stdout.""" + table = Table(title=title, show_header=True, header_style="bold") + for col in columns: + table.add_column(col) + for row in rows: + table.add_row(*row) + stdout_console.print(table) + + +def print_result(data: Any, columns: list[str] | None = None, title: str | None = None) -> None: + """Print data as JSON or table depending on mode. + + For lists, uses NDJSON in JSON mode or table in TTY mode. + For dicts, uses JSON or key-value display. + """ + if use_json(): + if isinstance(data, list): + print_ndjson(data) + else: + print_json(data) + else: + if isinstance(data, list) and columns: + rows = [] + for item in data: + row = [str(item.get(col, "")) if isinstance(item, dict) else str(item) for col in columns] + rows.append(row) + print_table(columns, rows, title=title) + elif isinstance(data, dict): + table = Table(show_header=False) + table.add_column("Field", style="bold") + table.add_column("Value") + for k, v in data.items(): + val = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v) + table.add_row(k, val) + stdout_console.print(table) + else: + stdout_console.print(data) + + +def print_error(code: str, message: str, details: dict | None = None) -> None: + """Print structured error.""" + err = { + "error": { + "code": code, + "message": message, + } + } + if details: + err["error"]["details"] = details + + if use_json(): + print(json.dumps(err, default=str), file=sys.stderr) + else: + console.print(f"[red]Error[/red] ({code}): {message}") + if details: + for k, v in details.items(): + console.print(f" {k}: {v}") + + +def status(msg: str) -> None: + """Print a status message (suppressed in quiet mode).""" + if not _quiet: + console.print(f"[dim]{msg}[/dim]") diff --git a/honcho-cli/src/honcho_cli/skills/CONTEXT.md b/honcho-cli/src/honcho_cli/skills/CONTEXT.md new file mode 100644 index 00000000..0435d0f1 --- /dev/null +++ b/honcho-cli/src/honcho_cli/skills/CONTEXT.md @@ -0,0 +1,45 @@ +--- +name: honcho-cli +version: 0.1.0 +description: Agent-first admin & debugging CLI for Honcho +--- + +# Honcho CLI — Agent Interface + +## Overview + +`honcho` is a CLI for administering and debugging Honcho workspaces. It wraps the Honcho Python SDK with agent-friendly defaults: JSON output, structured errors, input validation. + +## Output Modes + +- **TTY**: Human-readable tables (default when interactive) +- **Piped/scripted**: JSON/NDJSON automatically +- `--json`: Force JSON output +- `--quiet`: Suppress status messages + +## Exit Codes + +- 0: Success +- 1: Client error (bad input, not found) +- 2: Server error +- 3: Auth error + +## Config + +Stored at `~/.honcho/config.toml`. Set defaults to avoid repeating IDs: + +```bash +honcho config set workspace_id my-workspace +honcho config set peer_id my-peer +``` + +## Command Groups + +- `honcho config` — Manage CLI configuration +- `honcho workspace` — Inspect, delete, search workspaces +- `honcho peer` — Inspect, card, chat, search peers +- `honcho session` — Inspect, messages, context, summaries +- `honcho message` — List and get messages +- `honcho conclusion` — List, search, create, delete conclusions +- `honcho key` — Generate scoped JWT keys +- `honcho describe` — Schema introspection from live server diff --git a/honcho-cli/src/honcho_cli/skills/honcho-admin.md b/honcho-cli/src/honcho_cli/skills/honcho-admin.md new file mode 100644 index 00000000..3719d67b --- /dev/null +++ b/honcho-cli/src/honcho_cli/skills/honcho-admin.md @@ -0,0 +1,40 @@ +--- +name: honcho-cli-admin +version: 0.1.0 +description: Admin operations for Honcho workspaces +--- + +# Honcho CLI — Admin Skills + +## Rules + +- Always inspect before deleting: `honcho workspace inspect` then `honcho workspace delete` +- Use `--dry-run` for destructive operations to preview impact +- Generated keys default to 90-day expiry; use `--no-expire` only when intentional +- Admin JWT required for key generation and workspace deletion + +## Key Generation + +```bash +# Workspace-scoped key (90-day default) +honcho key generate --workspace my-ws --json + +# Peer-scoped key with custom expiry +honcho key generate --peer --expires 30d --json + +# No-expiry key (use with caution) +honcho key generate --workspace my-ws --no-expire --json +``` + +## Workspace Lifecycle + +```bash +# Inspect first +honcho workspace inspect --json + +# Check queue status +honcho workspace queue-status --json + +# Delete (requires --yes for non-interactive) +honcho workspace delete --yes +``` diff --git a/honcho-cli/src/honcho_cli/skills/honcho-debug.md b/honcho-cli/src/honcho_cli/skills/honcho-debug.md new file mode 100644 index 00000000..bec1e29f --- /dev/null +++ b/honcho-cli/src/honcho_cli/skills/honcho-debug.md @@ -0,0 +1,55 @@ +--- +name: honcho-cli-debug +version: 0.1.0 +description: Debug Honcho peer representations and memory +--- + +# Honcho CLI — Debug Skills + +## Rules + +- Use `honcho describe ` to understand schema before constructing queries +- Check queue status when derivation seems stalled +- Compare peer card with conclusions to understand memory state + +## Debugging Memory Issues + +### Peer not learning? + +```bash +# Check if observation is enabled +honcho peer inspect --json | jq '.configuration' + +# Check queue — are messages being processed? +honcho workspace queue-status --json + +# Check what conclusions exist +honcho conclusion list --observer --json +honcho conclusion search "expected topic" --observer --json +``` + +### Session context looks wrong? + +```bash +# See raw context +honcho session context --json + +# Check summaries +honcho session summaries --json + +# Check message history +honcho session messages --last 50 --json +``` + +### Dialectic giving bad answers? + +```bash +# Check what the peer card says +honcho peer card --json + +# Check conclusions for the specific topic +honcho conclusion search "topic" --observer --json + +# Try the dialectic directly +honcho peer chat "what do you know about X?" --json +``` diff --git a/honcho-cli/src/honcho_cli/skills/honcho-inspect.md b/honcho-cli/src/honcho_cli/skills/honcho-inspect.md new file mode 100644 index 00000000..dd1fb878 --- /dev/null +++ b/honcho-cli/src/honcho_cli/skills/honcho-inspect.md @@ -0,0 +1,53 @@ +--- +name: honcho-cli-inspect +version: 0.1.0 +description: Inspect Honcho workspace state for debugging +--- + +# Honcho CLI — Inspection Skills + +## Rules + +- Always use `--json` when processing output programmatically +- Run `honcho peer inspect` before `honcho peer chat` to understand context +- Use `honcho session context` to see exactly what an agent receives +- Never run `honcho workspace delete` without `honcho workspace inspect` first + +## Inspection Workflow + +### 1. Understand the workspace + +```bash +honcho workspace inspect --json +``` + +### 2. Find the peer + +```bash +honcho peer list --json +honcho peer inspect --json +``` + +### 3. Check peer's memory + +```bash +honcho peer card --json +honcho conclusion list --observer --json +honcho conclusion search "topic" --observer --json +``` + +### 4. Debug a session + +```bash +honcho session inspect --json +honcho session messages --last 20 --json +honcho session context --json +honcho session summaries --json +``` + +### 5. Search across workspace + +```bash +honcho workspace search "query" --json +honcho peer search "query" --json +``` diff --git a/honcho-cli/src/honcho_cli/validation.py b/honcho-cli/src/honcho_cli/validation.py new file mode 100644 index 00000000..a748ddd5 --- /dev/null +++ b/honcho-cli/src/honcho_cli/validation.py @@ -0,0 +1,61 @@ +"""Input hardening: validate resource IDs and workspace names. + +Agents hallucinate bad IDs. Catch them early with clear errors. +""" + +from __future__ import annotations + +import re +import sys + +UNSAFE_CHARS = re.compile(r'[?#%\x00-\x1f\x7f/\\]') +WORKSPACE_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]+$') + + +def validate_resource_id(value: str, resource_type: str = "resource") -> str: + """Validate a resource ID. Returns the value if valid, raises SystemExit on invalid.""" + if not value: + _fail( + "EMPTY_ID", + f"Empty {resource_type} ID provided", + {resource_type: ""}, + ) + + if UNSAFE_CHARS.search(value): + _fail( + "INVALID_ID", + f"Invalid {resource_type} ID: contains unsafe characters (?, #, %, control chars, path separators)", + {resource_type: value}, + ) + + if ".." in value: + _fail( + "INVALID_ID", + f"Invalid {resource_type} ID: contains path traversal", + {resource_type: value}, + ) + + return value + + +def validate_workspace_name(value: str) -> str: + """Validate workspace name: alphanumeric, hyphens, underscores.""" + if not value: + _fail("EMPTY_WORKSPACE", "Empty workspace name", {"workspace": ""}) + + if not WORKSPACE_NAME_RE.match(value): + _fail( + "INVALID_WORKSPACE", + "Workspace name must be alphanumeric with hyphens/underscores only", + {"workspace": value}, + ) + + return value + + +def _fail(code: str, message: str, details: dict) -> None: + """Print structured error and exit.""" + from honcho_cli.output import print_error + + print_error(code, message, details) + raise SystemExit(1) diff --git a/honcho-cli/tests/__init__.py b/honcho-cli/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/honcho-cli/tests/test_config.py b/honcho-cli/tests/test_config.py new file mode 100644 index 00000000..0d61636c --- /dev/null +++ b/honcho-cli/tests/test_config.py @@ -0,0 +1,57 @@ +"""Tests for config management.""" + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from honcho_cli.config import CLIConfig + + +class TestCLIConfig: + def test_defaults(self): + config = CLIConfig() + assert config.base_url == "https://api.honcho.dev" + assert config.api_key == "" + assert config.workspace_id == "" + + def test_env_override(self): + with patch.dict(os.environ, {"HONCHO_API_KEY": "test-key", "HONCHO_BASE_URL": "http://localhost:8000"}): + config = CLIConfig.load() + assert config.api_key == "test-key" + assert config.base_url == "http://localhost:8000" + + def test_save_and_load(self, tmp_path): + config_file = tmp_path / "config.toml" + # Clear env vars so they don't override file values + clean_env = {k: v for k, v in os.environ.items() if not k.startswith("HONCHO_")} + with patch("honcho_cli.config.CONFIG_FILE", config_file), patch("honcho_cli.config.CONFIG_DIR", tmp_path), patch.dict(os.environ, clean_env, clear=True): + config = CLIConfig( + base_url="http://localhost:8000", + api_key="test-key-123", + workspace_id="my-ws", + ) + config.save() + + loaded = CLIConfig.load() + assert loaded.base_url == "http://localhost:8000" + assert loaded.api_key == "test-key-123" + assert loaded.workspace_id == "my-ws" + + def test_redacted(self): + config = CLIConfig(api_key="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdef") + redacted = config.redacted() + assert "eyJhbGci" in redacted["api_key"] + assert "cdef" in redacted["api_key"] + assert "..." in redacted["api_key"] + + def test_redacted_short_key(self): + config = CLIConfig(api_key="short") + redacted = config.redacted() + assert redacted["api_key"] == "***" + + def test_redacted_empty_key(self): + config = CLIConfig(api_key="") + redacted = config.redacted() + assert redacted["api_key"] == "" diff --git a/honcho-cli/tests/test_output.py b/honcho-cli/tests/test_output.py new file mode 100644 index 00000000..81b4b93a --- /dev/null +++ b/honcho-cli/tests/test_output.py @@ -0,0 +1,55 @@ +"""Tests for output formatting.""" + +import json + +from honcho_cli.output import is_tty, set_json_mode, set_quiet_mode, use_json + + +class TestOutputModes: + def test_force_json(self): + set_json_mode(True) + assert use_json() is True + set_json_mode(False) + + def test_non_tty_defaults_to_json(self): + # In test context, stdout is not a TTY + set_json_mode(False) + assert use_json() is True # pytest redirects stdout + + +class TestPrintNdjson: + def test_ndjson_format(self, capsys): + from honcho_cli.output import print_ndjson + + items = [{"id": "1", "name": "a"}, {"id": "2", "name": "b"}] + print_ndjson(items) + output = capsys.readouterr().out + lines = output.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"id": "1", "name": "a"} + assert json.loads(lines[1]) == {"id": "2", "name": "b"} + + +class TestPrintJson: + def test_json_format(self, capsys): + from honcho_cli.output import print_json + + data = {"workspace_id": "test", "peer_count": 5} + print_json(data) + output = capsys.readouterr().out + parsed = json.loads(output) + assert parsed["workspace_id"] == "test" + assert parsed["peer_count"] == 5 + + +class TestPrintError: + def test_error_json_format(self, capsys): + set_json_mode(True) + from honcho_cli.output import print_error + + print_error("PEER_NOT_FOUND", "Peer 'abc' not found", {"peer_id": "abc"}) + output = capsys.readouterr().err + parsed = json.loads(output) + assert parsed["error"]["code"] == "PEER_NOT_FOUND" + assert parsed["error"]["details"]["peer_id"] == "abc" + set_json_mode(False) diff --git a/honcho-cli/tests/test_validation.py b/honcho-cli/tests/test_validation.py new file mode 100644 index 00000000..31b070c8 --- /dev/null +++ b/honcho-cli/tests/test_validation.py @@ -0,0 +1,77 @@ +"""Tests for input hardening / validation.""" + +import pytest + +from honcho_cli.validation import validate_resource_id, validate_workspace_name + + +class TestValidateResourceId: + def test_valid_id(self): + assert validate_resource_id("abc123") == "abc123" + + def test_valid_nanoid(self): + assert validate_resource_id("V1StGXR8_Z5jdHi6B-myT") == "V1StGXR8_Z5jdHi6B-myT" + + def test_empty_id(self): + with pytest.raises(SystemExit): + validate_resource_id("") + + def test_question_mark(self): + with pytest.raises(SystemExit): + validate_resource_id("abc?def") + + def test_hash(self): + with pytest.raises(SystemExit): + validate_resource_id("abc#def") + + def test_percent(self): + with pytest.raises(SystemExit): + validate_resource_id("abc%def") + + def test_control_chars(self): + with pytest.raises(SystemExit): + validate_resource_id("abc\x00def") + + def test_null_byte(self): + with pytest.raises(SystemExit): + validate_resource_id("abc\x01def") + + def test_path_traversal(self): + with pytest.raises(SystemExit): + validate_resource_id("../etc/passwd") + + def test_forward_slash(self): + with pytest.raises(SystemExit): + validate_resource_id("abc/def") + + def test_backslash(self): + with pytest.raises(SystemExit): + validate_resource_id("abc\\def") + + def test_tab(self): + with pytest.raises(SystemExit): + validate_resource_id("abc\tdef") + + +class TestValidateWorkspaceName: + def test_valid_name(self): + assert validate_workspace_name("my-workspace") == "my-workspace" + + def test_valid_underscore(self): + assert validate_workspace_name("my_workspace_123") == "my_workspace_123" + + def test_empty_name(self): + with pytest.raises(SystemExit): + validate_workspace_name("") + + def test_spaces(self): + with pytest.raises(SystemExit): + validate_workspace_name("my workspace") + + def test_special_chars(self): + with pytest.raises(SystemExit): + validate_workspace_name("my@workspace") + + def test_dots(self): + with pytest.raises(SystemExit): + validate_workspace_name("my.workspace") diff --git a/honcho-cli/uv.lock b/honcho-cli/uv.lock new file mode 100644 index 00000000..dcadb6f3 --- /dev/null +++ b/honcho-cli/uv.lock @@ -0,0 +1,407 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[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 = "honcho-ai" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/30/d30ba159404050d53b4b1b1c4477f9591f43af18758be1fb7dab6afbfe7d/honcho_ai-2.0.1.tar.gz", hash = "sha256:6fdeebf9454e62bc523d57888e50359e67baafdb21f68621f9c14e08dc00623a", size = 46732, upload-time = "2026-02-09T21:03:26.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/de/83fda0c057cfa11d6b5ed532623184591aa7dcff4a067934ba6811026229/honcho_ai-2.0.1-py3-none-any.whl", hash = "sha256:94887e61d59f353e1e1e20b395858040780f5d67ca1e9d450538646544e4e42f", size = 56780, upload-time = "2026-02-09T21:03:25.992Z" }, +] + +[[package]] +name = "honcho-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "honcho-ai" }, + { name = "httpx" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "honcho-ai", specifier = ">=0.1.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, + { name = "rich", specifier = ">=13.0.0" }, + { name = "typer", specifier = ">=0.15.0" }, +] +provides-extras = ["dev"] + +[[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 = "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]] +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 = "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-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 = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[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 = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" }, +] + +[[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" }, +]