Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
a67be83
Add guided onboarding flow design spec
alexkroman-assembly Jun 8, 2026
3d1dc05
Add onboarding flow implementation plan
alexkroman-assembly Jun 8, 2026
92a7e0e
feat(onboard): per-profile CLI request counter in config
alexkroman-assembly Jun 8, 2026
8049621
test(onboard): rely on autouse tmp_config fixture from conftest
alexkroman-assembly Jun 8, 2026
54901b8
feat(onboard): progress rendering and milestone copy
alexkroman-assembly Jun 8, 2026
dc19809
fix(onboard): cover milestone branch, sort imports, use GOAL constant
alexkroman-assembly Jun 8, 2026
6caf248
feat(onboard): count CLI API requests toward the 100 goal
alexkroman-assembly Jun 8, 2026
ea0f2d6
feat(onboard): interactive/non-interactive prompter abstraction
alexkroman-assembly Jun 8, 2026
69ce62a
feat(onboard): welcome, auth, and first-request sections
alexkroman-assembly Jun 8, 2026
a092ff9
fix(onboard): send rejected-key message to stderr
alexkroman-assembly Jun 8, 2026
396e777
refactor(init): extract run_init for reuse by the onboarding wizard
alexkroman-assembly Jun 8, 2026
51cd3ee
feat(onboard): environment, build-path, Claude Code, next-steps sections
alexkroman-assembly Jun 8, 2026
31c1737
fix(init): pin template requirements + gate against unpinned deps
alexkroman-assembly Jun 8, 2026
3877436
fix(onboard): harden build_path/claude_code failures and cover all se…
alexkroman-assembly Jun 8, 2026
75d987a
feat(onboard): wizard orchestrator with auth hard-stop and clean cancel
alexkroman-assembly Jun 9, 2026
e39885e
feat(onboard): aai onboard command with --status
alexkroman-assembly Jun 9, 2026
ed9f5e5
feat(onboard): list onboard first and offer it on a bare first run
alexkroman-assembly Jun 9, 2026
415440a
test(onboard): expect onboard first in smoke command-order check
alexkroman-assembly Jun 9, 2026
74c2a79
docs(onboard): route post-install and post-login hints to aai onboard
alexkroman-assembly Jun 9, 2026
5a4412f
test(onboard): cover InteractivePrompter for full patch coverage
alexkroman-assembly Jun 9, 2026
252fe4d
test(onboard): regenerate CLI help snapshot for aai onboard
alexkroman-assembly Jun 9, 2026
2e80734
test(onboard): satisfy mypy reexport and mutation-gate on new code
alexkroman-assembly Jun 9, 2026
280b19c
feat(onboard): remove the inaccurate request counter and prompt for a…
alexkroman-assembly Jun 9, 2026
a9dad4b
Show usage in dollars from line-item prices; handle empty rate limits
alexkroman-assembly Jun 9, 2026
0320651
test(onboard): regenerate snapshots after removing the request counter
alexkroman-assembly Jun 9, 2026
7238d00
feat(onboard): add welcome banner and retint help to brand blue
alexkroman-assembly Jun 9, 2026
abe5b03
feat(cli): remove samples + version commands, polish onboarding
alexkroman-assembly Jun 9, 2026
c5b5e36
feat(help): action-verb help panels (Build an app vs Run AssemblyAI)
alexkroman-assembly Jun 9, 2026
9c6c257
feat(help): order Setup & Tools below Run AssemblyAI
alexkroman-assembly Jun 9, 2026
8d76216
fix(help): Title-case the "Build an App" panel
alexkroman-assembly Jun 9, 2026
e6958a3
feat(ux): act on multi-persona UX/DX review of the CLI
alexkroman-assembly Jun 9, 2026
75f0bd3
feat(help): expand and sharpen --help examples across commands
alexkroman-assembly Jun 9, 2026
ba6f3df
Update aai_cli/commands/transcribe.py
alexkroman Jun 9, 2026
9d18331
Merge remote-tracking branch 'origin/onboarding-flow' into onboarding…
alexkroman-assembly Jun 9, 2026
000d50f
fix(init): raise template dependency floors to clear Aikido SCA CVEs
alexkroman-assembly Jun 9, 2026
8441627
refactor(transcribe): extract execution into transcribe_exec; harden …
alexkroman-assembly Jun 9, 2026
4c22eb5
Merge remote-tracking branch 'origin/main' into onboarding-flow
alexkroman-assembly Jun 9, 2026
51d9969
refactor(onboard): call public doctor/setup APIs instead of private i…
alexkroman-assembly Jun 9, 2026
b9bfe79
fix(ci): assert `aai --version`, not the removed `version` subcommand
alexkroman-assembly Jun 9, 2026
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
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ jobs:
python-version: "3.12"
cache: pip

# `aai version` imports the package, which pulls in sounddevice (needs
# `aai --version` imports the package, which pulls in sounddevice (needs
# PortAudio) and ffmpeg-backed sources. Match the other jobs' system deps.
- name: System deps (Linux)
if: runner.os == 'Linux'
Expand Down Expand Up @@ -272,12 +272,12 @@ jobs:
# throwaway local tap holding the pinned formula (--no-git avoids needing a
# git identity on the runner). --build-from-source compiles the full resource
# list (rust + cryptography native builds); `brew test` then runs the
# formula's own `test do` block, asserting `aai version`.
# formula's own `test do` block, asserting `aai --version`.
- name: brew install + test
run: |
set -euo pipefail
brew tap-new --no-git aai/local
cp Formula/aai.rb "$(brew --repository aai/local)/Formula/aai.rb"
brew install --build-from-source --formula aai/local/aai
aai version
aai --version
brew test aai/local/aai
1 change: 0 additions & 1 deletion .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ modules =
aai_cli.commands.keys
aai_cli.commands.llm
aai_cli.commands.login
aai_cli.commands.samples
aai_cli.commands.sessions
aai_cli.commands.setup
aai_cli.commands.stream
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app,

### Command layer

Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `transcripts`, `agent`, `llm`, `login`, `doctor`, `samples`, `init`, `claude`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.
Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `transcripts`, `agent`, `llm`, `login`, `doctor`, `init`, `claude`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures.

### Cross-cutting state (resolution order matters)

Expand All @@ -70,7 +70,7 @@ Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `tr
- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`).
- **`auth/`** — browser-assisted `aai login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps.
- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`.
- **`commands/claude.py`** — `aai claude install/status/remove` shells out to `claude mcp add` (the `assemblyai-docs` MCP) and `npx skills add` (the AssemblyAI skill). Missing `claude`/`npx` is reported and skipped, not an error.
- **`commands/setup.py`** — `aai setup install/status/remove` wires a coding agent up to AssemblyAI by installing three artifacts: the `assemblyai-docs` docs MCP (via `claude mcp add`), the AssemblyAI skill (via `npx skills add`), and the bundled `aai-cli` skill (copied out of the wheel, no network). Missing `claude`/`npx` is reported and skipped, not an error.

## Conventions

Expand Down
2 changes: 1 addition & 1 deletion Formula/aai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,6 @@ def install
end

test do
assert_match version.to_s, shell_output("#{bin}/aai version")
assert_match version.to_s, shell_output("#{bin}/aai --version")
end
end
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `--
| `aai agent` | *Run* a live two-way voice conversation (to **build** a voice agent app, use `aai init voice-agent`). |
| `aai llm <prompt>` | Prompt the LLM Gateway (`--transcript-id`, or `--follow` for a live stream). |
| `aai setup install` | Set up your coding agent for AssemblyAI (docs MCP + skills). |
| `aai samples create <name>` | Scaffold a runnable starter script. |
| `aai keys` / `balance` / `usage` / `limits` / `sessions` / `audit` | Account self-service (browser login). |

Every command prints human-readable text by default — terminal, pipe, CI, or agent alike. Add `--json` for machine-readable output; it never switches on just because stdout is piped, so `aai transcribe call.mp3 | grep hello` still gets the transcript, not a JSON blob. Errors go to **stderr**, so stdout stays clean for pipelines.
Expand Down
103 changes: 63 additions & 40 deletions aai_cli/commands/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

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

from aai_cli import help_panels, jsonshape, output, timeparse
Expand Down Expand Up @@ -40,6 +39,24 @@ def _usage_items(data: Mapping[str, object]) -> list[dict[str, object]]:
return jsonshape.mapping_list(data.get("usage_items"))


def _format_dollars(cents: float) -> str:
return f"${cents / 100:,.2f}"


def _window_total_cents(item: Mapping[str, object]) -> float:
"""Sum a window's spend (cents) from its ``line_items``.

The AMS usage endpoint returns ``total: 0.0`` on every window; the real
spend lives in each window's ``line_items[].price`` (cents, like
``balance_in_cents``), so the window total is derived from them rather than
the dead top-level ``total``.
"""
return sum(
jsonshape.as_float(line_item.get("price"))
for line_item in jsonshape.mapping_list(item.get("line_items"))
)


def _window_label(item: Mapping[str, object]) -> str:
start = timeparse.parse_iso_utc(item.get("start_timestamp"))
end = timeparse.parse_iso_utc(item.get("end_timestamp"))
Expand All @@ -50,39 +67,35 @@ def _window_label(item: Mapping[str, object]) -> str:
return f"{start.date().isoformat()} to {end.date().isoformat()}"


def _line_item_label(line_item: Mapping[str, object]) -> str:
label = next(
def _line_item_name(line_item: Mapping[str, object]) -> str:
"""The product/feature label for a usage line item, or ``""`` if it carries none."""
return 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 jsonshape.mapping_list(item.get("line_items"))
if (label := _line_item_label(line_item))
]
return ", ".join(labels)
"""Per-product spend for a window, in dollars, aggregated by product and ordered
biggest-first.

Both this and the window total derive from ``line_items[].price`` (cents), so the
breakdown is shown in the same unit as the ``total`` column and the products sum to
that total — they reconcile, instead of mixing dollars with raw quantities. Products
are aggregated by name (the AMS endpoint can return several rows for one product),
a row with no recognizable product is grouped under ``other``, and zero-dollar
products are dropped as noise (they don't affect the reconciliation).
"""
totals: dict[str, float] = {}
for line_item in jsonshape.mapping_list(item.get("line_items")):
name = _line_item_name(line_item) or "other"
totals[name] = totals.get(name, 0.0) + jsonshape.as_float(line_item.get("price"))
ordered = sorted(((n, c) for n, c in totals.items() if c), key=lambda nc: (-nc[1], nc[0]))
return ", ".join(f"{name}: {_format_dollars(cents)}" for name, cents in ordered)


app = typer.Typer(help="Account billing, usage, and limits.")
Expand All @@ -93,6 +106,7 @@ def _line_items_summary(item: Mapping[str, object]) -> str:
epilog=examples_epilog(
[
("Show your remaining balance", "aai balance"),
("Get the raw cents for scripting", "aai balance --json | jq '.balance_in_cents'"),
]
),
)
Expand Down Expand Up @@ -121,6 +135,11 @@ def body(state: AppState, json_mode: bool) -> None:
[
("Usage over the last 30 days", "aai usage"),
("A specific date range", "aai usage --start 2026-05-01 --end 2026-06-01"),
("Break spend down by month", "aai usage --window month"),
(
"Total spend in cents for scripting",
"aai usage --json | jq '[.usage_items[].line_items[].price] | add'",
),
]
),
)
Expand All @@ -144,41 +163,39 @@ def body(state: AppState, json_mode: bool) -> None:
data = ams.get_usage(jwt, start_date, end_date, window)

def render(d: dict[str, object]) -> object:
items = _usage_items(d)
shown = (
items
if include_zero
else [item for item in items if jsonshape.as_float(item.get("total"))]
)
total = sum(jsonshape.as_float(item.get("total")) for item in items)
windows = [(item, _window_total_cents(item)) for item in _usage_items(d)]
shown = windows if include_zero else [w for w in windows if w[1]]
total = sum(cents for _, cents in windows)
range_label = (
f"{timeparse.format_utc_day(start_date)} to "
f"{timeparse.format_utc_day(end_date)} (UTC)"
)
summary = Text(
f"Usage total: {_format_usage_number(total)} for {range_label}",
f"Usage total: {_format_dollars(total)} for {range_label}",
style="aai.heading",
)
if not shown:
message = (
"No usage in this range."
if items
if windows
else "No usage windows returned for this range."
)
return output.stack(summary, output.muted(message))

shown_with_breakdown = [(item, _line_items_summary(item)) for item in shown]
show_breakdown = any(summary for _, summary in shown_with_breakdown)
shown_with_breakdown = [
(item, cents, _line_items_summary(item)) for item, cents in shown
]
show_breakdown = any(breakdown for _, _, breakdown in shown_with_breakdown)
table = (
output.data_table("period", "total", "breakdown")
if show_breakdown
else output.data_table("period", "total")
)
hidden_count = len(items) - len(shown)
for item, breakdown in shown_with_breakdown:
hidden_count = len(windows) - len(shown)
for item, cents, breakdown in shown_with_breakdown:
row = [
escape(_window_label(item)),
_format_usage_number(item.get("total")),
_format_dollars(cents),
]
if show_breakdown:
row.append(escape(breakdown))
Expand All @@ -202,6 +219,7 @@ def render(d: dict[str, object]) -> object:
epilog=examples_epilog(
[
("Show rate limits per service", "aai limits"),
("As JSON for scripting", "aai limits --json"),
]
),
)
Expand All @@ -215,9 +233,14 @@ 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, object]) -> Table:
def render(d: dict[str, object]) -> object:
limits = jsonshape.mapping_list(d.get("rate_limits"))
if not limits:
return output.muted(
"No custom rate limits — this account uses AssemblyAI's standard limits."
)
table = output.data_table("service", "limit")
for limit in jsonshape.mapping_list(d.get("rate_limits")):
for limit in limits:
table.add_row(
escape(str(limit.get("service", ""))),
_format_usage_number(limit.get("magnitude")),
Expand Down
6 changes: 4 additions & 2 deletions aai_cli/commands/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def _open_audio(
[
("Start a live voice conversation", "aai agent"),
("Pick a voice and opening line", 'aai agent --voice james --greeting "Hi there"'),
("Give the agent a persona", 'aai agent --system-prompt "You are a terse pirate."'),
("See available voices", "aai agent --list-voices"),
("Print equivalent Python instead of running", "aai agent --show-code"),
]
Expand Down Expand Up @@ -117,8 +118,9 @@ def agent(
) -> None:
"""Have a live two-way voice conversation with an AssemblyAI voice agent.

Pass an audio file/URL (or --sample) to speak a recorded clip to the agent
instead of the microphone; the session then ends after the agent's reply.
Use headphones: the mic stays open while the agent speaks, so on speakers it would
hear itself and loop. Pass an audio file/URL (or --sample) to speak a recorded clip to
the agent instead of the microphone; the session then ends after the agent's reply.

This only runs a conversation in the terminal — it writes no code. To build
a voice agent app, run 'aai init voice-agent' instead.
Expand Down
4 changes: 3 additions & 1 deletion aai_cli/commands/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ def _audit_rows(payload: Mapping[str, object]) -> list[dict[str, object]]:
rich_help_panel=help_panels.ACCOUNT,
epilog=examples_epilog(
[
("Recent audit-log entries", "aai audit --limit 20"),
("Recent audit-log entries", "aai audit"),
("Show more entries", "aai audit --limit 100"),
("Include login events", "aai audit --include-logins"),
("Filter by action", "aai audit --action token.create"),
("Filter by resource, as JSON", "aai audit --resource token --json"),
]
),
)
Expand Down
19 changes: 10 additions & 9 deletions aai_cli/commands/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ 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`.
# 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:
def check_python() -> Check:
v = sys.version_info
version = f"{v.major}.{v.minor}.{v.micro}"
if v >= (3, 12):
Expand Down Expand Up @@ -99,7 +99,7 @@ def _check_api_key(profile: str) -> Check:
}


def _check_ffmpeg() -> Check:
def check_ffmpeg() -> Check:
# ffmpeg is ONLY used to stream non-WAV files or URLs (stream/agent), where it
# decodes them to 16 kHz mono PCM on the fly. Plain `transcribe` (including
# YouTube URLs) uploads the file to AssemblyAI and never invokes ffmpeg, so it is
Expand Down Expand Up @@ -139,7 +139,7 @@ def _input_channels(device: Mapping[str, object]) -> int:
return channels if isinstance(channels, int) else 0


def _check_audio() -> Check:
def check_audio() -> Check:
affects = ["stream (microphone)", "agent"]
try:
inputs = _probe_input_devices()
Expand Down Expand Up @@ -199,7 +199,7 @@ def _check_coding_agent() -> Check:
}


def _render(data: DoctorResult) -> str:
def render(data: DoctorResult) -> str:
checks = data["checks"]
lines = [output.heading("Environment check")]
for c in checks:
Expand All @@ -223,6 +223,7 @@ def _render(data: DoctorResult) -> str:
epilog=examples_epilog(
[
("Check your environment is ready", "aai doctor"),
("Output results as JSON", "aai doctor --json"),
]
),
)
Expand All @@ -235,15 +236,15 @@ def doctor(
def body(state: AppState, json_mode: bool) -> None:
profile = resolve_profile(state)
checks = [
_check_python(),
check_python(),
_check_api_key(profile),
_check_ffmpeg(),
_check_audio(),
check_ffmpeg(),
check_audio(),
_check_coding_agent(),
]
ok = not any(c["status"] == "fail" for c in checks)
payload: DoctorResult = {"ok": ok, "checks": checks}
output.emit(payload, _render, json_mode=json_mode)
output.emit(payload, render, json_mode=json_mode)
if not ok:
raise typer.Exit(code=1)

Expand Down
Loading
Loading