diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c00e94d5..32a40b53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,10 +33,11 @@ jobs: run: npm install -g markdownlint-cli@0.45.0 # check.sh runs every tool through `uv run` / `uv build` for a locked, - # reproducible env, so uv must be on PATH (installed from PyPI to match the - # repo's pip-based, no-new-action posture). + # reproducible env, so only uv must be on PATH (installed from PyPI to match + # the repo's pip-based, no-new-action posture). `uv run` itself syncs the + # project + dev group into .venv, so no `pip install -e .` is needed here. - name: Install - run: python -m pip install -e ".[dev]" uv + run: python -m pip install uv - name: Lint, typecheck, test run: ./scripts/check.sh @@ -55,9 +56,12 @@ jobs: - name: System deps (PortAudio + ffmpeg) run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg - # The local pytest hook runs `python -m pytest`, so the package must be importable. + # The local pytest hook runs `python -m pytest`, so the package + dev group + # must be importable. `pip install --group` needs pip >= 25.1, so upgrade first. - name: Install - run: python -m pip install -e ".[dev]" + run: | + python -m pip install --upgrade pip + python -m pip install -e . --group dev - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea186c08..26aca6b5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,15 +2,20 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: + # syrupy indents every line of an .ambr block (blank lines become " "), so + # trimming/EOF-fixing those files desyncs them from the live render and breaks + # the snapshot tests. Leave the generated snapshots alone. - id: trailing-whitespace + exclude: ^tests/__snapshots__/ - id: end-of-file-fixer + exclude: ^tests/__snapshots__/ - id: check-yaml - id: check-toml - id: check-merge-conflict - id: check-added-large-files - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 + rev: v0.15.16 hooks: - id: ruff args: [--fix] diff --git a/README.md b/README.md index 65513843..af2b7a04 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ curl -fsSL https://raw.githubusercontent.com/AssemblyAI/cli/main/install.sh | sh ``` The installer uses [`pipx`](https://pipx.pypa.io) when available (falling back to -`pip --user`) and requires Python 3.10+. Prefer to do it yourself: +`pip --user`) and requires Python 3.11+. Prefer to do it yourself: ```sh pipx install "git+https://github.com/AssemblyAI/cli.git" # or: pip install --user ... diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index 684bc28b..e400c87a 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -36,6 +36,7 @@ def _default_output_stream(rate: int) -> Any: f"Could not open the audio output device: {exc}", error_type="audio_output_error", exit_code=1, + suggestion="Check your speaker/output device, then run 'aai doctor'.", ) from exc return stream @@ -154,6 +155,7 @@ def _default_duplex_stream(*, rate: int, blocksize: int, callback: Any, device: f"Could not open the audio device: {exc}", error_type="audio_output_error", exit_code=1, + suggestion="Check your microphone/output device, then run 'aai doctor'.", ) from exc return stream diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index f229422b..cb18de68 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -1,6 +1,7 @@ from __future__ import annotations import webbrowser +from collections.abc import Mapping from typing import Any from aai_cli import output @@ -8,13 +9,17 @@ from aai_cli.errors import APIError -def _require(mapping: Any, key: str, what: str) -> Any: +def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any: """Pull a required field out of an AMS response, or raise a clean APIError. AMS only returns HTTP errors for outright failures; a 200 with an unexpected shape would otherwise KeyError into an ugly traceback, so map that to the same - "run login again" message the rest of the flow uses. + "run login again" message the rest of the flow uses. The return stays `Any` + because AMS JSON leaves are untyped and callers coerce them (int()/str()). """ + # Nested calls pass an `Any`-typed value that the type checker accepts as a + # Mapping but which may be a non-mapping at runtime (e.g. a malformed 200), + # so still guard with isinstance before calling .get. value = mapping.get(key) if isinstance(mapping, dict) else None if value is None: raise APIError( @@ -55,7 +60,10 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" projects = ams.list_projects(account_id, session_jwt) if not projects: - raise APIError("Your account has no project to create an API key in.") + raise APIError( + "Your account has no project to create an API key in.", + suggestion="Create a project in the AssemblyAI dashboard, then run 'aai login' again.", + ) for entry in projects: for token in entry.get("tokens", []): if _is_reusable_cli_token(token): @@ -71,9 +79,15 @@ def run_login_flow() -> str: result = _capture() if result.error == "timeout": - raise APIError("Login timed out waiting for the browser. Run 'aai login' again.") + raise APIError( + "Login timed out waiting for the browser.", + suggestion="Run 'aai login' again.", + ) if result.token_type != "discovery_oauth" or not result.token: # noqa: S105 - raise APIError("Login did not return a valid OAuth token. Run 'aai login' again.") + raise APIError( + "Login did not return a valid OAuth token.", + suggestion="Run 'aai login' again.", + ) disc = ams.discover(result.token) organizations = disc.get("organizations") or [] diff --git a/aai_cli/client.py b/aai_cli/client.py index 2d400c75..94670980 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -26,7 +26,10 @@ def resolve_audio_source(source: str | None, *, sample: bool) -> str: if sample: return SAMPLE_AUDIO_URL if not source: - raise UsageError("Provide an audio path/URL or use --sample.") + raise UsageError( + "Provide an audio path or URL.", + suggestion="Or pass --sample to use the hosted demo file.", + ) return source diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index bf0ed899..5846c8ba 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -13,12 +13,22 @@ from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError, UsageError +from aai_cli.help_text import examples_epilog from aai_cli.streaming.sources import FileSource app = typer.Typer() -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Start a live voice conversation", "aai agent"), + ("Pick a voice and opening line", 'aai agent --voice james --greeting "Hi there"'), + ("See available voices", "aai agent --list-voices"), + ("Print equivalent Python instead of running", "aai agent --show-code"), + ] + ) +) def agent( ctx: typer.Context, source: str = typer.Argument( @@ -65,7 +75,10 @@ def agent( def body(state: AppState, json_mode: bool) -> None: text_mode, json_mode = output.stream_output_modes(output_field, json_mode) if voice not in VOICES: - raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") + raise UsageError( + f"Unknown voice {voice!r}.", + suggestion="Run 'aai agent --list-voices' to see the options.", + ) if system_prompt_file is not None: try: system_prompt_text = system_prompt_file.read_text(encoding="utf-8") @@ -74,6 +87,7 @@ def body(state: AppState, json_mode: bool) -> None: f"Could not read --system-prompt-file {system_prompt_file}: {exc}", error_type="file_not_found", exit_code=2, + suggestion="Check the path and that the file is readable.", ) from exc else: system_prompt_text = system_prompt diff --git a/aai_cli/commands/claude.py b/aai_cli/commands/claude.py index dd21c315..d4cec390 100644 --- a/aai_cli/commands/claude.py +++ b/aai_cli/commands/claude.py @@ -10,6 +10,7 @@ from aai_cli import output from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError +from aai_cli.help_text import examples_epilog from aai_cli.steps import Step, render_steps app = typer.Typer( @@ -187,7 +188,14 @@ def _render(data: dict[str, list[Step]]) -> str: return render_steps(data["steps"], heading=_STEPS_HEADING) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Wire AssemblyAI docs + skill into Claude Code", "aai claude install"), + ("Install for the current project only", "aai claude install --scope project"), + ] + ) +) def install( ctx: typer.Context, scope: str = typer.Option( @@ -216,7 +224,13 @@ def body(_state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Show whether Claude Code is wired up", "aai claude status"), + ] + ) +) def status( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), @@ -230,7 +244,13 @@ def body(_state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Remove the AssemblyAI MCP server and skill", "aai claude remove"), + ] + ) +) def remove( ctx: typer.Context, scope: str | None = typer.Option( diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index 959678cf..abd62613 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -10,6 +10,7 @@ from aai_cli import client, config, output from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import CLIError, NotAuthenticated +from aai_cli.help_text import examples_epilog app = typer.Typer() @@ -31,14 +32,14 @@ class Check(TypedDict): def _check_python() -> Check: v = sys.version_info version = f"{v.major}.{v.minor}.{v.micro}" - if v >= (3, 10): + if v >= (3, 11): return {"name": "python", "status": "ok", "affects": [], "detail": version, "fix": None} return { "name": "python", "status": "fail", "affects": ["everything"], - "detail": f"Python {version} is too old; the CLI needs 3.10+", - "fix": "Install Python 3.10 or newer, then reinstall the CLI.", + "detail": f"Python {version} is too old; the CLI needs 3.11+", + "fix": "Install Python 3.11 or newer, then reinstall the CLI.", } @@ -187,7 +188,13 @@ def _render(data: dict[str, object]) -> str: return "\n".join(lines) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Check your environment is ready", "aai doctor"), + ] + ) +) def doctor( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index b983a971..d64a903a 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -10,6 +10,7 @@ from aai_cli import __version__, environments, output, steps from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog from aai_cli.init import keys, runner, scaffold, templates # Single-command sub-typer flattened to `aai init` (the exact pattern `aai transcribe` @@ -58,7 +59,15 @@ def _resolve_dir(directory: str | None, template: str, *, here: bool) -> Path: return Path.cwd() / f"{template}-app" -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Scaffold a new app interactively", "aai init"), + ("Scaffold a transcribe app into ./my-app", "aai init transcribe my-app"), + ("Scaffold into the current directory", "aai init transcribe --here"), + ] + ) +) def init( ctx: typer.Context, template: str = typer.Argument(None, help="Template to scaffold (omit to pick interactively)."), diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 54b44123..6851b8d3 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -8,11 +8,20 @@ from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer +from aai_cli.help_text import examples_epilog app = typer.Typer() -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Summarize a past transcript", 'aai llm "summarize" --transcript-id 5551234-abcd'), + ("Pipe any text in", 'echo "meeting notes" | aai llm "turn into action items"'), + ("See available models", "aai llm --list-models"), + ] + ) +) def llm( ctx: typer.Context, prompt: str = typer.Argument(None, help="The prompt to send to the model."), @@ -95,7 +104,10 @@ def ask(transcript_text: str) -> str: def body(state: AppState, json_mode: bool) -> None: if not prompt: - raise UsageError("Provide a prompt, or use --list-models.") + raise UsageError( + "Provide a prompt.", + suggestion="Or pass --list-models to see available models.", + ) output.validate_output_field(output_field, ("text", "json")) api_key = config.resolve_api_key(profile=state.profile) # Text piped on stdin becomes the content the prompt operates on, unless an diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 5a71bdb6..9d6945ba 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -7,11 +7,19 @@ from aai_cli.auth import run_login_flow from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.help_text import examples_epilog app = typer.Typer() -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Log in with your browser", "aai login"), + ("Log in non-interactively (CI)", "aai login --api-key sk_..."), + ] + ) +) def login( ctx: typer.Context, api_key: str = typer.Option(None, "--api-key", help="Provide key non-interactively."), @@ -24,7 +32,10 @@ def body(state: AppState, json_mode: bool) -> None: if api_key: # Non-interactive escape hatch for CI/automation. if not client.validate_key(api_key): - raise APIError("That API key was rejected (HTTP 401). Check it and retry.") + raise APIError( + "That API key was rejected (HTTP 401).", + suggestion="Check the key and retry.", + ) key = api_key else: key = run_login_flow() @@ -43,7 +54,13 @@ def body(state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Clear stored credentials for the active profile", "aai logout"), + ] + ) +) def logout( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), @@ -62,7 +79,13 @@ def body(state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Show the active profile and whether its key works", "aai whoami"), + ] + ) +) def whoami( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), diff --git a/aai_cli/commands/samples.py b/aai_cli/commands/samples.py index 8b4bb876..e601995a 100644 --- a/aai_cli/commands/samples.py +++ b/aai_cli/commands/samples.py @@ -11,6 +11,7 @@ from aai_cli.agent.voices import DEFAULT_VOICE from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog from aai_cli.streaming.sources import TARGET_RATE app = typer.Typer( @@ -36,7 +37,14 @@ def _generate(name: str) -> str: return code_gen.agent(DEFAULT_VOICE, DEFAULT_PROMPT, DEFAULT_GREETING) -@app.command(name="list") +@app.command( + name="list", + epilog=examples_epilog( + [ + ("List available starter scripts", "aai samples list"), + ] + ), +) def list_( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), @@ -53,7 +61,14 @@ def body(_state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Scaffold a transcribe starter script", "aai samples create transcribe"), + ("Overwrite an existing script", "aai samples create transcribe --force"), + ] + ) +) def create( ctx: typer.Context, name: str = typer.Argument(..., help="Sample name."), @@ -65,18 +80,20 @@ def create( def body(_state: AppState, json_mode: bool) -> None: if name not in SAMPLES: raise CLIError( - f"Unknown sample '{name}'. Try: {', '.join(SAMPLES)}.", + f"Unknown sample '{name}'.", error_type="unknown_sample", exit_code=1, + suggestion=f"Try one of: {', '.join(SAMPLES)}.", ) target_dir = Path.cwd() / name target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / f"{name}.py" if target.exists() and not force: raise CLIError( - f"{target} already exists. Delete it or pass --force to overwrite.", + f"{target} already exists.", error_type="file_exists", exit_code=1, + suggestion="Delete it or pass --force to overwrite.", ) target.write_text(_generate(name)) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 3786b810..cad5bcf0 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -10,6 +10,7 @@ from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer +from aai_cli.help_text import examples_epilog from aai_cli.microphone import MicrophoneSource from aai_cli.streaming.render import StreamRenderer from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource @@ -19,7 +20,19 @@ DEFAULT_SPEECH_MODEL = SpeechModel.universal_streaming_multilingual.value -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Stream from your microphone", "aai stream"), + ("Stream the hosted sample", "aai stream --sample"), + ( + "Summarize action items live as you talk", + 'aai stream --llm "summarize action items"', + ), + ("Print equivalent Python instead of running", "aai stream --show-code"), + ] + ) +) def stream( ctx: typer.Context, source: str = typer.Argument( diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index f8054061..ea01d41a 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -18,6 +18,7 @@ ) from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError +from aai_cli.help_text import examples_epilog app = typer.Typer() @@ -30,7 +31,20 @@ def _render_transform_steps(d: dict) -> str: return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Transcribe a local file", "aai transcribe call.mp3"), + ("Try it with the hosted sample", "aai transcribe --sample"), + ( + "Diarize two speakers and redact PII", + "aai transcribe call.mp3 --speaker-labels --speakers-expected 2 --redact-pii", + ), + ("Get just the text for a pipeline", "aai transcribe call.mp3 -o text"), + ("Print equivalent Python instead of running", "aai transcribe call.mp3 --show-code"), + ] + ) +) def transcribe( ctx: typer.Context, source: str = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."), diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 0448a8b4..e96f3716 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -8,11 +8,19 @@ from aai_cli import client, config, output, theme from aai_cli.context import AppState, run_command from aai_cli.errors import APIError +from aai_cli.help_text import examples_epilog app = typer.Typer(help="Browse and fetch past transcripts.", no_args_is_help=True) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Fetch a transcript's text by id", "aai transcripts get 5551234-abcd"), + ("Get the raw JSON", "aai transcripts get 5551234-abcd --json"), + ] + ) +) def get( ctx: typer.Context, transcript_id: str = typer.Argument(..., help="Transcript id."), @@ -48,7 +56,14 @@ def body(state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command(name="list") +@app.command( + name="list", + epilog=examples_epilog( + [ + ("List your recent transcripts", "aai transcripts list"), + ] + ), +) def list_( ctx: typer.Context, limit: int = typer.Option(10, "--limit", help="How many transcripts to show."), diff --git a/aai_cli/config.py b/aai_cli/config.py index e42a12a6..3917be6c 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -3,6 +3,7 @@ import contextlib import os import re +import tomllib from pathlib import Path from typing import Any @@ -10,7 +11,6 @@ import keyring.errors # keyring.errors is not re-exported by keyring/__init__ import platformdirs import tomli_w -import tomllib from aai_cli.errors import NotAuthenticated @@ -26,9 +26,10 @@ def _validate_profile(name: str) -> None: from aai_cli.errors import CLIError raise CLIError( - f"Invalid profile name {name!r}: use letters, digits, '-' or '_' only.", + f"Invalid profile name {name!r}.", error_type="invalid_profile", exit_code=2, + suggestion="Use only letters, digits, '-' or '_'.", ) @@ -115,7 +116,12 @@ def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = No if not api_key_flag: from aai_cli.errors import CLIError - raise CLIError("Empty --api-key provided.", error_type="invalid_key", exit_code=2) + raise CLIError( + "Empty --api-key provided.", + error_type="invalid_key", + exit_code=2, + suggestion="Pass a non-empty key, e.g. --api-key sk_...", + ) return api_key_flag env_key = os.environ.get(ENV_API_KEY) if env_key: diff --git a/aai_cli/config_builder.py b/aai_cli/config_builder.py index 00be01f8..25dd8f4a 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -1,6 +1,8 @@ from __future__ import annotations +import enum import json +import typing from pathlib import Path import assemblyai as aai @@ -8,93 +10,167 @@ from aai_cli.errors import UsageError -# field name -> coercion kind for --config/--config-file string values. -# The KEYS are the authoritative set of valid config fields per command. -TRANSCRIBE_COERCE: dict[str, str] = { - "language_code": "str", - "language_codes": "list", - "punctuate": "bool", - "format_text": "bool", - "dual_channel": "bool", - "multichannel": "bool", - "webhook_url": "str", - "webhook_auth_header_name": "str", - "webhook_auth_header_value": "str", - "audio_start_from": "int", - "audio_end_at": "int", - "word_boost": "list", - "boost_param": "str", - "filter_profanity": "bool", - "redact_pii": "bool", - "redact_pii_audio": "bool", - "redact_pii_audio_quality": "str", - "redact_pii_audio_options": "json", - "redact_pii_policies": "list", - "redact_pii_sub": "str", - "redact_pii_return_unredacted": "bool", - "speaker_labels": "bool", - "speakers_expected": "int", - "speaker_options": "json", - "content_safety": "bool", - "content_safety_confidence": "int", - "iab_categories": "bool", - "custom_spelling": "json", - "disfluencies": "bool", - "sentiment_analysis": "bool", - "auto_chapters": "bool", - "entity_detection": "bool", - "summarization": "bool", - "summary_model": "str", - "summary_type": "str", - "auto_highlights": "bool", - "language_detection": "bool", - "language_confidence_threshold": "float", - "language_detection_options": "json", - "speech_threshold": "float", - "speech_model": "str", - "speech_models": "list", - "prompt": "str", - "temperature": "float", - "remove_audio_tags": "str", - "keyterms_prompt": "list", - "keyterms_prompt_options": "json", - "speech_understanding": "json", - "domain": "str", -} - -STREAM_COERCE: dict[str, str] = { - "end_of_turn_confidence_threshold": "float", - "min_end_of_turn_silence_when_confident": "int", - "min_turn_silence": "int", - "max_turn_silence": "int", - "vad_threshold": "float", - "format_turns": "bool", - "keyterms_prompt": "list", - "filter_profanity": "bool", - "prompt": "str", - "sample_rate": "int", - "encoding": "str", - "speech_model": "str", - "language_detection": "bool", - "domain": "str", - "inactivity_timeout": "int", - "webhook_url": "str", - "webhook_auth_header_name": "str", - "webhook_auth_header_value": "str", - "llm_gateway": "json", - "speaker_labels": "bool", - "max_speakers": "int", - "voice_focus": "str", - "voice_focus_threshold": "float", - "noise_suppression_model": "str", - "noise_suppression_threshold": "float", - "continuous_partials": "bool", - "customer_support_audio_capture": "bool", - "include_partial_turns": "bool", - "redact_pii": "bool", - "redact_pii_policies": "list", - "redact_pii_sub": "str", -} +# The curated set of user-settable config fields per command. This is the authoritative +# allow-list (deliberately a subset of the SDK models — e.g. output-only and internal +# fields are excluded). The coercion KIND for each field is derived from the SDK model +# annotation in `_coerce_table`, so only the names are maintained here, not their types. +TRANSCRIBE_FIELD_NAMES: tuple[str, ...] = ( + "language_code", + "language_codes", + "punctuate", + "format_text", + "dual_channel", + "multichannel", + "webhook_url", + "webhook_auth_header_name", + "webhook_auth_header_value", + "audio_start_from", + "audio_end_at", + "word_boost", + "boost_param", + "filter_profanity", + "redact_pii", + "redact_pii_audio", + "redact_pii_audio_quality", + "redact_pii_audio_options", + "redact_pii_policies", + "redact_pii_sub", + "redact_pii_return_unredacted", + "speaker_labels", + "speakers_expected", + "speaker_options", + "content_safety", + "content_safety_confidence", + "iab_categories", + "custom_spelling", + "disfluencies", + "sentiment_analysis", + "auto_chapters", + "entity_detection", + "summarization", + "summary_model", + "summary_type", + "auto_highlights", + "language_detection", + "language_confidence_threshold", + "language_detection_options", + "speech_threshold", + "speech_model", + "speech_models", + "prompt", + "temperature", + "remove_audio_tags", + "keyterms_prompt", + "keyterms_prompt_options", + "speech_understanding", + "domain", +) + +STREAM_FIELD_NAMES: tuple[str, ...] = ( + "end_of_turn_confidence_threshold", + "min_end_of_turn_silence_when_confident", + "min_turn_silence", + "max_turn_silence", + "vad_threshold", + "format_turns", + "keyterms_prompt", + "filter_profanity", + "prompt", + "sample_rate", + "encoding", + "speech_model", + "language_detection", + "domain", + "inactivity_timeout", + "webhook_url", + "webhook_auth_header_name", + "webhook_auth_header_value", + "llm_gateway", + "speaker_labels", + "max_speakers", + "voice_focus", + "voice_focus_threshold", + "noise_suppression_model", + "noise_suppression_threshold", + "continuous_partials", + "customer_support_audio_capture", + "include_partial_turns", + "redact_pii", + "redact_pii_policies", + "redact_pii_sub", +) + +# Fields whose CLI input differs from the SDK annotation. `custom_spelling` is typed as a +# list-of-dicts on the model, but the CLI accepts the JSON object form directly. +_KIND_OVERRIDES: dict[str, str] = {"custom_spelling": "json"} + + +def _field_annotations(model_cls: type) -> dict[str, object]: + """Map field name -> type annotation for a pydantic model (v2 or v1).""" + model_fields = getattr(model_cls, "model_fields", None) + if model_fields: # pydantic v2 + return {name: field.annotation for name, field in model_fields.items()} + legacy_fields = getattr(model_cls, "__fields__", {}) # pydantic v1 + return {name: field.outer_type_ for name, field in legacy_fields.items()} + + +def _derive_kind(annotation: object) -> str: + """Map an SDK field annotation to a coercion kind for `coerce_value`.""" + if typing.get_origin(annotation) is typing.Union: + non_none = [a for a in typing.get_args(annotation) if a is not type(None)] + if len(non_none) == 1: + annotation = non_none[0] # unwrap Optional[X] + # A genuine multi-type union (e.g. Union[str, LanguageCode]): a string is + # always an acceptable input, otherwise fall back to a raw JSON value. + elif any(_is_str_like(a) for a in non_none): + return "str" + else: + return "json" + origin = typing.get_origin(annotation) + if origin in (list, set, tuple): + return "list" + if origin is dict: + return "json" + if isinstance(annotation, type): + if issubclass(annotation, bool): # before int: bool is an int subclass + return "bool" + if issubclass(annotation, enum.Enum): + return "str" + if issubclass(annotation, int): + return "int" + if issubclass(annotation, float): + return "float" + if issubclass(annotation, str): + return "str" + return "json" # pydantic submodels and anything else: accept a raw JSON value + + +def _is_str_like(annotation: object) -> bool: + return isinstance(annotation, type) and issubclass(annotation, str | enum.Enum) + + +def _coerce_table(model_cls: type, names: tuple[str, ...]) -> dict[str, str]: + """Build a field -> coercion-kind table for `names`, deriving kinds from the model.""" + annotations = _field_annotations(model_cls) + table: dict[str, str] = {} + for name in names: + if name in _KIND_OVERRIDES: + table[name] = _KIND_OVERRIDES[name] + elif name in annotations: + table[name] = _derive_kind(annotations[name]) + else: + # A curated name the SDK no longer exposes: pass through as a string and let + # the model constructor reject it, rather than crashing at import. + table[name] = "str" + return table + + +# field name -> coercion kind for --config/--config-file string values. The transcribe +# fields live on the request model behind TranscriptionConfig.raw. +TRANSCRIBE_COERCE: dict[str, str] = _coerce_table( + type(aai.TranscriptionConfig().raw), TRANSCRIBE_FIELD_NAMES +) +STREAM_COERCE: dict[str, str] = _coerce_table(StreamingParameters, STREAM_FIELD_NAMES) TRANSCRIBE_FIELDS = TRANSCRIBE_COERCE STREAM_FIELDS = STREAM_COERCE @@ -186,7 +262,7 @@ def merge_transcribe_config( return _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) -def construct_transcription_config(merged: dict[str, object]) -> aai.TranscriptionConfig: +def construct_transcription_config(merged: dict[str, typing.Any]) -> aai.TranscriptionConfig: """Build a TranscriptionConfig from a merged kwargs dict, surfacing errors as usage.""" try: return aai.TranscriptionConfig(**merged) @@ -221,7 +297,7 @@ def merge_streaming_params( return merged -def construct_streaming_params(merged: dict[str, object]) -> StreamingParameters: +def construct_streaming_params(merged: dict[str, typing.Any]) -> StreamingParameters: """Build StreamingParameters from a merged kwargs dict, surfacing errors as usage.""" try: return StreamingParameters(**merged) diff --git a/aai_cli/errors.py b/aai_cli/errors.py index 600b6b05..be0c1db5 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -2,7 +2,8 @@ class CLIError(Exception): - """Base error carrying an exit code and a machine-readable type.""" + """Base error carrying an exit code, a machine-readable type, and an optional + human suggestion for how to fix it.""" def __init__( self, @@ -11,33 +12,56 @@ def __init__( error_type: str = "error", exit_code: int = 1, transcript_id: str | None = None, + suggestion: str | None = None, ) -> None: super().__init__(message) self.message = message self.error_type = error_type self.exit_code = exit_code self.transcript_id = transcript_id + self.suggestion = suggestion def to_dict(self) -> dict[str, object]: body: dict[str, object] = {"type": self.error_type, "message": self.message} + if self.suggestion is not None: + body["suggestion"] = self.suggestion if self.transcript_id is not None: body["transcript_id"] = self.transcript_id return {"error": body} class NotAuthenticated(CLIError): - def __init__(self, message: str = "Not authenticated. Run 'aai login'.") -> None: - super().__init__(message, error_type="not_authenticated", exit_code=2) + def __init__( + self, + message: str = "Not authenticated.", + *, + suggestion: str | None = "Run 'aai login'.", + ) -> None: + super().__init__( + message, error_type="not_authenticated", exit_code=2, suggestion=suggestion + ) class APIError(CLIError): - def __init__(self, message: str, *, transcript_id: str | None = None) -> None: - super().__init__(message, error_type="api_error", exit_code=1, transcript_id=transcript_id) + def __init__( + self, + message: str, + *, + transcript_id: str | None = None, + suggestion: str | None = None, + ) -> None: + super().__init__( + message, + error_type="api_error", + exit_code=1, + transcript_id=transcript_id, + suggestion=suggestion, + ) class UsageError(CLIError): - def __init__(self, message: str) -> None: - super().__init__(message, error_type="usage_error", exit_code=2) + def __init__(self, message: str, *, suggestion: str | None = None) -> None: + super().__init__(message, error_type="usage_error", exit_code=2, suggestion=suggestion) # Word-level phrases that mark a failure as "the credentials were rejected" rather @@ -54,9 +78,8 @@ def __init__(self, message: str) -> None: "invalid key", ) -REJECTED_KEY_MESSAGE = ( - "Your API key was rejected. Run 'aai login' with a valid key (or set ASSEMBLYAI_API_KEY)." -) +REJECTED_KEY_MESSAGE = "Your API key was rejected." +REJECTED_KEY_SUGGESTION = "Run 'aai login' with a valid key, or set ASSEMBLYAI_API_KEY." def is_auth_failure(exc: object) -> bool: @@ -67,4 +90,4 @@ def is_auth_failure(exc: object) -> bool: def auth_failure() -> NotAuthenticated: """A NotAuthenticated for the 'key present but rejected by the server' case.""" - return NotAuthenticated(REJECTED_KEY_MESSAGE) + return NotAuthenticated(REJECTED_KEY_MESSAGE, suggestion=REJECTED_KEY_SUGGESTION) diff --git a/aai_cli/help_text.py b/aai_cli/help_text.py new file mode 100644 index 00000000..86503476 --- /dev/null +++ b/aai_cli/help_text.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from collections.abc import Sequence + +from rich.markup import escape + +# An (description, command) pair shown under a command's `--help`. +Example = tuple[str, str] + + +def examples_epilog(examples: Sequence[Example]) -> str: + """Build a Typer ``epilog`` that renders each example on its own line. + + The app runs with ``rich_markup_mode="rich"``, which reflows single newlines + into one paragraph but treats a blank line as a paragraph break. We join every + line with a blank line so each renders on its own row, dim the descriptions so + the commands stand out, and escape both so brackets in example commands (e.g. + ``jq '.x[]'``) are not parsed as rich markup tags. + """ + blocks = ["[bold]Examples[/bold]"] + for description, command in examples: + blocks.append(f"[dim]{escape(description)}[/dim]") + blocks.append(f"$ {escape(command)}") + return "\n\n".join(blocks) diff --git a/aai_cli/init/scaffold.py b/aai_cli/init/scaffold.py index cb412a37..49988e90 100644 --- a/aai_cli/init/scaffold.py +++ b/aai_cli/init/scaffold.py @@ -11,7 +11,9 @@ if TYPE_CHECKING: # Annotations only (PEP 563 strings), so no runtime import — `Traversable`'s # module location differs across 3.10/3.11 but that never matters at runtime. - from importlib.resources.abc import Traversable + # Import from importlib.abc (not importlib.resources.abc): that is the protocol + # variant `resources.files()` is typed to return, so the annotation matches. + from importlib.abc import Traversable PLACEHOLDER_KEY = "your_assemblyai_api_key_here" diff --git a/aai_cli/init/templates/transcribe/api/index.py b/aai_cli/init/templates/transcribe/api/index.py index f8b4401e..e5c55074 100644 --- a/aai_cli/init/templates/transcribe/api/index.py +++ b/aai_cli/init/templates/transcribe/api/index.py @@ -66,6 +66,8 @@ def _submit(audio: str) -> dict[str, str]: raise HTTPException( status_code=502, detail=f"Could not start transcription: {exc}" ) from exc + if transcript.id is None: + raise HTTPException(status_code=502, detail="Transcription did not return an id") return {"id": transcript.id} diff --git a/aai_cli/llm.py b/aai_cli/llm.py index 288c6dc9..ef2f4c25 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -4,6 +4,7 @@ import openai from openai import OpenAI +from openai.types.chat import ChatCompletion from aai_cli import environments from aai_cli.errors import APIError @@ -70,7 +71,7 @@ def complete( messages: list[dict[str, str]], max_tokens: int = DEFAULT_MAX_TOKENS, transcript_id: str | None = None, -) -> Any: +) -> ChatCompletion: """Create a chat completion via the gateway and return the OpenAI response. `transcript_id` is passed through as an extra body field so the gateway can @@ -96,7 +97,7 @@ def complete( raise APIError(f"LLM Gateway request failed: {exc}") from exc -def content_of(response: Any) -> str: +def content_of(response: ChatCompletion) -> str: """Pull the assistant's text out of a chat-completions response.""" try: content = response.choices[0].message.content @@ -105,17 +106,13 @@ def content_of(response: Any) -> str: return content or "" -def usage_of(response: Any) -> dict[str, Any] | None: +def usage_of(response: ChatCompletion) -> dict[str, Any] | None: """Return the token-usage block as a plain dict, if present.""" - usage = getattr(response, "usage", None) + usage = response.usage if usage is None: return None - if hasattr(usage, "model_dump"): - dumped: dict[str, Any] = usage.model_dump() - return dumped - if isinstance(usage, dict): - return usage - return None + dumped: dict[str, Any] = usage.model_dump() + return dumped def transform_transcript( diff --git a/aai_cli/microphone.py b/aai_cli/microphone.py index f53d350f..6eff85c5 100644 --- a/aai_cli/microphone.py +++ b/aai_cli/microphone.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterable, Iterator from typing import Any from aai_cli.errors import CLIError @@ -20,9 +20,10 @@ def audio_missing_error() -> CLIError: """The shared 'sounddevice can't be imported' error for mic and speaker paths.""" return CLIError( - "Audio support (sounddevice) is unavailable. Try: pip install --force-reinstall sounddevice", + "Audio support (sounddevice) is unavailable.", error_type="mic_missing", exit_code=2, + suggestion="Reinstall it: pip install --force-reinstall sounddevice", ) @@ -108,7 +109,7 @@ def __init__( target_rate: int | None = None, device: int | None = None, capture_rate: int | None = None, - stream_factory: Callable[..., Iterator[bytes]] | None = None, + stream_factory: Callable[..., Iterable[bytes]] | None = None, rate_query: Callable[[int | None], int] | None = None, on_open: Callable[[], None] | None = None, ) -> None: diff --git a/aai_cli/output.py b/aai_cli/output.py index f3656486..76803ab5 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -74,6 +74,8 @@ def emit_error(err: CLIError, *, json_mode: bool) -> None: print(json.dumps(err.to_dict(), default=str), file=sys.stderr) else: error_console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") + if err.suggestion: + error_console.print(f"[aai.muted]Suggestion:[/aai.muted] {escape(err.suggestion)}") def print_code(code: str, *, language: str = "python") -> None: diff --git a/aai_cli/streaming/sources.py b/aai_cli/streaming/sources.py index cbbaf62c..87bdf437 100644 --- a/aai_cli/streaming/sources.py +++ b/aai_cli/streaming/sources.py @@ -43,16 +43,20 @@ def __init__(self, source: str, *, sleep: Callable[[float], object] = time.sleep if self._path is not None: if not self._path.is_file(): raise CLIError( - f"No such file: {self._path}", error_type="file_not_found", exit_code=2 + f"No such file: {self._path}", + error_type="file_not_found", + exit_code=2, + suggestion="Check the path, or pass a URL or YouTube link instead.", ) self._wav = _is_streamable_wav(self._path) else: self._wav = False if not self._wav and shutil.which("ffmpeg") is None: raise CLIError( - "This audio source needs ffmpeg. Install ffmpeg, or pass a 16 kHz mono 16-bit WAV.", + "This audio source needs ffmpeg.", error_type="ffmpeg_missing", exit_code=2, + suggestion="Install ffmpeg, or pass a 16 kHz mono 16-bit WAV.", ) def __iter__(self) -> Iterator[bytes]: @@ -64,7 +68,10 @@ def __iter__(self) -> Iterator[bytes]: self._sleep(len(chunk) / (TARGET_RATE * 2)) # ~real-time pacing if produced == 0: raise CLIError( - f"No audio data in {self.source}.", error_type="empty_audio", exit_code=2 + f"No audio data in {self.source}.", + error_type="empty_audio", + exit_code=2, + suggestion="Check the file isn't empty or silent.", ) def _wav_chunks(self) -> Iterator[bytes]: diff --git a/aai_cli/youtube.py b/aai_cli/youtube.py index 4ac1d6c8..51ba20a1 100644 --- a/aai_cli/youtube.py +++ b/aai_cli/youtube.py @@ -29,9 +29,10 @@ def download_audio(url: str, dest_dir: Path) -> Path: import yt_dlp except ImportError as exc: raise CLIError( - "YouTube support needs yt-dlp. Install it with: pip install yt-dlp", + "YouTube support needs yt-dlp.", error_type="ytdlp_missing", exit_code=2, + suggestion="Install it: pip install yt-dlp", ) from exc options = { @@ -42,7 +43,9 @@ def download_audio(url: str, dest_dir: Path) -> Path: "noprogress": True, } try: - with yt_dlp.YoutubeDL(options) as ydl: + # yt-dlp types `params` as a private `_Params` TypedDict, but a plain options + # dict is the documented public API; pyright can't reconcile the two. + with yt_dlp.YoutubeDL(options) as ydl: # pyright: ignore[reportArgumentType] info = ydl.extract_info(url, download=True) path = Path(ydl.prepare_filename(info)) except Exception as exc: # yt-dlp raises many types; surface one clean CLI error diff --git a/install.sh b/install.sh index 3a1b193a..404e17f6 100755 --- a/install.sh +++ b/install.sh @@ -13,14 +13,14 @@ SPEC="git+https://github.com/${REPO}.git@${REF}" info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } err() { printf '\033[1;31merror:\033[0m %s\n' "$1" >&2; } -# --- Require Python 3.10+ ------------------------------------------------- +# --- Require Python 3.11+ ------------------------------------------------- PY="$(command -v python3 || command -v python || true)" if [ -z "$PY" ]; then - err "Python 3.10+ is required, but no python3 was found on PATH." + err "Python 3.11+ is required, but no python3 was found on PATH." exit 1 fi -if ! "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then - err "Python 3.10+ is required (found $("$PY" -V 2>&1))." +if ! "$PY" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'; then + err "Python 3.11+ is required (found $("$PY" -V 2>&1))." exit 1 fi diff --git a/pyproject.toml b/pyproject.toml index 5bd62099..3c9acbe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = "MIT" license-files = ["LICENSE"] authors = [{ name = "AssemblyAI", email = "support@assemblyai.com" }] -requires-python = ">=3.10" +requires-python = ">=3.11" keywords = ["assemblyai", "transcription", "speech-to-text", "cli", "audio", "streaming"] classifiers = [ "Development Status :: 4 - Beta", @@ -18,7 +18,6 @@ classifiers = [ "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -48,22 +47,35 @@ Homepage = "https://github.com/AssemblyAI/cli" Repository = "https://github.com/AssemblyAI/cli" Issues = "https://github.com/AssemblyAI/cli/issues" -[project.optional-dependencies] +[project.scripts] +aai = "aai_cli.main:run" + +# PEP 735 dependency group (not a [project] extra). uv installs this group by +# default for `uv run`/`uv sync` (see [tool.uv] default-groups below), so the +# type-checkers and pytest resolve their imports with no `--extra`/`--group` flag +# — and a plain `.venv` is correct for editors (see [tool.pyright] venv). pip +# installs it with `pip install --group dev` (requires pip >= 25.1). +[dependency-groups] dev = [ "pytest>=9.0.3", "pytest-cov>=7.1.0", "hypothesis>=6.155.1", "ruff>=0.15.15", "mypy>=2.1.0", + "pyright>=1.1.409", "pre-commit>=4.6.0", "fastapi>=0.115.0", "uvicorn>=0.30.0", "python-dotenv>=1.0.0", "python-multipart>=0.0.9", + "syrupy>=4.0", + "numpy>=2.0.0", # e2e tests resample kokoro audio (tests/e2e/test_cli_e2e.py) ] -[project.scripts] -aai = "aai_cli.main:run" +[tool.uv] +# Install the dev group for every `uv run`/`uv sync` (this is uv's default, pinned +# here so it's explicit and stable). +default-groups = ["dev"] [tool.hatch.build.targets.wheel] packages = ["aai_cli"] @@ -77,10 +89,11 @@ exclude = ["**/__pycache__", "**/*.pyc"] testpaths = ["tests"] markers = [ "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY + kokoro; skip otherwise)", + "install: install each init template's requirements.txt into a clean venv and import it (slow; needs network + uv; skip otherwise)", ] [tool.mypy] -python_version = "3.10" +python_version = "3.11" files = ["aai_cli", "tests"] # Third-party deps (assemblyai, sounddevice) ship no type stubs. ignore_missing_imports = true @@ -94,9 +107,23 @@ no_implicit_optional = true module = "tests.*" disallow_untyped_defs = false +[tool.pyright] +# Second type checker alongside mypy: pyright catches a different (and often +# stricter) class of issues. Same source scope as mypy (files above). +include = ["aai_cli", "tests"] +pythonVersion = "3.11" +typeCheckingMode = "basic" +# Third-party deps (assemblyai, sounddevice) ship no type stubs. +reportMissingTypeStubs = false +# Resolve imports against the uv-managed environment so the CLI and editors +# (Pylance) report identically. `uv run`/`uv sync` create `.venv` with the dev +# group, so pytest/fastapi/etc. resolve here. Run `uv sync` once on a fresh clone. +venvPath = "." +venv = ".venv" + [tool.ruff] line-length = 100 -target-version = "py310" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "BLE", "C4", "SIM", "RET", "PTH", "ARG", "S", "RUF"] diff --git a/scripts/check.sh b/scripts/check.sh index d72f11f1..6ca190bd 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -7,6 +7,10 @@ cd "$(dirname "$0")/.." # Run the Python tools through `uv run` so they use the project's locked # environment (pyproject + uv.lock), not whatever happens to be on PATH. This keeps # results reproducible and consistent with `uv run` used everywhere else. +# +# The dev dependencies live in [dependency-groups].dev, which uv installs by +# default (see [tool.uv] default-groups), so `uv run` already has pytest, +# hypothesis, fastapi, etc. — no `--extra`/`--group` flag needed here. echo "==> ruff check (src + tests)" uv run ruff check . @@ -17,13 +21,19 @@ uv run ruff format --check . echo "==> mypy (src + tests)" uv run mypy # files = ["aai_cli", "tests"] in pyproject.toml +echo "==> pyright (src + tests)" +uv run pyright # include = ["aai_cli", "tests"] in [tool.pyright] + echo "==> markdownlint (docs/ is generated, so excluded)" markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need -# a live API key + kokoro. Run them with: uv run pytest -m e2e -uv run pytest -q -m "not e2e" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +# a live API key + kokoro. And exclude install (real per-template dep install, +# slow + network), also uncounted by coverage. Run them with: +# uv run pytest -m e2e +# uv run pytest -m install +uv run pytest -q -m "not e2e and not install" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 echo "==> build + twine check (PyPI publish readiness)" # Build sdist + wheel into ./dist, then validate the metadata and README render diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr new file mode 100644 index 00000000..10e796ca --- /dev/null +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -0,0 +1,709 @@ +# serializer version: 1 +# name: test_command_help_matches_snapshot[agent] + ''' + + Usage: aai agent [OPTIONS] [SOURCE] + + Have a live two-way voice conversation with an AssemblyAI voice agent. + + Pass an audio file/URL (or --sample) to speak a recorded clip to the agent + instead of the microphone; the session then ends after the agent's reply. + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ source [SOURCE] Audio file path or URL to speak to the agent. Omit │ + │ to use the microphone. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --sample Speak the hosted wildfires.mp3 sample │ + │ to the agent. │ + │ --voice TEXT Agent voice. See --list-voices. │ + │ [default: ivy] │ + │ --system-prompt TEXT System prompt (the agent's persona). │ + │ [default: You are a friendly voice │ + │ assistant having a casual │ + │ conversation. Keep replies short and │ + │ natural, usually one or two │ + │ sentences. Speak the way a person │ + │ would in real conversation: relaxed, │ + │ low-key, no exclamation marks.] │ + │ --system-prompt-file PATH Read the system prompt from a file │ + │ (overrides --system-prompt). │ + │ --greeting TEXT Spoken greeting. │ + │ [default: Hey, what's on your mind?] │ + │ --device INTEGER Microphone device index. │ + │ --list-voices Print known voices and exit. │ + │ --json Emit newline-delimited JSON events. │ + │ --output -o TEXT Output mode: 'text' (you:/agent: │ + │ lines as plain stdout, pipe-friendly) │ + │ or 'json'. │ + │ --show-code Print the equivalent Python SDK code │ + │ and exit (does not start a session). │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Start a live voice conversation + $ aai agent + Pick a voice and opening line + $ aai agent --voice james --greeting "Hi there" + See available voices + $ aai agent --list-voices + Print equivalent Python instead of running + $ aai agent --show-code + + + + ''' +# --- +# name: test_command_help_matches_snapshot[claude_install] + ''' + + Usage: aai claude install [OPTIONS] + + Install the AssemblyAI docs MCP server and skill into Claude Code. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --scope TEXT Config scope to register the MCP under: user, project, │ + │ or local. Presence is detected across all scopes. │ + │ [default: user] │ + │ --force Reinstall even if already present. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Wire AssemblyAI docs + skill into Claude Code + $ aai claude install + Install for the current project only + $ aai claude install --scope project + + + + ''' +# --- +# name: test_command_help_matches_snapshot[claude_remove] + ''' + + Usage: aai claude remove [OPTIONS] + + Remove the AssemblyAI MCP server and skill from Claude Code. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --scope TEXT Only remove the MCP from this scope (user, project, or │ + │ local). Default: remove from whichever scope it exists │ + │ in. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Remove the AssemblyAI MCP server and skill + $ aai claude remove + + + + ''' +# --- +# name: test_command_help_matches_snapshot[claude_status] + ''' + + Usage: aai claude status [OPTIONS] + + Show whether the AssemblyAI MCP server and skill are wired into Claude Code. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Show whether Claude Code is wired up + $ aai claude status + + + + ''' +# --- +# name: test_command_help_matches_snapshot[doctor] + ''' + + Usage: aai doctor [OPTIONS] + + Check that your environment is ready to use AssemblyAI. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Check your environment is ready + $ aai doctor + + + + ''' +# --- +# name: test_command_help_matches_snapshot[init] + ''' + + Usage: aai init [OPTIONS] [TEMPLATE] [DIRECTORY] + + Pick a template, scaffold it, install deps, launch the server, open the + browser. + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ template [TEMPLATE] Template to scaffold (omit to pick │ + │ interactively). │ + │ directory [DIRECTORY] Target directory (default: