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/aai_cli/auth/__init__.py b/aai_cli/auth/__init__.py
new file mode 100644
index 00000000..d89a864d
--- /dev/null
+++ b/aai_cli/auth/__init__.py
@@ -0,0 +1,5 @@
+from __future__ import annotations
+
+from aai_cli.auth.flow import run_login_flow
+
+__all__ = ["run_login_flow"]
diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py
new file mode 100644
index 00000000..37bae2f7
--- /dev/null
+++ b/aai_cli/auth/ams.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import httpx
+
+from aai_cli.auth import endpoints
+from aai_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 _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 _client() 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 _client() 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 _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 _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))
+
+
+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 _client(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/aai_cli/auth/discovery.py b/aai_cli/auth/discovery.py
new file mode 100644
index 00000000..f1c46916
--- /dev/null
+++ b/aai_cli/auth/discovery.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from urllib.parse import urlencode
+
+from aai_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_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/aai_cli/auth/endpoints.py b/aai_cli/auth/endpoints.py
new file mode 100644
index 00000000..c1b59971
--- /dev/null
+++ b/aai_cli/auth/endpoints.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import os
+
+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"
+
+
+# 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
+
+
+def signup_url() -> str:
+ return environments.active().signup_url
+
+
+def redirect_uri() -> str:
+ """The exact loopback redirect URL registered in Stytch."""
+ return f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}{LOOPBACK_PATH}"
diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py
new file mode 100644
index 00000000..f229422b
--- /dev/null
+++ b/aai_cli/auth/flow.py
@@ -0,0 +1,101 @@
+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}")
+ 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 _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)
+ 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 _is_reusable_cli_token(token):
+ return str(token["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:
+ """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 this identity has no AssemblyAI account yet. "
+ f"Create one at {endpoints.signup_url()}, then run 'aai login' again."
+ )
+ 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")
+
+ 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
new file mode 100644
index 00000000..179653fe
--- /dev/null
+++ b/aai_cli/auth/loopback.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import threading
+from dataclasses import dataclass
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from urllib.parse import parse_qs, urlparse
+
+from aai_cli.auth import endpoints
+from aai_cli.errors import APIError
+
+_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: # 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
+
+ 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:
+ if not done.wait(timeout):
+ result.error = "timeout"
+ finally:
+ server.shutdown()
+ thread.join(timeout=5)
+ return result
diff --git a/assemblyai_cli/client.py b/aai_cli/client.py
similarity index 96%
rename from assemblyai_cli/client.py
rename to aai_cli/client.py
index c768613b..2d400c75 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 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,7 @@ 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/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 56%
rename from assemblyai_cli/commands/login.py
rename to aai_cli/commands/login.py
index 3643461d..924546fb 100644
--- a/assemblyai_cli/commands/login.py
+++ b/aai_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.context import AppState, resolve_profile, run_command
-from assemblyai_cli.errors import APIError, NotAuthenticated
+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
app = typer.Typer()
-DASHBOARD_KEYS_URL = "https://www.assemblyai.com/dashboard/api-keys"
-
@app.command()
def login(
@@ -20,28 +17,26 @@ 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()
+ 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,
)
@@ -80,10 +75,14 @@ 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/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 70%
rename from assemblyai_cli/config.py
rename to aai_cli/config.py
index 0f1bda1c..e42a12a6 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.",
@@ -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
@@ -81,6 +90,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)
@@ -89,7 +113,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/aai_cli/context.py b/aai_cli/context.py
new file mode 100644
index 00000000..923b391b
--- /dev/null
+++ b/aai_cli/context.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+import typer
+
+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:
+ """The profile to act on: explicit --profile, else the active profile."""
+ 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 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:
+ """Execute a command body, mapping CLIError to clean output + exit code."""
+ state: AppState = ctx.obj
+ json_mode = output.resolve_json(explicit=json)
+ try:
+ fn(state, json_mode)
+ except CLIError as err:
+ output.emit_error(err, json_mode=json_mode)
+ raise typer.Exit(code=err.exit_code) from 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/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 85%
rename from assemblyai_cli/llm.py
rename to aai_cli/llm.py
index adadede7..288c6dc9 100644
--- a/assemblyai_cli/llm.py
+++ b/aai_cli/llm.py
@@ -5,10 +5,12 @@
import openai
from openai import OpenAI
-from assemblyai_cli.errors import APIError, auth_failure
+from aai_cli import environments
+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. (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(
@@ -72,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
@@ -85,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/assemblyai_cli/main.py b/aai_cli/main.py
similarity index 78%
rename from assemblyai_cli/main.py
rename to aai_cli/main.py
index 8f11d297..d911812c 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__, environments, stdio
+from aai_cli.commands import (
agent,
claude,
doctor,
@@ -23,7 +23,8 @@
transcribe,
transcripts,
)
-from assemblyai_cli.context import AppState
+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
# voice/LLM, then account, then tooling, with `version` last. Names not listed
@@ -71,8 +72,21 @@ 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
+ 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/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/assemblyai_cli/context.py b/assemblyai_cli/context.py
deleted file mode 100644
index 2d41ab77..00000000
--- a/assemblyai_cli/context.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Callable
-from dataclasses import dataclass
-
-import typer
-
-from assemblyai_cli import config, output
-from assemblyai_cli.errors import CLIError
-
-
-@dataclass
-class AppState:
- profile: str | None = None
-
-
-def resolve_profile(state: AppState) -> str:
- """The profile to act on: explicit --profile, else the active profile."""
- return state.profile or config.get_active_profile()
-
-
-def run_command(
- ctx: typer.Context, fn: Callable[[AppState, bool], None], *, json: bool = False
-) -> None:
- """Execute a command body, mapping CLIError to clean output + exit code."""
- state: AppState = ctx.obj
- json_mode = output.resolve_json(explicit=json)
- try:
- fn(state, json_mode)
- except CLIError as err:
- output.emit_error(err, json_mode=json_mode)
- raise typer.Exit(code=err.exit_code) from None
diff --git a/pyproject.toml b/pyproject.toml
index e96cb9e3..2a2142a7 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"
@@ -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",
@@ -57,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"]
@@ -70,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
@@ -96,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/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..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()
@@ -56,5 +72,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..0c305d19 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
@@ -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(
- "assemblyai_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
@@ -268,7 +266,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 +277,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
new file mode 100644
index 00000000..18a64e79
--- /dev/null
+++ b/tests/test_auth_ams.py
@@ -0,0 +1,71 @@
+import httpx
+import pytest
+
+from aai_cli.auth import ams
+from aai_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)
diff --git a/tests/test_auth_discovery.py b/tests/test_auth_discovery.py
new file mode 100644
index 00000000..d19383b5
--- /dev/null
+++ b/tests/test_auth_discovery.py
@@ -0,0 +1,18 @@
+from urllib.parse import parse_qs, urlparse
+
+from aai_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_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"]
diff --git a/tests/test_auth_endpoints.py b/tests/test_auth_endpoints.py
new file mode 100644
index 00000000..3e162bc0
--- /dev/null
+++ b/tests/test_auth_endpoints.py
@@ -0,0 +1,23 @@
+from aai_cli.auth import endpoints
+
+
+def test_redirect_uri_is_fixed_loopback():
+ assert endpoints.redirect_uri() == "http://127.0.0.1:8585/callback"
+
+
+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"
+
+
+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/tests/test_auth_flow.py b/tests/test_auth_flow.py
new file mode 100644
index 00000000..09e2d873
--- /dev/null
+++ b/tests/test_auth_flow.py
@@ -0,0 +1,227 @@
+import pytest
+
+from aai_cli.auth import flow, loopback
+from aai_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_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(
+ 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 account"):
+ flow.run_login_flow()
diff --git a/tests/test_auth_loopback.py b/tests/test_auth_loopback.py
new file mode 100644
index 00000000..ebca7fed
--- /dev/null
+++ b/tests/test_auth_loopback.py
@@ -0,0 +1,58 @@
+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:
+ 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() # noqa: S310 - fixed localhost URL
+ return
+ except OSError:
+ 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
+
+
+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_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..966912f0 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,12 +64,20 @@ 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="")
+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_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..4f9cecbb 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,8 +1,9 @@
import typer
from typer.testing import CliRunner
-from assemblyai_cli.context import AppState, run_command
-from assemblyai_cli.errors import NotAuthenticated
+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
diff --git a/tests/test_doctor.py b/tests/test_doctor.py
index 6b77a53a..4dafb43a 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,9 @@
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(
- "assemblyai_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.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._probe_input_devices", lambda: 2)
def _checks(result):
@@ -45,7 +43,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 +53,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 +63,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 +76,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 +85,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 +93,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_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_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..225b26e5 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
_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=[])
diff --git a/tests/test_llm_command.py b/tests/test_llm_command.py
index 820dfa61..5cc99324 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
@@ -214,9 +214,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")
- )
+ 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"
@@ -224,14 +222,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 +245,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 +265,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 b1c6cfa6..8991c249 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -2,49 +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_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):
+ 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"
@@ -54,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)
@@ -73,3 +52,64 @@ 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("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"
+
+
+def test_login_oauth_flow_failure_exits_nonzero(monkeypatch):
+ from aai_cli.errors import APIError
+
+ def boom():
+ raise APIError("Login timed out waiting for the browser.")
+
+ 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
+
+
+def test_login_api_key_flag_still_bypasses_oauth(monkeypatch):
+ monkeypatch.setattr(
+ "aai_cli.commands.login.run_login_flow",
+ lambda: (_ for _ in ()).throw(AssertionError("OAuth must not run with --api-key")),
+ )
+ 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"
+
+
+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
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..6e64efdf 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,12 @@ 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"
- )
+ monkeypatch.setattr("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"
@@ -147,11 +145,9 @@ 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"
- )
+ monkeypatch.setattr("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 +166,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 +204,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 +221,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 +251,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 +291,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 +303,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 +322,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 +336,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.
@@ -357,16 +353,14 @@ 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(
- "assemblyai_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):
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 +374,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 +397,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 +409,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 +433,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 +448,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 +464,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 +480,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 +502,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 +514,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..8db7e846 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
@@ -66,9 +66,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()
- ):
+ 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(
- "assemblyai_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(
- "assemblyai_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"
@@ -102,7 +96,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
@@ -111,9 +105,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()
- ):
+ 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
@@ -129,7 +121,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 +141,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
@@ -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(
- "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()
- ):
- monkeypatch.setattr(
- "assemblyai_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(
- "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()
- ):
- monkeypatch.setattr(
- "assemblyai_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,
[
@@ -225,12 +209,10 @@ def fake_transform(
def test_transcribe_prompt_human_shows_only_transform(monkeypatch):
_auth()
- monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False)
- with patch(
- "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()
- ):
+ monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False)
+ with patch("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 +224,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 +235,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 +259,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 +282,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
@@ -308,9 +290,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()
- ):
+ 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
@@ -319,7 +299,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,
@@ -342,11 +322,9 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path
_auth()
fake = tmp_path / "vid.m4a"
fake.write_bytes(b"x")
- monkeypatch.setattr(
- "assemblyai_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(
- "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 +335,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 +352,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 +366,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 +380,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 d2fd8552..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,61 +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 = "assemblyai-cli"
-version = "0.1.0"
-source = { editable = "." }
-dependencies = [
- { name = "assemblyai" },
- { name = "audioop-lts", marker = "python_full_version >= '3.13'" },
- { 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 = "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"
@@ -565,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]]
@@ -975,7 +977,7 @@ wheels = [
[[package]]
name = "openai"
-version = "2.38.0"
+version = "2.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -987,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]]
@@ -1012,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]]
@@ -1349,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]]
@@ -1505,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" },
@@ -1513,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]]