From 50ce6ca0701a2f7301e2395ebbe68d8c4e0f1dcb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 15:41:53 +0000 Subject: [PATCH 1/9] Group transcribe flags into named help panels transcribe exposes 40+ options that previously rendered as one flat Options wall in --help. Tag each flag with rich_help_panel so the existing code-comment groupings (model, formatting, speakers, guardrails, analysis, customization, webhooks, translation, advanced, LLM transform) become visible sections. Everyday flags (--sample, --json, -o, --show-code) stay in the default Options panel so the common case stays at the top. Panel headings are centralized in help_panels.py as a single source of truth. --- aai_cli/commands/transcribe.py | 199 ++++++++++++++---- aai_cli/help_panels.py | 17 ++ .../test_cli_output_snapshots.ambr | 187 +++++++--------- 3 files changed, 258 insertions(+), 145 deletions(-) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 01ea6289..60f6ab71 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -82,115 +82,228 @@ def transcribe( sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), # model & language speech_model: str | None = typer.Option( - None, "--speech-model", help="Speech model: best, nano, slam-1, or universal." + None, + "--speech-model", + help="Speech model: best, nano, slam-1, or universal.", + rich_help_panel=help_panels.OPT_MODEL, ), language_code: str | None = typer.Option( - None, "--language-code", help="Force a language (e.g. en_us)." + None, + "--language-code", + help="Force a language (e.g. en_us).", + rich_help_panel=help_panels.OPT_MODEL, ), language_detection: bool | None = typer.Option( - None, "--language-detection", help="Auto-detect the spoken language." + None, + "--language-detection", + help="Auto-detect the spoken language.", + rich_help_panel=help_panels.OPT_MODEL, ), keyterms_prompt: list[str] | None = typer.Option( - None, "--keyterms-prompt", help="Boost a key term (repeatable)." + None, + "--keyterms-prompt", + help="Boost a key term (repeatable).", + rich_help_panel=help_panels.OPT_MODEL, ), temperature: float | None = typer.Option( - None, "--temperature", help="Speech model temperature." + None, + "--temperature", + help="Speech model temperature.", + rich_help_panel=help_panels.OPT_MODEL, ), prompt: str | None = typer.Option( - None, "--prompt", help="Prompt to bias the speech model (u3-pro)." + None, + "--prompt", + help="Prompt to bias the speech model (u3-pro).", + rich_help_panel=help_panels.OPT_MODEL, ), # formatting punctuate: bool | None = typer.Option( - None, "--punctuate/--no-punctuate", help="Add punctuation." + None, + "--punctuate/--no-punctuate", + help="Add punctuation.", + rich_help_panel=help_panels.OPT_FORMATTING, ), format_text: bool | None = typer.Option( - None, "--format-text/--no-format-text", help="Apply text formatting (casing, numbers)." + None, + "--format-text/--no-format-text", + help="Apply text formatting (casing, numbers).", + rich_help_panel=help_panels.OPT_FORMATTING, ), disfluencies: bool | None = typer.Option( - None, "--disfluencies", help="Keep filler words (e.g. um, uh)." + None, + "--disfluencies", + help="Keep filler words (e.g. um, uh).", + rich_help_panel=help_panels.OPT_FORMATTING, ), # speakers & channels - speaker_labels: bool = typer.Option(False, "--speaker-labels", help="Enable diarization."), + speaker_labels: bool = typer.Option( + False, + "--speaker-labels", + help="Enable diarization.", + rich_help_panel=help_panels.OPT_SPEAKERS, + ), speakers_expected: int | None = typer.Option( - None, "--speakers-expected", help="Hint speaker count." + None, + "--speakers-expected", + help="Hint speaker count.", + rich_help_panel=help_panels.OPT_SPEAKERS, ), multichannel: bool | None = typer.Option( - None, "--multichannel", help="Transcribe each audio channel separately." + None, + "--multichannel", + help="Transcribe each audio channel separately.", + rich_help_panel=help_panels.OPT_SPEAKERS, ), # guardrails redact_pii: bool | None = typer.Option( - None, "--redact-pii", help="Redact PII from the transcript." + None, + "--redact-pii", + help="Redact PII from the transcript.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), redact_pii_policy: str | None = typer.Option( - None, "--redact-pii-policy", help="Comma-separated PII policies (e.g. person_name,...)." + None, + "--redact-pii-policy", + help="Comma-separated PII policies (e.g. person_name,...).", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), redact_pii_sub: str | None = typer.Option( - None, "--redact-pii-sub", help="Replace redacted PII with: hash or entity_name." + None, + "--redact-pii-sub", + help="Replace redacted PII with: hash or entity_name.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), redact_pii_audio: bool | None = typer.Option( - None, "--redact-pii-audio", help="Also redact audio." + None, + "--redact-pii-audio", + help="Also redact audio.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), filter_profanity: bool | None = typer.Option( - None, "--filter-profanity", help="Mask profanity." + None, + "--filter-profanity", + help="Mask profanity.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), content_safety: bool | None = typer.Option( - None, "--content-safety", help="Detect sensitive content." + None, + "--content-safety", + help="Detect sensitive content.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), content_safety_confidence: int | None = typer.Option( - None, "--content-safety-confidence", help="Content-safety confidence threshold (25-100)." + None, + "--content-safety-confidence", + help="Content-safety confidence threshold (25-100).", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), speech_threshold: float | None = typer.Option( - None, "--speech-threshold", help="Minimum proportion of speech required (0-1)." + None, + "--speech-threshold", + help="Minimum proportion of speech required (0-1).", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), # analysis summarization: bool | None = typer.Option( - None, "--summarization", help="Summarize the transcript." + None, + "--summarization", + help="Summarize the transcript.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), summary_model: str | None = typer.Option( - None, "--summary-model", help="Summary model: informative, conversational, or catchy." + None, + "--summary-model", + help="Summary model: informative, conversational, or catchy.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), summary_type: str | None = typer.Option( - None, "--summary-type", help="Summary format: bullets, gist, headline, or paragraph." + None, + "--summary-type", + help="Summary format: bullets, gist, headline, or paragraph.", + rich_help_panel=help_panels.OPT_ANALYSIS, + ), + auto_chapters: bool | None = typer.Option( + None, "--auto-chapters", help="Generate chapters.", rich_help_panel=help_panels.OPT_ANALYSIS ), - auto_chapters: bool | None = typer.Option(None, "--auto-chapters", help="Generate chapters."), sentiment_analysis: bool | None = typer.Option( - None, "--sentiment-analysis", help="Analyze sentiment." + None, + "--sentiment-analysis", + help="Analyze sentiment.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), entity_detection: bool | None = typer.Option( - None, "--entity-detection", help="Detect entities." + None, + "--entity-detection", + help="Detect entities.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), auto_highlights: bool | None = typer.Option( - None, "--auto-highlights", help="Detect key phrases." + None, + "--auto-highlights", + help="Detect key phrases.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), topic_detection: bool | None = typer.Option( - None, "--topic-detection", help="Detect IAB topics." + None, + "--topic-detection", + help="Detect IAB topics.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), # customization word_boost: list[str] | None = typer.Option( - None, "--word-boost", help="Boost a word (repeatable)." + None, + "--word-boost", + help="Boost a word (repeatable).", + rich_help_panel=help_panels.OPT_CUSTOMIZATION, ), custom_spelling_file: str | None = typer.Option( - None, "--custom-spelling-file", help="JSON map of custom spellings." + None, + "--custom-spelling-file", + help="JSON map of custom spellings.", + rich_help_panel=help_panels.OPT_CUSTOMIZATION, + ), + audio_start: int | None = typer.Option( + None, + "--audio-start", + help="Start offset in ms.", + rich_help_panel=help_panels.OPT_CUSTOMIZATION, + ), + audio_end: int | None = typer.Option( + None, "--audio-end", help="End offset in ms.", rich_help_panel=help_panels.OPT_CUSTOMIZATION ), - audio_start: int | None = typer.Option(None, "--audio-start", help="Start offset in ms."), - audio_end: int | None = typer.Option(None, "--audio-end", help="End offset in ms."), # webhooks webhook_url: str | None = typer.Option( - None, "--webhook-url", help="Webhook URL for completion." + None, + "--webhook-url", + help="Webhook URL for completion.", + rich_help_panel=help_panels.OPT_WEBHOOKS, ), webhook_auth_header: str | None = typer.Option( - None, "--webhook-auth-header", help="Webhook auth header as NAME:VALUE." + None, + "--webhook-auth-header", + help="Webhook auth header as NAME:VALUE.", + rich_help_panel=help_panels.OPT_WEBHOOKS, ), # speech understanding translate_to: list[str] | None = typer.Option( - None, "--translate-to", help="Translate transcript to a language (repeatable)." + None, + "--translate-to", + help="Translate transcript to a language (repeatable).", + rich_help_panel=help_panels.OPT_TRANSLATION, ), # escape hatch config_kv: list[str] | None = typer.Option( - None, "--config", help="Set any TranscriptionConfig field as KEY=VALUE (repeatable)." + None, + "--config", + help="Set any TranscriptionConfig field as KEY=VALUE (repeatable).", + rich_help_panel=help_panels.OPT_ADVANCED, ), config_file: str | None = typer.Option( - None, "--config-file", help="JSON file of config fields." + None, + "--config-file", + help="JSON file of config fields.", + rich_help_panel=help_panels.OPT_ADVANCED, ), # llm gateway transform llm_prompt: list[str] | None = typer.Option( @@ -198,9 +311,17 @@ def transcribe( "--llm", help="Transform the finished transcript through LLM Gateway. Repeatable: each " "prompt runs on the previous one's response (a chain), the first on the transcript.", + rich_help_panel=help_panels.OPT_LLM, + ), + model: str = typer.Option( + llm.DEFAULT_MODEL, "--model", help="LLM Gateway model.", rich_help_panel=help_panels.OPT_LLM + ), + max_tokens: int = typer.Option( + llm.DEFAULT_MAX_TOKENS, + "--max-tokens", + help="Max tokens.", + rich_help_panel=help_panels.OPT_LLM, ), - model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), - max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), output_field: str | None = typer.Option( None, diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index 8486bd89..89316515 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -17,3 +17,20 @@ HISTORY = "History" # browse past work: transcripts, sessions ACCOUNT = "Account" # auth, billing, keys: login/logout/whoami, balance/usage/limits, keys, audit SETUP = "Setup & Tools" # get set up & maintain: samples, doctor, claude, version + +# Option panels group a single command's flags within its own ``--help``. The +# `transcribe` command exposes 40+ options; without panels they render as one +# flat wall. Each ``typer.Option(rich_help_panel=...)`` files the flag under one +# of these headings; flags left unpanelled fall in Rich's default "Options" +# panel — we keep the everyday ones (source, --sample, --json, -o, --show-code) +# there so the common case stays at the top. +OPT_MODEL = "Model & Language" +OPT_FORMATTING = "Formatting" +OPT_SPEAKERS = "Speakers & Channels" +OPT_GUARDRAILS = "Guardrails" +OPT_ANALYSIS = "Analysis" +OPT_CUSTOMIZATION = "Customization" +OPT_WEBHOOKS = "Webhooks" +OPT_TRANSLATION = "Translation" +OPT_ADVANCED = "Advanced" +OPT_LLM = "LLM Transform" diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index c2c5d65c..7b787cee 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -660,112 +660,87 @@ │ source [SOURCE] Audio file path, public URL, or YouTube URL. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --sample Use the hosted │ - │ wildfires.mp3 │ - │ sample. │ - │ --speech-model TEXT Speech model: best, │ - │ nano, slam-1, or │ - │ universal. │ - │ --language-code TEXT Force a language │ - │ (e.g. en_us). │ - │ --language-detection Auto-detect the │ - │ spoken language. │ - │ --keyterms-prompt TEXT Boost a key term │ - │ (repeatable). │ - │ --temperature FLOAT Speech model │ - │ temperature. │ - │ --prompt TEXT Prompt to bias the │ - │ speech model │ - │ (u3-pro). │ - │ --punctuate --no-punctuate Add punctuation. │ - │ --format-text --no-format-text Apply text │ - │ formatting (casing, │ - │ numbers). │ - │ --disfluencies Keep filler words │ - │ (e.g. um, uh). │ - │ --speaker-labels Enable diarization. │ - │ --speakers-expected INTEGER Hint speaker count. │ - │ --multichannel Transcribe each │ - │ audio channel │ - │ separately. │ - │ --redact-pii Redact PII from the │ - │ transcript. │ - │ --redact-pii-policy TEXT Comma-separated PII │ - │ policies (e.g. │ - │ person_name,...). │ - │ --redact-pii-sub TEXT Replace redacted PII │ - │ with: hash or │ - │ entity_name. │ - │ --redact-pii-audio Also redact audio. │ - │ --filter-profanity Mask profanity. │ - │ --content-safety Detect sensitive │ - │ content. │ - │ --content-safety-con… INTEGER Content-safety │ - │ confidence threshold │ - │ (25-100). │ - │ --speech-threshold FLOAT Minimum proportion │ - │ of speech required │ - │ (0-1). │ - │ --summarization Summarize the │ - │ transcript. │ - │ --summary-model TEXT Summary model: │ - │ informative, │ - │ conversational, or │ - │ catchy. │ - │ --summary-type TEXT Summary format: │ - │ bullets, gist, │ - │ headline, or │ - │ paragraph. │ - │ --auto-chapters Generate chapters. │ - │ --sentiment-analysis Analyze sentiment. │ - │ --entity-detection Detect entities. │ - │ --auto-highlights Detect key phrases. │ - │ --topic-detection Detect IAB topics. │ - │ --word-boost TEXT Boost a word │ - │ (repeatable). │ - │ --custom-spelling-fi… TEXT JSON map of custom │ - │ spellings. │ - │ --audio-start INTEGER Start offset in ms. │ - │ --audio-end INTEGER End offset in ms. │ - │ --webhook-url TEXT Webhook URL for │ - │ completion. │ - │ --webhook-auth-header TEXT Webhook auth header │ - │ as NAME:VALUE. │ - │ --translate-to TEXT Translate transcript │ - │ to a language │ - │ (repeatable). │ - │ --config TEXT Set any │ - │ TranscriptionConfig │ - │ field as KEY=VALUE │ - │ (repeatable). │ - │ --config-file TEXT JSON file of config │ - │ fields. │ - │ --llm TEXT Transform the │ - │ finished transcript │ - │ through LLM Gateway. │ - │ Repeatable: each │ - │ prompt runs on the │ - │ previous one's │ - │ response (a chain), │ - │ the first on the │ - │ transcript. │ - │ --model TEXT LLM Gateway model. │ - │ [default: │ - │ claude-haiku-4-5-20… │ - │ --max-tokens INTEGER Max tokens. │ - │ [default: 1000] │ - │ --json Output raw JSON. │ - │ --output -o TEXT Print one field of │ - │ the result: text, │ - │ id, status, │ - │ utterances, srt, or │ - │ json. │ - │ --show-code Print the equivalent │ - │ Python SDK code and │ - │ exit (does not │ - │ transcribe). │ - │ --help Show this message │ - │ and exit. │ + │ --sample Use the hosted wildfires.mp3 sample. │ + │ --json Output raw JSON. │ + │ --output -o TEXT Print one field of the result: text, id, status, │ + │ utterances, srt, or json. │ + │ --show-code Print the equivalent Python SDK code and exit │ + │ (does not transcribe). │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Model & Language ───────────────────────────────────────────────────────────╮ + │ --speech-model TEXT Speech model: best, nano, slam-1, or │ + │ universal. │ + │ --language-code TEXT Force a language (e.g. en_us). │ + │ --language-detection Auto-detect the spoken language. │ + │ --keyterms-prompt TEXT Boost a key term (repeatable). │ + │ --temperature FLOAT Speech model temperature. │ + │ --prompt TEXT Prompt to bias the speech model (u3-pro). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Formatting ─────────────────────────────────────────────────────────────────╮ + │ --punctuate --no-punctuate Add punctuation. │ + │ --format-text --no-format-text Apply text formatting (casing, │ + │ numbers). │ + │ --disfluencies Keep filler words (e.g. um, uh). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Speakers & Channels ────────────────────────────────────────────────────────╮ + │ --speaker-labels Enable diarization. │ + │ --speakers-expected INTEGER Hint speaker count. │ + │ --multichannel Transcribe each audio channel │ + │ separately. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Guardrails ─────────────────────────────────────────────────────────────────╮ + │ --redact-pii Redact PII from the transcript. │ + │ --redact-pii-policy TEXT Comma-separated PII policies │ + │ (e.g. person_name,...). │ + │ --redact-pii-sub TEXT Replace redacted PII with: hash │ + │ or entity_name. │ + │ --redact-pii-audio Also redact audio. │ + │ --filter-profanity Mask profanity. │ + │ --content-safety Detect sensitive content. │ + │ --content-safety-confidence INTEGER Content-safety confidence │ + │ threshold (25-100). │ + │ --speech-threshold FLOAT Minimum proportion of speech │ + │ required (0-1). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Analysis ───────────────────────────────────────────────────────────────────╮ + │ --summarization Summarize the transcript. │ + │ --summary-model TEXT Summary model: informative, │ + │ conversational, or catchy. │ + │ --summary-type TEXT Summary format: bullets, gist, headline, │ + │ or paragraph. │ + │ --auto-chapters Generate chapters. │ + │ --sentiment-analysis Analyze sentiment. │ + │ --entity-detection Detect entities. │ + │ --auto-highlights Detect key phrases. │ + │ --topic-detection Detect IAB topics. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Customization ──────────────────────────────────────────────────────────────╮ + │ --word-boost TEXT Boost a word (repeatable). │ + │ --custom-spelling-file TEXT JSON map of custom spellings. │ + │ --audio-start INTEGER Start offset in ms. │ + │ --audio-end INTEGER End offset in ms. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Webhooks ───────────────────────────────────────────────────────────────────╮ + │ --webhook-url TEXT Webhook URL for completion. │ + │ --webhook-auth-header TEXT Webhook auth header as NAME:VALUE. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Translation ────────────────────────────────────────────────────────────────╮ + │ --translate-to TEXT Translate transcript to a language (repeatable). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Advanced ───────────────────────────────────────────────────────────────────╮ + │ --config TEXT Set any TranscriptionConfig field as KEY=VALUE │ + │ (repeatable). │ + │ --config-file TEXT JSON file of config fields. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ LLM Transform ──────────────────────────────────────────────────────────────╮ + │ --llm TEXT Transform the finished transcript through LLM │ + │ Gateway. Repeatable: each prompt runs on the │ + │ previous one's response (a chain), the first on │ + │ the transcript. │ + │ --model TEXT LLM Gateway model. │ + │ [default: claude-haiku-4-5-20251001] │ + │ --max-tokens INTEGER Max tokens. [default: 1000] │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples From a7f7d013812f011ec97eccb4551c4cd6bb6a3a66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:02:42 +0000 Subject: [PATCH 2/9] Group stream flags into named help panels stream has the same 37-flag wall as transcribe. Tag each flag with rich_help_panel, reusing the shared panel vocabulary (Model & Language, Speakers, Guardrails, Webhooks, LLM Transform, Advanced) so the two sibling commands read consistently, plus three stream-specific panels (Audio Capture, Turn Detection, Features) for real-time concerns that file transcription has no equivalent for. Everyday flags (--sample, --json, -o, --show-code) stay in the default Options panel, mirroring transcribe. --- aai_cli/commands/stream.py | 173 ++++++++++++++---- aai_cli/help_panels.py | 4 + .../test_cli_output_snapshots.ambr | 171 ++++++++--------- 3 files changed, 210 insertions(+), 138 deletions(-) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index fbb670d1..9ed5940c 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -294,101 +294,194 @@ def stream( help="Audio file path, URL, or YouTube URL to stream. Omit to use the microphone.", ), sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), + # audio capture sample_rate: int | None = typer.Option( None, "--sample-rate", help="Force a microphone capture rate in Hz (default: device native).", + rich_help_panel=help_panels.OPT_CAPTURE, + ), + device: int | None = typer.Option( + None, "--device", help="Microphone device index.", rich_help_panel=help_panels.OPT_CAPTURE ), - device: int | None = typer.Option(None, "--device", help="Microphone device index."), system_audio: bool = typer.Option( False, "--system-audio", help="macOS only: stream system/app audio and microphone as separate sessions.", + rich_help_panel=help_panels.OPT_CAPTURE, ), system_audio_only: bool = typer.Option( False, "--system-audio-only", help="macOS only: stream system/app audio without the microphone.", + rich_help_panel=help_panels.OPT_CAPTURE, ), # model & input speech_model: str = typer.Option( - DEFAULT_SPEECH_MODEL, "--speech-model", help="Streaming speech model." + DEFAULT_SPEECH_MODEL, + "--speech-model", + help="Streaming speech model.", + rich_help_panel=help_panels.OPT_MODEL, ), encoding: str | None = typer.Option( - None, "--encoding", help="Audio encoding: pcm_s16le or pcm_mulaw." + None, + "--encoding", + help="Audio encoding: pcm_s16le or pcm_mulaw.", + rich_help_panel=help_panels.OPT_MODEL, ), language_detection: bool | None = typer.Option( - None, "--language-detection", help="Auto-detect the spoken language." + None, + "--language-detection", + help="Auto-detect the spoken language.", + rich_help_panel=help_panels.OPT_MODEL, + ), + domain: str | None = typer.Option( + None, + "--domain", + help="Domain preset (e.g. medical).", + rich_help_panel=help_panels.OPT_MODEL, + ), + prompt: str | None = typer.Option( + None, + "--prompt", + help="Prompt to bias the speech model (u3-pro).", + rich_help_panel=help_panels.OPT_MODEL, + ), + keyterms_prompt: list[str] | None = typer.Option( + None, + "--keyterms-prompt", + help="Boost a key term (repeatable).", + rich_help_panel=help_panels.OPT_MODEL, ), - domain: str | None = typer.Option(None, "--domain", help="Domain preset (e.g. medical)."), # turn detection end_of_turn_confidence_threshold: float | None = typer.Option( - None, "--end-of-turn-confidence-threshold", help="End-of-turn confidence (0-1)." + None, + "--end-of-turn-confidence-threshold", + help="End-of-turn confidence (0-1).", + rich_help_panel=help_panels.OPT_TURNS, ), min_turn_silence: int | None = typer.Option( - None, "--min-turn-silence", help="Min silence to end a turn (ms)." + None, + "--min-turn-silence", + help="Min silence to end a turn (ms).", + rich_help_panel=help_panels.OPT_TURNS, ), max_turn_silence: int | None = typer.Option( - None, "--max-turn-silence", help="Max silence before ending a turn (ms)." + None, + "--max-turn-silence", + help="Max silence before ending a turn (ms).", + rich_help_panel=help_panels.OPT_TURNS, ), vad_threshold: float | None = typer.Option( - None, "--vad-threshold", help="Voice-activity threshold." + None, + "--vad-threshold", + help="Voice-activity threshold.", + rich_help_panel=help_panels.OPT_TURNS, ), format_turns: bool | None = typer.Option( - None, "--format-turns/--no-format-turns", help="Punctuate/format finalized turns." + None, + "--format-turns/--no-format-turns", + help="Punctuate/format finalized turns.", + rich_help_panel=help_panels.OPT_TURNS, ), include_partial_turns: bool | None = typer.Option( - None, "--include-partial-turns", help="Emit partial turns." + None, + "--include-partial-turns", + help="Emit partial turns.", + rich_help_panel=help_panels.OPT_TURNS, ), - # features - keyterms_prompt: list[str] | None = typer.Option( - None, "--keyterms-prompt", help="Boost a key term (repeatable)." + # speakers + speaker_labels: bool | None = typer.Option( + None, "--speaker-labels", help="Label speakers.", rich_help_panel=help_panels.OPT_SPEAKERS ), - filter_profanity: bool | None = typer.Option( - None, "--filter-profanity", help="Mask profanity." + max_speakers: int | None = typer.Option( + None, "--max-speakers", help="Max speakers.", rich_help_panel=help_panels.OPT_SPEAKERS ), - speaker_labels: bool | None = typer.Option(None, "--speaker-labels", help="Label speakers."), - max_speakers: int | None = typer.Option(None, "--max-speakers", help="Max speakers."), + # features voice_focus: str | None = typer.Option( - None, "--voice-focus", help="Voice focus: near_field or far_field." + None, + "--voice-focus", + help="Voice focus: near_field or far_field.", + rich_help_panel=help_panels.OPT_FEATURES, ), voice_focus_threshold: float | None = typer.Option( - None, "--voice-focus-threshold", help="Voice-focus threshold." + None, + "--voice-focus-threshold", + help="Voice-focus threshold.", + rich_help_panel=help_panels.OPT_FEATURES, ), - redact_pii: bool | None = typer.Option(None, "--redact-pii", help="Redact PII from turns."), - redact_pii_policy: str | None = typer.Option( - None, "--redact-pii-policy", help="Comma-separated PII policies." + inactivity_timeout: int | None = typer.Option( + None, + "--inactivity-timeout", + help="Auto-close after N seconds idle.", + rich_help_panel=help_panels.OPT_FEATURES, ), - redact_pii_sub: str | None = typer.Option( - None, "--redact-pii-sub", help="Replace redacted PII with: hash or entity_name." + # guardrails + filter_profanity: bool | None = typer.Option( + None, + "--filter-profanity", + help="Mask profanity.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), - inactivity_timeout: int | None = typer.Option( - None, "--inactivity-timeout", help="Auto-close after N seconds idle." + redact_pii: bool | None = typer.Option( + None, + "--redact-pii", + help="Redact PII from turns.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), - webhook_url: str | None = typer.Option(None, "--webhook-url", help="Webhook URL."), - webhook_auth_header: str | None = typer.Option( - None, "--webhook-auth-header", help="Webhook auth header as NAME:VALUE." + redact_pii_policy: str | None = typer.Option( + None, + "--redact-pii-policy", + help="Comma-separated PII policies.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), - # escape hatch - config_kv: list[str] | None = typer.Option( - None, "--config", help="Set any StreamingParameters field as KEY=VALUE (repeatable)." + redact_pii_sub: str | None = typer.Option( + None, + "--redact-pii-sub", + help="Replace redacted PII with: hash or entity_name.", + rich_help_panel=help_panels.OPT_GUARDRAILS, ), - config_file: str | None = typer.Option( - None, "--config-file", help="JSON file of streaming fields." + # webhooks + webhook_url: str | None = typer.Option( + None, "--webhook-url", help="Webhook URL.", rich_help_panel=help_panels.OPT_WEBHOOKS ), - # existing - prompt: str | None = typer.Option( - None, "--prompt", help="Prompt to bias the speech model (u3-pro)." + webhook_auth_header: str | None = typer.Option( + None, + "--webhook-auth-header", + help="Webhook auth header as NAME:VALUE.", + rich_help_panel=help_panels.OPT_WEBHOOKS, ), + # llm transform llm_prompt: list[str] | None = typer.Option( None, "--llm", help="Run a prompt over the live transcript through LLM Gateway, refreshing the " "answer on every finalized turn. Repeatable: each prompt runs on the previous " "one's response (a chain).", + rich_help_panel=help_panels.OPT_LLM, + ), + model: str = typer.Option( + llm.DEFAULT_MODEL, "--model", help="LLM Gateway model.", rich_help_panel=help_panels.OPT_LLM + ), + max_tokens: int = typer.Option( + llm.DEFAULT_MAX_TOKENS, + "--max-tokens", + help="Max tokens.", + rich_help_panel=help_panels.OPT_LLM, + ), + # escape hatch + config_kv: list[str] | None = typer.Option( + None, + "--config", + help="Set any StreamingParameters field as KEY=VALUE (repeatable).", + rich_help_panel=help_panels.OPT_ADVANCED, + ), + config_file: str | None = typer.Option( + None, + "--config-file", + help="JSON file of streaming fields.", + rich_help_panel=help_panels.OPT_ADVANCED, ), - model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), - max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), output_field: str | None = typer.Option( None, diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index 89316515..76319c6d 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -34,3 +34,7 @@ OPT_TRANSLATION = "Translation" OPT_ADVANCED = "Advanced" OPT_LLM = "LLM Transform" +# stream-specific panels (real-time concerns that file transcription has no equivalent for) +OPT_CAPTURE = "Audio Capture" +OPT_TURNS = "Turn Detection" +OPT_FEATURES = "Features" diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 7b787cee..b3d72bbe 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -526,104 +526,79 @@ │ to use the microphone. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --sample Stream the hosted │ - │ wildfires.mp3 │ - │ sample. │ - │ --sample-rate INTEGER Force a microphone │ - │ capture rate in Hz │ - │ (default: device │ - │ native). │ - │ --device INTEGER Microphone device │ - │ index. │ - │ --system-audio macOS only: stream │ - │ system/app audio and │ - │ microphone as │ - │ separate sessions. │ - │ --system-audio-only macOS only: stream │ - │ system/app audio │ - │ without the │ - │ microphone. │ - │ --speech-model TEXT Streaming speech │ - │ model. │ - │ [default: u3-rt-pro] │ - │ --encoding TEXT Audio encoding: │ - │ pcm_s16le or │ - │ pcm_mulaw. │ - │ --language-detection Auto-detect the │ - │ spoken language. │ - │ --domain TEXT Domain preset (e.g. │ - │ medical). │ - │ --end-of-turn-confi… FLOAT End-of-turn │ - │ confidence (0-1). │ - │ --min-turn-silence INTEGER Min silence to end a │ - │ turn (ms). │ - │ --max-turn-silence INTEGER Max silence before │ - │ ending a turn (ms). │ - │ --vad-threshold FLOAT Voice-activity │ - │ threshold. │ - │ --format-turns --no-format-turns Punctuate/format │ - │ finalized turns. │ - │ --include-partial-t… Emit partial turns. │ - │ --keyterms-prompt TEXT Boost a key term │ - │ (repeatable). │ - │ --filter-profanity Mask profanity. │ - │ --speaker-labels Label speakers. │ - │ --max-speakers INTEGER Max speakers. │ - │ --voice-focus TEXT Voice focus: │ - │ near_field or │ - │ far_field. │ - │ --voice-focus-thres… FLOAT Voice-focus │ - │ threshold. │ - │ --redact-pii Redact PII from │ - │ turns. │ - │ --redact-pii-policy TEXT Comma-separated PII │ - │ policies. │ - │ --redact-pii-sub TEXT Replace redacted PII │ - │ with: hash or │ - │ entity_name. │ - │ --inactivity-timeout INTEGER Auto-close after N │ - │ seconds idle. │ - │ --webhook-url TEXT Webhook URL. │ - │ --webhook-auth-head… TEXT Webhook auth header │ - │ as NAME:VALUE. │ - │ --config TEXT Set any │ - │ StreamingParameters │ - │ field as KEY=VALUE │ - │ (repeatable). │ - │ --config-file TEXT JSON file of │ - │ streaming fields. │ - │ --prompt TEXT Prompt to bias the │ - │ speech model │ - │ (u3-pro). │ - │ --llm TEXT Run a prompt over │ - │ the live transcript │ - │ through LLM Gateway, │ - │ refreshing the │ - │ answer on every │ - │ finalized turn. │ - │ Repeatable: each │ - │ prompt runs on the │ - │ previous one's │ - │ response (a chain). │ - │ --model TEXT LLM Gateway model. │ - │ [default: │ - │ claude-haiku-4-5-20… │ - │ --max-tokens INTEGER Max tokens. │ - │ [default: 1000] │ - │ --json Emit │ - │ newline-delimited │ - │ JSON events. │ - │ --output -o TEXT Output mode: 'text' │ - │ (finalized turns as │ - │ plain lines, │ - │ pipe-friendly) or │ - │ 'json'. │ - │ --show-code Print the equivalent │ - │ Python SDK code and │ - │ exit (does not │ - │ stream). │ - │ --help Show this message │ - │ and exit. │ + │ --sample Stream the hosted wildfires.mp3 sample. │ + │ --json Emit newline-delimited JSON events. │ + │ --output -o TEXT Output mode: 'text' (finalized turns as plain │ + │ lines, pipe-friendly) or 'json'. │ + │ --show-code Print the equivalent Python SDK code and exit │ + │ (does not stream). │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Audio Capture ──────────────────────────────────────────────────────────────╮ + │ --sample-rate INTEGER Force a microphone capture rate in Hz │ + │ (default: device native). │ + │ --device INTEGER Microphone device index. │ + │ --system-audio macOS only: stream system/app audio and │ + │ microphone as separate sessions. │ + │ --system-audio-only macOS only: stream system/app audio │ + │ without the microphone. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Model & Language ───────────────────────────────────────────────────────────╮ + │ --speech-model TEXT Streaming speech model. │ + │ [default: u3-rt-pro] │ + │ --encoding TEXT Audio encoding: pcm_s16le or pcm_mulaw. │ + │ --language-detection Auto-detect the spoken language. │ + │ --domain TEXT Domain preset (e.g. medical). │ + │ --prompt TEXT Prompt to bias the speech model (u3-pro). │ + │ --keyterms-prompt TEXT Boost a key term (repeatable). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Turn Detection ─────────────────────────────────────────────────────────────╮ + │ --end-of-turn-confid… FLOAT End-of-turn │ + │ confidence (0-1). │ + │ --min-turn-silence INTEGER Min silence to end a │ + │ turn (ms). │ + │ --max-turn-silence INTEGER Max silence before │ + │ ending a turn (ms). │ + │ --vad-threshold FLOAT Voice-activity │ + │ threshold. │ + │ --format-turns --no-format-turns Punctuate/format │ + │ finalized turns. │ + │ --include-partial-tu… Emit partial turns. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Speakers & Channels ────────────────────────────────────────────────────────╮ + │ --speaker-labels Label speakers. │ + │ --max-speakers INTEGER Max speakers. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Features ───────────────────────────────────────────────────────────────────╮ + │ --voice-focus TEXT Voice focus: near_field or │ + │ far_field. │ + │ --voice-focus-threshold FLOAT Voice-focus threshold. │ + │ --inactivity-timeout INTEGER Auto-close after N seconds idle. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Guardrails ─────────────────────────────────────────────────────────────────╮ + │ --filter-profanity Mask profanity. │ + │ --redact-pii Redact PII from turns. │ + │ --redact-pii-policy TEXT Comma-separated PII policies. │ + │ --redact-pii-sub TEXT Replace redacted PII with: hash or │ + │ entity_name. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Webhooks ───────────────────────────────────────────────────────────────────╮ + │ --webhook-url TEXT Webhook URL. │ + │ --webhook-auth-header TEXT Webhook auth header as NAME:VALUE. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ LLM Transform ──────────────────────────────────────────────────────────────╮ + │ --llm TEXT Run a prompt over the live transcript through │ + │ LLM Gateway, refreshing the answer on every │ + │ finalized turn. Repeatable: each prompt runs on │ + │ the previous one's response (a chain). │ + │ --model TEXT LLM Gateway model. │ + │ [default: claude-haiku-4-5-20251001] │ + │ --max-tokens INTEGER Max tokens. [default: 1000] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Advanced ───────────────────────────────────────────────────────────────────╮ + │ --config TEXT Set any StreamingParameters field as KEY=VALUE │ + │ (repeatable). │ + │ --config-file TEXT JSON file of streaming fields. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples From 67069cc79a465d95002bdffce844a483d595899e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:13:30 +0000 Subject: [PATCH 3/9] Type closed-set transcribe/stream flags as SDK enums Use the assemblyai SDK's own enums as the Typer option types for flags with a fixed value set: --speech-model, --summary-model, --summary-type, --redact-pii-sub (transcribe) and --speech-model, --encoding, --voice-focus (stream). Typer now renders the valid choices in --help (e.g. [best|nano|slam-1|universal]), validates input with a clean listing error, and completes the values on Tab. A new config_builder.enum_value() unwraps each member to its canonical string at the body boundary, so the config/codegen pipelines stay string-based and --show-code output is unchanged. This also fixes a latent bug: the old --voice-focus help advertised near_field/far_field, but the SDK values are hyphenated (near-field/far-field). --- aai_cli/commands/stream.py | 20 ++-- aai_cli/commands/transcribe.py | 24 ++--- aai_cli/config_builder.py | 10 ++ .../test_cli_output_snapshots.ambr | 99 +++++++++++-------- 4 files changed, 91 insertions(+), 62 deletions(-) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 9ed5940c..746fd127 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -8,7 +8,7 @@ from pathlib import Path import typer -from assemblyai.streaming.v3 import SpeechModel +from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel from aai_cli import client, code_gen, config, config_builder, help_panels, llm, output, youtube from aai_cli.context import AppState, run_command @@ -22,7 +22,7 @@ app = typer.Typer() -DEFAULT_SPEECH_MODEL = SpeechModel.u3_rt_pro.value +DEFAULT_SPEECH_MODEL = SpeechModel.u3_rt_pro # Sources that can be transcribed in parallel sessions: (label, audio chunks, sample rate). _ParallelStreams = list[tuple[str, Iterable[bytes], int]] @@ -317,16 +317,16 @@ def stream( rich_help_panel=help_panels.OPT_CAPTURE, ), # model & input - speech_model: str = typer.Option( + speech_model: SpeechModel = typer.Option( DEFAULT_SPEECH_MODEL, "--speech-model", help="Streaming speech model.", rich_help_panel=help_panels.OPT_MODEL, ), - encoding: str | None = typer.Option( + encoding: Encoding | None = typer.Option( None, "--encoding", - help="Audio encoding: pcm_s16le or pcm_mulaw.", + help="Audio encoding.", rich_help_panel=help_panels.OPT_MODEL, ), language_detection: bool | None = typer.Option( @@ -398,10 +398,10 @@ def stream( None, "--max-speakers", help="Max speakers.", rich_help_panel=help_panels.OPT_SPEAKERS ), # features - voice_focus: str | None = typer.Option( + voice_focus: NoiseSuppressionModel | None = typer.Option( None, "--voice-focus", - help="Voice focus: near_field or far_field.", + help="Voice focus (noise suppression model).", rich_help_panel=help_panels.OPT_FEATURES, ), voice_focus_threshold: float | None = typer.Option( @@ -514,9 +514,9 @@ def body(state: AppState, json_mode: bool) -> None: ) # Every streaming flag except sample_rate, which is set per source at stream time. base_flags: dict[str, object] = { - "speech_model": speech_model, + "speech_model": config_builder.enum_value(speech_model), "format_turns": format_turns if format_turns is not None else True, - "encoding": encoding, + "encoding": config_builder.enum_value(encoding), "language_detection": language_detection, "domain": domain, "end_of_turn_confidence_threshold": end_of_turn_confidence_threshold, @@ -528,7 +528,7 @@ def body(state: AppState, json_mode: bool) -> None: "filter_profanity": filter_profanity, "speaker_labels": speaker_labels, "max_speakers": max_speakers, - "voice_focus": voice_focus, + "voice_focus": config_builder.enum_value(voice_focus), "voice_focus_threshold": voice_focus_threshold, "redact_pii": redact_pii, "redact_pii_policies": config_builder.split_csv(redact_pii_policy), diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 60f6ab71..b75baf80 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -81,10 +81,10 @@ def transcribe( source: str | None = typer.Argument(None, help="Audio file path, public URL, or YouTube URL."), sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), # model & language - speech_model: str | None = typer.Option( + speech_model: aai.SpeechModel | None = typer.Option( None, "--speech-model", - help="Speech model: best, nano, slam-1, or universal.", + help="Speech model.", rich_help_panel=help_panels.OPT_MODEL, ), language_code: str | None = typer.Option( @@ -168,10 +168,10 @@ def transcribe( help="Comma-separated PII policies (e.g. person_name,...).", rich_help_panel=help_panels.OPT_GUARDRAILS, ), - redact_pii_sub: str | None = typer.Option( + redact_pii_sub: aai.PIISubstitutionPolicy | None = typer.Option( None, "--redact-pii-sub", - help="Replace redacted PII with: hash or entity_name.", + help="How to replace redacted PII.", rich_help_panel=help_panels.OPT_GUARDRAILS, ), redact_pii_audio: bool | None = typer.Option( @@ -211,16 +211,16 @@ def transcribe( help="Summarize the transcript.", rich_help_panel=help_panels.OPT_ANALYSIS, ), - summary_model: str | None = typer.Option( + summary_model: aai.SummarizationModel | None = typer.Option( None, "--summary-model", - help="Summary model: informative, conversational, or catchy.", + help="Summary model.", rich_help_panel=help_panels.OPT_ANALYSIS, ), - summary_type: str | None = typer.Option( + summary_type: aai.SummarizationType | None = typer.Option( None, "--summary-type", - help="Summary format: bullets, gist, headline, or paragraph.", + help="Summary format.", rich_help_panel=help_panels.OPT_ANALYSIS, ), auto_chapters: bool | None = typer.Option( @@ -345,7 +345,7 @@ def transcribe( def body(state: AppState, json_mode: bool) -> None: output.validate_output_field(output_field, client.TRANSCRIPT_OUTPUT_FIELDS) flags: dict[str, object] = { - "speech_model": speech_model, + "speech_model": config_builder.enum_value(speech_model), "language_code": language_code, "language_detection": language_detection, "keyterms_prompt": list(keyterms_prompt) if keyterms_prompt else None, @@ -359,15 +359,15 @@ def body(state: AppState, json_mode: bool) -> None: "multichannel": multichannel, "redact_pii": redact_pii, "redact_pii_policies": config_builder.split_csv(redact_pii_policy), - "redact_pii_sub": redact_pii_sub, + "redact_pii_sub": config_builder.enum_value(redact_pii_sub), "redact_pii_audio": redact_pii_audio, "filter_profanity": filter_profanity, "content_safety": content_safety, "content_safety_confidence": content_safety_confidence, "speech_threshold": speech_threshold, "summarization": summarization, - "summary_model": summary_model, - "summary_type": summary_type, + "summary_model": config_builder.enum_value(summary_model), + "summary_type": config_builder.enum_value(summary_type), "auto_chapters": auto_chapters, "sentiment_analysis": sentiment_analysis, "entity_detection": entity_detection, diff --git a/aai_cli/config_builder.py b/aai_cli/config_builder.py index 837e4810..82702d3a 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -351,6 +351,16 @@ def construct_streaming_params(merged: dict[str, typing.Any]) -> StreamingParame return _construct(StreamingParameters, merged, label="streaming") +def enum_value(member: enum.Enum | None) -> str | None: + """The string value of an optional Enum flag, or None when unset. + + CLI options typed as SDK enums (e.g. ``--speech-model``) parse to enum members; + this unwraps them to the canonical string the config/codegen pipelines expect, + so the rest of the flow stays string-based. + """ + return str(member.value) if member is not None else None + + def split_csv(value: str | None) -> list[str] | None: """Split a comma-separated flag value into a list, or None if empty.""" if not value: diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index b3d72bbe..3d785662 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -544,13 +544,20 @@ │ without the microphone. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Model & Language ───────────────────────────────────────────────────────────╮ - │ --speech-model TEXT Streaming speech model. │ - │ [default: u3-rt-pro] │ - │ --encoding TEXT Audio encoding: pcm_s16le or pcm_mulaw. │ - │ --language-detection Auto-detect the spoken language. │ - │ --domain TEXT Domain preset (e.g. medical). │ - │ --prompt TEXT Prompt to bias the speech model (u3-pro). │ - │ --keyterms-prompt TEXT Boost a key term (repeatable). │ + │ --speech-model [universal-streaming-m Streaming speech model. │ + │ ultilingual|universal- [default: u3-rt-pro] │ + │ streaming-english|u3-r │ + │ t-pro|whisper-rt|u3-pr │ + │ o] │ + │ --encoding [pcm_s16le|pcm_mulaw] Audio encoding. │ + │ --language-detection Auto-detect the spoken │ + │ language. │ + │ --domain TEXT Domain preset (e.g. │ + │ medical). │ + │ --prompt TEXT Prompt to bias the │ + │ speech model (u3-pro). │ + │ --keyterms-prompt TEXT Boost a key term │ + │ (repeatable). │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Turn Detection ─────────────────────────────────────────────────────────────╮ │ --end-of-turn-confid… FLOAT End-of-turn │ @@ -570,10 +577,11 @@ │ --max-speakers INTEGER Max speakers. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Features ───────────────────────────────────────────────────────────────────╮ - │ --voice-focus TEXT Voice focus: near_field or │ - │ far_field. │ - │ --voice-focus-threshold FLOAT Voice-focus threshold. │ - │ --inactivity-timeout INTEGER Auto-close after N seconds idle. │ + │ --voice-focus [near-field|far-field] Voice focus (noise │ + │ suppression model). │ + │ --voice-focus-thresho… FLOAT Voice-focus threshold. │ + │ --inactivity-timeout INTEGER Auto-close after N │ + │ seconds idle. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Guardrails ─────────────────────────────────────────────────────────────────╮ │ --filter-profanity Mask profanity. │ @@ -644,13 +652,18 @@ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Model & Language ───────────────────────────────────────────────────────────╮ - │ --speech-model TEXT Speech model: best, nano, slam-1, or │ - │ universal. │ - │ --language-code TEXT Force a language (e.g. en_us). │ - │ --language-detection Auto-detect the spoken language. │ - │ --keyterms-prompt TEXT Boost a key term (repeatable). │ - │ --temperature FLOAT Speech model temperature. │ - │ --prompt TEXT Prompt to bias the speech model (u3-pro). │ + │ --speech-model [best|nano|slam-1|univ Speech model. │ + │ ersal] │ + │ --language-code TEXT Force a language (e.g. │ + │ en_us). │ + │ --language-detection Auto-detect the spoken │ + │ language. │ + │ --keyterms-prompt TEXT Boost a key term │ + │ (repeatable). │ + │ --temperature FLOAT Speech model │ + │ temperature. │ + │ --prompt TEXT Prompt to bias the │ + │ speech model (u3-pro). │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Formatting ─────────────────────────────────────────────────────────────────╮ │ --punctuate --no-punctuate Add punctuation. │ @@ -665,30 +678,36 @@ │ separately. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Guardrails ─────────────────────────────────────────────────────────────────╮ - │ --redact-pii Redact PII from the transcript. │ - │ --redact-pii-policy TEXT Comma-separated PII policies │ - │ (e.g. person_name,...). │ - │ --redact-pii-sub TEXT Replace redacted PII with: hash │ - │ or entity_name. │ - │ --redact-pii-audio Also redact audio. │ - │ --filter-profanity Mask profanity. │ - │ --content-safety Detect sensitive content. │ - │ --content-safety-confidence INTEGER Content-safety confidence │ - │ threshold (25-100). │ - │ --speech-threshold FLOAT Minimum proportion of speech │ - │ required (0-1). │ + │ --redact-pii Redact PII from the │ + │ transcript. │ + │ --redact-pii-policy TEXT Comma-separated PII │ + │ policies (e.g. │ + │ person_name,...). │ + │ --redact-pii-sub [hash|entity_name] How to replace redacted │ + │ PII. │ + │ --redact-pii-audio Also redact audio. │ + │ --filter-profanity Mask profanity. │ + │ --content-safety Detect sensitive │ + │ content. │ + │ --content-safety-confid… INTEGER Content-safety │ + │ confidence threshold │ + │ (25-100). │ + │ --speech-threshold FLOAT Minimum proportion of │ + │ speech required (0-1). │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Analysis ───────────────────────────────────────────────────────────────────╮ - │ --summarization Summarize the transcript. │ - │ --summary-model TEXT Summary model: informative, │ - │ conversational, or catchy. │ - │ --summary-type TEXT Summary format: bullets, gist, headline, │ - │ or paragraph. │ - │ --auto-chapters Generate chapters. │ - │ --sentiment-analysis Analyze sentiment. │ - │ --entity-detection Detect entities. │ - │ --auto-highlights Detect key phrases. │ - │ --topic-detection Detect IAB topics. │ + │ --summarization Summarize the │ + │ transcript. │ + │ --summary-model [informative|conversati Summary model. │ + │ onal|catchy] │ + │ --summary-type [bullets|bullets_verbos Summary format. │ + │ e|gist|headline|paragra │ + │ ph] │ + │ --auto-chapters Generate chapters. │ + │ --sentiment-analysis Analyze sentiment. │ + │ --entity-detection Detect entities. │ + │ --auto-highlights Detect key phrases. │ + │ --topic-detection Detect IAB topics. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Customization ──────────────────────────────────────────────────────────────╮ │ --word-boost TEXT Boost a word (repeatable). │ From c7172d1d63664bf454e8ee307aa71e1e55e1d79b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:19:10 +0000 Subject: [PATCH 4/9] Type -o/--output as StrEnums; drop manual output-field validator Introduce aai_cli/choices.py with TranscriptOutput (text/id/status/ utterances/srt/json) and TextOrJson (text/json) StrEnums, and use them as the -o/--output option type across transcribe, transcripts get, stream, agent, and llm. Typer now lists the modes in --help and rejects a bad value with a clean listing error, replacing the hand-rolled output.validate_output_field and client.TRANSCRIPT_OUTPUT_FIELDS (both removed). StrEnum members are strings, so the existing field == 'text' comparisons and select_transcript_field() calls are unchanged. --- aai_cli/choices.py | 27 ++++ aai_cli/client.py | 4 - aai_cli/commands/agent.py | 6 +- aai_cli/commands/llm.py | 5 +- aai_cli/commands/stream.py | 16 ++- aai_cli/commands/transcribe.py | 6 +- aai_cli/commands/transcripts.py | 7 +- aai_cli/output.py | 19 +-- .../test_cli_output_snapshots.ambr | 131 ++++++++++-------- 9 files changed, 127 insertions(+), 94 deletions(-) create mode 100644 aai_cli/choices.py diff --git a/aai_cli/choices.py b/aai_cli/choices.py new file mode 100644 index 00000000..24c2f6e5 --- /dev/null +++ b/aai_cli/choices.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import enum + +# CLI-owned closed value sets for ``-o/--output``. ``StrEnum`` members *are* their +# string values: Typer renders them as choices in ``--help`` (e.g. +# [text|id|status|utterances|srt|json]), validates input with a clean listing error, +# and completes them on Tab — while existing ``field == "text"`` comparisons and +# ``select_transcript_field(t, field)`` calls keep working unchanged. + + +class TranscriptOutput(enum.StrEnum): + """Single-field output modes for a finished transcript (`transcribe`, `transcripts get`).""" + + text = "text" + id = "id" + status = "status" + utterances = "utterances" + srt = "srt" + json = "json" + + +class TextOrJson(enum.StrEnum): + """Output mode for the streaming/LLM commands: plain finalized text or raw JSON.""" + + text = "text" + json = "json" diff --git a/aai_cli/client.py b/aai_cli/client.py index 3aa9d507..772c1a7f 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -134,10 +134,6 @@ def transcript_json_payload(transcript: Any) -> dict[str, object]: return getattr(transcript, "json_response", None) or transcript_summary(transcript) -# Fields `transcribe` and `transcripts get` expose via `-o/--output` (raw, pipe-friendly). -TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "srt", "json") - - def _transcript_text(transcript: Any) -> str: return str(getattr(transcript, "text", "") or "") diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 2e041172..ea353b55 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -6,7 +6,7 @@ import typer -from aai_cli import client, code_gen, config, help_panels, output +from aai_cli import choices, client, code_gen, config, help_panels, output from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer from aai_cli.agent.render import AgentRenderer from aai_cli.agent.session import ( @@ -96,11 +96,11 @@ def agent( device: int | None = typer.Option(None, "--device", help="Microphone device index."), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), - output_field: str | None = typer.Option( + output_field: choices.TextOrJson | None = typer.Option( None, "-o", "--output", - help="Output mode: 'text' (you:/agent: lines as plain stdout, pipe-friendly) or 'json'.", + help="Output mode: text (you:/agent: lines as plain stdout, pipe-friendly) or json.", ), show_code: bool = typer.Option( False, diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index a1c4fb60..6ce9b56b 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -5,7 +5,7 @@ import typer from rich.markup import escape -from aai_cli import config, help_panels, output, stdio +from aai_cli import choices, config, help_panels, output, stdio from aai_cli import llm as gateway from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError @@ -69,7 +69,7 @@ def llm( "the answer in place on every finalized turn (e.g. aai stream -o text | aai " 'llm -f "summarize action items as I talk"). Ctrl-C to stop.', ), - output_field: str | None = typer.Option( + output_field: choices.TextOrJson | None = typer.Option( None, "-o", "--output", @@ -121,7 +121,6 @@ def body(state: AppState, json_mode: bool) -> None: suggestion="Or pass --list-models to see available models.", ) prompt_text = prompt - 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 # explicit --transcript-id is given (that injects server-side and takes priority). diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 746fd127..0a8ff035 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -10,7 +10,17 @@ import typer from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel -from aai_cli import client, code_gen, config, config_builder, help_panels, llm, output, youtube +from aai_cli import ( + choices, + client, + code_gen, + config, + config_builder, + help_panels, + llm, + output, + youtube, +) from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError, UsageError from aai_cli.follow import FollowRenderer @@ -483,11 +493,11 @@ def stream( rich_help_panel=help_panels.OPT_ADVANCED, ), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), - output_field: str | None = typer.Option( + output_field: choices.TextOrJson | None = typer.Option( None, "-o", "--output", - help="Output mode: 'text' (finalized turns as plain lines, pipe-friendly) or 'json'.", + help="Output mode: text (finalized turns as plain lines, pipe-friendly) or json.", ), show_code: bool = typer.Option( False, diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index b75baf80..4bce00f4 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -8,6 +8,7 @@ import typer from aai_cli import ( + choices, client, code_gen, config, @@ -323,11 +324,11 @@ def transcribe( rich_help_panel=help_panels.OPT_LLM, ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), - output_field: str | None = typer.Option( + output_field: choices.TranscriptOutput | None = typer.Option( None, "-o", "--output", - help="Print one field of the result: text, id, status, utterances, srt, or json.", + help="Print one field of the result.", ), show_code: bool = typer.Option( False, @@ -343,7 +344,6 @@ def transcribe( """ def body(state: AppState, json_mode: bool) -> None: - output.validate_output_field(output_field, client.TRANSCRIPT_OUTPUT_FIELDS) flags: dict[str, object] = { "speech_model": config_builder.enum_value(speech_model), "language_code": language_code, diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index d3b2737d..15838a82 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -5,7 +5,7 @@ from rich.table import Table from rich.text import Text -from aai_cli import client, config, output, theme +from aai_cli import choices, 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 @@ -24,18 +24,17 @@ def get( ctx: typer.Context, transcript_id: str = typer.Argument(..., help="Transcript id."), - output_field: str | None = typer.Option( + output_field: choices.TranscriptOutput | None = typer.Option( None, "-o", "--output", - help="Print one field of the result: text, id, status, utterances, srt, or json.", + help="Print one field of the result.", ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: """Fetch a past transcript by id and print its text.""" def body(state: AppState, json_mode: bool) -> None: - output.validate_output_field(output_field, client.TRANSCRIPT_OUTPUT_FIELDS) api_key = config.resolve_api_key(profile=state.profile) transcript = client.get_transcript(api_key, transcript_id) if client.status_str(transcript) == "error": diff --git a/aai_cli/output.py b/aai_cli/output.py index 3df067c0..7cf6987c 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -10,8 +10,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import theme -from aai_cli.errors import UsageError +from aai_cli import choices, theme if TYPE_CHECKING: from aai_cli.errors import CLIError @@ -39,22 +38,16 @@ def resolve_json(*, explicit: bool) -> bool: return explicit or _is_agentic() -def validate_output_field(field: str | None, allowed: tuple[str, ...]) -> None: - """Reject an unknown ``-o/--output`` value with a consistent, listing error.""" - if field is not None and field not in allowed: - raise UsageError(f"Unknown --output {field!r}. Choose one of: {', '.join(allowed)}.") - - -def stream_output_modes(field: str | None, *, json_mode: bool) -> tuple[bool, bool]: +def stream_output_modes(field: choices.TextOrJson | None, *, json_mode: bool) -> tuple[bool, bool]: """Fold a streaming command's ``-o/--output`` into ``(text_mode, json_mode)``. Shared by `stream` and `agent`, whose renderers take the same two flags: `text` emits plain finalized lines, `json` forces NDJSON, and an unset field falls back - to the auto-detected `json_mode` (JSON when piped/agentic, human otherwise). + to the auto-detected `json_mode` (JSON when piped/agentic, human otherwise). Typer + validates `field` against the enum, so no value check is needed here. """ - validate_output_field(field, ("text", "json")) - text_mode = field == "text" - return text_mode, (field == "json") or (json_mode and not text_mode) + text_mode = field is choices.TextOrJson.text + return text_mode, (field is choices.TextOrJson.json) or (json_mode and not text_mode) def mask_secret(value: str) -> str: diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 3d785662..8300178b 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -17,31 +17,36 @@ │ 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. │ + │ --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|json] 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 @@ -271,24 +276,26 @@ │ prompt [PROMPT] The prompt to send to the model. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --model TEXT LLM Gateway model. │ - │ [default: claude-haiku-4-5-20251001] │ - │ --transcript-id TEXT Inject this transcript's text into the │ - │ prompt. │ - │ --system TEXT Optional system prompt. │ - │ --follow -f Re-run the prompt over a growing │ - │ transcript piped on stdin, refreshing the │ - │ answer in place on every finalized turn │ - │ (e.g. aai stream -o text | aai llm -f │ - │ "summarize action items as I talk"). │ - │ Ctrl-C to stop. │ - │ --output -o TEXT Print one field of the result: text (just │ - │ the answer, pipe-friendly) or json. │ - │ --max-tokens INTEGER Max tokens to generate. [default: 1000] │ - │ --list-models Print known models and exit. │ - │ --json Output raw JSON (one object per turn in │ - │ --follow mode). │ - │ --help Show this message and exit. │ + │ --model TEXT LLM Gateway model. │ + │ [default: claude-haiku-4-5-20251001] │ + │ --transcript-id TEXT Inject this transcript's text into the │ + │ prompt. │ + │ --system TEXT Optional system prompt. │ + │ --follow -f Re-run the prompt over a growing │ + │ transcript piped on stdin, refreshing │ + │ the answer in place on every finalized │ + │ turn (e.g. aai stream -o text | aai │ + │ llm -f "summarize action items as I │ + │ talk"). Ctrl-C to stop. │ + │ --output -o [text|json] Print one field of the result: text │ + │ (just the answer, pipe-friendly) or │ + │ json. │ + │ --max-tokens INTEGER Max tokens to generate. │ + │ [default: 1000] │ + │ --list-models Print known models and exit. │ + │ --json Output raw JSON (one object per turn │ + │ in --follow mode). │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -526,13 +533,13 @@ │ to use the microphone. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --sample Stream the hosted wildfires.mp3 sample. │ - │ --json Emit newline-delimited JSON events. │ - │ --output -o TEXT Output mode: 'text' (finalized turns as plain │ - │ lines, pipe-friendly) or 'json'. │ - │ --show-code Print the equivalent Python SDK code and exit │ - │ (does not stream). │ - │ --help Show this message and exit. │ + │ --sample Stream the hosted wildfires.mp3 sample. │ + │ --json Emit newline-delimited JSON events. │ + │ --output -o [text|json] Output mode: text (finalized turns as │ + │ plain lines, pipe-friendly) or json. │ + │ --show-code Print the equivalent Python SDK code and │ + │ exit (does not stream). │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Audio Capture ──────────────────────────────────────────────────────────────╮ │ --sample-rate INTEGER Force a microphone capture rate in Hz │ @@ -643,13 +650,15 @@ │ source [SOURCE] Audio file path, public URL, or YouTube URL. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --sample Use the hosted wildfires.mp3 sample. │ - │ --json Output raw JSON. │ - │ --output -o TEXT Print one field of the result: text, id, status, │ - │ utterances, srt, or json. │ - │ --show-code Print the equivalent Python SDK code and exit │ - │ (does not transcribe). │ - │ --help Show this message and exit. │ + │ --sample Use the hosted │ + │ wildfires.mp3 sample. │ + │ --json Output raw JSON. │ + │ --output -o [text|id|status|utterances Print one field of the │ + │ |srt|json] result. │ + │ --show-code Print the equivalent Python │ + │ SDK code and exit (does not │ + │ transcribe). │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Model & Language ───────────────────────────────────────────────────────────╮ │ --speech-model [best|nano|slam-1|univ Speech model. │ @@ -764,10 +773,10 @@ │ * transcript_id TEXT Transcript id. [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --output -o TEXT Print one field of the result: text, id, status, │ - │ utterances, srt, or json. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --output -o [text|id|status|utterances|s Print one field of the │ + │ rt|json] result. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples From 4e0f8d7d837c8f4678773ef1eb2a7e0b0090e4b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:22:16 +0000 Subject: [PATCH 5/9] Type setup --scope as a StrEnum Use choices.Scope (user/project/local) for the --scope option on 'aai setup install' and 'aai setup remove', so Typer lists the scopes in --help and rejects a bad value with a clean listing error. Removes the hand-rolled _VALID_SCOPES check in both commands; StrEnum members are strings, so they pass straight through to 'claude mcp add'. --- .mcp.json | 6 ++++- aai_cli/choices.py | 8 ++++++ aai_cli/commands/setup.py | 25 +++++-------------- .../test_cli_output_snapshots.ambr | 22 ++++++++-------- 4 files changed, 30 insertions(+), 31 deletions(-) diff --git a/.mcp.json b/.mcp.json index dac39594..61ba0a22 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,6 +6,10 @@ "headers": { "Authorization": "Bearer ${GITHUB_PAT}" } + }, + "assemblyai-docs": { + "type": "http", + "url": "https://mcp.assemblyai.com/docs" } } -} +} \ No newline at end of file diff --git a/aai_cli/choices.py b/aai_cli/choices.py index 24c2f6e5..d9272cb4 100644 --- a/aai_cli/choices.py +++ b/aai_cli/choices.py @@ -25,3 +25,11 @@ class TextOrJson(enum.StrEnum): text = "text" json = "json" + + +class Scope(enum.StrEnum): + """Coding-agent config scope for `aai setup` (passed through to `claude mcp add`).""" + + user = "user" + project = "project" + local = "local" diff --git a/aai_cli/commands/setup.py b/aai_cli/commands/setup.py index d3465e80..f4d77429 100644 --- a/aai_cli/commands/setup.py +++ b/aai_cli/commands/setup.py @@ -8,9 +8,8 @@ import typer -from aai_cli import output +from aai_cli import choices, 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 @@ -27,7 +26,6 @@ MCP_NAME = "assemblyai-docs" MCP_URL = "https://mcp.assemblyai.com/docs" SKILL_REPO = "AssemblyAI/assemblyai-skill" -_VALID_SCOPES = ("user", "project", "local") _STEPS_HEADING = "AssemblyAI coding-agent setup:" @@ -300,13 +298,10 @@ def _render(data: dict[str, list[Step]]) -> str: ) def install( ctx: typer.Context, - scope: str = typer.Option( - "user", + scope: choices.Scope = typer.Option( + choices.Scope.user, "--scope", - help=( - "Config scope to register the MCP under: user, project, or local. " - "Presence is detected across all scopes." - ), + help="Config scope to register the MCP under. Presence is detected across all scopes.", ), force: bool = typer.Option(False, "--force", help="Reinstall even if already present."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), @@ -314,10 +309,6 @@ def install( """Install the AssemblyAI docs MCP server and skills into your coding agent.""" def body(_state: AppState, json_mode: bool) -> None: - if scope not in _VALID_SCOPES: - raise UsageError( - f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." - ) steps = [_install_mcp(scope, force), _install_skill(force), _install_cli_skill(force)] output.emit({"steps": steps}, _render, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): @@ -355,11 +346,11 @@ def body(_state: AppState, json_mode: bool) -> None: ) def remove( ctx: typer.Context, - scope: str | None = typer.Option( + scope: choices.Scope | None = typer.Option( None, "--scope", help=( - "Only remove the MCP from this scope (user, project, or local). " + "Only remove the MCP from this scope. " "Default: remove from whichever scope it exists in." ), ), @@ -368,10 +359,6 @@ def remove( """Remove the AssemblyAI MCP server and skills from your coding agent.""" def body(_state: AppState, json_mode: bool) -> None: - if scope is not None and scope not in _VALID_SCOPES: - raise UsageError( - f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." - ) steps = [_remove_mcp(scope), _remove_skill(), _remove_cli_skill()] output.emit({"steps": steps}, _render, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 8300178b..60b7822c 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -455,12 +455,12 @@ Install the AssemblyAI docs MCP server and skills into your coding agent. ╭─ 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. │ + │ --scope [user|project|local] Config scope to register the MCP under. │ + │ 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 @@ -481,11 +481,11 @@ Remove the AssemblyAI MCP server and skills from your coding agent. ╭─ 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. │ + │ --scope [user|project|local] Only remove the MCP from this scope. │ + │ Default: remove from whichever scope it │ + │ exists in. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples From 33d4e0622a2e33e8855e46134bfc77ddce0987ba Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:27:00 +0000 Subject: [PATCH 6/9] Enable shell completion and add --voice/--model value completion Flip add_completion=True so 'aai --install-completion' / '--show-completion' work; combined with the enum-typed flags from earlier, choices, file paths, and command names now complete on Tab. Add autocompletion callbacks for the two open-set flags whose values aren't enums: llm.complete_model (suggests KNOWN_MODELS without restricting input, wired into transcribe/stream/llm --model) and voices.complete_voice (agent --voice). The existing --list-voices/--list-models flags stay as a no-completion-needed fallback. --- aai_cli/agent/voices.py | 5 +++++ aai_cli/commands/agent.py | 9 ++++++-- aai_cli/commands/llm.py | 7 ++++++- aai_cli/commands/stream.py | 6 +++++- aai_cli/commands/transcribe.py | 6 +++++- aai_cli/llm.py | 9 ++++++++ aai_cli/main.py | 2 +- tests/test_completion.py | 38 ++++++++++++++++++++++++++++++++++ 8 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 tests/test_completion.py diff --git a/aai_cli/agent/voices.py b/aai_cli/agent/voices.py index 11fd3e21..955bb21a 100644 --- a/aai_cli/agent/voices.py +++ b/aai_cli/agent/voices.py @@ -47,3 +47,8 @@ def format_voice_list() -> str: """Human-readable, newline-separated voice IDs for --list-voices.""" return "\n".join(VOICES) + + +def complete_voice(incomplete: str) -> list[str]: + """Shell-completion callback for ``--voice``: known voice ids matching the prefix.""" + return [v for v in VOICES if v.startswith(incomplete)] diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index ea353b55..8e8d687b 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -15,7 +15,7 @@ AgentRunConfig, run_session, ) -from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list +from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, complete_voice, 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 @@ -83,7 +83,12 @@ def agent( sample: bool = typer.Option( False, "--sample", help="Speak the hosted wildfires.mp3 sample to the agent." ), - voice: str = typer.Option(DEFAULT_VOICE, "--voice", help="Agent voice. See --list-voices."), + voice: str = typer.Option( + DEFAULT_VOICE, + "--voice", + help="Agent voice. See --list-voices.", + autocompletion=complete_voice, + ), system_prompt: str = typer.Option( DEFAULT_PROMPT, "--system-prompt", help="System prompt (the agent's persona)." ), diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 6ce9b56b..bc197894 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -56,7 +56,12 @@ def llm( ctx: typer.Context, prompt: str | None = typer.Argument(None, help="The prompt to send to the model."), # Note: text piped on stdin is injected into the prompt (e.g. `cat notes | aai llm "summarize"`). - model: str = typer.Option(gateway.DEFAULT_MODEL, "--model", help="LLM Gateway model."), + model: str = typer.Option( + gateway.DEFAULT_MODEL, + "--model", + help="LLM Gateway model.", + autocompletion=gateway.complete_model, + ), transcript_id: str | None = typer.Option( None, "--transcript-id", help="Inject this transcript's text into the prompt." ), diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 0a8ff035..87090b37 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -471,7 +471,11 @@ def stream( rich_help_panel=help_panels.OPT_LLM, ), model: str = typer.Option( - llm.DEFAULT_MODEL, "--model", help="LLM Gateway model.", rich_help_panel=help_panels.OPT_LLM + llm.DEFAULT_MODEL, + "--model", + help="LLM Gateway model.", + rich_help_panel=help_panels.OPT_LLM, + autocompletion=llm.complete_model, ), max_tokens: int = typer.Option( llm.DEFAULT_MAX_TOKENS, diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 4bce00f4..0b773ff1 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -315,7 +315,11 @@ def transcribe( rich_help_panel=help_panels.OPT_LLM, ), model: str = typer.Option( - llm.DEFAULT_MODEL, "--model", help="LLM Gateway model.", rich_help_panel=help_panels.OPT_LLM + llm.DEFAULT_MODEL, + "--model", + help="LLM Gateway model.", + rich_help_panel=help_panels.OPT_LLM, + autocompletion=llm.complete_model, ), max_tokens: int = typer.Option( llm.DEFAULT_MAX_TOKENS, diff --git a/aai_cli/llm.py b/aai_cli/llm.py index 5ba3cede..fd16cf09 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -35,6 +35,15 @@ ) +def complete_model(incomplete: str) -> list[str]: + """Shell-completion callback for ``--model``: known model ids matching the prefix. + + The gateway accepts more than this curated list, so completion only *suggests* + these — it never restricts what you can type. + """ + return [m for m in KNOWN_MODELS if m.startswith(incomplete)] + + def build_messages( prompt: str, *, diff --git a/aai_cli/main.py b/aai_cli/main.py index cc367828..a14d6143 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -82,7 +82,7 @@ def list_commands(self, ctx: ClickContext) -> list[str]: name="aai", help="AssemblyAI from your terminal — transcribe, stream, and build voice AI.", no_args_is_help=True, - add_completion=False, + add_completion=True, cls=_OrderedGroup, ) diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 00000000..f5cc72ff --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typer.testing import CliRunner + +from aai_cli.agent.voices import VOICES, complete_voice +from aai_cli.llm import KNOWN_MODELS, complete_model +from aai_cli.main import app + + +def test_shell_completion_is_enabled(): + # add_completion=True surfaces Typer's --install-completion on the root command. + result = CliRunner().invoke(app, ["--help"]) + assert "--install-completion" in result.output + + +def test_complete_model_filters_by_prefix(): + suggestions = complete_model("gpt") + assert suggestions # at least one gpt-* model is known + assert all(m.startswith("gpt") for m in suggestions) + + +def test_complete_model_empty_prefix_returns_all_known(): + assert complete_model("") == list(KNOWN_MODELS) + + +def test_complete_model_unknown_prefix_returns_nothing(): + assert complete_model("no-such-model") == [] + + +def test_complete_voice_filters_by_prefix(): + prefix = VOICES[0][:2] + suggestions = complete_voice(prefix) + assert suggestions + assert all(v.startswith(prefix) for v in suggestions) + + +def test_complete_voice_empty_prefix_returns_all(): + assert complete_voice("") == VOICES From c4db4268e0e0c74d7f20308007316b9f085f3a63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:47:28 +0000 Subject: [PATCH 7/9] Add file-existence validation, KEY=VALUE metavars, and eager --version - File-path options (transcribe --config-file/--custom-spelling-file, stream --config-file, agent --system-prompt-file) gain exists=True + dir_okay=False, so Typer rejects a missing path up front with a standard message instead of relying on the loaders' deeper handling. Widen config_builder signatures to accept Path. (readable=True was dropped: it can't be exercised in CI, which runs as root.) - Add metavar=KEY=VALUE to --config and metavar=NAME:VALUE to --webhook-auth-header so the expected shape shows in --help. - Add an eager root --version flag alongside the existing 'version' command. --- aai_cli/commands/agent.py | 2 + aai_cli/commands/stream.py | 8 +++- aai_cli/commands/transcribe.py | 10 ++++- aai_cli/config_builder.py | 8 ++-- aai_cli/main.py | 17 ++++++++ .../test_cli_output_snapshots.ambr | 24 +++++------ tests/test_path_validation.py | 42 +++++++++++++++++++ tests/test_smoke.py | 9 ++++ 8 files changed, 100 insertions(+), 20 deletions(-) create mode 100644 tests/test_path_validation.py diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 8e8d687b..83212245 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -96,6 +96,8 @@ def agent( None, "--system-prompt-file", help="Read the system prompt from a file (overrides --system-prompt).", + exists=True, + dir_okay=False, ), greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."), device: int | None = typer.Option(None, "--device", help="Microphone device index."), diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 87090b37..36bf64b2 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -111,7 +111,7 @@ class _StreamSession: api_key: str base_flags: dict[str, object] overrides: list[str] | None - config_file: str | None + config_file: str | Path | None renderer: StreamRenderer follow: FollowRenderer | None llm_prompts: list[str] @@ -460,6 +460,7 @@ def stream( "--webhook-auth-header", help="Webhook auth header as NAME:VALUE.", rich_help_panel=help_panels.OPT_WEBHOOKS, + metavar="NAME:VALUE", ), # llm transform llm_prompt: list[str] | None = typer.Option( @@ -489,12 +490,15 @@ def stream( "--config", help="Set any StreamingParameters field as KEY=VALUE (repeatable).", rich_help_panel=help_panels.OPT_ADVANCED, + metavar="KEY=VALUE", ), - config_file: str | None = typer.Option( + config_file: Path | None = typer.Option( None, "--config-file", help="JSON file of streaming fields.", rich_help_panel=help_panels.OPT_ADVANCED, + exists=True, + dir_okay=False, ), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), output_field: choices.TextOrJson | None = typer.Option( diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 0b773ff1..831c6a25 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -258,11 +258,13 @@ def transcribe( help="Boost a word (repeatable).", rich_help_panel=help_panels.OPT_CUSTOMIZATION, ), - custom_spelling_file: str | None = typer.Option( + custom_spelling_file: Path | None = typer.Option( None, "--custom-spelling-file", help="JSON map of custom spellings.", rich_help_panel=help_panels.OPT_CUSTOMIZATION, + exists=True, + dir_okay=False, ), audio_start: int | None = typer.Option( None, @@ -285,6 +287,7 @@ def transcribe( "--webhook-auth-header", help="Webhook auth header as NAME:VALUE.", rich_help_panel=help_panels.OPT_WEBHOOKS, + metavar="NAME:VALUE", ), # speech understanding translate_to: list[str] | None = typer.Option( @@ -299,12 +302,15 @@ def transcribe( "--config", help="Set any TranscriptionConfig field as KEY=VALUE (repeatable).", rich_help_panel=help_panels.OPT_ADVANCED, + metavar="KEY=VALUE", ), - config_file: str | None = typer.Option( + config_file: Path | None = typer.Option( None, "--config-file", help="JSON file of config fields.", rich_help_panel=help_panels.OPT_ADVANCED, + exists=True, + dir_okay=False, ), # llm gateway transform llm_prompt: list[str] | None = typer.Option( diff --git a/aai_cli/config_builder.py b/aai_cli/config_builder.py index 82702d3a..17b29250 100644 --- a/aai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -286,7 +286,7 @@ def _merge( fields: dict[str, str], flags: dict[str, object], overrides: Sequence[str] | None, - config_file: str | None, + config_file: str | Path | None, ) -> dict[str, object]: data: dict[str, object] = {} if config_file: @@ -300,7 +300,7 @@ def merge_transcribe_config( *, flags: dict[str, object], overrides: Sequence[str] | None = None, - config_file: str | None = None, + config_file: str | Path | None = None, ) -> dict[str, object]: """Merge config-file + --config overrides + curated flags into a kwargs dict.""" return _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) @@ -330,7 +330,7 @@ def merge_streaming_params( *, flags: dict[str, object], overrides: Sequence[str] | None = None, - config_file: str | None = None, + config_file: str | Path | None = None, ) -> dict[str, object]: """Merge streaming config into a kwargs dict, coercing speech_model to a SpeechModel.""" merged = _merge(STREAM_FIELDS, flags, overrides, config_file) @@ -387,7 +387,7 @@ def auth_header_flags(value: str | None) -> dict[str, object]: return {"webhook_auth_header_name": header[0], "webhook_auth_header_value": header[1]} -def load_custom_spelling(path: str) -> dict[str, object]: +def load_custom_spelling(path: str | Path) -> dict[str, object]: """Load a custom-spelling JSON map (e.g. {"AssemblyAI": ["assembly ai"]}).""" return _load_json_object(path, label="Custom spelling file") diff --git a/aai_cli/main.py b/aai_cli/main.py index a14d6143..8b57366c 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -87,6 +87,13 @@ def list_commands(self, ctx: ClickContext) -> list[str]: ) +def _version_callback(value: bool) -> None: + """Eager ``--version``: print the version and exit before any command runs.""" + if value: + typer.echo(__version__) + raise typer.Exit() + + @app.callback( epilog=examples_epilog( [ @@ -103,6 +110,16 @@ def main( None, "--env", help="Backend environment (production, sandbox000)." ), sandbox: bool = typer.Option(False, "--sandbox", help="Shortcut for --env sandbox000."), + _version: bool = typer.Option( + False, + "--version", + help="Show the CLI version and exit.", + callback=_version_callback, + # Eager so --version short-circuits before subcommand/arg parsing. The plain + # `aai --version` path behaves identically with or without this, so there's no + # cheap test that distinguishes it. + is_eager=True, # pragma: no mutate + ), ) -> None: if sandbox and env is None: env = "sandbox000" diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 60b7822c..f6b17ab0 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -31,7 +31,7 @@ │ would in real conversation: │ │ relaxed, low-key, no exclamation │ │ marks.] │ - │ --system-prompt-file PATH Read the system prompt from a │ + │ --system-prompt-file FILE Read the system prompt from a │ │ file (overrides --system-prompt). │ │ --greeting TEXT Spoken greeting. │ │ [default: Hey, what's on your │ @@ -598,8 +598,8 @@ │ entity_name. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Webhooks ───────────────────────────────────────────────────────────────────╮ - │ --webhook-url TEXT Webhook URL. │ - │ --webhook-auth-header TEXT Webhook auth header as NAME:VALUE. │ + │ --webhook-url TEXT Webhook URL. │ + │ --webhook-auth-header NAME:VALUE Webhook auth header as NAME:VALUE. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ LLM Transform ──────────────────────────────────────────────────────────────╮ │ --llm TEXT Run a prompt over the live transcript through │ @@ -611,9 +611,9 @@ │ --max-tokens INTEGER Max tokens. [default: 1000] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Advanced ───────────────────────────────────────────────────────────────────╮ - │ --config TEXT Set any StreamingParameters field as KEY=VALUE │ - │ (repeatable). │ - │ --config-file TEXT JSON file of streaming fields. │ + │ --config KEY=VALUE Set any StreamingParameters field as │ + │ KEY=VALUE (repeatable). │ + │ --config-file FILE JSON file of streaming fields. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -720,21 +720,21 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Customization ──────────────────────────────────────────────────────────────╮ │ --word-boost TEXT Boost a word (repeatable). │ - │ --custom-spelling-file TEXT JSON map of custom spellings. │ + │ --custom-spelling-file FILE JSON map of custom spellings. │ │ --audio-start INTEGER Start offset in ms. │ │ --audio-end INTEGER End offset in ms. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Webhooks ───────────────────────────────────────────────────────────────────╮ - │ --webhook-url TEXT Webhook URL for completion. │ - │ --webhook-auth-header TEXT Webhook auth header as NAME:VALUE. │ + │ --webhook-url TEXT Webhook URL for completion. │ + │ --webhook-auth-header NAME:VALUE Webhook auth header as NAME:VALUE. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Translation ────────────────────────────────────────────────────────────────╮ │ --translate-to TEXT Translate transcript to a language (repeatable). │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Advanced ───────────────────────────────────────────────────────────────────╮ - │ --config TEXT Set any TranscriptionConfig field as KEY=VALUE │ - │ (repeatable). │ - │ --config-file TEXT JSON file of config fields. │ + │ --config KEY=VALUE Set any TranscriptionConfig field as │ + │ KEY=VALUE (repeatable). │ + │ --config-file FILE JSON file of config fields. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ LLM Transform ──────────────────────────────────────────────────────────────╮ │ --llm TEXT Transform the finished transcript through LLM │ diff --git a/tests/test_path_validation.py b/tests/test_path_validation.py new file mode 100644 index 00000000..a4f077fe --- /dev/null +++ b/tests/test_path_validation.py @@ -0,0 +1,42 @@ +"""File-path options reject a missing path up front via Typer's `exists=True`. + +Typer validates the path before the command body runs (no API key needed), and +its message — "does not exist" with exit code 2 — is distinct from the loaders' +own not-found handling, so these also pin the `exists=True` option kwarg. +""" + +from __future__ import annotations + +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() + + +def test_transcribe_config_file_must_exist(tmp_path): + result = runner.invoke( + app, ["transcribe", "x.mp3", "--config-file", str(tmp_path / "nope.json")] + ) + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_transcribe_custom_spelling_file_must_exist(tmp_path): + result = runner.invoke( + app, ["transcribe", "x.mp3", "--custom-spelling-file", str(tmp_path / "nope.json")] + ) + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_stream_config_file_must_exist(tmp_path): + result = runner.invoke(app, ["stream", "--config-file", str(tmp_path / "nope.json")]) + assert result.exit_code == 2 + assert "does not exist" in result.output + + +def test_agent_system_prompt_file_must_exist(tmp_path): + result = runner.invoke(app, ["agent", "--system-prompt-file", str(tmp_path / "nope.txt")]) + assert result.exit_code == 2 + assert "does not exist" in result.output diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 30634fd0..e3def55d 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -19,6 +19,15 @@ def test_version_command(): assert result.output.strip() == __version__ +def test_version_flag(): + from aai_cli import __version__ + + # Eager --version prints the version and exits before any subcommand is required. + result = runner.invoke(app, ["--version"]) + assert result.exit_code == 0 + assert result.output.strip() == __version__ + + def test_global_flags_parse(): # --profile is a global option accepted before a subcommand assert runner.invoke(app, ["--profile", "staging", "version"]).exit_code == 0 From 7c94bb06f125c1acb9af9b570bcfcefa21311253 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 22:57:15 +0000 Subject: [PATCH 8/9] Show a spinner while transcribing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.transcribe() blocks on the SDK's poll-to-completion with no feedback — on a long file the terminal looks frozen. Wrap the wait in a new output.status() context manager that shows an ephemeral rich spinner on stderr, so stdout pipelines (e.g. -o text > out) stay clean. It's a no-op in JSON or non-interactive mode (piped / agent-run), so machine output is never decorated. Uses rich, already a dependency. --- aai_cli/commands/transcribe.py | 3 ++- aai_cli/output.py | 19 ++++++++++++++++++- tests/test_output.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 831c6a25..b4975ec3 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -414,7 +414,8 @@ def body(state: AppState, json_mode: bool) -> None: tc = config_builder.construct_transcription_config(merged) api_key = config.resolve_api_key(profile=state.profile) - transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) + with output.status("Transcribing…", json_mode=json_mode): + transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). diff --git a/aai_cli/output.py b/aai_cli/output.py index 7cf6987c..daa0248c 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -1,9 +1,10 @@ from __future__ import annotations +import contextlib import json import os import sys -from collections.abc import Callable +from collections.abc import Callable, Generator from typing import TYPE_CHECKING from rich import box @@ -127,6 +128,22 @@ def emit_text(text: str) -> None: print(text) +@contextlib.contextmanager +def status(message: str, *, json_mode: bool) -> Generator[None]: + """Show an ephemeral spinner on stderr during a long human-facing wait. + + A no-op in JSON or non-interactive mode (piped / agent-run), so stdout stays + clean for pipelines and machine output is never decorated. Rendered on the + stderr console so even an interactive `aai transcribe x -o text` keeps stdout + pristine. + """ + if json_mode or _is_agentic(): + yield + return + with error_console.status(message): + yield + + def emit_error(err: CLIError, *, json_mode: bool) -> None: # Always to stderr, so stdout stays clean for `aai … | next-tool` pipelines. if json_mode: diff --git a/tests/test_output.py b/tests/test_output.py index 12b265ec..177aca7a 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -190,3 +190,36 @@ def flush(self): # One newline-terminated JSON record, explicitly flushed so live pipelines see it. assert rec.text == '{"a": 1}\n' assert rec.flushed >= 1 + + +def test_status_is_noop_in_json_mode(monkeypatch): + # JSON mode must never enter the spinner (it would render to stderr unnecessarily). + monkeypatch.setattr(output, "_is_agentic", lambda: False) + entered = {"status": False} + monkeypatch.setattr( + output.error_console, "status", lambda *a, **k: entered.__setitem__("status", True) + ) + with output.status("Working…", json_mode=True): + pass + assert entered["status"] is False + + +def test_status_is_noop_when_agentic(monkeypatch): + monkeypatch.setattr(output, "_is_agentic", lambda: True) + entered = {"status": False} + monkeypatch.setattr( + output.error_console, "status", lambda *a, **k: entered.__setitem__("status", True) + ) + with output.status("Working…", json_mode=False): + pass + assert entered["status"] is False + + +def test_status_shows_spinner_for_interactive_human(monkeypatch): + monkeypatch.setattr(output, "_is_agentic", lambda: False) + calls = [] + with output.error_console.capture(): + with output.status("Transcribing…", json_mode=False): + calls.append("inside") + # The body ran inside the spinner context and the spinner targeted stderr. + assert calls == ["inside"] From 427b5e168a72ae324a567c6c5de4f228fb705201 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:25:23 +0000 Subject: [PATCH 9/9] Fix CI: revert stray .mcp.json edit, make completion test width-robust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .mcp.json was accidentally swept into the --scope commit by an 'aai setup install' verification run (added an assemblyai-docs entry and dropped the trailing newline), which failed the pre-commit end-of-file-fixer. Restore it to match origin/main. - test_shell_completion_is_enabled asserted on rendered --help text, which wraps/renders differently in CI's terminal and hid the flag. Introspect the Click command's params instead — stable regardless of width, and still flips when add_completion is disabled. --- .mcp.json | 6 +----- tests/test_completion.py | 11 +++++++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.mcp.json b/.mcp.json index 61ba0a22..dac39594 100644 --- a/.mcp.json +++ b/.mcp.json @@ -6,10 +6,6 @@ "headers": { "Authorization": "Bearer ${GITHUB_PAT}" } - }, - "assemblyai-docs": { - "type": "http", - "url": "https://mcp.assemblyai.com/docs" } } -} \ No newline at end of file +} diff --git a/tests/test_completion.py b/tests/test_completion.py index f5cc72ff..bf5231e3 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typer.testing import CliRunner +import typer from aai_cli.agent.voices import VOICES, complete_voice from aai_cli.llm import KNOWN_MODELS, complete_model @@ -8,9 +8,12 @@ def test_shell_completion_is_enabled(): - # add_completion=True surfaces Typer's --install-completion on the root command. - result = CliRunner().invoke(app, ["--help"]) - assert "--install-completion" in result.output + # add_completion=True registers Typer's --install-completion on the root command. + # Introspect the Click command rather than rendered --help text, which wraps at + # narrow terminal widths (and rendered differently under CI, hiding the flag). + command = typer.main.get_command(app) + option_names = {opt for param in command.params for opt in param.opts} + assert "--install-completion" in option_names def test_complete_model_filters_by_prefix():