From 865b54e905bd2b23a7d69dd69d4642978507bcff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 03:55:55 +0000 Subject: [PATCH 01/14] feat(config): persist Stytch session + account id per profile --- aai_cli/config.py | 55 ++++++++++++++++++++++++++++++++++++ tests/test_config_session.py | 30 ++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 tests/test_config_session.py diff --git a/aai_cli/config.py b/aai_cli/config.py index 3917be6c..a13b1364 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -1,6 +1,7 @@ from __future__ import annotations import contextlib +import json import os import re import tomllib @@ -111,6 +112,60 @@ def clear_api_key(profile: str) -> None: keyring.delete_password(KEYRING_SERVICE, profile) +SESSION_KEYRING_PREFIX = "session" # keyring username: f"{prefix}:{profile}" + + +def _session_username(profile: str) -> str: + return f"{SESSION_KEYRING_PREFIX}:{profile}" + + +def set_session(profile: str, *, session_jwt: str, session_token: str, account_id: int) -> None: + """Persist the browser-login Stytch session (secret) + account id (non-secret). + + AMS self-service endpoints authenticate with this session cookie, not the API + key. The JWT is short-lived; an expired session surfaces as NotAuthenticated. + """ + _validate_profile(profile) + keyring.set_password( + KEYRING_SERVICE, + _session_username(profile), + json.dumps({"jwt": session_jwt, "token": session_token}), + ) + data = _load() + data.setdefault("profiles", {}).setdefault(profile, {})["account_id"] = account_id + _dump(data) + + +def get_session(profile: str) -> dict[str, str] | None: + """The stored {'jwt', 'token'} for a profile, or None if absent/corrupt.""" + raw = keyring.get_password(KEYRING_SERVICE, _session_username(profile)) + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(data, dict) or "jwt" not in data: + return None + return data + + +def get_account_id(profile: str) -> int | None: + """The AMS account id recorded at login for a profile, if any.""" + value = _load().get("profiles", {}).get(profile, {}).get("account_id") + return int(value) if value is not None else None + + +def clear_session(profile: str) -> None: + with contextlib.suppress(keyring.errors.PasswordDeleteError): + keyring.delete_password(KEYRING_SERVICE, _session_username(profile)) + data = _load() + prof = data.get("profiles", {}).get(profile) + if prof and "account_id" in prof: + del prof["account_id"] + _dump(data) + + 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: diff --git a/tests/test_config_session.py b/tests/test_config_session.py new file mode 100644 index 00000000..4787af6a --- /dev/null +++ b/tests/test_config_session.py @@ -0,0 +1,30 @@ +from aai_cli import config + + +def test_set_and_get_session_roundtrips(): + config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) + assert config.get_session("default") == {"jwt": "jwt_1", "token": "tok_1"} + assert config.get_account_id("default") == 42 + + +def test_get_session_none_when_absent(): + assert config.get_session("default") is None + assert config.get_account_id("default") is None + + +def test_clear_session_removes_jwt_and_account_id(): + config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) + config.clear_session("default") + assert config.get_session("default") is None + assert config.get_account_id("default") is None + + +def test_clear_session_is_safe_when_absent(): + config.clear_session("never-logged-in") # must not raise + + +def test_get_session_returns_none_for_corrupt_blob(): + import keyring + + keyring.set_password(config.KEYRING_SERVICE, "session:default", "not-json") + assert config.get_session("default") is None From 058102514eea0bda9860aa81ab83c846e96ee91d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 03:56:56 +0000 Subject: [PATCH 02/14] refactor(auth): run_login_flow returns LoginResult with session --- aai_cli/auth/flow.py | 27 ++++++++++++++++++++---- tests/test_auth_flow.py | 46 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index cb18de68..a97a96e5 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -2,6 +2,7 @@ import webbrowser from collections.abc import Mapping +from dataclasses import dataclass from typing import Any from aai_cli import output @@ -9,6 +10,17 @@ from aai_cli.errors import APIError +@dataclass +class LoginResult: + """Everything a successful browser login yields: a usable API key plus the + Stytch session the AMS self-service commands authenticate with.""" + + api_key: str + session_jwt: str + session_token: str + account_id: int + + def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any: """Pull a required field out of an AMS response, or raise a clean APIError. @@ -73,8 +85,8 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: 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.""" +def run_login_flow() -> LoginResult: + """Drive the full browser + AMS login and return a LoginResult.""" _open_browser(discovery.build_start_url()) result = _capture() @@ -109,7 +121,14 @@ def run_login_flow() -> str: 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") + session_token = _require(signed_in, "session_token", "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) + account_id = int(_require(_require(signed_in, "account", "an account"), "id", "an account id")) + api_key = find_or_create_cli_key(account_id, session_jwt) + return LoginResult( + api_key=api_key, + session_jwt=session_jwt, + session_token=session_token, + account_id=account_id, + ) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 09c671ff..54c73bcb 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -71,7 +71,7 @@ def test_run_login_flow_happy_path(monkeypatch): 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 flow.run_login_flow().api_key == "sk_final" assert opened["url"].startswith("https://") @@ -130,7 +130,7 @@ def test_run_login_flow_uses_exchange_account_without_get_auth(monkeypatch): monkeypatch.setattr( flow.ams, "exchange", - lambda ist, org: {"account": {"id": 42}, "session_jwt": "jwt"}, + lambda ist, org: {"account": {"id": 42}, "session_jwt": "jwt", "session_token": "t"}, ) monkeypatch.setattr( flow.ams, "get_auth", lambda jwt: pytest.fail("get_auth is a redundant round-trip") @@ -142,7 +142,7 @@ def fake_find(acct, jwt): return "sk_final" monkeypatch.setattr(flow, "find_or_create_cli_key", fake_find) - assert flow.run_login_flow() == "sk_final" + assert flow.run_login_flow().api_key == "sk_final" assert captured["acct"] == 42 @@ -165,11 +165,13 @@ def test_run_login_flow_multi_org_notes_selection(monkeypatch, capsys): }, ) monkeypatch.setattr( - flow.ams, "exchange", lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt"} + flow.ams, + "exchange", + lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt", "session_token": "t"}, ) monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final") - assert flow.run_login_flow() == "sk_final" + assert flow.run_login_flow().api_key == "sk_final" out = capsys.readouterr().out assert "Acme" in out # the chosen org is named rather than silently picked @@ -227,3 +229,37 @@ def test_run_login_flow_zero_orgs_raises(monkeypatch): ) with pytest.raises(APIError, match="no AssemblyAI account"): flow.run_login_flow() + + +def test_run_login_flow_returns_session_material(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth", error=None), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [{"organization_id": "org_1"}], + "intermediate_session_token": "ist_1", + }, + ) + monkeypatch.setattr( + flow.ams, + "exchange", + lambda ist, org: { + "session_jwt": "jwt_1", + "session_token": "tok_1", + "account": {"id": 99}, + }, + ) + monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 99}) + monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_key") + + result = flow.run_login_flow() + assert result.api_key == "sk_key" + assert result.session_jwt == "jwt_1" + assert result.session_token == "tok_1" + assert result.account_id == 99 From d3e732ed48d78355a52613d6a0a35b98962ddc1a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 03:57:49 +0000 Subject: [PATCH 03/14] feat(auth): persist AMS session at browser login, clear on logout --- aai_cli/commands/login.py | 21 +++++++++++++++------ tests/test_login.py | 34 +++++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 9d6945ba..f63d1c96 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -29,19 +29,27 @@ def login( def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) + env = environments.active().name if api_key: - # Non-interactive escape hatch for CI/automation. + # Non-interactive escape hatch for CI/automation: no AMS session is + # obtained, so account self-service commands won't work for this profile. if not client.validate_key(api_key): raise APIError( "That API key was rejected (HTTP 401).", suggestion="Check the key and retry.", ) - key = api_key + config.set_api_key(profile, api_key) + config.set_profile_env(profile, env) else: - key = run_login_flow() - env = environments.active().name - config.set_api_key(profile, key) - config.set_profile_env(profile, env) + result = run_login_flow() + config.set_api_key(profile, result.api_key) + config.set_profile_env(profile, env) + config.set_session( + profile, + session_jwt=result.session_jwt, + session_token=result.session_token, + account_id=result.account_id, + ) output.emit( {"authenticated": True, "profile": profile, "env": env}, lambda _d: ( @@ -70,6 +78,7 @@ def logout( def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) config.clear_api_key(profile) + config.clear_session(profile) output.emit( {"logged_out": True, "profile": profile}, lambda _d: f"Logged out of profile '{escape(profile)}'.", diff --git a/tests/test_login.py b/tests/test_login.py index b3f378b5..05300368 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -3,11 +3,16 @@ from typer.testing import CliRunner from aai_cli import config +from aai_cli.auth.flow import LoginResult from aai_cli.main import app runner = CliRunner() +def _fake_login_result(key="sk_from_oauth"): + return LoginResult(api_key=key, session_jwt="jwt_x", session_token="tok_x", account_id=7) + + def test_login_with_api_key_flag_stores_key(): with patch("aai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) @@ -55,12 +60,35 @@ def test_logout_clears_key(): def test_login_oauth_flow_stores_returned_key(monkeypatch): - monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth") + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", _fake_login_result) result = runner.invoke(app, ["login"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_from_oauth" +def test_login_oauth_persists_session(monkeypatch): + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", _fake_login_result) + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert config.get_session("default") == {"jwt": "jwt_x", "token": "tok_x"} + assert config.get_account_id("default") == 7 + + +def test_login_api_key_flag_does_not_persist_session(): + 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_session("default") is None + + +def test_logout_clears_session(): + config.set_api_key("default", "sk_1234567890") + config.set_session("default", session_jwt="j", session_token="t", account_id=7) + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert config.get_session("default") is None + + def test_login_oauth_flow_failure_exits_nonzero(monkeypatch): from aai_cli.errors import APIError @@ -85,7 +113,7 @@ def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): def test_login_binds_env_to_profile(monkeypatch): - monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth") + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", _fake_login_result) result = runner.invoke(app, ["--env", "sandbox000", "login"]) assert result.exit_code == 0 assert config.get_api_key("default") == "sk_from_oauth" @@ -93,7 +121,7 @@ def test_login_binds_env_to_profile(monkeypatch): def test_sandbox_flag_is_shortcut_for_env(monkeypatch): - monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: "sk_x") + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", lambda: _fake_login_result("sk_x")) result = runner.invoke(app, ["--sandbox", "login"]) assert result.exit_code == 0 assert config.get_profile_env("default") == "sandbox000" From c99fbe40d683111c73803c33139c14a71b1f2791 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 03:58:11 +0000 Subject: [PATCH 04/14] feat(context): add resolve_session helper for AMS commands --- aai_cli/context.py | 19 +++++++++++++++++++ tests/test_context.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/aai_cli/context.py b/aai_cli/context.py index 923b391b..2e30ea17 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -27,6 +27,25 @@ def resolve_environment(state: AppState) -> Environment: return environments.resolve(state.env, profile_env) +def resolve_session(state: AppState) -> tuple[int, str]: + """Account id + Stytch session JWT for AMS self-service commands. + + These endpoints authenticate with the browser-login session, not the API + key. Raises NotAuthenticated when the active profile has no stored session + (e.g. it was set up with `aai login --api-key`, or the session expired). + """ + from aai_cli.errors import NotAuthenticated + + profile = resolve_profile(state) + session = config.get_session(profile) + account_id = config.get_account_id(profile) + if session is None or account_id is None: + raise NotAuthenticated( + "These commands need a browser login. Run 'aai login' (without --api-key)." + ) + return account_id, session["jwt"] + + def env_override_warning(state: AppState) -> str | None: """A warning when an explicit --env contradicts the profile's stored env. diff --git a/tests/test_context.py b/tests/test_context.py index 4f9cecbb..5e05fa2f 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -58,3 +58,23 @@ def test_env_override_warning_none_without_explicit_flag(): def test_env_override_warning_none_when_profile_has_no_env(): assert env_override_warning(AppState(env="production")) is None + + +def test_resolve_session_returns_account_and_jwt(): + from aai_cli import config + from aai_cli.context import AppState, resolve_session + + config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) + account_id, jwt = resolve_session(AppState()) + assert account_id == 42 + assert jwt == "jwt_1" + + +def test_resolve_session_raises_when_no_session(): + import pytest + + from aai_cli.context import AppState, resolve_session + from aai_cli.errors import NotAuthenticated + + with pytest.raises(NotAuthenticated): + resolve_session(AppState()) From 5b4e14a645246a269655426705fb1f56284380e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 03:58:50 +0000 Subject: [PATCH 05/14] feat(ams): add balance, usage, rate-limits, streaming, rename client fns --- aai_cli/auth/ams.py | 60 +++++++++++++++++++++- tests/test_ams_account.py | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/test_ams_account.py diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 37bae2f7..256ea516 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -20,11 +20,15 @@ def _detail(resp: httpx.Response) -> str: return resp.text or f"HTTP {resp.status_code}" -def _json_or_raise(resp: httpx.Response) -> Any: +def _raise_for_error(resp: httpx.Response) -> None: 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)}") + + +def _json_or_raise(resp: httpx.Response) -> Any: + _raise_for_error(resp) return resp.json() @@ -81,3 +85,57 @@ def create_token( json={"project_id": project_id, "token_name": token_name}, ) return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_balance(session_jwt: str) -> dict[str, Any]: + """GET /v2/billing/balance -> {account_id, balance_in_cents, ...}.""" + with _client(session_jwt) as client: + resp = client.get("/v2/billing/balance") + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_usage( + session_jwt: str, + starting_on: str, + ending_before: str, + window_size: str | None = None, +) -> dict[str, Any]: + """POST /v2/billing/usage (ISO dates) -> {usage_items: [...]}.""" + body: dict[str, Any] = {"starting_on": starting_on, "ending_before": ending_before} + if window_size: + body["window_size"] = window_size + with _client(session_jwt) as client: + resp = client.post("/v2/billing/usage", json=body) + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_rate_limits(account_id: int, session_jwt: str) -> dict[str, Any]: + """GET /v1/users/accounts/{id}/rate-limits -> {rate_limits: [...]}.""" + with _client(session_jwt) as client: + resp = client.get(f"/v1/users/accounts/{account_id}/rate-limits") + return cast(dict[str, Any], _json_or_raise(resp)) + + +def rename_token(account_id: int, token_id: int, token_name: str, session_jwt: str) -> None: + """PUT /v1/users/accounts/{id}/tokens/{token_id}; 200 returns an empty body.""" + with _client(session_jwt) as client: + resp = client.put( + f"/v1/users/accounts/{account_id}/tokens/{token_id}", + json={"token_name": token_name}, + ) + _raise_for_error(resp) + + +def list_streaming(session_jwt: str, **filters: Any) -> dict[str, Any]: + """GET /v1/users/streaming -> {page_details, data: [StreamingSessionSchema]}.""" + params = {k: v for k, v in filters.items() if v is not None} + with _client(session_jwt) as client: + resp = client.get("/v1/users/streaming", params=params) + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_streaming(session_id: str, session_jwt: str) -> dict[str, Any]: + """GET /v1/users/streaming/{session_id} -> StreamingSessionSchema.""" + with _client(session_jwt) as client: + resp = client.get(f"/v1/users/streaming/{session_id}") + return cast(dict[str, Any], _json_or_raise(resp)) diff --git a/tests/test_ams_account.py b/tests/test_ams_account.py new file mode 100644 index 00000000..1de327cc --- /dev/null +++ b/tests/test_ams_account.py @@ -0,0 +1,103 @@ +import httpx +import pytest + +from aai_cli.auth import ams +from aai_cli.errors import 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_get_balance_sends_cookie_and_parses(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["cookie"] = request.headers.get("cookie", "") + return httpx.Response(200, json={"account_id": 1, "balance_in_cents": 2500}) + + _patch_transport(monkeypatch, handler) + out = ams.get_balance("jwt_1") + assert out["balance_in_cents"] == 2500 + assert "/v2/billing/balance" in seen["url"] + assert "stytch_session_jwt=jwt_1" in seen["cookie"] + + +def test_get_usage_posts_date_range(monkeypatch): + seen = {} + + def handler(request): + seen["body"] = request.read().decode() + return httpx.Response(200, json={"usage_items": []}) + + _patch_transport(monkeypatch, handler) + ams.get_usage("jwt", "2026-05-01", "2026-06-01", "day") + assert "2026-05-01" in seen["body"] and "2026-06-01" in seen["body"] + assert "day" in seen["body"] + + +def test_get_rate_limits_uses_account_path(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + return httpx.Response(200, json={"rate_limits": []}) + + _patch_transport(monkeypatch, handler) + ams.get_rate_limits(42, "jwt") + assert "/v1/users/accounts/42/rate-limits" in seen["url"] + + +def test_rename_token_puts_name_and_tolerates_empty_body(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["method"] = request.method + seen["body"] = request.read().decode() + return httpx.Response(200, content=b"") # empty body, not JSON + + _patch_transport(monkeypatch, handler) + ams.rename_token(42, 7, "prod", "jwt") # must not raise + assert seen["method"] == "PUT" + assert "/v1/users/accounts/42/tokens/7" in seen["url"] + assert "prod" in seen["body"] + + +def test_list_streaming_passes_filters(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + return httpx.Response(200, json={"page_details": {}, "data": []}) + + _patch_transport(monkeypatch, handler) + ams.list_streaming("jwt", limit=5, status="completed") + assert "limit=5" in seen["url"] + assert "status=completed" in seen["url"] + + +def test_get_streaming_by_id(monkeypatch): + def handler(request): + assert "/v1/users/streaming/s_1" in str(request.url) + return httpx.Response(200, json={"session_id": "s_1", "status": "completed"}) + + _patch_transport(monkeypatch, handler) + out = ams.get_streaming("s_1", "jwt") + assert out["session_id"] == "s_1" + + +def test_account_4xx_raises_not_authenticated(monkeypatch): + def handler(request): + return httpx.Response(401, json={"detail": "expired"}) + + _patch_transport(monkeypatch, handler) + with pytest.raises(NotAuthenticated): + ams.get_balance("jwt") From 14e5685b3e661d7e64e9e287abc2864bf741eb5d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 03:59:43 +0000 Subject: [PATCH 06/14] feat(keys): add 'aai keys' list/create/rename commands --- aai_cli/commands/keys.py | 110 +++++++++++++++++++++++++++++++++++++++ aai_cli/main.py | 2 + tests/test_keys.py | 62 ++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 aai_cli/commands/keys.py create mode 100644 tests/test_keys.py diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py new file mode 100644 index 00000000..ed98c39b --- /dev/null +++ b/aai_cli/commands/keys.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table + +from aai_cli import output +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.errors import APIError + +app = typer.Typer(help="List, create, and rename your AssemblyAI API keys.", no_args_is_help=True) + + +def _mask(key: str) -> str: + return f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" + + +@app.command(name="list") +def list_( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List API keys across your projects (keys shown masked).""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + projects = ams.list_projects(account_id, jwt) + rows: list[dict[str, object]] = [] + for entry in projects: + project_name = entry["project"]["name"] + for token in entry.get("tokens", []): + rows.append( + { + "id": token["id"], + "name": token["name"], + "project": project_name, + "key": _mask(str(token["api_key"])), + "disabled": token["is_disabled"], + } + ) + + def render(data: list[dict[str, object]]) -> Table: + table = Table("id", "name", "project", "key", "disabled", header_style="aai.heading") + for row in data: + table.add_row( + str(row["id"]), + escape(str(row["name"])), + escape(str(row["project"])), + escape(str(row["key"])), + "yes" if row["disabled"] else "no", + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def create( + ctx: typer.Context, + name: str = typer.Option(..., "--name", help="A label for the new key."), + project_id: int = typer.Option( + None, "--project", help="Project id to create the key in (defaults to your first)." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Create a new API key. Prints the key value once — copy it now.""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + pid = project_id + if pid is None: + projects = ams.list_projects(account_id, jwt) + if not projects: + raise APIError("Your account has no project to create a key in.") + pid = projects[0]["project"]["id"] + created = ams.create_token(account_id, pid, name, jwt) + output.emit( + created, + lambda d: ( + f"Created key '[aai.success]{escape(name)}[/aai.success]': " + f"{escape(str(d['api_key']))}" + ), + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def rename( + ctx: typer.Context, + token_id: int = typer.Argument(..., help="The key id (see `aai keys list`)."), + new_name: str = typer.Argument(..., help="The new label."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Rename an existing API key.""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + ams.rename_token(account_id, token_id, new_name, jwt) + output.emit( + {"id": token_id, "name": new_name}, + lambda d: f"Renamed key {d['id']} to '{escape(new_name)}'.", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/main.py b/aai_cli/main.py index 30026f35..27dc6c93 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -17,6 +17,7 @@ claude, doctor, init, + keys, llm, login, samples, @@ -104,6 +105,7 @@ def main( app.add_typer(samples.app, name="samples") app.add_typer(init.app) app.add_typer(claude.app, name="claude") +app.add_typer(keys.app, name="keys") @app.command() diff --git a/tests/test_keys.py b/tests/test_keys.py new file mode 100644 index 00000000..9f619642 --- /dev/null +++ b/tests/test_keys.py @@ -0,0 +1,62 @@ +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def test_keys_list_flattens_tokens(): + _auth() + projects = [ + { + "project": {"id": 1, "name": "Default"}, + "tokens": [{"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False}], + } + ] + with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects): + result = runner.invoke(app, ["keys", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data[0]["id"] == 10 + assert "sk_abcdef1234" not in result.output # api key is masked + + +def test_keys_list_requires_session(): + result = runner.invoke(app, ["keys", "list"]) + assert result.exit_code == 2 + + +def test_keys_create_prints_new_key(): + _auth() + projects = [{"project": {"id": 1, "name": "Default"}, "tokens": []}] + created = { + "id": 11, + "project_id": 1, + "name": "ci", + "api_key": "sk_newkey9999", + "is_disabled": False, + } + with ( + patch("aai_cli.commands.keys.ams.list_projects", return_value=projects), + patch("aai_cli.commands.keys.ams.create_token", return_value=created) as create, + ): + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + assert result.exit_code == 0 + assert "sk_newkey9999" in result.output + create.assert_called_once_with(42, 1, "ci", "jwt") + + +def test_keys_rename_calls_ams(): + _auth() + with patch("aai_cli.commands.keys.ams.rename_token") as rename: + result = runner.invoke(app, ["keys", "rename", "10", "prod"]) + assert result.exit_code == 0 + rename.assert_called_once_with(42, 10, "prod", "jwt") From 93eba4df8b0050b026a56baa4eab81950548dde2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:01:40 +0000 Subject: [PATCH 07/14] feat(account): add 'aai balance', 'aai usage', 'aai limits' --- aai_cli/commands/account.py | 87 +++++++++++++++++++++++++++++ aai_cli/main.py | 2 + tests/test_account_command.py | 101 ++++++++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 aai_cli/commands/account.py create mode 100644 tests/test_account_command.py diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py new file mode 100644 index 00000000..65a97524 --- /dev/null +++ b/aai_cli/commands/account.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import typer +from rich.markup import escape +from rich.table import Table + +from aai_cli import output +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command + +app = typer.Typer(help="Account billing, usage, and limits.") + + +@app.command() +def balance( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show your remaining account balance.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + data = ams.get_balance(jwt) + cents = data.get("balance_in_cents", 0) or 0 + output.emit( + data, + lambda _d: f"Balance: [aai.success]${cents / 100:,.2f}[/aai.success]", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def usage( + ctx: typer.Context, + start: str = typer.Option(None, "--start", help="Start date (YYYY-MM-DD). Default: 30d ago."), + end: str = typer.Option(None, "--end", help="End date (YYYY-MM-DD). Default: today."), + window: str = typer.Option(None, "--window", help="Window size, e.g. 'day' or 'month'."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show usage over a date range (defaults to the last 30 days).""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + today = datetime.now(UTC).date() + end_date = end or today.isoformat() + start_date = start or (today - timedelta(days=30)).isoformat() + data = ams.get_usage(jwt, start_date, end_date, window) + + def render(d: dict) -> Table: + table = Table("window start", "window end", "total", header_style="aai.heading") + for item in d.get("usage_items", []): + table.add_row( + escape(str(item["start_timestamp"])), + escape(str(item["end_timestamp"])), + f"{item['total']:,}", + ) + return table + + output.emit(data, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def limits( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show your account's rate limits per service.""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + data = ams.get_rate_limits(account_id, jwt) + + def render(d: dict) -> Table: + table = Table("service", "limit", header_style="aai.heading") + for limit in d.get("rate_limits", []): + table.add_row(escape(str(limit["service"])), f"{limit['magnitude']:,}") + return table + + output.emit(data, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/main.py b/aai_cli/main.py index 27dc6c93..8e8999a1 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -13,6 +13,7 @@ from aai_cli import __version__, environments, stdio from aai_cli.commands import ( + account, agent, claude, doctor, @@ -100,6 +101,7 @@ def main( app.add_typer(transcripts.app, name="transcripts") app.add_typer(agent.app) app.add_typer(llm.app) +app.add_typer(account.app) # balance, usage, limits app.add_typer(login.app) # login, logout, whoami app.add_typer(doctor.app) app.add_typer(samples.app, name="samples") diff --git a/tests/test_account_command.py b/tests/test_account_command.py new file mode 100644 index 00000000..c2830023 --- /dev/null +++ b/tests/test_account_command.py @@ -0,0 +1,101 @@ +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def _human(monkeypatch): + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + + +def test_balance_formats_dollars(monkeypatch): + _auth() + _human(monkeypatch) + with patch( + "aai_cli.commands.account.ams.get_balance", + return_value={"account_id": 42, "balance_in_cents": 2575}, + ): + result = runner.invoke(app, ["balance"]) + assert result.exit_code == 0 + assert "$25.75" in result.output + + +def test_balance_requires_session(): + result = runner.invoke(app, ["balance"]) + assert result.exit_code == 2 + + +def test_usage_defaults_date_range_and_renders(monkeypatch): + _auth() + captured = {} + + def fake_usage(jwt, start, end, window): + captured["start"], captured["end"] = start, end + return { + "usage_items": [ + { + "start_timestamp": "2026-05-01", + "end_timestamp": "2026-05-02", + "total": 12.5, + "line_items": [], + } + ] + } + + with patch("aai_cli.commands.account.ams.get_usage", side_effect=fake_usage): + result = runner.invoke(app, ["usage", "--json"]) + assert result.exit_code == 0 + # both bounds are ISO dates (YYYY-MM-DD), defaulted when not passed + assert len(captured["start"]) == 10 and len(captured["end"]) == 10 + data = json.loads(result.output) + assert data["usage_items"][0]["total"] == 12.5 + + +def test_usage_renders_table_human(monkeypatch): + _auth() + _human(monkeypatch) + payload = { + "usage_items": [ + { + "start_timestamp": "2026-05-01", + "end_timestamp": "2026-05-02", + "total": 12.5, + "line_items": [], + } + ] + } + with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): + result = runner.invoke(app, ["usage"]) + assert result.exit_code == 0 + assert "2026-05-01" in result.output and "12.5" in result.output + + +def test_usage_passes_explicit_dates(): + _auth() + with patch( + "aai_cli.commands.account.ams.get_usage", return_value={"usage_items": []} + ) as get_usage: + result = runner.invoke(app, ["usage", "--start", "2026-01-01", "--end", "2026-02-01"]) + assert result.exit_code == 0 + get_usage.assert_called_once_with("jwt", "2026-01-01", "2026-02-01", None) + + +def test_limits_renders_services(monkeypatch): + _auth() + _human(monkeypatch) + with patch( + "aai_cli.commands.account.ams.get_rate_limits", + return_value={"rate_limits": [{"service": "transcript", "magnitude": 200}]}, + ): + result = runner.invoke(app, ["limits"]) + assert result.exit_code == 0 + assert "transcript" in result.output and "200" in result.output From 4165ac517833c41ed79f42b2438ff1ff4146b6f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:02:19 +0000 Subject: [PATCH 08/14] feat(sessions): add 'aai sessions' list/get for streaming history --- aai_cli/commands/sessions.py | 89 ++++++++++++++++++++++++++++++++++ aai_cli/main.py | 2 + tests/test_sessions_command.py | 89 ++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 aai_cli/commands/sessions.py create mode 100644 tests/test_sessions_command.py diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py new file mode 100644 index 00000000..e21b6d4d --- /dev/null +++ b/aai_cli/commands/sessions.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table +from rich.text import Text + +from aai_cli import output, theme +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command + +app = typer.Typer(help="Browse your past streaming (real-time) sessions.", no_args_is_help=True) + +# Fields shown by `sessions get`, in display order. +_DETAIL_FIELDS = ( + "session_id", + "status", + "region", + "created_at", + "completed_at", + "audio_duration_sec", + "session_duration_sec", + "speech_model", + "language_code", + "error", +) + + +@app.command(name="list") +def list_( + ctx: typer.Context, + limit: int = typer.Option(10, "--limit", help="How many sessions to show."), + status: str = typer.Option(None, "--status", help="Filter: created, completed, or error."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List recent streaming sessions.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + payload = ams.list_streaming(jwt, limit=limit, status=status) + rows = payload.get("data", []) + + def render(data: list[dict]) -> Table: + table = Table( + "session id", + "status", + "created", + "audio (s)", + "model", + header_style="aai.heading", + ) + for s in data: + status_str = str(s["status"]) + table.add_row( + escape(str(s["session_id"])), + Text(status_str, style=theme.status_style(status_str)), + escape(str(s.get("created_at") or "")), + escape(str(s.get("audio_duration_sec") or "")), + escape(str(s.get("speech_model") or "")), + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def get( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Streaming session id."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show details for one streaming session.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + data = ams.get_streaming(session_id, jwt) + + def render(d: dict) -> Table: + table = Table(show_header=False) + for field in _DETAIL_FIELDS: + value = d.get(field) + table.add_row(field, escape("" if value is None else str(value))) + return table + + output.emit(data, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/main.py b/aai_cli/main.py index 8e8999a1..9082d7e2 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -22,6 +22,7 @@ llm, login, samples, + sessions, stream, transcribe, transcripts, @@ -99,6 +100,7 @@ def main( app.add_typer(transcribe.app) app.add_typer(stream.app) app.add_typer(transcripts.app, name="transcripts") +app.add_typer(sessions.app, name="sessions") app.add_typer(agent.app) app.add_typer(llm.app) app.add_typer(account.app) # balance, usage, limits diff --git a/tests/test_sessions_command.py b/tests/test_sessions_command.py new file mode 100644 index 00000000..ea5400dc --- /dev/null +++ b/tests/test_sessions_command.py @@ -0,0 +1,89 @@ +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def _human(monkeypatch): + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + + +def test_sessions_list_renders_rows(): + _auth() + payload = { + "page_details": {"has_more": False}, + "data": [ + { + "session_id": "s_1", + "status": "completed", + "created_at": "2026-06-01", + "audio_duration_sec": 12.0, + "speech_model": "universal", + } + ], + } + with patch("aai_cli.commands.sessions.ams.list_streaming", return_value=payload): + result = runner.invoke(app, ["sessions", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data[0]["session_id"] == "s_1" + + +def test_sessions_list_renders_table_human(monkeypatch): + _auth() + _human(monkeypatch) + payload = { + "data": [ + { + "session_id": "s_1", + "status": "completed", + "created_at": "2026-06-01", + "audio_duration_sec": 12.0, + "speech_model": "universal", + } + ] + } + with patch("aai_cli.commands.sessions.ams.list_streaming", return_value=payload): + result = runner.invoke(app, ["sessions", "list"]) + assert result.exit_code == 0 + assert "s_1" in result.output and "universal" in result.output + + +def test_sessions_list_passes_status_filter(): + _auth() + with patch( + "aai_cli.commands.sessions.ams.list_streaming", return_value={"data": []} + ) as list_streaming: + result = runner.invoke(app, ["sessions", "list", "--status", "error", "--limit", "5"]) + assert result.exit_code == 0 + list_streaming.assert_called_once_with("jwt", limit=5, status="error") + + +def test_sessions_get_renders_detail(monkeypatch): + _auth() + _human(monkeypatch) + detail = { + "session_id": "s_1", + "status": "completed", + "speech_model": "universal", + "audio_duration_sec": 30.0, + "error": None, + } + with patch("aai_cli.commands.sessions.ams.get_streaming", return_value=detail): + result = runner.invoke(app, ["sessions", "get", "s_1"]) + assert result.exit_code == 0 + assert "s_1" in result.output and "universal" in result.output + + +def test_sessions_requires_session(): + result = runner.invoke(app, ["sessions", "list"]) + assert result.exit_code == 2 From 3588be4e4cdb3474fdb6390f705755d14f45672f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:03:04 +0000 Subject: [PATCH 09/14] feat(audit): add 'aai audit' to list account audit-log entries --- aai_cli/auth/ams.py | 8 ++++ aai_cli/commands/audit.py | 50 +++++++++++++++++++++ aai_cli/main.py | 2 + tests/test_ams_account.py | 16 +++++++ tests/test_audit_command.py | 86 +++++++++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+) create mode 100644 aai_cli/commands/audit.py create mode 100644 tests/test_audit_command.py diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 256ea516..51c1f972 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -139,3 +139,11 @@ def get_streaming(session_id: str, session_jwt: str) -> dict[str, Any]: with _client(session_jwt) as client: resp = client.get(f"/v1/users/streaming/{session_id}") return cast(dict[str, Any], _json_or_raise(resp)) + + +def list_audit_logs(session_jwt: str, **filters: Any) -> dict[str, Any]: + """GET /v2/user/audit-logs -> {page_details, data: [AuditLogResponse]}.""" + params = {k: v for k, v in filters.items() if v is not None} + with _client(session_jwt) as client: + resp = client.get("/v2/user/audit-logs", params=params) + return cast(dict[str, Any], _json_or_raise(resp)) diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py new file mode 100644 index 00000000..7c179e60 --- /dev/null +++ b/aai_cli/commands/audit.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table + +from aai_cli import output +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command + +app = typer.Typer(help="View your account's audit log.") + + +@app.command() +def audit( + ctx: typer.Context, + limit: int = typer.Option(20, "--limit", help="How many entries to show."), + action: str = typer.Option(None, "--action", help="Filter by action_taken."), + resource: str = typer.Option(None, "--resource", help="Filter by resource_type."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List recent audit-log entries for your account.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + payload = ams.list_audit_logs(jwt, limit=limit, action_taken=action, resource_type=resource) + rows = payload.get("data", []) + + def render(data: list[dict]) -> Table: + table = Table("time", "actor", "action", "resource", header_style="aai.heading") + for entry in data: + actor = str(entry["actor_type"]) + actor_id = entry.get("actor_id") + if actor_id is not None: + actor = f"{actor}:{actor_id}" + resource_label = entry.get("resource_type") or "" + resource_id = entry.get("resource_id") + if resource_label and resource_id: + resource_label = f"{resource_label}:{resource_id}" + table.add_row( + escape(str(entry["log_time"])), + escape(actor), + escape(str(entry["action_taken"])), + escape(str(resource_label)), + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/main.py b/aai_cli/main.py index 9082d7e2..3abd32a6 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -15,6 +15,7 @@ from aai_cli.commands import ( account, agent, + audit, claude, doctor, init, @@ -101,6 +102,7 @@ def main( app.add_typer(stream.app) app.add_typer(transcripts.app, name="transcripts") app.add_typer(sessions.app, name="sessions") +app.add_typer(audit.app) # audit app.add_typer(agent.app) app.add_typer(llm.app) app.add_typer(account.app) # balance, usage, limits diff --git a/tests/test_ams_account.py b/tests/test_ams_account.py index 1de327cc..2fc737d9 100644 --- a/tests/test_ams_account.py +++ b/tests/test_ams_account.py @@ -94,6 +94,22 @@ def handler(request): assert out["session_id"] == "s_1" +def test_list_audit_logs_passes_filters(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["cookie"] = request.headers.get("cookie", "") + return httpx.Response(200, json={"page_details": {}, "data": []}) + + _patch_transport(monkeypatch, handler) + ams.list_audit_logs("jwt", limit=5, action_taken="token.create") + assert "/v2/user/audit-logs" in seen["url"] + assert "limit=5" in seen["url"] + assert "action_taken=token.create" in seen["url"] + assert "stytch_session_jwt=jwt" in seen["cookie"] + + def test_account_4xx_raises_not_authenticated(monkeypatch): def handler(request): return httpx.Response(401, json={"detail": "expired"}) diff --git a/tests/test_audit_command.py b/tests/test_audit_command.py new file mode 100644 index 00000000..85a94d88 --- /dev/null +++ b/tests/test_audit_command.py @@ -0,0 +1,86 @@ +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def _human(monkeypatch): + monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: explicit) + + +def test_audit_renders_rows(): + _auth() + payload = { + "page_details": {"has_more": False}, + "data": [ + { + "id": 1, + "log_time": "2026-06-01T12:00:00Z", + "actor_type": "member", + "actor_id": 7, + "action_taken": "token.create", + "resource_type": "token", + "resource_id": "10", + } + ], + } + with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): + result = runner.invoke(app, ["audit", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data[0]["action_taken"] == "token.create" + + +def test_audit_passes_filters(): + _auth() + with patch( + "aai_cli.commands.audit.ams.list_audit_logs", return_value={"data": []} + ) as list_logs: + result = runner.invoke(app, ["audit", "--limit", "5", "--action", "token.create"]) + assert result.exit_code == 0 + list_logs.assert_called_once_with( + "jwt", limit=5, action_taken="token.create", resource_type=None + ) + + +def test_audit_human_mode_renders_table(monkeypatch): + _auth() + _human(monkeypatch) + payload = { + "data": [ + { + "id": 1, + "log_time": "2026-06-01T12:00:00Z", + "actor_type": "member", + "actor_id": 7, + "action_taken": "token.create", + "resource_type": "token", + "resource_id": "10", + }, + { + "id": 2, + "log_time": "2026-06-01T12:01:00Z", + "actor_type": "member", + "action_taken": "login", + "resource_type": None, + }, + ] + } + with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): + result = runner.invoke(app, ["audit"]) + assert result.exit_code == 0 + assert "login" in result.output and "token.create" in result.output + + +def test_audit_requires_session(): + result = runner.invoke(app, ["audit"]) + assert result.exit_code == 2 From 9417b13b6e56723848c56c9fd822ec00d4faaa0e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:03:35 +0000 Subject: [PATCH 10/14] feat(whoami): report account id + session; order new commands in help --- aai_cli/commands/login.py | 14 ++++++++++++-- aai_cli/main.py | 6 ++++++ tests/test_login.py | 25 +++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index f63d1c96..bd79d526 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -109,11 +109,21 @@ def body(state: AppState, json_mode: bool) -> None: masked = f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" env = environments.active().name reachable = client.validate_key(key) + session = config.get_session(profile) + account_id = config.get_account_id(profile) output.emit( - {"profile": profile, "env": env, "api_key": masked, "reachable": reachable}, + { + "profile": profile, + "env": env, + "api_key": masked, + "reachable": reachable, + "account_id": account_id, + "session": "stored" if session else "none", + }, lambda _d: ( f"profile={escape(profile)} env={escape(env)} " - f"key={escape(masked)} reachable={reachable}" + f"key={escape(masked)} reachable={reachable} " + f"account={account_id} session={'stored' if session else 'none'}" ), json_mode=json_mode, ) diff --git a/aai_cli/main.py b/aai_cli/main.py index 3abd32a6..d3973ccf 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -38,8 +38,14 @@ "transcribe", "stream", "transcripts", + "sessions", "agent", "llm", + "balance", + "usage", + "limits", + "keys", + "audit", "login", "logout", "whoami", diff --git a/tests/test_login.py b/tests/test_login.py index 05300368..b4d2e4ff 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -150,3 +150,28 @@ def test_rejected_api_key_has_suggestion(monkeypatch): result = runner.invoke(app, ["login", "--api-key", "sk_bad", "--json"]) assert result.exit_code == 1 assert "Check the key and retry" in result.output + + +def test_whoami_reports_session_and_account(): + import json + + config.set_api_key("default", "sk_1234567890") + config.set_session("default", session_jwt="j", session_token="t", account_id=77) + 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) + assert data["account_id"] == 77 + assert data["session"] == "stored" + + +def test_whoami_session_none_without_browser_login(): + 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, ["whoami", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["session"] == "none" + assert data["account_id"] is None From 812aba4b91b787c8368a25a673532cd86f4cbd12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:09:40 +0000 Subject: [PATCH 11/14] docs: document account self-service commands; add help examples + snapshots --- README.md | 29 +++ aai_cli/commands/account.py | 26 ++- aai_cli/commands/audit.py | 10 +- aai_cli/commands/keys.py | 28 ++- aai_cli/commands/sessions.py | 19 +- .../test_cli_output_snapshots.ambr | 208 ++++++++++++++++++ tests/test_smoke.py | 6 + 7 files changed, 317 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index af2b7a04..70f255fc 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ and `aai whoami` / `aai doctor` confirm which source is active without printing | `aai llm ` | Prompt AssemblyAI's LLM Gateway (over a past transcript with `--transcript-id`, or a live streamed transcript with `--follow`). | | `aai claude install` | Wire Claude Code up to AssemblyAI's docs + skill. | | `aai samples create ` | Scaffold a runnable starter script (reads your key from `ASSEMBLYAI_API_KEY`). | +| `aai keys list` / `create` / `rename` | Manage your API keys (browser login). | +| `aai balance` / `usage` / `limits` | Account billing, usage, and rate limits (browser login). | +| `aai sessions list` / `get ` | Browse past streaming (real-time) sessions (browser login). | +| `aai audit` | View your account's audit log (browser login). | Add `--json` to any command for machine-readable output (it's also the default when output is piped or run by an agent). Errors always go to **stderr**, so stdout stays @@ -108,6 +112,31 @@ across every command. > aai transcribe "https://www.youtube.com/watch?v=VIDEO_ID" > ``` +## Account self-service + +These commands use your browser login session (run `aai login` without +`--api-key`), not your API key: + +```sh +aai keys list # list API keys (masked) across projects +aai keys create --name ci-pipeline # mint a new key (printed once) +aai keys rename 123 "prod" # relabel a key + +aai balance # remaining account balance +aai usage --start 2026-05-01 --end 2026-06-01 +aai limits # rate limits per service + +aai sessions list --status completed +aai sessions get # one streaming session's details + +aai audit --limit 20 # recent account audit-log entries +aai audit --action token.create # filter by action +``` + +If a command reports it needs a browser login, your session has expired — run +`aai login` again. (AMS sessions are short-lived and cannot be refreshed +silently.) + ## Transcribe options `aai transcribe` exposes the full `TranscriptionConfig` surface as curated flags, diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 65a97524..02eb2543 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -9,11 +9,18 @@ from aai_cli import output from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.help_text import examples_epilog app = typer.Typer(help="Account billing, usage, and limits.") -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Show your remaining balance", "aai balance"), + ] + ) +) def balance( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), @@ -33,7 +40,14 @@ def body(state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Usage over the last 30 days", "aai usage"), + ("A specific date range", "aai usage --start 2026-05-01 --end 2026-06-01"), + ] + ) +) def usage( ctx: typer.Context, start: str = typer.Option(None, "--start", help="Start date (YYYY-MM-DD). Default: 30d ago."), @@ -65,7 +79,13 @@ def render(d: dict) -> Table: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Show rate limits per service", "aai limits"), + ] + ) +) def limits( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 7c179e60..4a4b467b 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -7,11 +7,19 @@ from aai_cli import output from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.help_text import examples_epilog app = typer.Typer(help="View your account's audit log.") -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Recent audit-log entries", "aai audit --limit 20"), + ("Filter by action", "aai audit --action token.create"), + ] + ) +) def audit( ctx: typer.Context, limit: int = typer.Option(20, "--limit", help="How many entries to show."), diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index ed98c39b..2491ffcb 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -8,6 +8,7 @@ from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.errors import APIError +from aai_cli.help_text import examples_epilog app = typer.Typer(help="List, create, and rename your AssemblyAI API keys.", no_args_is_help=True) @@ -16,7 +17,15 @@ def _mask(key: str) -> str: return f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" -@app.command(name="list") +@app.command( + name="list", + epilog=examples_epilog( + [ + ("List your API keys (masked)", "aai keys list"), + ("As JSON for scripting", "aai keys list --json"), + ] + ), +) def list_( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), @@ -57,7 +66,14 @@ def render(data: list[dict[str, object]]) -> Table: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Create a key in your default project", "aai keys create --name ci-pipeline"), + ("Create a key in a specific project", "aai keys create --name prod --project 7"), + ] + ) +) def create( ctx: typer.Context, name: str = typer.Option(..., "--name", help="A label for the new key."), @@ -89,7 +105,13 @@ def body(state: AppState, json_mode: bool) -> None: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Relabel a key (id from `aai keys list`)", 'aai keys rename 123 "prod"'), + ] + ) +) def rename( ctx: typer.Context, token_id: int = typer.Argument(..., help="The key id (see `aai keys list`)."), diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index e21b6d4d..ce48b73b 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -8,6 +8,7 @@ from aai_cli import output, theme from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.help_text import examples_epilog app = typer.Typer(help="Browse your past streaming (real-time) sessions.", no_args_is_help=True) @@ -26,7 +27,15 @@ ) -@app.command(name="list") +@app.command( + name="list", + epilog=examples_epilog( + [ + ("List recent streaming sessions", "aai sessions list"), + ("Only completed sessions", "aai sessions list --status completed"), + ] + ), +) def list_( ctx: typer.Context, limit: int = typer.Option(10, "--limit", help="How many sessions to show."), @@ -65,7 +74,13 @@ def render(data: list[dict]) -> Table: run_command(ctx, body, json=json_out) -@app.command() +@app.command( + epilog=examples_epilog( + [ + ("Show one session's details", "aai sessions get "), + ] + ) +) def get( ctx: typer.Context, session_id: str = typer.Argument(..., help="Streaming session id."), diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 10e796ca..7bca0a3f 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -53,6 +53,51 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[audit] + ''' + + Usage: aai audit [OPTIONS] + + List recent audit-log entries for your account. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --limit INTEGER How many entries to show. [default: 20] │ + │ --action TEXT Filter by action_taken. │ + │ --resource TEXT Filter by resource_type. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Recent audit-log entries + $ aai audit --limit 20 + Filter by action + $ aai audit --action token.create + + + + ''' +# --- +# name: test_command_help_matches_snapshot[balance] + ''' + + Usage: aai balance [OPTIONS] + + Show your remaining account balance. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Show your remaining balance + $ aai balance + + + ''' # --- # name: test_command_help_matches_snapshot[claude_install] @@ -177,6 +222,97 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[keys_create] + ''' + + Usage: aai keys create [OPTIONS] + + Create a new API key. Prints the key value once — copy it now. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ * --name TEXT A label for the new key. [required] │ + │ --project INTEGER Project id to create the key in (defaults to │ + │ your first). │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Create a key in your default project + $ aai keys create --name ci-pipeline + Create a key in a specific project + $ aai keys create --name prod --project 7 + + + + ''' +# --- +# name: test_command_help_matches_snapshot[keys_list] + ''' + + Usage: aai keys list [OPTIONS] + + List API keys across your projects (keys shown masked). + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + List your API keys (masked) + $ aai keys list + As JSON for scripting + $ aai keys list --json + + + + ''' +# --- +# name: test_command_help_matches_snapshot[keys_rename] + ''' + + Usage: aai keys rename [OPTIONS] TOKEN_ID NEW_NAME + + Rename an existing API key. + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ * token_id INTEGER The key id (see `aai keys list`). [required] │ + │ * new_name TEXT The new label. [required] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Relabel a key (id from `aai keys list`) + $ aai keys rename 123 "prod" + + + + ''' +# --- +# name: test_command_help_matches_snapshot[limits] + ''' + + Usage: aai limits [OPTIONS] + + Show your account's rate limits per service. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Show rate limits per service + $ aai limits + + + ''' # --- # name: test_command_help_matches_snapshot[llm] @@ -314,6 +450,53 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[sessions_get] + ''' + + Usage: aai sessions get [OPTIONS] SESSION_ID + + Show details for one streaming session. + + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ + │ * session_id TEXT Streaming session id. [required] │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Show one session's details + $ aai sessions get + + + + ''' +# --- +# name: test_command_help_matches_snapshot[sessions_list] + ''' + + Usage: aai sessions list [OPTIONS] + + List recent streaming sessions. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --limit INTEGER How many sessions to show. [default: 10] │ + │ --status TEXT Filter: created, completed, or error. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + List recent streaming sessions + $ aai sessions list + Only completed sessions + $ aai sessions list --status completed + + + ''' # --- # name: test_command_help_matches_snapshot[stream] @@ -608,6 +791,31 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[usage] + ''' + + Usage: aai usage [OPTIONS] + + Show usage over a date range (defaults to the last 30 days). + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --start TEXT Start date (YYYY-MM-DD). Default: 30d ago. │ + │ --end TEXT End date (YYYY-MM-DD). Default: today. │ + │ --window TEXT Window size, e.g. 'day' or 'month'. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Usage over the last 30 days + $ aai usage + A specific date range + $ aai usage --start 2026-05-01 --end 2026-06-01 + + + ''' # --- # name: test_command_help_matches_snapshot[version] diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 10213deb..e4a97d55 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -44,8 +44,14 @@ def test_help_lists_commands_in_workflow_order(): "transcribe", "stream", "transcripts", + "sessions", "agent", "llm", + "balance", + "usage", + "limits", + "keys", + "audit", "login", "logout", "whoami", From 0384b5ed31cf1fd0116c3b808842f05da825b051 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:12:04 +0000 Subject: [PATCH 12/14] fix(auth): clear stale browser session on --api-key re-login --- aai_cli/commands/login.py | 4 ++++ tests/test_login.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index bd79d526..76131903 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -40,6 +40,10 @@ def body(state: AppState, json_mode: bool) -> None: ) config.set_api_key(profile, api_key) config.set_profile_env(profile, env) + # Clear any session from a prior browser login: this profile is now + # api-key-only, so account self-service must report it needs a browser + # login rather than silently reusing the old (possibly different) identity. + config.clear_session(profile) else: result = run_login_flow() config.set_api_key(profile, result.api_key) diff --git a/tests/test_login.py b/tests/test_login.py index b4d2e4ff..340d5728 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -81,6 +81,18 @@ def test_login_api_key_flag_does_not_persist_session(): assert config.get_session("default") is None +def test_login_api_key_flag_clears_prior_browser_session(): + # A profile previously authenticated via browser becomes api-key-only on + # re-login; the stale session must not linger and silently authenticate + # account self-service commands as the previous identity. + config.set_session("default", session_jwt="old_j", session_token="old_t", account_id=5) + 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_session("default") is None + assert config.get_account_id("default") is None + + def test_logout_clears_session(): config.set_api_key("default", "sk_1234567890") config.set_session("default", session_jwt="j", session_token="t", account_id=7) From 2c24600a12b0d1218130a6f2733e4b29bd2fde91 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:17:03 +0000 Subject: [PATCH 13/14] refactor: extract shared mask_secret helper; compute whoami session label once --- aai_cli/commands/keys.py | 6 +----- aai_cli/commands/login.py | 8 ++++---- aai_cli/output.py | 5 +++++ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index 2491ffcb..a36dc316 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -13,10 +13,6 @@ app = typer.Typer(help="List, create, and rename your AssemblyAI API keys.", no_args_is_help=True) -def _mask(key: str) -> str: - return f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" - - @app.command( name="list", epilog=examples_epilog( @@ -44,7 +40,7 @@ def body(state: AppState, json_mode: bool) -> None: "id": token["id"], "name": token["name"], "project": project_name, - "key": _mask(str(token["api_key"])), + "key": output.mask_secret(str(token["api_key"])), "disabled": token["is_disabled"], } ) diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 76131903..517b7ab1 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -110,10 +110,10 @@ def body(state: AppState, json_mode: bool) -> None: key = config.get_api_key(profile) if not key: raise NotAuthenticated() - masked = f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" + masked = output.mask_secret(key) env = environments.active().name reachable = client.validate_key(key) - session = config.get_session(profile) + session_label = "stored" if config.get_session(profile) else "none" account_id = config.get_account_id(profile) output.emit( { @@ -122,12 +122,12 @@ def body(state: AppState, json_mode: bool) -> None: "api_key": masked, "reachable": reachable, "account_id": account_id, - "session": "stored" if session else "none", + "session": session_label, }, lambda _d: ( f"profile={escape(profile)} env={escape(env)} " f"key={escape(masked)} reachable={reachable} " - f"account={account_id} session={'stored' if session else 'none'}" + f"account={account_id} session={session_label}" ), json_mode=json_mode, ) diff --git a/aai_cli/output.py b/aai_cli/output.py index 76803ab5..8431c14d 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -56,6 +56,11 @@ def stream_output_modes(field: str | None, json_mode: bool) -> tuple[bool, bool] return text_mode, (field == "json") or (json_mode and not text_mode) +def mask_secret(value: str) -> str: + """Render a secret (API key, token) for display: first 3 + last 4 chars, else ``***``.""" + return f"{value[:3]}…{value[-4:]}" if len(value) > 7 else "***" + + def emit(data: T, human_renderer: Callable[[T], object], *, json_mode: bool) -> None: if json_mode: print(json.dumps(data, default=str)) From 59a5b1b7fdd8b13bf5e00f2012850efca7045aee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:26:11 +0000 Subject: [PATCH 14/14] test: drop stale get_auth patch (removed on main in #10) --- tests/test_auth_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_auth_flow.py b/tests/test_auth_flow.py index 92dfc7c2..63286e92 100644 --- a/tests/test_auth_flow.py +++ b/tests/test_auth_flow.py @@ -253,7 +253,6 @@ def test_run_login_flow_returns_session_material(monkeypatch): "account": {"id": 99}, }, ) - monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 99}) monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_key") result = flow.run_login_flow()