From 2abbc9a3c400c36208884a587bf6e728d2a58905 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 23:56:24 +0000 Subject: [PATCH 1/2] Make CLI output idioms match the tools agents see most Aligns the CLI with the reflexes drilled from gh/docker/kubectl/aws, and makes human-readable text the default everywhere instead of auto-switching to JSON when piped or under CI/an agent. - Default to human output; JSON is opt-in via --json (or -o json where it applied). resolve_json no longer flips on a non-TTY or CI/CLAUDECODE env, so `aai transcribe x | grep word` keeps getting the transcript, not JSON. Being off a TTY still drops color and syntax highlighting. - Add root `aai --version` / `-V` (eager) alongside the `version` subcommand. - Reconcile --json vs -o: -o/--output is now a plain-text field projection only (text, id, status, utterances, srt; text for llm/stream/agent); full JSON / NDJSON lives on --json. Drops the confusing -o json overlap. - Ship shell completion (add_completion=True): --show/--install-completion. - Add a global --quiet/-q to suppress non-essential stderr (warnings, hints). - Give NotAuthenticated its own exit code 4 (was 2), so scripts can tell "not signed in" apart from a usage error; UsageError keeps 2. - Update help text, snapshots, README, and tests to match. https://claude.ai/code/session_01MyytEHLdk3mWdAY3p16cwX --- README.md | 6 ++-- aai_cli/client.py | 4 +-- aai_cli/commands/agent.py | 2 +- aai_cli/commands/llm.py | 6 ++-- aai_cli/commands/stream.py | 2 +- aai_cli/commands/transcribe.py | 3 +- aai_cli/commands/transcripts.py | 15 ++++---- aai_cli/context.py | 10 +++--- aai_cli/errors.py | 5 ++- aai_cli/main.py | 34 ++++++++++++++++-- aai_cli/output.py | 35 ++++++++++--------- .../test_cli_output_snapshots.ambr | 33 ++++++++--------- tests/test_account_command.py | 2 +- tests/test_agent_command.py | 2 +- tests/test_audit_command.py | 2 +- tests/test_context.py | 12 ++++--- tests/test_errors.py | 2 +- tests/test_init_command.py | 10 ++++-- tests/test_keys.py | 4 +-- tests/test_llm_command.py | 24 ++++++++----- tests/test_login.py | 6 ++-- tests/test_output.py | 24 +++++-------- tests/test_samples.py | 2 +- tests/test_sessions_command.py | 2 +- tests/test_setup.py | 8 +++++ tests/test_smoke.py | 30 ++++++++++++++++ tests/test_stream_command.py | 2 +- tests/test_transcribe.py | 2 +- tests/test_transcripts.py | 18 +++++++++- 29 files changed, 203 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 2d7afdd6..6f6807cd 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `-- | `aai samples create ` | Scaffold a runnable starter script. | | `aai keys` / `balance` / `usage` / `limits` / `sessions` / `audit` | Account self-service (browser login). | -Add `--json` to any command for machine-readable output (the default when output is piped or run by an agent). Errors go to **stderr**, so stdout stays clean for pipelines. +Every command prints human-readable text by default — in a terminal, a pipe, CI, or under an agent alike. Add `--json` for machine-readable output (it never switches on you 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. > **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"`. @@ -131,7 +131,7 @@ aai stream --llm "summarize action items as I talk" aai stream --llm "extract action items" --llm "rewrite them as a checklist" # chains ``` -On a terminal you watch one evolving panel; piped onward it emits one JSON object per refresh. Prefer the pipe? Compose the primitives — `aai stream -o text` writes one finalized turn per line and `aai llm -f` re-runs your prompt over the growing transcript: +On a terminal you watch one evolving panel; add `--json` for one JSON object per refresh. Prefer the pipe? Compose the primitives — `aai stream -o text` writes one finalized turn per line and `aai llm -f` re-runs your prompt over the growing transcript: ```sh aai stream -o text | aai llm -f --system "You are a meeting scribe" "summarize action items" @@ -169,7 +169,7 @@ With `--llm` (repeatable), it emits the chained LLM Gateway calls too. # Pick one field with -o aai transcribe call.mp3 -o text # just the transcript text aai transcribe video.mp4 -o srt # SubRip (.srt) captions -aai transcribe call.mp3 -o json | jq . # full JSON when you do want jq +aai transcribe call.mp3 --json | jq . # full JSON when you do want jq # Read audio from stdin ffmpeg -i talk.mp4 -f wav - | aai transcribe - # transcribe any video diff --git a/aai_cli/client.py b/aai_cli/client.py index 3aa9d507..4626e0a2 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -135,7 +135,8 @@ def transcript_json_payload(transcript: Any) -> dict[str, object]: # Fields `transcribe` and `transcripts get` expose via `-o/--output` (raw, pipe-friendly). -TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "srt", "json") +# `-o` is a plain-text projection of one field; full JSON is reached through `--json`. +TRANSCRIPT_OUTPUT_FIELDS = ("text", "id", "status", "utterances", "srt") def _transcript_text(transcript: Any) -> str: @@ -168,7 +169,6 @@ def _export_srt(transcript: Any) -> str: "status": status_str, "utterances": _render_utterances, "srt": _export_srt, - "json": lambda t: json.dumps(transcript_json_payload(t), default=str), } diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 2e041172..288e9a51 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -100,7 +100,7 @@ def agent( None, "-o", "--output", - help="Output mode: 'text' (you:/agent: lines as plain stdout, pipe-friendly) or 'json'.", + help="Print you:/agent: lines as plain stdout (pipe-friendly). For NDJSON, use --json.", ), show_code: bool = typer.Option( False, diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index a1c4fb60..747e89c7 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -73,7 +73,7 @@ def llm( None, "-o", "--output", - help="Print one field of the result: text (just the answer, pipe-friendly) or json.", + help="Print just the answer as plain text (pipe-friendly). For full JSON, use --json.", ), max_tokens: int = typer.Option( gateway.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens to generate." @@ -121,7 +121,7 @@ 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")) + output.validate_output_field(output_field, ("text",)) 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). @@ -144,7 +144,7 @@ def body(state: AppState, json_mode: bool) -> None: output.emit( {"model": model, "output": content, "usage": gateway.usage_of(response)}, lambda d: escape(str(d["output"])), - json_mode=json_mode or output_field == "json", + json_mode=json_mode, ) run_command(ctx, follow_body if follow else body, json=json_out) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index fbb670d1..1f7f529b 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -394,7 +394,7 @@ def stream( None, "-o", "--output", - help="Output mode: 'text' (finalized turns as plain lines, pipe-friendly) or 'json'.", + help="Print finalized turns as plain lines (pipe-friendly). For NDJSON, use --json.", ), show_code: bool = typer.Option( False, diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 01ea6289..67fca99b 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -206,7 +206,8 @@ def transcribe( None, "-o", "--output", - help="Print one field of the result: text, id, status, utterances, srt, or json.", + help="Print one field as plain text: text, id, status, utterances, or srt. " + "For full JSON, use --json.", ), show_code: bool = typer.Option( False, diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index d3b2737d..9123d302 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -28,7 +28,8 @@ def get( None, "-o", "--output", - help="Print one field of the result: text, id, status, utterances, srt, or json.", + help="Print one field as plain text: text, id, status, utterances, or srt. " + "For full JSON, use --json.", ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: @@ -44,14 +45,14 @@ def body(state: AppState, json_mode: bool) -> None: transcript_id=transcript_id, ) if output_field is not None: - # Raw single-field output for pipelines (overrides --json), matching `transcribe`. + # Raw single-field output for pipelines, 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, - ) + # --json emits the full payload (matching `transcribe`); human mode prints text. + if json_mode: + output.emit(client.transcript_json_payload(transcript), lambda d: d, json_mode=True) + else: + output.console.print(escape(client.select_transcript_field(transcript, "text"))) run_command(ctx, body, json=json_out) diff --git a/aai_cli/context.py b/aai_cli/context.py index 35cf79a3..f0ba31ed 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -25,6 +25,7 @@ class AppState: profile: str | None = None env: str | None = None + quiet: bool = False def resolve_profile(self) -> str: """The profile to act on: explicit --profile, else the active profile.""" @@ -115,7 +116,7 @@ def _rerun_after_login_error() -> CLIError: return CLIError( "Signed in. Run the command again to continue.", error_type="login_required", - exit_code=2, + exit_code=4, suggestion="Run the same command again.", ) @@ -146,9 +147,10 @@ def run_command( output.emit_error(err, json_mode=json_mode) raise typer.Exit(code=err.exit_code) from None try: - output.error_console.print( - "[aai.muted]Not signed in; starting browser login.[/aai.muted]" - ) + if not state.quiet: + output.error_console.print( + "[aai.muted]Not signed in; starting browser login.[/aai.muted]" + ) _persist_browser_login(state) except CLIError as login_err: output.emit_error(login_err, json_mode=json_mode) diff --git a/aai_cli/errors.py b/aai_cli/errors.py index fced4741..bedd81c8 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -31,6 +31,9 @@ def to_dict(self) -> dict[str, object]: class NotAuthenticated(CLIError): + # Exit code 4 (not 2) so scripts can tell "you're not signed in" apart from a + # usage error: UsageError keeps the conventional 2, auth gets its own code, the + # same split gh uses. def __init__( self, message: str = "Not authenticated.", @@ -38,7 +41,7 @@ def __init__( suggestion: str | None = "Run 'aai login'.", ) -> None: super().__init__( - message, error_type="not_authenticated", exit_code=2, suggestion=suggestion + message, error_type="not_authenticated", exit_code=4, suggestion=suggestion ) diff --git a/aai_cli/main.py b/aai_cli/main.py index cc367828..a51c6473 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -82,11 +82,22 @@ 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, + # `aai --install-completion` / `--show-completion` for bash/zsh/fish/PowerShell, + # the discoverability affordance gh/kubectl/docker users reach for. + add_completion=True, cls=_OrderedGroup, ) +def _version_callback(value: bool) -> None: + """Print the version and exit when `aai --version`/`-V` is passed, before any command + runs. Mirrors the reflex (`tool --version`) every other CLI answers; the `version` + subcommand stays for parity.""" + if value: + typer.echo(__version__) + raise typer.Exit() + + @app.callback( epilog=examples_epilog( [ @@ -103,10 +114,27 @@ def main( None, "--env", help="Backend environment (production, sandbox000)." ), sandbox: bool = typer.Option(False, "--sandbox", help="Shortcut for --env sandbox000."), + quiet: bool = typer.Option( + False, "--quiet", "-q", help="Suppress non-essential messages (warnings, hints)." + ), + version: bool = typer.Option( + False, + "--version", + "-V", + help="Show the CLI version and exit.", + callback=_version_callback, + # Eager so --version short-circuits before any other option processing — the + # idiomatic default. With no competing eager option or required root argument + # here it's behaviorally indistinguishable from non-eager, so no test can pin it. + is_eager=True, # pragma: no mutate + ), ) -> None: + # `version` is consumed by its eager callback above (prints the version and exits + # before we get here); the parameter exists only to declare the --version/-V option. + del version if sandbox and env is None: env = "sandbox000" - state = AppState(profile=profile, env=env) + state = AppState(profile=profile, env=env, quiet=quiet) ctx.obj = state try: environments.set_active(resolve_environment(state)) @@ -114,7 +142,7 @@ def main( typer.echo(err.message, err=True) raise typer.Exit(code=err.exit_code) from None warning = env_override_warning(state) - if warning: + if warning and not quiet: typer.echo(warning, err=True) diff --git a/aai_cli/output.py b/aai_cli/output.py index 3df067c0..c2748729 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import os import sys from collections.abc import Callable from typing import TYPE_CHECKING @@ -20,7 +19,6 @@ # Errors go to stderr so they never pollute piped stdout (e.g. `aai transcribe x -o text > out`). error_console = theme.make_console(stderr=True) -_AGENT_ENV_VARS = ("CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT") _MIN_MASKABLE_SECRET_LENGTH = 8 @@ -28,15 +26,19 @@ def _stdout_is_tty() -> bool: return sys.stdout.isatty() -def _is_agentic() -> bool: - if not _stdout_is_tty(): - return True - return any(os.environ.get(var) for var in _AGENT_ENV_VARS) - - def resolve_json(*, explicit: bool) -> bool: - """JSON output when asked for, or when not attached to an interactive human.""" - return explicit or _is_agentic() + """JSON output only when explicitly requested with ``--json`` (or ``-o json``). + + Human-readable text is the default for every command, in every context — a + terminal, a pipe, CI, or an agent. We deliberately do NOT switch the output + *shape* to JSON just because stdout is piped or a ``CI``/``CLAUDECODE`` env var + is set: that surprised plain-text pipelines like ``aai transcribe x | grep word`` + by handing them a JSON blob instead of the transcript. Being off a TTY still + drops color and interactivity (Rich handles that automatically); it just no + longer changes the structure. This matches gh/docker/kubectl, which keep their + human/tabular output until you opt in to ``--json``. + """ + return explicit def validate_output_field(field: str | None, allowed: tuple[str, ...]) -> None: @@ -48,13 +50,14 @@ def validate_output_field(field: str | None, allowed: tuple[str, ...]) -> None: def stream_output_modes(field: str | 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). + Shared by `stream` and `agent`. ``-o text`` emits plain finalized lines (handy for + ``aai stream -o text | aai llm -f``); ``--json`` (the canonical machine switch) + forces NDJSON. With neither, the live human panel renders. ``-o`` is a plain-text + projection only — JSON is reached through ``--json``, so the two no longer overlap. """ - validate_output_field(field, ("text", "json")) + validate_output_field(field, ("text",)) text_mode = field == "text" - return text_mode, (field == "json") or (json_mode and not text_mode) + return text_mode, json_mode and not text_mode def mask_secret(value: str) -> str: @@ -149,7 +152,7 @@ def print_code(code: str, *, language: str = "python") -> None: otherwise. Piping/redirecting (or an agent) yields plain text with no ANSI, so `aai … --show-code > script.py` stays byte-clean and runnable. """ - if _is_agentic(): + if not _stdout_is_tty(): print(code) return from rich.syntax import Syntax # lazily import Pygments-backed highlighter diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index c2c5d65c..e359ba68 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -36,9 +36,9 @@ │ --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'. │ + │ --output -o TEXT Print you:/agent: lines as plain │ + │ stdout (pipe-friendly). For NDJSON, │ + │ use --json. │ │ --show-code Print the equivalent Python SDK code │ │ and exit (does not start a session). │ │ --help Show this message and exit. │ @@ -282,8 +282,9 @@ │ (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. │ + │ --output -o TEXT Print just the answer as plain text │ + │ (pipe-friendly). For full JSON, use │ + │ --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 │ @@ -613,11 +614,10 @@ │ --json Emit │ │ newline-delimited │ │ JSON events. │ - │ --output -o TEXT Output mode: 'text' │ - │ (finalized turns as │ - │ plain lines, │ - │ pipe-friendly) or │ - │ 'json'. │ + │ --output -o TEXT Print finalized │ + │ turns as plain lines │ + │ (pipe-friendly). For │ + │ NDJSON, use --json. │ │ --show-code Print the equivalent │ │ Python SDK code and │ │ exit (does not │ @@ -755,11 +755,12 @@ │ --max-tokens INTEGER Max tokens. │ │ [default: 1000] │ │ --json Output raw JSON. │ - │ --output -o TEXT Print one field of │ - │ the result: text, │ + │ --output -o TEXT Print one field as │ + │ plain text: text, │ │ id, status, │ - │ utterances, srt, or │ - │ json. │ + │ utterances, or srt. │ + │ For full JSON, use │ + │ --json. │ │ --show-code Print the equivalent │ │ Python SDK code and │ │ exit (does not │ @@ -795,8 +796,8 @@ │ * transcript_id TEXT Transcript id. [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --output -o TEXT Print one field of the result: text, id, status, │ - │ utterances, srt, or json. │ + │ --output -o TEXT Print one field as plain text: text, id, status, │ + │ utterances, or srt. For full JSON, use --json. │ │ --json Output raw JSON. │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 9fae8fb8..dfa39151 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -44,7 +44,7 @@ def test_balance_without_session_runs_login(monkeypatch): return_value={"account_id": 42, "balance_in_cents": 2575}, ) as get_balance: result = runner.invoke(app, ["balance", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} get_balance.assert_not_called() assert "Run the same command again" in result.output diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 075c461b..39c91866 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -43,7 +43,7 @@ def fake_run_session(api_key, **_kwargs): monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) result = runner.invoke(app, ["agent", "--sample", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" assert "Run the same command again" in result.output diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py index cdd7c7dc..58b90d80 100644 --- a/tests/test_audit_command.py +++ b/tests/test_audit_command.py @@ -178,7 +178,7 @@ def test_audit_without_session_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value={"data": []}) as logs: result = runner.invoke(app, ["audit", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} logs.assert_not_called() assert "Run the same command again" in result.output diff --git a/tests/test_context.py b/tests/test_context.py index 1ad2261b..977bccec 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -28,7 +28,7 @@ def body(state, json_mode): raise NotAuthenticated() result = runner.invoke(_make_app(body, auto_login=False), ["go"]) - assert result.exit_code == 2 + assert result.exit_code == 4 def test_run_command_auto_logs_in_and_asks_for_rerun(monkeypatch): @@ -48,12 +48,14 @@ def body(state, json_mode): raise NotAuthenticated() result = runner.invoke(_make_app(body), ["go"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert calls["count"] == 1 assert config.get_session("default") == {"jwt": "jwt_auto", "token": "tok_auto"} assert config.get_account_id("default") == 42 assert config.resolve_api_key() == "sk_auto" assert "Run the same command again" in result.output + # The auto-login notice prints because AppState defaults to non-quiet (quiet=False). + assert "starting browser login" in result.output def test_run_command_auto_login_persistence_failure_is_clean(monkeypatch): @@ -105,7 +107,7 @@ def body(state, json_mode): raise auth_failure() result = runner.invoke(_make_app(body), ["go"]) - assert result.exit_code == 2 + assert result.exit_code == 4 def test_run_command_never_auto_logs_in_login_command(monkeypatch): @@ -128,7 +130,7 @@ def body(state, json_mode): run_command(ctx, body) result = runner.invoke(app, ["login"]) - assert result.exit_code == 2 + assert result.exit_code == 4 def test_run_command_runs_body_on_success(): @@ -227,7 +229,7 @@ def body(state, json_mode): result = runner.invoke(_make_app(body), ["go"]) assert ran["login"] == 1 # auto-login was attempted despite the env key - assert result.exit_code == 2 + assert result.exit_code == 4 assert "Run the same command again" in result.output diff --git a/tests/test_errors.py b/tests/test_errors.py index c81c6fa9..a6bd934e 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -3,7 +3,7 @@ def test_not_authenticated_defaults(): err = NotAuthenticated() - assert err.exit_code == 2 + assert err.exit_code == 4 assert err.error_type == "not_authenticated" assert err.message == "Not authenticated." assert err.suggestion == "Run 'aai login'." diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 2a5895b0..2b7bf534 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -71,7 +71,9 @@ def test_init_writes_base_url_for_active_env(tmp_path, monkeypatch): def test_init_placeholder_key_when_logged_out(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install"]) + # --json: human output always prints a "run it with uvicorn" next-step hint, so the + # launch-skipped row only shows up as a distinct step in the structured output. + result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install", "--json"]) env = (tmp_path / "myapp" / ".env").read_text() assert "your_assemblyai_api_key_here" in env # --no-install means no deps were installed, so there's no launch-skipped row even @@ -122,7 +124,7 @@ def test_init_appears_in_help(): def test_init_prints_cli_banner_in_human_mode(tmp_path, monkeypatch): # Vercel-style header at the top of an interactive run (human output only). monkeypatch.chdir(tmp_path) - monkeypatch.setattr("aai_cli.output._is_agentic", lambda: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) result = runner.invoke(app, ["init", TEMPLATE, "x", "--no-install"]) assert result.exit_code == 0, result.output assert "AssemblyAI CLI" in result.output @@ -227,7 +229,9 @@ def test_init_launches_when_key_present(tmp_path, monkeypatch): # Key present + install succeeds -> the server is launched and the browser opens. monkeypatch.chdir(tmp_path) monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk-real-key") - monkeypatch.setattr("aai_cli.output._is_agentic", lambda: False) # exercise human banner + monkeypatch.setattr( + "aai_cli.output.resolve_json", lambda *, explicit: False + ) # exercise human banner monkeypatch.setattr( "aai_cli.init.runner.run_setup", lambda *a, **k: subprocess.CompletedProcess([], 0, "ok", ""), diff --git a/tests/test_keys.py b/tests/test_keys.py index a01e7d6f..b4c00d14 100644 --- a/tests/test_keys.py +++ b/tests/test_keys.py @@ -87,7 +87,7 @@ def test_keys_list_without_session_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) with patch("aai_cli.commands.keys.ams.list_projects", return_value=[]) as list_projects: result = runner.invoke(app, ["keys", "list", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} list_projects.assert_not_called() assert "Run the same command again" in result.output @@ -158,7 +158,7 @@ def test_keys_list_renders_human_table(): } ] with ( - patch("aai_cli.output._is_agentic", return_value=False), + patch("aai_cli.output.resolve_json", return_value=False), patch("aai_cli.commands.keys.ams.list_projects", return_value=projects), ): result = runner.invoke(app, ["keys", "list"]) diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 5b5627ba..6dbe1ff1 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -67,18 +67,26 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): assert seen["messages"][0]["content"] == "What is 2+2?" -def test_llm_output_json_forces_json_for_human(monkeypatch): +def test_llm_json_emits_json_even_for_interactive_human(monkeypatch): _auth() - # Simulate an interactive human (not piped/agentic); `-o json` must still emit - # JSON, pinning the `output_field == "json"` that forces machine output. - monkeypatch.setattr("aai_cli.output._is_agentic", lambda: False) + # Even at an interactive terminal, --json emits machine output (it's the single, + # explicit opt-in; we never auto-switch on pipe/agent anymore). + monkeypatch.setattr("aai_cli.output._stdout_is_tty", lambda: True) monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("4")) - result = runner.invoke(app, ["llm", "hi", "-o", "json"]) + result = runner.invoke(app, ["llm", "hi", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) assert data["output"] == "4" +def test_llm_output_json_field_is_rejected(monkeypatch): + # `-o json` no longer exists: -o is a plain-text projection, JSON lives on --json. + _auth() + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("4")) + result = runner.invoke(app, ["llm", "hi", "-o", "json"]) + assert result.exit_code == 2 + + def test_llm_transcript_id_injected(monkeypatch): _auth() seen = {} @@ -147,7 +155,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke(app, ["llm", "hello", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" assert "Run the same command again" in result.output @@ -241,10 +249,10 @@ def test_llm_output_text_prints_raw_answer(monkeypatch): assert "{" not in result.output -def test_llm_output_json_forces_json(monkeypatch): +def test_llm_json_flag_emits_json(monkeypatch): _auth() monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hello")) - result = runner.invoke(app, ["llm", "hi", "-o", "json"]) + result = runner.invoke(app, ["llm", "hi", "--json"]) assert result.exit_code == 0 assert json.loads(result.output)["output"] == "hello" diff --git a/tests/test_login.py b/tests/test_login.py index 67aece52..99b3c979 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -66,7 +66,7 @@ def test_whoami_unauthenticated_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context.run_login_flow", _fake_login_result) with patch("aai_cli.commands.login.client.validate_key", return_value=True) as validate: result = runner.invoke(app, ["whoami", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" validate.assert_not_called() assert "Run the same command again" in result.output @@ -257,7 +257,7 @@ def test_whoami_renders_human_table_reachable(): config.set_api_key("default", "sk_1234567890") config.set_session("default", session_jwt="j", session_token="t", account_id=77) with ( - patch("aai_cli.output._is_agentic", return_value=False), + patch("aai_cli.output.resolve_json", return_value=False), patch("aai_cli.commands.login.client.validate_key", return_value=True), ): result = runner.invoke(app, ["whoami"]) @@ -275,7 +275,7 @@ def test_whoami_renders_human_table_rejected_key(): # account/session "none" fallbacks (the em-dash placeholder). config.set_api_key("default", "sk_1234567890") with ( - patch("aai_cli.output._is_agentic", return_value=False), + patch("aai_cli.output.resolve_json", return_value=False), patch("aai_cli.commands.login.client.validate_key", return_value=False), ): result = runner.invoke(app, ["whoami"]) diff --git a/tests/test_output.py b/tests/test_output.py index 12b265ec..d89d84a7 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,26 +5,18 @@ from aai_cli.errors import CLIError -def test_resolve_json_true_when_explicit(monkeypatch): - monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) +def test_resolve_json_true_only_when_explicit(): + # JSON is opt-in: the flag is the single source of truth. assert output.resolve_json(explicit=True) is True -def test_resolve_json_true_when_not_tty(monkeypatch): +def test_resolve_json_false_when_not_explicit_even_off_tty(monkeypatch): + # Human text is the default everywhere — piped, in CI, or under an agent — so a + # plain-text pipeline (`aai transcribe x | grep word`) keeps getting text, not JSON. monkeypatch.setattr(output, "_stdout_is_tty", lambda: False) - assert output.resolve_json(explicit=False) is True - - -def test_resolve_json_true_in_ci(monkeypatch): - monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) monkeypatch.setenv("CI", "true") - assert output.resolve_json(explicit=False) is True - - -def test_resolve_json_true_for_agent(monkeypatch): - monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) monkeypatch.setenv("CLAUDECODE", "1") - assert output.resolve_json(explicit=False) is True + assert output.resolve_json(explicit=False) is False def test_resolve_json_false_for_human(monkeypatch): @@ -127,7 +119,7 @@ def test_affordance_helpers_use_resolvable_styles(capsys): def test_print_code_plain_when_piped(monkeypatch, capsys): - monkeypatch.setattr(output, "_is_agentic", lambda: True) + monkeypatch.setattr(output, "_stdout_is_tty", lambda: False) output.print_code("import os\nprint(os.getcwd())\n") out = capsys.readouterr().out assert "import os" in out @@ -137,7 +129,7 @@ def test_print_code_plain_when_piped(monkeypatch, capsys): def test_print_code_highlights_for_interactive_human(monkeypatch, capsys): from aai_cli import theme - monkeypatch.setattr(output, "_is_agentic", lambda: False) + monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) monkeypatch.setattr( output, "console", theme.make_console(force_terminal=True, color_system="truecolor") ) diff --git a/tests/test_samples.py b/tests/test_samples.py index f6cbe45e..acddbd31 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -18,7 +18,7 @@ def test_samples_list_shows_transcribe(): def test_samples_list_human_mode_renders_bullets(monkeypatch): # Force human (non-agentic) rendering so the bullet-list branch runs; pins the # string concatenation in the human renderer (a `-` there would raise TypeError). - monkeypatch.setattr("aai_cli.output._is_agentic", lambda: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) result = runner.invoke(app, ["samples", "list"]) assert result.exit_code == 0 assert "Available samples:" in result.output diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py index e3fa9e13..d9e89642 100644 --- a/tests/test_sessions_command.py +++ b/tests/test_sessions_command.py @@ -107,7 +107,7 @@ def test_sessions_without_session_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context.run_login_flow", _login_result) with patch("aai_cli.commands.sessions.ams.list_streaming", return_value={"data": []}) as list_: result = runner.invoke(app, ["sessions", "list", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_session("default") == {"jwt": "jwt", "token": "tok"} list_.assert_not_called() assert "Run the same command again" in result.output diff --git a/tests/test_setup.py b/tests/test_setup.py index cc6fecf6..f2050889 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -18,6 +18,14 @@ def _isolate_home(tmp_path, monkeypatch): monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) +@pytest.fixture(autouse=True) +def _force_json(monkeypatch): + """These tests pin the structured step/status JSON. The CLI now defaults to human + text everywhere (JSON is opt-in), so force the machine output the assertions parse — + the equivalent of invoking each command with --json.""" + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: True) + + def test_proc_detail_prefers_stderr_then_falls_back_to_stdout(): from aai_cli.commands import setup diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 30634fd0..14c8a3f9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -19,6 +19,36 @@ def test_version_command(): assert result.output.strip() == __version__ +def test_version_flag_prints_and_exits(): + # `aai --version` / `-V` is the reflex every CLI answers; the eager callback prints + # the version and exits before any command runs. + from aai_cli import __version__ + + for flag in ("--version", "-V"): + result = runner.invoke(app, [flag]) + assert result.exit_code == 0 + assert result.output.strip() == __version__ + + +def test_quiet_suppresses_env_override_warning(monkeypatch): + # --env contradicting the profile normally warns on stderr; --quiet silences it. + from aai_cli import config + + config.set_api_key("default", "sk_live") + config.set_profile_env("default", "production") + noisy = runner.invoke(app, ["--env", "sandbox000", "version"]) + quiet = runner.invoke(app, ["--quiet", "--env", "sandbox000", "version"]) + assert "may be rejected" in noisy.output + assert "may be rejected" not in quiet.output + + +def test_shell_completion_is_available(): + # add_completion=True ships `--show-completion` (and --install-completion), the + # discoverability affordance gh/kubectl/docker users reach for. + result = runner.invoke(app, ["--show-completion"]) + assert result.exit_code == 0 + + def test_global_flags_parse(): # --profile is a global option accepted before a subcommand assert runner.invoke(app, ["--profile", "staging", "version"]).exit_code == 0 diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index a14e8cf7..be9b400e 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -194,7 +194,7 @@ def fake_stream_audio(api_key, source, *, params, **_kwargs): monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream_audio) result = runner.invoke(app, ["stream", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" assert "Run the same command again" in result.output diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 4bdc2227..57842344 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -84,7 +84,7 @@ def test_transcribe_unauthenticated_runs_login_then_transcribes(monkeypatch): "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: result = runner.invoke(app, ["transcribe", "--sample"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" tx.assert_not_called() assert "Run the same command again" in result.output diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 844206a5..bc320392 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -1,3 +1,4 @@ +import json from unittest.mock import MagicMock, patch from typer.testing import CliRunner @@ -51,6 +52,21 @@ def test_get_output_id_prints_id(): assert result.output.strip() == "t_42" +def test_get_json_emits_full_payload(): + config.set_api_key("default", "sk_live") + fake = MagicMock() + fake.id = "t_42" + fake.text = "retrieved text" + fake.status = "completed" + fake.json_response = None # falls back to the compact summary + with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): + result = runner.invoke(app, ["transcripts", "get", "t_42", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["id"] == "t_42" + assert data["text"] == "retrieved text" + + def test_get_output_invalid_field_exits_2(): config.set_api_key("default", "sk_live") result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "bogus"]) @@ -71,7 +87,7 @@ def test_list_unauthenticated_runs_login(monkeypatch): rows = [{"id": "t1", "status": "completed"}] with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows) as list_: result = runner.invoke(app, ["transcripts", "list", "--json"]) - assert result.exit_code == 2 + assert result.exit_code == 4 assert config.get_api_key("default") == "sk_from_oauth" list_.assert_not_called() assert "Run the same command again" in result.output From 838460f0e56adf216556a284ad1857f0147cecec Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 00:56:00 +0000 Subject: [PATCH 2/2] Force JSON in test_setup_install.py for human-default output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #38 split the setup tests into test_setup.py and test_setup_install.py (sharing tests/setup_helpers.py). The `_force_json` autouse fixture this branch added — needed because output is now human-by-default — only lived in test_setup.py, so the install/status tests that moved to the new file parsed human text as JSON and failed (14 JSONDecodeError failures in CI). Add the same fixture to test_setup_install.py. https://claude.ai/code/session_01MyytEHLdk3mWdAY3p16cwX --- tests/test_setup_install.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_setup_install.py b/tests/test_setup_install.py index ff22d939..d43d47e1 100644 --- a/tests/test_setup_install.py +++ b/tests/test_setup_install.py @@ -22,6 +22,14 @@ def _isolate_home(tmp_path, monkeypatch): monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) +@pytest.fixture(autouse=True) +def _force_json(monkeypatch): + """These tests pin the structured step/status JSON. The CLI now defaults to human + text everywhere (JSON is opt-in), so force the machine output the assertions parse — + the equivalent of invoking each command with --json.""" + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: True) + + # --- install: all three steps ------------------------------------------------