diff --git a/AGENTS.md b/AGENTS.md index f215dc8e..36be8543 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `Options` dataclass and hands it to a module-level `run_(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. @@ -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. diff --git a/REFERENCE.md b/REFERENCE.md new file mode 100644 index 00000000..6c301b9e --- /dev/null +++ b/REFERENCE.md @@ -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 `) 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 --json` | `result` (one per source) | + +New event types may be added; existing fields are stable. Consumers should +ignore types they don't recognize. diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index daa43b0b..0ecf890b 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -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 @@ -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} " _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, ) @@ -177,11 +193,11 @@ 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 ' 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 ' instead." + f"No browser here? Run '{STDIN_KEY_RECIPE}' instead." ), ) result = capture.wait() @@ -189,7 +205,7 @@ def run_login_flow(*, json_mode: bool = False) -> LoginResult: if result.error == "timeout": raise NotAuthenticated( "Login timed out waiting for the browser.", - suggestion="Run 'assembly login' again, or use 'assembly login --api-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( diff --git a/aai_cli/choices.py b/aai_cli/choices.py index 55e58286..b54961c2 100644 --- a/aai_cli/choices.py +++ b/aai_cli/choices.py @@ -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" diff --git a/aai_cli/commands/config_cmd.py b/aai_cli/commands/config_cmd.py new file mode 100644 index 00000000..4dcdde5b --- /dev/null +++ b/aai_cli/commands/config_cmd.py @@ -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 `."), + ) + + 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) diff --git a/aai_cli/commands/dub.py b/aai_cli/commands/dub.py index ef96f89a..a2c2b850 100644 --- a/aai_cli/commands/dub.py +++ b/aai_cli/commands/dub.py @@ -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, diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index 3bcc71e8..b0ae4194 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -71,6 +71,12 @@ def llm( max_tokens: int = typer.Option( gateway.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens to generate.", min=1 ), + config_kv: list[str] | None = typer.Option( + None, + "--config", + help="Set any extra gateway request field: KEY=VALUE, repeatable " + "(e.g. --config temperature=0.2). Values parse as JSON, else literal text.", + ), list_models: bool = typer.Option(False, "--list-models", help="Print known models and exit."), json_out: bool = options.json_option("Output raw JSON (one object per turn in --follow mode)."), ) -> None: @@ -94,6 +100,7 @@ def llm( follow=follow, output_field=output_field, max_tokens=max_tokens, + config_kv=tuple(config_kv or ()), ) run_command( ctx, diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 38571368..26c0cc08 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -1,29 +1,72 @@ from __future__ import annotations +import sys + import typer from rich.markup import escape from rich.table import Table from aai_cli import client, config, environments, help_panels, options, output from aai_cli.context import AppState, persist_browser_login, run_command -from aai_cli.errors import APIError, CLIError, UsageError +from aai_cli.errors import ( + STDIN_KEY_RECIPE, + APIError, + CLIError, + UsageError, + mutually_exclusive, +) from aai_cli.help_text import examples_epilog app = typer.Typer() +def _read_stdin_key() -> str: + """The API key piped on stdin for ``--with-api-key``, validated non-empty. + + Stdin-only on purpose (the Codex-CLI pattern): a key passed as an argv value + lands in shell history and ``ps`` output; a piped key does not. + """ + if sys.stdin.isatty(): + raise UsageError( + "--with-api-key reads the key from stdin, but stdin is a terminal.", + suggestion=f"Pipe the key in: {STDIN_KEY_RECIPE}", + ) + key = sys.stdin.read().strip() + if not key: + raise UsageError( + "--with-api-key found no key on stdin.", + suggestion=( + f"Pipe a non-empty key: {STDIN_KEY_RECIPE} " + "(check that the variable you piped is set)." + ), + ) + return key + + @app.command( rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Log in with your browser", "assembly login"), - ("Log in non-interactively (CI)", "assembly login --api-key sk_..."), + ("Log in non-interactively (CI)", STDIN_KEY_RECIPE), ] ), ) def login( ctx: typer.Context, - api_key: str | None = typer.Option(None, "--api-key", help="Provide key non-interactively."), + # Deprecation trap, not removal: the flag still works so existing CI scripts + # don't break, but it's hidden from --help and warns toward the stdin form. + api_key: str | None = typer.Option( + None, + "--api-key", + hidden=True, + help="Deprecated: use --with-api-key (reads stdin; keeps the key out of shell history).", + ), + with_api_key: bool = typer.Option( + False, + "--with-api-key", + help=f"Read an API key from stdin instead of the browser flow: {STDIN_KEY_RECIPE}", + ), json_out: bool = options.json_option(), ) -> None: """Authenticate via your browser; stores a CLI API key.""" @@ -31,6 +74,13 @@ def login( def body(state: AppState, json_mode: bool) -> None: profile = state.resolve_profile() env = environments.active().name + # `api_key is not None` (not truthiness): --api-key "" combined with + # --with-api-key must report the conflict, not fall through to stdin. + mutually_exclusive( + ("--api-key", api_key is not None), + ("--with-api-key", with_api_key), + suggestion=f"Use the stdin form alone: {STDIN_KEY_RECIPE}", + ) if api_key is not None and not api_key.strip(): # An explicitly-passed empty/whitespace key (e.g. --api-key "$UNSET_VAR") # must fail loudly, not silently fall into the browser flow as if the @@ -38,10 +88,17 @@ def body(state: AppState, json_mode: bool) -> None: raise UsageError( "--api-key was given an empty value.", suggestion=( - "Pass a real key: assembly login --api-key " + f"Pipe a real key instead: {STDIN_KEY_RECIPE} " "(check that the shell variable you expanded is set)." ), ) + if api_key is not None and not state.quiet: + output.emit_warning( + "--api-key puts the key in shell history and process lists; " + f"pipe it instead: {STDIN_KEY_RECIPE}", + json_mode=json_mode, + ) + provided_key = _read_stdin_key() if with_api_key else api_key # Both login paths persist to the OS keyring, so probe it before any # browser/network work: completing the whole OAuth dance only to fail on # the final keyring write is the worst place to discover a headless box. @@ -55,28 +112,28 @@ def body(state: AppState, json_mode: bool) -> None: "a keyring), or unlock/install an OS keyring and retry." ), ) - if api_key is None: + if provided_key is None: persist_browser_login(profile, env, json_mode=json_mode) else: # Non-interactive escape hatch for CI/automation: no AMS session is # obtained, so account self-service commands won't work for this profile. - if not client.validate_key(api_key): + if not client.validate_key(provided_key): raise APIError( "That API key was rejected (HTTP 401/403).", suggestion="Check the key and retry.", ) - config.set_api_key(profile, api_key) + config.set_api_key(profile, provided_key) config.set_profile_env(profile, env) # Clear any session from a prior browser login: this profile is now # api-key-only, so account self-service must report it needs a browser # login rather than silently reusing the old (possibly different) identity. config.clear_session(profile) - # An --api-key login stores no browser session, so the AMS self-service + # A key-only login stores no browser session, so the AMS self-service # commands won't work for this profile — say so up front instead of letting # the user hit "needs a browser login" later. Named `key_only` (not api_key_*): # CodeQL's name heuristic would classify the boolean itself as a secret and # flag the emit below (py/clear-text-logging-sensitive-data). - key_only = api_key is not None + key_only = provided_key is not None def render(_d: object) -> str: lines = [ @@ -88,8 +145,8 @@ def render(_d: object) -> str: if key_only: lines.append( output.hint( - "Account commands (keys/balance/usage/limits/audit) need " - "`assembly login` without --api-key." + "Account commands (keys/balance/usage/limits/audit) need the " + "browser flow: run `assembly login` with no key flag." ) ) return "\n".join(lines) diff --git a/aai_cli/commands/speak.py b/aai_cli/commands/speak.py index 578a1927..1a99b7c9 100644 --- a/aai_cli/commands/speak.py +++ b/aai_cli/commands/speak.py @@ -63,7 +63,7 @@ def speak( ), json_out: bool = options.json_option("Emit JSON metadata about the synthesized audio."), ) -> None: - """Synthesize speech from text with AssemblyAI streaming TTS (sandbox only). + """[sandbox] Synthesize speech from text with AssemblyAI streaming TTS. Plays the audio through your speakers by default, or writes a WAV with --out. Speaker-labeled input (from 'assembly transcribe diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 3cbf7df7..bb03b172 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -414,3 +414,13 @@ def transcribe( lambda state, json_mode: transcribe_exec.run_transcribe(opts, state, json_mode=json_mode), json=json_out, ) + + +# `assembly t` — a one-letter alias for the CLI's highest-frequency command (the +# pattern codex uses for `e`/`a`). Registered hidden so the root help table keeps +# one row per command; `assembly t --help` still renders the full transcribe help. +app.command( + name="t", + hidden=True, + epilog=examples_epilog([("Same flags as transcribe", "assembly t call.mp3 --speaker-labels")]), +)(transcribe) diff --git a/aai_cli/commands/update.py b/aai_cli/commands/update.py new file mode 100644 index 00000000..494eaef0 --- /dev/null +++ b/aai_cli/commands/update.py @@ -0,0 +1,97 @@ +"""`assembly update` — upgrade the CLI in place. + +The startup "Update available" notice (``update_check.py``) tells the user a +newer release exists; this command is the action it points at. It detects how +the CLI was installed (Homebrew, pipx, uv tool) and shells out to that +channel's own upgrade command, the same install-channel dispatch ``codex +update`` does — there is no bespoke self-replacing binary logic to go wrong. +""" + +from __future__ import annotations + +import shlex +import subprocess + +import typer + +from aai_cli import __version__, config, help_panels, options, output, update_check +from aai_cli.context import AppState, run_command +from aai_cli.errors import APIError, CLIError +from aai_cli.help_text import examples_epilog + +app = typer.Typer() + + +def _latest_version() -> str: + """The newest released version, fetched now (not the day-old startup cache).""" + update_check.fetch_and_cache() + _, latest = config.get_update_cache() + if latest is None: + raise APIError( + "Couldn't determine the latest version from GitHub releases.", + suggestion="Check your network connection and retry.", + ) + return latest + + +def _run_upgrade(command: str) -> None: + """Run the install channel's own upgrade command, inheriting stdio so the user + watches the real brew/pipx/uv output stream by.""" + returncode = subprocess.run(shlex.split(command), check=False).returncode + if returncode != 0: + raise CLIError( + f"'{command}' exited with status {returncode}.", + error_type="update_failed", + suggestion="Re-run it directly to see the full output.", + ) + + +@app.command( + rich_help_panel=help_panels.SETUP, + epilog=examples_epilog( + [ + ("Upgrade to the latest release", "assembly update"), + ("Only check whether one exists", "assembly update --check"), + ] + ), +) +def update( + ctx: typer.Context, + check: bool = typer.Option( + False, "--check", help="Report whether a newer release exists without installing it." + ), + json_out: bool = options.json_option(), +) -> None: + """Update the CLI to the latest release via your install method (brew/pipx/uv).""" + + def body(_state: AppState, json_mode: bool) -> None: + latest = _latest_version() + newer = update_check.is_newer(latest, __version__) + if check or not newer: + status = ( + f"Update available: {__version__} → {latest}. Run `assembly update` to install." + if newer + else f"Already up to date ({__version__})." + ) + output.emit( + {"current": __version__, "latest": latest, "update_available": newer}, + lambda _d: status, + json_mode=json_mode, + ) + return + command = update_check.detect_upgrade_command() + if not command: + raise CLIError( + "Couldn't detect how this CLI was installed, so it can't self-update.", + error_type="unknown_install", + exit_code=2, + suggestion=f"Upgrade with your install method — see {update_check.DOCS_URL}.", + ) + _run_upgrade(command) + output.emit( + {"updated": True, "from": __version__, "to": latest, "command": command}, + lambda _d: output.success(f"Updated {__version__} → {latest} (via '{command}')."), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/config.py b/aai_cli/config.py index 889bdcbc..782bba60 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -84,6 +84,12 @@ def config_dir() -> Path: return Path(platformdirs.user_config_dir("assemblyai")) +def config_file_path() -> Path: + """Where config.toml lives — surfaced by `assembly config path` so users can + find the file without knowing the platformdirs convention.""" + return _config_file() + + def _config_file() -> Path: return config_dir() / "config.toml" @@ -175,6 +181,32 @@ def get_active_profile() -> str: return _load().active_profile or DEFAULT_PROFILE +def list_profiles() -> dict[str, str | None]: + """Profile name -> stored backend env, for every profile in config.toml.""" + return {name: prof.env for name, prof in _load().profiles.items()} + + +def set_active_profile(name: str) -> None: + """Make ``name`` the default profile for future runs (``assembly config set``). + + Only an existing profile can become active: pointing the default at a name with + no stored credentials would make every later command fail as "not signed in" + with no hint why, so the typo is rejected here with the known names listed. + """ + validate_profile(name) + cfg = _load() + if name not in cfg.profiles: + known = ", ".join(sorted(cfg.profiles)) or "none yet" + raise CLIError( + f"No profile named {name!r} (known: {known}).", + error_type="invalid_profile", + exit_code=2, + suggestion=f"Create it first: assembly --profile {name} login", + ) + cfg.active_profile = name + _dump(cfg) + + def _keyring_set(username: str, secret: str) -> None: """Write a secret to the OS keyring, turning backend failures into a clean error. diff --git a/aai_cli/dictate_exec.py b/aai_cli/dictate_exec.py index 896ca78e..e33c81c3 100644 --- a/aai_cli/dictate_exec.py +++ b/aai_cli/dictate_exec.py @@ -89,8 +89,11 @@ def _record(keys: TerminalKeys, mic: MicrophoneSource, *, max_seconds: float) -> def _emit(result: sync_stt.SyncTranscript, *, json_mode: bool) -> None: """One utterance to stdout: the bare transcript text, or one NDJSON object.""" if json_mode: + # "type" first: every multi-line NDJSON stream the CLI emits discriminates + # its lines the same way (stream/agent already do; see docs/cli-reference.md). output.emit_ndjson( { + "type": "utterance", "text": result.text, "confidence": result.confidence, "audio_duration_ms": result.audio_duration_ms, diff --git a/aai_cli/errors.py b/aai_cli/errors.py index 0d23147b..05bbe129 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -1,6 +1,6 @@ """The CLIError hierarchy and the exit-code contract scripts can rely on. -Exit codes (stable; mirrored in the README "Exit codes" table): +Exit codes (stable; mirrored in the REFERENCE.md "Exit codes" table): * ``0`` — success. * ``1`` — a generic runtime failure: an API/network error (:class:`APIError`), @@ -126,6 +126,9 @@ def mutually_exclusive(*flags: tuple[str, object], suggestion: str | None = None REJECTED_KEY_MESSAGE = "Your API key was rejected." REJECTED_KEY_SUGGESTION = "Run 'assembly login' with a valid key, or set ASSEMBLYAI_API_KEY." +# The non-interactive sign-in recipe (stdin-only so the key never reaches shell +# history or `ps`); referenced by login's flags and the browser-flow fallbacks. +STDIN_KEY_RECIPE = "printenv ASSEMBLYAI_API_KEY | assembly login --with-api-key" _VOICE_AGENT_POLICY_VIOLATION_CODE = 1008 diff --git a/aai_cli/follow.py b/aai_cli/follow.py index 9ac95430..a77d2bca 100644 --- a/aai_cli/follow.py +++ b/aai_cli/follow.py @@ -37,7 +37,8 @@ def __enter__(self) -> FollowRenderer: def __call__(self, answer: str, turns: int) -> None: if self.json_mode: - output.emit_ndjson({"turns": turns, "output": answer}) + # "type" discriminates NDJSON lines CLI-wide (see docs/cli-reference.md). + output.emit_ndjson({"type": "answer", "turns": turns, "output": answer}) elif self._live is not None: title = f"scribe · {turns} turn{'s' if turns != 1 else ''}" self._last = Panel(escape(answer or "…"), title=title, border_style="aai.brand") diff --git a/aai_cli/llm.py b/aai_cli/llm.py index cb2e715b..401e7d60 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -1,9 +1,11 @@ from __future__ import annotations +import json +from collections.abc import Sequence from typing import TYPE_CHECKING, Any from aai_cli import environments -from aai_cli.errors import APIError +from aai_cli.errors import APIError, UsageError if TYPE_CHECKING: from openai import OpenAI @@ -42,6 +44,32 @@ def complete_model(incomplete: str) -> list[str]: return [m for m in KNOWN_MODELS if m.startswith(incomplete)] +def parse_gateway_overrides(pairs: Sequence[str]) -> dict[str, object]: + """``--config KEY=VALUE`` pairs to typed gateway request fields. + + The escape hatch for request fields the curated flags don't cover (the same + role ``--config`` plays on `transcribe`/`stream`). The gateway's field set is + open-ended (it is OpenAI-compatible per model family), so values aren't + allow-listed; each VALUE parses as JSON when it can (``temperature=0.2`` → + float, ``stop=["END"]`` → list) and falls back to the literal string + otherwise (``reasoning_effort=low``). + """ + extra: dict[str, object] = {} + for pair in pairs: + key, sep, raw = pair.partition("=") + key = key.strip() + if not sep or not key: + raise UsageError( + f"--config expects KEY=VALUE, got {pair!r}.", + suggestion="e.g. --config temperature=0.2", + ) + try: + extra[key] = json.loads(raw) + except ValueError: + extra[key] = raw + return extra + + def build_messages( prompt: str, *, @@ -105,23 +133,29 @@ def complete( messages: list[dict[str, str]], max_tokens: int = DEFAULT_MAX_TOKENS, transcript_id: str | None = None, + extra: dict[str, object] | None = None, ) -> ChatCompletion: """Create a chat completion via the gateway and return the OpenAI response. `transcript_id` is passed through as an extra body field so the gateway can - inject the transcript text server-side. Access/permission and other gateway - errors surface the gateway's own message as APIError. + inject the transcript text server-side; `extra` carries the user's ``--config`` + overrides the same way. Access/permission and other gateway errors surface + the gateway's own message as APIError. """ import openai client = _client(api_key) - extra_body = {"transcript_id": transcript_id} if transcript_id is not None else None + extra_body: dict[str, object] = {} + if transcript_id is not None: + extra_body["transcript_id"] = transcript_id + if extra: + extra_body.update(extra) try: return client.chat.completions.create( model=model, messages=messages, # type: ignore[arg-type] max_tokens=max_tokens, - extra_body=extra_body, + extra_body=extra_body or None, ) except (openai.AuthenticationError, openai.PermissionDeniedError) as exc: # The gateway returns 401/403 for an invalid key, a proxy block, and a diff --git a/aai_cli/llm_exec.py b/aai_cli/llm_exec.py index d8c500e6..6fcb332b 100644 --- a/aai_cli/llm_exec.py +++ b/aai_cli/llm_exec.py @@ -41,6 +41,8 @@ class LlmOptions: follow: bool output_field: choices.TextOrJson | None max_tokens: int + # Raw --config KEY=VALUE pairs; parsed (and validated) once in run_llm. + config_kv: tuple[str, ...] = () def _validate_follow_args( @@ -88,7 +90,9 @@ def _stdin_transcript_text( return None -def _run_follow(opts: LlmOptions, state: AppState, *, json_mode: bool) -> None: +def _run_follow( + opts: LlmOptions, state: AppState, extra: dict[str, object], *, json_mode: bool +) -> None: prompt_text = _validate_follow_args(opts.prompt, opts.output_field, opts.transcript_id) api_key = state.resolve_api_key() @@ -97,7 +101,7 @@ def ask(transcript_text: str) -> str: prompt_text, system=opts.system, transcript_text=transcript_text ) response = gateway.complete( - api_key, model=opts.model, messages=messages, max_tokens=opts.max_tokens + api_key, model=opts.model, messages=messages, max_tokens=opts.max_tokens, extra=extra ) return gateway.content_of(response) @@ -117,7 +121,9 @@ def ask(transcript_text: str) -> str: raise UsageError(_FOLLOW_STDIN_MESSAGE) -def _run_oneshot(opts: LlmOptions, state: AppState, *, json_mode: bool) -> None: +def _run_oneshot( + opts: LlmOptions, state: AppState, extra: dict[str, object], *, json_mode: bool +) -> None: if not opts.prompt: raise UsageError( "Provide a prompt.", @@ -138,6 +144,7 @@ def _run_oneshot(opts: LlmOptions, state: AppState, *, json_mode: bool) -> None: messages=messages, max_tokens=opts.max_tokens, transcript_id=opts.transcript_id, + extra=extra, ) content = gateway.content_of(response) if opts.output_field == "text": @@ -153,7 +160,9 @@ def _run_oneshot(opts: LlmOptions, state: AppState, *, json_mode: bool) -> None: def run_llm(opts: LlmOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly llm` invocation (one-shot or --follow) from parsed flags.""" + # Parsed before any stdin/network work so a malformed pair fails fast. + extra = gateway.parse_gateway_overrides(opts.config_kv) if opts.follow: - _run_follow(opts, state, json_mode=json_mode) + _run_follow(opts, state, extra, json_mode=json_mode) else: - _run_oneshot(opts, state, json_mode=json_mode) + _run_oneshot(opts, state, extra, json_mode=json_mode) diff --git a/aai_cli/main.py b/aai_cli/main.py index b3a233c8..ec938430 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -20,13 +20,24 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from aai_cli import __version__, argscan, debuglog, environments, help_panels, output, stdio, theme +from aai_cli import ( + __version__, + argscan, + choices, + debuglog, + environments, + help_panels, + output, + stdio, + theme, +) from aai_cli.commands import ( account, agent, audit, caption, clip, + config_cmd, deploy, dev, dictate, @@ -46,6 +57,7 @@ telemetry, transcribe, transcripts, + update, webhooks, ) from aai_cli.context import AppState @@ -81,6 +93,8 @@ # Setup & Tools — get set up & maintain "doctor", "setup", + "config", + "update", "telemetry", # History — browse past work "transcripts", @@ -352,6 +366,11 @@ def main( quiet: bool = typer.Option( False, "--quiet", "-q", help="Suppress non-essential messages (warnings, hints)." ), + color: choices.ColorMode = typer.Option( + choices.ColorMode.auto, + "--color", + help="Color output: auto (TTY detection), always, or never. NO_COLOR is also honored.", + ), verbose: int = typer.Option( 0, "--verbose", @@ -379,6 +398,7 @@ def main( # Enabled before anything else runs so even environment/profile resolution # failures can be diagnosed with -v. debuglog.enable(verbose) + output.set_color_mode(color) raw_args: list[str] = ctx.meta.get(_RAW_ARGS_META_KEY, []) json_mode = output.resolve_json(explicit=argscan.requests_json(raw_args)) conflict_warning = _sandbox_conflict_warning(sandbox, env) @@ -428,6 +448,8 @@ def main( app.add_typer(deploy.app) app.add_typer(onboard.app) app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP) +app.add_typer(config_cmd.app, name="config", rich_help_panel=help_panels.SETUP) +app.add_typer(update.app) # update app.add_typer(telemetry.app, name="telemetry", rich_help_panel=help_panels.SETUP) app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) app.add_typer(webhooks.app, name="webhooks", rich_help_panel=help_panels.TRANSCRIPTION) diff --git a/aai_cli/output.py b/aai_cli/output.py index 5d2f5acf..e974c72c 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -40,6 +40,37 @@ def is_agentic() -> bool: return any(os.environ.get(var) for var in _AGENT_ENV_VARS) +def set_color_mode(mode: choices.ColorMode) -> None: + """Apply the root ``--color`` tri-state process-wide. + + ``auto`` keeps Rich's TTY detection (which already honors ``NO_COLOR`` / + ``FORCE_COLOR``). The explicit modes do two things: rebuild this module's + shared consoles, and set the corresponding env var so consoles created later + (the realtime renderers build their own) and child processes agree. + """ + if mode is choices.ColorMode.auto: + return + if mode is choices.ColorMode.always: + os.environ["FORCE_COLOR"] = "1" + os.environ.pop("NO_COLOR", None) + rebuilt = { + "console": theme.make_console(force_terminal=True), + "error_console": theme.make_console(stderr=True, force_terminal=True), + } + else: + os.environ["NO_COLOR"] = "1" + os.environ.pop("FORCE_COLOR", None) + rebuilt = { + "console": theme.make_console(no_color=True), + "error_console": theme.make_console(stderr=True, no_color=True), + } + # Swapped via module setattr (the `_patch_module` pattern in main.py) rather + # than a `global` statement; readers always go through `output.console`, so + # the rebind is visible everywhere. + for name, console_obj in rebuilt.items(): + setattr(sys.modules[__name__], name, console_obj) + + def resolve_json(*, explicit: bool) -> bool: """JSON output only when explicitly requested with ``--json`` (or ``-o json``). diff --git a/aai_cli/transcribe_batch.py b/aai_cli/transcribe_batch.py index 22f81bd3..849eb642 100644 --- a/aai_cli/transcribe_batch.py +++ b/aai_cli/transcribe_batch.py @@ -275,7 +275,8 @@ class _Item: def record(self) -> dict[str, str]: """The NDJSON record emitted for this source under ``--json``.""" - rec = {"source": self.source, "status": self.status} + # "type" discriminates NDJSON lines CLI-wide (see docs/cli-reference.md). + rec = {"type": "result", "source": self.source, "status": self.status} if self.transcript_id: rec["id"] = self.transcript_id if self.status == "failed": diff --git a/aai_cli/update_check.py b/aai_cli/update_check.py index 1c80c228..a6f2345d 100644 --- a/aai_cli/update_check.py +++ b/aai_cli/update_check.py @@ -23,7 +23,7 @@ ENV_DISABLED = "AAI_NO_UPDATE_CHECK" _RELEASES_URL = "https://api.github.com/repos/AssemblyAI/cli/releases/latest" -_DOCS_URL = "https://github.com/AssemblyAI/cli#installation" +DOCS_URL = "https://github.com/AssemblyAI/cli#installation" _CHECK_INTERVAL_SECONDS = 24 * 60 * 60 _FETCH_TIMEOUT_SECONDS = 5.0 _USER_AGENT = f"assembly-cli/{__version__}" @@ -105,7 +105,7 @@ def _render(current: str, latest: str) -> None: if upgrade: action: Text = Text.assemble("Run ", (upgrade, "aai.success"), " to update") else: - action = Text(f"See {_DOCS_URL} to upgrade") + action = Text(f"See {DOCS_URL} to upgrade") body = Group( Text.assemble("Update available ", (current, "aai.muted"), " → ", (latest, "aai.success")), action, diff --git a/tests/__snapshots__/test_snapshots_help_account.ambr b/tests/__snapshots__/test_snapshots_help_account.ambr index d5f8dbba..c43588a3 100644 --- a/tests/__snapshots__/test_snapshots_help_account.ambr +++ b/tests/__snapshots__/test_snapshots_help_account.ambr @@ -162,16 +162,18 @@ Authenticate via your browser; stores a CLI API key. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --api-key TEXT Provide key non-interactively. │ - │ --json -j Output raw JSON. │ - │ --help Show this message and exit. │ + │ --with-api-key Read an API key from stdin instead of the browser │ + │ flow: printenv ASSEMBLYAI_API_KEY | assembly login │ + │ --with-api-key │ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples Log in with your browser $ assembly login Log in non-interactively (CI) - $ assembly login --api-key sk_... + $ printenv ASSEMBLYAI_API_KEY | assembly login --with-api-key diff --git a/tests/__snapshots__/test_snapshots_help_run.ambr b/tests/__snapshots__/test_snapshots_help_run.ambr index 241e1f11..87fcaedd 100644 --- a/tests/__snapshots__/test_snapshots_help_run.ambr +++ b/tests/__snapshots__/test_snapshots_help_run.ambr @@ -287,7 +287,7 @@ Usage: assembly dub [OPTIONS] MEDIA - Dub a video or audio file into another language (sandbox only). + 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, @@ -472,6 +472,12 @@ │ json. │ │ --max-tokens INTEGER RANGE [x>=1] Max tokens to generate. │ │ [default: 1000] │ + │ --config TEXT Set any extra gateway request │ + │ field: KEY=VALUE, repeatable │ + │ (e.g. --config │ + │ temperature=0.2). Values │ + │ parse as JSON, else literal │ + │ text. │ │ --list-models Print known models and exit. │ │ --json -j Output raw JSON (one object │ │ per turn in --follow mode). │ @@ -498,7 +504,7 @@ Usage: assembly speak [OPTIONS] [TEXT] - Synthesize speech from text with AssemblyAI streaming TTS (sandbox only). + Synthesize speech from text with AssemblyAI streaming TTS. Plays the audio through your speakers by default, or writes a WAV with --out. Speaker-labeled input (from 'assembly transcribe @@ -695,6 +701,184 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[t] + ''' + + Usage: assembly t [OPTIONS] [SOURCE] + + Transcribe an audio file, URL, or YouTube/podcast link — or a whole batch. + + Quickest start: assembly transcribe call.mp3 (or --sample for the hosted + demo). + + Save with --out FILE, or pipe one field with -o text. YouTube and podcast-page + URLs (any page yt-dlp can extract) are downloaded first, then transcribed. + + Batch mode: pass a directory or glob (or pipe a list with --from-stdin) to + transcribe many sources concurrently. Each source gets a .aai.json sidecar + with the full result (including any --llm responses), and a re-run skips + sources already transcribed — with changed --llm prompts it replays just + the LLM step, never a second transcription. + + Bucket URLs (s3://, gs://, az://, sftp://, …) work for single files and for + batches (a glob, or a folder ending in /); install the matching fsspec + backend first (e.g. pip install s3fs) and use its usual credentials. + + Curated flags cover common features; --config KEY=VALUE and --config-file + reach every other field. Analysis (summary, chapters, ...) renders in human + mode. + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ source [SOURCE] Audio file, URL, YouTube/podcast URL, bucket URL │ + │ (s3://, gs://, …), or a directory/glob (batch mode). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --sample Use the hosted │ + │ wildfires.mp3 sample. │ + │ --json -j Output the full result │ + │ as JSON. Text stays the │ + │ default even when │ + │ piped; opt in here │ + │ (same as -o json). │ + │ --output -o [text|id|status|uttera Print one field: text, │ + │ nces|srt|vtt|json] id, status, utterances, │ + │ srt or vtt (captions), │ + │ or json. │ + │ --chars-per-caption INTEGER RANGE [x>=1] Max characters per │ + │ caption line (only with │ + │ -o srt or -o vtt). │ + │ --out FILE Save the result to a │ + │ file instead of │ + │ printing it (clean │ + │ text; pairs with -o). │ + │ --show-code Print the equivalent │ + │ Python SDK code and │ + │ exit (does not │ + │ transcribe). │ + │ --help Show this message and │ + │ exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Batch ──────────────────────────────────────────────────────────────────────╮ + │ --from-stdin Batch mode: read audio paths/URLs │ + │ from stdin, one per line │ + │ (composes with find/ls/yt-dlp │ + │ output). │ + │ --concurrency INTEGER RANGE [x>=1] How many sources to transcribe at │ + │ once in batch mode. │ + │ [default: 4] │ + │ --force Batch mode: re-transcribe sources │ + │ whose sidecar already records a │ + │ completed run. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Model & Language ───────────────────────────────────────────────────────────╮ + │ --speech-model [best|nano|slam-1|univ Speech model. │ + │ ersal] │ + │ --language-code TEXT Force a language (e.g. │ + │ en_us). │ + │ --language-detection Auto-detect the spoken │ + │ language. │ + │ --keyterms-prompt TEXT Boost a key term │ + │ (repeatable). │ + │ --temperature FLOAT RANGE Speech model temperature │ + │ [0.0<=x<=1.0] (0 most deterministic, 1 │ + │ least). │ + │ --prompt TEXT Prompt to bias the │ + │ speech model (supported │ + │ models only). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Formatting ─────────────────────────────────────────────────────────────────╮ + │ --punctuate --no-punctuate Add punctuation. │ + │ --format-text --no-format-text Apply text formatting (casing, │ + │ numbers). │ + │ --disfluencies Keep filler words (e.g. um, uh). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Speakers & Channels ────────────────────────────────────────────────────────╮ + │ --speaker-labels Enable diarization. │ + │ --speakers-expected INTEGER RANGE [x>=1] Hint speaker count. │ + │ --multichannel Transcribe each audio │ + │ channel separately. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Guardrails ─────────────────────────────────────────────────────────────────╮ + │ --redact-pii Redact PII from the │ + │ transcript. │ + │ --redact-pii-policy TEXT Comma-separated PII │ + │ policies (e.g. │ + │ person_name,...). │ + │ --redact-pii-sub [hash|entity_name] How to replace │ + │ redacted PII. │ + │ --redact-pii-audio Also redact audio. │ + │ --filter-profanity Mask profanity. │ + │ --content-safety Detect sensitive │ + │ content. │ + │ --content-safety-confidence INTEGER RANGE Content-safety │ + │ [25<=x<=100] confidence threshold │ + │ (25-100). │ + │ --speech-threshold FLOAT RANGE Minimum proportion │ + │ [0.0<=x<=1.0] of speech required │ + │ (0-1). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Analysis ───────────────────────────────────────────────────────────────────╮ + │ --summarization Summarize the │ + │ transcript. │ + │ --summary-model [informative|conversati Summary model. │ + │ onal|catchy] │ + │ --summary-type [bullets|bullets_verbos Summary format. │ + │ e|gist|headline|paragra │ + │ ph] │ + │ --auto-chapters Generate chapters. │ + │ --sentiment-analysis Analyze sentiment. │ + │ --entity-detection Detect entities. │ + │ --auto-highlights Detect key phrases. │ + │ --topic-detection Detect IAB topics. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Customization ──────────────────────────────────────────────────────────────╮ + │ --word-boost TEXT Boost a word │ + │ (repeatable). │ + │ --custom-spelling-file FILE JSON map of custom │ + │ spellings. │ + │ --audio-start INTEGER RANGE [x>=0] Start offset in ms. │ + │ --audio-end INTEGER RANGE [x>=0] End offset in ms. │ + │ --download-sections TEXT For a YouTube/podcast │ + │ URL, download only part │ + │ of the source (yt-dlp │ + │ "--download-sections" │ + │ syntax, e.g. │ + │ "*0:00-5:00" for the │ + │ first five minutes; │ + │ repeatable). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Webhooks ───────────────────────────────────────────────────────────────────╮ + │ --webhook-url TEXT Webhook URL for completion (get a │ + │ dev URL with `assembly webhooks │ + │ listen`). │ + │ --webhook-auth-header NAME:VALUE Webhook auth header as NAME:VALUE. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Translation ────────────────────────────────────────────────────────────────╮ + │ --translate-to TEXT Translate transcript to a language (repeatable). │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Advanced ───────────────────────────────────────────────────────────────────╮ + │ --config KEY=VALUE Set any TranscriptionConfig field as │ + │ KEY=VALUE (repeatable). │ + │ --config-file FILE JSON file of config fields. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ LLM Transform ──────────────────────────────────────────────────────────────╮ + │ --llm TEXT Transform the finished transcript through LLM │ + │ Gateway. Repeatable: each prompt runs on the │ + │ previous one's response (a chain), the first on │ + │ the transcript. │ + │ --model TEXT LLM Gateway model. │ + │ [default: claude-haiku-4-5-20251001] │ + │ --max-tokens INTEGER Max tokens. [default: 1000] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Same flags as transcribe + $ assembly t call.mp3 --speaker-labels + + + ''' # --- # name: test_command_help_matches_snapshot[transcribe] diff --git a/tests/__snapshots__/test_snapshots_help_tools.ambr b/tests/__snapshots__/test_snapshots_help_tools.ambr index fb71d46f..cef3d763 100644 --- a/tests/__snapshots__/test_snapshots_help_tools.ambr +++ b/tests/__snapshots__/test_snapshots_help_tools.ambr @@ -16,6 +16,106 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[config_get] + ''' + + Usage: assembly config get [OPTIONS] + KEY:{active_profile|env|telemetry_enabled} + + Print one setting's stored value (`env` reads the selected profile's). + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ * key KEY:{active_profile|env|teleme Which setting to read. │ + │ try_enabled} [required] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Read one setting (pipe-friendly) + $ assembly config get env + Read a named profile's env + $ assembly -p staging config get env + + + + ''' +# --- +# name: test_command_help_matches_snapshot[config_list] + ''' + + Usage: assembly config list [OPTIONS] + + Show every persisted setting and the stored profiles. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Show every persisted setting + $ assembly config list + As JSON for scripting + $ assembly config list --json + + + + ''' +# --- +# name: test_command_help_matches_snapshot[config_path] + ''' + + Usage: assembly config path [OPTIONS] + + Print where config.toml lives. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Where settings are stored + $ assembly config path + + + + ''' +# --- +# name: test_command_help_matches_snapshot[config_set] + ''' + + Usage: assembly config set [OPTIONS] + KEY:{active_profile|env|telemetry_enabled} + VALUE + + Change one setting (`env` writes to the selected profile). + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ * key KEY:{active_profile|env|tele Which setting to change. │ + │ metry_enabled} [required] │ + │ * value TEXT The new value. [required] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + 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 + + + ''' # --- # name: test_command_help_matches_snapshot[doctor] @@ -211,5 +311,29 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[update] + ''' + + Usage: assembly update [OPTIONS] + + Update the CLI to the latest release via your install method (brew/pipx/uv). + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --check Report whether a newer release exists without installing │ + │ it. │ + │ --json -j Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Upgrade to the latest release + $ assembly update + Only check whether one exists + $ assembly update --check + + + ''' # --- diff --git a/tests/_dub_helpers.py b/tests/_dub_helpers.py index c8ddb2b0..e8f314c8 100644 --- a/tests/_dub_helpers.py +++ b/tests/_dub_helpers.py @@ -84,7 +84,15 @@ def record_translate(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, object]] """Record each gateway call and reply with a marked 'DE:' translation.""" calls: list[dict[str, object]] = [] - def _fake(api_key, *, model, messages, max_tokens=llm.DEFAULT_MAX_TOKENS, transcript_id=None): + def _fake( + api_key, + *, + model, + messages, + max_tokens=llm.DEFAULT_MAX_TOKENS, + transcript_id=None, + extra=None, + ): calls.append({"model": model, "messages": messages, "max_tokens": max_tokens}) return completion(f"DE:{messages[-1]['content']}") diff --git a/tests/_snapshot_surface.py b/tests/_snapshot_surface.py index 1b7f94f5..f6fb4c22 100644 --- a/tests/_snapshot_surface.py +++ b/tests/_snapshot_surface.py @@ -26,6 +26,7 @@ "run": frozenset( { "transcribe", + "t", # hidden alias for transcribe "stream", "dictate", "agent", @@ -38,7 +39,7 @@ "webhooks", } ), - "tools": frozenset({"doctor", "setup", "telemetry", "_update-check"}), + "tools": frozenset({"doctor", "setup", "config", "update", "telemetry", "_update-check"}), "history": frozenset({"transcripts", "sessions"}), "account": frozenset( {"login", "logout", "whoami", "balance", "usage", "limits", "keys", "audit"} diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 00f7ce82..259a09fc 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -166,9 +166,9 @@ def test_run_login_flow_timeout_raises_auth_typed_error(monkeypatch): assert exc.value.message == "Login timed out waiting for the browser." assert exc.value.error_type == "not_authenticated" # auth-typed, not api_error assert exc.value.exit_code == 4 - assert ( - exc.value.suggestion - == "Run 'assembly login' again, or use 'assembly login --api-key '." + assert exc.value.suggestion == ( + "Run 'assembly login' again, or use " + "'printenv ASSEMBLYAI_API_KEY | assembly login --with-api-key'." ) @@ -411,7 +411,7 @@ def test_run_login_flow_prints_waiting_hint(monkeypatch, capsys): assert flow.run_login_flow().api_key == "sk_final" err = capsys.readouterr().err assert "Waiting up to 2 minutes" in err - assert "assembly login --api-key" in err + assert "printenv ASSEMBLYAI_API_KEY" in err # the stdin login recipe (may wrap) assert '"hint"' not in err # json_mode defaults to False: prose, not JSON objects @@ -458,7 +458,7 @@ def test_run_login_flow_json_mode_keeps_stderr_machine_readable(monkeypatch, cap assert flow.run_login_flow(json_mode=True).api_key == "sk_final" lines = [json.loads(line) for line in capsys.readouterr().err.strip().splitlines()] waiting = next(obj for obj in lines if "Waiting up to 2 minutes" in obj["hint"]) - assert "assembly login --api-key" in waiting["hint"] + assert "printenv ASSEMBLYAI_API_KEY | assembly login --with-api-key" in waiting["hint"] assert "url" not in waiting # the url field only ships on the browser-open notes opening = next(obj for obj in lines if "Opening your browser" in obj["hint"]) assert opening["url"].startswith("https://") diff --git a/tests/test_auth_flow_headless.py b/tests/test_auth_flow_headless.py new file mode 100644 index 00000000..60595558 --- /dev/null +++ b/tests/test_auth_flow_headless.py @@ -0,0 +1,41 @@ +"""The browser-fallback messaging in the login flow (headless/SSH boxes).""" + +import json + +from aai_cli.auth import flow + + +def _fail_browser(monkeypatch): + monkeypatch.setattr(flow.webbrowser, "open", lambda _url: False) + + +def _unwrapped(err: str) -> str: + """Collapse the console's soft line wrapping so substrings match reliably.""" + return " ".join(err.split()) + + +def test_fallback_names_the_port_forward_and_stdin_recipe(monkeypatch, capsys): + _fail_browser(monkeypatch) + flow._open_browser("https://login.example", json_mode=False) + err = _unwrapped(capsys.readouterr().err) + assert "Could not open a browser" in err + # The callback lands on *this* machine's loopback, so the SSH case needs the + # exact forward command (default port 8585) before the URL can work remotely. + assert "ssh -L 8585:127.0.0.1:8585" in err + assert "printenv ASSEMBLYAI_API_KEY" in err # the no-browser-at-all escape + + +def test_fallback_honors_auth_port_override(monkeypatch, capsys): + monkeypatch.setenv("AAI_AUTH_PORT", "9000") + _fail_browser(monkeypatch) + flow._open_browser("https://login.example", json_mode=False) + assert "ssh -L 9000:127.0.0.1:9000" in _unwrapped(capsys.readouterr().err) + + +def test_fallback_json_mode_ships_hint_objects(monkeypatch, capsys): + _fail_browser(monkeypatch) + flow._open_browser("https://login.example", json_mode=True) + lines = [json.loads(line) for line in capsys.readouterr().err.strip().splitlines()] + fallback = next(obj for obj in lines if "Could not open a browser" in obj["hint"]) + assert "ssh -L 8585:127.0.0.1:8585" in fallback["hint"] + assert fallback["url"] == "https://login.example" diff --git a/tests/test_color_mode.py b/tests/test_color_mode.py new file mode 100644 index 00000000..e70662a3 --- /dev/null +++ b/tests/test_color_mode.py @@ -0,0 +1,92 @@ +"""The root `--color` tri-state and `output.set_color_mode`.""" + +import os + +import pytest +from typer.testing import CliRunner + +from aai_cli import output +from aai_cli.choices import ColorMode +from aai_cli.main import app + +runner = CliRunner() + +_COLOR_VARS = ("NO_COLOR", "FORCE_COLOR") + + +@pytest.fixture(autouse=True) +def restore_color_state(monkeypatch): + """set_color_mode swaps module-global consoles and writes env vars *during* the + test (not via monkeypatch), so snapshot and restore both by hand. The vars are + also cleared up front: CI exports FORCE_COLOR, which would skew the asserts. + The update notice is disabled because a forced-terminal stderr console would + otherwise convince it to spawn the real detached refresh process.""" + monkeypatch.setenv("AAI_NO_UPDATE_CHECK", "1") + saved_env = {var: os.environ.pop(var, None) for var in _COLOR_VARS} + saved_consoles = (output.console, output.error_console) + yield + for var, value in saved_env.items(): + if value is None: + os.environ.pop(var, None) + else: + os.environ[var] = value + output.console, output.error_console = saved_consoles + + +def test_never_strips_color_and_sets_no_color_env(): + output.set_color_mode(ColorMode.never) + assert os.environ["NO_COLOR"] == "1" + assert "FORCE_COLOR" not in os.environ + assert output.console.no_color is True + assert output.error_console.no_color is True + assert output.error_console.stderr is True # stderr console stays on stderr + + +def test_always_forces_color_and_sets_force_color_env(): + os.environ["NO_COLOR"] = "1" # an inherited NO_COLOR must lose to --color always + output.set_color_mode(ColorMode.always) + assert os.environ["FORCE_COLOR"] == "1" + assert "NO_COLOR" not in os.environ + assert output.console.is_terminal is True # force_terminal even when captured + assert output.error_console.is_terminal is True + assert output.error_console.stderr is True + + +def test_auto_changes_nothing(): + before_out, before_err = output.console, output.error_console + output.set_color_mode(ColorMode.auto) + assert "NO_COLOR" not in os.environ + assert "FORCE_COLOR" not in os.environ + assert output.console is before_out # the consoles aren't rebuilt + assert output.error_console is before_err + + +def test_root_callback_wires_the_flag(monkeypatch): + seen = [] + monkeypatch.setattr(output, "set_color_mode", seen.append) + result = runner.invoke(app, ["--color", "never", "config", "path"]) + assert result.exit_code == 0 + assert seen == [ColorMode.never] + + +def test_color_defaults_to_auto(monkeypatch): + seen = [] + monkeypatch.setattr(output, "set_color_mode", seen.append) + result = runner.invoke(app, ["config", "path"]) + assert result.exit_code == 0 + assert seen == [ColorMode.auto] + + +def test_color_rejects_unknown_value(): + result = runner.invoke(app, ["--color", "sometimes", "config", "path"]) + assert result.exit_code == 2 + assert "sometimes" in result.output + + +def test_never_yields_plain_output_end_to_end(): + # The user-visible effect: a forced-color invocation carries SGR escapes, a + # --color never one does not (CliRunner captures off-TTY, so force via flag). + plain = runner.invoke(app, ["--color", "never", "config", "list"]) + forced = runner.invoke(app, ["--color", "always", "config", "list"]) + assert "\x1b[" not in plain.output + assert "\x1b[" in forced.output diff --git a/tests/test_config_command.py b/tests/test_config_command.py new file mode 100644 index 00000000..ad5989f8 --- /dev/null +++ b/tests/test_config_command.py @@ -0,0 +1,207 @@ +"""`assembly config` — the persisted-settings surface (path/list/get/set).""" + +import json + +import pytest +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.errors import CLIError +from aai_cli.main import app + +runner = CliRunner() + + +# --- config.py helpers ------------------------------------------------------ + + +def test_list_profiles_maps_names_to_envs(): + config.set_api_key("default", "sk_1") + config.set_api_key("staging", "sk_2") + config.set_profile_env("staging", "sandbox000") + assert config.list_profiles() == {"default": None, "staging": "sandbox000"} + + +def test_set_active_profile_switches_default(): + config.set_api_key("default", "sk_1") + config.set_api_key("staging", "sk_2") + config.set_active_profile("staging") + assert config.get_active_profile() == "staging" + + +def test_set_active_profile_rejects_unknown_name_listing_known(): + config.set_api_key("default", "sk_1") + with pytest.raises(CLIError) as exc: + config.set_active_profile("nope") + assert exc.value.error_type == "invalid_profile" + assert exc.value.exit_code == 2 + assert "known: default" in exc.value.message + assert exc.value.suggestion == "Create it first: assembly --profile nope login" + assert config.get_active_profile() == "default" # nothing was written + + +def test_set_active_profile_with_no_profiles_says_none_yet(): + with pytest.raises(CLIError) as exc: + config.set_active_profile("nope") + assert "known: none yet" in exc.value.message + + +def test_config_file_path_is_the_toml_under_config_dir(): + path = config.config_file_path() + assert path.name == "config.toml" + assert path.parent == config.config_dir() + + +def test_bare_config_shows_subcommand_help(): + # no_args_is_help: `assembly config` alone renders the command table instead + # of a bare "Missing command" usage error. + result = runner.invoke(app, ["config"]) + assert "Change one setting" in result.output # the `set` row of the help table + assert "Print where config.toml lives" in result.output # the `path` row + + +# --- assembly config path / list --------------------------------------------- + + +def test_config_path_prints_bare_path(): + result = runner.invoke(app, ["config", "path"]) + assert result.exit_code == 0 + assert result.output.strip() == str(config.config_file_path()) + + +def test_config_path_json(): + result = runner.invoke(app, ["config", "path", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == {"path": str(config.config_file_path())} + + +def test_config_list_json_is_the_full_settings_object(): + config.set_api_key("staging", "sk_2") + config.set_profile_env("staging", "sandbox000") + config.set_telemetry_enabled(enabled=False) + result = runner.invoke(app, ["config", "list", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "path": str(config.config_file_path()), + "active_profile": "staging", + "profiles": {"staging": "sandbox000"}, + "telemetry_enabled": False, + } + + +def test_config_list_human_render_shows_rows_and_hint(monkeypatch): + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + config.set_api_key("staging", "sk_2") + config.set_profile_env("staging", "sandbox000") + result = runner.invoke(app, ["config", "list"]) + assert result.exit_code == 0 + assert "Config file" in result.output + assert "Active profile" in result.output + assert "staging (sandbox000)" in result.output # profile listed with its env + assert "unset" in result.output # telemetry never chosen + assert "assembly config set" in result.output # the next-step hint + assert '"path"' not in result.output # human mode, not JSON + + +def test_config_list_human_with_no_profiles_says_none_yet(): + result = runner.invoke(app, ["config", "list"]) + assert result.exit_code == 0 + assert "none yet" in result.output + + +# --- assembly config get ------------------------------------------------------ + + +def test_config_get_active_profile_defaults_to_default(): + result = runner.invoke(app, ["config", "get", "active_profile"]) + assert result.exit_code == 0 + assert result.output.strip() == "default" + + +def test_config_get_env_unset_prints_unset_and_null_json(): + human = runner.invoke(app, ["config", "get", "env"]) + assert human.exit_code == 0 + assert human.output.strip() == "unset" + as_json = runner.invoke(app, ["config", "get", "env", "--json"]) + assert json.loads(as_json.output) == {"key": "env", "value": None} + + +def test_config_get_env_honors_profile_flag(): + config.set_api_key("staging", "sk_2") + config.set_profile_env("staging", "sandbox000") + result = runner.invoke(app, ["--profile", "staging", "config", "get", "env"]) + assert result.exit_code == 0 + assert result.output.strip() == "sandbox000" + + +def test_config_get_telemetry_renders_booleans_in_toml_case(): + config.set_telemetry_enabled(enabled=True) + assert runner.invoke(app, ["config", "get", "telemetry_enabled"]).output.strip() == "true" + config.set_telemetry_enabled(enabled=False) + assert runner.invoke(app, ["config", "get", "telemetry_enabled"]).output.strip() == "false" + + +def test_config_get_rejects_unknown_key(): + result = runner.invoke(app, ["config", "get", "bogus"]) + assert result.exit_code == 2 # Typer enum validation + assert "bogus" in result.output + + +# --- assembly config set ------------------------------------------------------ + + +def test_config_set_active_profile_round_trips(): + config.set_api_key("staging", "sk_2") + result = runner.invoke(app, ["config", "set", "active_profile", "staging"]) + assert result.exit_code == 0 + assert "active_profile = staging" in result.output + assert config.get_active_profile() == "staging" + + +def test_config_set_active_profile_unknown_fails_cleanly(): + result = runner.invoke(app, ["config", "set", "active_profile", "ghost"]) + assert result.exit_code == 2 + assert "No profile named 'ghost'" in result.output + + +def test_config_set_env_writes_to_selected_profile(): + config.set_api_key("staging", "sk_2") + result = runner.invoke(app, ["--profile", "staging", "config", "set", "env", "sandbox000"]) + assert result.exit_code == 0 + assert config.get_profile_env("staging") == "sandbox000" + assert config.get_profile_env("default") is None # only the selected profile + + +def test_config_set_env_rejects_unknown_environment(): + result = runner.invoke(app, ["config", "set", "env", "bogus"]) + assert result.exit_code == 2 + assert "Unknown environment 'bogus'" in result.output + assert "production, sandbox000" in result.output + + +@pytest.mark.parametrize("word", ["true", "1", "yes", "on", "TRUE", " Yes "]) +def test_config_set_telemetry_true_words(word): + result = runner.invoke(app, ["config", "set", "telemetry_enabled", word]) + assert result.exit_code == 0 + assert config.get_telemetry_enabled() is True + + +@pytest.mark.parametrize("word", ["false", "0", "no", "off", "OFF"]) +def test_config_set_telemetry_false_words(word): + result = runner.invoke(app, ["config", "set", "telemetry_enabled", word]) + assert result.exit_code == 0 + assert config.get_telemetry_enabled() is False + + +def test_config_set_telemetry_rejects_non_boolean(): + result = runner.invoke(app, ["config", "set", "telemetry_enabled", "maybe"]) + assert result.exit_code == 2 + assert "telemetry_enabled expects a boolean" in result.output + assert "'maybe'" in result.output + assert config.get_telemetry_enabled() is None # nothing persisted + + +def test_config_set_json_reports_typed_value(): + result = runner.invoke(app, ["config", "set", "telemetry_enabled", "false", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == {"key": "telemetry_enabled", "value": False} diff --git a/tests/test_dictate_exec.py b/tests/test_dictate_exec.py index 2b6b185f..f86ca906 100644 --- a/tests/test_dictate_exec.py +++ b/tests/test_dictate_exec.py @@ -134,6 +134,7 @@ def test_json_mode_emits_one_ndjson_object_per_utterance(seams, capsys): _run(json_mode=True) captured = capsys.readouterr() assert json.loads(captured.out) == { + "type": "utterance", "text": "hello world", "confidence": 0.9, "audio_duration_ms": 1500, diff --git a/tests/test_follow.py b/tests/test_follow.py index 9fab657a..99747014 100644 --- a/tests/test_follow.py +++ b/tests/test_follow.py @@ -10,8 +10,8 @@ def test_json_mode_emits_ndjson_per_refresh(monkeypatch): r("second answer", 2) assert emitted == [ - {"turns": 1, "output": "first answer"}, - {"turns": 2, "output": "second answer"}, + {"type": "answer", "turns": 1, "output": "first answer"}, + {"type": "answer", "turns": 2, "output": "second answer"}, ] diff --git a/tests/test_llm.py b/tests/test_llm.py index ed410bda..32cbe04e 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -176,7 +176,7 @@ def test_build_messages_with_system_prompt(): def test_transform_transcript_roundtrips(monkeypatch): seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["transcript_id"] = transcript_id seen["messages"] = messages return _response("SUMMARY") @@ -191,7 +191,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_run_chain_single_prompt_runs_over_transcript(monkeypatch): seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["messages"] = messages seen["transcript_id"] = transcript_id return _response("SUMMARY") @@ -208,7 +208,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_run_chain_threads_output_through_prompts(monkeypatch): calls = [] - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): calls.append(messages[-1]["content"]) return _response(f"out{len(calls)}") @@ -239,7 +239,7 @@ def fail_complete(*args, **kwargs): def test_run_chain_steps_uses_transcript_id_then_prior_output(monkeypatch): calls = [] - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): calls.append({"content": messages[-1]["content"], "transcript_id": transcript_id}) return _response(f"out{len(calls)}") diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 9c453cc6..46648883 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -66,7 +66,7 @@ def test_llm_sends_prompt_and_prints_output(monkeypatch): _auth() seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["model"] = model seen["messages"] = messages seen["transcript_id"] = transcript_id @@ -98,7 +98,7 @@ def test_llm_transcript_id_injected(monkeypatch): _auth() seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["transcript_id"] = transcript_id seen["content"] = messages[0]["content"] return _payload("summary") @@ -114,7 +114,7 @@ def test_llm_reads_content_from_stdin(monkeypatch): _auth() seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["content"] = messages[0]["content"] seen["transcript_id"] = transcript_id return _payload("done") @@ -132,7 +132,7 @@ def test_llm_transcript_id_takes_priority_over_stdin(monkeypatch): _auth() seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["content"] = messages[0]["content"] seen["transcript_id"] = transcript_id return _payload("s") @@ -242,7 +242,7 @@ def test_llm_unauthenticated_runs_login(monkeypatch): monkeypatch.setattr("aai_cli.context._interactive_session", lambda: True) monkeypatch.setattr("aai_cli.auth.run_login_flow", _login_result) - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): raise AssertionError(f"LLM request should not run after auto-login: {api_key}") monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) @@ -256,7 +256,7 @@ def test_llm_follow_summarizes_each_turn(monkeypatch): _auth() calls = [] - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): calls.append(messages[-1]["content"]) return _payload(f"summary-{len(calls)}") @@ -282,7 +282,7 @@ def test_llm_follow_includes_system_prompt(monkeypatch): _auth() seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["roles"] = [m["role"] for m in messages] seen["system"] = messages[0]["content"] return _payload("ok") @@ -314,7 +314,7 @@ def test_llm_follow_ignores_blank_lines(monkeypatch): _auth() calls = [] - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): calls.append(messages[-1]["content"]) return _payload("ok") @@ -401,7 +401,7 @@ def test_llm_follow_empty_stdin_exits_2(monkeypatch): _auth() calls = [] - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): calls.append(messages) return _payload("ok") @@ -435,7 +435,7 @@ def test_llm_follow_stops_cleanly_on_interrupt(monkeypatch): _auth() calls = [] - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): calls.append(messages[-1]["content"]) if len(calls) == 2: raise KeyboardInterrupt # user hits Ctrl-C mid-meeting @@ -456,7 +456,7 @@ def test_llm_passes_model_and_max_tokens(monkeypatch): _auth() seen = {} - def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): seen["model"] = model seen["max_tokens"] = max_tokens return _payload() diff --git a/tests/test_llm_config_overrides.py b/tests/test_llm_config_overrides.py new file mode 100644 index 00000000..a7c7fd8a --- /dev/null +++ b/tests/test_llm_config_overrides.py @@ -0,0 +1,127 @@ +"""`assembly llm --config KEY=VALUE` — the open-ended gateway-field escape hatch.""" + +import pytest +from typer.testing import CliRunner + +from aai_cli import config, llm +from aai_cli.errors import UsageError +from aai_cli.main import app +from tests.test_llm import _fake_client, _response + +runner = CliRunner() + + +# --- parse_gateway_overrides -------------------------------------------------- + + +def test_overrides_parse_json_typed_values(): + assert llm.parse_gateway_overrides( + ["temperature=0.2", 'stop=["END"]', "logprobs=true", "n=2"] + ) == {"temperature": 0.2, "stop": ["END"], "logprobs": True, "n": 2} + + +def test_overrides_fall_back_to_literal_strings(): + # Not valid JSON -> the literal text, so enum-ish values need no quoting. + assert llm.parse_gateway_overrides(["reasoning_effort=low"]) == {"reasoning_effort": "low"} + + +def test_overrides_key_is_stripped_and_empty_value_allowed(): + assert llm.parse_gateway_overrides([" user =alex"]) == {"user": "alex"} + + +def test_overrides_reject_pair_without_equals(): + with pytest.raises(UsageError) as exc: + llm.parse_gateway_overrides(["temperature"]) + assert "KEY=VALUE" in exc.value.message + assert "'temperature'" in exc.value.message + assert "temperature=0.2" in (exc.value.suggestion or "") + + +def test_overrides_reject_empty_key(): + with pytest.raises(UsageError): + llm.parse_gateway_overrides(["=0.2"]) + + +def test_overrides_empty_input_yields_empty_dict(): + assert llm.parse_gateway_overrides([]) == {} + + +# --- complete() plumbing ------------------------------------------------------- + + +def test_complete_merges_extra_with_transcript_id(monkeypatch): + seen = _fake_client(monkeypatch, result=_response()) + llm.complete( + "sk", + model="m", + messages=[], + transcript_id="t_42", + extra={"temperature": 0.2}, + ) + assert seen["extra_body"] == {"transcript_id": "t_42", "temperature": 0.2} + + +def test_complete_without_extras_sends_no_extra_body(monkeypatch): + seen = _fake_client(monkeypatch, result=_response()) + llm.complete("sk", model="m", messages=[], extra={}) + assert seen["extra_body"] is None + + +# --- command wiring ------------------------------------------------------------ + + +def test_llm_config_flags_reach_the_gateway(monkeypatch): + config.set_api_key("default", "sk_live") + seen = {} + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen["extra"] = extra + return _response("ok") + + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) + result = runner.invoke( + app, + [ + "llm", + "hi", + "--config", + "temperature=0.2", + "--config", + "reasoning_effort=low", + "-o", + "text", + ], + ) + assert result.exit_code == 0 + assert seen["extra"] == {"temperature": 0.2, "reasoning_effort": "low"} + + +def test_llm_bad_config_pair_fails_before_any_request(monkeypatch): + config.set_api_key("default", "sk_live") + called = [] + monkeypatch.setattr( + "aai_cli.commands.llm.gateway.complete", + lambda *a, **k: called.append(1), + ) + result = runner.invoke(app, ["llm", "hi", "--config", "broken"]) + assert result.exit_code == 2 + assert "KEY=VALUE" in result.output + assert called == [] + + +def test_llm_follow_passes_config_to_every_refresh(monkeypatch): + config.set_api_key("default", "sk_live") + seen = [] + + def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None, extra=None): + seen.append(extra) + return _response("ans") + + monkeypatch.setattr("aai_cli.llm_exec.gateway.complete", fake_complete) + result = runner.invoke( + app, + ["llm", "-f", "summarize", "--config", "temperature=0.1", "--json"], + input="turn one\nturn two\n", + ) + assert result.exit_code == 0 + assert seen == [{"temperature": 0.1}, {"temperature": 0.1}] diff --git a/tests/test_login.py b/tests/test_login.py index 4f20bc9c..a5eb2ada 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -22,7 +22,12 @@ def test_login_with_api_key_flag_stores_key(mocker): result = runner.invoke(app, ["login", "--api-key", "sk_flag", "--json"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_flag" - assert json.loads(result.output)["authenticated"] is True # pins the success flag + # CliRunner merges stderr into output, so the deprecation {"warning": …} line + # rides along — filter by the key the success payload carries. + objs = [json.loads(line) for line in result.output.strip().splitlines()] + assert next(o for o in objs if "authenticated" in o)["authenticated"] is True + warning = next(o for o in objs if "warning" in o)["warning"] + assert "--with-api-key" in warning # the deprecation points at the stdin form def test_login_rejects_invalid_key(mocker): diff --git a/tests/test_login_with_key.py b/tests/test_login_with_key.py new file mode 100644 index 00000000..fc29df43 --- /dev/null +++ b/tests/test_login_with_key.py @@ -0,0 +1,94 @@ +"""`assembly login --with-api-key` (stdin key) and the `--api-key` deprecation trap.""" + +import json +import types + +import pytest +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.commands import login as login_cmd +from aai_cli.errors import STDIN_KEY_RECIPE, UsageError +from aai_cli.main import app + +runner = CliRunner() + + +def test_stdin_key_recipe_is_the_documented_pipe(): + # The constant is interpolated into help, errors, and the auth flow; pin it once. + assert STDIN_KEY_RECIPE == "printenv ASSEMBLYAI_API_KEY | assembly login --with-api-key" + + +def test_with_api_key_reads_stdin_and_stores(mocker): + validate = mocker.patch( + "aai_cli.commands.login.client.validate_key", autospec=True, return_value=True + ) + result = runner.invoke(app, ["login", "--with-api-key", "--json"], input=" sk_piped \n") + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_piped" # whitespace stripped + validate.assert_called_once_with("sk_piped") # the stdin key is what's validated + objs = [json.loads(line) for line in result.output.strip().splitlines()] + payload = next(o for o in objs if "authenticated" in o) + assert payload["api_key_only"] is True # no AMS session from a key-only login + + +def test_with_api_key_human_mode_mentions_account_command_limit(mocker, monkeypatch): + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--with-api-key"], input="sk_piped\n") + assert result.exit_code == 0 + assert "Signed in as default" in result.output + assert "browser flow" in result.output # the key-only caveat hint + + +def test_with_api_key_empty_stdin_fails_with_recipe(mocker): + validate = mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True) + result = runner.invoke(app, ["login", "--with-api-key"], input=" \n") + assert result.exit_code == 2 + assert "found no key on stdin" in result.output + assert "printenv ASSEMBLYAI_API_KEY" in result.output + validate.assert_not_called() + assert config.get_api_key("default") is None + + +def test_read_stdin_key_rejects_a_terminal_stdin(monkeypatch): + # A TTY stdin means nothing was piped; reading would block forever. + monkeypatch.setattr( + "sys.stdin", types.SimpleNamespace(isatty=lambda: True, read=lambda: "never") + ) + with pytest.raises(UsageError) as exc: + login_cmd._read_stdin_key() + assert "stdin is a terminal" in exc.value.message + assert STDIN_KEY_RECIPE in (exc.value.suggestion or "") + + +def test_api_key_and_with_api_key_conflict(mocker): + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--api-key", "sk_x", "--with-api-key"], input="sk_y\n") + assert result.exit_code == 2 + assert "--api-key and --with-api-key can't be combined" in result.output + assert config.get_api_key("default") is None + + +def test_api_key_flag_warns_toward_stdin_form(mocker, monkeypatch): + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) + assert result.exit_code == 0 + assert "shell history" in result.output # the deprecation warning + assert "--with-api-key" in result.output + assert config.get_api_key("default") == "sk_flag" # but it still works + + +def test_api_key_warning_respects_quiet(mocker): + mocker.patch("aai_cli.commands.login.client.validate_key", autospec=True, return_value=True) + result = runner.invoke(app, ["--quiet", "login", "--api-key", "sk_flag"]) + assert result.exit_code == 0 + assert "shell history" not in result.output + + +def test_api_key_flag_is_hidden_from_help(): + result = runner.invoke(app, ["login", "--help"]) + assert result.exit_code == 0 + assert "--with-api-key" in result.output + assert "--api-key " not in result.output # trailing space: --with-api-key still matches diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 20b6fbb3..9a1420a9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -165,6 +165,8 @@ def test_help_lists_commands_in_workflow_order(): # Setup & Tools "doctor", "setup", + "config", + "update", "telemetry", # History "transcripts", diff --git a/tests/test_stream_llm.py b/tests/test_stream_llm.py index 503d23a4..2825f4b0 100644 --- a/tests/test_stream_llm.py +++ b/tests/test_stream_llm.py @@ -49,10 +49,10 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): assert seen["model"] == "gpt-4.1" assert seen["max_tokens"] == 50 lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] - assert {"turns": 1, "output": "answer:hola"} in lines - assert {"turns": 2, "output": "answer:hola mundo"} in lines + assert {"type": "answer", "turns": 1, "output": "answer:hola"} in lines + assert {"type": "answer", "turns": 2, "output": "answer:hola mundo"} in lines # Live mode replaces the raw turn envelopes; only follow refreshes reach stdout. - assert '"type"' not in result.output + assert '"type": "turn"' not in result.output def test_stream_llm_chains_multiple_prompts(monkeypatch): diff --git a/tests/test_transcribe_alias.py b/tests/test_transcribe_alias.py new file mode 100644 index 00000000..6bcea694 --- /dev/null +++ b/tests/test_transcribe_alias.py @@ -0,0 +1,23 @@ +"""`assembly t` — the hidden one-letter alias for transcribe.""" + +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() + + +def test_t_runs_the_transcribe_command(): + # --show-code needs no auth/network and proves the full flag surface is shared. + alias = runner.invoke(app, ["t", "--sample", "--show-code"]) + full = runner.invoke(app, ["transcribe", "--sample", "--show-code"]) + assert alias.exit_code == 0 + assert "import assemblyai" in alias.output + assert alias.output == full.output # same command, same behavior + + +def test_t_is_hidden_from_root_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + # No bare "t" row in the command table (transcribe itself still lists). + assert "\n│ t " not in result.output diff --git a/tests/test_transcribe_batch.py b/tests/test_transcribe_batch.py index 3cc2e367..ff7ea663 100644 --- a/tests/test_transcribe_batch.py +++ b/tests/test_transcribe_batch.py @@ -91,6 +91,7 @@ def test_rerun_skips_sources_with_completed_sidecars(tmp_path, mocker, monkeypat assert seen == ["b.mp3"] # only the unfinished source pays records = {r["source"]: r for r in _ndjson(result)} assert records["a.mp3"] == { + "type": "result", "source": "a.mp3", "status": "skipped", "id": "t_old", @@ -159,7 +160,13 @@ def test_url_source_resumes_on_sidecar_alone(tmp_path, mocker, monkeypatch): assert result.exit_code == 0 assert seen == [] assert _ndjson(result) == [ - {"source": url, "status": "skipped", "id": "t_old", "sidecar": str(sidecar)} + { + "type": "result", + "source": url, + "status": "skipped", + "id": "t_old", + "sidecar": str(sidecar), + } ] @@ -210,7 +217,12 @@ def fake(api_key, audio, *, config): result = runner.invoke(app, ["transcribe", "*.mp3", "--json"]) assert result.exit_code == 1 records = {r["source"]: r for r in _ndjson(result) if "source" in r} - assert records["a.mp3"] == {"source": "a.mp3", "status": "failed", "error": "upload exploded"} + assert records["a.mp3"] == { + "type": "result", + "source": "a.mp3", + "status": "failed", + "error": "upload exploded", + } assert records["b.mp3"]["status"] == "completed" assert (tmp_path / "b.mp3.aai.json").exists() assert not (tmp_path / "a.mp3.aai.json").exists() @@ -377,7 +389,11 @@ def test_resumable_record_requires_matching_hash(): def test_item_record_minimal_shape_before_any_work(): # A not-yet-finished item serializes to just {source, status}: no empty id/sidecar keys. - assert transcribe_batch._Item("x.mp3").record() == {"source": "x.mp3", "status": "queued"} + assert transcribe_batch._Item("x.mp3").record() == { + "type": "result", + "source": "x.mp3", + "status": "queued", + } def test_source_digest_is_sha256_of_file_bytes(tmp_path): diff --git a/tests/test_transcribe_batch_llm.py b/tests/test_transcribe_batch_llm.py index 4da1e984..aa7f5584 100644 --- a/tests/test_transcribe_batch_llm.py +++ b/tests/test_transcribe_batch_llm.py @@ -126,6 +126,7 @@ def test_batch_llm_stores_chain_steps_in_each_sidecar(tmp_path, mocker, monkeypa assert sidecar["transcript"]["id"] == "t_a.mp3" # transcription payload kept alongside records = {r["source"]: r for r in _ndjson(result)} assert records["a.mp3"] == { + "type": "result", "source": "a.mp3", "status": "completed", "id": "t_a.mp3", @@ -192,7 +193,13 @@ def test_rerun_with_same_llm_chain_skips_entirely(tmp_path, mocker, monkeypatch) assert seen == [] assert calls == [] assert _ndjson(result) == [ - {"source": "a.mp3", "status": "skipped", "id": "t_old", "sidecar": "a.mp3.aai.json"} + { + "type": "result", + "source": "a.mp3", + "status": "skipped", + "id": "t_old", + "sidecar": "a.mp3.aai.json", + } ] diff --git a/tests/test_transcribe_batch_sources.py b/tests/test_transcribe_batch_sources.py index ce750299..2cddaa2d 100644 --- a/tests/test_transcribe_batch_sources.py +++ b/tests/test_transcribe_batch_sources.py @@ -314,6 +314,7 @@ def test_glob_batch_writes_per_source_sidecars(tmp_path, mocker, monkeypatch): assert sorted(seen) == ["a.mp3", "b.mp3"] records = {r["source"]: r for r in map(json.loads, result.output.splitlines())} assert records["a.mp3"] == { + "type": "result", "source": "a.mp3", "status": "completed", "id": "t_a.mp3", diff --git a/tests/test_update_command.py b/tests/test_update_command.py new file mode 100644 index 00000000..77b58081 --- /dev/null +++ b/tests/test_update_command.py @@ -0,0 +1,122 @@ +"""`assembly update` — install-channel dispatching self-update.""" + +import json +import types + +from typer.testing import CliRunner + +from aai_cli import __version__, config, update_check +from aai_cli.main import app + +runner = CliRunner() + + +def _unwrapped(text: str) -> str: + """Collapse console soft-wrapping (the dev version string is long).""" + return " ".join(text.split()) + + +def _pin_latest(monkeypatch, latest): + """Make the explicit fetch land ``latest`` in the cache (no network).""" + + def fake_fetch(): + config.set_update_cache(last_check=1.0, latest_version=latest) + + monkeypatch.setattr(update_check, "fetch_and_cache", fake_fetch) + + +def _record_subprocess(monkeypatch, returncode=0): + calls = [] + + def fake_run(argv, check): + calls.append((argv, check)) + return types.SimpleNamespace(returncode=returncode) + + monkeypatch.setattr("aai_cli.commands.update.subprocess.run", fake_run) + return calls + + +def test_update_check_reports_available(monkeypatch): + _pin_latest(monkeypatch, "999.0.0") + result = runner.invoke(app, ["update", "--check"]) + assert result.exit_code == 0 + out = _unwrapped(result.output) + assert "Update available" in out + assert "999.0.0" in out + assert "assembly update" in out # points at the action + + +def test_update_check_json_shape(monkeypatch): + _pin_latest(monkeypatch, "999.0.0") + result = runner.invoke(app, ["update", "--check", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "current": __version__, + "latest": "999.0.0", + "update_available": True, + } + + +def test_update_already_up_to_date_skips_the_upgrade(monkeypatch): + _pin_latest(monkeypatch, __version__) + calls = _record_subprocess(monkeypatch) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + assert "Already up to date" in result.output + assert __version__ in result.output + assert calls == [] # no channel command ran + + +def test_update_fetch_failure_is_a_clean_api_error(monkeypatch): + _pin_latest(monkeypatch, None) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 1 + assert "Couldn't determine the latest version" in result.output + + +def test_update_unknown_install_channel_exits_2_with_docs(monkeypatch): + _pin_latest(monkeypatch, "999.0.0") + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "") + result = runner.invoke(app, ["update"]) + assert result.exit_code == 2 + assert "Couldn't detect how this CLI was installed" in result.output + assert update_check.DOCS_URL in result.output + + +def test_update_runs_the_channel_command(monkeypatch): + _pin_latest(monkeypatch, "999.0.0") + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly") + calls = _record_subprocess(monkeypatch, returncode=0) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 0 + # shlex-split argv, check=False so the exit code is inspected, not raised. + assert calls == [(["brew", "upgrade", "assembly"], False)] + out = _unwrapped(result.output) + assert "Updated" in out + assert "999.0.0" in out + assert "brew upgrade assembly" in out + + +def test_update_json_reports_versions_and_command(monkeypatch): + _pin_latest(monkeypatch, "999.0.0") + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "pipx upgrade aai-cli") + _record_subprocess(monkeypatch, returncode=0) + result = runner.invoke(app, ["update", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == { + "updated": True, + "from": __version__, + "to": "999.0.0", + "command": "pipx upgrade aai-cli", + } + + +def test_update_failed_upgrade_surfaces_exit_status(monkeypatch): + _pin_latest(monkeypatch, "999.0.0") + monkeypatch.setattr(update_check, "detect_upgrade_command", lambda: "brew upgrade assembly") + _record_subprocess(monkeypatch, returncode=3) + result = runner.invoke(app, ["update"]) + assert result.exit_code == 1 + out = _unwrapped(result.output) + assert "'brew upgrade assembly' exited with status 3" in out + assert "Re-run it directly" in out