Skip to content
Closed
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app,

### Command layer

Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `dictate`, `agent`, `speak`, `llm`, `clip`, `transcripts`, `login` (login/logout/whoami), `doctor`, `init`, `dev`, `share`, `deploy`, `setup`, `onboard`, `account` (balance/usage/limits), `keys`, `sessions`, `audit`, `telemetry` (status/enable/disable), `webhooks` (listen)). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.
Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe` (alias `t`), `stream`, `dictate`, `agent`, `speak`, `llm`, `clip`, `transcripts`, `login` (login/logout/whoami), `doctor`, `init`, `dev`, `share`, `deploy`, `setup`, `onboard`, `account` (balance/usage/limits), `keys`, `sessions`, `audit`, `config_cmd` (config path/list/get/set), `update`, `telemetry` (status/enable/disable), `webhooks` (listen)). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures. The user-facing contracts (exit codes, env vars, precedence, NDJSON event types) are pinned in `REFERENCE.md` — keep it in sync when changing any of them.

**Options/run split for flag-heavy commands** (gh-CLI style): the Typer function only parses argv into a frozen `<Cmd>Options` dataclass and hands it to a module-level `run_<cmd>(opts, state, *, json_mode)` through a thin lambda adapter in `run_command(ctx, ..., json=...)`. The seven run commands follow it — `aai_cli/stream_exec.py` (the reference implementation), `transcribe_exec.py`, `agent_exec.py`, `speak_exec.py`, `llm_exec.py`, `clip_exec.py`, `dictate_exec.py`. Because the run path is a plain function of data, tests construct options directly (`dataclasses.replace` off a defaults instance, see `tests/test_stream_exec.py` and `tests/test_command_options_seam.py`) instead of round-tripping argv through `CliRunner` — which is also the cheap way to kill mutation-gate mutants on orchestration lines. Follow this for new or heavily-reworked commands with long bodies; small commands keep the inline `body()` closure — the dataclass is pure ceremony there.

Expand Down Expand Up @@ -202,3 +202,6 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `di
- Ruff lint set: `E,F,I,UP,B,BLE,C4,SIM,RET,PTH,ARG,S,RUF`. `S603/S607` are ignored project-wide because the CLI intentionally shells out to `claude`/`npx` with controlled args. `B008` is ignored (Typer uses `typer.Option/Argument` calls as defaults).
- mypy is strict on `aai_cli` (`disallow_untyped_defs`); tests are type-checked but exempt from return annotations.
- Errors → stderr, data → stdout. Preserve this split; it's what makes the CLI pipeline-safe.
- **Deprecate flags with hidden traps, not removal**: keep the old flag parsing (`hidden=True`), emit a one-line "use X instead" warning, and drop it a release or two later — never hard-break a script mid-cycle. `login --api-key` (→ `--with-api-key`) is the pattern to copy.
- **Secrets never ride argv**: a key/token-valued option must read from stdin (`--with-api-key`) or the env, so it can't leak into shell history or `ps`. Run commands deliberately have no `--api-key` at all.
- **Every NDJSON stream line carries a `"type"` field** (see REFERENCE.md "JSON output"); new event types are additive, existing fields stay stable.
84 changes: 84 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# CLI reference

The contracts scripts and agents can rely on: exit codes, environment
variables, configuration precedence, and machine-readable output shapes.

## Exit codes

Stable, and deliberately split the way `gh` splits them (the source of truth
is the docstring in `aai_cli/errors.py`):

| Code | Meaning |
| ---- | ------- |
| `0` | Success. |
| `1` | Generic runtime failure: an API/network error, a missing dependency, or an unexpected internal error. |
| `2` | Usage/validation error: bad flags, a bad path, a malformed id, or an unusable config file. |
| `4` | Not authenticated: no usable credential, a rejected key, or a self-service command that needs a browser login. |
| `130` | Cancelled with Ctrl-C. |

A subprocess the CLI shells out to (`assembly deploy`, `assembly dev`,
`assembly update`) propagates that process's own exit code unchanged. Under
`--json`, every failure also emits one `{"error": {"type": …, "message": …}}`
object on stderr; the `error.type` pairs 1:1 with the exit code.

## Environment variables

Product-scoped variables are `ASSEMBLYAI_*`; CLI-behavior variables are
`AAI_*`. Keep new variables in that split.

| Variable | Effect |
| -------- | ------ |
| `ASSEMBLYAI_API_KEY` | API key for all API calls; beats the keyring, loses to nothing but a `--api-key` validation flag. |
| `AAI_ENV` | Backend environment (`production`, `sandbox000`); beats the profile's stored env, loses to `--env`/`--sandbox`. |
| `AAI_AUTH_PORT` | Loopback callback port for `assembly login` (dev/test only; default 8585). |
| `AAI_NO_UPDATE_CHECK` | Disables the "update available" notice and its background refresh. |
| `AAI_TELEMETRY_DISABLED` / `DO_NOT_TRACK` | Disables anonymous usage telemetry (always beats the persisted choice). |
| `NO_COLOR` / `FORCE_COLOR` | Standard color overrides; `--color always` / `--color never` sets them for child consoles too. |
| `CI` | Suppresses interactive affordances (spinners, the update notice); never changes output shape. |

## Configuration and precedence

Non-secret settings persist in `config.toml` (`assembly config path` prints
where; `assembly config list/get/set` reads and writes it). The API key lives
only in the OS keyring — never in a file.

Precedence, highest first:

1. Command flags (`--profile`, `--env`/`--sandbox`).
2. Environment variables (`ASSEMBLYAI_API_KEY`, `AAI_ENV`).
3. Stored settings (`config.toml` + keyring): the active profile, its env
binding, and its key.
4. Built-in defaults (`production`, profile `default`).

## Non-interactive authentication

Pipe the key on stdin so it never reaches shell history or `ps`:

```sh
printenv ASSEMBLYAI_API_KEY | assembly login --with-api-key
```

Or skip storage entirely and set `ASSEMBLYAI_API_KEY` per invocation. On a
remote/SSH machine the browser flow also works by forwarding the callback
port (`ssh -L 8585:127.0.0.1:8585 <host>`) and opening the printed URL in
your local browser.

## JSON output

`--json` (or `-o json`) is always an explicit opt-in — piping never switches
the output shape. One-shot commands emit a single JSON object on stdout;
errors and warnings are single JSON objects on stderr.

Streaming commands emit newline-delimited JSON (NDJSON), one event per line,
each carrying a `"type"` field to dispatch on:

| Command | Event types |
| ------- | ----------- |
| `assembly stream --json` | `begin`, `turn`, `termination` |
| `assembly agent --json` | `session.ready`, `transcript.user.delta`, `transcript.user`, `reply.started`, `transcript.agent`, `reply.done` |
| `assembly dictate --json` | `utterance` |
| `assembly llm --follow --json` | `answer` |
| `assembly transcribe <batch> --json` | `result` (one per source) |

New event types may be added; existing fields are stable. Consumers should
ignore types they don't recognize.
28 changes: 22 additions & 6 deletions aai_cli/auth/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from aai_cli import output
from aai_cli.auth import ams, discovery, endpoints, loopback
from aai_cli.errors import APIError, NotAuthenticated
from aai_cli.errors import STDIN_KEY_RECIPE, APIError, NotAuthenticated


@dataclass
Expand Down Expand Up @@ -117,10 +117,26 @@ def _open_browser(url: str, *, json_mode: bool) -> None:
# usable browser, so the fallback must fire on the boolean too; otherwise the
# user sits out the 120s timeout with no hint that nothing opened.
if not opened:
# The OAuth callback lands on this machine's loopback port, so a browser on
# another machine (the common SSH case) can only complete the flow through a
# port forward — say so now, with the exact command, instead of letting the
# user open the URL remotely and watch the callback go nowhere.
port = endpoints.loopback_port()
forward = f"ssh -L {port}:{endpoints.LOOPBACK_HOST}:{port} <this-host>"
_note(
json_mode=json_mode,
human="[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]",
hint="Could not open a browser; open the URL manually.",
human=(
"[aai.muted]Could not open a browser; open the URL above manually.\n"
f"On a remote/SSH machine, forward the callback port first ({forward}) "
"and open the URL in your local browser — or skip the browser entirely: "
f"{STDIN_KEY_RECIPE}.[/aai.muted]"
),
hint=(
"Could not open a browser; open the URL manually. On a remote/SSH "
f"machine, forward the callback port first ({forward}) and open the "
"URL in your local browser — or skip the browser entirely: "
f"{STDIN_KEY_RECIPE}."
),
url=url,
)

Expand Down Expand Up @@ -177,19 +193,19 @@ def run_login_flow(*, json_mode: bool = False) -> LoginResult:
json_mode=json_mode,
human=(
"[aai.muted]Waiting up to 2 minutes for you to finish signing in…[/aai.muted]\n"
"[aai.muted]No browser here? Run 'assembly login --api-key <KEY>' instead.[/aai.muted]"
f"[aai.muted]No browser here? Run '{STDIN_KEY_RECIPE}' instead.[/aai.muted]"
),
hint=(
"Waiting up to 2 minutes for you to finish signing in. "
"No browser here? Run 'assembly login --api-key <KEY>' instead."
f"No browser here? Run '{STDIN_KEY_RECIPE}' instead."
),
)
result = capture.wait()

if result.error == "timeout":
raise NotAuthenticated(
"Login timed out waiting for the browser.",
suggestion="Run 'assembly login' again, or use 'assembly login --api-key <KEY>'.",
suggestion=f"Run 'assembly login' again, or use '{STDIN_KEY_RECIPE}'.",
)
if result.token_type != "discovery_oauth" or not result.token: # noqa: S105
raise APIError(
Expand Down
16 changes: 16 additions & 0 deletions aai_cli/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,19 @@ class Scope(enum.StrEnum):
user = "user"
project = "project"
local = "local"


class ConfigKey(enum.StrEnum):
"""The settings `assembly config get/set` exposes (the persisted, non-secret ones)."""

active_profile = "active_profile"
env = "env"
telemetry_enabled = "telemetry_enabled"


class ColorMode(enum.StrEnum):
"""The conventional tri-state for ANSI color (`--color`), matching git/gh/cargo."""

auto = "auto"
always = "always"
never = "never"
207 changes: 207 additions & 0 deletions aai_cli/commands/config_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"""`assembly config` — inspect and edit the persisted CLI settings.

The settings live in ``config.toml`` (``assembly config path`` prints where); the
API key itself lives only in the OS keyring and is deliberately not reachable
from here. Runtime precedence for everything this file stores: command flags
(``--profile``/``--env``) > environment variables (``AAI_ENV``,
``ASSEMBLYAI_API_KEY``) > these stored settings > built-in defaults.
"""

from __future__ import annotations

import typer
from rich.markup import escape

from aai_cli import config, environments, options, output
from aai_cli.choices import ConfigKey
from aai_cli.context import AppState, run_command
from aai_cli.errors import UsageError
from aai_cli.help_text import examples_epilog

app = typer.Typer(
help="Inspect and edit persisted CLI settings (profiles, env, telemetry).",
no_args_is_help=True,
)

_TRUE_WORDS = frozenset({"true", "1", "yes", "on"})
_FALSE_WORDS = frozenset({"false", "0", "no", "off"})


def _parse_bool(key: ConfigKey, raw: str) -> bool:
word = raw.strip().lower()
if word in _TRUE_WORDS:
return True
if word in _FALSE_WORDS:
return False
raise UsageError(
f"{key} expects a boolean, got {raw!r}.",
suggestion=f"Use one of: {', '.join(sorted(_TRUE_WORDS | _FALSE_WORDS))}.",
)


def _validated_env(value: str) -> str:
name = value.strip()
if name not in environments.ENVIRONMENTS:
raise UsageError(
f"Unknown environment {value!r}.",
suggestion=f"Use one of: {', '.join(environments.ENVIRONMENTS)}.",
)
return name


def _current_value(key: ConfigKey, state: AppState) -> object:
if key is ConfigKey.active_profile:
return config.get_active_profile()
if key is ConfigKey.env:
return config.get_profile_env(state.resolve_profile())
return config.get_telemetry_enabled()


def _store_value(key: ConfigKey, raw: str, state: AppState) -> object:
"""Persist ``raw`` under ``key`` and return the typed value that was stored."""
if key is ConfigKey.active_profile:
config.set_active_profile(raw)
return raw
if key is ConfigKey.env:
env = _validated_env(raw)
config.set_profile_env(state.resolve_profile(), env)
return env
enabled = _parse_bool(key, raw)
config.set_telemetry_enabled(enabled=enabled)
return enabled


def _render_value(value: object) -> str:
"""One stable spelling per value for the pipe-friendly `get` output: booleans in
TOML/JSON case (``true``/``false``), an unset value as ``unset``."""
if value is None:
return "unset"
if isinstance(value, bool):
return "true" if value else "false"
return str(value)


@app.command(
epilog=examples_epilog(
[
("Where settings are stored", "assembly config path"),
]
)
)
def path(
ctx: typer.Context,
json_out: bool = options.json_option(),
) -> None:
"""Print where config.toml lives."""

def body(_state: AppState, json_mode: bool) -> None:
file = config.config_file_path()
if json_mode:
output.emit({"path": str(file)}, str, json_mode=True)
else:
# Raw print, not the Rich console: a long path must reach a pipe
# unwrapped (`cd "$(assembly config path | xargs dirname)"`).
output.emit_text(str(file))

run_command(ctx, body, json=json_out)


@app.command(
name="list",
epilog=examples_epilog(
[
("Show every persisted setting", "assembly config list"),
("As JSON for scripting", "assembly config list --json"),
]
),
)
def list_settings(
ctx: typer.Context,
json_out: bool = options.json_option(),
) -> None:
"""Show every persisted setting and the stored profiles."""

def body(_state: AppState, json_mode: bool) -> None:
data: dict[str, object] = {
"path": str(config.config_file_path()),
"active_profile": config.get_active_profile(),
"profiles": config.list_profiles(),
"telemetry_enabled": config.get_telemetry_enabled(),
}

def render(d: dict[str, object]) -> object:
table = output.detail_table()
table.add_row("Config file", escape(str(d["path"])))
table.add_row("Active profile", escape(str(d["active_profile"])))
profiles = config.list_profiles()
listed = (
", ".join(
f"{name} ({env})" if env else name for name, env in sorted(profiles.items())
)
or "none yet"
)
table.add_row("Profiles", escape(listed))
table.add_row("Telemetry", _render_value(d["telemetry_enabled"]))
return output.stack(
table,
output.hint("Change a value with `assembly config set <key> <value>`."),
)

output.emit(data, render, json_mode=json_mode)

run_command(ctx, body, json=json_out)


@app.command(
epilog=examples_epilog(
[
("Read one setting (pipe-friendly)", "assembly config get env"),
("Read a named profile's env", "assembly -p staging config get env"),
]
)
)
def get(
ctx: typer.Context,
key: ConfigKey = typer.Argument(..., help="Which setting to read."),
json_out: bool = options.json_option(),
) -> None:
"""Print one setting's stored value (`env` reads the selected profile's)."""

def body(state: AppState, json_mode: bool) -> None:
value = _current_value(key, state)
if json_mode:
output.emit({"key": str(key), "value": value}, str, json_mode=True)
else:
# Raw print (see `path`): the bare value is the pipe contract here.
output.emit_text(_render_value(value))

run_command(ctx, body, json=json_out)


@app.command(
name="set",
epilog=examples_epilog(
[
("Switch the default profile", "assembly config set active_profile staging"),
("Bind the active profile to the sandbox", "assembly config set env sandbox000"),
("Opt out of telemetry", "assembly config set telemetry_enabled false"),
]
),
)
def set_setting(
ctx: typer.Context,
key: ConfigKey = typer.Argument(..., help="Which setting to change."),
value: str = typer.Argument(..., help="The new value."),
json_out: bool = options.json_option(),
) -> None:
"""Change one setting (`env` writes to the selected profile)."""

def body(state: AppState, json_mode: bool) -> None:
stored = _store_value(key, value, state)
output.emit(
{"key": str(key), "value": stored},
lambda d: output.success(f"{d['key']} = {escape(_render_value(d['value']))}"),
json_mode=json_mode,
)

run_command(ctx, body, json=json_out)
2 changes: 1 addition & 1 deletion aai_cli/commands/dub.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ def dub(
),
json_out: bool = options.json_option("Emit JSON describing the dubbed file."),
) -> None:
"""Dub a video or audio file into another language (sandbox only).
"""[sandbox] Dub a video or audio file into another language.

The whole platform in one command: the media is transcribed with diarized
utterance timestamps, each utterance is translated by an LLM Gateway model,
Expand Down
Loading
Loading