Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `--
| `aai samples create <name>` | 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"`.

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions aai_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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.",
)

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion aai_cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ 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.",
*,
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
)


Expand Down
26 changes: 18 additions & 8 deletions aai_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -110,31 +114,37 @@ 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))
except CLIError as err:
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))


Expand Down
30 changes: 23 additions & 7 deletions aai_cli/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_account_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/test_agent_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion tests/test_audit_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 7 additions & 5 deletions tests/test_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand All @@ -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():
Expand Down Expand Up @@ -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


Expand Down
2 changes: 1 addition & 1 deletion tests/test_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'."
Expand Down
10 changes: 7 additions & 3 deletions tests/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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", ""),
Expand Down
4 changes: 2 additions & 2 deletions tests/test_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
16 changes: 8 additions & 8 deletions tests/test_llm_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"

Expand Down
Loading
Loading