diff --git a/README.md b/README.md index a590571..e8f05cd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,134 @@ -# Silly scripts +# Silly Scripts -Silly scripts for doing random things around +Silly scripts for doing random things around. -* Land of holy [prompts](./prompts.txt) for code assitants ๐Ÿค– -* [Changelog](./changelog) files +## Installation -# Scripts -* ๐Ÿ“• [Re create table of contents](./docs/re_toc_epub.md) in epub +Requires **Python 3.14+** and [uv](https://docs.astral.sh/uv/). +```bash +git clone https://github.com/deti/silly-scripts.git +cd silly-scripts +make init # creates a venv and installs all dependencies +``` +Or manually: + +```bash +uv venv +uv sync +``` + +## Configuration + +Copy the environment template and fill in your values: + +```bash +cp env.template .env +``` + +The most important setting for the `ask-claude` command is the Anthropic API key: + +```bash +# .env +ANTHROPIC_API_KEY=sk-ant-... +``` + +You can also configure defaults for the Claude CLI: + +| Variable | Default | Description | +|---|---|---| +| `ANTHROPIC_API_KEY` | *(required)* | Your Anthropic API key | +| `CLAUDE_DEFAULT_MODEL` | `sonnet` | Model to use (`sonnet`, `opus`, `haiku`) | +| `CLAUDE_DEFAULT_TOOLS` | `Read,Glob,Grep` | Comma-separated tools available to the agent | + +All settings are loaded from the `.env` file at the project root via +[pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/). +See `env.template` for the full list of available options. + +## Usage + +### ask-claude + +Ask Claude a question using the Claude Agent SDK. Run `ask-claude --help` +for the full option list. + +``` +Usage: ask-claude [OPTIONS] [PROMPT] + + Ask Claude a question using the Claude Agent SDK. + + Pass a prompt as a positional argument, via --prompt, or pipe from stdin. + +Options: + -p, --prompt TEXT The prompt to send to Claude (alternative to + positional argument). + -m, --model TEXT Model to use (e.g. sonnet, opus, haiku). + -t, --tools TEXT Allowed tools, comma-separated (e.g. + Read,Glob,Grep). + --system-prompt TEXT Custom system prompt. + --permission-mode [default|acceptEdits|bypassPermissions] + Permission mode for tool execution. + [default: default] + -C, --working-dir DIRECTORY Working directory for the agent. + -v, --verbose Show all message types (tool calls, system). + --json Output raw JSON messages (NDJSON). + --help Show this message and exit. +``` + +### Examples + +**Simple question:** + +```bash +ask-claude "What files are in this directory?" +``` + +**Pipe input from another command:** + +```bash +echo "Summarize this codebase" | ask-claude +``` + +**Choose a model and enable verbose output:** + +```bash +ask-claude "Find TODO comments" -m opus --verbose +``` + +**Use specific tools with a custom working directory:** + +```bash +ask-claude "Refactor the utils module" \ + --tools "Read,Edit,Bash" \ + --permission-mode acceptEdits \ + -C /path/to/project +``` + +**Get structured NDJSON output:** + +```bash +ask-claude --json "List all API endpoints" | jq . +``` + +### Other scripts + +| Command | Description | +|---|---| +| `re-toc-epub` | [Re-create table of contents](./docs/re_toc_epub.md) in an EPUB | +| `speech-to-text` | Transcribe audio using Deepgram | +| `split-video` | Split a video file into segments | +| `m4b-to-m4a` | Convert M4B audiobooks to M4A chapters | +| `show-settings` | Print current application settings | +| `serve` | Start the FastAPI development server | + +## Development + +```bash +make test # run the test suite +make test-cov # run tests with coverage report +make lint # lint and format with ruff +make clean # remove generated files +``` + +Run `make help` for all available targets. diff --git a/docs/adr-001-claude-agent-cli.md b/docs/adr-001-claude-agent-cli.md new file mode 100644 index 0000000..be8c317 --- /dev/null +++ b/docs/adr-001-claude-agent-cli.md @@ -0,0 +1,294 @@ +# ADR-001: CLI Command Wrapping the Claude Agent SDK + +**Status:** Proposed +**Date:** 2026-03-08 +**Context:** task-001 โ€” Design the CLI command that wraps the Claude Agent SDK + +--- + +## 1. Decision Summary + +Add a new CLI command `ask-claude` to the existing `silly-scripts` Python package. The command accepts a natural-language prompt, forwards it to the Claude Agent SDK's `query()` async iterator, and streams all output to stdout in real time. It follows every established project convention: Click for argument parsing, pydantic-settings for configuration, and an independent module under `src/silly_scripts/cli/`. + +--- + +## 2. Target Language / Runtime + +**Python 3.14+** (matches `pyproject.toml` `requires-python`). + +**Rationale โ€” Python over TypeScript:** + +| Criterion | Python | TypeScript | +|-----------|--------|------------| +| Existing project runtime | Yes (`silly-scripts` is Python) | Would require a second runtime | +| Claude Agent SDK maturity | First-class, async iterator API | Equivalent, but adds Node.js dependency | +| CLI framework in use | Click (already a dependency) | Would need a new framework | +| Consistency with other CLIs | Identical pattern | Breaks project uniformity | + +Adding a TypeScript entry point was considered and rejected because it would introduce a second runtime, a second package manager, and a second build system into a single-package Python project. + +--- + +## 3. CLI Argument Interface + +### Command name + +``` +ask-claude +``` + +Registered as a console-script entry point in `pyproject.toml`. + +### Argument parsing (Click) + +| Argument / Flag | Type | Required | Default | Description | +|-----------------|------|----------|---------|-------------| +| `PROMPT` | positional `str` | No* | โ€” | The prompt to send to Claude | +| `--prompt` / `-p` | `str` | No* | โ€” | Alternative: pass prompt via flag | +| `--model` / `-m` | `str` | No | `"sonnet"` | Model to use (e.g. `sonnet`, `opus`, `haiku`) | +| `--tools` / `-t` | `str` (comma-sep) | No | `"Read,Glob,Grep"` | Allowed tools (comma-separated) | +| `--system-prompt` | `str` | No | `None` | Custom system prompt | +| `--permission-mode` | Choice | No | `"default"` | One of `default`, `acceptEdits`, `bypassPermissions` | +| `--working-dir` / `-C` | `Path` | No | `.` | Working directory for agent | +| `--verbose` / `-v` | flag | No | `False` | Show all message types (tool calls, system) | +| `--json` | flag | No | `False` | Output raw JSON messages (one per line, NDJSON) | + +*\* At least one of positional `PROMPT` or `--prompt` must be provided. If neither is given and stdin is a pipe, read from stdin. This three-source strategy (positional โ†’ flag โ†’ stdin) allows ergonomic interactive use and scriptable piping.* + +### Usage examples + +```bash +# Positional (most common) +ask-claude "What files are in this directory?" + +# Flag form (useful when prompt contains shell-special characters) +ask-claude --prompt "Find TODO comments" --tools "Read,Glob,Grep" + +# Pipe from another command +echo "Summarize this codebase" | ask-claude + +# With model and permission overrides +ask-claude "Fix the bug in auth.py" -m opus --permission-mode acceptEdits --tools "Read,Edit,Bash" +``` + +--- + +## 4. SDK Invocation + +### Entry point used + +`claude_agent_sdk.query()` โ€” the async iterator API. This is the correct entry point because: + +1. It streams messages incrementally (required for real-time output). +2. It manages the full agentic loop internally (tool execution, retries, context). +3. It does not require manual tool implementation. + +`ClaudeSDKClient` was considered but rejected for v1 โ€” it adds session-management complexity that is unnecessary for a stateless CLI command. Session support (`--resume `) can be added later without architectural changes. + +### Instantiation pattern + +```python +from claude_agent_sdk import query, ClaudeAgentOptions + +options = ClaudeAgentOptions( + allowed_tools=parsed_tools, # from --tools + permission_mode=permission_mode, # from --permission-mode + system_prompt=system_prompt, # from --system-prompt, if provided +) + +async for message in query(prompt=prompt, options=options): + handle_message(message) +``` + +### API key sourcing + +The SDK reads `ANTHROPIC_API_KEY` from the environment automatically. The CLI does **not** accept an API key as a flag (security: avoids key exposure in shell history and process listings). The CLI validates the env var is set before calling `query()` and emits a clear error message if missing. + +The SDK also supports Bedrock (`CLAUDE_CODE_USE_BEDROCK=1`), Vertex AI (`CLAUDE_CODE_USE_VERTEX=1`), and Azure (`CLAUDE_CODE_USE_FOUNDRY=1`) via environment variables. The CLI inherits this behavior without additional code. + +--- + +## 5. Streaming / Output Strategy + +### Default mode (human-readable) + +All `AssistantMessage` text blocks are printed to **stdout** as they arrive, producing a live-typing effect. Tool invocations are printed as single summary lines to **stderr** (so they can be suppressed or redirected independently). + +``` +[stdout] Claude's reasoning text streams here in real time... +[stderr] [tool] Read: src/auth.py +[stderr] [tool] Edit: src/auth.py +[stdout] I've fixed the null-check bug in the authentication module. +``` + +### `--verbose` mode + +All message types are printed, including system init, tool inputs/outputs, and result metadata. Useful for debugging. + +### `--json` mode (machine-readable) + +Each SDK message is serialized as a single JSON line to stdout (NDJSON format). This enables piping to `jq`, log aggregators, or downstream programs. + +``` +{"type":"system","subtype":"init","session_id":"abc123"} +{"type":"assistant","content":[{"type":"text","text":"Reading auth.py..."}]} +{"type":"result","subtype":"success","result":"..."} +``` + +### Output contract + +| Stream | Content | +|--------|---------| +| stdout | Agent's textual output (human mode) or full NDJSON (json mode) | +| stderr | Tool-call summaries (human mode), warnings, errors | + +This separation lets callers capture the "answer" from stdout while still seeing operational detail on stderr. + +--- + +## 6. Error Handling Strategy + +### Error categories and behavior + +| Error | Detection | User message | Exit code | +|-------|-----------|-------------|-----------| +| Missing API key | Check `ANTHROPIC_API_KEY` before SDK call | `Error: ANTHROPIC_API_KEY environment variable is not set.` | 2 | +| Invalid arguments | Click validation | Click's built-in error formatting | 2 | +| SDK authentication failure | Catch SDK auth exception | `Error: Authentication failed. Verify your API key.` | 3 | +| SDK rate limit / quota | Catch SDK rate-limit exception | `Error: Rate limited. Retry after {n} seconds.` | 4 | +| Network / transient error | Catch `ConnectionError` / SDK transport errors | `Error: Network error: {detail}` | 5 | +| Agent task failure | `ResultMessage` with error subtype | Print error detail, no stack trace | 1 | +| Unexpected exception | Top-level `except Exception` | `Error: Unexpected failure: {detail}` + suggest `--verbose` | 99 | +| Keyboard interrupt | `KeyboardInterrupt` handler | `\nInterrupted.` (clean newline) | 130 | + +### Design principles + +1. **No stack traces by default.** Users see a one-line message. `--verbose` adds the traceback. +2. **Errors to stderr.** stdout remains clean for piping. +3. **Consistent with existing CLIs.** Uses `click.ClickException` where appropriate (exit code 1 for Click errors) and `sys.exit(n)` for SDK-specific codes. + +--- + +## 7. Exit Code Conventions + +| Code | Meaning | +|------|---------| +| 0 | Success โ€” agent completed task | +| 1 | Agent reported a task-level failure | +| 2 | Usage error (bad args, missing env var) | +| 3 | Authentication error | +| 4 | Rate limit / quota exceeded | +| 5 | Network / transient error | +| 99 | Unexpected internal error | +| 130 | Interrupted (SIGINT / Ctrl-C) | + +These follow POSIX conventions (0 = success, 1 = general error, 2 = usage error, 128+signal for signals). + +--- + +## 8. Project Structure + +### New files + +``` +src/silly_scripts/cli/ + ask_claude.py # Click command โ€” the only new module +``` + +### Module layout (`ask_claude.py`) + +``` +ask_claude.py +โ”œโ”€โ”€ main() # Click command entry point +โ”œโ”€โ”€ _resolve_prompt() # Positional / flag / stdin resolution +โ”œโ”€โ”€ _parse_tools() # Comma-separated string โ†’ list +โ”œโ”€โ”€ _run_agent() # async: calls query(), yields messages +โ”œโ”€โ”€ _print_human() # Human-readable formatter +โ”œโ”€โ”€ _print_json() # NDJSON formatter +โ””โ”€โ”€ _handle_error() # Categorize exception โ†’ exit code +``` + +No new packages or sub-packages. One file, following the project's established pattern where each CLI command is a single self-contained module. + +### Entry point registration (`pyproject.toml`) + +```toml +[project.scripts] +# ... existing entries ... +ask-claude = "silly_scripts.cli.ask_claude:main" +``` + +### New dependency + +```toml +[project] +dependencies = [ + # ... existing ... + "claude-agent-sdk", +] +``` + +No other new dependencies. `click` and `pydantic-settings` are already present. + +--- + +## 9. Configuration via pydantic-settings + +Extend the existing `Settings` class in `src/silly_scripts/settings.py`: + +```python +class Settings(BaseSettings): + # ... existing fields ... + + # Claude Agent SDK + anthropic_api_key: str | None = None # read from ANTHROPIC_API_KEY + claude_default_model: str = "sonnet" # overridable default + claude_default_tools: str = "Read,Glob,Grep" # overridable default +``` + +This enables `.env` file support (already wired via `pydantic-settings`) and a single source of truth for defaults. CLI flags override settings values; settings values override hardcoded defaults. + +**Precedence:** CLI flag > environment variable (via Settings) > hardcoded default. + +--- + +## 10. Architectural Invariants + +1. **No SDK leakage.** `ask_claude.py` is the only module that imports from `claude_agent_sdk`. No other CLI or library module depends on it. +2. **Stateless.** Each invocation is independent. No session persistence in v1. +3. **No interactive prompts during agent execution.** `permission_mode` is set at invocation time. The CLI does not implement a `canUseTool` callback in v1 (would require a TUI, which is out of scope). +4. **stdout/stderr contract.** Answer text on stdout, operational detail on stderr. This is a public API that scripts may depend on. + +--- + +## 11. Alternatives Considered + +| Decision | Alternative | Why rejected | +|----------|-------------|-------------| +| Python | TypeScript/Node.js | Adds second runtime to a Python-only project | +| `query()` iterator | `ClaudeSDKClient` | Adds session complexity not needed for stateless CLI | +| Click | argparse | Click is already a project dependency; consistency wins | +| Single module | Separate package | Violates project's one-package structure | +| API key via flag | `--api-key` flag | Security risk (shell history, `/proc` exposure) | +| Interactive permission prompts | `canUseTool` callback | Requires TUI; out of scope for v1 | + +--- + +## 12. Future Extensions (out of scope for v1) + +- **Session resume:** `--resume ` flag, using `ClaudeSDKClient`. +- **MCP server attachment:** `--mcp ` flag. +- **Interactive permission mode:** TUI-based `canUseTool` callback (e.g. via `rich` or `prompt_toolkit`). +- **Subagent definitions:** `--agent ` for custom subagents. +- **Config file:** `~/.config/silly-scripts/claude.toml` for persistent defaults. + +These can be added incrementally without changing the core architecture. + +--- + +## Sources + +- [Claude Agent SDK Overview](https://platform.claude.com/docs/en/agent-sdk/overview) +- [Claude Agent SDK Quickstart](https://platform.claude.com/docs/en/agent-sdk/quickstart) +- [claude-agent-sdk on PyPI](https://pypi.org/project/claude-agent-sdk/) +- [@anthropic-ai/claude-agent-sdk on npm](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk) diff --git a/env.template b/env.template index 7ba2a76..4959723 100644 --- a/env.template +++ b/env.template @@ -21,3 +21,11 @@ ENVIRONMENT=development # Port # PORT=80 + +# Claude Agent SDK +# API key for Anthropic (required for ask-claude command) +# ANTHROPIC_API_KEY=sk-ant-... +# Default model for ask-claude (e.g. sonnet, opus, haiku) +# CLAUDE_DEFAULT_MODEL=sonnet +# Default comma-separated tools for ask-claude +# CLAUDE_DEFAULT_TOOLS=Read,Glob,Grep diff --git a/pyproject.toml b/pyproject.toml index eaa895a..a08765a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "ebooklib", "lxml", "deepgram-sdk>=5.3.0", + "claude-agent-sdk", ] [dependency-groups] @@ -40,6 +41,7 @@ re-toc-epub = "silly_scripts.cli.re_toc_epub:main" speech-to-text = "silly_scripts.cli.speech_to_text:main" m4b-to-m4a = "silly_scripts.cli.m4b_to_m4a:main" split-video = "silly_scripts.cli.split_video:main" +ask-claude = "silly_scripts.cli.ask_claude:main" # Configure hatchling to find packages in src/ [tool.hatch.build.targets.wheel] diff --git a/src/silly_scripts/cli/ask_claude.py b/src/silly_scripts/cli/ask_claude.py new file mode 100644 index 0000000..f0d1a70 --- /dev/null +++ b/src/silly_scripts/cli/ask_claude.py @@ -0,0 +1,272 @@ +"""Ask Claude a question using the Claude Agent SDK.""" + +import asyncio +import json +import logging +import sys +from dataclasses import asdict +from pathlib import Path + +import click +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKError, + CLINotFoundError, + ProcessError, + ResultMessage, + SystemMessage, + TextBlock, + ToolUseBlock, + query, +) + +from silly_scripts.settings import get_settings + + +logger = logging.getLogger(__name__) + + +def _resolve_prompt(prompt_arg: str | None, prompt_flag: str | None) -> str: + """Resolve the prompt from positional arg, flag, or stdin. + + Args: + prompt_arg: Positional argument value. + prompt_flag: --prompt flag value. + + Returns: + The resolved prompt string. + + Raises: + click.UsageError: If no prompt is provided. + """ + if prompt_arg: + return prompt_arg + if prompt_flag: + return prompt_flag + if not sys.stdin.isatty(): + text = sys.stdin.read().strip() + if text: + return text + msg = ( + "No prompt provided. Pass a prompt as an argument, " + "via --prompt, or pipe to stdin." + ) + raise click.UsageError(msg) + + +def _parse_tools(tools_str: str) -> list[str]: + """Parse a comma-separated tools string into a list. + + Args: + tools_str: Comma-separated tool names (e.g. "Read,Glob,Grep"). + + Returns: + List of tool name strings. + """ + return [t.strip() for t in tools_str.split(",") if t.strip()] + + +@click.command() +@click.argument("prompt", required=False, default=None) +@click.option( + "--prompt", + "-p", + "prompt_flag", + default=None, + help="The prompt to send to Claude (alternative to positional argument).", +) +@click.option( + "--model", + "-m", + default=None, + help="Model to use (e.g. sonnet, opus, haiku).", +) +@click.option( + "--tools", + "-t", + default=None, + help="Allowed tools, comma-separated (e.g. Read,Glob,Grep).", +) +@click.option( + "--system-prompt", + default=None, + help="Custom system prompt.", +) +@click.option( + "--permission-mode", + type=click.Choice(["default", "acceptEdits", "bypassPermissions"]), + default="default", + show_default=True, + help="Permission mode for tool execution.", +) +@click.option( + "--working-dir", + "-C", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=None, + help="Working directory for the agent.", +) +@click.option( + "--verbose", + "-v", + is_flag=True, + default=False, + help="Show all message types (tool calls, system).", +) +@click.option( + "--json", + "json_mode", + is_flag=True, + default=False, + help="Output raw JSON messages (NDJSON).", +) +def main( + prompt: str | None, + prompt_flag: str | None, + model: str | None, + tools: str | None, + system_prompt: str | None, + permission_mode: str, + working_dir: Path | None, + verbose: bool, + json_mode: bool, +) -> None: + """Ask Claude a question using the Claude Agent SDK. + + Pass a prompt as a positional argument, via --prompt, or pipe from stdin. + + \b + Examples: + ask-claude "What files are in this directory?" + ask-claude --prompt "Find TODO comments" --tools "Read,Glob,Grep" + echo "Summarize this codebase" | ask-claude + ask-claude "Fix the bug" -m opus --permission-mode acceptEdits + """ + logging.basicConfig( + level=logging.DEBUG if verbose else logging.WARNING, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + resolved_prompt = _resolve_prompt(prompt, prompt_flag) + + settings = get_settings() + resolved_model = model or settings.claude_default_model + resolved_tools = _parse_tools(tools or settings.claude_default_tools) + + logger.debug(f"Prompt: {resolved_prompt}") + logger.debug(f"Model: {resolved_model}") + logger.debug(f"Tools: {resolved_tools}") + logger.debug(f"System prompt: {system_prompt}") + logger.debug(f"Permission mode: {permission_mode}") + logger.debug(f"Working dir: {working_dir}") + logger.debug(f"JSON mode: {json_mode}") + + api_key = settings.anthropic_api_key + if not api_key: + msg = ( + "Anthropic API key not set. " + "Export ANTHROPIC_API_KEY or add it to your .env file." + ) + raise click.ClickException(msg) + + options = ClaudeAgentOptions( + model=resolved_model, + allowed_tools=resolved_tools, + permission_mode=permission_mode, + cwd=str(working_dir) if working_dir else None, + system_prompt=system_prompt, + ) + + try: + asyncio.run( + _run_query(resolved_prompt, options, verbose=verbose, json_mode=json_mode) + ) + except KeyboardInterrupt: + click.echo("\nInterrupted.", err=True) + raise SystemExit(130) # noqa: B904 โ€” intentional re-raise as SystemExit + except CLINotFoundError as exc: + logger.debug("CLI not found", exc_info=True) + msg = f"Claude Code CLI not found. Is it installed? ({exc})" + raise click.ClickException(msg) from exc + except ProcessError as exc: + logger.debug("Process error", exc_info=True) + msg = f"Claude process failed: {exc}" + raise click.ClickException(msg) from exc + except ClaudeSDKError as exc: + # TODO: Differentiate auth/rate-limit/network errors when SDK + # exposes specific exception types (ADR-001 exit codes 3-5). + logger.debug("SDK error", exc_info=True) + msg = f"Claude SDK error: {exc}" + raise click.ClickException(msg) from exc + + +async def _run_query( + prompt: str, + options: ClaudeAgentOptions, + *, + verbose: bool, + json_mode: bool, +) -> None: + """Send a prompt to Claude and stream output to stdout. + + Args: + prompt: The user prompt to send. + options: SDK options for the query. + verbose: If True, show tool calls and system messages. + json_mode: If True, output each message as NDJSON. + """ + async for message in query(prompt=prompt, options=options): + if json_mode: + _print_json(message) + continue + + if isinstance(message, AssistantMessage): + _print_assistant(message, verbose=verbose) + elif isinstance(message, SystemMessage) and verbose: + click.echo(f"[system:{message.subtype}] {message.data}", err=True) + elif isinstance(message, ResultMessage): + logger.debug( + f"Done: {message.num_turns} turns, " + f"{message.duration_ms}ms, " + f"cost=${message.total_cost_usd}" + ) + if message.is_error: + msg = f"Claude returned an error (session {message.session_id})." + raise click.ClickException(msg) + + +def _print_assistant(message: AssistantMessage, *, verbose: bool) -> None: + """Print assistant message content blocks to stdout. + + Args: + message: The assistant message to print. + verbose: If True, also print tool-use blocks. + """ + for block in message.content: + if isinstance(block, TextBlock): + click.echo(block.text) + # NOTE: Deviates from ADR-001 โ€” tool calls hidden by default for + # cleaner output. ADR specifies short summaries always on stderr. + elif isinstance(block, ToolUseBlock) and verbose: + click.echo( + f"[tool:{block.name}] {json.dumps(block.input, indent=2)}", + err=True, + ) + + +def _print_json(message: object) -> None: + """Print a message as a single NDJSON line to stdout. + + Args: + message: The SDK message object to serialize. + """ + try: + data = asdict(message) # type: ignore[arg-type] + except TypeError: + data = {"raw": str(message)} + click.echo(json.dumps(data, default=str)) + + +if __name__ == "__main__": + main() # pragma: no cover diff --git a/src/silly_scripts/settings.py b/src/silly_scripts/settings.py index abe8b7e..43c5251 100644 --- a/src/silly_scripts/settings.py +++ b/src/silly_scripts/settings.py @@ -54,6 +54,18 @@ class Settings(BaseSettings): default="", description="Deepgram API key for speech-to-text transcription.", ) + anthropic_api_key: str | None = Field( + default=None, + description="Anthropic API key for Claude Agent SDK.", + ) + claude_default_model: str = Field( + default="sonnet", + description="Default model for ask-claude CLI command.", + ) + claude_default_tools: str = Field( + default="Read,Glob,Grep", + description="Default comma-separated tools for ask-claude CLI command.", + ) # Pydantic v2 settings config model_config = SettingsConfigDict( # Read .env from the project root diff --git a/tests/cli/test_ask_claude.py b/tests/cli/test_ask_claude.py new file mode 100644 index 0000000..d5d8720 --- /dev/null +++ b/tests/cli/test_ask_claude.py @@ -0,0 +1,1083 @@ +"""Tests for the ask_claude CLI command.""" + +import json +from unittest.mock import patch + +import click +import pytest +from claude_agent_sdk import ( + AssistantMessage, + ClaudeAgentOptions, + ClaudeSDKError, + CLINotFoundError, + ProcessError, + ResultMessage, + SystemMessage, + TextBlock, + ToolUseBlock, +) +from click.testing import CliRunner + +from silly_scripts.cli.ask_claude import ( + _parse_tools, + _print_assistant, + _print_json, + _resolve_prompt, + _run_query, + main, +) +from silly_scripts.settings import get_settings + + +@pytest.fixture(autouse=True) +def _clear_settings_cache() -> None: + """Clear settings cache before and after each test.""" + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +class TestResolvePrompt: + """Tests for prompt resolution logic.""" + + def test_positional_arg_wins(self) -> None: + """Positional argument is used when provided.""" + assert _resolve_prompt("hello", None) == "hello" + + def test_flag_used_when_no_positional(self) -> None: + """Flag is used when positional is absent.""" + assert _resolve_prompt(None, "from flag") == "from flag" + + def test_positional_takes_precedence_over_flag(self) -> None: + """Positional argument takes precedence over flag.""" + assert _resolve_prompt("positional", "flag") == "positional" + + def test_raises_when_no_prompt(self) -> None: + """Raises UsageError when no prompt is provided and stdin is a tty.""" + with patch("silly_scripts.cli.ask_claude.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = True + with pytest.raises(Exception, match="No prompt provided"): + _resolve_prompt(None, None) + + def test_reads_from_stdin_pipe(self) -> None: + """Reads prompt from stdin when piped.""" + with patch("silly_scripts.cli.ask_claude.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + mock_stdin.read.return_value = "piped input\n" + assert _resolve_prompt(None, None) == "piped input" + + def test_raises_on_empty_stdin(self) -> None: + """Raises UsageError when stdin pipe is empty.""" + with patch("silly_scripts.cli.ask_claude.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + mock_stdin.read.return_value = " " + with pytest.raises(Exception, match="No prompt provided"): + _resolve_prompt(None, None) + + +class TestParseTools: + """Tests for tool string parsing.""" + + def test_basic_comma_separated(self) -> None: + """Parses comma-separated tools.""" + assert _parse_tools("Read,Glob,Grep") == ["Read", "Glob", "Grep"] + + def test_whitespace_trimmed(self) -> None: + """Trims whitespace around tool names.""" + assert _parse_tools("Read , Glob , Grep") == ["Read", "Glob", "Grep"] + + def test_empty_entries_ignored(self) -> None: + """Ignores empty entries from trailing commas.""" + assert _parse_tools("Read,,Grep,") == ["Read", "Grep"] + + def test_single_tool(self) -> None: + """Handles a single tool.""" + assert _parse_tools("Read") == ["Read"] + + +class TestPrintAssistant: + """Tests for assistant message printing.""" + + def test_prints_text_blocks(self, capsys: pytest.CaptureFixture[str]) -> None: + """Prints text content from assistant messages.""" + msg = AssistantMessage(content=[TextBlock(text="Hello world")], model="sonnet") + _print_assistant(msg, verbose=False) + captured = capsys.readouterr() + assert "Hello world" in captured.out + + def test_hides_tool_use_when_not_verbose( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Tool-use blocks are hidden when verbose is False.""" + msg = AssistantMessage( + content=[ToolUseBlock(id="1", name="Read", input={"path": "/tmp"})], + model="sonnet", + ) + _print_assistant(msg, verbose=False) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_shows_tool_use_when_verbose( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Tool-use blocks are shown on stderr when verbose is True.""" + msg = AssistantMessage( + content=[ToolUseBlock(id="1", name="Read", input={"path": "/tmp"})], + model="sonnet", + ) + _print_assistant(msg, verbose=True) + captured = capsys.readouterr() + assert "[tool:Read]" in captured.err + assert "/tmp" in captured.err + + +class TestPrintJson: + """Tests for NDJSON output.""" + + def test_serializes_dataclass(self, capsys: pytest.CaptureFixture[str]) -> None: + """Dataclass messages are serialized as JSON.""" + block = TextBlock(text="hello") + _print_json(block) + captured = capsys.readouterr() + data = json.loads(captured.out.strip()) + assert data["text"] == "hello" + + def test_fallback_for_non_dataclass( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Non-dataclass objects are serialized with fallback.""" + _print_json("plain string") + captured = capsys.readouterr() + data = json.loads(captured.out.strip()) + assert "raw" in data + + +def _make_success_messages(text: str = "Answer") -> list: + """Build a standard success message sequence for tests.""" + return [ + AssistantMessage(content=[TextBlock(text=text)], model="sonnet"), + ResultMessage( + subtype="result", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + +def _make_result_only(*, is_error: bool = False, session_id: str = "s1") -> list: + """Build a result-only message sequence for tests.""" + return [ + ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=is_error, + num_turns=1, + session_id=session_id, + ), + ] + + +class TestRunQuery: + """Tests for the async _run_query function.""" + + @pytest.mark.asyncio + async def test_streams_text_output( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Text blocks from assistant messages are printed to stdout.""" + messages = _make_success_messages("Hello!") + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=False + ) + + captured = capsys.readouterr() + assert "Hello!" in captured.out + + @pytest.mark.asyncio + async def test_raises_on_error_result(self) -> None: + """Raises ClickException when result message indicates an error.""" + messages = _make_result_only(is_error=True, session_id="err-session") + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with ( + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + pytest.raises(Exception, match="error"), + ): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=False + ) + + @pytest.mark.asyncio + async def test_json_mode_outputs_ndjson( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """In JSON mode, messages are serialized as NDJSON.""" + messages = _make_success_messages("Hi") + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=True + ) + + captured = capsys.readouterr() + lines = [line for line in captured.out.strip().split("\n") if line] + assert len(lines) == 2 + # Each line should be valid JSON + for line in lines: + json.loads(line) + + +class TestCli: + """Integration tests for the Click CLI.""" + + def test_help_flag(self) -> None: + """--help prints usage and examples without crashing.""" + runner = CliRunner() + result = runner.invoke(main, ["--help"]) + assert result.exit_code == 0 + assert "Ask Claude a question" in result.output + assert "--model" in result.output + assert "--tools" in result.output + assert "--verbose" in result.output + assert "--json" in result.output + assert "--system-prompt" in result.output + assert "--permission-mode" in result.output + assert "--working-dir" in result.output + assert "Examples:" in result.output + assert "ask-claude" in result.output + + def test_no_args_prints_error(self) -> None: + """Running with no args and no stdin prints an error.""" + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code != 0 + assert "No prompt provided" in result.output + + def test_missing_api_key_prints_error(self) -> None: + """Missing API key produces a clear error and non-zero exit code.""" + runner = CliRunner() + with patch.dict("os.environ", {}, clear=True): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"], catch_exceptions=False) + assert result.exit_code != 0 + assert "API key" in result.output + + def test_successful_query_exits_zero(self) -> None: + """Successful query exits with code 0.""" + messages = _make_success_messages() + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"]) + + assert result.exit_code == 0 + assert "Answer" in result.output + + def test_sdk_error_exits_nonzero(self) -> None: + """SDK errors produce non-zero exit code with message.""" + + async def mock_query(*, prompt, options): # noqa: ARG001 + msg = "connection lost" + raise ClaudeSDKError(msg) + yield # make it an async generator # pragma: no cover + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"]) + + assert result.exit_code != 0 + assert "SDK error" in result.output + + def test_cli_not_found_exits_nonzero(self) -> None: + """CLINotFoundError produces non-zero exit code with message.""" + + async def mock_query(*, prompt, options): # noqa: ARG001 + raise CLINotFoundError + yield # make it an async generator # pragma: no cover + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"]) + + assert result.exit_code != 0 + assert "not found" in result.output + + def test_process_error_exits_nonzero(self) -> None: + """ProcessError produces non-zero exit code with message.""" + + async def mock_query(*, prompt, options): # noqa: ARG001 + msg = "process crashed" + raise ProcessError(msg) + yield # make it an async generator # pragma: no cover + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"]) + + assert result.exit_code != 0 + assert "process failed" in result.output + + def test_keyboard_interrupt_exits_130(self) -> None: + """KeyboardInterrupt produces exit code 130 with message.""" + + async def mock_query(*, prompt, options): # noqa: ARG001 + raise KeyboardInterrupt + yield # make it an async generator # pragma: no cover + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"]) + + assert result.exit_code == 130 + + def test_invalid_permission_mode_rejected(self) -> None: + """Invalid permission mode is rejected by Click.""" + runner = CliRunner() + result = runner.invoke(main, ["hello", "--permission-mode", "invalid"]) + assert result.exit_code != 0 + assert "Invalid value" in result.output + + def test_stdin_pipe_with_api_key(self) -> None: + """Piped stdin is accepted as prompt when API key is set.""" + messages = _make_result_only() + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, [], input="piped prompt\n") + + assert result.exit_code == 0 + + def test_model_flag_passed_to_options(self) -> None: + """--model flag value is forwarded to SDK options.""" + captured_options = {} + + async def mock_query(*, prompt, options): # noqa: ARG001 + captured_options["model"] = options.model + yield ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=False, + num_turns=1, + session_id="s1", + ) + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["hello", "--model", "opus"]) + + assert result.exit_code == 0 + assert captured_options["model"] == "opus" + + +# --------------------------------------------------------------------------- +# Additional unit tests: edge cases for prompt resolution +# --------------------------------------------------------------------------- + + +class TestResolvePromptEdgeCases: + """Edge-case tests for _resolve_prompt.""" + + def test_empty_string_positional_falls_through_to_flag(self) -> None: + """Empty string positional arg is falsy, so flag is used instead.""" + assert _resolve_prompt("", "fallback") == "fallback" + + def test_empty_string_both_raises(self) -> None: + """Empty string for both positional and flag raises UsageError.""" + with patch("silly_scripts.cli.ask_claude.sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = True + with pytest.raises(click.UsageError, match="No prompt provided"): + _resolve_prompt("", "") + + def test_whitespace_only_positional_falls_through(self) -> None: + """Whitespace-only positional is truthy but preserved as-is.""" + # " " is truthy, so it's returned directly + assert _resolve_prompt(" ", None) == " " + + def test_very_long_prompt_accepted(self) -> None: + """Very long prompts are accepted without truncation.""" + long_prompt = "x" * 100_000 + assert _resolve_prompt(long_prompt, None) == long_prompt + + def test_prompt_with_newlines(self) -> None: + """Prompts containing newlines are preserved.""" + multiline = "line1\nline2\nline3" + assert _resolve_prompt(multiline, None) == multiline + + def test_prompt_with_unicode(self) -> None: + """Unicode characters in prompts are preserved.""" + unicode_prompt = "ใ“ใ‚“ใซใกใฏ ๐ŸŒ rรฉsumรฉ" + assert _resolve_prompt(unicode_prompt, None) == unicode_prompt + + +# --------------------------------------------------------------------------- +# Additional unit tests: edge cases for tool parsing +# --------------------------------------------------------------------------- + + +class TestParseToolsEdgeCases: + """Edge-case tests for _parse_tools.""" + + def test_empty_string_returns_empty_list(self) -> None: + """Empty string returns an empty list.""" + assert _parse_tools("") == [] + + def test_only_commas_returns_empty_list(self) -> None: + """String of only commas returns an empty list.""" + assert _parse_tools(",,,") == [] + + def test_only_whitespace_returns_empty_list(self) -> None: + """Whitespace-only entries are filtered out.""" + assert _parse_tools(" , , ") == [] + + +# --------------------------------------------------------------------------- +# Additional unit tests: _print_assistant with multiple blocks +# --------------------------------------------------------------------------- + + +class TestPrintAssistantEdgeCases: + """Edge-case tests for _print_assistant.""" + + def test_multiple_text_blocks_printed_in_order( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Multiple text blocks in one message are all printed to stdout.""" + msg = AssistantMessage( + content=[ + TextBlock(text="first"), + TextBlock(text="second"), + TextBlock(text="third"), + ], + model="sonnet", + ) + _print_assistant(msg, verbose=False) + captured = capsys.readouterr() + assert "first" in captured.out + assert "second" in captured.out + assert "third" in captured.out + # Verify order: first before second before third + assert captured.out.index("first") < captured.out.index("second") + assert captured.out.index("second") < captured.out.index("third") + + def test_mixed_blocks_text_and_tool( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Text blocks are printed, tool blocks hidden when not verbose.""" + msg = AssistantMessage( + content=[ + TextBlock(text="visible text"), + ToolUseBlock(id="t1", name="Grep", input={"pattern": "foo"}), + ], + model="sonnet", + ) + _print_assistant(msg, verbose=False) + captured = capsys.readouterr() + assert "visible text" in captured.out + assert "Grep" not in captured.out + assert "Grep" not in captured.err + + def test_empty_content_list(self, capsys: pytest.CaptureFixture[str]) -> None: + """Message with empty content list produces no output.""" + msg = AssistantMessage(content=[], model="sonnet") + _print_assistant(msg, verbose=False) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + def test_empty_text_block(self, capsys: pytest.CaptureFixture[str]) -> None: + """Text block with empty string still calls echo (prints newline).""" + msg = AssistantMessage(content=[TextBlock(text="")], model="sonnet") + _print_assistant(msg, verbose=False) + captured = capsys.readouterr() + # click.echo("") prints a newline + assert captured.out == "\n" + + +# --------------------------------------------------------------------------- +# Additional unit tests: _print_json edge cases +# --------------------------------------------------------------------------- + + +class TestPrintJsonEdgeCases: + """Edge-case tests for _print_json.""" + + def test_result_message_serialized( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """ResultMessage with cost field serializes correctly.""" + msg = ResultMessage( + subtype="result", + duration_ms=200, + duration_api_ms=150, + is_error=False, + num_turns=3, + session_id="s42", + total_cost_usd=0.05, + ) + _print_json(msg) + captured = capsys.readouterr() + data = json.loads(captured.out.strip()) + assert data["session_id"] == "s42" + assert data["num_turns"] == 3 + assert data["total_cost_usd"] == 0.05 + + def test_assistant_message_with_tool_use_serialized( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """AssistantMessage containing ToolUseBlock serializes as JSON.""" + msg = AssistantMessage( + content=[ToolUseBlock(id="t1", name="Read", input={"path": "/x"})], + model="opus", + ) + _print_json(msg) + captured = capsys.readouterr() + data = json.loads(captured.out.strip()) + assert data["model"] == "opus" + assert data["content"][0]["name"] == "Read" + + +# --------------------------------------------------------------------------- +# Additional async tests: _run_query streaming and message types +# --------------------------------------------------------------------------- + + +class TestRunQueryEdgeCases: + """Edge-case tests for the async _run_query function.""" + + @pytest.mark.asyncio + async def test_multiple_assistant_messages_streamed( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Multiple assistant messages stream text incrementally to stdout.""" + messages = [ + AssistantMessage(content=[TextBlock(text="chunk1")], model="sonnet"), + AssistantMessage(content=[TextBlock(text="chunk2")], model="sonnet"), + AssistantMessage(content=[TextBlock(text="chunk3")], model="sonnet"), + ResultMessage( + subtype="result", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=False + ) + + captured = capsys.readouterr() + assert "chunk1" in captured.out + assert "chunk2" in captured.out + assert "chunk3" in captured.out + + @pytest.mark.asyncio + async def test_system_message_hidden_when_not_verbose( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """System messages are hidden when verbose is False.""" + messages = [ + SystemMessage(subtype="init", data={"version": "1.0"}), + ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=False + ) + + captured = capsys.readouterr() + assert captured.out == "" + assert "init" not in captured.err + + @pytest.mark.asyncio + async def test_system_message_shown_when_verbose( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """System messages are shown on stderr when verbose is True.""" + messages = [ + SystemMessage(subtype="init", data={"version": "1.0"}), + ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + "test", ClaudeAgentOptions(), verbose=True, json_mode=False + ) + + captured = capsys.readouterr() + assert "[system:init]" in captured.err + + @pytest.mark.asyncio + async def test_error_result_includes_session_id(self) -> None: + """Error result ClickException message includes session ID.""" + messages = [ + ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=True, + num_turns=1, + session_id="err-sess-42", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with ( + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + pytest.raises(click.ClickException, match="err-sess-42"), + ): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=False + ) + + @pytest.mark.asyncio + async def test_json_mode_system_message_serialized( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """In JSON mode, system messages are also serialized as NDJSON.""" + messages = [ + SystemMessage(subtype="init", data={"version": "1.0"}), + ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + "test", ClaudeAgentOptions(), verbose=False, json_mode=True + ) + + captured = capsys.readouterr() + lines = [line for line in captured.out.strip().split("\n") if line] + assert len(lines) == 2 + system_data = json.loads(lines[0]) + assert system_data["subtype"] == "init" + + @pytest.mark.asyncio + async def test_very_long_prompt_forwarded(self) -> None: + """Very long prompts are forwarded to the SDK without truncation.""" + long_prompt = "a" * 100_000 + captured_prompt = {} + + async def mock_query(*, prompt, options): # noqa: ARG001 + captured_prompt["value"] = prompt + for msg in _make_result_only(): + yield msg + + with patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query): + await _run_query( + long_prompt, ClaudeAgentOptions(), verbose=False, json_mode=False + ) + + assert captured_prompt["value"] == long_prompt + assert len(captured_prompt["value"]) == 100_000 + + +# --------------------------------------------------------------------------- +# Additional CLI integration tests: options forwarding +# --------------------------------------------------------------------------- + + +class TestCliOptionsForwarding: + """Integration tests verifying all CLI options are forwarded to the SDK.""" + + def _run_with_captured_options( + self, cli_args: list[str], *, input_text: str | None = None + ) -> tuple: + """Helper: run CLI, capture prompt + options passed to query().""" + captured = {} + + async def mock_query(*, prompt, options): + captured["prompt"] = prompt + captured["options"] = options + for msg in _make_result_only(): + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, cli_args, input=input_text) + + return result, captured + + def test_tools_flag_forwarded(self) -> None: + """--tools flag is parsed and forwarded to allowed_tools.""" + result, captured = self._run_with_captured_options( + ["hello", "--tools", "Write,Edit,Bash"] + ) + assert result.exit_code == 0 + assert captured["options"].allowed_tools == ["Write", "Edit", "Bash"] + + def test_system_prompt_forwarded(self) -> None: + """--system-prompt flag is forwarded to options.""" + result, captured = self._run_with_captured_options( + ["hello", "--system-prompt", "Be concise."] + ) + assert result.exit_code == 0 + assert captured["options"].system_prompt == "Be concise." + + def test_permission_mode_forwarded(self) -> None: + """--permission-mode flag is forwarded to options.""" + result, captured = self._run_with_captured_options( + ["hello", "--permission-mode", "bypassPermissions"] + ) + assert result.exit_code == 0 + assert captured["options"].permission_mode == "bypassPermissions" + + def test_working_dir_forwarded(self, tmp_path: pytest.TempPathFactory) -> None: + """--working-dir flag is forwarded to options.cwd.""" + result, captured = self._run_with_captured_options( + ["hello", "--working-dir", str(tmp_path)] + ) + assert result.exit_code == 0 + assert captured["options"].cwd == str(tmp_path) + + def test_prompt_flag_short_form(self) -> None: + """-p short form for --prompt is accepted.""" + result, captured = self._run_with_captured_options(["-p", "short flag prompt"]) + assert result.exit_code == 0 + assert captured["prompt"] == "short flag prompt" + + def test_model_short_form(self) -> None: + """-m short form for --model is accepted.""" + result, captured = self._run_with_captured_options(["hello", "-m", "haiku"]) + assert result.exit_code == 0 + assert captured["options"].model == "haiku" + + def test_tools_short_form(self) -> None: + """-t short form for --tools is accepted.""" + result, captured = self._run_with_captured_options(["hello", "-t", "Read"]) + assert result.exit_code == 0 + assert captured["options"].allowed_tools == ["Read"] + + def test_default_model_from_settings(self) -> None: + """Default model comes from settings when --model is not provided.""" + result, captured = self._run_with_captured_options(["hello"]) + assert result.exit_code == 0 + # Default from settings is "sonnet" + assert captured["options"].model == "sonnet" + + def test_default_tools_from_settings(self) -> None: + """Default tools come from settings when --tools is not provided.""" + result, captured = self._run_with_captured_options(["hello"]) + assert result.exit_code == 0 + assert captured["options"].allowed_tools == ["Read", "Glob", "Grep"] + + def test_prompt_forwarded_to_query(self) -> None: + """Prompt text is forwarded verbatim to query().""" + result, captured = self._run_with_captured_options( + ["Tell me about Python 3.14"] + ) + assert result.exit_code == 0 + assert captured["prompt"] == "Tell me about Python 3.14" + + +# --------------------------------------------------------------------------- +# Additional CLI integration tests: output behavior +# --------------------------------------------------------------------------- + + +class TestCliOutputBehavior: + """Integration tests for CLI output formatting and streaming.""" + + def test_streamed_text_appears_in_stdout(self) -> None: + """Text from multiple assistant messages appears in CLI stdout.""" + messages = [ + AssistantMessage(content=[TextBlock(text="Part 1.")], model="sonnet"), + AssistantMessage(content=[TextBlock(text="Part 2.")], model="sonnet"), + ResultMessage( + subtype="result", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test"]) + + assert result.exit_code == 0 + assert "Part 1." in result.output + assert "Part 2." in result.output + + def test_json_mode_via_cli(self) -> None: + """--json flag produces valid NDJSON output via CLI.""" + messages = _make_success_messages("JSON test") + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test", "--json"]) + + assert result.exit_code == 0 + lines = [line for line in result.output.strip().split("\n") if line] + assert len(lines) == 2 + # First line: AssistantMessage + first = json.loads(lines[0]) + assert first["content"][0]["text"] == "JSON test" + # Second line: ResultMessage + second = json.loads(lines[1]) + assert second["subtype"] == "result" + + def test_verbose_shows_tool_calls(self) -> None: + """--verbose flag shows tool calls on stderr (verified via capsys).""" + messages = [ + AssistantMessage( + content=[ + TextBlock(text="Searching..."), + ToolUseBlock(id="t1", name="Grep", input={"pattern": "TODO"}), + ], + model="sonnet", + ), + ResultMessage( + subtype="result", + duration_ms=100, + duration_api_ms=80, + is_error=False, + num_turns=1, + session_id="s1", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test", "--verbose"]) + + assert result.exit_code == 0 + assert "Searching..." in result.output + # Tool call output goes to stderr via click.echo(err=True), + # which CliRunner captures in output when mix_stderr is not available + assert "[tool:Grep]" in result.output + + def test_error_result_exits_nonzero_via_cli(self) -> None: + """ResultMessage with is_error=True produces non-zero exit via CLI.""" + messages = [ + AssistantMessage(content=[TextBlock(text="Oops")], model="sonnet"), + ResultMessage( + subtype="result", + duration_ms=50, + duration_api_ms=40, + is_error=True, + num_turns=1, + session_id="err-session", + ), + ] + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test"]) + + assert result.exit_code != 0 + assert "error" in result.output.lower() + + def test_very_long_prompt_via_cli(self) -> None: + """Very long prompt is accepted and forwarded via CLI.""" + long_prompt = "z" * 50_000 + captured_prompt = {} + + async def mock_query(*, prompt, options): # noqa: ARG001 + captured_prompt["value"] = prompt + for msg in _make_result_only(): + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, [long_prompt]) + + assert result.exit_code == 0 + assert len(captured_prompt["value"]) == 50_000 + + +# --------------------------------------------------------------------------- +# Exit code summary tests +# --------------------------------------------------------------------------- + + +class TestExitCodes: + """Comprehensive exit code verification.""" + + def test_success_exit_code_zero(self) -> None: + """Successful execution exits with code 0.""" + messages = _make_success_messages() + + async def mock_query(*, prompt, options): # noqa: ARG001 + for msg in messages: + yield msg + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test"]) + + assert result.exit_code == 0 + + def test_usage_error_exit_code_two(self) -> None: + """Click usage errors (like invalid option) exit with code 2.""" + runner = CliRunner() + result = runner.invoke(main, ["--nonexistent-option"]) + assert result.exit_code == 2 + + def test_click_exception_exit_code_one(self) -> None: + """ClickException (missing API key) exits with code 1.""" + runner = CliRunner() + with patch.dict("os.environ", {}, clear=True): + get_settings.cache_clear() + result = runner.invoke(main, ["test prompt"], catch_exceptions=False) + assert result.exit_code == 1 + + def test_keyboard_interrupt_exit_code_130(self) -> None: + """KeyboardInterrupt exits with POSIX-conventional code 130.""" + + async def mock_query(*, prompt, options): # noqa: ARG001 + raise KeyboardInterrupt + yield # make it an async generator # pragma: no cover + + runner = CliRunner() + with ( + patch.dict("os.environ", {"ANTHROPIC_API_KEY": "sk-test-key"}), + patch("silly_scripts.cli.ask_claude.query", side_effect=mock_query), + ): + get_settings.cache_clear() + result = runner.invoke(main, ["test"]) + + assert result.exit_code == 130 diff --git a/tests/test_settings.py b/tests/test_settings.py index 72ab292..f035b12 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -92,3 +92,26 @@ def test_invalid_log_level_raises(monkeypatch): with pytest.raises(ValidationError): Settings() + + +def test_claude_defaults(monkeypatch): + """Claude settings should have sensible defaults.""" + for key in ["ANTHROPIC_API_KEY", "CLAUDE_DEFAULT_MODEL", "CLAUDE_DEFAULT_TOOLS"]: + monkeypatch.delenv(key, raising=False) + + s = Settings() + assert s.anthropic_api_key is None + assert s.claude_default_model == "sonnet" + assert s.claude_default_tools == "Read,Glob,Grep" + + +def test_claude_env_overrides(monkeypatch): + """Claude settings should be overridable via environment variables.""" + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test") + monkeypatch.setenv("CLAUDE_DEFAULT_MODEL", "opus") + monkeypatch.setenv("CLAUDE_DEFAULT_TOOLS", "Read,Edit,Bash") + + s = Settings() + assert s.anthropic_api_key == "sk-ant-test" + assert s.claude_default_model == "opus" + assert s.claude_default_tools == "Read,Edit,Bash" diff --git a/uv.lock b/uv.lock index f51ccb1..43ad411 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "certifi" version = "2025.11.12" @@ -42,6 +51,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "claude-agent-sdk" +version = "0.1.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "mcp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/dd/2818538efd18ed4ef72d4775efa75bb36cbea0fa418eda51df85ee9c2424/claude_agent_sdk-0.1.48.tar.gz", hash = "sha256:ee294d3f02936c0b826119ffbefcf88c67731cf8c2d2cb7111ccc97f76344272", size = 87375, upload-time = "2026-03-07T00:21:37.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/cf/bbbdee52ee0c63c8709b0ac03ce3c1da5bdc37def5da0eca63363448744f/claude_agent_sdk-0.1.48-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5761ff1d362e0f17c2b1bfd890d1c897f0aa81091e37bbd15b7d06f05ced552d", size = 57559306, upload-time = "2026-03-07T00:21:20.011Z" }, + { url = "https://files.pythonhosted.org/packages/57/d1/2179154b88d4cf6ba1cf6a15066ee8e96257aaeb1330e625e809ba2f28eb/claude_agent_sdk-0.1.48-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:39c1307daa17e42fa8a71180bb20af8a789d72d3891fc93519ff15540badcb83", size = 73980309, upload-time = "2026-03-07T00:21:24.592Z" }, + { url = "https://files.pythonhosted.org/packages/dc/99/55b0cd3bf54a7449e744d23cf50be104e9445cf623e1ed75722112aa6264/claude_agent_sdk-0.1.48-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:543d70acba468eccfff836965a14b8ac88cf90809aeeb88431dfcea3ee9a2fa9", size = 74583686, upload-time = "2026-03-07T00:21:28.969Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f6/4851bd9a238b7aadba7639eb906aca7da32a51f01563fa4488469c608b3a/claude_agent_sdk-0.1.48-py3-none-win_amd64.whl", hash = "sha256:0d37e60bd2b17efc3f927dccef080f14897ab62cd1d0d67a4abc8a0e2d4f1006", size = 74956045, upload-time = "2026-03-07T00:21:33.475Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -63,6 +121,59 @@ 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 = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + [[package]] name = "deepgram-sdk" version = "5.3.0" @@ -144,6 +255,15 @@ 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 = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -162,6 +282,33 @@ 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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -206,6 +353,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, ] +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -224,6 +396,15 @@ 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 = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.4" @@ -301,6 +482,20 @@ 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 = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.1" @@ -338,6 +533,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + [[package]] name = "ruff" version = "0.14.6" @@ -369,6 +633,7 @@ name = "silly-scripts" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "claude-agent-sdk" }, { name = "click" }, { name = "deepgram-sdk" }, { name = "ebooklib" }, @@ -389,6 +654,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "claude-agent-sdk" }, { name = "click" }, { name = "deepgram-sdk", specifier = ">=5.3.0" }, { name = "ebooklib" }, @@ -425,6 +691,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/c3695c2d2d4ef70072c3a06992850498b01c6bc9be531950813716b426fa/sse_starlette-3.3.2.tar.gz", hash = "sha256:678fca55a1945c734d8472a6cad186a55ab02840b4f6786f5ee8770970579dcd", size = 32326, upload-time = "2026-02-28T11:24:34.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/28/8cb142d3fe80c4a2d8af54ca0b003f47ce0ba920974e7990fa6e016402d1/sse_starlette-3.3.2-py3-none-any.whl", hash = "sha256:5c3ea3dad425c601236726af2f27689b74494643f57017cafcb6f8c9acfbb862", size = 14270, upload-time = "2026-02-28T11:24:32.984Z" }, +] + [[package]] name = "starlette" version = "0.50.0"