From 916e42854cffab820653e3ccbd4eac3ae06c2e27 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:55:12 -0700 Subject: [PATCH 01/29] feat(help): add examples_epilog helper for --help examples Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/help_text.py | 24 ++++++++++++++++++++++++ tests/test_help_text.py | 24 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 aai_cli/help_text.py create mode 100644 tests/test_help_text.py 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/tests/test_help_text.py b/tests/test_help_text.py new file mode 100644 index 00000000..cad2e0f0 --- /dev/null +++ b/tests/test_help_text.py @@ -0,0 +1,24 @@ +from aai_cli.help_text import examples_epilog + + +def test_examples_epilog_has_header_and_entries(): + epi = examples_epilog([("Do a thing", "aai do --thing")]) + assert "[bold]Examples[/bold]" in epi + assert "[dim]Do a thing[/dim]" in epi + assert "$ aai do --thing" in epi + + +def test_examples_epilog_blank_line_separates_entries(): + # rich_markup_mode="rich" reflows single newlines; blank lines keep each + # entry on its own row. + epi = examples_epilog([("First", "aai a"), ("Second", "aai b")]) + assert "\n\n" in epi + assert epi.count("\n\n") >= 3 # header + 2 descs + 2 cmds, joined by blanks + + +def test_examples_epilog_escapes_markup_in_commands(): + # Brackets in example commands (jq filters, arrays) must not be parsed as + # rich markup tags. rich.markup.escape escapes [word] patterns that Rich + # would otherwise parse as markup tags (e.g. [key], [bold], [/bold]). + epi = examples_epilog([("Filter JSON", "aai transcribe x -o json | jq '.[key]'")]) + assert "jq '.\\[key]'" in epi From 3d4b2cb8943f41f873fde1a5deb880356ad1b26f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:55:54 -0700 Subject: [PATCH 02/29] feat(help): add --help examples to transcribe and stream Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/stream.py | 12 +++++++++++- aai_cli/commands/transcribe.py | 16 +++++++++++++++- tests/test_stream_command.py | 9 +++++++++ tests/test_transcribe.py | 10 ++++++++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 3786b810..4f41abde 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,16 @@ 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/tests/test_stream_command.py b/tests/test_stream_command.py index 6e64efdf..2a6d32da 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -520,3 +520,12 @@ def _boom(*a, **k): assert "from openai import OpenAI" in result.output assert "summarize" in result.output assert "run_chain" in result.output # the live transcribe->LLM-per-turn loop + + +def test_stream_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["stream", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 8db7e846..e6c401b8 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -389,3 +389,13 @@ def test_transcribe_renders_summary_human(monkeypatch): assert result.exit_code == 0 assert "Summary:" in result.output assert "three bullet summary" in result.output + + +def test_transcribe_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["transcribe", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output + assert "aai transcribe" in result.output From 85d526e930cc1a07d7b6288dd54b3d38960b6501 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:56:20 -0700 Subject: [PATCH 03/29] feat(help): add --help examples to transcripts get/list Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/transcripts.py | 19 +++++++++++++++++-- tests/test_transcripts.py | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) 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/tests/test_transcripts.py b/tests/test_transcripts.py index 12fc0724..71f2b294 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -108,3 +108,21 @@ def test_list_table_colors_status(monkeypatch): assert "error" in result.output assert "\x1b[32m" in result.output # aai.success (green) → "completed" cell assert "\x1b[1;31m" in result.output # aai.error (bold red) → "error" cell + + +def test_transcripts_get_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["transcripts", "get", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output + + +def test_transcripts_list_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["transcripts", "list", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output From 4207859200400b0c3a59ba66468548d366f3ccf8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:56:54 -0700 Subject: [PATCH 04/29] feat(help): add --help examples to agent and llm Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/agent.py | 12 +++++++++++- aai_cli/commands/llm.py | 11 ++++++++++- tests/test_agent_command.py | 9 +++++++++ tests/test_llm_command.py | 9 +++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index bf0ed899..4e9f5f3c 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( diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 54b44123..c07f1977 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."), diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 0c305d19..4c33888d 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -289,3 +289,12 @@ def fake_run_session(api_key, *, renderer, **kwargs): assert "you: hello there" in result.output assert "agent: hi, how can I help?" in result.output assert '"type"' not in result.output # not NDJSON + + +def test_agent_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["agent", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 5cc99324..ddd38b1c 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -272,3 +272,12 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): assert result.exit_code == 0 assert seen["model"] == "gemini-2.5-flash" assert seen["max_tokens"] == 42 + + +def test_llm_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["llm", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output From 36d332ea35cfba1ed1610e5b9e82a3d46bd1822d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:57:21 -0700 Subject: [PATCH 05/29] feat(help): add --help examples to login/logout/whoami Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/login.py | 26 +++++++++++++++++++++++--- tests/test_login.py | 13 +++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 5a71bdb6..8d230a41 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."), @@ -43,7 +51,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 +76,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/tests/test_login.py b/tests/test_login.py index 8991c249..0c687100 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -113,3 +113,16 @@ def test_whoami_reports_env(): def test_unknown_env_exits_2(): result = runner.invoke(app, ["--env", "bogus", "whoami"]) assert result.exit_code == 2 + + +import pytest + + +@pytest.mark.parametrize("cmd", ["login", "logout", "whoami"]) +def test_auth_commands_help_has_examples(cmd): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, [cmd, "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output From b5589fc1115ddd7df985b970d0564e5a6d13dadb Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:58:00 -0700 Subject: [PATCH 06/29] feat(help): add --help examples to doctor and samples Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/doctor.py | 9 ++++++++- aai_cli/commands/samples.py | 19 +++++++++++++++++-- tests/test_doctor.py | 9 +++++++++ tests/test_samples.py | 13 +++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index 959678cf..e511d081 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() @@ -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/samples.py b/aai_cli/commands/samples.py index 8b4bb876..dfeb14bc 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."), diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4dafb43a..2564809f 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -175,3 +175,12 @@ def test_render_problem_payload_shows_fix_and_problem_banner(): assert "fix:" in text assert "Run 'aai login'." in text assert "Problems found" in text + + +def test_doctor_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["doctor", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output diff --git a/tests/test_samples.py b/tests/test_samples.py index 1dbc84e2..5ed0fd42 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -92,3 +92,16 @@ def test_samples_create_is_valid_python(tmp_path, monkeypatch): for name in ("transcribe", "stream", "agent"): assert runner.invoke(app, ["samples", "create", name]).exit_code == 0 ast.parse(Path(tmp_path, name, f"{name}.py").read_text()) # generated code parses + + +import pytest + + +@pytest.mark.parametrize("argv", [["samples", "list"], ["samples", "create"]]) +def test_samples_help_has_examples(argv): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, [*argv, "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output From 47b0de48ba5e7b582ed88d3ff93a0891dccdddb2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 17:58:37 -0700 Subject: [PATCH 07/29] feat(help): add --help examples to claude install/status/remove Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/claude.py | 26 +++++++++++++++++++++++--- tests/test_claude.py | 13 +++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) 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/tests/test_claude.py b/tests/test_claude.py index c0d9708d..a78c026f 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -429,3 +429,16 @@ def test_claude_no_subcommand_lists_commands(): assert "install" in result.output assert "status" in result.output assert "remove" in result.output + + +import pytest + + +@pytest.mark.parametrize("sub", ["install", "status", "remove"]) +def test_claude_help_has_examples(sub): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["claude", sub, "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output From c50f9f6ae583869fb53b8b136bfa2aef1b890c99 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:00:21 -0700 Subject: [PATCH 08/29] test(help): guard that every leaf command ships --help examples Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/init.py | 11 +++++++++- tests/test_agent_command.py | 1 + tests/test_claude.py | 3 +-- tests/test_doctor.py | 1 + tests/test_help_examples_coverage.py | 30 ++++++++++++++++++++++++++++ tests/test_llm_command.py | 1 + tests/test_login.py | 4 ++-- tests/test_samples.py | 4 ++-- tests/test_stream_command.py | 1 + tests/test_transcribe.py | 1 + tests/test_transcripts.py | 2 ++ 11 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 tests/test_help_examples_coverage.py 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/tests/test_agent_command.py b/tests/test_agent_command.py index 4c33888d..01a3b6ff 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -293,6 +293,7 @@ def fake_run_session(api_key, *, renderer, **kwargs): def test_agent_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["agent", "--help"]) diff --git a/tests/test_claude.py b/tests/test_claude.py index a78c026f..cbe8766d 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -431,12 +431,11 @@ def test_claude_no_subcommand_lists_commands(): assert "remove" in result.output -import pytest - @pytest.mark.parametrize("sub", ["install", "status", "remove"]) def test_claude_help_has_examples(sub): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["claude", sub, "--help"]) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 2564809f..4aef9792 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -179,6 +179,7 @@ def test_render_problem_payload_shows_fix_and_problem_banner(): def test_doctor_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["doctor", "--help"]) diff --git a/tests/test_help_examples_coverage.py b/tests/test_help_examples_coverage.py new file mode 100644 index 00000000..12d2a248 --- /dev/null +++ b/tests/test_help_examples_coverage.py @@ -0,0 +1,30 @@ +# tests/test_help_examples_coverage.py +import typer + +from aai_cli.main import app + +# `version` is a trivial command with no flags; examples would be noise. +_EXEMPT = {"version"} + + +def _leaf_commands(click_cmd, prefix=()): + """Yield (path_tuple, command) for every non-group command in the tree.""" + sub = getattr(click_cmd, "commands", None) + if not sub: + yield prefix, click_cmd + return + for name, child in sub.items(): + yield from _leaf_commands(child, (*prefix, name)) + + +def test_every_leaf_command_has_examples_epilog(): + root = typer.main.get_command(app) + missing = [] + for path, cmd in _leaf_commands(root): + name = path[-1] if path else cmd.name + if name in _EXEMPT: + continue + epilog = getattr(cmd, "epilog", None) + if not (epilog and "Examples" in epilog): + missing.append(" ".join(path)) + assert not missing, f"commands missing --help examples: {missing}" diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index ddd38b1c..c313c800 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -276,6 +276,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_llm_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["llm", "--help"]) diff --git a/tests/test_login.py b/tests/test_login.py index 0c687100..946ee05d 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,5 +1,6 @@ from unittest.mock import patch +import pytest from typer.testing import CliRunner from aai_cli import config @@ -115,12 +116,11 @@ def test_unknown_env_exits_2(): assert result.exit_code == 2 -import pytest - @pytest.mark.parametrize("cmd", ["login", "logout", "whoami"]) def test_auth_commands_help_has_examples(cmd): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, [cmd, "--help"]) diff --git a/tests/test_samples.py b/tests/test_samples.py index 5ed0fd42..7e2b4a78 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -1,5 +1,6 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from aai_cli.main import app @@ -94,12 +95,11 @@ def test_samples_create_is_valid_python(tmp_path, monkeypatch): ast.parse(Path(tmp_path, name, f"{name}.py").read_text()) # generated code parses -import pytest - @pytest.mark.parametrize("argv", [["samples", "list"], ["samples", "create"]]) def test_samples_help_has_examples(argv): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, [*argv, "--help"]) diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 2a6d32da..39a6f1c6 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -524,6 +524,7 @@ def _boom(*a, **k): def test_stream_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["stream", "--help"]) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index e6c401b8..9d97a69c 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -393,6 +393,7 @@ def test_transcribe_renders_summary_human(monkeypatch): def test_transcribe_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["transcribe", "--help"]) diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 71f2b294..5d04ce79 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -112,6 +112,7 @@ def test_list_table_colors_status(monkeypatch): def test_transcripts_get_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["transcripts", "get", "--help"]) @@ -121,6 +122,7 @@ def test_transcripts_get_help_has_examples(): def test_transcripts_list_help_has_examples(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["transcripts", "list", "--help"]) From 33d018dc2bb5f82ed5af0c1417611fd73e090d89 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:20:50 -0700 Subject: [PATCH 09/29] style(tests): drop redundant inline imports in help-examples tests Addresses code-review feedback: CliRunner/app are already imported at module scope in each test file; remove the duplicated in-function imports and normalize blank lines. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_command.py | 4 ---- tests/test_claude.py | 5 ----- tests/test_doctor.py | 4 ---- tests/test_llm_command.py | 4 ---- tests/test_login.py | 5 ----- tests/test_samples.py | 5 ----- tests/test_stream_command.py | 4 ---- tests/test_transcribe.py | 4 ---- tests/test_transcripts.py | 8 -------- 9 files changed, 43 deletions(-) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 01a3b6ff..1c5d3319 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -292,10 +292,6 @@ def fake_run_session(api_key, *, renderer, **kwargs): def test_agent_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["agent", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_claude.py b/tests/test_claude.py index cbe8766d..a5b1df55 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -431,13 +431,8 @@ def test_claude_no_subcommand_lists_commands(): assert "remove" in result.output - @pytest.mark.parametrize("sub", ["install", "status", "remove"]) def test_claude_help_has_examples(sub): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["claude", sub, "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 4aef9792..8eecfadb 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -178,10 +178,6 @@ def test_render_problem_payload_shows_fix_and_problem_banner(): def test_doctor_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["doctor", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index c313c800..1b80446b 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -275,10 +275,6 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_llm_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["llm", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_login.py b/tests/test_login.py index 946ee05d..af6758a4 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -116,13 +116,8 @@ def test_unknown_env_exits_2(): assert result.exit_code == 2 - @pytest.mark.parametrize("cmd", ["login", "logout", "whoami"]) def test_auth_commands_help_has_examples(cmd): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, [cmd, "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_samples.py b/tests/test_samples.py index 7e2b4a78..bbdb898e 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -95,13 +95,8 @@ def test_samples_create_is_valid_python(tmp_path, monkeypatch): ast.parse(Path(tmp_path, name, f"{name}.py").read_text()) # generated code parses - @pytest.mark.parametrize("argv", [["samples", "list"], ["samples", "create"]]) def test_samples_help_has_examples(argv): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, [*argv, "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 39a6f1c6..b7b4ce5f 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -523,10 +523,6 @@ def _boom(*a, **k): def test_stream_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["stream", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 9d97a69c..791d7f53 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -392,10 +392,6 @@ def test_transcribe_renders_summary_human(monkeypatch): def test_transcribe_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["transcribe", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 5d04ce79..2f343ce9 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -111,20 +111,12 @@ def test_list_table_colors_status(monkeypatch): def test_transcripts_get_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["transcripts", "get", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output def test_transcripts_list_help_has_examples(): - from typer.testing import CliRunner - - from aai_cli.main import app - result = CliRunner().invoke(app, ["transcripts", "list", "--help"]) assert result.exit_code == 0 assert "Examples" in result.output From 5bc0b9e146b0c15ef172698d0a215ca3525c8d10 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:23:02 -0700 Subject: [PATCH 10/29] feat(errors): add optional suggestion field to CLIError model Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/errors.py | 38 +++++++++++++++++++++++++++++++------- tests/test_errors.py | 21 ++++++++++++++++++--- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/aai_cli/errors.py b/aai_cli/errors.py index 600b6b05..0189a980 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 diff --git a/tests/test_errors.py b/tests/test_errors.py index c724ec73..1c5bafab 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,11 +1,12 @@ -from aai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure +from aai_cli.errors import APIError, CLIError, NotAuthenticated, UsageError, is_auth_failure def test_not_authenticated_defaults(): err = NotAuthenticated() assert err.exit_code == 2 assert err.error_type == "not_authenticated" - assert "aai login" in str(err) + assert err.message == "Not authenticated." + assert err.suggestion == "Run 'aai login'." def test_api_error_carries_fields(): @@ -17,11 +18,25 @@ def test_api_error_carries_fields(): } -def test_to_dict_omits_none_transcript_id(): +def test_to_dict_includes_suggestion_when_present(): + err = CLIError("nope", error_type="generic", exit_code=1, suggestion="do this") + assert err.to_dict() == { + "error": {"type": "generic", "message": "nope", "suggestion": "do this"} + } + + +def test_to_dict_omits_none_transcript_id_and_suggestion(): err = CLIError("nope", error_type="generic", exit_code=1) assert err.to_dict() == {"error": {"type": "generic", "message": "nope"}} +def test_api_error_carries_suggestion(): + err = APIError("boom", suggestion="retry") + assert err.to_dict() == { + "error": {"type": "api_error", "message": "boom", "suggestion": "retry"} + } + + def test_is_auth_failure_matches_credential_signals(): assert is_auth_failure(Exception("HTTP 401 Unauthorized")) assert is_auth_failure(Exception("Forbidden")) From cd7787a2be8b348042b5eefe329563c1ae8aacd4 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:23:26 -0700 Subject: [PATCH 11/29] feat(errors): render Suggestion line in human error output Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/output.py | 3 +++ tests/test_output.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/aai_cli/output.py b/aai_cli/output.py index f3656486..629427be 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -74,6 +74,9 @@ 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)}") + suggestion = getattr(err, "suggestion", None) + if suggestion: + error_console.print(f"[aai.muted]Suggestion:[/aai.muted] {escape(suggestion)}") def print_code(code: str, *, language: str = "python") -> None: diff --git a/tests/test_output.py b/tests/test_output.py index bdab3918..383be891 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -61,6 +61,32 @@ def test_emit_error_json_goes_to_stderr(capsys): assert captured.out == "" +def test_emit_error_renders_suggestion_line(capsys): + import types + + err = types.SimpleNamespace( + message="bad thing", + suggestion="try this instead", + to_dict=lambda: {"error": {}}, + ) + output.emit_error(err, json_mode=False) + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "bad thing" in captured.err + assert "Suggestion:" in captured.err + assert "try this instead" in captured.err + assert captured.out == "" + + +def test_emit_error_no_suggestion_line_when_absent(capsys): + import types + + err = types.SimpleNamespace(message="bad thing", suggestion=None, to_dict=lambda: {"error": {}}) + output.emit_error(err, json_mode=False) + captured = capsys.readouterr() + assert "Suggestion:" not in captured.err + + def test_print_code_plain_when_piped(monkeypatch, capsys): monkeypatch.setattr(output, "_is_agentic", lambda: True) output.print_code("import os\nprint(os.getcwd())\n") From 4b8821c9a54223fda0b26bd2f751e1df6b51ea38 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:24:29 -0700 Subject: [PATCH 12/29] feat(errors): split suggestion out of auth and audio error helpers Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/errors.py | 7 +++---- aai_cli/microphone.py | 3 ++- tests/test_errors.py | 10 ++++++++++ tests/test_microphone.py | 9 +++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/aai_cli/errors.py b/aai_cli/errors.py index 0189a980..be0c1db5 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -78,9 +78,8 @@ def __init__(self, message: str, *, suggestion: str | None = None) -> 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: @@ -91,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/microphone.py b/aai_cli/microphone.py index f53d350f..42a85351 100644 --- a/aai_cli/microphone.py +++ b/aai_cli/microphone.py @@ -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", ) diff --git a/tests/test_errors.py b/tests/test_errors.py index 1c5bafab..5fb05e64 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -37,6 +37,16 @@ def test_api_error_carries_suggestion(): } +def test_auth_failure_splits_message_and_suggestion(): + from aai_cli.errors import auth_failure + + err = auth_failure() + assert err.error_type == "not_authenticated" + assert err.message == "Your API key was rejected." + assert "aai login" in (err.suggestion or "") + assert "ASSEMBLYAI_API_KEY" in (err.suggestion or "") + + def test_is_auth_failure_matches_credential_signals(): assert is_auth_failure(Exception("HTTP 401 Unauthorized")) assert is_auth_failure(Exception("Forbidden")) diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 741c2a24..96e380c0 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -34,6 +34,15 @@ def close(self): self.closed = True +def test_audio_missing_error_has_reinstall_suggestion(): + from aai_cli.microphone import audio_missing_error + + err = audio_missing_error() + assert "sounddevice" in err.message + assert err.suggestion is not None + assert "pip install" in err.suggestion + + def test_yields_chunks_at_capture_rate(): seen = {} From 5cfb3410e3d538884e63020a708d24ef192ac318 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:24:58 -0700 Subject: [PATCH 13/29] feat(errors): add suggestions to config profile/key errors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/config.py | 10 ++++++++-- tests/test_config.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/aai_cli/config.py b/aai_cli/config.py index e42a12a6..382f4ff5 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -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/tests/test_config.py b/tests/test_config.py index 966912f0..01c60148 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -70,6 +70,18 @@ def test_empty_api_key_flag_rejected(): config.resolve_api_key(api_key_flag="") +def test_invalid_profile_name_has_suggestion(): + import pytest + + from aai_cli import config + from aai_cli.errors import CLIError + + with pytest.raises(CLIError) as exc: + config.set_active_profile("bad name!") + assert exc.value.message.startswith("Invalid profile name") + assert exc.value.suggestion == "Use only letters, digits, '-' or '_'." + + def test_malformed_config_raises_clean_error(tmp_config): from aai_cli.errors import CLIError From c7f7f0a331fe8e358f7a0df53d4b1c1dd1caf608 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:25:49 -0700 Subject: [PATCH 14/29] feat(errors): add suggestions to agent voice/system-prompt errors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/agent.py | 6 +++++- tests/test_agent_command.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 4e9f5f3c..5846c8ba 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -75,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") @@ -84,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/tests/test_agent_command.py b/tests/test_agent_command.py index 1c5d3319..8a813f19 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -291,6 +291,18 @@ def fake_run_session(api_key, *, renderer, **kwargs): assert '"type"' not in result.output # not NDJSON +def test_unknown_voice_suggests_list_voices(): + import pytest + + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["agent", "--voice", "not-a-voice", "--json"]) + assert result.exit_code == 2 + # JSON error on stderr carries the structured suggestion. + assert "--list-voices" in result.output + + def test_agent_help_has_examples(): result = CliRunner().invoke(app, ["agent", "--help"]) assert result.exit_code == 0 From cdba1b3dd41691aff73763b2678e90cce02fa5c0 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:26:17 -0700 Subject: [PATCH 15/29] feat(errors): add suggestions to samples create errors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/samples.py | 6 ++++-- tests/test_samples.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/aai_cli/commands/samples.py b/aai_cli/commands/samples.py index dfeb14bc..e601995a 100644 --- a/aai_cli/commands/samples.py +++ b/aai_cli/commands/samples.py @@ -80,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/tests/test_samples.py b/tests/test_samples.py index bbdb898e..3da84597 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -95,6 +95,21 @@ def test_samples_create_is_valid_python(tmp_path, monkeypatch): ast.parse(Path(tmp_path, name, f"{name}.py").read_text()) # generated code parses +def test_unknown_sample_has_suggestion(): + import pytest + + from aai_cli.commands import samples as samples_mod + from aai_cli.errors import CLIError + + # Drive the command body directly through the Typer app for a clean error. + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["samples", "create", "nope", "--json"]) + assert result.exit_code == 1 + assert "Try one of" in result.output + + @pytest.mark.parametrize("argv", [["samples", "list"], ["samples", "create"]]) def test_samples_help_has_examples(argv): result = CliRunner().invoke(app, [*argv, "--help"]) From 30774d9c260b0be49afe63499f3867836c05af8d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:26:57 -0700 Subject: [PATCH 16/29] feat(errors): add suggestions to llm prompt and login key errors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/llm.py | 5 ++++- aai_cli/commands/login.py | 5 ++++- tests/test_llm_command.py | 9 +++++++++ tests/test_login.py | 11 +++++++++++ 4 files changed, 28 insertions(+), 2 deletions(-) diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index c07f1977..6851b8d3 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -104,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 8d230a41..9d6945ba 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -32,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() diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 1b80446b..48edb666 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -274,6 +274,15 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): assert seen["max_tokens"] == 42 +def test_no_prompt_suggests_list_models(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["llm", "--json"]) + assert result.exit_code == 2 + assert "--list-models" in result.output + + def test_llm_help_has_examples(): result = CliRunner().invoke(app, ["llm", "--help"]) assert result.exit_code == 0 diff --git a/tests/test_login.py b/tests/test_login.py index af6758a4..66e652b2 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -116,6 +116,17 @@ def test_unknown_env_exits_2(): assert result.exit_code == 2 +def test_rejected_api_key_has_suggestion(monkeypatch): + from typer.testing import CliRunner + from aai_cli import client + from aai_cli.main import app + + monkeypatch.setattr(client, "validate_key", lambda key: False) + result = CliRunner().invoke(app, ["login", "--api-key", "sk_bad", "--json"]) + assert result.exit_code == 1 + assert "Check the key and retry" in result.output + + @pytest.mark.parametrize("cmd", ["login", "logout", "whoami"]) def test_auth_commands_help_has_examples(cmd): result = CliRunner().invoke(app, [cmd, "--help"]) From a79cb9bca8e5d2c23fd906dd27c005a27357a887 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:27:28 -0700 Subject: [PATCH 17/29] feat(errors): add suggestions to login flow errors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/auth/flow.py | 15 ++++++++++++--- tests/test_auth_flow.py | 9 +++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index f229422b..47e7f44f 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -55,7 +55,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 +74,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/tests/test_auth_flow.py b/tests/test_auth_flow.py index 09e2d873..ea3358d3 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -207,6 +207,15 @@ def test_run_login_flow_org_missing_id_raises_api_error(monkeypatch): flow.run_login_flow() +def test_login_timeout_suggests_retry(): + # Mirror the existing timeout-path test setup in this module; the raised + # APIError should now split message and suggestion. + from aai_cli.errors import APIError + + err = APIError("Login timed out waiting for the browser.", suggestion="Run 'aai login' again.") + assert err.suggestion == "Run 'aai login' again." + + def test_run_login_flow_zero_orgs_raises(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) monkeypatch.setattr( From 857dedb11b23ae09a78c835b7505728a1ac9fa03 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:29:04 -0700 Subject: [PATCH 18/29] feat(errors): add suggestions to source/IO and dependency errors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/agent/audio.py | 2 ++ aai_cli/client.py | 5 ++++- aai_cli/streaming/sources.py | 13 ++++++++++--- aai_cli/youtube.py | 3 ++- tests/test_client.py | 10 ++++++++++ tests/test_streaming_sources.py | 14 ++++++++++++++ tests/test_youtube.py | 8 ++++++++ 7 files changed, 50 insertions(+), 5 deletions(-) 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/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/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..6c874da8 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 = { diff --git a/tests/test_client.py b/tests/test_client.py index dbc17220..7159cdc4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -98,6 +98,16 @@ def test_resolve_audio_source_sample_explicit_and_missing(): client.resolve_audio_source(None, sample=False) +def test_no_audio_source_suggests_sample(): + import pytest + + from aai_cli.errors import UsageError + + # Reproduce the "neither path nor --sample" guard at client.py:29. + err = UsageError("Provide an audio path or URL.", suggestion="Or pass --sample to use the hosted demo file.") + assert err.suggestion is not None + + def test_transcribe_blocks_and_returns_transcript(): fake_transcript = MagicMock() fake_transcript.status = client.aai.TranscriptStatus.completed diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index 29517f76..0f0d3efd 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -200,3 +200,17 @@ def test_filesource_url_without_ffmpeg_raises(monkeypatch): with pytest.raises(CLIError) as exc: FileSource("https://example.com/clip.mp3") assert exc.value.error_type == "ffmpeg_missing" + + +def test_missing_ffmpeg_suggests_install(monkeypatch, tmp_path): + import shutil + + # A non-WAV file with ffmpeg absent must raise with an actionable suggestion. + f = tmp_path / "audio.mp3" + f.write_bytes(b"not really audio") + monkeypatch.setattr(sources.shutil, "which", lambda name: None) + with pytest.raises(CLIError) as exc: + sources.FileSource(str(f)) + assert "ffmpeg" in exc.value.message + assert exc.value.suggestion is not None + assert "WAV" in exc.value.suggestion or "ffmpeg" in exc.value.suggestion diff --git a/tests/test_youtube.py b/tests/test_youtube.py index ae19e5af..13156132 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -103,3 +103,11 @@ def test_download_audio_missing_ytdlp_raises(tmp_path, monkeypatch): youtube.download_audio("https://youtu.be/x", tmp_path) assert exc.value.error_type == "ytdlp_missing" assert exc.value.exit_code == 2 + + +def test_missing_ytdlp_suggests_install(tmp_path, monkeypatch): + monkeypatch.setitem(sys.modules, "yt_dlp", None) # force ImportError on `import yt_dlp` + with pytest.raises(CLIError) as exc: + youtube.download_audio("https://youtu.be/x", tmp_path) + assert "yt-dlp" in exc.value.message + assert "pip install yt-dlp" in (exc.value.suggestion or "") From e18096a1f40d5263144f0323a7102ae19d496604 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:30:32 -0700 Subject: [PATCH 19/29] test: fix ruff lint in B9 test files (unused imports, import order) Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_agent_command.py | 3 +-- tests/test_client.py | 7 ++++--- tests/test_errors.py | 2 +- tests/test_llm_command.py | 1 + tests/test_login.py | 1 + tests/test_samples.py | 6 +----- tests/test_streaming_sources.py | 2 -- 7 files changed, 9 insertions(+), 13 deletions(-) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 8a813f19..d4bead03 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -292,9 +292,8 @@ def fake_run_session(api_key, *, renderer, **kwargs): def test_unknown_voice_suggests_list_voices(): - import pytest - from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["agent", "--voice", "not-a-voice", "--json"]) diff --git a/tests/test_client.py b/tests/test_client.py index 7159cdc4..6ebc9293 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -99,12 +99,13 @@ def test_resolve_audio_source_sample_explicit_and_missing(): def test_no_audio_source_suggests_sample(): - import pytest - from aai_cli.errors import UsageError # Reproduce the "neither path nor --sample" guard at client.py:29. - err = UsageError("Provide an audio path or URL.", suggestion="Or pass --sample to use the hosted demo file.") + err = UsageError( + "Provide an audio path or URL.", + suggestion="Or pass --sample to use the hosted demo file.", + ) assert err.suggestion is not None diff --git a/tests/test_errors.py b/tests/test_errors.py index 5fb05e64..ef6894e7 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,4 +1,4 @@ -from aai_cli.errors import APIError, CLIError, NotAuthenticated, UsageError, is_auth_failure +from aai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure def test_not_authenticated_defaults(): diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 48edb666..f6f5bc54 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -276,6 +276,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_no_prompt_suggests_list_models(): from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["llm", "--json"]) diff --git a/tests/test_login.py b/tests/test_login.py index 66e652b2..e19289ac 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -118,6 +118,7 @@ def test_unknown_env_exits_2(): def test_rejected_api_key_has_suggestion(monkeypatch): from typer.testing import CliRunner + from aai_cli import client from aai_cli.main import app diff --git a/tests/test_samples.py b/tests/test_samples.py index 3da84597..a2b213b4 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -96,13 +96,9 @@ def test_samples_create_is_valid_python(tmp_path, monkeypatch): def test_unknown_sample_has_suggestion(): - import pytest - - from aai_cli.commands import samples as samples_mod - from aai_cli.errors import CLIError - # Drive the command body directly through the Typer app for a clean error. from typer.testing import CliRunner + from aai_cli.main import app result = CliRunner().invoke(app, ["samples", "create", "nope", "--json"]) diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index 0f0d3efd..f2e4f805 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -203,8 +203,6 @@ def test_filesource_url_without_ffmpeg_raises(monkeypatch): def test_missing_ffmpeg_suggests_install(monkeypatch, tmp_path): - import shutil - # A non-WAV file with ffmpeg absent must raise with an actionable suggestion. f = tmp_path / "audio.mp3" f.write_bytes(b"not really audio") From 9bf26c54b1120392cba39c13fa86f2d7bba49454 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 18:47:45 -0700 Subject: [PATCH 20/29] test(errors): assert suggestion on live login/audio-source paths Addresses code-review feedback: replace constructor-only contract tests with assertions on the real raise sites (flow.run_login_flow timeout, client.resolve_audio_source), checking message and suggestion separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_auth_flow.py | 13 +++---------- tests/test_client.py | 15 +++------------ 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index ea3358d3..09c671ff 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -78,8 +78,10 @@ def test_run_login_flow_happy_path(monkeypatch): def test_run_login_flow_timeout_raises(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) monkeypatch.setattr(flow, "_capture", lambda: loopback.CallbackResult(error="timeout")) - with pytest.raises(APIError, match="timed out"): + with pytest.raises(APIError) as exc: flow.run_login_flow() + assert exc.value.message == "Login timed out waiting for the browser." + assert exc.value.suggestion == "Run 'aai login' again." def test_find_or_create_reuses_token_with_token_name_field(monkeypatch): @@ -207,15 +209,6 @@ def test_run_login_flow_org_missing_id_raises_api_error(monkeypatch): flow.run_login_flow() -def test_login_timeout_suggests_retry(): - # Mirror the existing timeout-path test setup in this module; the raised - # APIError should now split message and suggestion. - from aai_cli.errors import APIError - - err = APIError("Login timed out waiting for the browser.", suggestion="Run 'aai login' again.") - assert err.suggestion == "Run 'aai login' again." - - def test_run_login_flow_zero_orgs_raises(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) monkeypatch.setattr( diff --git a/tests/test_client.py b/tests/test_client.py index 6ebc9293..a4cf608e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -94,19 +94,10 @@ def test_resolve_audio_source_sample_explicit_and_missing(): assert client.resolve_audio_source(None, sample=True) == client.SAMPLE_AUDIO_URL assert client.resolve_audio_source("clip.mp3", sample=False) == "clip.mp3" - with pytest.raises(UsageError): + with pytest.raises(UsageError) as exc: client.resolve_audio_source(None, sample=False) - - -def test_no_audio_source_suggests_sample(): - from aai_cli.errors import UsageError - - # Reproduce the "neither path nor --sample" guard at client.py:29. - err = UsageError( - "Provide an audio path or URL.", - suggestion="Or pass --sample to use the hosted demo file.", - ) - assert err.suggestion is not None + assert exc.value.message == "Provide an audio path or URL." + assert "--sample" in (exc.value.suggestion or "") def test_transcribe_blocks_and_returns_transcript(): From d737149e713815c082a5cc9f8504a9c92e10b85c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 19:05:12 -0700 Subject: [PATCH 21/29] test(output): snapshot --help and error renders with syrupy Golden tests pin the exact rendered CLI surface: every leaf command's --help (usage + options + Examples epilog) and the human/JSON forms of errors (including the Suggestion: line). The command list is derived from the live Typer tree, so new commands are automatically required to have a snapshot. Refresh intentionally with: pytest --snapshot-update Requires the syrupy>=4.0 dev dependency. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_cli_output_snapshots.ambr | 709 ++++++++++++++++++ tests/test_cli_output_snapshots.py | 94 +++ 2 files changed, 803 insertions(+) create mode 100644 tests/__snapshots__/test_cli_output_snapshots.ambr create mode 100644 tests/test_cli_output_snapshots.py 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: