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/choices.py b/aai_cli/choices.py new file mode 100644 index 00000000..d9272cb4 --- /dev/null +++ b/aai_cli/choices.py @@ -0,0 +1,35 @@ +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" + + +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/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..83212245 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 ( @@ -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)." ), @@ -91,16 +96,18 @@ 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."), 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..bc197894 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 @@ -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." ), @@ -69,7 +74,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 +126,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/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/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index fbb670d1..36bf64b2 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -8,9 +8,19 @@ from pathlib import Path import typer -from assemblyai.streaming.v3 import SpeechModel - -from aai_cli import client, code_gen, config, config_builder, help_panels, llm, output, youtube +from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel + +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 @@ -22,7 +32,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]] @@ -101,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] @@ -294,107 +304,208 @@ 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." + 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( - None, "--encoding", help="Audio encoding: pcm_s16le or pcm_mulaw." + encoding: Encoding | None = typer.Option( + None, + "--encoding", + help="Audio encoding.", + 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."), - voice_focus: str | None = typer.Option( - None, "--voice-focus", help="Voice focus: near_field or far_field." + # features + voice_focus: NoiseSuppressionModel | None = typer.Option( + None, + "--voice-focus", + help="Voice focus (noise suppression model).", + 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, + metavar="NAME:VALUE", ), + # 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, + autocompletion=llm.complete_model, + ), + 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, + metavar="KEY=VALUE", + ), + 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, ), - 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( + 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, @@ -421,9 +532,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, @@ -435,7 +546,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 01ea6289..b4975ec3 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, @@ -81,116 +82,235 @@ 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( - None, "--speech-model", help="Speech model: best, nano, slam-1, or universal." + speech_model: aai.SpeechModel | None = typer.Option( + None, + "--speech-model", + help="Speech model.", + 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." + redact_pii_sub: aai.PIISubstitutionPolicy | None = typer.Option( + None, + "--redact-pii-sub", + help="How to replace redacted PII.", + 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." + summary_model: aai.SummarizationModel | None = typer.Option( + None, + "--summary-model", + help="Summary model.", + rich_help_panel=help_panels.OPT_ANALYSIS, ), - summary_type: str | None = typer.Option( - None, "--summary-type", help="Summary format: bullets, gist, headline, or paragraph." + summary_type: aai.SummarizationType | None = typer.Option( + None, + "--summary-type", + help="Summary format.", + 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: 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, + "--audio-start", + help="Start offset in ms.", + rich_help_panel=help_panels.OPT_CUSTOMIZATION, ), - custom_spelling_file: str | None = typer.Option( - None, "--custom-spelling-file", help="JSON map of custom spellings." + 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, + metavar="NAME:VALUE", ), # 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, + metavar="KEY=VALUE", ), - config_file: str | None = typer.Option( - None, "--config-file", help="JSON file of config fields." + 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( @@ -198,15 +318,27 @@ 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, + autocompletion=llm.complete_model, + ), + 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( + 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, @@ -222,9 +354,8 @@ 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, @@ -238,15 +369,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, @@ -283,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/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/config_builder.py b/aai_cli/config_builder.py index 837e4810..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) @@ -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: @@ -377,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/help_panels.py b/aai_cli/help_panels.py index 8486bd89..76319c6d 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -17,3 +17,24 @@ 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" +# 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/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 8e0f5838..f5399235 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -82,11 +82,18 @@ 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, ) +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/aai_cli/output.py b/aai_cli/output.py index 3df067c0..daa0248c 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -1,17 +1,17 @@ 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 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 +39,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: @@ -134,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/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index c2c5d65c..f6b17ab0 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 FILE 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 @@ -448,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 @@ -474,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 @@ -526,104 +533,87 @@ │ 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|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 │ + │ (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 [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 │ + │ 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 [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. │ + │ --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 NAME:VALUE 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 KEY=VALUE Set any StreamingParameters field as │ + │ KEY=VALUE (repeatable). │ + │ --config-file FILE JSON file of streaming fields. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -660,112 +650,100 @@ │ 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|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. │ + │ 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. │ + │ --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 [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 [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). │ + │ --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 NAME:VALUE Webhook auth header as NAME:VALUE. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Translation ────────────────────────────────────────────────────────────────╮ + │ --translate-to TEXT Translate transcript to a language (repeatable). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Advanced ───────────────────────────────────────────────────────────────────╮ + │ --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 │ + │ 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 @@ -795,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 diff --git a/tests/test_completion.py b/tests/test_completion.py new file mode 100644 index 00000000..bf5231e3 --- /dev/null +++ b/tests/test_completion.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import typer + +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 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(): + 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 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"] 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