From 0f27dfc018482d431341de929323e4dd85b0db7d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:33:50 +0000 Subject: [PATCH 1/2] Fix UX/DX audit findings across the CLI surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the prioritized findings from the UX/DX audit: High - Onboarding no longer hangs: a non-interactive `aai onboard` session refuses to start a browser sign-in (which would block ~2min on a callback that can't arrive) and fails fast with actionable guidance. - `aai transcripts get --json` now emits the full transcript payload, identical to `aai transcribe --json`, so a fetched transcript round-trips for scripting. - Exit codes are consistent: deploy usage errors and the voice-agent rejected-connection error use the canonical UsageError (2) / NotAuthenticated (4) codes. Documented the exit-code contract in errors.py and the README, plus a JSON-output schema table. Medium - doctor flags an unusable OS keyring and recommends ASSEMBLYAI_API_KEY (instead of a dead-end browser login), and suggests a next step on success. - `aai login --api-key` now warns that account self-service needs a browser login. - The env-mismatch warning carries remediation and is emitted as a structured {"warning": …} object in --json mode. - transcripts/sessions timestamps normalized to UTC and labelled; empty-state messages for transcripts/sessions/keys list. - `--quiet` now also suppresses the spinner. - Reworded the misleading `--prompt` help and split the dense transcribe/stream docstrings so help renders cleanly. Low - `-j` short alias for `--json`; `usage --include-zero` (keeps `--all`); concrete `sessions get` example id; unified file-not-found wording; added remediation to AMS/LLM-gateway errors and the keys no-project error. README documents the flat account command layout and leads Quick Start with `aai onboard`. Tests + snapshots updated; full gate green. https://claude.ai/code/session_014YFntb8MvWVTJNoS4dVpoP --- README.md | 39 +++- aai_cli/agent/session.py | 10 +- aai_cli/auth/ams.py | 10 +- aai_cli/commands/account.py | 9 +- aai_cli/commands/deploy.py | 12 +- aai_cli/commands/doctor.py | 15 ++ aai_cli/commands/keys.py | 10 +- aai_cli/commands/login.py | 27 ++- aai_cli/commands/sessions.py | 14 +- aai_cli/commands/stream.py | 11 +- aai_cli/commands/transcribe.py | 15 +- aai_cli/commands/transcripts.py | 26 ++- aai_cli/config.py | 15 ++ aai_cli/context.py | 4 +- aai_cli/errors.py | 19 ++ aai_cli/llm.py | 11 +- aai_cli/main.py | 6 +- aai_cli/onboard/prompter.py | 8 + aai_cli/onboard/sections.py | 14 +- aai_cli/onboard/wizard.py | 7 +- aai_cli/options.py | 4 +- aai_cli/output.py | 25 ++- aai_cli/streaming/sources.py | 2 +- .../test_cli_output_snapshots.ambr | 210 +++++++++--------- tests/test_account_command.py | 24 +- tests/test_agent_session.py | 11 +- tests/test_auth_ams.py | 5 +- tests/test_config.py | 15 ++ tests/test_context.py | 3 + tests/test_deploy.py | 4 +- tests/test_doctor.py | 24 ++ tests/test_keys.py | 13 ++ tests/test_llm.py | 7 +- tests/test_login.py | 27 ++- tests/test_main_module.py | 1 + tests/test_onboard_prompter.py | 6 + tests/test_onboard_sections.py | 27 ++- tests/test_onboard_wizard.py | 5 + tests/test_output.py | 47 ++++ tests/test_sessions_command.py | 19 +- tests/test_smoke.py | 19 ++ tests/test_streaming_sources.py | 2 + tests/test_transcripts.py | 54 ++++- 43 files changed, 643 insertions(+), 193 deletions(-) diff --git a/README.md b/README.md index c1286515..e45cc2b8 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,12 @@ Prefers [`pipx`](https://pipx.pypa.io), falling back to `pip --user`. ## Quick Start +```sh +aai onboard # guided setup: sign in, first transcription, start building +``` + +Prefer to do it by hand? + ```sh aai login # store your API key (browser-assisted) aai transcribe --sample # transcribe the hosted wildfires.mp3 sample @@ -91,7 +97,38 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `-- | `aai setup install` | Set up your coding agent for AssemblyAI (docs MCP + skills). | | `aai keys` / `balance` / `usage` / `limits` / `sessions` / `audit` | Account self-service (browser login). | -Every command prints human-readable text by default — terminal, pipe, CI, or agent alike. Add `--json` for machine-readable output; it never switches on just because stdout is piped, so `aai transcribe call.mp3 | grep hello` still gets the transcript, not a JSON blob. Errors go to **stderr**, so stdout stays clean for pipelines. +Every command prints human-readable text by default — terminal, pipe, CI, or agent alike. Add `--json` (or `-j`) for machine-readable output; it never switches on just because stdout is piped, so `aai transcribe call.mp3 | grep hello` still gets the transcript, not a JSON blob. Errors go to **stderr**, so stdout stays clean for pipelines. + +Account data lives in **top-level** commands — `aai balance` / `usage` / `limits` / `keys` / `audit`, and `aai login` / `logout` / `whoami` — not under an `aai account` group. + +### JSON output + +`--json` is the scripting contract. The shapes are stable: + +| Command | `--json` shape | +| --- | --- | +| `transcribe` / `transcripts get` | the full transcript payload (`id`, `status`, `text`, `words`, `utterances`, …) — identical for both, so a fetched transcript round-trips | +| `transcribe --llm` | `{id, status, text, transform: {model, steps: [{prompt, output}]}}` | +| `transcripts list` / `sessions list` / `keys list` | a JSON array of row objects (`[]` when empty) | +| `balance` / `usage` / `limits` / `audit` | the raw AMS payload (e.g. `balance.balance_in_cents`; `usage.usage_items[].line_items[].price` in cents) | +| `doctor` | `{ok, profile, environment, checks: [{name, status, affects, detail, fix}]}` | +| any error | `{"error": {"type", "message", "suggestion"?, "transcript_id"?}}` on **stderr** | + +`stream`/`agent` with `--json` emit newline-delimited JSON (one object per event/turn). + +### Exit codes + +Scripts can branch on the exit code: + +| Code | Meaning | +| --- | --- | +| `0` | success | +| `1` | API/network error, missing dependency, or unexpected internal error | +| `2` | usage/validation error (bad flag, bad path, malformed id, unusable config) | +| `4` | not authenticated (no usable key, rejected key, or a self-service command needing browser login) | +| `130` | cancelled with Ctrl-C | + +`aai deploy` / `aai dev` shell out to other tools and propagate that tool's own exit code. > **Tip:** Quote URLs that contain `?` (most YouTube links do) — in zsh the `?` is a glob character: `aai transcribe "https://www.youtube.com/watch?v=VIDEO_ID"`. diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index f5375430..b311aeaf 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -10,7 +10,7 @@ from typing import Any from aai_cli import environments -from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure +from aai_cli.errors import APIError, CLIError, NotAuthenticated, auth_failure, is_auth_failure def ws_url() -> str: @@ -29,7 +29,8 @@ def ws_url() -> str: ) DEFAULT_GREETING = "Hey, what's on your mind?" -# session.error codes that mean the connection is unauthorized -> exit 2. +# session.error codes that mean the connection is unauthorized -> exit 4, the same +# NotAuthenticated code every other rejected-credential path across the CLI uses. _AUTH_ERROR_CODES = {"UNAUTHORIZED", "FORBIDDEN"} # A pre-upgrade HTTP 403 on the WebSocket handshake (see _is_rejected_key). @@ -154,10 +155,9 @@ def raise_error(self, event: dict[str, Any]) -> None: code = event.get("code", "") message = event.get("message") or code or "Voice agent error." if code in _AUTH_ERROR_CODES: - raise CLIError( + raise NotAuthenticated( f"Voice agent rejected the connection: {message}", - error_type="unauthorized", - exit_code=2, + suggestion="Run 'aai login' with a valid key, or set ASSEMBLYAI_API_KEY.", ) raise APIError(f"Voice agent error ({code}): {message}") diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index a81e93bf..9ed24dc6 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -24,9 +24,15 @@ def _detail(resp: httpx.Response) -> str: def _raise_for_error(resp: httpx.Response) -> None: if resp.status_code in (401, 403): - raise NotAuthenticated(f"AMS rejected the login ({resp.status_code}): {_detail(resp)}") + raise NotAuthenticated( + f"AMS rejected the login ({resp.status_code}): {_detail(resp)}", + suggestion="Your browser session may have expired — run 'aai login' again.", + ) if resp.status_code >= _HTTP_ERROR_MIN_STATUS: - raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}") + raise APIError( + f"AMS request failed ({resp.status_code}): {_detail(resp)}", + suggestion="Check your network and try again; if it persists, contact support.", + ) def _json_or_raise(resp: httpx.Response) -> object: diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index c507fe3a..00babd2b 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -150,7 +150,12 @@ def usage( ), end: str | None = typer.Option(None, "--end", help="End date (YYYY-MM-DD). Default: today."), window: str | None = typer.Option(None, "--window", help="Window size, e.g. 'day' or 'month'."), - include_zero: bool = typer.Option(False, "--all", help="Include zero-usage windows."), + include_zero: bool = typer.Option( + False, + "--include-zero", + "--all", + help="Include zero-usage windows (matches --include-logins on `aai audit`).", + ), json_out: bool = options.json_option(), ) -> None: """Show usage over a date range (defaults to the last 30 days).""" @@ -204,7 +209,7 @@ def render(d: dict[str, object]) -> object: table.add_row(*row) hidden_note = ( output.muted( - f"Hidden: {hidden_count} zero-usage window(s). Use --all to show them." + f"Hidden: {hidden_count} zero-usage window(s). Use --include-zero to show them." ) if hidden_count else None diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py index 487e20a6..9e687ab2 100644 --- a/aai_cli/commands/deploy.py +++ b/aai_cli/commands/deploy.py @@ -71,11 +71,7 @@ def command(self, *, prod: bool) -> list[str]: def _resolve_target(selected: list[Target]) -> Target: if len(selected) > 1: flags = " / ".join(t.flag for t in TARGETS) - raise CLIError( - f"Pass at most one deploy target ({flags}).", - error_type="usage_error", - exit_code=1, - ) + raise UsageError(f"Pass at most one deploy target ({flags}).") return selected[0] if selected else VERCEL # Vercel is the default @@ -102,11 +98,9 @@ def _confirmed(target: Target, *, assume_yes: bool) -> bool: if assume_yes: return True if output.is_agentic(): - raise CLIError( + raise UsageError( "Refusing to deploy without confirmation in a non-interactive session. " - "Pass --yes to deploy.", - error_type="usage_error", - exit_code=1, + "Pass --yes to deploy." ) return typer.confirm(f"Deploy this project to {target.name}?") diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index e4839612..d8104373 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -79,6 +79,16 @@ def _check_api_key(profile: str) -> Check: try: key = config.resolve_api_key(profile=profile) except NotAuthenticated: + if not config.keyring_usable(): + # On a box with no keyring, `aai login` can't persist a key either, so + # point at the env var that actually works here instead of a dead end. + return _check( + "api-key", + "fail", + "No API key found, and this machine has no usable OS keyring.", + fix="Set ASSEMBLYAI_API_KEY (browser login can't store a key without a keyring).", + affects=["everything"], + ) return _check( "api-key", "fail", @@ -217,6 +227,11 @@ def render(data: DoctorResult) -> str: lines.append(" " + output.hint(f"fix: {escape(c['fix'])}")) if data["ok"]: lines.append(" " + output.success("Everything looks good.")) + # Only the real `aai doctor` carries profile context; the onboarding wizard + # reuses render() for a partial check and has its own next-steps, so don't + # tack a "try transcribe" hint onto that one. + if data.get("profile") is not None: + lines.append(" " + output.hint("Try it: aai transcribe --sample")) else: failed = sum(1 for c in checks if c["status"] == "fail") noun = "problem" if failed == 1 else "problems" diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index 2020c62a..65d54a4d 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -2,7 +2,6 @@ import typer from rich.markup import escape -from rich.table import Table from aai_cli import jsonshape, options, output from aai_cli.auth import ams @@ -37,7 +36,10 @@ def _default_project_id(account_id: int, jwt: str) -> int: project = jsonshape.as_mapping(projects[0].get("project")) if projects else None pid = _project_id(project) if project is not None else None if pid is None: - raise APIError("Your account has no project to create a key in.") + raise APIError( + "Your account has no project to create a key in.", + suggestion="Create a project in the AssemblyAI dashboard, then try again.", + ) return pid @@ -75,7 +77,9 @@ def body(state: AppState, json_mode: bool) -> None: for token in jsonshape.mapping_list(entry.get("tokens")) ) - def render(data: list[dict[str, object]]) -> Table: + def render(data: list[dict[str, object]]) -> object: + if not data: + return output.muted("No API keys found.") table = output.data_table("id", "name", "project", "key", "disabled") for row in data: table.add_row( diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 0db75ad0..15b4f986 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -58,13 +58,28 @@ def body(state: AppState, json_mode: bool) -> None: # api-key-only, so account self-service must report it needs a browser # login rather than silently reusing the old (possibly different) identity. config.clear_session(profile) + # An --api-key login stores no browser session, so the AMS self-service + # commands won't work for this profile — say so up front instead of letting + # the user hit "needs a browser login" later. + api_key_only = api_key is not None + + def render(_d: object) -> str: + lines = [ + output.success(f"Signed in as {escape(profile)} ({escape(env)})."), + output.hint("Run `aai onboard` to finish setup, or `aai transcribe `."), + ] + if api_key_only: + lines.append( + output.hint( + "Account commands (keys/balance/usage/limits/audit) need " + "`aai login` without --api-key." + ) + ) + return "\n".join(lines) + output.emit( - {"authenticated": True, "profile": profile, "env": env}, - lambda _d: ( - output.success(f"Signed in as {escape(profile)} ({escape(env)}).") - + "\n" - + output.hint("Run `aai onboard` to finish setup, or `aai transcribe `.") - ), + {"authenticated": True, "profile": profile, "env": env, "api_key_only": api_key_only}, + render, json_mode=json_mode, ) diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index 9c054a44..acb265f9 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.table import Table -from aai_cli import jsonshape, options, output, theme +from aai_cli import jsonshape, options, output, theme, timeparse from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.help_text import examples_epilog @@ -62,11 +62,13 @@ def body(state: AppState, json_mode: bool) -> None: payload = ams.list_streaming(jwt, limit=limit, status=status) rows = _session_rows(payload.get("data")) - def render(data: list[dict[str, object]]) -> Table: + def render(data: list[dict[str, object]]) -> object: + if not data: + return output.muted("No streaming sessions yet.") table = output.data_table( "session id", "status", - "created", + "created (UTC)", "audio (s)", "model", ) @@ -74,7 +76,7 @@ def render(data: list[dict[str, object]]) -> Table: table.add_row( escape(str(s["session_id"])), theme.status_text(str(s["status"])), - escape(str(s.get("created_at") or "")), + escape(timeparse.format_utc_datetime(s.get("created_at"))), escape(str(s.get("audio_duration_sec") or "")), escape(str(s.get("speech_model") or "")), ) @@ -88,8 +90,8 @@ def render(data: list[dict[str, object]]) -> Table: @app.command( epilog=examples_epilog( [ - ("Show one session's details", "aai sessions get "), - ("Raw JSON for one session", "aai sessions get --json"), + ("Show one session's details", "aai sessions get sess_5551234"), + ("Raw JSON for one session", "aai sessions get sess_5551234 --json"), ( "Drill into the latest session", "aai sessions get $(aai sessions list --json | jq -r '.[0].session_id')", diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 5d4c397c..dc74b610 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -155,7 +155,7 @@ def stream( prompt: str | None = typer.Option( None, "--prompt", - help="Prompt to bias the speech model (u3-pro).", + help="Prompt to bias the speech model (supported models only).", rich_help_panel=help_panels.OPT_MODEL, ), keyterms_prompt: list[str] | None = typer.Option( @@ -341,10 +341,11 @@ def stream( """Transcribe live audio in real time — from your mic, a file, a URL, or a pipe. Pass - as the source to read raw PCM16/mono/16k audio on stdin, e.g. - ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream -. --prompt biases the - speech model. --llm runs a prompt over the live transcript in-process, refreshing the - answer on every finalized turn; for a separate step instead, pipe the text out with - -o text | aai llm -f "…". + ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream -. + + --prompt biases the speech model. --llm runs a prompt over the live transcript + in-process, refreshing the answer on every finalized turn; for a separate step + instead, pipe the text out with -o text | aai llm -f "…". """ def body(state: AppState, json_mode: bool) -> None: diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index cc81d0ad..92c38b4f 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -111,7 +111,7 @@ def transcribe( prompt: str | None = typer.Option( None, "--prompt", - help="Prompt to bias the speech model (u3-pro).", + help="Prompt to bias the speech model (supported models only).", rich_help_panel=help_panels.OPT_MODEL, ), # formatting @@ -359,10 +359,13 @@ def transcribe( ) -> None: """Transcribe an audio file, URL, or YouTube link. - Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo). Save with - --out FILE, or pipe one field with -o text. A YouTube URL is downloaded first, then - transcribed. Curated flags cover common features; --config KEY=VALUE and --config-file - reach every other field. Analysis (summary, chapters, ...) renders in human mode. + Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo). + + Save with --out FILE, or pipe one field with -o text. A YouTube URL is downloaded + first, then transcribed. + + Curated flags cover common features; --config KEY=VALUE and --config-file reach + every other field. Analysis (summary, chapters, ...) renders in human mode. """ def body(state: AppState, json_mode: bool) -> None: @@ -447,7 +450,7 @@ def body(state: AppState, json_mode: bool) -> None: transcribe_exec.check_source_exists(source, sample=sample) api_key = config.resolve_api_key(profile=state.profile) - with output.status("Transcribing…", json_mode=json_mode): + with output.status("Transcribing…", json_mode=json_mode, quiet=state.quiet): transcript = transcribe_exec.run_transcription( api_key, source, sample=sample, transcription_config=tc ) diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index b33fe37c..692f476c 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -2,9 +2,8 @@ import typer from rich.markup import escape -from rich.table import Table -from aai_cli import choices, client, config, options, output, theme +from aai_cli import choices, client, config, options, output, theme, timeparse from aai_cli.context import AppState, run_command from aai_cli.errors import APIError from aai_cli.help_text import examples_epilog @@ -47,11 +46,16 @@ def body(state: AppState, json_mode: bool) -> None: # Raw single-field output for pipelines (overrides --json), matching `transcribe`. output.emit_text(client.select_transcript_field(transcript, output_field)) return - output.emit( - client.transcript_summary(transcript), - lambda d: escape(str(d["text"])), - json_mode=json_mode, - ) + if json_mode: + # The full SDK payload, identical to `aai transcribe … --json`, so the + # same `jq` works whether the transcript is fetched fresh or re-fetched. + output.emit(client.transcript_json_payload(transcript), lambda d: d, json_mode=True) + else: + output.emit( + client.transcript_summary(transcript), + lambda d: escape(str(d["text"])), + json_mode=False, + ) run_command(ctx, body, json=json_out) @@ -82,13 +86,15 @@ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) rows = client.list_transcripts(api_key, limit=limit) - def render(data: list[dict[str, object]]) -> Table: - table = output.data_table("id", "status", "created") + def render(data: list[dict[str, object]]) -> object: + if not data: + return output.muted("No transcripts yet.") + table = output.data_table("id", "status", "created (UTC)") for row in data: table.add_row( escape(str(row["id"])), theme.status_text(str(row["status"])), - escape(str(row.get("created", ""))), + escape(timeparse.format_utc_datetime(row.get("created"))), ) return table diff --git a/aai_cli/config.py b/aai_cli/config.py index 8adfff8b..27a88759 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -221,6 +221,21 @@ def get_api_key(profile: str) -> str | None: return _keyring_get(profile) +def keyring_usable() -> bool: + """True when the OS keyring backend can be read. + + Headless boxes (containers, CI, bare SSH) often have no keyring backend, so + ``keyring`` raises on every access. ``aai doctor`` uses this to tell a user with + no key that the *backend* is the problem — and to recommend ASSEMBLYAI_API_KEY — + rather than pointing at `aai login`, whose browser flow also can't persist there. + """ + try: + keyring.get_password(KEYRING_SERVICE, "__probe__") + except keyring.errors.KeyringError: + return False + return True + + def get_profile_env(profile: str) -> str | None: """The backend environment recorded for a profile, if any (e.g. 'sandbox000').""" prof = _load().profiles.get(profile) diff --git a/aai_cli/context.py b/aai_cli/context.py index 13c63675..3b411618 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -74,9 +74,11 @@ def env_override_warning(self) -> str | None: profile_env = config.get_profile_env(profile) if profile_env is None or profile_env == selected: return None + fix = "Unset AAI_ENV" if source == "AAI_ENV" else "Drop --env" return ( f"Using {source} {selected}, but profile '{profile}' was set up for " - f"{profile_env}; its stored key may be rejected by {selected}." + f"{profile_env}; its stored key may be rejected by {selected}. " + f"{fix}, or run 'aai login' for {selected}." ) diff --git a/aai_cli/errors.py b/aai_cli/errors.py index f2a3964e..36055fd8 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -1,3 +1,22 @@ +"""The CLIError hierarchy and the exit-code contract scripts can rely on. + +Exit codes (stable; mirrored in the README "Exit codes" table): + +* ``0`` — success. +* ``1`` — a generic runtime failure: an API/network error (:class:`APIError`), + a missing dependency, or an unexpected internal error. +* ``2`` — a usage/validation error (:class:`UsageError` and the validation + ``CLIError``\\s): bad flags, a bad path, a malformed id, or an unusable config. +* ``4`` — not authenticated (:class:`NotAuthenticated`): no usable credential, + a rejected key, or a self-service command that needs a browser login. The + gh-style split keeps "you're not signed in" distinct from a usage error. +* ``130`` — cancelled with Ctrl-C. + +A subprocess the CLI shells out to (``aai deploy``/``dev``) propagates that +process's own exit code unchanged. Each :class:`CLIError` carries an +``error_type`` (the JSON ``error.type``) paired 1:1 with its ``exit_code``. +""" + from __future__ import annotations diff --git a/aai_cli/llm.py b/aai_cli/llm.py index 5f0808ac..02d545f4 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -103,9 +103,16 @@ def complete( # entitlement block ("no access to LLM Gateway"), so surface its actual # message rather than a generic "run aai login" that misleads unpaid # accounts (the key is fine; the feature requires a paid plan). - raise APIError(f"LLM Gateway access denied: {exc}") from exc + raise APIError( + f"LLM Gateway access denied: {exc}", + suggestion="The LLM Gateway requires a paid plan — check your plan at " + "https://www.assemblyai.com/dashboard.", + ) from exc except openai.OpenAIError as exc: - raise APIError(f"LLM Gateway request failed: {exc}") from exc + raise APIError( + f"LLM Gateway request failed: {exc}", + suggestion="Check your network and try again.", + ) from exc def content_of(response: ChatCompletion) -> str: diff --git a/aai_cli/main.py b/aai_cli/main.py index a0ab6938..ff881efe 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -218,7 +218,7 @@ def _command_line_requests_json(raw_args: list[str]) -> bool: callback runs, so sniffing it lets every failure class honor the request. """ for index, token in enumerate(raw_args): - if token in ("--json", "--output=json", "-ojson"): + if token in ("--json", "-j", "--output=json", "-ojson"): return True if token in ("-o", "--output") and raw_args[index + 1 : index + 2] == ["json"]: return True @@ -299,7 +299,9 @@ def main( raise typer.Exit(code=err.exit_code) from None warning = env_override_warning(state) if warning and not quiet: - output.error_console.print(output.warn(warning)) + # Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets a + # machine-readable hint instead of an unexplained downstream auth failure. + output.emit_warning(warning, json_mode=json_mode) if ctx.invoked_subcommand is None: _offer_or_help(ctx, state) diff --git a/aai_cli/onboard/prompter.py b/aai_cli/onboard/prompter.py index fe861ec4..7e7ea1d8 100644 --- a/aai_cli/onboard/prompter.py +++ b/aai_cli/onboard/prompter.py @@ -15,6 +15,10 @@ class WizardCancelled(Exception): class Prompter(Protocol): """How the wizard asks for input — one interface, interactive or not.""" + # True only when a human can answer prompts (and complete a browser sign-in); + # the wizard reads this to skip steps that would otherwise hang a headless run. + interactive: bool + def section(self, title: str) -> None: ... def note(self, message: str) -> None: ... def confirm(self, title: str, *, default: bool = True) -> bool: ... # pragma: no mutate @@ -27,6 +31,8 @@ def text(self, title: str, *, default: str | None = None) -> str: ... class InteractivePrompter: """Drives real terminal prompts (questionary for select, Typer for the rest).""" + interactive = True + def section(self, title: str) -> None: output.console.print("\n" + output.heading(title)) @@ -61,6 +67,8 @@ class NonInteractivePrompter: the wizard without it hanging on a prompt no human will answer. """ + interactive = False + def section(self, title: str) -> None: output.error_console.print(output.heading(title)) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index deeadaca..45b906b1 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -43,6 +43,16 @@ def auth(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.note("Already signed in.") return SectionResult.SKIPPED prompter.section("Sign in") + if not prompter.interactive: + # A browser sign-in can't complete in a non-interactive/agent session: it + # would bind a loopback port and block for two minutes on a callback no one + # can produce. Stop here with the actionable next step instead of hanging. + prompter.note( + "No API key found, and this is a non-interactive session — " + "browser sign-in can't complete here. Run `aai login` in a terminal, " + "or set ASSEMBLYAI_API_KEY." + ) + return SectionResult.FAILED # Browser sign-in only: we deliberately don't offer an API-key paste here so a # secret never lands in the terminal scrollback or shell history. prompter.note(f"No account yet? Create one at {environments.active().signup_url}") @@ -59,7 +69,9 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: ).strip() label = source or "the sample clip" try: - with output.status(f"Transcribing {label}…", json_mode=ctx.json_mode): + with output.status( + f"Transcribing {label}…", json_mode=ctx.json_mode, quiet=ctx.state.quiet + ): transcript = transcribe_exec.run_transcription( api_key, source or None, diff --git a/aai_cli/onboard/wizard.py b/aai_cli/onboard/wizard.py index 197a8ed7..d1d4dd6b 100644 --- a/aai_cli/onboard/wizard.py +++ b/aai_cli/onboard/wizard.py @@ -16,9 +16,10 @@ def run_onboarding(prompter: Prompter, ctx: WizardContext) -> int: try: sections.welcome(prompter, ctx) if sections.auth(prompter, ctx) is SectionResult.FAILED: - output.error_console.print( - output.fail("Could not sign in. Run `aai onboard` again to retry.") - ) + # The auth section already printed the specific next step (browser retry, + # or — non-interactively — `aai login`/ASSEMBLYAI_API_KEY), so keep this + # terminal line neutral rather than implying a re-run always fixes it. + output.error_console.print(output.fail("Sign-in didn't complete.")) return NotAuthenticated().exit_code sections.first_request(prompter, ctx) sections.environment(prompter, ctx) diff --git a/aai_cli/options.py b/aai_cli/options.py index a9472684..af5d0f92 100644 --- a/aai_cli/options.py +++ b/aai_cli/options.py @@ -10,6 +10,6 @@ def json_option(help_text: str = "Output raw JSON.") -> bool: - """The standard ``--json`` flag; pass ``help_text`` where the output shape differs.""" - flag: bool = typer.Option(False, "--json", help=help_text) + """The standard ``--json``/``-j`` flag; pass ``help_text`` where the shape differs.""" + flag: bool = typer.Option(False, "--json", "-j", help=help_text) return flag diff --git a/aai_cli/output.py b/aai_cli/output.py index 03f7f602..dd2f6573 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -167,21 +167,34 @@ def emit_text(text: str) -> None: @contextlib.contextmanager -def status(message: str, *, json_mode: bool) -> Generator[None]: +def status(message: str, *, json_mode: bool, quiet: bool = False) -> 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. + A no-op in JSON or non-interactive mode (piped / agent-run), under ``--quiet``, + 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(): + if json_mode or quiet or is_agentic(): yield return with error_console.status(message): yield +def emit_warning(message: str, *, json_mode: bool) -> None: + """Emit a non-fatal warning to stderr, structured under ``--json``. + + In JSON mode a human ``! …`` line would corrupt a ``{"error": …}`` pipeline, so + the warning ships as its own ``{"warning": …}`` object on stderr — keeping stdout + clean and stderr machine-readable. Human mode gets the familiar yellow line. + """ + if json_mode: + print(json.dumps({"warning": message}, default=str), file=sys.stderr) + else: + error_console.print(warn(message)) + + 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/aai_cli/streaming/sources.py b/aai_cli/streaming/sources.py index 3d83fb8e..41a9a009 100644 --- a/aai_cli/streaming/sources.py +++ b/aai_cli/streaming/sources.py @@ -46,7 +46,7 @@ def __init__(self, source: str, *, sleep: Callable[[float], object] = time.sleep if self._path is not None: if not self._path.is_file(): raise CLIError( - f"No such file: {self._path}", + f"File not found: {self._path}", error_type="file_not_found", exit_code=2, suggestion="Check the path, or pass a URL or YouTube link instead.", diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 7c08045f..92fd6ac8 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -42,7 +42,7 @@ │ mind?] │ │ --device INTEGER Microphone device index. │ │ --list-voices Print known voices and exit. │ - │ --json Emit newline-delimited JSON │ + │ --json -j Emit newline-delimited JSON │ │ events. │ │ --output -o [text|json] Output mode: text (you:/agent: │ │ lines as plain stdout, │ @@ -77,12 +77,12 @@ List recent audit-log entries for your account. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --limit INTEGER How many entries to show. [default: 20] │ - │ --action TEXT Filter by raw action name. │ - │ --resource TEXT Filter by raw resource type. │ - │ --include-logins Show successful login events. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --limit INTEGER How many entries to show. [default: 20] │ + │ --action TEXT Filter by raw action name. │ + │ --resource TEXT Filter by raw resource type. │ + │ --include-logins Show successful login events. │ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -109,8 +109,8 @@ Show your remaining account balance. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -169,14 +169,14 @@ browser. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --port INTEGER Local server port. [default: 3000] │ - │ --host TEXT Interface to bind. Loopback by default; pass │ - │ 0.0.0.0 to expose on your network. │ - │ [default: 127.0.0.1] │ - │ --no-open Launch, but don't open the browser. │ - │ --no-install Skip dependency install; launch directly. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --port INTEGER Local server port. [default: 3000] │ + │ --host TEXT Interface to bind. Loopback by default; pass │ + │ 0.0.0.0 to expose on your network. │ + │ [default: 127.0.0.1] │ + │ --no-open Launch, but don't open the browser. │ + │ --no-install Skip dependency install; launch directly. │ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -201,8 +201,8 @@ Check that your environment is ready to use AssemblyAI. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples @@ -233,13 +233,13 @@ │ directory [DIRECTORY] Target directory (default: