From 1a9829b6a34f3260e6dc029f0566ac90958b09f9 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 11:45:08 -0700 Subject: [PATCH 01/15] feat(auth): add auth package skeleton and endpoint config Co-Authored-By: Claude Sonnet 4.6 --- assemblyai_cli/auth/__init__.py | 0 assemblyai_cli/auth/endpoints.py | 31 +++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_auth_endpoints.py | 17 +++++++++++++++++ uv.lock | 2 ++ 5 files changed, 51 insertions(+) create mode 100644 assemblyai_cli/auth/__init__.py create mode 100644 assemblyai_cli/auth/endpoints.py create mode 100644 tests/test_auth_endpoints.py diff --git a/assemblyai_cli/auth/__init__.py b/assemblyai_cli/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/assemblyai_cli/auth/endpoints.py b/assemblyai_cli/auth/endpoints.py new file mode 100644 index 00000000..5220a557 --- /dev/null +++ b/assemblyai_cli/auth/endpoints.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os + +# Stytch B2B project (sandbox defaults; override via env for prod). +STYTCH_PROJECT_DOMAIN = os.environ.get( + "AAI_AUTH_STYTCH_DOMAIN", + "https://psychedelic-journey-5884.customers.stytch.dev", +) +STYTCH_PUBLIC_TOKEN = os.environ.get( + "AAI_AUTH_PUBLIC_TOKEN", + "public-token-test-79ad7d8d-09df-495e-8eb8-72d18efdafa4", +) +STYTCH_OAUTH_PROVIDER = os.environ.get("AAI_AUTH_PROVIDER", "google") + +# Fixed loopback (Stytch does exact-match redirect validation; 8585 is registered). +LOOPBACK_HOST = "127.0.0.1" +LOOPBACK_PORT = int(os.environ.get("AAI_AUTH_PORT", "8585")) +LOOPBACK_PATH = "/callback" + +# Accounts Management Service. +AMS_BASE_URL = os.environ.get( + "AAI_AUTH_AMS_URL", "https://ams.sandbox000.assemblyai-labs.com" +) + +CLI_TOKEN_NAME = "AssemblyAI CLI" + + +def redirect_uri() -> str: + """The exact loopback redirect URL registered in Stytch.""" + return f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}{LOOPBACK_PATH}" diff --git a/pyproject.toml b/pyproject.toml index e96cb9e3..a53048f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "assemblyai>=0.64.4", "rich>=15.0.0", "keyring>=25.7.0", + "httpx>=0.28.1", "platformdirs>=4.10.0", "tomli-w>=1.2.0", "websockets>=16.0", diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py new file mode 100644 index 00000000..945fac8b --- /dev/null +++ b/tests/test_auth_endpoints.py @@ -0,0 +1,17 @@ +from assemblyai_cli.auth import endpoints + + +def test_redirect_uri_is_fixed_loopback(): + assert endpoints.redirect_uri() == "http://127.0.0.1:8585/callback" + + +def test_defaults_point_at_sandbox(): + assert endpoints.AMS_BASE_URL == "https://ams.sandbox000.assemblyai-labs.com" + assert endpoints.STYTCH_OAUTH_PROVIDER == "google" + assert endpoints.CLI_TOKEN_NAME == "AssemblyAI CLI" + assert endpoints.STYTCH_PUBLIC_TOKEN.startswith("public-token-") + + +def test_env_override_changes_redirect_uri(monkeypatch): + monkeypatch.setattr(endpoints, "LOOPBACK_PORT", 9999) + assert endpoints.redirect_uri() == "http://127.0.0.1:9999/callback" diff --git a/uv.lock b/uv.lock index d2fd8552..7deded49 100644 --- a/uv.lock +++ b/uv.lock @@ -63,6 +63,7 @@ source = { editable = "." } dependencies = [ { name = "assemblyai" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "httpx" }, { name = "keyring" }, { name = "openai" }, { name = "platformdirs" }, @@ -89,6 +90,7 @@ requires-dist = [ { name = "assemblyai", specifier = ">=0.34" }, { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "keyring", specifier = ">=24.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "openai", specifier = ">=1.40" }, From 124dfacccfcfeecebff7910c705d2456522a62cf Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 11:50:56 -0700 Subject: [PATCH 02/15] feat(auth): build Stytch B2B OAuth discovery start URL Co-Authored-By: Claude Sonnet 4.6 --- assemblyai_cli/auth/discovery.py | 25 +++++++++++++++++++++++++ tests/test_auth_discovery.py | 18 ++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 assemblyai_cli/auth/discovery.py create mode 100644 tests/test_auth_discovery.py diff --git a/assemblyai_cli/auth/discovery.py b/assemblyai_cli/auth/discovery.py new file mode 100644 index 00000000..e669267a --- /dev/null +++ b/assemblyai_cli/auth/discovery.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +from assemblyai_cli.auth import endpoints + + +def build_start_url() -> str: + """The Stytch B2B OAuth discovery *start* URL the browser opens. + + Client-side endpoint authenticated by the project's public token. After the + user authenticates with the provider, Stytch redirects to our loopback + `discovery_redirect_url` with `?stytch_token_type=discovery_oauth&token=...`. + No custom state is appended: the redirect is exact-match validated and the + server is loopback-only, single-shot. + """ + base = ( + f"{endpoints.STYTCH_PROJECT_DOMAIN}" + f"/v1/b2b/public/oauth/{endpoints.STYTCH_OAUTH_PROVIDER}/discovery/start" + ) + params = { + "public_token": endpoints.STYTCH_PUBLIC_TOKEN, + "discovery_redirect_url": endpoints.redirect_uri(), + } + return f"{base}?{urlencode(params)}" diff --git a/tests/test_auth_discovery.py b/tests/test_auth_discovery.py new file mode 100644 index 00000000..85da4180 --- /dev/null +++ b/tests/test_auth_discovery.py @@ -0,0 +1,18 @@ +from urllib.parse import parse_qs, urlparse + +from assemblyai_cli.auth import discovery, endpoints + + +def test_build_start_url_targets_b2b_discovery_for_provider(): + url = discovery.build_start_url() + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.path == "/v1/b2b/public/oauth/google/discovery/start" + assert url.startswith(endpoints.STYTCH_PROJECT_DOMAIN) + + +def test_build_start_url_includes_public_token_and_redirect(): + url = discovery.build_start_url() + qs = parse_qs(urlparse(url).query) + assert qs["public_token"] == [endpoints.STYTCH_PUBLIC_TOKEN] + assert qs["discovery_redirect_url"] == ["http://127.0.0.1:8585/callback"] From 1932239e78779552abcc7e429244e96125610e0a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 11:53:19 -0700 Subject: [PATCH 03/15] feat(auth): add fixed-port loopback callback server --- assemblyai_cli/auth/loopback.py | 60 +++++++++++++++++++++++++++++++++ tests/test_auth_loopback.py | 39 +++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 assemblyai_cli/auth/loopback.py create mode 100644 tests/test_auth_loopback.py diff --git a/assemblyai_cli/auth/loopback.py b/assemblyai_cli/auth/loopback.py new file mode 100644 index 00000000..e2e79a70 --- /dev/null +++ b/assemblyai_cli/auth/loopback.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +from assemblyai_cli.auth import endpoints + +_SUCCESS_HTML = ( + b"" + b"

Signed in.

You can close this tab and return to the terminal.

" + b"" +) + + +@dataclass +class CallbackResult: + token: str | None = None + token_type: str | None = None + error: str | None = None + + +def capture_callback(timeout: float = 120.0) -> CallbackResult: + """Bind the fixed loopback port, capture one OAuth callback, return its token. + + Returns a CallbackResult; `error="timeout"` if no callback arrives in time. + """ + result = CallbackResult() + done = threading.Event() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - stdlib API name + parsed = urlparse(self.path) + if parsed.path != endpoints.LOOPBACK_PATH: + self.send_response(404) + self.end_headers() + return + qs = parse_qs(parsed.query) + result.token = next(iter(qs.get("token", [])), None) + result.token_type = next(iter(qs.get("stytch_token_type", [])), None) + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_HTML) + done.set() + + def log_message(self, *args: object) -> None: # silence stderr logging + pass + + server = HTTPServer((endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + if not done.wait(timeout): + result.error = "timeout" + finally: + server.shutdown() + thread.join(timeout=5) + return result diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py new file mode 100644 index 00000000..bb0c3996 --- /dev/null +++ b/tests/test_auth_loopback.py @@ -0,0 +1,39 @@ +import threading +import time +import urllib.request + +from assemblyai_cli.auth import endpoints, loopback + + +def _hit(path: str) -> None: + url = f"http://{endpoints.LOOPBACK_HOST}:{endpoints.LOOPBACK_PORT}{path}" + # Retry briefly until the server thread is bound. + for _ in range(50): + try: + urllib.request.urlopen(url, timeout=2).read() + return + except Exception: + time.sleep(0.05) + + +def test_capture_returns_token_and_type(): + result_box = {} + + def run(): + result_box["result"] = loopback.capture_callback(timeout=5.0) + + t = threading.Thread(target=run) + t.start() + _hit("/callback?stytch_token_type=discovery_oauth&token=tok_abc") + t.join(timeout=5) + + result = result_box["result"] + assert result.token == "tok_abc" + assert result.token_type == "discovery_oauth" + assert result.error is None + + +def test_capture_times_out_without_callback(): + result = loopback.capture_callback(timeout=0.3) + assert result.error == "timeout" + assert result.token is None From 4edec0a26d84dce7acb42d3a528dbb6072e3a98a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 11:59:21 -0700 Subject: [PATCH 04/15] feat(auth): add AMS http client (discover/exchange/auth/projects/tokens) Co-Authored-By: Claude Sonnet 4.6 --- assemblyai_cli/auth/ams.py | 89 ++++++++++++++++++++++++++++++++++++++ tests/test_auth_ams.py | 71 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 assemblyai_cli/auth/ams.py create mode 100644 tests/test_auth_ams.py diff --git a/assemblyai_cli/auth/ams.py b/assemblyai_cli/auth/ams.py new file mode 100644 index 00000000..5168f950 --- /dev/null +++ b/assemblyai_cli/auth/ams.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any, cast + +import httpx + +from assemblyai_cli.auth import endpoints +from assemblyai_cli.errors import APIError, NotAuthenticated + +_TIMEOUT = 30.0 + + +def _detail(resp: httpx.Response) -> str: + try: + body = resp.json() + if isinstance(body, dict) and "detail" in body: + return str(body["detail"]) + except Exception: # noqa: BLE001,S110 - non-JSON error body; pass is intentional + pass + return resp.text or f"HTTP {resp.status_code}" + + +def _json_or_raise(resp: httpx.Response) -> Any: + if resp.status_code in (401, 403): + raise NotAuthenticated(f"AMS rejected the login ({resp.status_code}): {_detail(resp)}") + if resp.status_code >= 400: + raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}") + return resp.json() + + +def discover(token: str) -> dict[str, Any]: + """POST /v2/auth/discover with a discovery_oauth token -> {orgs, email, IST}.""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.post( + "/v2/auth/discover", + json={"token": token, "token_type": "discovery_oauth"}, + ) + return cast(dict[str, Any], _json_or_raise(resp)) + + +def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]: + """POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}.""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.post( + "/v2/auth/exchange", + json={ + "intermediate_session_token": intermediate_session_token, + "organization_id": organization_id, + }, + ) + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_auth(session_jwt: str) -> dict[str, Any]: + """GET /v1/auth (session cookie) -> account incl. `id`.""" + with httpx.Client( + base_url=endpoints.AMS_BASE_URL, + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get("/v1/auth") + return cast(dict[str, Any], _json_or_raise(resp)) + + +def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]: + """GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}].""" + with httpx.Client( + base_url=endpoints.AMS_BASE_URL, + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get(f"/v1/users/accounts/{account_id}/projects") + return cast(list[dict[str, Any]], _json_or_raise(resp)) + + +def create_token( + account_id: int, project_id: int, token_name: str, session_jwt: str +) -> dict[str, Any]: + """POST /v1/users/accounts/{id}/tokens -> TokenSchema incl. `api_key`.""" + with httpx.Client( + base_url=endpoints.AMS_BASE_URL, + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.post( + f"/v1/users/accounts/{account_id}/tokens", + json={"project_id": project_id, "token_name": token_name}, + ) + return cast(dict[str, Any], _json_or_raise(resp)) diff --git a/tests/test_auth_ams.py b/tests/test_auth_ams.py new file mode 100644 index 00000000..9a5c255c --- /dev/null +++ b/tests/test_auth_ams.py @@ -0,0 +1,71 @@ +import httpx +import pytest + +from assemblyai_cli.auth import ams +from assemblyai_cli.errors import APIError, NotAuthenticated + + +def _patch_transport(monkeypatch, handler): + real_client = httpx.Client + + def fake_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + monkeypatch.setattr(ams.httpx, "Client", fake_client) + + +def test_discover_posts_token_and_type(monkeypatch): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["body"] = request.read().decode() + return httpx.Response( + 200, + json={ + "organizations": [{"organization_id": "org_1", "organization_name": "Acme"}], + "email": "a@b.com", + "intermediate_session_token": "ist_123", + }, + ) + + _patch_transport(monkeypatch, handler) + out = ams.discover("tok_abc") + assert out["intermediate_session_token"] == "ist_123" + assert "/v2/auth/discover" in seen["url"] + assert "discovery_oauth" in seen["body"] + assert "tok_abc" in seen["body"] + + +def test_get_auth_sends_session_cookie(monkeypatch): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["cookie"] = request.headers.get("cookie", "") + return httpx.Response(200, json={"id": 42, "email": "a@b.com"}) + + _patch_transport(monkeypatch, handler) + out = ams.get_auth("sess_jwt_xyz") + assert out["id"] == 42 + assert "stytch_session_jwt=sess_jwt_xyz" in seen["cookie"] + + +@pytest.mark.parametrize("status", [401, 403]) +def test_auth_4xx_raises_not_authenticated(monkeypatch, status): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, json={"detail": "Invalid credentials"}) + + _patch_transport(monkeypatch, handler) + with pytest.raises(NotAuthenticated): + ams.discover("bad") + + +def test_500_raises_api_error_with_detail(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"detail": "Something went wrong"}) + + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError) as exc: + ams.discover("x") + assert "Something went wrong" in str(exc.value) From 9726f3a54129fe442b1485013ce8d16ff4d16b12 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 12:09:01 -0700 Subject: [PATCH 05/15] chore(auth): satisfy ruff S/BLE lints in auth tests Co-Authored-By: Claude Sonnet 4.6 --- assemblyai_cli/auth/endpoints.py | 2 +- assemblyai_cli/auth/loopback.py | 2 +- pyproject.toml | 2 +- tests/test_auth_loopback.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/assemblyai_cli/auth/endpoints.py b/assemblyai_cli/auth/endpoints.py index 5220a557..0444c838 100644 --- a/assemblyai_cli/auth/endpoints.py +++ b/assemblyai_cli/auth/endpoints.py @@ -23,7 +23,7 @@ "AAI_AUTH_AMS_URL", "https://ams.sandbox000.assemblyai-labs.com" ) -CLI_TOKEN_NAME = "AssemblyAI CLI" +CLI_TOKEN_NAME = "AssemblyAI CLI" # noqa: S105 - display name, not a credential def redirect_uri() -> str: diff --git a/assemblyai_cli/auth/loopback.py b/assemblyai_cli/auth/loopback.py index e2e79a70..0fa58c1e 100644 --- a/assemblyai_cli/auth/loopback.py +++ b/assemblyai_cli/auth/loopback.py @@ -30,7 +30,7 @@ def capture_callback(timeout: float = 120.0) -> CallbackResult: done = threading.Event() class Handler(BaseHTTPRequestHandler): - def do_GET(self) -> None: # noqa: N802 - stdlib API name + def do_GET(self) -> None: # stdlib API name parsed = urlparse(self.path) if parsed.path != endpoints.LOOPBACK_PATH: self.send_response(404) diff --git a/pyproject.toml b/pyproject.toml index a53048f0..3fa4624b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,4 +97,4 @@ ignore = ["E501", "B008", "S603", "S607"] [tool.ruff.lint.per-file-ignores] # Tests assert freely, use throwaway args/temp paths, and don't need pathlib/security lints. -"tests/**" = ["S101", "S108", "ARG001", "ARG002", "ARG005", "PTH123", "SIM117"] +"tests/**" = ["S101", "S105", "S106", "S108", "ARG001", "ARG002", "ARG005", "PTH123", "SIM117"] diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index bb0c3996..1c2670e1 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -10,9 +10,9 @@ def _hit(path: str) -> None: # Retry briefly until the server thread is bound. for _ in range(50): try: - urllib.request.urlopen(url, timeout=2).read() + urllib.request.urlopen(url, timeout=2).read() # noqa: S310 - fixed localhost URL return - except Exception: + except OSError: time.sleep(0.05) From 66c121cb2a24b4d8a8ef4624f28db64afcbc9911 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 12:11:25 -0700 Subject: [PATCH 06/15] feat(auth): orchestrate browser+AMS login into an API key Co-Authored-By: Claude Sonnet 4.6 --- assemblyai_cli/auth/__init__.py | 5 ++ assemblyai_cli/auth/flow.py | 63 ++++++++++++++++++++ tests/test_auth_flow.py | 102 ++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 assemblyai_cli/auth/flow.py create mode 100644 tests/test_auth_flow.py diff --git a/assemblyai_cli/auth/__init__.py b/assemblyai_cli/auth/__init__.py index e69de29b..2e609007 100644 --- a/assemblyai_cli/auth/__init__.py +++ b/assemblyai_cli/auth/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from assemblyai_cli.auth.flow import run_login_flow + +__all__ = ["run_login_flow"] diff --git a/assemblyai_cli/auth/flow.py b/assemblyai_cli/auth/flow.py new file mode 100644 index 00000000..fba3f7cb --- /dev/null +++ b/assemblyai_cli/auth/flow.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import webbrowser + +from assemblyai_cli import output +from assemblyai_cli.auth import ams, discovery, endpoints, loopback +from assemblyai_cli.errors import APIError + + +def _open_browser(url: str) -> None: + """Open the system browser, falling back to printing the URL.""" + output.console.print(f"Opening your browser to sign in:\n {url}") + try: + webbrowser.open(url) + except Exception: # noqa: BLE001 - opening a browser is best-effort + output.console.print( + "[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]" + ) + + +def _capture() -> loopback.CallbackResult: + return loopback.capture_callback() + + +def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: + """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" + projects = ams.list_projects(account_id, session_jwt) + if not projects: + raise APIError("Your account has no project to create an API key in.") + for entry in projects: + for token in entry.get("tokens", []): + if token.get("name") == endpoints.CLI_TOKEN_NAME and not token.get("is_disabled"): + return str(token["api_key"]) + project_id = projects[0]["project"]["id"] + created = ams.create_token( + account_id, project_id, endpoints.CLI_TOKEN_NAME, session_jwt + ) + return str(created["api_key"]) + + +def run_login_flow() -> str: + """Drive the full browser + AMS login and return a usable AssemblyAI API key.""" + _open_browser(discovery.build_start_url()) + result = _capture() + + if result.error == "timeout": + raise APIError("Login timed out waiting for the browser. Run 'aai login' again.") + if result.token_type != "discovery_oauth" or not result.token: # noqa: S105 + raise APIError("Login did not return a valid OAuth token. Run 'aai login' again.") + + disc = ams.discover(result.token) + organizations = disc.get("organizations") or [] + if not organizations: + raise APIError( + "Signed in, but found no AssemblyAI organization for this account." + ) + organization_id = organizations[0]["organization_id"] + + signed_in = ams.exchange(disc["intermediate_session_token"], organization_id) + session_jwt = signed_in["session_jwt"] + + account = ams.get_auth(session_jwt) + return find_or_create_cli_key(int(account["id"]), session_jwt) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py new file mode 100644 index 00000000..ce0382ba --- /dev/null +++ b/tests/test_auth_flow.py @@ -0,0 +1,102 @@ +import pytest + +from assemblyai_cli.auth import flow, loopback +from assemblyai_cli.errors import APIError + + +def test_find_or_create_reuses_existing_cli_key(monkeypatch): + projects = [ + { + "project": {"id": 7}, + "tokens": [ + {"name": "Default Token", "api_key": "sk_default", "is_disabled": False}, + {"name": "AssemblyAI CLI", "api_key": "sk_cli", "is_disabled": False}, + ], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr( + flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create") + ) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" + + +def test_find_or_create_creates_when_absent(monkeypatch): + projects = [{"project": {"id": 7}, "tokens": []}] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + + created = {} + + def fake_create(account_id, project_id, token_name, session_jwt): + created.update(project_id=project_id, token_name=token_name) + return {"api_key": "sk_new"} + + monkeypatch.setattr(flow.ams, "create_token", fake_create) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_new" + assert created == {"project_id": 7, "token_name": "AssemblyAI CLI"} + + +def test_find_or_create_creates_when_existing_cli_token_disabled(monkeypatch): + projects = [ + { + "project": {"id": 5}, + "tokens": [ + {"name": "AssemblyAI CLI", "api_key": "sk_old", "is_disabled": True} + ], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: {"api_key": "sk_fresh"}) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_fresh" + + +def test_run_login_flow_happy_path(monkeypatch): + opened = {} + monkeypatch.setattr(flow, "_open_browser", lambda url: opened.setdefault("url", url)) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [{"organization_id": "org_1", "organization_name": "Acme"}], + "email": "a@b.com", + "intermediate_session_token": "ist", + }, + ) + monkeypatch.setattr( + flow.ams, + "exchange", + lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt", "session_token": "t"}, + ) + monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 9}) + monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final") + + assert flow.run_login_flow() == "sk_final" + assert opened["url"].startswith("https://") + + +def test_run_login_flow_timeout_raises(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr(flow, "_capture", lambda: loopback.CallbackResult(error="timeout")) + with pytest.raises(APIError, match="timed out"): + flow.run_login_flow() + + +def test_run_login_flow_zero_orgs_raises(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: {"organizations": [], "email": "a@b.com", "intermediate_session_token": "ist"}, + ) + with pytest.raises(APIError, match="no AssemblyAI organization"): + flow.run_login_flow() From 142fde6dbea5ffe92fe79e1b6893e1315ba323ad Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 12:18:06 -0700 Subject: [PATCH 07/15] feat(auth): aai login uses Stytch OAuth, keeps --api-key escape hatch --- assemblyai_cli/commands/login.py | 28 ++++++----------- tests/test_login.py | 53 +++++++++++++++++++------------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/assemblyai_cli/commands/login.py b/assemblyai_cli/commands/login.py index 3643461d..45a203e7 100644 --- a/assemblyai_cli/commands/login.py +++ b/assemblyai_cli/commands/login.py @@ -1,18 +1,15 @@ from __future__ import annotations -import webbrowser - import typer from rich.markup import escape from assemblyai_cli import client, config, output +from assemblyai_cli.auth import run_login_flow from assemblyai_cli.context import AppState, resolve_profile, run_command from assemblyai_cli.errors import APIError, NotAuthenticated app = typer.Typer() -DASHBOARD_KEYS_URL = "https://www.assemblyai.com/dashboard/api-keys" - @app.command() def login( @@ -20,24 +17,17 @@ def login( api_key: str = typer.Option(None, "--api-key", help="Provide key non-interactively."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Authenticate by storing an API key (browser-assisted on a terminal).""" + """Authenticate via your browser (Stytch); stores a CLI API key.""" def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) - key = api_key - if not key: - output.console.print( - f"Opening the AssemblyAI dashboard to get your API key:\n {DASHBOARD_KEYS_URL}" - ) - try: - webbrowser.open(DASHBOARD_KEYS_URL) - except Exception: # noqa: BLE001 - opening a browser is best-effort - output.console.print( - "[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]" - ) - key = typer.prompt("Paste your API key", hide_input=True) - if not client.validate_key(key): - raise APIError("That API key was rejected (HTTP 401). Check it and retry.") + if api_key: + # Non-interactive escape hatch for CI/automation. + if not client.validate_key(api_key): + raise APIError("That API key was rejected (HTTP 401). Check it and retry.") + key = api_key + else: + key = run_login_flow() config.set_api_key(profile, key) output.emit( {"authenticated": True, "profile": profile}, diff --git a/tests/test_login.py b/tests/test_login.py index b1c6cfa6..6b793cda 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -22,27 +22,6 @@ def test_login_rejects_invalid_key(): assert config.get_api_key("default") is None -def test_login_interactive_prompts_when_no_flag(monkeypatch): - monkeypatch.setattr("assemblyai_cli.commands.login.webbrowser.open", lambda url: True) - monkeypatch.setattr("assemblyai_cli.commands.login.typer.prompt", lambda *a, **k: "sk_prompted") - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["login"]) - assert result.exit_code == 0 - assert config.get_api_key("default") == "sk_prompted" - - -def test_login_interactive_survives_browser_failure(monkeypatch): - def boom(_url): - raise RuntimeError("no display") - - monkeypatch.setattr("assemblyai_cli.commands.login.webbrowser.open", boom) - monkeypatch.setattr("assemblyai_cli.commands.login.typer.prompt", lambda *a, **k: "sk_typed") - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): - result = runner.invoke(app, ["login"]) - assert result.exit_code == 0 - assert config.get_api_key("default") == "sk_typed" - - def test_login_stores_under_named_profile(): with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["--profile", "staging", "login", "--api-key", "sk_s"]) @@ -73,3 +52,35 @@ def test_logout_clears_key(): result = runner.invoke(app, ["logout"]) assert result.exit_code == 0 assert config.get_api_key("default") is None + + +def test_login_oauth_flow_stores_returned_key(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" + ) + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_from_oauth" + + +def test_login_oauth_flow_failure_exits_nonzero(monkeypatch): + from assemblyai_cli.errors import APIError + + def boom(): + raise APIError("Login timed out waiting for the browser.") + + monkeypatch.setattr("assemblyai_cli.commands.login.run_login_flow", boom) + result = runner.invoke(app, ["login"]) + assert result.exit_code != 0 + assert config.get_api_key("default") is None + + +def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.login.run_login_flow", + lambda: (_ for _ in ()).throw(AssertionError("OAuth must not run with --api-key")), + ) + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["login", "--api-key", "sk_flag2"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_flag2" From 11c7929d131925cfb3a85a0d95cb3f5c5d566076 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 12:52:53 -0700 Subject: [PATCH 08/15] chore(packaging): rename distribution to aai-cli to avoid PyPI collision The PyPI name "assemblyai-cli" is already taken by a third party, which blocks the clean `uv tool install` / `pipx install` / Homebrew path. Rename the distribution to the available "aai-cli". The `aai` command and the `assemblyai_cli` import package are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3fa4624b..6722aff6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "assemblyai-cli" +name = "aai-cli" version = "0.1.0" description = "Command-line interface for AssemblyAI" readme = "README.md" From 608272e2bbe11da06ea0bcf20c786fbb72f19965 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 13:24:20 -0700 Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20rename=20package=20assemblyai?= =?UTF-8?q?=5Fcli=20=E2=86=92=20aai=5Fcli?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the new distribution name (aai-cli). Renames the package directory, updates all imports, the `aai` console-script entry point, hatchling wheel packages, mypy files, scripts, and the uv.lock root package name. No behavior change; the keyring service name ("assemblyai-cli") is intentionally preserved to keep already-stored credentials readable. Co-Authored-By: Claude Opus 4.8 (1M context) --- {assemblyai_cli => aai_cli}/__init__.py | 0 {assemblyai_cli => aai_cli}/__main__.py | 2 +- {assemblyai_cli => aai_cli}/agent/__init__.py | 0 {assemblyai_cli => aai_cli}/agent/audio.py | 6 +- {assemblyai_cli => aai_cli}/agent/render.py | 2 +- {assemblyai_cli => aai_cli}/agent/session.py | 2 +- {assemblyai_cli => aai_cli}/agent/voices.py | 0 {assemblyai_cli => aai_cli}/auth/__init__.py | 2 +- {assemblyai_cli => aai_cli}/auth/ams.py | 4 +- {assemblyai_cli => aai_cli}/auth/discovery.py | 2 +- {assemblyai_cli => aai_cli}/auth/endpoints.py | 0 {assemblyai_cli => aai_cli}/auth/flow.py | 6 +- {assemblyai_cli => aai_cli}/auth/loopback.py | 2 +- {assemblyai_cli => aai_cli}/client.py | 4 +- .../code_gen/__init__.py | 6 +- {assemblyai_cli => aai_cli}/code_gen/agent.py | 0 .../code_gen/serialize.py | 0 .../code_gen/snippets.py | 0 .../code_gen/stream.py | 4 +- .../code_gen/transcribe.py | 4 +- .../commands/__init__.py | 0 {assemblyai_cli => aai_cli}/commands/agent.py | 16 ++-- .../commands/claude.py | 6 +- .../commands/doctor.py | 6 +- {assemblyai_cli => aai_cli}/commands/llm.py | 10 +-- {assemblyai_cli => aai_cli}/commands/login.py | 8 +- .../commands/samples.py | 12 +-- .../commands/stream.py | 14 ++-- .../commands/transcribe.py | 6 +- .../commands/transcripts.py | 6 +- {assemblyai_cli => aai_cli}/config.py | 6 +- {assemblyai_cli => aai_cli}/config_builder.py | 2 +- {assemblyai_cli => aai_cli}/context.py | 4 +- {assemblyai_cli => aai_cli}/errors.py | 0 {assemblyai_cli => aai_cli}/follow.py | 2 +- {assemblyai_cli => aai_cli}/llm.py | 2 +- {assemblyai_cli => aai_cli}/main.py | 6 +- {assemblyai_cli => aai_cli}/microphone.py | 2 +- {assemblyai_cli => aai_cli}/output.py | 6 +- {assemblyai_cli => aai_cli}/render.py | 2 +- {assemblyai_cli => aai_cli}/stdio.py | 0 .../streaming/__init__.py | 0 .../streaming/render.py | 2 +- .../streaming/sources.py | 4 +- {assemblyai_cli => aai_cli}/theme.py | 0 .../transcribe_render.py | 2 +- {assemblyai_cli => aai_cli}/youtube.py | 2 +- pyproject.toml | 6 +- scripts/check.sh | 4 +- tests/conftest.py | 2 +- tests/e2e/test_cli_e2e.py | 4 +- tests/test_agent_audio.py | 6 +- tests/test_agent_command.py | 56 +++++++------- tests/test_agent_render.py | 4 +- tests/test_agent_session.py | 6 +- tests/test_agent_voices.py | 2 +- tests/test_auth_ams.py | 4 +- tests/test_auth_discovery.py | 2 +- tests/test_auth_endpoints.py | 2 +- tests/test_auth_flow.py | 4 +- tests/test_auth_loopback.py | 2 +- tests/test_claude.py | 52 ++++++------- tests/test_claude_render.py | 4 +- tests/test_client.py | 28 +++---- tests/test_code_gen.py | 10 +-- tests/test_config.py | 8 +- tests/test_config_builder.py | 12 +-- tests/test_context.py | 4 +- tests/test_doctor.py | 26 +++---- tests/test_errors.py | 2 +- tests/test_llm.py | 4 +- tests/test_llm_command.py | 36 ++++----- tests/test_login.py | 22 +++--- tests/test_main_module.py | 10 +-- tests/test_microphone.py | 4 +- tests/test_output.py | 4 +- tests/test_properties.py | 8 +- tests/test_samples.py | 2 +- tests/test_smoke.py | 4 +- tests/test_stdio.py | 2 +- tests/test_stream_command.py | 76 +++++++++---------- tests/test_streaming_render.py | 4 +- tests/test_streaming_sources.py | 8 +- tests/test_theme.py | 6 +- tests/test_transcribe.py | 64 ++++++++-------- tests/test_transcribe_render.py | 4 +- tests/test_transcripts.py | 26 +++---- tests/test_youtube.py | 4 +- uv.lock | 2 +- 89 files changed, 356 insertions(+), 356 deletions(-) rename {assemblyai_cli => aai_cli}/__init__.py (100%) rename {assemblyai_cli => aai_cli}/__main__.py (51%) rename {assemblyai_cli => aai_cli}/agent/__init__.py (100%) rename {assemblyai_cli => aai_cli}/agent/audio.py (97%) rename {assemblyai_cli => aai_cli}/agent/render.py (98%) rename {assemblyai_cli => aai_cli}/agent/session.py (99%) rename {assemblyai_cli => aai_cli}/agent/voices.py (100%) rename {assemblyai_cli => aai_cli}/auth/__init__.py (55%) rename {assemblyai_cli => aai_cli}/auth/ams.py (96%) rename {assemblyai_cli => aai_cli}/auth/discovery.py (95%) rename {assemblyai_cli => aai_cli}/auth/endpoints.py (100%) rename {assemblyai_cli => aai_cli}/auth/flow.py (94%) rename {assemblyai_cli => aai_cli}/auth/loopback.py (97%) rename {assemblyai_cli => aai_cli}/client.py (98%) rename {assemblyai_cli => aai_cli}/code_gen/__init__.py (85%) rename {assemblyai_cli => aai_cli}/code_gen/agent.py (100%) rename {assemblyai_cli => aai_cli}/code_gen/serialize.py (100%) rename {assemblyai_cli => aai_cli}/code_gen/snippets.py (100%) rename {assemblyai_cli => aai_cli}/code_gen/stream.py (97%) rename {assemblyai_cli => aai_cli}/code_gen/transcribe.py (97%) rename {assemblyai_cli => aai_cli}/commands/__init__.py (100%) rename {assemblyai_cli => aai_cli}/commands/agent.py (90%) rename {assemblyai_cli => aai_cli}/commands/claude.py (98%) rename {assemblyai_cli => aai_cli}/commands/doctor.py (97%) rename {assemblyai_cli => aai_cli}/commands/llm.py (95%) rename {assemblyai_cli => aai_cli}/commands/login.py (91%) rename {assemblyai_cli => aai_cli}/commands/samples.py (89%) rename {assemblyai_cli => aai_cli}/commands/stream.py (96%) rename {assemblyai_cli => aai_cli}/commands/transcribe.py (98%) rename {assemblyai_cli => aai_cli}/commands/transcripts.py (94%) rename {assemblyai_cli => aai_cli}/config.py (94%) rename {assemblyai_cli => aai_cli}/config_builder.py (99%) rename {assemblyai_cli => aai_cli}/context.py (90%) rename {assemblyai_cli => aai_cli}/errors.py (100%) rename {assemblyai_cli => aai_cli}/follow.py (98%) rename {assemblyai_cli => aai_cli}/llm.py (98%) rename {assemblyai_cli => aai_cli}/main.py (95%) rename {assemblyai_cli => aai_cli}/microphone.py (99%) rename {assemblyai_cli => aai_cli}/output.py (95%) rename {assemblyai_cli => aai_cli}/render.py (99%) rename {assemblyai_cli => aai_cli}/stdio.py (100%) rename {assemblyai_cli => aai_cli}/streaming/__init__.py (100%) rename {assemblyai_cli => aai_cli}/streaming/render.py (97%) rename {assemblyai_cli => aai_cli}/streaming/sources.py (97%) rename {assemblyai_cli => aai_cli}/theme.py (100%) rename {assemblyai_cli => aai_cli}/transcribe_render.py (99%) rename {assemblyai_cli => aai_cli}/youtube.py (97%) diff --git a/assemblyai_cli/__init__.py b/aai_cli/__init__.py similarity index 100% rename from assemblyai_cli/__init__.py rename to aai_cli/__init__.py diff --git a/assemblyai_cli/__main__.py b/aai_cli/__main__.py similarity index 51% rename from assemblyai_cli/__main__.py rename to aai_cli/__main__.py index 40342726..06f81719 100644 --- a/assemblyai_cli/__main__.py +++ b/aai_cli/__main__.py @@ -1,4 +1,4 @@ -from assemblyai_cli.main import run +from aai_cli.main import run if __name__ == "__main__": run() diff --git a/assemblyai_cli/agent/__init__.py b/aai_cli/agent/__init__.py similarity index 100% rename from assemblyai_cli/agent/__init__.py rename to aai_cli/agent/__init__.py diff --git a/assemblyai_cli/agent/audio.py b/aai_cli/agent/audio.py similarity index 97% rename from assemblyai_cli/agent/audio.py rename to aai_cli/agent/audio.py index 5b4bb781..684bc28b 100644 --- a/assemblyai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -6,8 +6,8 @@ from collections.abc import Callable, Iterator from typing import Any -from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import _default_rate, _resample, audio_missing_error +from aai_cli.errors import CLIError +from aai_cli.microphone import _default_rate, _resample, audio_missing_error SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate @@ -291,5 +291,5 @@ def __iter__(self) -> Iterator[bytes]: return self._duplex.capture_frames() -# Microphone capture (MicrophoneSource) lives in assemblyai_cli.microphone and is +# Microphone capture (MicrophoneSource) lives in aai_cli.microphone and is # shared with `aai stream`; the agent's live mic+speaker run through DuplexAudio. diff --git a/assemblyai_cli/agent/render.py b/aai_cli/agent/render.py similarity index 98% rename from assemblyai_cli/agent/render.py rename to aai_cli/agent/render.py index 216be082..e10c4351 100644 --- a/assemblyai_cli/agent/render.py +++ b/aai_cli/agent/render.py @@ -4,7 +4,7 @@ from rich.text import Text -from assemblyai_cli.render import BaseRenderer +from aai_cli.render import BaseRenderer def _labeled(label: str, body: str, *, style: str = "aai.label") -> Text: diff --git a/assemblyai_cli/agent/session.py b/aai_cli/agent/session.py similarity index 99% rename from assemblyai_cli/agent/session.py rename to aai_cli/agent/session.py index c9783450..a603ca9a 100644 --- a/assemblyai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -6,7 +6,7 @@ import threading from typing import Any -from assemblyai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure +from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure WS_URL = "wss://agents.assemblyai.com/v1/ws" diff --git a/assemblyai_cli/agent/voices.py b/aai_cli/agent/voices.py similarity index 100% rename from assemblyai_cli/agent/voices.py rename to aai_cli/agent/voices.py diff --git a/assemblyai_cli/auth/__init__.py b/aai_cli/auth/__init__.py similarity index 55% rename from assemblyai_cli/auth/__init__.py rename to aai_cli/auth/__init__.py index 2e609007..d89a864d 100644 --- a/assemblyai_cli/auth/__init__.py +++ b/aai_cli/auth/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from assemblyai_cli.auth.flow import run_login_flow +from aai_cli.auth.flow import run_login_flow __all__ = ["run_login_flow"] diff --git a/assemblyai_cli/auth/ams.py b/aai_cli/auth/ams.py similarity index 96% rename from assemblyai_cli/auth/ams.py rename to aai_cli/auth/ams.py index 5168f950..338b84a3 100644 --- a/assemblyai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -4,8 +4,8 @@ import httpx -from assemblyai_cli.auth import endpoints -from assemblyai_cli.errors import APIError, NotAuthenticated +from aai_cli.auth import endpoints +from aai_cli.errors import APIError, NotAuthenticated _TIMEOUT = 30.0 diff --git a/assemblyai_cli/auth/discovery.py b/aai_cli/auth/discovery.py similarity index 95% rename from assemblyai_cli/auth/discovery.py rename to aai_cli/auth/discovery.py index e669267a..14201652 100644 --- a/assemblyai_cli/auth/discovery.py +++ b/aai_cli/auth/discovery.py @@ -2,7 +2,7 @@ from urllib.parse import urlencode -from assemblyai_cli.auth import endpoints +from aai_cli.auth import endpoints def build_start_url() -> str: diff --git a/assemblyai_cli/auth/endpoints.py b/aai_cli/auth/endpoints.py similarity index 100% rename from assemblyai_cli/auth/endpoints.py rename to aai_cli/auth/endpoints.py diff --git a/assemblyai_cli/auth/flow.py b/aai_cli/auth/flow.py similarity index 94% rename from assemblyai_cli/auth/flow.py rename to aai_cli/auth/flow.py index fba3f7cb..3a44f7d2 100644 --- a/assemblyai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -2,9 +2,9 @@ import webbrowser -from assemblyai_cli import output -from assemblyai_cli.auth import ams, discovery, endpoints, loopback -from assemblyai_cli.errors import APIError +from aai_cli import output +from aai_cli.auth import ams, discovery, endpoints, loopback +from aai_cli.errors import APIError def _open_browser(url: str) -> None: diff --git a/assemblyai_cli/auth/loopback.py b/aai_cli/auth/loopback.py similarity index 97% rename from assemblyai_cli/auth/loopback.py rename to aai_cli/auth/loopback.py index 0fa58c1e..276f5a60 100644 --- a/assemblyai_cli/auth/loopback.py +++ b/aai_cli/auth/loopback.py @@ -5,7 +5,7 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import parse_qs, urlparse -from assemblyai_cli.auth import endpoints +from aai_cli.auth import endpoints _SUCCESS_HTML = ( b"" diff --git a/assemblyai_cli/client.py b/aai_cli/client.py similarity index 98% rename from assemblyai_cli/client.py rename to aai_cli/client.py index c768613b..fe7cc0a4 100644 --- a/assemblyai_cli/client.py +++ b/aai_cli/client.py @@ -12,8 +12,8 @@ StreamingParameters, ) -from assemblyai_cli import stdio -from assemblyai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure +from aai_cli import stdio +from aai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" diff --git a/assemblyai_cli/code_gen/__init__.py b/aai_cli/code_gen/__init__.py similarity index 85% rename from assemblyai_cli/code_gen/__init__.py rename to aai_cli/code_gen/__init__.py index 4f918fff..96505f9b 100644 --- a/assemblyai_cli/code_gen/__init__.py +++ b/aai_cli/code_gen/__init__.py @@ -1,8 +1,8 @@ from __future__ import annotations -from assemblyai_cli.code_gen import agent as _agent -from assemblyai_cli.code_gen import stream as _stream -from assemblyai_cli.code_gen import transcribe as _transcribe +from aai_cli.code_gen import agent as _agent +from aai_cli.code_gen import stream as _stream +from aai_cli.code_gen import transcribe as _transcribe def agent(voice: str, system_prompt: str, greeting: str) -> str: diff --git a/assemblyai_cli/code_gen/agent.py b/aai_cli/code_gen/agent.py similarity index 100% rename from assemblyai_cli/code_gen/agent.py rename to aai_cli/code_gen/agent.py diff --git a/assemblyai_cli/code_gen/serialize.py b/aai_cli/code_gen/serialize.py similarity index 100% rename from assemblyai_cli/code_gen/serialize.py rename to aai_cli/code_gen/serialize.py diff --git a/assemblyai_cli/code_gen/snippets.py b/aai_cli/code_gen/snippets.py similarity index 100% rename from assemblyai_cli/code_gen/snippets.py rename to aai_cli/code_gen/snippets.py diff --git a/assemblyai_cli/code_gen/stream.py b/aai_cli/code_gen/stream.py similarity index 97% rename from assemblyai_cli/code_gen/stream.py rename to aai_cli/code_gen/stream.py index bab581a2..631f32f3 100644 --- a/assemblyai_cli/code_gen/stream.py +++ b/aai_cli/code_gen/stream.py @@ -2,8 +2,8 @@ from typing import cast -from assemblyai_cli import llm as gateway -from assemblyai_cli.code_gen import serialize +from aai_cli import llm as gateway +from aai_cli.code_gen import serialize # Streaming-class imports always used by the generated scaffold. SpeechModel is added # only when a speech_model kwarg is emitted, so the generated script stays lint-clean. diff --git a/assemblyai_cli/code_gen/transcribe.py b/aai_cli/code_gen/transcribe.py similarity index 97% rename from assemblyai_cli/code_gen/transcribe.py rename to aai_cli/code_gen/transcribe.py index 05eed243..cdb53531 100644 --- a/assemblyai_cli/code_gen/transcribe.py +++ b/aai_cli/code_gen/transcribe.py @@ -2,8 +2,8 @@ from typing import cast -from assemblyai_cli import llm -from assemblyai_cli.code_gen import serialize, snippets +from aai_cli import llm +from aai_cli.code_gen import serialize, snippets def render( diff --git a/assemblyai_cli/commands/__init__.py b/aai_cli/commands/__init__.py similarity index 100% rename from assemblyai_cli/commands/__init__.py rename to aai_cli/commands/__init__.py diff --git a/assemblyai_cli/commands/agent.py b/aai_cli/commands/agent.py similarity index 90% rename from assemblyai_cli/commands/agent.py rename to aai_cli/commands/agent.py index b1e00d80..bf0ed899 100644 --- a/assemblyai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -6,14 +6,14 @@ import typer -from assemblyai_cli import client, code_gen, config, output -from assemblyai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer -from assemblyai_cli.agent.render import AgentRenderer -from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session -from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import CLIError, UsageError -from assemblyai_cli.streaming.sources import FileSource +from aai_cli import client, code_gen, config, output +from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer +from aai_cli.agent.render import AgentRenderer +from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session +from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list +from aai_cli.context import AppState, run_command +from aai_cli.errors import CLIError, UsageError +from aai_cli.streaming.sources import FileSource app = typer.Typer() diff --git a/assemblyai_cli/commands/claude.py b/aai_cli/commands/claude.py similarity index 98% rename from assemblyai_cli/commands/claude.py rename to aai_cli/commands/claude.py index 3e7109ce..ef8ccb09 100644 --- a/assemblyai_cli/commands/claude.py +++ b/aai_cli/commands/claude.py @@ -9,9 +9,9 @@ import typer from rich.markup import escape -from assemblyai_cli import output, theme -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import UsageError +from aai_cli import output, theme +from aai_cli.context import AppState, run_command +from aai_cli.errors import UsageError app = typer.Typer( help="Wire up Claude Code for AssemblyAI (docs MCP + skill).", diff --git a/assemblyai_cli/commands/doctor.py b/aai_cli/commands/doctor.py similarity index 97% rename from assemblyai_cli/commands/doctor.py rename to aai_cli/commands/doctor.py index d2468447..959678cf 100644 --- a/assemblyai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -7,9 +7,9 @@ import typer from rich.markup import escape -from assemblyai_cli import client, config, output -from assemblyai_cli.context import AppState, resolve_profile, run_command -from assemblyai_cli.errors import CLIError, NotAuthenticated +from aai_cli import client, config, output +from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.errors import CLIError, NotAuthenticated app = typer.Typer() diff --git a/assemblyai_cli/commands/llm.py b/aai_cli/commands/llm.py similarity index 95% rename from assemblyai_cli/commands/llm.py rename to aai_cli/commands/llm.py index bff1a5e3..54b44123 100644 --- a/assemblyai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -3,11 +3,11 @@ import typer from rich.markup import escape -from assemblyai_cli import config, output, stdio -from assemblyai_cli import llm as gateway -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import UsageError -from assemblyai_cli.follow import FollowRenderer +from aai_cli import config, output, stdio +from aai_cli import llm as gateway +from aai_cli.context import AppState, run_command +from aai_cli.errors import UsageError +from aai_cli.follow import FollowRenderer app = typer.Typer() diff --git a/assemblyai_cli/commands/login.py b/aai_cli/commands/login.py similarity index 91% rename from assemblyai_cli/commands/login.py rename to aai_cli/commands/login.py index 45a203e7..3d6600c3 100644 --- a/assemblyai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -3,10 +3,10 @@ import typer from rich.markup import escape -from assemblyai_cli import client, config, output -from assemblyai_cli.auth import run_login_flow -from assemblyai_cli.context import AppState, resolve_profile, run_command -from assemblyai_cli.errors import APIError, NotAuthenticated +from aai_cli import client, config, output +from aai_cli.auth import run_login_flow +from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.errors import APIError, NotAuthenticated app = typer.Typer() diff --git a/assemblyai_cli/commands/samples.py b/aai_cli/commands/samples.py similarity index 89% rename from assemblyai_cli/commands/samples.py rename to aai_cli/commands/samples.py index d40185e8..8b4bb876 100644 --- a/assemblyai_cli/commands/samples.py +++ b/aai_cli/commands/samples.py @@ -6,12 +6,12 @@ from assemblyai.streaming.v3 import SpeechModel from rich.markup import escape -from assemblyai_cli import client, code_gen, output -from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT -from assemblyai_cli.agent.voices import DEFAULT_VOICE -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import CLIError -from assemblyai_cli.streaming.sources import TARGET_RATE +from aai_cli import client, code_gen, output +from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT +from aai_cli.agent.voices import DEFAULT_VOICE +from aai_cli.context import AppState, run_command +from aai_cli.errors import CLIError +from aai_cli.streaming.sources import TARGET_RATE app = typer.Typer( help="Scaffold runnable AssemblyAI starter scripts.", diff --git a/assemblyai_cli/commands/stream.py b/aai_cli/commands/stream.py similarity index 96% rename from assemblyai_cli/commands/stream.py rename to aai_cli/commands/stream.py index 328e2e14..3786b810 100644 --- a/assemblyai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -6,13 +6,13 @@ import typer from assemblyai.streaming.v3 import SpeechModel -from assemblyai_cli import client, code_gen, config, config_builder, llm, output, youtube -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import UsageError -from assemblyai_cli.follow import FollowRenderer -from assemblyai_cli.microphone import MicrophoneSource -from assemblyai_cli.streaming.render import StreamRenderer -from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource +from aai_cli import client, code_gen, config, config_builder, llm, output, youtube +from aai_cli.context import AppState, run_command +from aai_cli.errors import UsageError +from aai_cli.follow import FollowRenderer +from aai_cli.microphone import MicrophoneSource +from aai_cli.streaming.render import StreamRenderer +from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource app = typer.Typer() diff --git a/assemblyai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py similarity index 98% rename from assemblyai_cli/commands/transcribe.py rename to aai_cli/commands/transcribe.py index a6979d80..f8054061 100644 --- a/assemblyai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -5,7 +5,7 @@ import typer -from assemblyai_cli import ( +from aai_cli import ( client, code_gen, config, @@ -16,8 +16,8 @@ transcribe_render, youtube, ) -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import UsageError +from aai_cli.context import AppState, run_command +from aai_cli.errors import UsageError app = typer.Typer() diff --git a/assemblyai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py similarity index 94% rename from assemblyai_cli/commands/transcripts.py rename to aai_cli/commands/transcripts.py index b0e550a7..0448a8b4 100644 --- a/assemblyai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -5,9 +5,9 @@ from rich.table import Table from rich.text import Text -from assemblyai_cli import client, config, output, theme -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import APIError +from aai_cli import client, config, output, theme +from aai_cli.context import AppState, run_command +from aai_cli.errors import APIError app = typer.Typer(help="Browse and fetch past transcripts.", no_args_is_help=True) diff --git a/assemblyai_cli/config.py b/aai_cli/config.py similarity index 94% rename from assemblyai_cli/config.py rename to aai_cli/config.py index 0f1bda1c..a84d4deb 100644 --- a/assemblyai_cli/config.py +++ b/aai_cli/config.py @@ -12,7 +12,7 @@ import tomli_w import tomllib -from assemblyai_cli.errors import NotAuthenticated +from aai_cli.errors import NotAuthenticated KEYRING_SERVICE = "assemblyai-cli" ENV_API_KEY = "ASSEMBLYAI_API_KEY" @@ -23,7 +23,7 @@ def _validate_profile(name: str) -> None: if not _PROFILE_RE.match(name): - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError raise CLIError( f"Invalid profile name {name!r}: use letters, digits, '-' or '_' only.", @@ -89,7 +89,7 @@ def clear_api_key(profile: str) -> None: def resolve_api_key(*, profile: str | None = None, api_key_flag: str | None = None) -> str: if api_key_flag is not None: if not api_key_flag: - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError raise CLIError("Empty --api-key provided.", error_type="invalid_key", exit_code=2) return api_key_flag diff --git a/assemblyai_cli/config_builder.py b/aai_cli/config_builder.py similarity index 99% rename from assemblyai_cli/config_builder.py rename to aai_cli/config_builder.py index 257f197e..00be01f8 100644 --- a/assemblyai_cli/config_builder.py +++ b/aai_cli/config_builder.py @@ -6,7 +6,7 @@ import assemblyai as aai from assemblyai.streaming.v3 import SpeechModel, StreamingParameters -from assemblyai_cli.errors import UsageError +from aai_cli.errors import UsageError # field name -> coercion kind for --config/--config-file string values. # The KEYS are the authoritative set of valid config fields per command. diff --git a/assemblyai_cli/context.py b/aai_cli/context.py similarity index 90% rename from assemblyai_cli/context.py rename to aai_cli/context.py index 2d41ab77..98643c50 100644 --- a/assemblyai_cli/context.py +++ b/aai_cli/context.py @@ -5,8 +5,8 @@ import typer -from assemblyai_cli import config, output -from assemblyai_cli.errors import CLIError +from aai_cli import config, output +from aai_cli.errors import CLIError @dataclass diff --git a/assemblyai_cli/errors.py b/aai_cli/errors.py similarity index 100% rename from assemblyai_cli/errors.py rename to aai_cli/errors.py diff --git a/assemblyai_cli/follow.py b/aai_cli/follow.py similarity index 98% rename from assemblyai_cli/follow.py rename to aai_cli/follow.py index 5eac2586..c843899e 100644 --- a/assemblyai_cli/follow.py +++ b/aai_cli/follow.py @@ -4,7 +4,7 @@ from rich.markup import escape from rich.panel import Panel -from assemblyai_cli import output +from aai_cli import output class FollowRenderer: diff --git a/assemblyai_cli/llm.py b/aai_cli/llm.py similarity index 98% rename from assemblyai_cli/llm.py rename to aai_cli/llm.py index adadede7..48cedfdf 100644 --- a/assemblyai_cli/llm.py +++ b/aai_cli/llm.py @@ -5,7 +5,7 @@ import openai from openai import OpenAI -from assemblyai_cli.errors import APIError, auth_failure +from aai_cli.errors import APIError, auth_failure # The LLM Gateway is OpenAI-compatible, so we talk to it through the OpenAI SDK # pointed at this base URL. (The synchronous gateway has no assemblyai-SDK client.) diff --git a/assemblyai_cli/main.py b/aai_cli/main.py similarity index 95% rename from assemblyai_cli/main.py rename to aai_cli/main.py index 8f11d297..69be892e 100644 --- a/assemblyai_cli/main.py +++ b/aai_cli/main.py @@ -11,8 +11,8 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from assemblyai_cli import __version__, stdio -from assemblyai_cli.commands import ( +from aai_cli import __version__, stdio +from aai_cli.commands import ( agent, claude, doctor, @@ -23,7 +23,7 @@ transcribe, transcripts, ) -from assemblyai_cli.context import AppState +from aai_cli.context import AppState # The order commands appear under `aai --help`: core transcription first, then # voice/LLM, then account, then tooling, with `version` last. Names not listed diff --git a/assemblyai_cli/microphone.py b/aai_cli/microphone.py similarity index 99% rename from assemblyai_cli/microphone.py rename to aai_cli/microphone.py index 99c56a26..f53d350f 100644 --- a/assemblyai_cli/microphone.py +++ b/aai_cli/microphone.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Iterator from typing import Any -from assemblyai_cli.errors import CLIError +from aai_cli.errors import CLIError with warnings.catch_warnings(): # audioop is deprecated stdlib on 3.11/3.12 (warning suppressed here) and is diff --git a/assemblyai_cli/output.py b/aai_cli/output.py similarity index 95% rename from assemblyai_cli/output.py rename to aai_cli/output.py index 2ae8432f..f3656486 100644 --- a/assemblyai_cli/output.py +++ b/aai_cli/output.py @@ -8,11 +8,11 @@ from rich.markup import escape -from assemblyai_cli import theme -from assemblyai_cli.errors import UsageError +from aai_cli import theme +from aai_cli.errors import UsageError if TYPE_CHECKING: - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError T = TypeVar("T") diff --git a/assemblyai_cli/render.py b/aai_cli/render.py similarity index 99% rename from assemblyai_cli/render.py rename to aai_cli/render.py index 1807e609..aff2a21d 100644 --- a/assemblyai_cli/render.py +++ b/aai_cli/render.py @@ -8,7 +8,7 @@ from rich.live import Live from rich.text import Text -from assemblyai_cli import theme +from aai_cli import theme class BaseRenderer: diff --git a/assemblyai_cli/stdio.py b/aai_cli/stdio.py similarity index 100% rename from assemblyai_cli/stdio.py rename to aai_cli/stdio.py diff --git a/assemblyai_cli/streaming/__init__.py b/aai_cli/streaming/__init__.py similarity index 100% rename from assemblyai_cli/streaming/__init__.py rename to aai_cli/streaming/__init__.py diff --git a/assemblyai_cli/streaming/render.py b/aai_cli/streaming/render.py similarity index 97% rename from assemblyai_cli/streaming/render.py rename to aai_cli/streaming/render.py index 590129da..660493d3 100644 --- a/assemblyai_cli/streaming/render.py +++ b/aai_cli/streaming/render.py @@ -2,7 +2,7 @@ from rich.text import Text -from assemblyai_cli.render import BaseRenderer +from aai_cli.render import BaseRenderer class StreamRenderer(BaseRenderer): diff --git a/assemblyai_cli/streaming/sources.py b/aai_cli/streaming/sources.py similarity index 97% rename from assemblyai_cli/streaming/sources.py rename to aai_cli/streaming/sources.py index ae93a4cf..cbbaf62c 100644 --- a/assemblyai_cli/streaming/sources.py +++ b/aai_cli/streaming/sources.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from assemblyai_cli.errors import APIError, CLIError +from aai_cli.errors import APIError, CLIError TARGET_RATE = 16000 CHUNK_BYTES = TARGET_RATE * 2 // 10 # 100 ms of 16-bit mono PCM @@ -157,5 +157,5 @@ def __iter__(self) -> Iterator[bytes]: yield bytes(data) -# MicrophoneSource (mic capture) lives in assemblyai_cli.microphone and is shared +# MicrophoneSource (mic capture) lives in aai_cli.microphone and is shared # with the voice agent; FileSource above is the only streaming-specific source. diff --git a/assemblyai_cli/theme.py b/aai_cli/theme.py similarity index 100% rename from assemblyai_cli/theme.py rename to aai_cli/theme.py diff --git a/assemblyai_cli/transcribe_render.py b/aai_cli/transcribe_render.py similarity index 99% rename from assemblyai_cli/transcribe_render.py rename to aai_cli/transcribe_render.py index 297f63da..0d6b5aa7 100644 --- a/assemblyai_cli/transcribe_render.py +++ b/aai_cli/transcribe_render.py @@ -5,7 +5,7 @@ from rich.console import Console from rich.text import Text -from assemblyai_cli import theme +from aai_cli import theme def _fmt_ms(ms: int) -> str: diff --git a/assemblyai_cli/youtube.py b/aai_cli/youtube.py similarity index 97% rename from assemblyai_cli/youtube.py rename to aai_cli/youtube.py index 5da037a8..4ac1d6c8 100644 --- a/assemblyai_cli/youtube.py +++ b/aai_cli/youtube.py @@ -3,7 +3,7 @@ import re from pathlib import Path -from assemblyai_cli.errors import CLIError +from aai_cli.errors import CLIError # youtube.com/watch, youtu.be/, music.youtube.com, shorts, with or without scheme. _YOUTUBE_RE = re.compile( diff --git a/pyproject.toml b/pyproject.toml index 6722aff6..2a2142a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,10 +58,10 @@ dev = [ ] [project.scripts] -aai = "assemblyai_cli.main:run" +aai = "aai_cli.main:run" [tool.hatch.build.targets.wheel] -packages = ["assemblyai_cli"] +packages = ["aai_cli"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -71,7 +71,7 @@ markers = [ [tool.mypy] python_version = "3.10" -files = ["assemblyai_cli", "tests"] +files = ["aai_cli", "tests"] # Third-party deps (assemblyai, sounddevice) ship no type stubs. ignore_missing_imports = true disallow_untyped_defs = true diff --git a/scripts/check.sh b/scripts/check.sh index 677aa849..d72f11f1 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -15,7 +15,7 @@ echo "==> ruff format --check (src + tests)" uv run ruff format --check . echo "==> mypy (src + tests)" -uv run mypy # files = ["assemblyai_cli", "tests"] in pyproject.toml +uv run mypy # files = ["aai_cli", "tests"] in pyproject.toml echo "==> markdownlint (docs/ is generated, so excluded)" markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache @@ -23,7 +23,7 @@ markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cach echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need # a live API key + kokoro. Run them with: uv run pytest -m e2e -uv run pytest -q -m "not e2e" --cov=assemblyai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +uv run pytest -q -m "not e2e" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 echo "==> build + twine check (PyPI publish readiness)" # Build sdist + wheel into ./dist, then validate the metadata and README render diff --git a/tests/conftest.py b/tests/conftest.py index 58d318fb..c398aeba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,5 +56,5 @@ def memory_keyring(): def tmp_config(monkeypatch, tmp_path): cfg_dir = tmp_path / "config" cfg_dir.mkdir() - monkeypatch.setattr("assemblyai_cli.config.config_dir", lambda: cfg_dir) + monkeypatch.setattr("aai_cli.config.config_dir", lambda: cfg_dir) return cfg_dir diff --git a/tests/e2e/test_cli_e2e.py b/tests/e2e/test_cli_e2e.py index 09bf6488..df82b28d 100644 --- a/tests/e2e/test_cli_e2e.py +++ b/tests/e2e/test_cli_e2e.py @@ -68,11 +68,11 @@ def _synthesize_wav(pipeline: Any, text: str, path: Path, *, lead_silence_s: flo def _run_cli(args: list[str], key: str, *, timeout: int = 120) -> subprocess.CompletedProcess[str]: - """Run `python -m assemblyai_cli ` against the working tree with the real key.""" + """Run `python -m aai_cli ` against the working tree with the real key.""" env = dict(os.environ) env["ASSEMBLYAI_API_KEY"] = key return subprocess.run( - [sys.executable, "-m", "assemblyai_cli", *args], + [sys.executable, "-m", "aai_cli", *args], capture_output=True, text=True, timeout=timeout, diff --git a/tests/test_agent_audio.py b/tests/test_agent_audio.py index 97e55888..dfea48b6 100644 --- a/tests/test_agent_audio.py +++ b/tests/test_agent_audio.py @@ -3,8 +3,8 @@ import pytest -from assemblyai_cli.agent.audio import Player, _default_output_stream -from assemblyai_cli.errors import CLIError +from aai_cli.agent.audio import Player, _default_output_stream +from aai_cli.errors import CLIError class FakeStream: @@ -123,7 +123,7 @@ def test_player_resamples_source_to_device_rate(): assert len(written) > 240 * 2 # upsampled to ~48 kHz -> more bytes than the 24 kHz input -from assemblyai_cli.agent.audio import DuplexAudio # noqa: E402 +from aai_cli.agent.audio import DuplexAudio # noqa: E402 def test_duplex_opens_at_device_rate_and_closes(): diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 284aca1f..92ed1119 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -2,8 +2,8 @@ from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.main import app runner = CliRunner() @@ -20,7 +20,7 @@ def test_list_voices_prints_and_exits_without_connecting(monkeypatch): def fake_run_session(*a, **k): called["ran"] = True - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) result = runner.invoke(app, ["agent", "--list-voices"]) assert result.exit_code == 0 assert "ivy" in result.output @@ -51,7 +51,7 @@ def fake_run_session( renderer.user_final("hello agent") renderer.agent_transcript("hello human", interrupted=False) - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) result = runner.invoke(app, ["agent", "--json"]) assert result.exit_code == 0 lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] @@ -79,7 +79,7 @@ def fake_run_session( seen["prompt"] = system_prompt seen["full_duplex"] = full_duplex - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) prompt_file = tmp_path / "p.txt" prompt_file.write_text("be a pirate") result = runner.invoke( @@ -102,8 +102,8 @@ def fake_run_session( def test_agent_headphones_notice_in_human_mode(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: None) result = runner.invoke(app, ["agent"]) assert result.exit_code == 0 assert "headphones" in result.output.lower() # mic stays open -> warn to use headphones @@ -115,21 +115,21 @@ def test_agent_ctrl_c_exits_cleanly(monkeypatch): def raise_kbd(*a, **k): raise KeyboardInterrupt - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", raise_kbd) + monkeypatch.setattr("aai_cli.commands.agent.run_session", raise_kbd) result = runner.invoke(app, ["agent"]) assert result.exit_code == 0 def test_agent_unknown_voice_exits_2(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: None) result = runner.invoke(app, ["agent", "--voice", "not-a-voice"]) assert result.exit_code == 2 def test_agent_prompt_file_not_found_exits_2(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: None) result = runner.invoke( app, ["agent", "--system-prompt-file", "/tmp/no_such_file_xyz_voiceagent.txt"] ) @@ -143,7 +143,7 @@ def _capture_run_session(monkeypatch): def fake_run_session(api_key, **kwargs): seen.update(kwargs) - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) return seen @@ -152,7 +152,7 @@ def test_agent_file_source_streams_clip_and_exits_after_reply(monkeypatch, tmp_p wav = tmp_path / "say.wav" wav.write_bytes(b"RIFF") # FileSource is faked below; contents don't matter - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: f"filesrc:{src}") + monkeypatch.setattr("aai_cli.commands.agent.FileSource", lambda src: f"filesrc:{src}") seen = _capture_run_session(monkeypatch) result = runner.invoke(app, ["agent", str(wav)]) @@ -162,7 +162,7 @@ def test_agent_file_source_streams_clip_and_exits_after_reply(monkeypatch, tmp_p assert seen["exit_after_reply"] is True assert seen["full_duplex"] is True assert seen["greeting"] == "" - from assemblyai_cli.agent.audio import NullPlayer + from aai_cli.agent.audio import NullPlayer assert isinstance(seen["player"], NullPlayer) @@ -175,7 +175,7 @@ def fake_file_source(src): captured["src"] = src return "filesrc" - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", fake_file_source) + monkeypatch.setattr("aai_cli.commands.agent.FileSource", fake_file_source) seen = _capture_run_session(monkeypatch) result = runner.invoke(app, ["agent", "--sample"]) @@ -186,7 +186,7 @@ def fake_file_source(src): def test_agent_file_source_with_device_exits_2(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: None) wav = tmp_path / "say.wav" wav.write_bytes(b"RIFF") result = runner.invoke(app, ["agent", str(wav), "--device", "1"]) @@ -195,9 +195,9 @@ def test_agent_file_source_with_device_exits_2(monkeypatch, tmp_path): def test_agent_file_source_no_headphones_notice(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.commands.agent.FileSource", lambda src: "filesrc") + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: None) wav = tmp_path / "say.wav" wav.write_bytes(b"RIFF") result = runner.invoke(app, ["agent", str(wav)]) @@ -207,13 +207,13 @@ def test_agent_file_source_no_headphones_notice(monkeypatch, tmp_path): def test_agent_file_source_no_start_talking_notice(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.commands.agent.FileSource", lambda src: "filesrc") def fake_run_session(api_key, *, renderer, **kwargs): renderer.connected() # session.ready arrives even for a file-driven run - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) wav = tmp_path / "say.wav" wav.write_bytes(b"RIFF") result = runner.invoke(app, ["agent", str(wav)]) @@ -224,7 +224,7 @@ def fake_run_session(api_key, *, renderer, **kwargs): def test_agent_mic_shows_start_talking_notice(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) # Avoid opening real audio hardware; the renderer is what we're testing. class FakeDuplex: @@ -238,12 +238,12 @@ def start(self): def close(self): pass - monkeypatch.setattr("assemblyai_cli.commands.agent.DuplexAudio", FakeDuplex) + monkeypatch.setattr("aai_cli.commands.agent.DuplexAudio", FakeDuplex) def fake_run_session(api_key, *, renderer, **kwargs): renderer.connected() - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) result = runner.invoke(app, ["agent"]) assert result.exit_code == 0 assert "start talking" in result.output.lower() # live mic -> prompt the user to speak @@ -253,7 +253,7 @@ def test_agent_show_code_prints_without_session(monkeypatch): # Print-only: emits the agent script, never starts a session or opens audio, no auth. called = [] monkeypatch.setattr( - "assemblyai_cli.commands.agent.run_session", lambda *a, **k: called.append(True) + "aai_cli.commands.agent.run_session", lambda *a, **k: called.append(True) ) result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code"]) assert result.exit_code == 0 @@ -268,7 +268,7 @@ def _boom(*a, **k): raise AssertionError("must not run a session") monkeypatch.setattr( - "assemblyai_cli.commands.agent.run_session", + "aai_cli.commands.agent.run_session", _boom, ) result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code", "--json"]) @@ -279,13 +279,13 @@ def _boom(*a, **k): def test_agent_output_text_emits_plain_transcript(monkeypatch): # `-o text` -> plain you:/agent: lines on stdout (pipe into aai llm). config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.commands.agent.FileSource", lambda src: "filesrc") + monkeypatch.setattr("aai_cli.commands.agent.FileSource", lambda src: "filesrc") def fake_run_session(api_key, *, renderer, **kwargs): renderer.user_final("hello there") renderer.agent_transcript("hi, how can I help?", interrupted=False) - monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + monkeypatch.setattr("aai_cli.commands.agent.run_session", fake_run_session) result = runner.invoke(app, ["agent", "--sample", "-o", "text"]) assert result.exit_code == 0 assert "you: hello there" in result.output diff --git a/tests/test_agent_render.py b/tests/test_agent_render.py index 233b0fe0..7bbff818 100644 --- a/tests/test_agent_render.py +++ b/tests/test_agent_render.py @@ -3,8 +3,8 @@ import pytest -from assemblyai_cli import theme -from assemblyai_cli.agent.render import AgentRenderer +from aai_cli import theme +from aai_cli.agent.render import AgentRenderer def _json_lines(buf: io.StringIO): diff --git a/tests/test_agent_session.py b/tests/test_agent_session.py index 0157ced0..23ac7ce2 100644 --- a/tests/test_agent_session.py +++ b/tests/test_agent_session.py @@ -3,8 +3,8 @@ import pytest -from assemblyai_cli.agent.session import VoiceAgentSession, _send_audio_loop, run_session -from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated +from aai_cli.agent.session import VoiceAgentSession, _send_audio_loop, run_session +from aai_cli.errors import APIError, CLIError, NotAuthenticated class FakeRenderer: @@ -240,7 +240,7 @@ def close(self): def test_run_session_surfaces_mic_open_failure_from_capture_thread(): import threading as _threading - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError class _BoomMic: def __iter__(self): diff --git a/tests/test_agent_voices.py b/tests/test_agent_voices.py index 6e5b3b34..42b51a2c 100644 --- a/tests/test_agent_voices.py +++ b/tests/test_agent_voices.py @@ -1,4 +1,4 @@ -from assemblyai_cli.agent import voices +from aai_cli.agent import voices def test_voices_includes_default(): diff --git a/tests/test_auth_ams.py b/tests/test_auth_ams.py index 9a5c255c..18a64e79 100644 --- a/tests/test_auth_ams.py +++ b/tests/test_auth_ams.py @@ -1,8 +1,8 @@ import httpx import pytest -from assemblyai_cli.auth import ams -from assemblyai_cli.errors import APIError, NotAuthenticated +from aai_cli.auth import ams +from aai_cli.errors import APIError, NotAuthenticated def _patch_transport(monkeypatch, handler): diff --git a/tests/test_auth_discovery.py b/tests/test_auth_discovery.py index 85da4180..b6230ba5 100644 --- a/tests/test_auth_discovery.py +++ b/tests/test_auth_discovery.py @@ -1,6 +1,6 @@ from urllib.parse import parse_qs, urlparse -from assemblyai_cli.auth import discovery, endpoints +from aai_cli.auth import discovery, endpoints def test_build_start_url_targets_b2b_discovery_for_provider(): diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py index 945fac8b..5420574b 100644 --- a/tests/test_auth_endpoints.py +++ b/tests/test_auth_endpoints.py @@ -1,4 +1,4 @@ -from assemblyai_cli.auth import endpoints +from aai_cli.auth import endpoints def test_redirect_uri_is_fixed_loopback(): diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index ce0382ba..c1fdb5df 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -1,7 +1,7 @@ import pytest -from assemblyai_cli.auth import flow, loopback -from assemblyai_cli.errors import APIError +from aai_cli.auth import flow, loopback +from aai_cli.errors import APIError def test_find_or_create_reuses_existing_cli_key(monkeypatch): diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index 1c2670e1..759de461 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -2,7 +2,7 @@ import time import urllib.request -from assemblyai_cli.auth import endpoints, loopback +from aai_cli.auth import endpoints, loopback def _hit(path: str) -> None: diff --git a/tests/test_claude.py b/tests/test_claude.py index 23019eeb..c0d9708d 100644 --- a/tests/test_claude.py +++ b/tests/test_claude.py @@ -6,7 +6,7 @@ import pytest from typer.testing import CliRunner -from assemblyai_cli.main import app +from aai_cli.main import app runner = CliRunner() @@ -57,7 +57,7 @@ def __call__(self, cmd, *args, **kwargs): def _all_tools_present(monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.claude.shutil.which", + "aai_cli.commands.claude.shutil.which", lambda tool: f"/usr/bin/{tool}", ) @@ -66,7 +66,7 @@ def test_install_happy_path_runs_both_steps(monkeypatch): _all_tools_present(monkeypatch) # MCP not yet present -> `mcp get` returns non-zero. fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 0 @@ -102,7 +102,7 @@ def test_install_skill_failed_when_npx_succeeds_but_nothing_installed(monkeypatc # code — otherwise install says "installed" while status says "not_installed". _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 1}, creates_skill=False) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 1 # skill step failed @@ -125,7 +125,7 @@ def record(cmd, *args, **kwargs): seen.append(kwargs) return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="") - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", record) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", record) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code in (0, 1) assert seen, "expected subprocess.run to be called" @@ -137,7 +137,7 @@ def record(cmd, *args, **kwargs): def test_install_scope_passthrough(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install", "--scope", "project"]) assert result.exit_code == 0 @@ -156,7 +156,7 @@ def test_install_scope_passthrough(monkeypatch): def test_install_invalid_scope_exits_2(monkeypatch): _all_tools_present(monkeypatch) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", FakeRun()) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", FakeRun()) result = runner.invoke(app, ["claude", "install", "--scope", "bogus"]) assert result.exit_code == 2 @@ -165,7 +165,7 @@ def test_install_idempotent_when_mcp_present(monkeypatch): _all_tools_present(monkeypatch) # `mcp get` returns 0 -> already registered. fake = FakeRun({("claude", "mcp", "get"): 0}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 0 @@ -184,7 +184,7 @@ def test_install_skill_idempotent_when_present(monkeypatch): skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 0 @@ -201,7 +201,7 @@ def test_install_force_reinstalls_skill(monkeypatch): skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install", "--force"]) assert result.exit_code == 0 @@ -221,7 +221,7 @@ def test_install_force_reinstalls_skill(monkeypatch): def test_install_force_removes_then_adds(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 0}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install", "--force"]) assert result.exit_code == 0 @@ -231,11 +231,11 @@ def test_install_force_removes_then_adds(monkeypatch): def test_install_skips_mcp_when_claude_missing(monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.claude.shutil.which", + "aai_cli.commands.claude.shutil.which", lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", ) fake = FakeRun() - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 0 # skip is not a failure @@ -248,11 +248,11 @@ def test_install_skips_mcp_when_claude_missing(monkeypatch): def test_install_skips_skill_when_npx_missing(monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.claude.shutil.which", + "aai_cli.commands.claude.shutil.which", lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", ) fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 0 @@ -267,7 +267,7 @@ def test_install_failure_exits_nonzero(monkeypatch): _all_tools_present(monkeypatch) # mcp not present, but `mcp add` fails. fake = FakeRun({("claude", "mcp", "get"): 1, ("claude", "mcp", "add"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install"]) assert result.exit_code == 1 @@ -280,7 +280,7 @@ def test_install_force_remove_failure_reports_failed(monkeypatch): _all_tools_present(monkeypatch) # present, but the forced remove fails fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install", "--force"]) assert result.exit_code == 1 @@ -297,7 +297,7 @@ def test_status_reports_both_installed(monkeypatch, tmp_path): (skill / "SKILL.md").write_text("# AssemblyAI") # `mcp get` returns 0 -> present. monkeypatch.setattr( - "assemblyai_cli.commands.claude.subprocess.run", + "aai_cli.commands.claude.subprocess.run", FakeRun({("claude", "mcp", "get"): 0}), ) @@ -311,7 +311,7 @@ def test_status_reports_not_installed(monkeypatch, tmp_path): _all_tools_present(monkeypatch) monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir created monkeypatch.setattr( - "assemblyai_cli.commands.claude.subprocess.run", + "aai_cli.commands.claude.subprocess.run", FakeRun({("claude", "mcp", "get"): 1}), ) @@ -323,11 +323,11 @@ def test_status_reports_not_installed(monkeypatch, tmp_path): def test_status_mcp_unknown_when_claude_missing(monkeypatch, tmp_path): monkeypatch.setattr( - "assemblyai_cli.commands.claude.shutil.which", + "aai_cli.commands.claude.shutil.which", lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", ) monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", FakeRun()) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", FakeRun()) result = runner.invoke(app, ["claude", "status"]) assert result.exit_code == 0 @@ -342,7 +342,7 @@ def test_remove_unwinds_both(monkeypatch, tmp_path): skill.mkdir(parents=True) (skill / "SKILL.md").write_text("# AssemblyAI") fake = FakeRun({("claude", "mcp", "get"): 0}) # present -> removable - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "remove"]) assert result.exit_code == 0 @@ -357,7 +357,7 @@ def test_remove_when_absent_is_not_an_error(monkeypatch, tmp_path): _all_tools_present(monkeypatch) monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir fake = FakeRun({("claude", "mcp", "get"): 1}) # absent - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "remove"]) assert result.exit_code == 0 @@ -374,7 +374,7 @@ def test_remove_skill_failure_reports_failed(monkeypatch, tmp_path): # MCP absent (so only the skill step can fail) and `npx skills remove` runs but # leaves the skill in place -> install/remove must report it as failed, not removed. monkeypatch.setattr( - "assemblyai_cli.commands.claude.subprocess.run", + "aai_cli.commands.claude.subprocess.run", FakeRun({("claude", "mcp", "get"): 1}, removes_skill=False), ) @@ -387,7 +387,7 @@ def test_remove_skill_failure_reports_failed(monkeypatch, tmp_path): def test_install_scope_local_passthrough(monkeypatch): _all_tools_present(monkeypatch) fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "install", "--scope", "local"]) assert result.exit_code == 0 @@ -408,7 +408,7 @@ def test_remove_scope_passthrough(monkeypatch, tmp_path): _all_tools_present(monkeypatch) monkeypatch.setenv("HOME", str(tmp_path)) fake = FakeRun({("claude", "mcp", "get"): 0}) # present - monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) result = runner.invoke(app, ["claude", "remove", "--scope", "project"]) assert result.exit_code == 0 diff --git a/tests/test_claude_render.py b/tests/test_claude_render.py index efa86cdf..079299ff 100644 --- a/tests/test_claude_render.py +++ b/tests/test_claude_render.py @@ -1,7 +1,7 @@ import io -from assemblyai_cli import theme -from assemblyai_cli.commands.claude import _render_steps +from aai_cli import theme +from aai_cli.commands.claude import _render_steps def test_render_steps_colors_status(): diff --git a/tests/test_client.py b/tests/test_client.py index 82c727ae..dbc17220 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,8 +4,8 @@ import assemblyai as aai import pytest -from assemblyai_cli import client -from assemblyai_cli.errors import APIError +from aai_cli import client +from aai_cli.errors import APIError def _stream_params(sample_rate: int = 16000): @@ -79,7 +79,7 @@ def test_list_transcripts_auth_error_becomes_apierror(): def test_list_transcripts_rejected_key_becomes_not_authenticated(): - from assemblyai_cli.errors import NotAuthenticated + from aai_cli.errors import NotAuthenticated with patch.object(client.aai, "Transcriber") as T: T.return_value.list_transcripts.side_effect = aai.types.AssemblyAIError( @@ -90,7 +90,7 @@ def test_list_transcripts_rejected_key_becomes_not_authenticated(): def test_resolve_audio_source_sample_explicit_and_missing(): - from assemblyai_cli.errors import UsageError + from aai_cli.errors import UsageError assert client.resolve_audio_source(None, sample=True) == client.SAMPLE_AUDIO_URL assert client.resolve_audio_source("clip.mp3", sample=False) == "clip.mp3" @@ -143,7 +143,7 @@ def test_select_transcript_field_srt_network_error_becomes_apierror(): def test_select_transcript_field_srt_auth_error_becomes_not_authenticated(): - from assemblyai_cli.errors import NotAuthenticated + from aai_cli.errors import NotAuthenticated t = MagicMock() t.export_subtitles_srt.side_effect = RuntimeError("HTTP 401 Unauthorized") @@ -166,7 +166,7 @@ def test_get_transcript_generic_error_becomes_apierror(): def test_get_transcript_auth_error_becomes_not_authenticated(): - from assemblyai_cli.errors import NotAuthenticated + from aai_cli.errors import NotAuthenticated with patch.object( client.aai.Transcript, "get_by_id", side_effect=RuntimeError("HTTP 401 Unauthorized") @@ -184,7 +184,7 @@ def test_transcribe_network_error_becomes_apierror(): def test_transcribe_auth_error_becomes_not_authenticated(): - from assemblyai_cli.errors import NotAuthenticated + from aai_cli.errors import NotAuthenticated fake_transcriber = MagicMock() fake_transcriber.transcribe.side_effect = RuntimeError("Invalid API key") @@ -278,7 +278,7 @@ def connect(self, params): def test_stream_audio_connect_auth_error_becomes_not_authenticated(monkeypatch): - from assemblyai_cli.errors import NotAuthenticated + from aai_cli.errors import NotAuthenticated class ConnectUnauthorized(_FakeStreamingClient): def connect(self, params): @@ -290,7 +290,7 @@ def connect(self, params): def test_stream_audio_auth_error_event_becomes_not_authenticated(monkeypatch): - from assemblyai_cli.errors import NotAuthenticated + from aai_cli.errors import NotAuthenticated class AuthErrClient(_FakeStreamingClient): def stream(self, source): @@ -319,7 +319,7 @@ def test_stream_audio_swallows_broken_pipe_in_callback(monkeypatch): # reader thread; the guard must swallow it instead of dumping a thread traceback. monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) # never touch the real stdout fd during the test - monkeypatch.setattr("assemblyai_cli.stdio.silence_stdout", lambda: None) + monkeypatch.setattr("aai_cli.stdio.silence_stdout", lambda: None) def on_turn(_event): raise BrokenPipeError @@ -328,7 +328,7 @@ def on_turn(_event): def test_stream_audio_passes_through_clierror(monkeypatch): - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError class StreamRaisesCLIError(_FakeStreamingClient): def stream(self, source): @@ -343,7 +343,7 @@ def stream(self, source): def test_transcribe_passes_prebuilt_config(monkeypatch): import assemblyai as aai - from assemblyai_cli import client + from aai_cli import client captured = {} @@ -365,7 +365,7 @@ def transcribe(self, audio, config=None): def test_stream_audio_accepts_params(monkeypatch): from assemblyai.streaming.v3 import SpeechModel, StreamingParameters - from assemblyai_cli import client + from aai_cli import client captured = {} @@ -385,7 +385,7 @@ def stream(self, source): def disconnect(self, terminate=True): pass - monkeypatch.setattr("assemblyai_cli.client.StreamingClient", FakeSC) + monkeypatch.setattr("aai_cli.client.StreamingClient", FakeSC) params = StreamingParameters( sample_rate=16000, speech_model=SpeechModel.universal_streaming_multilingual ) diff --git a/tests/test_code_gen.py b/tests/test_code_gen.py index 36d28ea2..424153b5 100644 --- a/tests/test_code_gen.py +++ b/tests/test_code_gen.py @@ -5,7 +5,7 @@ from hypothesis import given, settings from hypothesis import strategies as st -from assemblyai_cli.code_gen import serialize +from aai_cli.code_gen import serialize settings.register_profile("codegen", max_examples=150) settings.load_profile("codegen") @@ -45,7 +45,7 @@ def test_config_kwarg_lines_empty_dict(): # --------------------------------------------------------------------------- from assemblyai.streaming.v3 import SpeechModel # noqa: E402 -from assemblyai_cli import config_builder # noqa: E402 +from aai_cli import config_builder # noqa: E402 # JSON-ish values that repr()->eval() round-trips (string keys, no NaN/inf). _json = st.recursive( @@ -103,7 +103,7 @@ def test_serializer_round_trips_full_stream_domain(merged): assert eval(src, {"SpeechModel": SpeechModel}) == merged # noqa: S307 -from assemblyai_cli.code_gen import snippets # noqa: E402 +from aai_cli.code_gen import snippets # noqa: E402 def test_result_handling_includes_only_enabled_features(): @@ -126,7 +126,7 @@ def test_every_render_feature_has_a_snippet(): # but no `_render_speaker_labels` function, so it is an allowed orphan. import inspect - from assemblyai_cli import transcribe_render + from aai_cli import transcribe_render rendered = { name[len("_render_") :] @@ -144,7 +144,7 @@ def test_every_render_feature_has_a_snippet(): import ast # noqa: E402 -from assemblyai_cli import code_gen # noqa: E402 +from aai_cli import code_gen # noqa: E402 def test_transcribe_render_parses_and_uses_env_key(): diff --git a/tests/test_config.py b/tests/test_config.py index d718bfa3..7c6d6546 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ import pytest -from assemblyai_cli import config -from assemblyai_cli.errors import NotAuthenticated +from aai_cli import config +from aai_cli.errors import NotAuthenticated def test_set_and_get_api_key_roundtrip(): @@ -55,7 +55,7 @@ def test_clear_api_key_missing_is_silent(): def test_invalid_profile_name_rejected(): import pytest - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError with pytest.raises(CLIError): config.set_api_key("bad name!", "sk_x") @@ -64,7 +64,7 @@ def test_invalid_profile_name_rejected(): def test_empty_api_key_flag_rejected(): import pytest - from assemblyai_cli.errors import CLIError + from aai_cli.errors import CLIError with pytest.raises(CLIError): config.resolve_api_key(api_key_flag="") diff --git a/tests/test_config_builder.py b/tests/test_config_builder.py index 86b2d494..6038e7d7 100644 --- a/tests/test_config_builder.py +++ b/tests/test_config_builder.py @@ -2,8 +2,8 @@ import pytest -from assemblyai_cli import config_builder as cb -from assemblyai_cli.errors import UsageError +from aai_cli import config_builder as cb +from aai_cli.errors import UsageError def _param_names(model_cls) -> set[str]: @@ -168,7 +168,7 @@ def test_every_transcribe_field_is_a_valid_param(field): def test_merge_transcribe_config_returns_kwargs_dict(): - from assemblyai_cli import config_builder + from aai_cli import config_builder merged = config_builder.merge_transcribe_config( flags={"speaker_labels": True, "language_code": None}, @@ -181,7 +181,7 @@ def test_merge_transcribe_config_returns_kwargs_dict(): def test_construct_transcribe_config_from_merged(): import assemblyai as aai - from assemblyai_cli import config_builder + from aai_cli import config_builder tc = config_builder.construct_transcription_config({"speaker_labels": True}) assert isinstance(tc, aai.TranscriptionConfig) @@ -192,7 +192,7 @@ def test_construct_transcribe_config_from_merged(): def test_merge_streaming_params_coerces_speech_model_enum(): from assemblyai.streaming.v3 import SpeechModel - from assemblyai_cli import config_builder + from aai_cli import config_builder merged = config_builder.merge_streaming_params( flags={"speech_model": "universal-streaming-multilingual", "sample_rate": 16000}, @@ -206,7 +206,7 @@ def test_merge_streaming_params_coerces_speech_model_enum(): def test_build_transcription_config_still_works(): import assemblyai as aai - from assemblyai_cli import config_builder + from aai_cli import config_builder tc = config_builder.build_transcription_config( flags={"speaker_labels": True}, overrides=[], config_file=None diff --git a/tests/test_context.py b/tests/test_context.py index e1373f5c..e25c6810 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,8 +1,8 @@ import typer from typer.testing import CliRunner -from assemblyai_cli.context import AppState, run_command -from assemblyai_cli.errors import NotAuthenticated +from aai_cli.context import AppState, run_command +from aai_cli.errors import NotAuthenticated runner = CliRunner() diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 6b77a53a..7f9179ca 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -4,10 +4,10 @@ import pytest from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.commands import doctor -from assemblyai_cli.errors import APIError -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.commands import doctor +from aai_cli.errors import APIError +from aai_cli.main import app runner = CliRunner() @@ -16,11 +16,11 @@ def healthy(monkeypatch): """A fully-ready environment: valid key, all tools present, a microphone.""" config.set_api_key("default", "sk_1234567890") - monkeypatch.setattr("assemblyai_cli.commands.doctor.client.validate_key", lambda _key: True) + monkeypatch.setattr("aai_cli.commands.doctor.client.validate_key", lambda _key: True) monkeypatch.setattr( - "assemblyai_cli.commands.doctor.shutil.which", lambda tool: f"/usr/bin/{tool}" + "aai_cli.commands.doctor.shutil.which", lambda tool: f"/usr/bin/{tool}" ) - monkeypatch.setattr("assemblyai_cli.commands.doctor._probe_input_devices", lambda: 2) + monkeypatch.setattr("aai_cli.commands.doctor._probe_input_devices", lambda: 2) def _checks(result): @@ -45,7 +45,7 @@ def test_doctor_no_api_key_fails(healthy): def test_doctor_rejected_key_fails(healthy, monkeypatch): - monkeypatch.setattr("assemblyai_cli.commands.doctor.client.validate_key", lambda _key: False) + monkeypatch.setattr("aai_cli.commands.doctor.client.validate_key", lambda _key: False) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 1 assert _checks(result)["api-key"]["status"] == "fail" @@ -55,7 +55,7 @@ def test_doctor_network_error_is_a_failure(healthy, monkeypatch): def boom(_key): raise APIError("Network error contacting AssemblyAI: timeout") - monkeypatch.setattr("assemblyai_cli.commands.doctor.client.validate_key", boom) + monkeypatch.setattr("aai_cli.commands.doctor.client.validate_key", boom) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 1 api = _checks(result)["api-key"] @@ -65,7 +65,7 @@ def boom(_key): def test_doctor_ffmpeg_missing_warns_but_passes(healthy, monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.doctor.shutil.which", + "aai_cli.commands.doctor.shutil.which", lambda tool: None if tool == "ffmpeg" else f"/usr/bin/{tool}", ) result = runner.invoke(app, ["doctor", "--json"]) @@ -78,7 +78,7 @@ def test_doctor_audio_unavailable_warns_but_passes(healthy, monkeypatch): def no_audio(): raise ImportError("no sounddevice") - monkeypatch.setattr("assemblyai_cli.commands.doctor._probe_input_devices", no_audio) + monkeypatch.setattr("aai_cli.commands.doctor._probe_input_devices", no_audio) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 0 audio = _checks(result)["audio"] @@ -87,7 +87,7 @@ def no_audio(): def test_doctor_no_microphone_warns(healthy, monkeypatch): - monkeypatch.setattr("assemblyai_cli.commands.doctor._probe_input_devices", lambda: 0) + monkeypatch.setattr("aai_cli.commands.doctor._probe_input_devices", lambda: 0) result = runner.invoke(app, ["doctor", "--json"]) assert result.exit_code == 0 assert _checks(result)["audio"]["status"] == "warn" @@ -95,7 +95,7 @@ def test_doctor_no_microphone_warns(healthy, monkeypatch): def test_doctor_coding_agent_missing_warns(healthy, monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.doctor.shutil.which", + "aai_cli.commands.doctor.shutil.which", lambda tool: None if tool in ("claude", "npx") else f"/usr/bin/{tool}", ) result = runner.invoke(app, ["doctor", "--json"]) diff --git a/tests/test_errors.py b/tests/test_errors.py index 3e9d4caf..c724ec73 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,4 +1,4 @@ -from assemblyai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure +from aai_cli.errors import APIError, CLIError, NotAuthenticated, is_auth_failure def test_not_authenticated_defaults(): diff --git a/tests/test_llm.py b/tests/test_llm.py index b16ec40d..f476bf3c 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -4,8 +4,8 @@ import openai import pytest -from assemblyai_cli import llm -from assemblyai_cli.errors import APIError, NotAuthenticated +from aai_cli import llm +from aai_cli.errors import APIError, NotAuthenticated _REQUEST = httpx.Request("POST", f"{llm.GATEWAY_BASE_URL}/chat/completions") diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 820dfa61..98fe63da 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -3,8 +3,8 @@ from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.main import app runner = CliRunner() @@ -29,7 +29,7 @@ def test_llm_help_lists_command(): def test_llm_list_models_exits_without_network(monkeypatch): called = {"ran": False} monkeypatch.setattr( - "assemblyai_cli.commands.llm.gateway.complete", + "aai_cli.commands.llm.gateway.complete", lambda *a, **k: called.__setitem__("ran", True), ) result = runner.invoke(app, ["llm", "--list-models"]) @@ -48,7 +48,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): seen["transcript_id"] = transcript_id return _payload("4") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke(app, ["llm", "What is 2+2?", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -67,7 +67,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): seen["content"] = messages[0]["content"] return _payload("summary") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke(app, ["llm", "summarize", "--transcript-id", "t_7", "--json"]) assert result.exit_code == 0 assert seen["transcript_id"] == "t_7" @@ -83,7 +83,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): seen["transcript_id"] = transcript_id return _payload("done") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke(app, ["llm", "summarize", "--json"], input="meeting notes here") assert result.exit_code == 0 # The piped text is injected into the prompt content; no transcript id is used. @@ -101,7 +101,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): seen["transcript_id"] = transcript_id return _payload("s") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke( app, ["llm", "summarize", "--transcript-id", "t_9", "--json"], input="ignored stdin" ) @@ -113,7 +113,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_llm_missing_prompt_exits_2(monkeypatch): _auth() - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) result = runner.invoke(app, ["llm"]) assert result.exit_code == 2 @@ -131,7 +131,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): calls.append(messages[-1]["content"]) return _payload(f"summary-{len(calls)}") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke( app, ["llm", "summarize action items", "--follow", "--json"], @@ -158,7 +158,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): seen["system"] = messages[0]["content"] return _payload("ok") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke( app, ["llm", "summarize", "--follow", "--system", "You are a scribe", "--json"], @@ -171,7 +171,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_llm_follow_rejects_transcript_id(monkeypatch): _auth() - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) result = runner.invoke( app, ["llm", "summarize", "--follow", "--transcript-id", "t_1", "--json"], @@ -189,7 +189,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): calls.append(messages[-1]["content"]) return _payload("ok") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke( app, ["llm", "summarize", "--follow", "--json"], @@ -203,7 +203,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): def test_llm_output_text_prints_raw_answer(monkeypatch): _auth() monkeypatch.setattr( - "assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("just the answer") + "aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("just the answer") ) result = runner.invoke(app, ["llm", "hi", "-o", "text"]) assert result.exit_code == 0 @@ -215,7 +215,7 @@ def test_llm_output_text_prints_raw_answer(monkeypatch): def test_llm_output_json_forces_json(monkeypatch): _auth() monkeypatch.setattr( - "assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hello") + "aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hello") ) result = runner.invoke(app, ["llm", "hi", "-o", "json"]) assert result.exit_code == 0 @@ -224,14 +224,14 @@ def test_llm_output_json_forces_json(monkeypatch): def test_llm_output_invalid_field_exits_2(monkeypatch): _auth() - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) result = runner.invoke(app, ["llm", "hi", "-o", "bogus"]) assert result.exit_code == 2 def test_llm_output_with_follow_is_rejected(monkeypatch): _auth() - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload()) result = runner.invoke(app, ["llm", "hi", "-f", "-o", "text"], input="x\n") assert result.exit_code == 2 assert "one-shot" in result.output @@ -247,7 +247,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): raise KeyboardInterrupt # user hits Ctrl-C mid-meeting return _payload("ok") - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke( app, ["llm", "summarize", "--follow", "--json"], input="alpha\nbeta\ngamma\n" ) @@ -267,7 +267,7 @@ def fake_complete(api_key, *, model, messages, max_tokens, transcript_id=None): seen["max_tokens"] = max_tokens return _payload() - monkeypatch.setattr("assemblyai_cli.commands.llm.gateway.complete", fake_complete) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", fake_complete) result = runner.invoke( app, ["llm", "hi", "--model", "gemini-2.5-flash", "--max-tokens", "42", "--json"] ) diff --git a/tests/test_login.py b/tests/test_login.py index 6b793cda..a18844de 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -2,28 +2,28 @@ from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.main import app runner = CliRunner() def test_login_with_api_key_flag_stores_key(): - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + with patch("aai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_flag" def test_login_rejects_invalid_key(): - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=False): + with patch("aai_cli.commands.login.client.validate_key", return_value=False): result = runner.invoke(app, ["login", "--api-key", "sk_bad"]) assert result.exit_code != 0 assert config.get_api_key("default") is None def test_login_stores_under_named_profile(): - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + with patch("aai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["--profile", "staging", "login", "--api-key", "sk_s"]) assert result.exit_code == 0 assert config.get_api_key("staging") == "sk_s" @@ -33,7 +33,7 @@ def test_whoami_reports_authenticated(): import json config.set_api_key("default", "sk_1234567890") - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + with patch("aai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["whoami", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -56,7 +56,7 @@ def test_logout_clears_key(): def test_login_oauth_flow_stores_returned_key(monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" + "aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" ) result = runner.invoke(app, ["login"]) assert result.exit_code == 0 @@ -64,12 +64,12 @@ def test_login_oauth_flow_stores_returned_key(monkeypatch): def test_login_oauth_flow_failure_exits_nonzero(monkeypatch): - from assemblyai_cli.errors import APIError + from aai_cli.errors import APIError def boom(): raise APIError("Login timed out waiting for the browser.") - monkeypatch.setattr("assemblyai_cli.commands.login.run_login_flow", boom) + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", boom) result = runner.invoke(app, ["login"]) assert result.exit_code != 0 assert config.get_api_key("default") is None @@ -77,10 +77,10 @@ def boom(): def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): monkeypatch.setattr( - "assemblyai_cli.commands.login.run_login_flow", + "aai_cli.commands.login.run_login_flow", lambda: (_ for _ in ()).throw(AssertionError("OAuth must not run with --api-key")), ) - with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + with patch("aai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["login", "--api-key", "sk_flag2"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_flag2" diff --git a/tests/test_main_module.py b/tests/test_main_module.py index 72ff63f2..a40db37a 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -3,7 +3,7 @@ import pytest -import assemblyai_cli.main as main_mod +import aai_cli.main as main_mod def test_run_exits_clean_on_broken_pipe(monkeypatch): @@ -14,7 +14,7 @@ def boom(*a, **k): monkeypatch.setattr(main_mod, "app", boom) # Don't dup2 the real stdout fd during the test; just verify the exit contract. - monkeypatch.setattr("assemblyai_cli.stdio.silence_stdout", lambda: None) + monkeypatch.setattr("aai_cli.stdio.silence_stdout", lambda: None) with pytest.raises(SystemExit) as exc: main_mod.run() assert exc.value.code == 0 @@ -33,9 +33,9 @@ def normal(*a, **k): def test_python_dash_m_entrypoint_runs(): - """`python -m assemblyai_cli` wires up the Typer app (exercises __main__.py).""" + """`python -m aai_cli` wires up the Typer app (exercises __main__.py).""" result = subprocess.run( - [sys.executable, "-m", "assemblyai_cli", "--help"], + [sys.executable, "-m", "aai_cli", "--help"], capture_output=True, text=True, ) @@ -45,7 +45,7 @@ def test_python_dash_m_entrypoint_runs(): def test_python_dash_m_version(): result = subprocess.run( - [sys.executable, "-m", "assemblyai_cli", "version"], + [sys.executable, "-m", "aai_cli", "version"], capture_output=True, text=True, ) diff --git a/tests/test_microphone.py b/tests/test_microphone.py index 657eedb6..741c2a24 100644 --- a/tests/test_microphone.py +++ b/tests/test_microphone.py @@ -3,8 +3,8 @@ import pytest -from assemblyai_cli.errors import CLIError -from assemblyai_cli.microphone import ( +from aai_cli.errors import CLIError +from aai_cli.microphone import ( _FALLBACK_RATE, MicrophoneSource, _default_mic_stream, diff --git a/tests/test_output.py b/tests/test_output.py index 8b2b5ac6..bdab3918 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,6 +1,6 @@ import json -from assemblyai_cli import output +from aai_cli import output def test_resolve_json_true_when_explicit(monkeypatch): @@ -70,7 +70,7 @@ def test_print_code_plain_when_piped(monkeypatch, capsys): def test_print_code_highlights_for_interactive_human(monkeypatch, capsys): - from assemblyai_cli import theme + from aai_cli import theme monkeypatch.setattr(output, "_is_agentic", lambda: False) monkeypatch.setattr( diff --git a/tests/test_properties.py b/tests/test_properties.py index 8ef02baa..2c9173d8 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -8,10 +8,10 @@ from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st -from assemblyai_cli import config_builder as cb -from assemblyai_cli.agent.render import AgentRenderer -from assemblyai_cli.streaming import sources -from assemblyai_cli.streaming.render import StreamRenderer +from aai_cli import config_builder as cb +from aai_cli.agent.render import AgentRenderer +from aai_cli.streaming import sources +from aai_cli.streaming.render import StreamRenderer @given(text=st.text()) diff --git a/tests/test_samples.py b/tests/test_samples.py index 51474d72..1dbc84e2 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -2,7 +2,7 @@ from typer.testing import CliRunner -from assemblyai_cli.main import app +from aai_cli.main import app runner = CliRunner() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index db99a4f2..a55891b5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,6 +1,6 @@ from typer.testing import CliRunner -from assemblyai_cli.main import app +from aai_cli.main import app runner = CliRunner() @@ -12,7 +12,7 @@ def test_help_runs(): def test_version_command(): - from assemblyai_cli import __version__ + from aai_cli import __version__ result = runner.invoke(app, ["version"]) assert result.exit_code == 0 diff --git a/tests/test_stdio.py b/tests/test_stdio.py index 979c2979..3f4c6173 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -1,6 +1,6 @@ import io -from assemblyai_cli import stdio +from aai_cli import stdio class _Tty(io.StringIO): diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index 17915b71..ea63e5e8 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -3,8 +3,8 @@ from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.main import app runner = CliRunner() @@ -27,7 +27,7 @@ def test_stream_help_lists_command(): def test_stream_mic_renders_turns(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _drive_turns) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _drive_turns) result = runner.invoke(app, ["stream", "--json"]) assert result.exit_code == 0 lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] @@ -44,7 +44,7 @@ def fake_stream_audio( seen["source_type"] = type(source).__name__ seen["rate"] = params.sample_rate - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream_audio) import wave p = tmp_path / "a.wav" @@ -61,7 +61,7 @@ def fake_stream_audio( def test_stream_mic_listening_notice_waits_for_mic_open(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) captured = {} @@ -74,7 +74,7 @@ def __iter__(self): captured["on_open"]() # the SDK iterating us == the mic is now live return iter([b"\x00\x00"]) - monkeypatch.setattr("assemblyai_cli.commands.stream.MicrophoneSource", FakeMic) + monkeypatch.setattr("aai_cli.commands.stream.MicrophoneSource", FakeMic) order = [] @@ -85,7 +85,7 @@ def fake_stream_audio(api_key, source, *, params, on_begin=None, **_kwargs): list(source) # consume the mic -> on_open fires -> "Listening…" prints order.append("consumed") - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream_audio) result = runner.invoke(app, ["stream"]) assert result.exit_code == 0 assert "Listening" in result.output # shown once the mic opened @@ -94,13 +94,13 @@ def fake_stream_audio(api_key, source, *, params, on_begin=None, **_kwargs): def test_stream_file_shows_no_listening_notice(monkeypatch, tmp_path): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) def fake(api_key, source, *, params, on_begin=None, **_kwargs): if on_begin: on_begin(types.SimpleNamespace(id="x")) - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake) import wave p = tmp_path / "a.wav" @@ -130,14 +130,14 @@ def fake( def test_stream_sample_uses_hosted_clip(monkeypatch): - from assemblyai_cli import client + from aai_cli import client config.set_api_key("default", "sk_live") monkeypatch.setattr( - "assemblyai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" + "aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" ) seen = {} - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _capture_source(seen)) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _capture_source(seen)) result = runner.invoke(app, ["stream", "--sample"]) assert result.exit_code == 0 assert type(seen["source"]).__name__ == "FileSource" @@ -148,10 +148,10 @@ def test_stream_sample_uses_hosted_clip(monkeypatch): def test_stream_url_source_uses_filesource(monkeypatch): config.set_api_key("default", "sk_live") monkeypatch.setattr( - "assemblyai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" + "aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" ) seen = {} - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _capture_source(seen)) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _capture_source(seen)) result = runner.invoke(app, ["stream", "https://example.com/clip.mp3"]) assert result.exit_code == 0 assert type(seen["source"]).__name__ == "FileSource" @@ -170,19 +170,19 @@ def test_stream_ctrl_c_exits_cleanly(monkeypatch): def raise_kbd(*a, **k): raise KeyboardInterrupt - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_kbd) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", raise_kbd) result = runner.invoke(app, ["stream"]) assert result.exit_code == 0 def test_stream_ctrl_c_human_mode_prints_stopped(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) def raise_kbd(*a, **k): raise KeyboardInterrupt - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_kbd) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", raise_kbd) result = runner.invoke(app, ["stream"]) assert result.exit_code == 0 assert "Stopped." in result.output @@ -208,7 +208,7 @@ def test_stream_broken_pipe_exits_zero(monkeypatch): def raise_broken_pipe(*a, **k): raise BrokenPipeError - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_broken_pipe) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", raise_broken_pipe) result = runner.invoke(app, ["stream"]) assert result.exit_code == 0 @@ -225,7 +225,7 @@ def fake( if on_turn: on_turn(types.SimpleNamespace(transcript="from file", end_of_turn=True)) - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake) p = tmp_path / "a.wav" with wave.open(str(p), "wb") as w: w.setnchannels(1) @@ -255,8 +255,8 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): seen["max_tokens"] = max_tokens return f"answer:{transcript_text}" - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) - monkeypatch.setattr("assemblyai_cli.commands.stream.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("aai_cli.commands.stream.llm.run_chain", fake_run_chain) result = runner.invoke( app, [ @@ -295,8 +295,8 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): seen["prompts"] = prompts return "done" - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) - monkeypatch.setattr("assemblyai_cli.commands.stream.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("aai_cli.commands.stream.llm.run_chain", fake_run_chain) result = runner.invoke( app, ["stream", "--llm", "summarize", "--llm", "translate to french", "--json"] ) @@ -307,7 +307,7 @@ def fake_run_chain(api_key, prompts, *, transcript_text, model, max_tokens): def test_stream_llm_rejects_output_text(monkeypatch): config.set_api_key("default", "sk_live") monkeypatch.setattr( - "assemblyai_cli.commands.stream.client.stream_audio", + "aai_cli.commands.stream.client.stream_audio", lambda *a, **k: (_ for _ in ()).throw(AssertionError("must not stream")), ) result = runner.invoke(app, ["stream", "--llm", "summarize", "-o", "text"]) @@ -326,8 +326,8 @@ def fake_run_chain(*a, **k): called["ran"] = True return "x" - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) - monkeypatch.setattr("assemblyai_cli.commands.stream.llm.run_chain", fake_run_chain) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("aai_cli.commands.stream.llm.run_chain", fake_run_chain) result = runner.invoke(app, ["stream", "--json"]) assert result.exit_code == 0 assert called["ran"] is False # no --llm -> no gateway call @@ -340,7 +340,7 @@ def test_stream_prompt_biases_speech_model(monkeypatch): def fake(api_key, source, *, params, **kwargs): seen["prompt"] = params.prompt - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake) result = runner.invoke(app, ["stream", "--prompt", "expect crypto jargon", "--json"]) assert result.exit_code == 0 # --prompt is the speech-model prompt, forwarded to the streaming session. @@ -358,7 +358,7 @@ def test_stream_youtube_url_downloads_then_streams(monkeypatch, tmp_path): w.setframerate(16000) w.writeframes(b"\x00\x01" * 100) monkeypatch.setattr( - "assemblyai_cli.commands.stream.youtube.download_audio", lambda url, d: fake + "aai_cli.commands.stream.youtube.download_audio", lambda url, d: fake ) seen = {} @@ -366,7 +366,7 @@ def fake_stream(api_key, source, *, params, **kwargs): seen["source_type"] = type(source).__name__ seen["src"] = getattr(source, "source", None) - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream) result = runner.invoke(app, ["stream", "https://youtu.be/abc"]) assert result.exit_code == 0 assert seen["source_type"] == "FileSource" # streamed the downloaded local file @@ -380,7 +380,7 @@ def test_stream_maps_turn_detection_flags(monkeypatch): def fake_stream_audio(api_key, source, *, params, **kw): captured["params"] = params - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream_audio) runner.invoke( app, @@ -403,7 +403,7 @@ def test_stream_config_escape_hatch(monkeypatch): config.set_api_key("default", "sk_live") captured = {} monkeypatch.setattr( - "assemblyai_cli.commands.stream.client.stream_audio", + "aai_cli.commands.stream.client.stream_audio", lambda api_key, source, *, params, **kw: captured.update(params=params), ) @@ -415,7 +415,7 @@ def test_stream_maps_webhook_auth_header(monkeypatch): config.set_api_key("default", "sk_live") captured = {} monkeypatch.setattr( - "assemblyai_cli.commands.stream.client.stream_audio", + "aai_cli.commands.stream.client.stream_audio", lambda api_key, source, *, params, **kw: captured.update(params=params), ) @@ -439,7 +439,7 @@ def test_stream_format_turns_tristate(monkeypatch): config.set_api_key("default", "sk_live") captured = {} monkeypatch.setattr( - "assemblyai_cli.commands.stream.client.stream_audio", + "aai_cli.commands.stream.client.stream_audio", lambda api_key, source, *, params, **kw: captured.update(params=params), ) @@ -454,7 +454,7 @@ def test_stream_show_code_prints_without_streaming(monkeypatch): # Print-only: emits the mic-streaming script, never opens audio or streams, no auth. called = [] monkeypatch.setattr( - "assemblyai_cli.commands.stream.client.stream_audio", + "aai_cli.commands.stream.client.stream_audio", lambda *a, **k: called.append(True), ) result = runner.invoke(app, ["stream", "--show-code"]) @@ -470,7 +470,7 @@ def _boom(*a, **k): raise AssertionError("must not stream") monkeypatch.setattr( - "assemblyai_cli.commands.stream.client.stream_audio", + "aai_cli.commands.stream.client.stream_audio", _boom, ) result = runner.invoke(app, ["stream", "--show-code", "--json"]) @@ -486,7 +486,7 @@ def fake_stream_audio(api_key, source, *, params, on_begin=None, **_kwargs): seen["rate"] = params.sample_rate seen["audio"] = b"".join(source) # consume the StdinSource - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream_audio) result = runner.invoke(app, ["stream", "-"], input=b"\x01\x02" * 100) assert result.exit_code == 0 assert seen["rate"] == 16000 # default raw-PCM rate @@ -508,7 +508,7 @@ def fake_stream_audio(api_key, source, *, params, on_begin=None, on_turn=None, * on_turn(types.SimpleNamespace(transcript="partial", end_of_turn=False)) on_turn(types.SimpleNamespace(transcript="hello world", end_of_turn=True)) - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", fake_stream_audio) result = runner.invoke(app, ["stream", "-", "-o", "text"], input=b"\x00\x00") assert result.exit_code == 0 # Final turn only, plain text; partials and JSON envelopes are not on stdout. @@ -520,7 +520,7 @@ def test_stream_show_code_with_llm_emits_follow_loop(monkeypatch): def _boom(*a, **k): raise AssertionError("must not stream") - monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _boom) + monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _boom) result = runner.invoke(app, ["stream", "--llm", "summarize", "--show-code"]) assert result.exit_code == 0 assert "from openai import OpenAI" in result.output diff --git a/tests/test_streaming_render.py b/tests/test_streaming_render.py index fca7be05..70c3e2fb 100644 --- a/tests/test_streaming_render.py +++ b/tests/test_streaming_render.py @@ -4,8 +4,8 @@ import pytest -from assemblyai_cli import theme -from assemblyai_cli.streaming.render import StreamRenderer +from aai_cli import theme +from aai_cli.streaming.render import StreamRenderer def _turn(transcript, end_of_turn): diff --git a/tests/test_streaming_sources.py b/tests/test_streaming_sources.py index b053cbf2..29517f76 100644 --- a/tests/test_streaming_sources.py +++ b/tests/test_streaming_sources.py @@ -3,9 +3,9 @@ import pytest -from assemblyai_cli.errors import CLIError -from assemblyai_cli.streaming import sources -from assemblyai_cli.streaming.sources import FileSource +from aai_cli.errors import CLIError +from aai_cli.streaming import sources +from aai_cli.streaming.sources import FileSource def _write_wav(path, *, seconds=0.5, rate=16000): @@ -150,7 +150,7 @@ def wait(self): pass monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FailProc()) - from assemblyai_cli.errors import APIError + from aai_cli.errors import APIError with pytest.raises(APIError): list(sources.FileSource(str(p), sleep=lambda _s: None)) diff --git a/tests/test_theme.py b/tests/test_theme.py index 7ebb96b3..b5724604 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -1,6 +1,6 @@ import io -from assemblyai_cli import theme +from aai_cli import theme def test_make_console_resolves_named_styles(): @@ -46,8 +46,8 @@ def test_speaker_style_deterministic_and_in_palette(): def test_output_console_is_themed_and_error_is_styled(monkeypatch): - from assemblyai_cli import output, theme - from assemblyai_cli.errors import CLIError + from aai_cli import output, theme + from aai_cli.errors import CLIError buf = io.StringIO() monkeypatch.setattr( diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 2a76b9c5..56ba883a 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -3,8 +3,8 @@ from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.main import app runner = CliRunner() @@ -40,7 +40,7 @@ def _enum_or_str(value): def test_transcribe_sample_prints_text(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: result = runner.invoke(app, ["transcribe", "--sample"]) assert result.exit_code == 0 @@ -58,7 +58,7 @@ def test_transcribe_requires_source(): def test_transcribe_passes_speaker_labels(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) assert tx.call_args.kwargs["config"].speaker_labels is True @@ -67,7 +67,7 @@ def test_transcribe_passes_speaker_labels(): def test_transcribe_json_output(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert '"id": "t_1"' in result.output @@ -81,7 +81,7 @@ def test_transcribe_unauthenticated_exits_2(): def test_transcribe_output_text_field(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text"]) assert result.exit_code == 0 @@ -91,7 +91,7 @@ def test_transcribe_output_text_field(): def test_transcribe_output_id_field(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): result = runner.invoke(app, ["transcribe", "audio.mp3", "--output", "id"]) assert result.exit_code == 0 @@ -102,7 +102,7 @@ def test_transcribe_output_srt_field(): _auth() t = _fake_transcript() t.export_subtitles_srt.return_value = "1\n00:00:00,000 --> 00:00:02,000\nhello world\n" - with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=t): result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "srt"]) assert result.exit_code == 0 assert "00:00:00,000 --> 00:00:02,000" in result.output # SRT body, pipe-friendly @@ -112,7 +112,7 @@ def test_transcribe_output_srt_field(): def test_transcribe_output_invalid_exits_2(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "bogus"]) assert result.exit_code == 2 # unknown field rejected @@ -129,7 +129,7 @@ def fake_transcribe(api_key, audio, *, config): seen["bytes"] = pathlib.Path(audio).read_bytes() return _fake_transcript() - monkeypatch.setattr("assemblyai_cli.commands.transcribe.client.transcribe", fake_transcribe) + monkeypatch.setattr("aai_cli.commands.transcribe.client.transcribe", fake_transcribe) result = runner.invoke(app, ["transcribe", "-", "-o", "text"], input=b"RIFFfake-wav-bytes") assert result.exit_code == 0 assert result.output.strip() == "hello world" @@ -149,7 +149,7 @@ def test_transcribe_status_renders_enum_value(): t = _fake_transcript() t.status = aai.TranscriptStatus.completed t.json_response = None - with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=t): result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert result.exit_code == 0 assert '"status": "completed"' in result.output @@ -166,10 +166,10 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens): return "a short summary" with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.llm.transform_transcript", fake_transform + "aai_cli.commands.transcribe.llm.transform_transcript", fake_transform ) result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize", "--json"]) assert result.exit_code == 0 @@ -195,10 +195,10 @@ def fake_transform( return f"out({prompt})" with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.llm.transform_transcript", fake_transform + "aai_cli.commands.transcribe.llm.transform_transcript", fake_transform ) result = runner.invoke( app, @@ -225,12 +225,12 @@ def fake_transform( def test_transcribe_prompt_human_shows_only_transform(monkeypatch): _auth() - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.llm.transform_transcript", + "aai_cli.commands.transcribe.llm.transform_transcript", lambda *a, **k: "TRANSFORMED", ) result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize"]) @@ -242,7 +242,7 @@ def test_transcribe_prompt_human_shows_only_transform(monkeypatch): def test_transcribe_prompt_biases_speech_model(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: result = runner.invoke(app, ["transcribe", "audio.mp3", "--prompt", "expect medical terms"]) assert result.exit_code == 0 @@ -253,7 +253,7 @@ def test_transcribe_prompt_biases_speech_model(): def test_transcribe_maps_analysis_flags(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: runner.invoke( app, @@ -277,7 +277,7 @@ def test_transcribe_maps_analysis_flags(): def test_transcribe_redact_pii_policy_csv(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: runner.invoke( app, @@ -300,7 +300,7 @@ def test_transcribe_redact_pii_policy_csv(): def test_transcribe_config_escape_hatch(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: runner.invoke(app, ["transcribe", "audio.mp3", "--config", "speech_threshold=0.5"]) assert tx.call_args.kwargs["config"].raw.speech_threshold == 0.5 @@ -309,7 +309,7 @@ def test_transcribe_config_escape_hatch(): def test_transcribe_unknown_config_field_exits_2(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ): result = runner.invoke(app, ["transcribe", "audio.mp3", "--config", "bogus=1"]) assert result.exit_code == 2 @@ -319,7 +319,7 @@ def test_transcribe_unknown_config_field_exits_2(): def test_transcribe_webhook_auth_header(): _auth() with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: runner.invoke( app, @@ -343,10 +343,10 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path fake = tmp_path / "vid.m4a" fake.write_bytes(b"x") monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake + "aai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake ) with patch( - "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: result = runner.invoke(app, ["transcribe", "https://youtu.be/abc", "--json"]) assert result.exit_code == 0 @@ -357,7 +357,7 @@ def test_transcribe_show_code_prints_without_transcribing(monkeypatch): # Print-only: emits code, never calls the API, needs no auth. called = [] monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.client.transcribe", + "aai_cli.commands.transcribe.client.transcribe", lambda *a, **k: called.append(True), ) result = runner.invoke(app, ["transcribe", "--sample", "--speaker-labels", "--show-code"]) @@ -374,7 +374,7 @@ def _boom(*a, **k): raise AssertionError("must not transcribe") monkeypatch.setattr( - "assemblyai_cli.commands.transcribe.client.transcribe", + "aai_cli.commands.transcribe.client.transcribe", _boom, ) result = runner.invoke(app, ["transcribe", "--sample", "--show-code", "--json"]) @@ -388,8 +388,8 @@ def test_transcribe_show_code_includes_llm_gateway_without_running(monkeypatch): def _boom(*a, **k): raise AssertionError("must not call the API") - monkeypatch.setattr("assemblyai_cli.commands.transcribe.client.transcribe", _boom) - monkeypatch.setattr("assemblyai_cli.commands.transcribe.llm.transform_transcript", _boom) + monkeypatch.setattr("aai_cli.commands.transcribe.client.transcribe", _boom) + monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", _boom) result = runner.invoke( app, ["transcribe", "--sample", "--llm", "translate to spanish", "--show-code"], @@ -402,11 +402,11 @@ def _boom(*a, **k): def test_transcribe_renders_summary_human(monkeypatch): _auth() - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) t = _fake_transcript() t.summary = "three bullet summary" t.chapters = [] - with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=t): result = runner.invoke(app, ["transcribe", "audio.mp3", "--summarization"]) assert result.exit_code == 0 assert "Summary:" in result.output diff --git a/tests/test_transcribe_render.py b/tests/test_transcribe_render.py index 41013341..5ca473a7 100644 --- a/tests/test_transcribe_render.py +++ b/tests/test_transcribe_render.py @@ -2,8 +2,8 @@ from rich.console import Console -from assemblyai_cli import theme -from assemblyai_cli import transcribe_render as tr +from aai_cli import theme +from aai_cli import transcribe_render as tr def _render(transcript) -> str: diff --git a/tests/test_transcripts.py b/tests/test_transcripts.py index 05846df1..12fc0724 100644 --- a/tests/test_transcripts.py +++ b/tests/test_transcripts.py @@ -2,8 +2,8 @@ from typer.testing import CliRunner -from assemblyai_cli import config -from assemblyai_cli.main import app +from aai_cli import config +from aai_cli.main import app runner = CliRunner() @@ -14,7 +14,7 @@ def test_get_prints_transcript_text(): fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" - with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): result = runner.invoke(app, ["transcripts", "get", "t_42"]) assert result.exit_code == 0 assert "retrieved text" in result.output @@ -26,7 +26,7 @@ def test_get_output_text_prints_raw(): fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" - with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "text"]) assert result.exit_code == 0 assert result.output.strip() == "retrieved text" @@ -38,7 +38,7 @@ def test_get_output_id_prints_id(): fake.id = "t_42" fake.text = "retrieved text" fake.status = "completed" - with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): result = runner.invoke(app, ["transcripts", "get", "t_42", "-o", "id"]) assert result.exit_code == 0 assert result.output.strip() == "t_42" @@ -53,7 +53,7 @@ def test_get_output_invalid_field_exits_2(): def test_list_renders_rows(): config.set_api_key("default", "sk_live") rows = [{"id": "t1", "status": "completed"}, {"id": "t2", "status": "processing"}] - with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows): result = runner.invoke(app, ["transcripts", "list", "--json"]) assert result.exit_code == 0 assert "t1" in result.output and "t2" in result.output @@ -66,9 +66,9 @@ def test_list_unauthenticated_exits_2(): def test_list_human_mode_renders_table(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] - with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows): result = runner.invoke(app, ["transcripts", "list"]) assert result.exit_code == 0 assert "t1" in result.output # rendered through the Rich table path @@ -82,26 +82,26 @@ def test_get_errored_transcript_exits_nonzero(): fake.id = "t_err" fake.status = "error" fake.error = "decode failed" - with patch("assemblyai_cli.commands.transcripts.client.get_transcript", return_value=fake): + with patch("aai_cli.commands.transcripts.client.get_transcript", return_value=fake): result = runner.invoke(app, ["transcripts", "get", "t_err"]) assert result.exit_code == 1 def test_list_table_colors_status(monkeypatch): - from assemblyai_cli.theme import make_console + from aai_cli.theme import make_console config.set_api_key("default", "sk_live") - monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) # Force a real color terminal so styling produces ANSI we can assert on. monkeypatch.setattr( - "assemblyai_cli.output.console", + "aai_cli.output.console", make_console(force_terminal=True, color_system="truecolor"), ) rows = [ {"id": "t1", "status": "completed", "created": "2026-01-01"}, {"id": "t2", "status": "error", "created": "2026-01-02"}, ] - with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + with patch("aai_cli.commands.transcripts.client.list_transcripts", return_value=rows): result = runner.invoke(app, ["transcripts", "list"], color=True) assert result.exit_code == 0 assert "completed" in result.output diff --git a/tests/test_youtube.py b/tests/test_youtube.py index d73de82f..ae19e5af 100644 --- a/tests/test_youtube.py +++ b/tests/test_youtube.py @@ -3,8 +3,8 @@ import pytest -from assemblyai_cli import youtube -from assemblyai_cli.errors import CLIError +from aai_cli import youtube +from aai_cli.errors import CLIError def test_is_youtube_url_variants(): diff --git a/uv.lock b/uv.lock index 7deded49..27d3a824 100644 --- a/uv.lock +++ b/uv.lock @@ -57,7 +57,7 @@ wheels = [ ] [[package]] -name = "assemblyai-cli" +name = "aai-cli" version = "0.1.0" source = { editable = "." } dependencies = [ From 864f7948eddbd2abaf70577504c99d7aae6aca68 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 13:24:52 -0700 Subject: [PATCH 10/15] feat(auth): sandbox Stytch/AMS config + OAuth state-cookie fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes found during end-to-end sandbox testing: - Start B2B OAuth discovery on test.stytch.com (Stytch's API domain), not the vanity *.customers.stytch.dev CNAME, so the CSRF state cookie is set on the same domain as the OAuth callback — fixes `oauth_invalid_state`. - Point at the Stytch project that ams.sandbox000 is bound to (public-token-test-a161155e…) so AMS authenticates the discovery token. - Add env-overridable SIGNUP_URL (sandbox default) and surface it in the friendlier 0-org "no AssemblyAI account yet" message. Verified end-to-end against sandbox: browser login → AMS discover/exchange → find-or-create "AssemblyAI CLI" key → stored in keychain; second login reuses the same key (idempotent). Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/auth/endpoints.py | 18 +++++++++++++++--- aai_cli/auth/flow.py | 3 ++- tests/test_auth_endpoints.py | 1 + tests/test_auth_flow.py | 2 +- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/aai_cli/auth/endpoints.py b/aai_cli/auth/endpoints.py index 0444c838..19da27c0 100644 --- a/aai_cli/auth/endpoints.py +++ b/aai_cli/auth/endpoints.py @@ -2,14 +2,19 @@ import os -# Stytch B2B project (sandbox defaults; override via env for prod). +# Stytch B2B project (sandbox/test defaults; override via env for prod). +# Use Stytch's API domain, NOT the vanity CNAME (…customers.stytch.dev): the OAuth +# callback runs on test.stytch.com, so the CSRF state cookie set by the discovery +# /start must be on the same domain. Starting on the CNAME sets the cookie there, +# the callback can't read it, and Stytch returns `oauth_invalid_state`. +# (Prod equivalent: https://api.stytch.com) STYTCH_PROJECT_DOMAIN = os.environ.get( "AAI_AUTH_STYTCH_DOMAIN", - "https://psychedelic-journey-5884.customers.stytch.dev", + "https://test.stytch.com", ) STYTCH_PUBLIC_TOKEN = os.environ.get( "AAI_AUTH_PUBLIC_TOKEN", - "public-token-test-79ad7d8d-09df-495e-8eb8-72d18efdafa4", + "public-token-test-a161155e-7e9b-4dd1-9d43-493c899b4117", ) STYTCH_OAUTH_PROVIDER = os.environ.get("AAI_AUTH_PROVIDER", "google") @@ -25,6 +30,13 @@ CLI_TOKEN_NAME = "AssemblyAI CLI" # noqa: S105 - display name, not a credential +# Where to send a first-time user who has no AssemblyAI account yet (sandbox +# default; override via env for prod, e.g. https://www.assemblyai.com/dashboard). +SIGNUP_URL = os.environ.get( + "AAI_AUTH_SIGNUP_URL", + "https://dashboard-assemblyai.vercel.app/dashboard/login", +) + def redirect_uri() -> str: """The exact loopback redirect URL registered in Stytch.""" diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index 3a44f7d2..928f66df 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -52,7 +52,8 @@ def run_login_flow() -> str: organizations = disc.get("organizations") or [] if not organizations: raise APIError( - "Signed in, but found no AssemblyAI organization for this account." + "Signed in, but this identity has no AssemblyAI account yet. " + f"Create one at {endpoints.SIGNUP_URL}, then run 'aai login' again." ) organization_id = organizations[0]["organization_id"] diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py index 5420574b..7c7d6215 100644 --- a/tests/test_auth_endpoints.py +++ b/tests/test_auth_endpoints.py @@ -10,6 +10,7 @@ def test_defaults_point_at_sandbox(): assert endpoints.STYTCH_OAUTH_PROVIDER == "google" assert endpoints.CLI_TOKEN_NAME == "AssemblyAI CLI" assert endpoints.STYTCH_PUBLIC_TOKEN.startswith("public-token-") + assert endpoints.SIGNUP_URL.startswith("https://") def test_env_override_changes_redirect_uri(monkeypatch): diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index c1fdb5df..8fcd6654 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -98,5 +98,5 @@ def test_run_login_flow_zero_orgs_raises(monkeypatch): "discover", lambda token: {"organizations": [], "email": "a@b.com", "intermediate_session_token": "ist"}, ) - with pytest.raises(APIError, match="no AssemblyAI organization"): + with pytest.raises(APIError, match="no AssemblyAI account"): flow.run_login_flow() From 0981578cb5663096a1dcab325ddf89829f615f8e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 13:48:49 -0700 Subject: [PATCH 11/15] feat: --env/--sandbox environment switching, bound to profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a named-environment registry (production, sandbox000) mapping the full backend host set: api_base (/v2 upload+transcript), streaming_host (/v3/ws), llm_gateway_base (/v1), AMS, and Stytch domain/public-token/signup URL. Select with the global --env / --sandbox flags or AAI_ENV; `aai login` records the resolved env on the profile so a credential and its hosts always stay matched — this structurally prevents the sandbox-key-against-prod-host rejection (HTTP 1008 / 401). Precedence: --env > AAI_ENV > profile > default. client.py (SDK base_url + streaming api_host), llm.py (gateway base), and the auth modules now resolve hosts from the active environment instead of hardcoded prod literals; whoami reports the active env. Default is sandbox000 for now; flip DEFAULT_ENV to production once prod AMS/Stytch values are stood up. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/auth/ams.py | 10 ++-- aai_cli/auth/discovery.py | 4 +- aai_cli/auth/endpoints.py | 48 +++++++++---------- aai_cli/auth/flow.py | 2 +- aai_cli/client.py | 7 ++- aai_cli/commands/login.py | 15 ++++-- aai_cli/config.py | 15 ++++++ aai_cli/context.py | 10 +++- aai_cli/environments.py | 89 ++++++++++++++++++++++++++++++++++++ aai_cli/llm.py | 6 ++- aai_cli/main.py | 19 ++++++-- tests/conftest.py | 18 +++++++- tests/test_auth_discovery.py | 4 +- tests/test_auth_endpoints.py | 13 ++++-- tests/test_environments.py | 50 ++++++++++++++++++++ tests/test_login.py | 33 +++++++++++++ 16 files changed, 289 insertions(+), 54 deletions(-) create mode 100644 aai_cli/environments.py create mode 100644 tests/test_environments.py diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 338b84a3..4c377ba2 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -30,7 +30,7 @@ def _json_or_raise(resp: httpx.Response) -> Any: def discover(token: str) -> dict[str, Any]: """POST /v2/auth/discover with a discovery_oauth token -> {orgs, email, IST}.""" - with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + with httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT) as client: resp = client.post( "/v2/auth/discover", json={"token": token, "token_type": "discovery_oauth"}, @@ -40,7 +40,7 @@ def discover(token: str) -> dict[str, Any]: def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]: """POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}.""" - with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + with httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT) as client: resp = client.post( "/v2/auth/exchange", json={ @@ -54,7 +54,7 @@ def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, def get_auth(session_jwt: str) -> dict[str, Any]: """GET /v1/auth (session cookie) -> account incl. `id`.""" with httpx.Client( - base_url=endpoints.AMS_BASE_URL, + base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies={"stytch_session_jwt": session_jwt}, ) as client: @@ -65,7 +65,7 @@ def get_auth(session_jwt: str) -> dict[str, Any]: def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]: """GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}].""" with httpx.Client( - base_url=endpoints.AMS_BASE_URL, + base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies={"stytch_session_jwt": session_jwt}, ) as client: @@ -78,7 +78,7 @@ def create_token( ) -> dict[str, Any]: """POST /v1/users/accounts/{id}/tokens -> TokenSchema incl. `api_key`.""" with httpx.Client( - base_url=endpoints.AMS_BASE_URL, + base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies={"stytch_session_jwt": session_jwt}, ) as client: diff --git a/aai_cli/auth/discovery.py b/aai_cli/auth/discovery.py index 14201652..f1c46916 100644 --- a/aai_cli/auth/discovery.py +++ b/aai_cli/auth/discovery.py @@ -15,11 +15,11 @@ def build_start_url() -> str: server is loopback-only, single-shot. """ base = ( - f"{endpoints.STYTCH_PROJECT_DOMAIN}" + f"{endpoints.stytch_domain()}" f"/v1/b2b/public/oauth/{endpoints.STYTCH_OAUTH_PROVIDER}/discovery/start" ) params = { - "public_token": endpoints.STYTCH_PUBLIC_TOKEN, + "public_token": endpoints.stytch_public_token(), "discovery_redirect_url": endpoints.redirect_uri(), } return f"{base}?{urlencode(params)}" diff --git a/aai_cli/auth/endpoints.py b/aai_cli/auth/endpoints.py index 19da27c0..c1b59971 100644 --- a/aai_cli/auth/endpoints.py +++ b/aai_cli/auth/endpoints.py @@ -2,40 +2,36 @@ import os -# Stytch B2B project (sandbox/test defaults; override via env for prod). -# Use Stytch's API domain, NOT the vanity CNAME (…customers.stytch.dev): the OAuth -# callback runs on test.stytch.com, so the CSRF state cookie set by the discovery -# /start must be on the same domain. Starting on the CNAME sets the cookie there, -# the callback can't read it, and Stytch returns `oauth_invalid_state`. -# (Prod equivalent: https://api.stytch.com) -STYTCH_PROJECT_DOMAIN = os.environ.get( - "AAI_AUTH_STYTCH_DOMAIN", - "https://test.stytch.com", -) -STYTCH_PUBLIC_TOKEN = os.environ.get( - "AAI_AUTH_PUBLIC_TOKEN", - "public-token-test-a161155e-7e9b-4dd1-9d43-493c899b4117", -) -STYTCH_OAUTH_PROVIDER = os.environ.get("AAI_AUTH_PROVIDER", "google") +from aai_cli import environments + +# Constant across environments. +STYTCH_OAUTH_PROVIDER = "google" +CLI_TOKEN_NAME = "AssemblyAI CLI" # noqa: S105 - display name, not a credential # Fixed loopback (Stytch does exact-match redirect validation; 8585 is registered). LOOPBACK_HOST = "127.0.0.1" LOOPBACK_PORT = int(os.environ.get("AAI_AUTH_PORT", "8585")) LOOPBACK_PATH = "/callback" -# Accounts Management Service. -AMS_BASE_URL = os.environ.get( - "AAI_AUTH_AMS_URL", "https://ams.sandbox000.assemblyai-labs.com" -) -CLI_TOKEN_NAME = "AssemblyAI CLI" # noqa: S105 - display name, not a credential +# Environment-specific values resolve from the active environment (see +# aai_cli.environments). The B2B OAuth flow starts on Stytch's API domain (not a +# vanity CNAME) so the CSRF state cookie is set on the same domain as the OAuth +# callback — otherwise Stytch returns `oauth_invalid_state`. +def stytch_domain() -> str: + return environments.active().stytch_domain + + +def stytch_public_token() -> str: + return environments.active().stytch_public_token + + +def ams_base() -> str: + return environments.active().ams_base + -# Where to send a first-time user who has no AssemblyAI account yet (sandbox -# default; override via env for prod, e.g. https://www.assemblyai.com/dashboard). -SIGNUP_URL = os.environ.get( - "AAI_AUTH_SIGNUP_URL", - "https://dashboard-assemblyai.vercel.app/dashboard/login", -) +def signup_url() -> str: + return environments.active().signup_url def redirect_uri() -> str: diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index 928f66df..2fe41a87 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -53,7 +53,7 @@ def run_login_flow() -> str: if not organizations: raise APIError( "Signed in, but this identity has no AssemblyAI account yet. " - f"Create one at {endpoints.SIGNUP_URL}, then run 'aai login' again." + f"Create one at {endpoints.signup_url()}, then run 'aai login' again." ) organization_id = organizations[0]["organization_id"] diff --git a/aai_cli/client.py b/aai_cli/client.py index fe7cc0a4..90256bc7 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -12,7 +12,7 @@ StreamingParameters, ) -from aai_cli import stdio +from aai_cli import environments, stdio from aai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" @@ -32,6 +32,7 @@ def resolve_audio_source(source: str | None, *, sample: bool) -> str: def _configure(api_key: str) -> None: aai.settings.api_key = api_key + aai.settings.base_url = environments.active().api_base def validate_key(api_key: str) -> bool: @@ -156,7 +157,9 @@ def stream_audio( `params` is a fully-built StreamingParameters (sample_rate/speech_model/etc). """ sc = StreamingClient( - StreamingClientOptions(api_key=api_key, api_host="streaming.assemblyai.com") + StreamingClientOptions( + api_key=api_key, api_host=environments.active().streaming_host + ) ) def _guard(cb: Callable[[Any], Any]) -> Callable[[Any, Any], None]: diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 3d6600c3..ff4b765d 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -3,7 +3,7 @@ import typer from rich.markup import escape -from aai_cli import client, config, output +from aai_cli import client, config, environments, output from aai_cli.auth import run_login_flow from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import APIError, NotAuthenticated @@ -28,10 +28,13 @@ def body(state: AppState, json_mode: bool) -> None: key = api_key else: key = run_login_flow() + env = environments.active().name config.set_api_key(profile, key) + config.set_profile_env(profile, env) output.emit( - {"authenticated": True, "profile": profile}, - lambda _d: f"[aai.success]Authenticated[/aai.success] on profile '{escape(profile)}'.", + {"authenticated": True, "profile": profile, "env": env}, + lambda _d: f"[aai.success]Authenticated[/aai.success] on profile " + f"'{escape(profile)}' (env: {escape(env)}).", json_mode=json_mode, ) @@ -70,10 +73,12 @@ def body(state: AppState, json_mode: bool) -> None: if not key: raise NotAuthenticated() masked = f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" + env = environments.active().name reachable = client.validate_key(key) output.emit( - {"profile": profile, "api_key": masked, "reachable": reachable}, - lambda _d: f"profile={escape(profile)} key={escape(masked)} reachable={reachable}", + {"profile": profile, "env": env, "api_key": masked, "reachable": reachable}, + lambda _d: f"profile={escape(profile)} env={escape(env)} " + f"key={escape(masked)} reachable={reachable}", json_mode=json_mode, ) diff --git a/aai_cli/config.py b/aai_cli/config.py index a84d4deb..81c5b3be 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -81,6 +81,21 @@ def get_api_key(profile: str) -> str | None: return keyring.get_password(KEYRING_SERVICE, profile) +def get_profile_env(profile: str) -> str | None: + """The backend environment recorded for a profile, if any (e.g. 'sandbox000').""" + profiles = _load().get("profiles", {}) + value = profiles.get(profile, {}).get("env") + return str(value) if value is not None else None + + +def set_profile_env(profile: str, env: str) -> None: + """Bind a backend environment to a profile so its key and hosts stay matched.""" + _validate_profile(profile) + data = _load() + data.setdefault("profiles", {}).setdefault(profile, {})["env"] = env + _dump(data) + + def clear_api_key(profile: str) -> None: with contextlib.suppress(keyring.errors.PasswordDeleteError): keyring.delete_password(KEYRING_SERVICE, profile) diff --git a/aai_cli/context.py b/aai_cli/context.py index 98643c50..453fd9e1 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -5,13 +5,15 @@ import typer -from aai_cli import config, output +from aai_cli import config, environments, output +from aai_cli.environments import Environment from aai_cli.errors import CLIError @dataclass class AppState: profile: str | None = None + env: str | None = None def resolve_profile(state: AppState) -> str: @@ -19,6 +21,12 @@ def resolve_profile(state: AppState) -> str: return state.profile or config.get_active_profile() +def resolve_environment(state: AppState) -> Environment: + """The backend environment: --env > AAI_ENV > the profile's stored env > default.""" + profile_env = config.get_profile_env(resolve_profile(state)) + return environments.resolve(state.env, profile_env) + + def run_command( ctx: typer.Context, fn: Callable[[AppState, bool], None], *, json: bool = False ) -> None: diff --git a/aai_cli/environments.py b/aai_cli/environments.py new file mode 100644 index 00000000..4554464c --- /dev/null +++ b/aai_cli/environments.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +from aai_cli.errors import CLIError + + +@dataclass(frozen=True) +class Environment: + """The full set of backend hosts for one deployment (prod, sandbox, …). + + A credential is only valid against its own environment's hosts, so the + environment is bound to the profile that minted it (see commands/login). + """ + + name: str + api_base: str # SDK base_url for /v2/upload + /v2/transcript + streaming_host: str # StreamingClientOptions.api_host (SDK builds wss://host/v3/ws) + llm_gateway_base: str # OpenAI base_url for the LLM Gateway (…/v1) + ams_base: str # Accounts Management Service + stytch_domain: str # Stytch API domain for B2B OAuth discovery + stytch_public_token: str # client-side public token (safe to ship) + signup_url: str # where a first-time user creates an account + + +ENVIRONMENTS: dict[str, Environment] = { + "production": Environment( + name="production", + api_base="https://api.assemblyai.com", + streaming_host="streaming.assemblyai.com", + llm_gateway_base="https://llm-gateway.assemblyai.com/v1", + # TODO: production AMS + Stytch values are not stood up yet (spec P2/O4). + ams_base="https://ams.assemblyai.com", + stytch_domain="https://api.stytch.com", + stytch_public_token="public-token-live-REPLACE_ME", # noqa: S106 - public token, safe to ship + signup_url="https://www.assemblyai.com/dashboard", + ), + "sandbox000": Environment( + name="sandbox000", + api_base="https://api.sandbox000.assemblyai-labs.com", + streaming_host="streaming.sandbox000.assemblyai-labs.com", + llm_gateway_base="https://llm-gateway.sandbox000.assemblyai-labs.com/v1", + ams_base="https://ams.sandbox000.assemblyai-labs.com", + stytch_domain="https://test.stytch.com", + stytch_public_token="public-token-test-a161155e-7e9b-4dd1-9d43-493c899b4117", # noqa: S106 - public token, safe to ship + signup_url="https://dashboard-assemblyai.vercel.app/dashboard/login", + ), +} + +# Shipped default when nothing selects an environment. Flip to "production" at +# release once the prod AMS/Stytch values above are real. +DEFAULT_ENV = "sandbox000" + +# The environment in effect for this process, set once at CLI startup (like +# aai.settings). Resolved from --env / AAI_ENV / the profile's stored env. +_active: Environment | None = None + + +def known() -> tuple[str, ...]: + return tuple(ENVIRONMENTS) + + +def get(name: str) -> Environment: + """The named environment, or a clean CLIError if it's unknown.""" + env = ENVIRONMENTS.get(name) + if env is None: + raise CLIError( + f"Unknown environment {name!r}. Known: {', '.join(ENVIRONMENTS)}.", + error_type="invalid_environment", + exit_code=2, + ) + return env + + +def resolve(flag: str | None, profile_env: str | None) -> Environment: + """Pick the environment by precedence: --env flag > AAI_ENV > profile > default.""" + name = flag or os.environ.get("AAI_ENV") or profile_env or DEFAULT_ENV + return get(name) + + +def set_active(env: Environment) -> None: + global _active + _active = env + + +def active() -> Environment: + """The environment in effect; falls back to the default if startup didn't set one.""" + return _active if _active is not None else get(DEFAULT_ENV) diff --git a/aai_cli/llm.py b/aai_cli/llm.py index 48cedfdf..cb504fe9 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -5,10 +5,12 @@ import openai from openai import OpenAI +from aai_cli import environments from aai_cli.errors import APIError, auth_failure # The LLM Gateway is OpenAI-compatible, so we talk to it through the OpenAI SDK -# pointed at this base URL. (The synchronous gateway has no assemblyai-SDK client.) +# pointed at this base URL. This is the production host used in generated code +# snippets (code_gen); runtime calls use the active environment's gateway base. GATEWAY_BASE_URL = "https://llm-gateway.assemblyai.com/v1" DEFAULT_MODEL = "claude-haiku-4-5-20251001" DEFAULT_MAX_TOKENS = 1000 @@ -58,7 +60,7 @@ def build_messages( def _client(api_key: str) -> OpenAI: - return OpenAI(api_key=api_key, base_url=GATEWAY_BASE_URL) + return OpenAI(api_key=api_key, base_url=environments.active().llm_gateway_base) def complete( diff --git a/aai_cli/main.py b/aai_cli/main.py index 69be892e..8d237269 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -11,7 +11,7 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from aai_cli import __version__, stdio +from aai_cli import __version__, environments, stdio from aai_cli.commands import ( agent, claude, @@ -23,7 +23,8 @@ transcribe, transcripts, ) -from aai_cli.context import AppState +from aai_cli.context import AppState, resolve_environment +from aai_cli.errors import CLIError # The order commands appear under `aai --help`: core transcription first, then # voice/LLM, then account, then tooling, with `version` last. Names not listed @@ -71,8 +72,20 @@ def list_commands(self, ctx: ClickContext) -> list[str]: def main( ctx: typer.Context, profile: str = typer.Option(None, "--profile", "-p", help="Named credential profile."), + env: str = typer.Option( + None, "--env", help="Backend environment (production, sandbox000)." + ), + sandbox: bool = typer.Option(False, "--sandbox", help="Shortcut for --env sandbox000."), ) -> None: - ctx.obj = AppState(profile=profile) + if sandbox and env is None: + env = "sandbox000" + state = AppState(profile=profile, env=env) + ctx.obj = state + try: + environments.set_active(resolve_environment(state)) + except CLIError as err: + typer.echo(err.message, err=True) + raise typer.Exit(code=err.exit_code) from None # Commands are registered in the order they should appear in `aai --help`: diff --git a/tests/conftest.py b/tests/conftest.py index c398aeba..c8c0157e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,10 +41,26 @@ def delete_password(self, service, username): @pytest.fixture(autouse=True) def isolate_env(monkeypatch): - for var in ("ASSEMBLYAI_API_KEY", "CI", "CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "NO_COLOR"): + for var in ( + "ASSEMBLYAI_API_KEY", + "AAI_ENV", + "CI", + "CLAUDECODE", + "CLAUDE_CODE_ENTRYPOINT", + "NO_COLOR", + ): monkeypatch.delenv(var, raising=False) +@pytest.fixture(autouse=True) +def reset_active_environment(): + # The active environment is a process-global (set at CLI startup); pin it to + # the default before each test so unit tests aren't affected by ordering. + from aai_cli import environments + + environments.set_active(environments.get(environments.DEFAULT_ENV)) + + @pytest.fixture(autouse=True) def memory_keyring(): backend = MemoryKeyring() diff --git a/tests/test_auth_discovery.py b/tests/test_auth_discovery.py index b6230ba5..d19383b5 100644 --- a/tests/test_auth_discovery.py +++ b/tests/test_auth_discovery.py @@ -8,11 +8,11 @@ def test_build_start_url_targets_b2b_discovery_for_provider(): parsed = urlparse(url) assert parsed.scheme == "https" assert parsed.path == "/v1/b2b/public/oauth/google/discovery/start" - assert url.startswith(endpoints.STYTCH_PROJECT_DOMAIN) + assert url.startswith(endpoints.stytch_domain()) def test_build_start_url_includes_public_token_and_redirect(): url = discovery.build_start_url() qs = parse_qs(urlparse(url).query) - assert qs["public_token"] == [endpoints.STYTCH_PUBLIC_TOKEN] + assert qs["public_token"] == [endpoints.stytch_public_token()] assert qs["discovery_redirect_url"] == ["http://127.0.0.1:8585/callback"] diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py index 7c7d6215..3e162bc0 100644 --- a/tests/test_auth_endpoints.py +++ b/tests/test_auth_endpoints.py @@ -5,12 +5,17 @@ def test_redirect_uri_is_fixed_loopback(): assert endpoints.redirect_uri() == "http://127.0.0.1:8585/callback" -def test_defaults_point_at_sandbox(): - assert endpoints.AMS_BASE_URL == "https://ams.sandbox000.assemblyai-labs.com" +def test_env_specific_values_come_from_active_environment(): + # With no active env set, helpers fall back to the default (sandbox000). + assert endpoints.ams_base() == "https://ams.sandbox000.assemblyai-labs.com" + assert endpoints.stytch_domain() == "https://test.stytch.com" + assert endpoints.stytch_public_token().startswith("public-token-") + assert endpoints.signup_url().startswith("https://") + + +def test_constants_are_environment_independent(): assert endpoints.STYTCH_OAUTH_PROVIDER == "google" assert endpoints.CLI_TOKEN_NAME == "AssemblyAI CLI" - assert endpoints.STYTCH_PUBLIC_TOKEN.startswith("public-token-") - assert endpoints.SIGNUP_URL.startswith("https://") def test_env_override_changes_redirect_uri(monkeypatch): diff --git a/tests/test_environments.py b/tests/test_environments.py new file mode 100644 index 00000000..3c9d29a8 --- /dev/null +++ b/tests/test_environments.py @@ -0,0 +1,50 @@ +import pytest + +from aai_cli import config, environments +from aai_cli.errors import CLIError + + +def test_known_includes_production_and_sandbox(): + assert "production" in environments.known() + assert "sandbox000" in environments.known() + + +def test_get_returns_named_environment(): + env = environments.get("sandbox000") + assert env.name == "sandbox000" + assert env.streaming_host == "streaming.sandbox000.assemblyai-labs.com" + assert env.api_base == "https://api.sandbox000.assemblyai-labs.com" + assert env.llm_gateway_base == "https://llm-gateway.sandbox000.assemblyai-labs.com/v1" + + +def test_get_unknown_raises_cli_error(): + with pytest.raises(CLIError) as exc: + environments.get("nope") + assert exc.value.exit_code == 2 + + +def test_resolve_precedence(monkeypatch): + monkeypatch.delenv("AAI_ENV", raising=False) + # default when nothing is provided + assert environments.resolve(None, None).name == environments.DEFAULT_ENV + # profile-stored env beats default + assert environments.resolve(None, "production").name == "production" + # AAI_ENV beats the profile + monkeypatch.setenv("AAI_ENV", "sandbox000") + assert environments.resolve(None, "production").name == "sandbox000" + # explicit flag beats everything + assert environments.resolve("production", "sandbox000").name == "production" + + +def test_set_active_and_active(): + environments.set_active(environments.get("production")) + try: + assert environments.active().name == "production" + finally: + environments.set_active(environments.get(environments.DEFAULT_ENV)) + + +def test_profile_env_roundtrip(): + assert config.get_profile_env("default") is None + config.set_profile_env("default", "sandbox000") + assert config.get_profile_env("default") == "sandbox000" diff --git a/tests/test_login.py b/tests/test_login.py index a18844de..0bc52688 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -84,3 +84,36 @@ def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): result = runner.invoke(app, ["login", "--api-key", "sk_flag2"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_flag2" + + +def test_login_binds_env_to_profile(monkeypatch): + monkeypatch.setattr( + "aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" + ) + result = runner.invoke(app, ["--env", "sandbox000", "login"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_from_oauth" + assert config.get_profile_env("default") == "sandbox000" + + +def test_sandbox_flag_is_shortcut_for_env(monkeypatch): + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: "sk_x") + result = runner.invoke(app, ["--sandbox", "login"]) + assert result.exit_code == 0 + assert config.get_profile_env("default") == "sandbox000" + + +def test_whoami_reports_env(): + import json + + config.set_api_key("default", "sk_1234567890") + with patch("aai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["--env", "production", "whoami", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["env"] == "production" + + +def test_unknown_env_exits_2(): + result = runner.invoke(app, ["--env", "bogus", "whoami"]) + assert result.exit_code == 2 From 5290b6a6a87abd319d43ed231e5c66ca39db9541 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 13:55:44 -0700 Subject: [PATCH 12/15] fix(llm): surface LLM Gateway access errors instead of "run aai login" The gateway returns 401/403 both for an invalid key and for a plan-entitlement block ("Your account does not have access to LLM Gateway"). Mapping that to auth_failure() told unpaid accounts to re-run `aai login`, which never helps. Surface the gateway's own message as APIError so the cause is actionable. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/llm.py | 12 ++++++++---- tests/test_llm.py | 10 +++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/aai_cli/llm.py b/aai_cli/llm.py index cb504fe9..288c6dc9 100644 --- a/aai_cli/llm.py +++ b/aai_cli/llm.py @@ -6,7 +6,7 @@ from openai import OpenAI from aai_cli import environments -from aai_cli.errors import APIError, auth_failure +from aai_cli.errors import APIError # The LLM Gateway is OpenAI-compatible, so we talk to it through the OpenAI SDK # pointed at this base URL. This is the production host used in generated code @@ -74,8 +74,8 @@ def complete( """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. Auth failures map to NotAuthenticated - and everything else to APIError, matching the rest of the CLI. + inject the transcript text server-side. Access/permission and other gateway + errors surface the gateway's own message as APIError. """ client = _client(api_key) extra_body = {"transcript_id": transcript_id} if transcript_id is not None else None @@ -87,7 +87,11 @@ def complete( extra_body=extra_body, ) except (openai.AuthenticationError, openai.PermissionDeniedError) as exc: - raise auth_failure() from exc + # The gateway returns 401/403 for both an invalid key and a plan + # entitlement block ("no access to LLM Gateway"), so surface its actual + # message rather than a generic "run aai login" that misleads unpaid + # accounts (the key is fine; the feature requires a paid plan). + raise APIError(f"LLM Gateway access denied: {exc}") from exc except openai.OpenAIError as exc: raise APIError(f"LLM Gateway request failed: {exc}") from exc diff --git a/tests/test_llm.py b/tests/test_llm.py index f476bf3c..225b26e5 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -5,7 +5,7 @@ import pytest from aai_cli import llm -from aai_cli.errors import APIError, NotAuthenticated +from aai_cli.errors import APIError _REQUEST = httpx.Request("POST", f"{llm.GATEWAY_BASE_URL}/chat/completions") @@ -55,21 +55,21 @@ def test_complete_passes_transcript_id_as_extra_body(monkeypatch): assert seen["extra_body"] == {"transcript_id": "t_42"} -def test_complete_auth_error_maps_to_not_authenticated(monkeypatch): +def test_complete_auth_error_surfaces_gateway_message(monkeypatch): err = openai.AuthenticationError( "bad key", response=httpx.Response(401, request=_REQUEST), body=None ) _fake_client(monkeypatch, error=err) - with pytest.raises(NotAuthenticated): + with pytest.raises(APIError, match="access denied"): llm.complete("sk", model="m", messages=[]) -def test_complete_permission_error_maps_to_not_authenticated(monkeypatch): +def test_complete_permission_error_surfaces_gateway_message(monkeypatch): err = openai.PermissionDeniedError( "forbidden", response=httpx.Response(403, request=_REQUEST), body=None ) _fake_client(monkeypatch, error=err) - with pytest.raises(NotAuthenticated): + with pytest.raises(APIError, match="access denied"): llm.complete("sk", model="m", messages=[]) From c28c6834d7182e4ad482e70882a27f7c82cc9f28 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 14:29:51 -0700 Subject: [PATCH 13/15] fix(auth): harden OAuth login flow and env handling against bad input Address code-review findings in the new browser-OAuth login + environment switching. All changes are defensive; the happy path is unchanged. - loopback: map a port-bind OSError (port 8585 already in use) to a clean APIError instead of letting a raw traceback escape run_login_flow. - flow: tolerate AMS responses that key the token name as `token_name` (not `name`) and skip tokens whose `api_key` the list omits, so an existing CLI token is reused rather than crashing or duplicated. - flow: note the chosen organization when an identity has more than one, instead of silently picking the first. - flow: wrap required response fields in `_require()` so an unexpected 200 shape surfaces as "Run 'aai login' again" rather than a KeyError. - flow: read the account id from the exchange() response instead of a second GET /v1/auth round-trip. - config: map a malformed config.toml (TOMLDecodeError) to a clean CLIError so every command no longer aborts with a traceback. - context/main: warn when an explicit --env contradicts the profile's stored env (the stored key won't authenticate against the other hosts). - ams: extract a shared _client() helper for the five httpx.Client blocks. Adds regression tests for each (TDD). 545 pass, 93% branch coverage. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/auth/ams.py | 28 +++----- aai_cli/auth/flow.py | 61 ++++++++++++---- aai_cli/auth/loopback.py | 10 ++- aai_cli/config.py | 11 ++- aai_cli/context.py | 18 +++++ aai_cli/main.py | 9 +-- tests/test_auth_flow.py | 139 ++++++++++++++++++++++++++++++++++-- tests/test_auth_loopback.py | 19 +++++ tests/test_config.py | 8 +++ tests/test_context.py | 22 +++++- 10 files changed, 282 insertions(+), 43 deletions(-) diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 4c377ba2..37bae2f7 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -28,9 +28,15 @@ def _json_or_raise(resp: httpx.Response) -> Any: return resp.json() +def _client(session_jwt: str | None = None) -> httpx.Client: + """An AMS HTTP client; pass a session JWT to send the authenticated cookie.""" + cookies = {"stytch_session_jwt": session_jwt} if session_jwt else None + return httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies=cookies) + + def discover(token: str) -> dict[str, Any]: """POST /v2/auth/discover with a discovery_oauth token -> {orgs, email, IST}.""" - with httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT) as client: + with _client() as client: resp = client.post( "/v2/auth/discover", json={"token": token, "token_type": "discovery_oauth"}, @@ -40,7 +46,7 @@ def discover(token: str) -> dict[str, Any]: def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]: """POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}.""" - with httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT) as client: + with _client() as client: resp = client.post( "/v2/auth/exchange", json={ @@ -53,22 +59,14 @@ def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, def get_auth(session_jwt: str) -> dict[str, Any]: """GET /v1/auth (session cookie) -> account incl. `id`.""" - with httpx.Client( - base_url=endpoints.ams_base(), - timeout=_TIMEOUT, - cookies={"stytch_session_jwt": session_jwt}, - ) as client: + with _client(session_jwt) as client: resp = client.get("/v1/auth") return cast(dict[str, Any], _json_or_raise(resp)) def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]: """GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}].""" - with httpx.Client( - base_url=endpoints.ams_base(), - timeout=_TIMEOUT, - cookies={"stytch_session_jwt": session_jwt}, - ) as client: + with _client(session_jwt) as client: resp = client.get(f"/v1/users/accounts/{account_id}/projects") return cast(list[dict[str, Any]], _json_or_raise(resp)) @@ -77,11 +75,7 @@ def create_token( account_id: int, project_id: int, token_name: str, session_jwt: str ) -> dict[str, Any]: """POST /v1/users/accounts/{id}/tokens -> TokenSchema incl. `api_key`.""" - with httpx.Client( - base_url=endpoints.ams_base(), - timeout=_TIMEOUT, - cookies={"stytch_session_jwt": session_jwt}, - ) as client: + with _client(session_jwt) as client: resp = client.post( f"/v1/users/accounts/{account_id}/tokens", json={"project_id": project_id, "token_name": token_name}, diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index 2fe41a87..f229422b 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -1,12 +1,28 @@ from __future__ import annotations import webbrowser +from typing import Any from aai_cli import output from aai_cli.auth import ams, discovery, endpoints, loopback from aai_cli.errors import APIError +def _require(mapping: Any, key: str, what: str) -> Any: + """Pull a required field out of an AMS response, or raise a clean APIError. + + AMS only returns HTTP errors for outright failures; a 200 with an unexpected + shape would otherwise KeyError into an ugly traceback, so map that to the same + "run login again" message the rest of the flow uses. + """ + value = mapping.get(key) if isinstance(mapping, dict) else None + if value is None: + raise APIError( + f"Login failed: the server response was missing {what}. Run 'aai login' again." + ) + return value + + def _open_browser(url: str) -> None: """Open the system browser, falling back to printing the URL.""" output.console.print(f"Opening your browser to sign in:\n {url}") @@ -22,6 +38,19 @@ def _capture() -> loopback.CallbackResult: return loopback.capture_callback() +def _is_reusable_cli_token(token: dict[str, Any]) -> bool: + """A live 'AssemblyAI CLI' token whose key the list actually exposes.""" + # List endpoints may key the display name as either "name" or "token_name" + # (the latter matches the create payload); accept either so we don't mint a + # duplicate every login. A token whose api_key the list omits can't be reused. + name = token.get("name") or token.get("token_name") + return ( + name == endpoints.CLI_TOKEN_NAME + and not token.get("is_disabled") + and bool(token.get("api_key")) + ) + + def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" projects = ams.list_projects(account_id, session_jwt) @@ -29,13 +58,11 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: raise APIError("Your account has no project to create an API key in.") for entry in projects: for token in entry.get("tokens", []): - if token.get("name") == endpoints.CLI_TOKEN_NAME and not token.get("is_disabled"): + if _is_reusable_cli_token(token): return str(token["api_key"]) - project_id = projects[0]["project"]["id"] - created = ams.create_token( - account_id, project_id, endpoints.CLI_TOKEN_NAME, session_jwt - ) - return str(created["api_key"]) + project_id = _require(_require(projects[0], "project", "a project"), "id", "a project id") + created = ams.create_token(account_id, project_id, endpoints.CLI_TOKEN_NAME, session_jwt) + return str(_require(created, "api_key", "an API key")) def run_login_flow() -> str: @@ -55,10 +82,20 @@ def run_login_flow() -> str: "Signed in, but this identity has no AssemblyAI account yet. " f"Create one at {endpoints.signup_url()}, then run 'aai login' again." ) - organization_id = organizations[0]["organization_id"] - - signed_in = ams.exchange(disc["intermediate_session_token"], organization_id) - session_jwt = signed_in["session_jwt"] + if len(organizations) > 1: + chosen = organizations[0].get("organization_name") or organizations[0].get( + "organization_id", "the first" + ) + output.console.print( + f"[aai.muted]Found {len(organizations)} organizations; signing in to " + f"'{chosen}'.[/aai.muted]" + ) + organization_id = _require(organizations[0], "organization_id", "an organization id") - account = ams.get_auth(session_jwt) - return find_or_create_cli_key(int(account["id"]), session_jwt) + intermediate_session_token = _require(disc, "intermediate_session_token", "a session token") + signed_in = ams.exchange(intermediate_session_token, organization_id) + session_jwt = _require(signed_in, "session_jwt", "a session token") + # `exchange` already returns the signed-in account, so read the id from it + # rather than making a second GET /v1/auth round-trip. + account_id = _require(_require(signed_in, "account", "an account"), "id", "an account id") + return find_or_create_cli_key(int(account_id), session_jwt) diff --git a/aai_cli/auth/loopback.py b/aai_cli/auth/loopback.py index 276f5a60..179653fe 100644 --- a/aai_cli/auth/loopback.py +++ b/aai_cli/auth/loopback.py @@ -6,6 +6,7 @@ from urllib.parse import parse_qs, urlparse from aai_cli.auth import endpoints +from aai_cli.errors import APIError _SUCCESS_HTML = ( b"" @@ -48,7 +49,14 @@ def do_GET(self) -> None: # stdlib API name def log_message(self, *args: object) -> None: # silence stderr logging pass - server = HTTPServer((endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT), Handler) + try: + server = HTTPServer((endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT), Handler) + except OSError as exc: + raise APIError( + f"Could not start the login callback server on " + f"{endpoints.LOOPBACK_HOST}:{endpoints.LOOPBACK_PORT} ({exc}). " + "Close whatever is using that port and run 'aai login' again." + ) from exc thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() try: diff --git a/aai_cli/config.py b/aai_cli/config.py index 81c5b3be..e42a12a6 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -45,7 +45,16 @@ def _load() -> dict[str, Any]: if not path.exists(): return {} with path.open("rb") as fh: - data: dict[str, Any] = tomllib.load(fh) + try: + data: dict[str, Any] = tomllib.load(fh) + except tomllib.TOMLDecodeError as exc: + from aai_cli.errors import CLIError + + raise CLIError( + f"Config file at {path} is not valid TOML ({exc}). Fix or delete it.", + error_type="invalid_config", + exit_code=2, + ) from exc return data diff --git a/aai_cli/context.py b/aai_cli/context.py index 453fd9e1..923b391b 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -27,6 +27,24 @@ def resolve_environment(state: AppState) -> Environment: return environments.resolve(state.env, profile_env) +def env_override_warning(state: AppState) -> str | None: + """A warning when an explicit --env contradicts the profile's stored env. + + The stored key was minted against the profile's environment, so forcing a + different --env points the client at hosts that key won't authenticate to. + """ + if state.env is None: + return None + profile = resolve_profile(state) + profile_env = config.get_profile_env(profile) + if profile_env is None or profile_env == state.env: + return None + return ( + f"Using --env {state.env}, but profile '{profile}' was set up for " + f"{profile_env}; its stored key may be rejected by {state.env}." + ) + + def run_command( ctx: typer.Context, fn: Callable[[AppState, bool], None], *, json: bool = False ) -> None: diff --git a/aai_cli/main.py b/aai_cli/main.py index 8d237269..d911812c 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -23,7 +23,7 @@ transcribe, transcripts, ) -from aai_cli.context import AppState, resolve_environment +from aai_cli.context import AppState, env_override_warning, resolve_environment from aai_cli.errors import CLIError # The order commands appear under `aai --help`: core transcription first, then @@ -72,9 +72,7 @@ def list_commands(self, ctx: ClickContext) -> list[str]: def main( ctx: typer.Context, profile: str = typer.Option(None, "--profile", "-p", help="Named credential profile."), - env: str = typer.Option( - None, "--env", help="Backend environment (production, sandbox000)." - ), + env: str = typer.Option(None, "--env", help="Backend environment (production, sandbox000)."), sandbox: bool = typer.Option(False, "--sandbox", help="Shortcut for --env sandbox000."), ) -> None: if sandbox and env is None: @@ -86,6 +84,9 @@ def main( except CLIError as err: typer.echo(err.message, err=True) raise typer.Exit(code=err.exit_code) from None + warning = env_override_warning(state) + if warning: + typer.echo(warning, err=True) # Commands are registered in the order they should appear in `aai --help`: diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 8fcd6654..09e2d873 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -15,9 +15,7 @@ def test_find_or_create_reuses_existing_cli_key(monkeypatch): } ] monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) - monkeypatch.setattr( - flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create") - ) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" @@ -40,9 +38,7 @@ def test_find_or_create_creates_when_existing_cli_token_disabled(monkeypatch): projects = [ { "project": {"id": 5}, - "tokens": [ - {"name": "AssemblyAI CLI", "api_key": "sk_old", "is_disabled": True} - ], + "tokens": [{"name": "AssemblyAI CLI", "api_key": "sk_old", "is_disabled": True}], } ] monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) @@ -86,6 +82,131 @@ def test_run_login_flow_timeout_raises(monkeypatch): flow.run_login_flow() +def test_find_or_create_reuses_token_with_token_name_field(monkeypatch): + # AMS list endpoints may key the display name as "token_name" (matching the + # create payload) rather than "name"; either must be reused, not duplicated. + projects = [ + { + "project": {"id": 7}, + "tokens": [{"token_name": "AssemblyAI CLI", "api_key": "sk_cli", "is_disabled": False}], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create")) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" + + +def test_find_or_create_creates_when_existing_token_has_no_api_key(monkeypatch): + # A matching token whose api_key the list endpoint doesn't expose can't be + # reused; fall through to minting a fresh one instead of crashing on KeyError. + projects = [ + { + "project": {"id": 7}, + "tokens": [{"name": "AssemblyAI CLI", "is_disabled": False}], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr(flow.ams, "create_token", lambda *a, **k: {"api_key": "sk_new"}) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_new" + + +def test_run_login_flow_uses_exchange_account_without_get_auth(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [{"organization_id": "org_1"}], + "intermediate_session_token": "ist", + }, + ) + monkeypatch.setattr( + flow.ams, + "exchange", + lambda ist, org: {"account": {"id": 42}, "session_jwt": "jwt"}, + ) + monkeypatch.setattr( + flow.ams, "get_auth", lambda jwt: pytest.fail("get_auth is a redundant round-trip") + ) + captured = {} + + def fake_find(acct, jwt): + captured["acct"] = acct + return "sk_final" + + monkeypatch.setattr(flow, "find_or_create_cli_key", fake_find) + assert flow.run_login_flow() == "sk_final" + assert captured["acct"] == 42 + + +def test_run_login_flow_multi_org_notes_selection(monkeypatch, capsys): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [ + {"organization_id": "org_1", "organization_name": "Acme"}, + {"organization_id": "org_2", "organization_name": "Beta"}, + ], + "intermediate_session_token": "ist", + }, + ) + monkeypatch.setattr( + flow.ams, "exchange", lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt"} + ) + monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final") + + assert flow.run_login_flow() == "sk_final" + out = capsys.readouterr().out + assert "Acme" in out # the chosen org is named rather than silently picked + + +def test_run_login_flow_missing_session_token_raises_api_error(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: {"organizations": [{"organization_id": "org_1"}]}, # no IST + ) + with pytest.raises(APIError): + flow.run_login_flow() + + +def test_run_login_flow_org_missing_id_raises_api_error(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [{"organization_name": "Acme"}], # no organization_id + "intermediate_session_token": "ist", + }, + ) + with pytest.raises(APIError): + flow.run_login_flow() + + def test_run_login_flow_zero_orgs_raises(monkeypatch): monkeypatch.setattr(flow, "_open_browser", lambda url: None) monkeypatch.setattr( @@ -96,7 +217,11 @@ def test_run_login_flow_zero_orgs_raises(monkeypatch): monkeypatch.setattr( flow.ams, "discover", - lambda token: {"organizations": [], "email": "a@b.com", "intermediate_session_token": "ist"}, + lambda token: { + "organizations": [], + "email": "a@b.com", + "intermediate_session_token": "ist", + }, ) with pytest.raises(APIError, match="no AssemblyAI account"): flow.run_login_flow() diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py index 759de461..ebca7fed 100644 --- a/tests/test_auth_loopback.py +++ b/tests/test_auth_loopback.py @@ -1,8 +1,12 @@ +import socket import threading import time import urllib.request +import pytest + from aai_cli.auth import endpoints, loopback +from aai_cli.errors import APIError def _hit(path: str) -> None: @@ -37,3 +41,18 @@ def test_capture_times_out_without_callback(): result = loopback.capture_callback(timeout=0.3) assert result.error == "timeout" assert result.token is None + + +def test_capture_raises_clean_error_when_port_unavailable(monkeypatch): + # Occupy a port, then point the callback server at it: binding must fail with a + # clean APIError, not a raw OSError traceback escaping run_login_flow. + busy = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + busy.bind((endpoints.LOOPBACK_HOST, 0)) + busy.listen(1) + port = busy.getsockname()[1] + monkeypatch.setattr(endpoints, "LOOPBACK_PORT", port) + try: + with pytest.raises(APIError): + loopback.capture_callback(timeout=1.0) + finally: + busy.close() diff --git a/tests/test_config.py b/tests/test_config.py index 7c6d6546..966912f0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -70,6 +70,14 @@ def test_empty_api_key_flag_rejected(): config.resolve_api_key(api_key_flag="") +def test_malformed_config_raises_clean_error(tmp_config): + from aai_cli.errors import CLIError + + (tmp_config / "config.toml").write_text("this is not = = valid toml ===\n") + with pytest.raises(CLIError): + config.get_active_profile() + + def test_config_roundtrips_after_special_value(tmp_path, monkeypatch): # active profile name is validated; this checks tomli_w writes valid TOML for normal data config.set_api_key("default", "sk_x") diff --git a/tests/test_context.py b/tests/test_context.py index e25c6810..4f9cecbb 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,7 +1,8 @@ import typer from typer.testing import CliRunner -from aai_cli.context import AppState, run_command +from aai_cli import config +from aai_cli.context import AppState, env_override_warning, run_command from aai_cli.errors import NotAuthenticated runner = CliRunner() @@ -38,3 +39,22 @@ def body(state, json_mode): result = runner.invoke(_make_app(body), ["go"]) assert result.exit_code == 0 assert seen.get("ran") is True + + +def test_env_override_warning_when_flag_contradicts_profile(): + config.set_profile_env("default", "sandbox000") + assert env_override_warning(AppState(env="production")) is not None + + +def test_env_override_warning_none_when_flag_matches_profile(): + config.set_profile_env("default", "sandbox000") + assert env_override_warning(AppState(env="sandbox000")) is None + + +def test_env_override_warning_none_without_explicit_flag(): + config.set_profile_env("default", "sandbox000") + assert env_override_warning(AppState(env=None)) is None + + +def test_env_override_warning_none_when_profile_has_no_env(): + assert env_override_warning(AppState(env="production")) is None From 6435265380ab3594e0a97c73c112b773f65f26d6 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 14:31:07 -0700 Subject: [PATCH 14/15] chore(packaging): refresh uv.lock after aai-cli rename `uv lock` relocates the editable root-package entry under its new `aai-cli` name; no dependency versions change. Co-Authored-By: Claude Opus 4.8 (1M context) --- uv.lock | 176 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 88 insertions(+), 88 deletions(-) diff --git a/uv.lock b/uv.lock index 27d3a824..467b4b6e 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,58 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "aai-cli" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "assemblyai" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, + { name = "httpx" }, + { name = "keyring" }, + { name = "openai" }, + { name = "platformdirs" }, + { name = "rich" }, + { name = "sounddevice" }, + { name = "tomli-w" }, + { name = "typer" }, + { name = "websockets" }, + { name = "yt-dlp" }, +] + +[package.optional-dependencies] +dev = [ + { name = "hypothesis" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "assemblyai", specifier = ">=0.64.4" }, + { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.155.1" }, + { name = "keyring", specifier = ">=25.7.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.1.0" }, + { name = "openai", specifier = ">=2.41.0" }, + { name = "platformdirs", specifier = ">=4.10.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.6.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" }, + { name = "rich", specifier = ">=15.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.15" }, + { name = "sounddevice", specifier = ">=0.5.5" }, + { name = "tomli-w", specifier = ">=1.2.0" }, + { name = "typer", specifier = ">=0.26.7" }, + { name = "websockets", specifier = ">=16.0" }, + { name = "yt-dlp", specifier = ">=2026.3.17" }, +] +provides-extras = ["dev"] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -42,7 +94,7 @@ wheels = [ [[package]] name = "assemblyai" -version = "0.64.3" +version = "0.64.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -51,63 +103,11 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/cf/749048698da4a469a9ca1afc36e437ecd36b415a626f3f00ac5ce7aaf6f5/assemblyai-0.64.3.tar.gz", hash = "sha256:6fffa15cb7942184ebbe43867a244568fab507d5e09548ba728da47ca241be7e", size = 71386, upload-time = "2026-05-19T21:51:28.669Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/b7/e3e515476c3589cbf18b3b935fc869ba4e7c4fddde1bf697d8f27440dae2/assemblyai-0.64.4.tar.gz", hash = "sha256:f0d8d17d083bed93fc90e5494e8bd7546fdab3c3c96092761495fecd53c25ed2", size = 71630, upload-time = "2026-05-28T18:45:22.094Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/86/f2/ff2044c128b783ebea34806d9cb2a2ac9d2a5d86d642fc7f9fbf807057c5/assemblyai-0.64.3-py3-none-any.whl", hash = "sha256:3ee8806fd27ce4004d35d6cbc63564ffd1c7ddad97375f780f4ee3353282d907", size = 62864, upload-time = "2026-05-19T21:51:27.53Z" }, + { url = "https://files.pythonhosted.org/packages/ff/60/218ccc68b665a64b876507bf926179aff07f46091c1db9f63fdc902e86c7/assemblyai-0.64.4-py3-none-any.whl", hash = "sha256:ba5c1eba9e5b9aa87c99e4be12eee0a2f81ae56bb9798f3d36a30fec99cc5205", size = 63127, upload-time = "2026-05-28T18:45:20.722Z" }, ] -[[package]] -name = "aai-cli" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "assemblyai" }, - { name = "audioop-lts", marker = "python_full_version >= '3.13'" }, - { name = "httpx" }, - { name = "keyring" }, - { name = "openai" }, - { name = "platformdirs" }, - { name = "rich" }, - { name = "sounddevice" }, - { name = "tomli-w" }, - { name = "typer" }, - { name = "websockets" }, - { name = "yt-dlp" }, -] - -[package.optional-dependencies] -dev = [ - { name = "hypothesis" }, - { name = "mypy" }, - { name = "pre-commit" }, - { name = "pytest" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "assemblyai", specifier = ">=0.34" }, - { name = "audioop-lts", marker = "python_full_version >= '3.13'", specifier = ">=0.2" }, - { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0" }, - { name = "httpx", specifier = ">=0.28.1" }, - { name = "keyring", specifier = ">=24.0" }, - { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "openai", specifier = ">=1.40" }, - { name = "platformdirs", specifier = ">=4.0" }, - { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, - { name = "rich", specifier = ">=13.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11" }, - { name = "sounddevice", specifier = ">=0.5" }, - { name = "tomli-w", specifier = ">=1.0" }, - { name = "typer", specifier = ">=0.13" }, - { name = "websockets", specifier = ">=13" }, - { name = "yt-dlp", specifier = ">=2024.0" }, -] -provides-extras = ["dev"] - [[package]] name = "ast-serialize" version = "0.5.0" @@ -567,15 +567,15 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.153.6" +version = "6.155.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/c3/8c661bb893725eedeb003e85f3050274da2d77abf0847c4d61b4af53969c/hypothesis-6.153.6.tar.gz", hash = "sha256:8f7663251c57c9ee1fb6c0e919a6027cbda98d52b210dea441957d11d644c271", size = 475551, upload-time = "2026-05-27T17:43:32.524Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/ef/4a94c12429986a90076057513e084bf32106a9bdc62c8e29f58673dd85a2/hypothesis-6.155.1.tar.gz", hash = "sha256:07c102031612b98d7c1be15ca3608c43e1234d9d07e3a190a53fa01536700196", size = 477300, upload-time = "2026-05-29T23:12:57.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/33/f3ec54e6fb89c2279f0dd911ba512321e70038e447d1984c35fad61840f8/hypothesis-6.153.6-py3-none-any.whl", hash = "sha256:a892e3460e4dd8cfb8525682d8901be8f5e2d2c7b352359b71a44e5def2b89c8", size = 541876, upload-time = "2026-05-27T17:43:30.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/6e/8c9cf32201238617454303b1605dfa667d90cd1ef51226f92d9c2b3b8f7c/hypothesis-6.155.1-py3-none-any.whl", hash = "sha256:2753f469df3ba3c483b08e0c37dbcbc41d8316ebb921abcc07493ee9c8a7d187", size = 543715, upload-time = "2026-05-29T23:12:54.77Z" }, ] [[package]] @@ -977,7 +977,7 @@ wheels = [ [[package]] name = "openai" -version = "2.38.0" +version = "2.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -989,9 +989,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/12/cfa322c5f5dd8fa21aab9a7a8e979e7a11123800f86ca8d82eb68a83d213/openai-2.38.0.tar.gz", hash = "sha256:798694c6cf74145541fda94325b6f8f72d8e1fd0262cc137c8d728177a6a4ce3", size = 772764, upload-time = "2026-05-21T21:23:42.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/bf/ccff9be562e24207716d04ef9dc931c76aff0c89a7265da43e2104d7fe06/openai-2.38.0-py3-none-any.whl", hash = "sha256:ec6661c57b2dcc47414a767e6e3335c7ed3d19c9696999283a3c82e95c756a3c", size = 1344910, upload-time = "2026-05-21T21:23:39.636Z" }, + { url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" }, ] [[package]] @@ -1014,11 +1014,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -1351,27 +1351,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.15.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" }, + { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" }, + { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" }, + { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" }, + { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" }, + { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" }, + { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] [[package]] @@ -1507,7 +1507,7 @@ wheels = [ [[package]] name = "typer" -version = "0.26.2" +version = "0.26.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1515,9 +1515,9 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/a5/756f2e6bc81a7dd79aa3c625dd01b74cabc4516628cace2caaec09ca6ff2/typer-0.26.2.tar.gz", hash = "sha256:9b4f19e08fcc9427a822d1ef467b1fe76737a2f65c7926bdeba2337d73569b68", size = 198991, upload-time = "2026-05-27T10:41:39.166Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/a5/6ffd702beda8798b2b82ff70805ed4a66d963557e43a5d1823ab456251a4/typer-0.26.2-py3-none-any.whl", hash = "sha256:39beff72ffbb31978a5b545f677d57edb97c6f980f433b38556deb0af25f094d", size = 123123, upload-time = "2026-05-27T10:41:40.504Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" }, ] [[package]] From e5d4b87cb204d755dde6ce0279cb289c9708999a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 14:35:23 -0700 Subject: [PATCH 15/15] style: apply ruff 0.15.16 formatting (matches uv.lock) CI runs `ruff format --check` with the locked ruff 0.15.16; reformat the files whose style differs under that version. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/client.py | 4 +--- aai_cli/commands/login.py | 12 ++++++---- tests/test_agent_command.py | 4 +--- tests/test_doctor.py | 4 +--- tests/test_llm_command.py | 4 +--- tests/test_login.py | 8 ++----- tests/test_stream_command.py | 12 +++------- tests/test_transcribe.py | 44 +++++++++--------------------------- 8 files changed, 28 insertions(+), 64 deletions(-) diff --git a/aai_cli/client.py b/aai_cli/client.py index 90256bc7..2d400c75 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -157,9 +157,7 @@ def stream_audio( `params` is a fully-built StreamingParameters (sample_rate/speech_model/etc). """ sc = StreamingClient( - StreamingClientOptions( - api_key=api_key, api_host=environments.active().streaming_host - ) + StreamingClientOptions(api_key=api_key, api_host=environments.active().streaming_host) ) def _guard(cb: Callable[[Any], Any]) -> Callable[[Any, Any], None]: diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index ff4b765d..924546fb 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -33,8 +33,10 @@ def body(state: AppState, json_mode: bool) -> None: config.set_profile_env(profile, env) output.emit( {"authenticated": True, "profile": profile, "env": env}, - lambda _d: f"[aai.success]Authenticated[/aai.success] on profile " - f"'{escape(profile)}' (env: {escape(env)}).", + lambda _d: ( + f"[aai.success]Authenticated[/aai.success] on profile " + f"'{escape(profile)}' (env: {escape(env)})." + ), json_mode=json_mode, ) @@ -77,8 +79,10 @@ def body(state: AppState, json_mode: bool) -> None: reachable = client.validate_key(key) output.emit( {"profile": profile, "env": env, "api_key": masked, "reachable": reachable}, - lambda _d: f"profile={escape(profile)} env={escape(env)} " - f"key={escape(masked)} reachable={reachable}", + lambda _d: ( + f"profile={escape(profile)} env={escape(env)} " + f"key={escape(masked)} reachable={reachable}" + ), json_mode=json_mode, ) diff --git a/tests/test_agent_command.py b/tests/test_agent_command.py index 92ed1119..0c305d19 100644 --- a/tests/test_agent_command.py +++ b/tests/test_agent_command.py @@ -252,9 +252,7 @@ def fake_run_session(api_key, *, renderer, **kwargs): def test_agent_show_code_prints_without_session(monkeypatch): # Print-only: emits the agent script, never starts a session or opens audio, no auth. called = [] - monkeypatch.setattr( - "aai_cli.commands.agent.run_session", lambda *a, **k: called.append(True) - ) + monkeypatch.setattr("aai_cli.commands.agent.run_session", lambda *a, **k: called.append(True)) result = runner.invoke(app, ["agent", "--voice", "ivy", "--show-code"]) assert result.exit_code == 0 assert called == [] # never ran a session diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7f9179ca..4dafb43a 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -17,9 +17,7 @@ def healthy(monkeypatch): """A fully-ready environment: valid key, all tools present, a microphone.""" config.set_api_key("default", "sk_1234567890") monkeypatch.setattr("aai_cli.commands.doctor.client.validate_key", lambda _key: True) - monkeypatch.setattr( - "aai_cli.commands.doctor.shutil.which", lambda tool: f"/usr/bin/{tool}" - ) + monkeypatch.setattr("aai_cli.commands.doctor.shutil.which", lambda tool: f"/usr/bin/{tool}") monkeypatch.setattr("aai_cli.commands.doctor._probe_input_devices", lambda: 2) diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py index 98fe63da..5cc99324 100644 --- a/tests/test_llm_command.py +++ b/tests/test_llm_command.py @@ -214,9 +214,7 @@ def test_llm_output_text_prints_raw_answer(monkeypatch): def test_llm_output_json_forces_json(monkeypatch): _auth() - monkeypatch.setattr( - "aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hello") - ) + monkeypatch.setattr("aai_cli.commands.llm.gateway.complete", lambda *a, **k: _payload("hello")) result = runner.invoke(app, ["llm", "hi", "-o", "json"]) assert result.exit_code == 0 assert json.loads(result.output)["output"] == "hello" diff --git a/tests/test_login.py b/tests/test_login.py index 0bc52688..8991c249 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -55,9 +55,7 @@ def test_logout_clears_key(): def test_login_oauth_flow_stores_returned_key(monkeypatch): - monkeypatch.setattr( - "aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" - ) + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth") result = runner.invoke(app, ["login"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_from_oauth" @@ -87,9 +85,7 @@ def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): def test_login_binds_env_to_profile(monkeypatch): - monkeypatch.setattr( - "aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" - ) + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth") result = runner.invoke(app, ["--env", "sandbox000", "login"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_from_oauth" diff --git a/tests/test_stream_command.py b/tests/test_stream_command.py index ea63e5e8..6e64efdf 100644 --- a/tests/test_stream_command.py +++ b/tests/test_stream_command.py @@ -133,9 +133,7 @@ def test_stream_sample_uses_hosted_clip(monkeypatch): from aai_cli import client config.set_api_key("default", "sk_live") - monkeypatch.setattr( - "aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" - ) + monkeypatch.setattr("aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg") seen = {} monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _capture_source(seen)) result = runner.invoke(app, ["stream", "--sample"]) @@ -147,9 +145,7 @@ def test_stream_sample_uses_hosted_clip(monkeypatch): def test_stream_url_source_uses_filesource(monkeypatch): config.set_api_key("default", "sk_live") - monkeypatch.setattr( - "aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg" - ) + monkeypatch.setattr("aai_cli.streaming.sources.shutil.which", lambda _n: "/usr/bin/ffmpeg") seen = {} monkeypatch.setattr("aai_cli.commands.stream.client.stream_audio", _capture_source(seen)) result = runner.invoke(app, ["stream", "https://example.com/clip.mp3"]) @@ -357,9 +353,7 @@ def test_stream_youtube_url_downloads_then_streams(monkeypatch, tmp_path): w.setsampwidth(2) w.setframerate(16000) w.writeframes(b"\x00\x01" * 100) - monkeypatch.setattr( - "aai_cli.commands.stream.youtube.download_audio", lambda url, d: fake - ) + monkeypatch.setattr("aai_cli.commands.stream.youtube.download_audio", lambda url, d: fake) seen = {} def fake_stream(api_key, source, *, params, **kwargs): diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 56ba883a..8db7e846 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -66,9 +66,7 @@ def test_transcribe_passes_speaker_labels(): def test_transcribe_json_output(): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): result = runner.invoke(app, ["transcribe", "audio.mp3", "--json"]) assert '"id": "t_1"' in result.output @@ -80,9 +78,7 @@ def test_transcribe_unauthenticated_exits_2(): def test_transcribe_output_text_field(): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "text"]) assert result.exit_code == 0 assert result.output.strip() == "hello world" # raw text, pipe-friendly @@ -90,9 +86,7 @@ def test_transcribe_output_text_field(): def test_transcribe_output_id_field(): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): result = runner.invoke(app, ["transcribe", "audio.mp3", "--output", "id"]) assert result.exit_code == 0 assert result.output.strip() == "t_1" @@ -111,9 +105,7 @@ def test_transcribe_output_srt_field(): def test_transcribe_output_invalid_exits_2(): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "bogus"]) assert result.exit_code == 2 # unknown field rejected @@ -165,12 +157,8 @@ def fake_transform(api_key, *, prompt, model, transcript_id, max_tokens): seen["transcript_id"] = transcript_id return "a short summary" - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - monkeypatch.setattr( - "aai_cli.commands.transcribe.llm.transform_transcript", fake_transform - ) + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", fake_transform) result = runner.invoke(app, ["transcribe", "audio.mp3", "--llm", "summarize", "--json"]) assert result.exit_code == 0 data = json.loads(result.output) @@ -194,12 +182,8 @@ def fake_transform( ) return f"out({prompt})" - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): - monkeypatch.setattr( - "aai_cli.commands.transcribe.llm.transform_transcript", fake_transform - ) + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + monkeypatch.setattr("aai_cli.commands.transcribe.llm.transform_transcript", fake_transform) result = runner.invoke( app, [ @@ -226,9 +210,7 @@ def fake_transform( def test_transcribe_prompt_human_shows_only_transform(monkeypatch): _auth() monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): monkeypatch.setattr( "aai_cli.commands.transcribe.llm.transform_transcript", lambda *a, **k: "TRANSFORMED", @@ -308,9 +290,7 @@ def test_transcribe_config_escape_hatch(): def test_transcribe_unknown_config_field_exits_2(): _auth() - with patch( - "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() - ): + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): result = runner.invoke(app, ["transcribe", "audio.mp3", "--config", "bogus=1"]) assert result.exit_code == 2 assert "bogus" in result.output @@ -342,9 +322,7 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path _auth() fake = tmp_path / "vid.m4a" fake.write_bytes(b"x") - monkeypatch.setattr( - "aai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake - ) + monkeypatch.setattr("aai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake) with patch( "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: