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/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 f5399235..e9e406ee 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -82,13 +82,17 @@ 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, + # `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: - """Eager ``--version``: print the version and exit before any command runs.""" + """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() @@ -110,23 +114,29 @@ 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)." + ), + # Underscore name: the eager callback does the work, so the parameter is intentionally + # unused in the body (avoids ARG001 without a `del`). _version: bool = typer.Option( False, "--version", + "-V", 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. + # Eager so --version short-circuits before subcommand/arg parsing — the idiomatic + # default. 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" - state = AppState(profile=profile, env=env) + state = AppState(profile=profile, env=env, quiet=quiet) ctx.obj = state - # The command's own --json flag isn't parsed yet, so fall back to auto-detection - # (JSON when piped/agentic) — the same default run_command uses for its errors. + # The command's own --json flag isn't parsed yet, and output is human-by-default, so a + # root-callback (e.g. bad --env) error renders as human text on stderr. json_mode = output.resolve_json(explicit=False) try: environments.set_active(resolve_environment(state)) @@ -134,7 +144,7 @@ def main( output.emit_error(err, json_mode=json_mode) raise typer.Exit(code=err.exit_code) from None warning = env_override_warning(state) - if warning: + if warning and not quiet: output.error_console.print(output.warn(warning)) diff --git a/aai_cli/output.py b/aai_cli/output.py index daa0248c..19d4a0d7 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -29,23 +29,39 @@ def _stdout_is_tty() -> bool: def _is_agentic() -> bool: + """True when there's no interactive human at stdout: piped/redirected, or a CI/agent + env var is set. Used to suppress *interactivity* (the spinner) — never to change the + output *shape*; `resolve_json` keeps text the default regardless (see its docstring). + """ 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 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). Typer - validates `field` against the enum, so no value check is needed here. + Shared by `stream` and `agent`. ``-o text`` emits plain finalized lines (handy for + ``aai stream -o text | aai llm -f``); ``-o json`` or ``--json`` forces NDJSON; an + unset field renders the live human panel. With output now human-by-default + (`resolve_json` only flips on an explicit `--json`), `json_mode` here is simply + whether `--json` was passed — we never auto-switch to NDJSON just because piped. + Typer validates `field` against the enum, so no value check is needed here. """ text_mode = field is choices.TextOrJson.text return text_mode, (field is choices.TextOrJson.json) or (json_mode and not text_mode) @@ -159,7 +175,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/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..e7044e19 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -67,13 +67,13 @@ 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" @@ -147,7 +147,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 +241,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 9f80d227..efd3f848 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 @@ -202,27 +202,18 @@ def test_root_callback_sandbox_overrides_profile_env(): def test_unknown_env_exits_2(): - import json - - result = runner.invoke(app, ["--env", "bogus", "whoami"]) - assert result.exit_code == 2 - # Routed through the standard error path: JSON shape (piped/agentic) + a suggestion, - # not a bare echo of the message. - payload = json.loads(result.output.strip().splitlines()[-1]) - assert payload["error"]["type"] == "invalid_environment" - assert "unset AAI_ENV" in payload["error"]["suggestion"] - - -def test_unknown_env_human_output_when_interactive(): - # For an interactive human (not piped/agentic) the callback emits the human - # "Error:" + "Suggestion:" pair, not JSON — pinning that resolve_json(explicit=False) - # actually consults the auto-detection rather than always forcing JSON. - with patch("aai_cli.output._is_agentic", return_value=False): - result = runner.invoke(app, ["--env", "bogus", "whoami"]) - assert result.exit_code == 2 - assert "Error:" in result.output - assert "Suggestion:" in result.output - assert '"error"' not in result.output # not the JSON shape + # Routed through the standard error path. Output is human-by-default (the root + # callback can't see a per-command --json, and we never auto-switch to JSON on a + # pipe/agent), so it's the "Error:" + "Suggestion:" pair on stderr, not a JSON blob — + # regardless of whether stdout is a TTY. + for agentic in (True, False): + with patch("aai_cli.output._is_agentic", return_value=agentic): + result = runner.invoke(app, ["--env", "bogus", "whoami"]) + assert result.exit_code == 2 + assert "Error:" in result.output + assert "Suggestion:" in result.output + assert "unset AAI_ENV" in result.output + assert '"error"' not in result.output # never the JSON shape def test_env_override_prints_warning_to_stderr(): @@ -276,7 +267,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"]) @@ -294,7 +285,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 177aca7a..26d6a74d 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,31 +5,36 @@ 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 + monkeypatch.setenv("CI", "true") + monkeypatch.setenv("CLAUDECODE", "1") + assert output.resolve_json(explicit=False) is False -def test_resolve_json_true_in_ci(monkeypatch): +def test_resolve_json_false_for_human(monkeypatch): monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) - monkeypatch.setenv("CI", "true") - assert output.resolve_json(explicit=False) is True + assert output.resolve_json(explicit=False) is False -def test_resolve_json_true_for_agent(monkeypatch): +def test_is_agentic_true_for_agent_env_var_even_with_tty(monkeypatch): + # Interactivity detection (used to suppress the spinner) still reports "no human" + # when a CI/agent env var is set — independent of resolve_json, which stays text. monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) monkeypatch.setenv("CLAUDECODE", "1") - assert output.resolve_json(explicit=False) is True + assert output._is_agentic() is True -def test_resolve_json_false_for_human(monkeypatch): +def test_is_agentic_false_for_plain_interactive_tty(monkeypatch): monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) - assert output.resolve_json(explicit=False) is False + assert output._is_agentic() is False def test_mask_secret_preserves_only_short_edges(): @@ -127,7 +132,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 +142,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 47e6f13c..b515e844 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -16,6 +16,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_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 ------------------------------------------------ diff --git a/tests/test_smoke.py b/tests/test_smoke.py index e3def55d..14c8a3f9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -19,13 +19,34 @@ def test_version_command(): assert result.output.strip() == __version__ -def test_version_flag(): +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__ - # Eager --version prints the version and exits before any subcommand is required. - result = runner.invoke(app, ["--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 - assert result.output.strip() == __version__ def test_global_flags_parse(): diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index fbc06ef9..85593ccf 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -131,7 +131,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