Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ jobs:

- name: Audit runtime dependencies for known CVEs
run: |
# Keep build tooling current first: pip-audit scans the whole environment,
# so a pip/setuptools advisory that a one-line upgrade fixes would otherwise
# fail the gate on something that isn't one of our runtime dependencies.
python -m pip install --upgrade pip setuptools
python -m pip install -e . pip-audit
# Append `--ignore-vuln <ID>` to accept an unfixable transitive advisory.
python -m pip_audit
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ uv run pytest tests/test_transcribe.py::test_name -q # a single test
The default suite **excludes** two slow/credentialed marker sets (see `scripts/check.sh` and `pyproject.toml`):

```sh
uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY + kokoro, else skips
uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY, else skips
uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx
```

Expand Down
9 changes: 7 additions & 2 deletions aai_cli/agent/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@
from collections.abc import Callable
from typing import Any

from aai_cli import environments
from aai_cli.errors import APIError, CLIError, auth_failure, is_auth_failure

WS_URL = "wss://agents.assemblyai.com/v1/ws"

def _ws_url() -> str:
"""Voice Agent socket URL for the active environment (set at CLI startup)."""
return f"wss://{environments.active().agents_host}/v1/ws"


DEFAULT_PROMPT = (
"You are a friendly voice assistant having a casual conversation. Keep replies "
Expand Down Expand Up @@ -166,7 +171,7 @@ def _auth_or_api_error(exc: Exception, message: str) -> CLIError:
def _open_ws(connect: Any, api_key: str) -> Any:
"""Open the Voice Agent socket, mapping a connect failure to a clean CLIError."""
try:
return connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"})
return connect(_ws_url(), additional_headers={"Authorization": f"Bearer {api_key}"})
except Exception as exc:
raise _auth_or_api_error(exc, "Could not connect to the voice agent") from exc

Expand Down
2 changes: 1 addition & 1 deletion aai_cli/auth/ams.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import Any, cast

import httpx
import httpx2 as httpx

from aai_cli.auth import endpoints
from aai_cli.errors import APIError, NotAuthenticated
Expand Down
4 changes: 3 additions & 1 deletion aai_cli/auth/flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from dataclasses import dataclass
from typing import Any

from rich.markup import escape

from aai_cli import output
from aai_cli.auth import ams, discovery, endpoints, loopback
from aai_cli.errors import APIError
Expand Down Expand Up @@ -42,7 +44,7 @@ def _require(mapping: Mapping[str, Any], key: str, what: str) -> Any:

def _open_browser(url: str) -> None:
"""Open the system browser, falling back to printing the URL."""
output.console.print(f"Opening your browser to sign in:\n {url}")
output.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
Expand Down
33 changes: 26 additions & 7 deletions aai_cli/commands/account.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,43 @@
from __future__ import annotations

from datetime import UTC, datetime, timedelta
from datetime import UTC, date, datetime, timedelta
from typing import Any

import typer
from rich.markup import escape
from rich.table import Table

from aai_cli import output
from aai_cli import help_panels, output
from aai_cli.auth import ams
from aai_cli.context import AppState, resolve_session, run_command
from aai_cli.errors import UsageError
from aai_cli.help_text import examples_epilog


def _utc_day_start(day: str) -> str:
"""Render a ``YYYY-MM-DD`` date as a tz-aware UTC ISO-8601 timestamp.

The AMS billing endpoint compares the bounds against tz-aware datetimes and
rejects naive ones ("can't compare offset-naive and offset-aware datetimes"),
so the wire value always carries an explicit ``+00:00`` offset.
"""
try:
parsed = date.fromisoformat(day)
except ValueError as exc:
raise UsageError(f"Invalid date {day!r}; expected YYYY-MM-DD.") from exc
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC).isoformat()


app = typer.Typer(help="Account billing, usage, and limits.")


@app.command(
rich_help_panel=help_panels.ACCOUNT,
epilog=examples_epilog(
[
("Show your remaining balance", "aai balance"),
]
)
),
)
def balance(
ctx: typer.Context,
Expand All @@ -42,12 +59,13 @@ def body(state: AppState, json_mode: bool) -> None:


@app.command(
rich_help_panel=help_panels.ACCOUNT,
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,
Expand All @@ -61,8 +79,8 @@ def usage(
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()
start_date = _utc_day_start(start or (today - timedelta(days=30)).isoformat())
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:
Expand All @@ -81,11 +99,12 @@ def render(d: dict[str, Any]) -> Table:


@app.command(
rich_help_panel=help_panels.ACCOUNT,
epilog=examples_epilog(
[
("Show rate limits per service", "aai limits"),
]
)
),
)
def limits(
ctx: typer.Context,
Expand Down
5 changes: 3 additions & 2 deletions aai_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import typer

from aai_cli import client, code_gen, config, output
from aai_cli import client, code_gen, config, help_panels, output
from aai_cli.agent.audio import SAMPLE_RATE, DuplexAudio, NullPlayer
from aai_cli.agent.render import AgentRenderer
from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session
Expand All @@ -20,14 +20,15 @@


@app.command(
rich_help_panel=help_panels.TRANSCRIPTION,
epilog=examples_epilog(
[
("Start a live voice conversation", "aai agent"),
("Pick a voice and opening line", 'aai agent --voice james --greeting "Hi there"'),
("See available voices", "aai agent --list-voices"),
("Print equivalent Python instead of running", "aai agent --show-code"),
]
)
),
)
def agent(
ctx: typer.Context,
Expand Down
5 changes: 3 additions & 2 deletions aai_cli/commands/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from rich.markup import escape
from rich.table import Table

from aai_cli import output
from aai_cli import help_panels, 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
Expand All @@ -13,12 +13,13 @@


@app.command(
rich_help_panel=help_panels.ACCOUNT,
epilog=examples_epilog(
[
("Recent audit-log entries", "aai audit --limit 20"),
("Filter by action", "aai audit --action token.create"),
]
)
),
)
def audit(
ctx: typer.Context,
Expand Down
29 changes: 18 additions & 11 deletions aai_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import typer
from rich.markup import escape

from aai_cli import client, config, output
from aai_cli import client, config, help_panels, output, theme
from aai_cli.context import AppState, resolve_profile, run_command
from aai_cli.errors import CLIError, NotAuthenticated
from aai_cli.help_text import examples_epilog
Expand All @@ -25,8 +25,13 @@ class Check(TypedDict):
fix: str | None


# Status -> render style. "fail" is a blocker; "warn" is degraded-but-usable.
_STYLE = {"ok": "aai.success", "warn": "aai.warn", "fail": "aai.error"}
# Status -> (affordance symbol, render style). "fail" is a blocker; "warn" is
# degraded-but-usable. Drives the per-check glyph in `_render`.
_SYMBOL = {
"ok": (theme.SYMBOL_SUCCESS, "aai.success"),
"warn": (theme.SYMBOL_WARN, "aai.warn"),
"fail": (theme.SYMBOL_ERROR, "aai.error"),
}


def _check_python() -> Check:
Expand Down Expand Up @@ -172,28 +177,30 @@ def _check_coding_agent() -> Check:

def _render(data: dict[str, object]) -> str:
checks: list[Check] = data["checks"] # type: ignore[assignment]
lines = ["[aai.heading]AssemblyAI environment check:[/aai.heading]"]
lines = [output.heading("Environment check")]
for c in checks:
style = _STYLE.get(c["status"], "aai.muted")
symbol, style = _SYMBOL.get(c["status"], (theme.SYMBOL_HINT, "aai.muted"))
lines.append(
f" {escape(c['name'])}: "
f"[{style}]{escape(c['status'])}[/{style}] — {escape(c['detail'])}"
f" [{style}]{escape(symbol)}[/{style}] {escape(c['name'])} — {escape(c['detail'])}"
)
if c["fix"]:
lines.append(f" [aai.muted]fix:[/aai.muted] {escape(c['fix'])}")
lines.append(" " + output.hint(f"fix: {escape(c['fix'])}"))
if data["ok"]:
lines.append(" [aai.success]Ready.[/aai.success]")
lines.append(" " + output.success("Everything looks good."))
else:
lines.append(" [aai.error]Problems found — see fixes above.[/aai.error]")
failed = sum(1 for c in checks if c["status"] == "fail")
noun = "problem" if failed == 1 else "problems"
lines.append(" " + output.fail(f"{failed} {noun} found — see fixes above."))
return "\n".join(lines)


@app.command(
rich_help_panel=help_panels.SETUP,
epilog=examples_epilog(
[
("Check your environment is ready", "aai doctor"),
]
)
),
)
def doctor(
ctx: typer.Context,
Expand Down
18 changes: 12 additions & 6 deletions aai_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import typer
from rich.markup import escape

from aai_cli import __version__, environments, output, steps
from aai_cli import __version__, environments, help_panels, output, steps
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError
from aai_cli.help_text import examples_epilog
Expand Down Expand Up @@ -60,13 +60,14 @@ def _resolve_dir(directory: str | None, template: str, *, here: bool) -> Path:


@app.command(
rich_help_panel=help_panels.QUICK_START,
epilog=examples_epilog(
[
("Scaffold a new app interactively", "aai init"),
("Scaffold a transcribe app into ./my-app", "aai init transcribe my-app"),
("Scaffold into the current directory", "aai init transcribe --here"),
]
)
),
)
def init(
ctx: typer.Context,
Expand Down Expand Up @@ -177,9 +178,7 @@ def body(state: AppState, json_mode: bool) -> None:
}
)

output.emit(
report, lambda d: steps.render_steps(d, heading="aai init:"), json_mode=json_mode
)
output.emit(report, lambda d: steps.render_steps(d, heading="Setup"), json_mode=json_mode)
if any(s["status"] == "failed" for s in report):
raise typer.Exit(code=1)

Expand All @@ -188,12 +187,19 @@ def body(state: AppState, json_mode: bool) -> None:
url = f"http://localhost:{chosen_port}"
if not json_mode:
output.console.print(
f"[aai.heading]Starting[/aai.heading] {escape(url)} (Ctrl-C to stop)"
f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]"
" [aai.muted](Ctrl-C to stop)[/aai.muted]"
)
code = runner.launch_and_open(
target, port=chosen_port, use_uv=use_uv, open_browser=not no_open
)
if code:
raise typer.Exit(code=code)
elif not json_mode:
# Scaffolded but not launched (no key, or --no-install): leave the user with
# the one command that starts their app, the way `vercel`/`supabase` sign off.
output.console.print(
output.hint(f"Run `cd {escape(str(target))} && uv run uvicorn api.index:app`.")
)

run_command(ctx, body, json=json_out)
5 changes: 3 additions & 2 deletions aai_cli/commands/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ def body(state: AppState, json_mode: bool) -> None:
output.emit(
created,
lambda d: (
f"Created key '[aai.success]{escape(name)}[/aai.success]': "
f"{escape(str(d['api_key']))}"
output.success(f"Created API key '{escape(name)}'.")
+ f"\n {escape(str(d['api_key']))}\n"
+ output.warn("Shown once — copy it now.")
),
json_mode=json_mode,
)
Expand Down
5 changes: 3 additions & 2 deletions aai_cli/commands/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer
from rich.markup import escape

from aai_cli import config, output, stdio
from aai_cli import config, help_panels, output, stdio
from aai_cli import llm as gateway
from aai_cli.context import AppState, run_command
from aai_cli.errors import UsageError
Expand All @@ -14,13 +14,14 @@


@app.command(
rich_help_panel=help_panels.TRANSCRIPTION,
epilog=examples_epilog(
[
("Summarize a past transcript", 'aai llm "summarize" --transcript-id 5551234-abcd'),
("Pipe any text in", 'echo "meeting notes" | aai llm "turn into action items"'),
("See available models", "aai llm --list-models"),
]
)
),
)
def llm(
ctx: typer.Context,
Expand Down
Loading
Loading