diff --git a/.gitignore b/.gitignore index 2510994d..c2c09a6f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ build/ .coverage coverage.xml htmlcov/ +mutants/ # Editor/agent local artifacts: keep personal settings local, but track the # team-shared bits (.claude/settings.json, agents/, skills/). diff --git a/.importlinter b/.importlinter new file mode 100644 index 00000000..08d091d1 --- /dev/null +++ b/.importlinter @@ -0,0 +1,64 @@ +[importlinter] +root_package = aai_cli +include_external_packages = True + +[importlinter:contract:1] +name = Core modules do not import command modules +type = forbidden +source_modules = + aai_cli.agent + aai_cli.auth + aai_cli.client + aai_cli.code_gen + aai_cli.config + aai_cli.config_builder + aai_cli.context + aai_cli.environments + aai_cli.errors + aai_cli.follow + aai_cli.help_panels + aai_cli.help_text + aai_cli.init + aai_cli.llm + aai_cli.microphone + aai_cli.output + aai_cli.render + aai_cli.stdio + aai_cli.streaming + aai_cli.theme + aai_cli.transcribe_render + aai_cli.youtube +forbidden_modules = + aai_cli.commands + +[importlinter:contract:2] +name = Command modules are independent +type = independence +modules = + aai_cli.commands.account + aai_cli.commands.agent + aai_cli.commands.audit + aai_cli.commands.claude + aai_cli.commands.doctor + aai_cli.commands.init + aai_cli.commands.keys + aai_cli.commands.llm + aai_cli.commands.login + aai_cli.commands.samples + aai_cli.commands.sessions + aai_cli.commands.stream + aai_cli.commands.transcribe + aai_cli.commands.transcripts + +[importlinter:contract:3] +name = Library layers do not depend on Rich rendering +type = forbidden +source_modules = + aai_cli.client + aai_cli.config + aai_cli.config_builder + aai_cli.environments + aai_cli.errors + aai_cli.llm +forbidden_modules = + rich diff --git a/aai_cli/agent/audio.py b/aai_cli/agent/audio.py index 33c51823..4421d7fb 100644 --- a/aai_cli/agent/audio.py +++ b/aai_cli/agent/audio.py @@ -7,7 +7,7 @@ from typing import Any from aai_cli.errors import CLIError -from aai_cli.microphone import _default_rate, _resample, audio_missing_error +from aai_cli.microphone import audio_missing_error, default_rate, resample_pcm16 SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate @@ -19,7 +19,7 @@ def _output_default_rate(device: int | None = None) -> int: 'paramErr' (-50) from forcing an unsupported one; agent audio (24 kHz) is resampled to it. Falls back to a safe default when the device can't be queried. """ - return _default_rate("output", device) + return default_rate("output", device) class NullPlayer: @@ -141,7 +141,7 @@ def start(self) -> None: def feed(self, pcm: bytes) -> None: """Queue target-rate PCM for playback, resampled to the device rate.""" if self._device_rate != self._target: - pcm, self._out_state = _resample( + pcm, self._out_state = resample_pcm16( pcm, self._out_state, src_rate=self._target, dst_rate=self._device_rate ) with self._lock: @@ -163,7 +163,7 @@ def capture_frames(self) -> Iterator[bytes]: if chunk is None: return if self._device_rate != self._target: - chunk, state = _resample( + chunk, state = resample_pcm16( chunk, state, src_rate=self._device_rate, dst_rate=self._target ) yield chunk diff --git a/aai_cli/agent/session.py b/aai_cli/agent/session.py index 1581be6b..44b96220 100644 --- a/aai_cli/agent/session.py +++ b/aai_cli/agent/session.py @@ -71,41 +71,41 @@ def dispatch(self, event: dict[str, Any]) -> None: if handler is not None: handler(self, event) - def _on_session_ready(self, _event: dict[str, Any]) -> None: + def on_session_ready(self, _event: dict[str, Any]) -> None: with self._lock: self.ready = True if self.ready_event is not None: self.ready_event.set() self.renderer.connected() - def _on_speech_started(self, _event: dict[str, Any]) -> None: + def on_speech_started(self, _event: dict[str, Any]) -> None: if self.full_duplex: self.player.flush() - def _on_user_delta(self, event: dict[str, Any]) -> None: + def on_user_delta(self, event: dict[str, Any]) -> None: self.renderer.user_partial(event.get("text", "")) - def _on_user_final(self, event: dict[str, Any]) -> None: + def on_user_final(self, event: dict[str, Any]) -> None: self._saw_user = True self.renderer.user_final(event.get("text", "")) - def _on_reply_started(self, _event: dict[str, Any]) -> None: + def on_reply_started(self, _event: dict[str, Any]) -> None: if not self.full_duplex: with self._lock: self.muted = True self.renderer.reply_started() - def _on_reply_audio(self, event: dict[str, Any]) -> None: + def on_reply_audio(self, event: dict[str, Any]) -> None: data = event.get("data") if data: self.player.enqueue(base64.b64decode(data)) - def _on_agent_transcript(self, event: dict[str, Any]) -> None: + def on_agent_transcript(self, event: dict[str, Any]) -> None: self.renderer.agent_transcript( event.get("text", ""), interrupted=bool(event.get("interrupted", False)) ) - def _on_reply_done(self, event: dict[str, Any]) -> None: + def on_reply_done(self, event: dict[str, Any]) -> None: if not self.full_duplex: with self._lock: self.muted = False @@ -117,7 +117,7 @@ def _on_reply_done(self, event: dict[str, Any]) -> None: if self.exit_after_reply and self._saw_user and not interrupted: self.finished = True - def _raise_error(self, event: dict[str, Any]) -> None: + def raise_error(self, event: dict[str, Any]) -> None: code = event.get("code", "") message = event.get("message") or code or "Voice agent error." if code in _AUTH_ERROR_CODES: @@ -132,15 +132,15 @@ def _raise_error(self, event: dict[str, Any]) -> None: # Server event type -> the VoiceAgentSession method that handles it. Types absent # here (input.speech.stopped, tool.call, anything unrecognized) are ignored. _EVENT_HANDLERS: dict[str, Callable[[VoiceAgentSession, dict[str, Any]], None]] = { - "session.ready": VoiceAgentSession._on_session_ready, - "input.speech.started": VoiceAgentSession._on_speech_started, - "transcript.user.delta": VoiceAgentSession._on_user_delta, - "transcript.user": VoiceAgentSession._on_user_final, - "reply.started": VoiceAgentSession._on_reply_started, - "reply.audio": VoiceAgentSession._on_reply_audio, - "transcript.agent": VoiceAgentSession._on_agent_transcript, - "reply.done": VoiceAgentSession._on_reply_done, - "session.error": VoiceAgentSession._raise_error, + "session.ready": VoiceAgentSession.on_session_ready, + "input.speech.started": VoiceAgentSession.on_speech_started, + "transcript.user.delta": VoiceAgentSession.on_user_delta, + "transcript.user": VoiceAgentSession.on_user_final, + "reply.started": VoiceAgentSession.on_reply_started, + "reply.audio": VoiceAgentSession.on_reply_audio, + "transcript.agent": VoiceAgentSession.on_agent_transcript, + "reply.done": VoiceAgentSession.on_reply_done, + "session.error": VoiceAgentSession.raise_error, } diff --git a/aai_cli/auth/ams.py b/aai_cli/auth/ams.py index 4fd1bca9..7f611699 100644 --- a/aai_cli/auth/ams.py +++ b/aai_cli/auth/ams.py @@ -1,35 +1,60 @@ from __future__ import annotations -from typing import Any, cast - import httpx2 as httpx +from pydantic import TypeAdapter, ValidationError from aai_cli.auth import endpoints from aai_cli.errors import APIError, NotAuthenticated _TIMEOUT = 30.0 +_HTTP_ERROR_MIN_STATUS = 400 +_JSON_OBJECT: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object]) +_JSON_OBJECTS: TypeAdapter[list[dict[str, object]]] = TypeAdapter(list[dict[str, object]]) def _detail(resp: httpx.Response) -> str: + fallback = resp.text or f"HTTP {resp.status_code}" 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}" + body: object = resp.json() + mapping = _JSON_OBJECT.validate_python(body) + if "detail" in mapping: + return str(mapping["detail"]) + except (TypeError, ValueError, ValidationError): + return fallback + return fallback 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: + if resp.status_code >= _HTTP_ERROR_MIN_STATUS: raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}") -def _json_or_raise(resp: httpx.Response) -> Any: +def _json_or_raise(resp: httpx.Response) -> object: _raise_for_error(resp) - return resp.json() + data: object = resp.json() + return data + + +def _json_object_or_raise(resp: httpx.Response) -> dict[str, object]: + data = _json_or_raise(resp) + try: + return _JSON_OBJECT.validate_python(data) + except ValidationError as exc: + raise APIError( + f"AMS request returned unexpected JSON: expected object, got {type(data).__name__}." + ) from exc + + +def _json_object_list_or_raise(resp: httpx.Response) -> list[dict[str, object]]: + data = _json_or_raise(resp) + try: + return _JSON_OBJECTS.validate_python(data) + except ValidationError as exc: + raise APIError( + f"AMS request returned unexpected JSON: expected list of objects, got {type(data).__name__}." + ) from exc def _client(session_jwt: str | None = None) -> httpx.Client: @@ -38,17 +63,17 @@ def _client(session_jwt: str | None = None) -> httpx.Client: return httpx.Client(base_url=endpoints.ams_base(), timeout=_TIMEOUT, cookies=cookies) -def discover(token: str) -> dict[str, Any]: +def discover(token: str) -> dict[str, object]: """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)) + return _json_object_or_raise(resp) -def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]: +def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, object]: """POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}.""" with _client() as client: resp = client.post( @@ -58,33 +83,33 @@ def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, "organization_id": organization_id, }, ) - return cast(dict[str, Any], _json_or_raise(resp)) + return _json_object_or_raise(resp) -def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]: +def list_projects(account_id: int, session_jwt: str) -> list[dict[str, object]]: """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)) + return _json_object_list_or_raise(resp) def create_token( account_id: int, project_id: int, token_name: str, session_jwt: str -) -> dict[str, Any]: +) -> dict[str, object]: """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)) + return _json_object_or_raise(resp) -def get_balance(session_jwt: str) -> dict[str, Any]: +def get_balance(session_jwt: str) -> dict[str, object]: """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)) + return _json_object_or_raise(resp) def get_usage( @@ -92,21 +117,21 @@ def get_usage( starting_on: str, ending_before: str, window_size: str | None = None, -) -> dict[str, Any]: +) -> dict[str, object]: """POST /v2/billing/usage (ISO dates) -> {usage_items: [...]}.""" - body: dict[str, Any] = {"starting_on": starting_on, "ending_before": ending_before} + body: dict[str, object] = {"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)) + return _json_object_or_raise(resp) -def get_rate_limits(account_id: int, session_jwt: str) -> dict[str, Any]: +def get_rate_limits(account_id: int, session_jwt: str) -> dict[str, object]: """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)) + return _json_object_or_raise(resp) def rename_token(account_id: int, token_id: int, token_name: str, session_jwt: str) -> None: @@ -119,24 +144,28 @@ def rename_token(account_id: int, token_id: int, token_name: str, session_jwt: s _raise_for_error(resp) -def list_streaming(session_jwt: str, **filters: Any) -> dict[str, Any]: +def list_streaming(session_jwt: str, **filters: object) -> dict[str, object]: """GET /v1/users/streaming -> {page_details, data: [StreamingSessionSchema]}.""" - params = {k: v for k, v in filters.items() if v is not None} + params = { + key: value for key, value in filters.items() if isinstance(value, str | int | float | bool) + } with _client(session_jwt) as client: resp = client.get("/v1/users/streaming", params=params) - return cast(dict[str, Any], _json_or_raise(resp)) + return _json_object_or_raise(resp) -def get_streaming(session_id: str, session_jwt: str) -> dict[str, Any]: +def get_streaming(session_id: str, session_jwt: str) -> dict[str, object]: """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)) + return _json_object_or_raise(resp) -def list_audit_logs(session_jwt: str, **filters: Any) -> dict[str, Any]: +def list_audit_logs(session_jwt: str, **filters: object) -> dict[str, object]: """GET /v2/user/audit-logs -> {page_details, data: [AuditLogResponse]}.""" - params = {k: v for k, v in filters.items() if v is not None} + params = { + key: value for key, value in filters.items() if isinstance(value, str | int | float | bool) + } with _client(session_jwt) as client: resp = client.get("/v2/user/audit-logs", params=params) - return cast(dict[str, Any], _json_or_raise(resp)) + return _json_object_or_raise(resp) diff --git a/aai_cli/auth/flow.py b/aai_cli/auth/flow.py index f7c10226..d0f88e45 100644 --- a/aai_cli/auth/flow.py +++ b/aai_cli/auth/flow.py @@ -3,11 +3,10 @@ import webbrowser from collections.abc import Mapping from dataclasses import dataclass -from typing import Any from rich.markup import escape -from aai_cli import output +from aai_cli import jsonshape, output from aai_cli.auth import ams, discovery, endpoints, loopback from aai_cli.errors import APIError @@ -23,18 +22,41 @@ class LoginResult: account_id: int -def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any: +def _as_mapping(value: object) -> dict[str, object] | None: + return jsonshape.as_mapping(value) + + +def _mapping_list(value: object) -> list[dict[str, object]]: + return jsonshape.mapping_list(value) + + +def _require_int(mapping: Mapping[str, object], key: str, what: str) -> int: + value = _require(mapping, key, what) + if isinstance(value, bool): + raise APIError( + f"Login failed: the server response was missing {what}. Run 'aai login' again." + ) + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value) + except ValueError as exc: + raise APIError( + f"Login failed: the server response was missing {what}. Run 'aai login' again." + ) from exc + raise APIError(f"Login failed: the server response was missing {what}. Run 'aai login' again.") + + +def _require(mapping: Mapping[str, object], key: str, what: str) -> object: """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. The return stays `Any` - because AMS JSON leaves are untyped and callers coerce them (int()/str()). + "run login again" message the rest of the flow uses. The return stays `object` + because AMS JSON leaves are narrowed by callers with int()/str(). """ - # Nested calls pass an `Any`-typed value that the type checker accepts as a - # Mapping but which may be a non-mapping at runtime (e.g. a malformed 200), - # so still guard with isinstance before calling .get. - value = mapping.get(key) if isinstance(mapping, dict) else None + value = mapping.get(key) if value is None: raise APIError( f"Login failed: the server response was missing {what}. Run 'aai login' again." @@ -42,13 +64,25 @@ def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any: return value +def _require_mapping(mapping: Mapping[str, object], key: str, what: str) -> dict[str, object]: + value = _require(mapping, key, what) + mapped = _as_mapping(value) + if mapped is None: + raise APIError( + f"Login failed: the server response was missing {what}. Run 'aai login' again." + ) + return mapped + + 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 [aai.url]{escape(url)}[/aai.url]") + output.error_console.print( + f"Opening your browser to sign in:\n [aai.url]{escape(url)}[/aai.url]" + ) try: webbrowser.open(url) except Exception: # noqa: BLE001 - opening a browser is best-effort - output.console.print( + output.error_console.print( "[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]" ) @@ -57,7 +91,7 @@ def _capture() -> loopback.CallbackResult: return loopback.capture_callback() -def _is_reusable_cli_token(token: dict[str, Any]) -> bool: +def _is_reusable_cli_token(token: dict[str, object]) -> 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 @@ -79,10 +113,12 @@ def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: suggestion="Create a project in the AssemblyAI dashboard, then run 'aai login' again.", ) for entry in projects: - for token in entry.get("tokens", []): + for token in _mapping_list(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") + project_id = _require_int( + _require_mapping(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")) @@ -104,29 +140,34 @@ def run_login_flow() -> LoginResult: ) disc = ams.discover(result.token) - organizations = disc.get("organizations") or [] + organizations = _mapping_list(disc.get("organizations")) 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" + chosen = str( + organizations[0].get("organization_name") + or organizations[0].get("organization_id", "the first") ) - output.console.print( + output.error_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") + organization_id = str(_require(organizations[0], "organization_id", "an organization id")) - intermediate_session_token = _require(disc, "intermediate_session_token", "a session token") + intermediate_session_token = str( + _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") + session_jwt = str(_require(signed_in, "session_jwt", "a session token")) + session_token = str(_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 = int(_require(_require(signed_in, "account", "an account"), "id", "an account id")) + account_id = _require_int( + _require_mapping(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, diff --git a/aai_cli/auth/loopback.py b/aai_cli/auth/loopback.py index 42cd889d..7042031d 100644 --- a/aai_cli/auth/loopback.py +++ b/aai_cli/auth/loopback.py @@ -46,7 +46,7 @@ def do_GET(self) -> None: # stdlib API name self.wfile.write(_SUCCESS_HTML) done.set() - def log_message(self, *args: object) -> None: # silence stderr logging + def log_message(self, format: str, *args: object) -> None: # silence stderr logging pass try: diff --git a/aai_cli/client.py b/aai_cli/client.py index 5eaac4c9..3aa9d507 100644 --- a/aai_cli/client.py +++ b/aai_cli/client.py @@ -2,8 +2,8 @@ import contextlib import json -from collections.abc import Callable, Iterable, Iterator -from typing import Any +from collections.abc import Callable, Generator, Iterable +from typing import Any, Literal, Protocol import assemblyai as aai from assemblyai.streaming.v3 import ( @@ -13,10 +13,28 @@ StreamingParameters, ) -from aai_cli import environments, stdio +from aai_cli import environments, jsonshape, stdio from aai_cli.errors import APIError, CLIError, UsageError, auth_failure, is_auth_failure SAMPLE_AUDIO_URL = "https://assembly.ai/wildfires.mp3" +_StreamHandler = Callable[[Any, Any], object] + + +class _StreamingClientLike(Protocol): + def on(self, event: StreamingEvents, handler: _StreamHandler) -> None: ... + + def connect(self, params: StreamingParameters) -> None: ... + + def stream(self, data: bytes | Generator[bytes, None, None] | Iterable[bytes]) -> None: ... + + def disconnect(self, *, terminate: Literal[False, True] = False) -> None: ... + + +def _make_streaming_client(api_key: str) -> _StreamingClientLike: + client: _StreamingClientLike = StreamingClient( + StreamingClientOptions(api_key=api_key, api_host=environments.active().streaming_host) + ) + return client def resolve_audio_source(source: str | None, *, sample: bool) -> str: @@ -40,7 +58,7 @@ def _configure(api_key: str) -> None: @contextlib.contextmanager -def _sdk_errors(message: str) -> Iterator[None]: +def _sdk_errors(message: str) -> Generator[None]: """Normalize SDK exceptions for one call: a rejected key becomes a single clean auth_failure(); any other error becomes APIError(f"{message}: {exc}"). @@ -124,11 +142,18 @@ def _transcript_text(transcript: Any) -> str: return str(getattr(transcript, "text", "") or "") +def _objects(value: object) -> list[object]: + return jsonshape.object_list(value) + + def _render_utterances(transcript: Any) -> str: - utterances = getattr(transcript, "utterances", None) or [] + utterances = _objects(getattr(transcript, "utterances", None)) if not utterances: return _transcript_text(transcript) - return "\n".join(f"Speaker {u.speaker}: {u.text}" for u in utterances) + return "\n".join( + f"Speaker {getattr(utterance, 'speaker', '')}: {getattr(utterance, 'text', '')}" + for utterance in utterances + ) def _export_srt(transcript: Any) -> str: @@ -172,11 +197,9 @@ def stream_audio( Forwards Begin/Turn/Termination events to the callbacks; raises APIError on a stream error. `params` is a fully-built StreamingParameters (sample_rate/speech_model/etc). """ - sc = StreamingClient( - StreamingClientOptions(api_key=api_key, api_host=environments.active().streaming_host) - ) + sc = _make_streaming_client(api_key) - def _guard(cb: Callable[[Any], Any]) -> Callable[[Any, Any], None]: + def _guard(cb: Callable[[Any], Any]) -> _StreamHandler: # Event callbacks run on the SDK's reader thread. If the downstream pipe is # gone (e.g. a Ctrl-C'd `| aai llm`, or `| head`), writing a turn raises # BrokenPipeError there with no handler -> an ugly thread traceback. Swallow @@ -191,13 +214,17 @@ def handler(_client: Any, event: Any) -> None: return handler errors: list[object] = [] + + def _record_error(_client: object, error: object) -> None: + errors.append(error) + if on_begin is not None: sc.on(StreamingEvents.Begin, _guard(on_begin)) if on_turn is not None: sc.on(StreamingEvents.Turn, _guard(on_turn)) if on_termination is not None: sc.on(StreamingEvents.Termination, _guard(on_termination)) - sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) + sc.on(StreamingEvents.Error, _record_error) with _sdk_errors("Could not start streaming session"): sc.connect(params) diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index 2e546662..435b561a 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -1,13 +1,15 @@ from __future__ import annotations +from collections.abc import Mapping from datetime import UTC, date, datetime, timedelta -from typing import Any import typer +from rich.console import Group from rich.markup import escape from rich.table import Table +from rich.text import Text -from aai_cli import help_panels, output +from aai_cli import help_panels, jsonshape, output, timeparse from aai_cli.auth import ams from aai_cli.context import AppState, resolve_session, run_command from aai_cli.errors import UsageError @@ -28,6 +30,90 @@ def _utc_day_start(day: str) -> str: return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC).isoformat() +def _parse_usage_timestamp(value: object) -> datetime | None: + return timeparse.parse_iso_utc(value) + + +def _format_usage_day(value: object) -> str: + parsed = _parse_usage_timestamp(value) + if parsed is None: + return str(value or "") + return parsed.date().isoformat() + + +def _usage_number(value: object) -> float: + if isinstance(value, bool): + return 0.0 + if isinstance(value, int | float): + return float(value) + if isinstance(value, str): + try: + return float(value) + except ValueError: + return 0.0 + return 0.0 + + +def _format_usage_number(value: object) -> str: + number = _usage_number(value) + if number.is_integer(): + return f"{int(number):,}" + return f"{number:,.6f}".rstrip("0").rstrip(".") + + +def _mapping_list(value: object) -> list[dict[str, object]]: + return jsonshape.mapping_list(value) + + +def _usage_items(data: Mapping[str, object]) -> list[dict[str, object]]: + return _mapping_list(data.get("usage_items")) + + +def _window_label(item: Mapping[str, object]) -> str: + start = _parse_usage_timestamp(item.get("start_timestamp")) + end = _parse_usage_timestamp(item.get("end_timestamp")) + if start is None or end is None: + return _format_usage_day(item.get("start_timestamp")) + if end.date() == start.date() + timedelta(days=1): + return start.date().isoformat() + return f"{start.date().isoformat()} to {end.date().isoformat()}" + + +def _line_item_label(line_item: Mapping[str, object]) -> str: + label = next( + ( + str(value) + for key in ("name", "product", "service", "feature", "model", "type", "description") + if (value := line_item.get(key)) + ), + "", + ) + value = next( + ( + line_item[key] + for key in ("total", "quantity", "amount", "usage", "count") + if key in line_item + ), + None, + ) + if label and value is not None: + return f"{label}: {_format_usage_number(value)}" + if label: + return label + if value is not None: + return _format_usage_number(value) + return "" + + +def _line_items_summary(item: Mapping[str, object]) -> str: + labels = [ + label + for line_item in _mapping_list(item.get("line_items")) + if (label := _line_item_label(line_item)) + ] + return ", ".join(labels) + + app = typer.Typer(help="Account billing, usage, and limits.") @@ -48,7 +134,7 @@ def 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 + cents = _usage_number(data.get("balance_in_cents")) output.emit( data, lambda _d: f"Balance: [aai.success]${cents / 100:,.2f}[/aai.success]", @@ -69,9 +155,12 @@ def body(state: AppState, json_mode: bool) -> None: ) 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'."), + start: str | None = typer.Option( + None, "--start", help="Start date (YYYY-MM-DD). Default: 30d ago." + ), + end: str | None = typer.Option(None, "--end", help="End date (YYYY-MM-DD). Default: today."), + window: str | None = typer.Option(None, "--window", help="Window size, e.g. 'day' or 'month'."), + include_zero: bool = typer.Option(False, "--all", help="Include zero-usage windows."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: """Show usage over a date range (defaults to the last 30 days).""" @@ -83,15 +172,56 @@ def body(state: AppState, json_mode: bool) -> None: end_date = _utc_day_start(end or today.isoformat()) data = ams.get_usage(jwt, start_date, end_date, window) - def render(d: dict[str, Any]) -> 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']:,}", + def render(d: dict[str, object]) -> object: + items = _usage_items(d) + shown = ( + items + if include_zero + else [item for item in items if _usage_number(item.get("total"))] + ) + total = sum(_usage_number(item.get("total")) for item in items) + range_label = f"{_format_usage_day(start_date)} to {_format_usage_day(end_date)} (UTC)" + summary = Text( + f"Usage total: {_format_usage_number(total)} for {range_label}", + style="aai.heading", + ) + if not shown: + if items: + return Group( + summary, + Text("No usage in this range.", style="aai.muted"), + ) + return Group( + summary, + Text("No usage windows returned for this range.", style="aai.muted"), ) - return table + + shown_with_breakdown = [(item, _line_items_summary(item)) for item in shown] + show_breakdown = any(summary for _, summary in shown_with_breakdown) + table = ( + Table("period", "total", "breakdown", header_style="aai.heading") + if show_breakdown + else Table("period", "total", header_style="aai.heading") + ) + hidden_count = len(items) - len(shown) + for item, breakdown in shown_with_breakdown: + row = [ + escape(_window_label(item)), + _format_usage_number(item.get("total")), + ] + if show_breakdown: + row.append(escape(breakdown)) + table.add_row(*row) + if hidden_count: + return Group( + summary, + table, + Text( + f"Hidden: {hidden_count} zero-usage window(s). Use --all to show them.", + style="aai.muted", + ), + ) + return Group(summary, table) output.emit(data, render, json_mode=json_mode) @@ -116,10 +246,13 @@ 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[str, Any]) -> Table: + def render(d: dict[str, object]) -> 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']:,}") + for limit in _mapping_list(d.get("rate_limits")): + table.add_row( + escape(str(limit.get("service", ""))), + _format_usage_number(limit.get("magnitude")), + ) return table output.emit(data, render, json_mode=json_mode) diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index d58f08ac..6dc81e6a 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -32,7 +32,7 @@ ) def agent( ctx: typer.Context, - source: str = typer.Argument( + source: str | None = typer.Argument( None, help="Audio file path or URL to speak to the agent. Omit to use the microphone." ), sample: bool = typer.Option( @@ -42,7 +42,7 @@ def agent( system_prompt: str = typer.Option( DEFAULT_PROMPT, "--system-prompt", help="System prompt (the agent's persona)." ), - system_prompt_file: Path = typer.Option( + system_prompt_file: Path | None = typer.Option( None, "--system-prompt-file", help="Read the system prompt from a file (overrides --system-prompt).", @@ -51,7 +51,7 @@ def agent( device: int | None = typer.Option(None, "--device", help="Microphone device index."), list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), - output_field: str = typer.Option( + output_field: str | None = typer.Option( None, "-o", "--output", @@ -74,7 +74,7 @@ def agent( raise typer.Exit(code=0) def body(state: AppState, json_mode: bool) -> None: - text_mode, json_mode = output.stream_output_modes(output_field, json_mode) + text_mode, json_mode = output.stream_output_modes(output_field, json_mode=json_mode) if voice not in VOICES: raise UsageError( f"Unknown voice {voice!r}.", diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 6fb2b4eb..f8976478 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -1,22 +1,109 @@ from __future__ import annotations +from collections.abc import Mapping +from datetime import datetime + import typer +from rich.console import Group from rich.markup import escape from rich.table import Table +from rich.text import Text -from aai_cli import help_panels, output +from aai_cli import help_panels, jsonshape, output, timeparse 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.") +_LOGIN_ACTIONS = {"login", "login.succeeded", "login__succeeded"} +_ACTION_LABELS = { + "account.create": "Account created", + "account.created": "Account created", + "account__created": "Account created", + "account.tos_accepted": "Terms accepted", + "account.tos.accepted": "Terms accepted", + "account__tos_accepted": "Terms accepted", + "account.upgrade": "Account upgraded", + "account.upgraded": "Account upgraded", + "account__upgraded": "Account upgraded", + "member.create": "Member created", + "member.created": "Member created", + "member__created": "Member created", + "token.create": "API key created", + "token.created": "API key created", + "token__created": "API key created", + "token.rename": "API key renamed", + "token.renamed": "API key renamed", + "token__renamed": "API key renamed", + "login": "Login", + "login.succeeded": "Login succeeded", + "login__succeeded": "Login succeeded", +} + + +def _normalize_action(action: object) -> str: + return str(action or "").replace("__", ".") + + +def _format_action(action: object) -> str: + raw = str(action or "") + if raw in _ACTION_LABELS: + return _ACTION_LABELS[raw] + normalized = _normalize_action(raw) + if normalized in _ACTION_LABELS: + return _ACTION_LABELS[normalized] + return normalized.replace("_", " ").replace(".", " ").strip().capitalize() or "Unknown" + + +def _is_login(entry: Mapping[str, object]) -> bool: + raw = str(entry.get("action_taken") or "") + return raw in _LOGIN_ACTIONS or _normalize_action(raw) in _LOGIN_ACTIONS + + +def _parse_time(value: object) -> datetime | None: + parsed = timeparse.parse_iso_utc(value) + return None if parsed is None else parsed.replace(tzinfo=None) + + +def _format_time(value: object) -> str: + parsed = _parse_time(value) + if parsed is None: + return str(value or "") + return parsed.strftime("%Y-%m-%d %H:%M:%S") + + +def _actor_label(entry: Mapping[str, object]) -> str: + actor_type = str(entry.get("actor_type") or "system") + actor_id = entry.get("actor_id") + if actor_type == "system": + return "system" if actor_id is None else f"system #{actor_id}" + return actor_type if actor_id is None else f"{actor_type} #{actor_id}" + + +def _resource_label(entry: Mapping[str, object]) -> str: + resource_type = entry.get("resource_type") + if not resource_type: + return "" + resource_id = entry.get("resource_id") + label = str(resource_type).replace("_", " ") + return label if not resource_id else f"{label} #{resource_id}" + + +def _mapping_list(value: object) -> list[dict[str, object]]: + return jsonshape.mapping_list(value) + + +def _audit_rows(payload: Mapping[str, object]) -> list[dict[str, object]]: + return _mapping_list(payload.get("data")) + @app.command( rich_help_panel=help_panels.ACCOUNT, epilog=examples_epilog( [ ("Recent audit-log entries", "aai audit --limit 20"), + ("Include login events", "aai audit --include-logins"), ("Filter by action", "aai audit --action token.create"), ] ), @@ -24,8 +111,11 @@ 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."), + action: str | None = typer.Option(None, "--action", help="Filter by raw action name."), + resource: str | None = typer.Option(None, "--resource", help="Filter by raw resource type."), + include_logins: bool = typer.Option( + False, "--include-logins", help="Show successful login events." + ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: """List recent audit-log entries for your account.""" @@ -33,24 +123,43 @@ def audit( 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[str, object]]) -> 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}" + rows = _audit_rows(payload) + + def render(data: list[dict[str, object]]) -> object: + hide_logins = not include_logins and action is None + shown = [entry for entry in data if not (hide_logins and _is_login(entry))] + hidden_logins = len(data) - len(shown) + if not shown: + message = ( + "No notable audit events in the recent log." + if hidden_logins + else "No audit events found." + ) + if hidden_logins: + return Group( + Text(message, style="aai.muted"), + Text( + f"Hidden: {hidden_logins} login event(s). Use --include-logins to show them.", + style="aai.muted", + ), + ) + return Text(message, style="aai.muted") + + table = Table("when (UTC)", "event", "resource", "actor", header_style="aai.heading") + for entry in shown: table.add_row( - escape(str(entry["log_time"])), - escape(actor), - escape(str(entry["action_taken"])), - escape(str(resource_label)), + escape(_format_time(entry.get("log_time"))), + escape(_format_action(entry.get("action_taken"))), + escape(_resource_label(entry)), + escape(_actor_label(entry)), + ) + if hidden_logins: + return Group( + table, + Text( + f"Hidden: {hidden_logins} login event(s). Use --include-logins to show them.", + style="aai.muted", + ), ) return table diff --git a/aai_cli/commands/claude.py b/aai_cli/commands/claude.py index a9d50481..354e30a8 100644 --- a/aai_cli/commands/claude.py +++ b/aai_cli/commands/claude.py @@ -25,7 +25,7 @@ _STEPS_HEADING = "AssemblyAI coding-agent setup:" -def _run(cmd: list[str], *, timeout: float = 120) -> subprocess.CompletedProcess: +def _run(cmd: list[str], *, timeout: float = 120) -> subprocess.CompletedProcess[str]: # stdin=DEVNULL so a child that would otherwise prompt (npx's "Ok to proceed?", # a `claude` confirmation) gets EOF and fails fast instead of hanging forever on # input the user can't see (its stdout is captured). timeout is a final backstop. @@ -33,6 +33,7 @@ def _run(cmd: list[str], *, timeout: float = 120) -> subprocess.CompletedProcess return subprocess.run( cmd, capture_output=True, + check=False, text=True, stdin=subprocess.DEVNULL, timeout=timeout, diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index d7e53437..4f5bd8a1 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -2,7 +2,8 @@ import shutil import sys -from typing import TypedDict +from collections.abc import Mapping, Sequence +from typing import Protocol, TypedDict import typer from rich.markup import escape @@ -25,6 +26,15 @@ class Check(TypedDict): fix: str | None +class DoctorResult(TypedDict): + ok: bool + checks: list[Check] + + +class _SoundDeviceModule(Protocol): + def query_devices(self) -> Sequence[Mapping[str, object]]: ... + + # Status -> (affordance symbol, render style). "fail" is a blocker; "warn" is # degraded-but-usable. Drives the per-check glyph in `_render`. _SYMBOL = { @@ -112,10 +122,21 @@ def _check_ffmpeg() -> Check: def _probe_input_devices() -> int: """Number of available microphone (input) devices. Raises if audio is unavailable.""" - import sounddevice as sd - + sd = _sounddevice() devices = sd.query_devices() - return sum(1 for d in devices if d.get("max_input_channels", 0) > 0) + return sum(1 for device in devices if _input_channels(device) > 0) + + +def _sounddevice() -> _SoundDeviceModule: + import sounddevice as module + + sd: _SoundDeviceModule = module + return sd + + +def _input_channels(device: Mapping[str, object]) -> int: + channels = device.get("max_input_channels") + return channels if isinstance(channels, int) else 0 def _check_audio() -> Check: @@ -175,8 +196,8 @@ def _check_coding_agent() -> Check: } -def _render(data: dict[str, object]) -> str: - checks: list[Check] = data["checks"] # type: ignore[assignment] +def _render(data: DoctorResult) -> str: + checks = data["checks"] lines = [output.heading("Environment check")] for c in checks: symbol, style = _SYMBOL.get(c["status"], (theme.SYMBOL_HINT, "aai.muted")) @@ -218,7 +239,8 @@ def body(state: AppState, json_mode: bool) -> None: _check_coding_agent(), ] ok = not any(c["status"] == "fail" for c in checks) - output.emit({"ok": ok, "checks": checks}, _render, json_mode=json_mode) + payload: DoctorResult = {"ok": ok, "checks": checks} + output.emit(payload, _render, json_mode=json_mode) if not ok: raise typer.Exit(code=1) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index 97607054..a6dff8cb 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -71,8 +71,12 @@ def _resolve_dir(directory: str | None, template: str, *, here: bool) -> Path: ) def init( ctx: typer.Context, - template: str = typer.Argument(None, help="Template to scaffold (omit to pick interactively)."), - directory: str = typer.Argument(None, help="Target directory (default: