From a67be83ad90fa277b5487844965e086ee148da52 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 15:47:33 -0700 Subject: [PATCH 01/37] Add guided onboarding flow design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Designs `aai onboard`: a guided first-run wizard (auth → first API request → env check → build-path picker → Claude Code wiring → progress) modeled on OpenClaw's onboarding blueprint, plus a local request counter tracking progress toward 100 requests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-08-onboarding-flow-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-onboarding-flow-design.md diff --git a/docs/superpowers/specs/2026-06-08-onboarding-flow-design.md b/docs/superpowers/specs/2026-06-08-onboarding-flow-design.md new file mode 100644 index 00000000..77e26bd3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-onboarding-flow-design.md @@ -0,0 +1,220 @@ +# Design: `aai onboard` — Guided Onboarding Flow + +**Date:** 2026-06-08 +**Status:** Approved design, pending implementation plan +**Goal:** Make the `aai` CLI the easiest way to onboard to AssemblyAI and reach +100 API requests. Attack all four funnel stages: install→first request, +first request→habit, account/billing friction, and discovery of value. + +## Background + +Today the CLI's onboarding is a set of discrete, well-built commands (`login`, +`init`, `doctor`, `samples`, `setup`) with **no unifying first-run flow**. A +newcomer must already know the sequence. There is also **no visibility into +progress toward a usage milestone**, so a user who stops at one request gets no +nudge to return. + +The design borrows OpenClaw's onboarding blueprint +(`github.com/openclaw/openclaw`), specifically: + +- A single `onboard` wizard distinct from a minimal `setup` + (`src/commands/onboard-interactive.ts` → `runSetupWizard`). +- A **prompter abstraction** so one flow runs both interactively and + non-interactively (`createClackPrompter` vs + `createNonInteractiveLoggingPrompter`). +- **Ordered, resumable sections** that self-skip when already satisfied + (`CONFIGURE_WIZARD_SECTIONS`). +- **Grouped auth choice** shared by onboarding and later setup + (`promptAuthChoiceGrouped`). +- **Terminal restore on every exit path** (`finally` block; clean cancel). +- **Ending on a concrete next action** (OpenClaw's onboarding chat). + +The one thing OpenClaw lacks that our goal demands — **progress toward a usage +milestone (100 requests)** — is our addition. + +## Scope + +All seven sections ship in this spec (chosen over a smaller MVP). Progress is +measured with a **local CLI request counter** (chosen over querying account +usage), because it is always available, adds zero API calls, and ships fast. A +one-line pointer to `aai usage` gives the authoritative account picture without +us owning that mapping. + +## Architecture + +New package `aai_cli/onboard/` plus one command module: + +``` +aai_cli/onboard/ + __init__.py + wizard.py # run_onboarding(prompter, state, ctx) — orchestrates sections in order + sections.py # each step: (prompter, ctx) -> SectionResult (DONE | SKIPPED | FAILED) + prompter.py # Prompter protocol + InteractivePrompter / NonInteractivePrompter + progress.py # local request counter + "N of 100" rendering + milestone copy +aai_cli/commands/onboard.py # Typer sub-app: builds prompter, runs wizard, restores terminal in finally +``` + +Conventions follow the repo: `from __future__ import annotations`, modern +typing, errors→stderr / data→stdout, strict mypy on `aai_cli`, command bodies +wrapped via `context.run_command`. + +### Prompter abstraction + +`Prompter` is a `typing.Protocol` with the minimal surface the sections need: + +- `select(title, options) -> str` +- `confirm(title, *, default) -> bool` +- `text(title, *, default) -> str` +- `note(message)` — informational, no input +- `section(title)` — visual step header + +`InteractivePrompter` wraps the existing Rich/Typer prompt helpers in +`output.py`. `NonInteractivePrompter` **never blocks for input**: `confirm` +returns its default, `select`/`text` return the provided default or raise a +clean `UsageError` when no default exists, and every call logs what it chose to +stderr. This mirrors `createNonInteractiveLoggingPrompter` and preserves the +CLI's pipeline-safety: `--json`, piped stdin, or agent-run sessions never hang. + +Prompter selection reuses the existing "is this interactive?" signal already +used to auto-enable `--json` (piped/agent detection in `context.py`/`output.py`). + +### Resumable sections + +Each section is a function returning a `SectionResult`. Before doing work it +checks whether the step is already satisfied and returns `SKIPPED` if so: + +- Auth → key already resolves (`config.resolve_api_key` succeeds). +- Environment → checks already pass. +- Build-path scaffold → target dir already exists (offer reuse/skip). +- Claude Code → `aai setup status` reports installed. + +Re-running `aai onboard` therefore resumes rather than restarts. A `FAILED` +section records the reason, prints the standard `Error:`/`Suggestion:` pair, and +the wizard continues to subsequent independent sections where sensible (auth +failure is the one hard stop, since later steps need a key). + +### Terminal restore + +`run_onboarding` wraps the section loop in `try/finally`. `KeyboardInterrupt` +and a `WizardCancelled` sentinel both exit cleanly (no traceback, terminal +state restored). This matches the repo's existing discipline of never dumping +tracebacks for expected control flow. + +## The flow (ordered sections) + +1. **Welcome** — one-line value statement and a list of what the wizard will + do. If a local progress counter already shows prior requests, greet as a + returning user and show "N of 100" instead of the cold intro. + +2. **Auth** *(install → first request gate)* — reuse + `auth.flow.persist_browser_login()`. Offer an API-key fallback (grouped + choice: *Browser sign-in* / *Paste an API key*). If org discovery returns + **no account or no project**, print the signup/dashboard URL + (`environments.signup_url()`), wait for the user, then retry. Auth is the one + hard-stop section. + +3. **First request — the activation moment** — the wizard itself runs the + equivalent of `transcribe --sample` (hosted `wildfires.mp3`, + `client.SAMPLE_AUDIO_URL`), streams the transcript, and celebrates success. + This guarantees nobody completes onboarding un-activated. Increments the + progress counter (→ "1 of 100"). On API failure, surface the normal error + and offer retry. + +4. **Environment check (non-blocking)** — run `doctor`'s existing checks + (python / ffmpeg / mic) and render ✓/!/✗. Never a hard stop: warnings only + matter for `stream`/`agent`. Reuse the doctor check functions rather than + duplicating them. + +5. **"What do you want to build?"** *(discovery of value + repeat requests)* — + `select`: *Transcribe files* → `init audio-transcription`; *Live captions* → + `init live-captions`; *Voice agent* → `init voice-agent`; *Just the CLI* → + skip. The chosen template is scaffolded in place via the existing `init` + path (deps install + `.env` write + browser launch already handled there). + Offer `samples create ` as a lighter-weight alternative for users who + want a single script. + +6. **Optional: wire up Claude Code** — call `aai setup install` (docs MCP + + skills). Skipped silently if `claude`/`npx` are absent, consistent with the + existing `setup` behavior (missing tools are reported and skipped, not + errors). + +7. **Next steps + progress** — render "✅ N of 100 API requests" and a short + menu of copy-pasteable commands tailored to the path chosen in step 5 + (e.g. `aai transcribe `, `aai stream`, `aai llm`). End on action. + +## First-run autodetect + +- New command **`aai onboard`**, registered in the **Quick Start** help group + in `main.py` `_COMMAND_ORDER`, listed above `init`. +- `aai onboard --status` prints only the progress panel (section 7's counter + + `aai usage` pointer) and exits — a cheap "where am I?" check. +- **Bare `aai` with no credentials configured** prints a short banner and + *offers* the wizard (`Run guided setup now? [Y/n]`). It never force-hijacks + `--help`, and with credentials present, bare `aai` behaves exactly as today. + Non-interactive sessions get a one-line hint, not a prompt. +- `install.sh`'s final line changes from + `"Installed. Next: run 'aai login', then 'aai transcribe --sample'."` to + `"Installed. Next: run 'aai onboard'."` — one command to remember. +- `aai login` success hint and the `app.callback` epilog examples are updated to + point at `aai onboard` as the canonical starting point. + +## Progress toward 100 (local counter) + +- Persist a small record in `config.toml` (via the existing `config.py` + platformdirs-backed store): `requests_made: int` and `first_request_at` / + `last_request_at` timestamps. Per-profile, alongside existing profile state. +- Increment once per successful request from the run commands: `transcribe`, + `stream` (per session), `agent` (per session), `llm`. A single shared helper + `progress.record_request()` is called from each command's success path so the + logic lives in one place. +- Render "N of 100 requests" with milestone encouragement at 1, 10, 50, 100 + (e.g. first request → "You're activated 🎉"; 100 → "You've hit 100 — you're + off the ground"). Rendering lives in `progress.py`; the wizard's final screen + and `aai onboard --status` both call it. +- The counter is explicitly **CLI-originated requests only**. The status panel + carries a one-line pointer: "For your full account usage, run `aai usage`." + We do not attempt to reconcile the local count with server-side billing. + +## Error handling + +- All failures use the existing `CLIError` hierarchy (`errors.py`) and + `output.emit_error` (stderr, `Error:`/`Suggestion:`), preserving exit codes. +- Auth failure inside the wizard reuses `errors.auth_failure()` / the + `NotAuthenticated` path and stops the wizard with a retry suggestion. +- Cancellation (Ctrl-C / `WizardCancelled`) → clean exit, terminal restored, no + traceback. +- The non-interactive prompter converts "would need to prompt" into a clean + `UsageError` telling the user which flag/value to supply, never a hang. + +## Testing + +- **Prompter**: unit tests for `NonInteractivePrompter` (returns defaults, + raises cleanly when no default, logs choices) and `InteractivePrompter` + (drives the existing prompt helpers via injected I/O). +- **Sections**: each section tested for the DONE / SKIPPED / FAILED branches + with a fake prompter and mocked client/auth (no real API). Auth, first-request + (mock `client.transcribe`), env-check, build-path (mock `init`), claude-wiring + (mock `setup`). +- **Wizard orchestration**: ordering, resume-skips-completed, auth hard-stop, + cancellation restores terminal. +- **Progress**: counter increments once per success, persists/round-trips + through `config.toml`, milestone copy at 1/10/50/100, `--status` rendering. +- **First-run autodetect**: bare `aai` with/without creds; interactive vs + non-interactive banner-vs-hint. +- **Snapshot tests** (`syrupy`, `tests/__snapshots__/*.ambr`): updated + `aai --help` ordering, new `aai onboard --help`, the wizard's rendered panels, + and the progress/status panel. Regenerate with `--snapshot-update`; never + hand-edit `.ambr`. +- Must clear the existing gate: 90% branch coverage, 100% patch coverage vs + `origin/main`, no new escape hatches, strict mypy/pyright, xenon grade. + +## Out of scope + +- Server-side usage reconciliation / true free-tier request accounting. +- A conversational/agent-driven onboarding mode (rejected approach C). +- Changes to the auth backend (Stytch B2B discovery flow is reused as-is). +- New `init` templates. + +## Open questions + +None blocking. Milestone copy wording can be refined during implementation. From 3d1dc052d62dd3ccdfbfb1cbb42419ec06b8cddc Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 15:53:26 -0700 Subject: [PATCH 02/37] Add onboarding flow implementation plan 12 TDD tasks: request counter, progress rendering, counter wiring, prompter abstraction, run_init extraction, seven wizard sections, orchestrator, aai onboard command, help reordering + bare-aai offer, hint updates, and snapshot/gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-08-onboarding-flow.md | 1370 +++++++++++++++++ 1 file changed, 1370 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-onboarding-flow.md diff --git a/docs/superpowers/plans/2026-06-08-onboarding-flow.md b/docs/superpowers/plans/2026-06-08-onboarding-flow.md new file mode 100644 index 00000000..a0ecf29c --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-onboarding-flow.md @@ -0,0 +1,1370 @@ +# Guided Onboarding Flow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `aai onboard` — a guided first-run wizard that takes a newcomer from zero to a successful API request and tracks progress toward 100 requests. + +**Architecture:** A new `aai_cli/onboard/` package holds a prompter abstraction (interactive vs non-interactive), ordered resumable section functions, a wizard orchestrator, and progress rendering. A per-profile request counter lives in `config.toml` and is incremented by the run commands. A new `aai_cli/commands/onboard.py` Typer sub-app wires it up; `main.py` registers it, reorders help, and offers the wizard on a credential-less bare `aai`. + +**Tech Stack:** Python 3.12+, Typer, Rich, questionary, pydantic, pytest + syrupy. Run every tool through `uv run`. + +--- + +## Conventions for every task + +- Start each file with `from __future__ import annotations`. +- Errors → stderr via `output.error_console` / `CLIError`; data → stdout. +- Strict mypy on `aai_cli` (annotate everything). Tests may skip return annotations but must annotate fixture params (see [[mypy-warn-return-any-untyped-fixtures]] in repo memory — annotate locals fed by untyped fixtures). +- Run a single test: `uv run pytest tests/test_x.py::test_name -q`. +- After a feature lands, the full gate is `./scripts/check.sh` (must print `All checks passed.`). +- Never hand-edit `tests/__snapshots__/*.ambr`; regenerate with `uv run pytest --snapshot-update`. +- Commit after each task. + +--- + +## File Structure + +**Create:** +- `aai_cli/onboard/__init__.py` — package exports. +- `aai_cli/onboard/prompter.py` — `Prompter` protocol, `InteractivePrompter`, `NonInteractivePrompter`, `WizardCancelled`. +- `aai_cli/onboard/progress.py` — `GOAL`, `milestone_message`, `render_progress`. +- `aai_cli/onboard/sections.py` — `WizardContext`, `SectionResult`, the seven section functions. +- `aai_cli/onboard/wizard.py` — `run_onboarding`. +- `aai_cli/commands/onboard.py` — Typer sub-app (`onboard`, `onboard --status`). +- `tests/test_onboard_progress.py`, `tests/test_onboard_prompter.py`, `tests/test_onboard_sections.py`, `tests/test_onboard_wizard.py`, `tests/test_onboard_command.py`, `tests/test_onboard_counter.py`. + +**Modify:** +- `aai_cli/config.py` — `Profile.requests_made` field + `get_requests_made` + `record_request`. +- `aai_cli/commands/transcribe.py`, `llm.py`, `stream.py`, `agent.py` — increment the counter on success. +- `aai_cli/commands/init.py` — extract `run_init(...)` so the wizard can scaffold without a launch. +- `aai_cli/commands/login.py` — point the post-login hint at `aai onboard`. +- `aai_cli/main.py` — register `onboard`, reorder `_COMMAND_ORDER`, bare-`aai` offer, update root epilog. +- `aai_cli/help_panels.py` — `QUICK_START` comment mentions `onboard`. +- `install.sh` — final hint becomes `aai onboard`. + +--- + +## Task 1: Per-profile request counter in config + +**Files:** +- Modify: `aai_cli/config.py` (add field at `Profile`, lines 25-35; add functions after `get_account_id`, ~line 230) +- Test: `tests/test_onboard_progress.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_onboard_progress.py +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aai_cli import config + + +@pytest.fixture +def tmp_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + return tmp_path + + +def test_requests_made_starts_at_zero(tmp_config: Path) -> None: + assert config.get_requests_made("default") == 0 + + +def test_record_request_increments_and_persists(tmp_config: Path) -> None: + assert config.record_request("default") == 1 + assert config.record_request("default") == 2 + # Survives a fresh read (new process would re-_load from disk). + assert config.get_requests_made("default") == 2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_progress.py -q` +Expected: FAIL with `AttributeError: module 'aai_cli.config' has no attribute 'get_requests_made'`. + +- [ ] **Step 3: Add the field and functions** + +In `aai_cli/config.py`, add to `Profile` (after `account_id: int | None = None`): + +```python + requests_made: int | None = None +``` + +After `get_account_id` (around line 230), add: + +```python +def get_requests_made(profile: str) -> int: + """How many billable API requests this profile has made through the CLI.""" + prof = _load().profiles.get(profile) + return prof.requests_made or 0 if prof else 0 + + +def record_request(profile: str) -> int: + """Increment and persist this profile's CLI request count; return the new total. + + Powers the 'N of 100 requests' onboarding nudge. Counts only requests made + through the CLI; `aai usage` is the authoritative account-wide figure. + """ + _validate_profile(profile) + cfg = _load() + prof = cfg.profiles.setdefault(profile, Profile()) + prof.requests_made = (prof.requests_made or 0) + 1 + _dump(cfg) + return prof.requests_made +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_progress.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/config.py tests/test_onboard_progress.py +git commit -m "feat(onboard): per-profile CLI request counter in config" +``` + +--- + +## Task 2: Progress rendering and milestone copy + +**Files:** +- Create: `aai_cli/onboard/__init__.py`, `aai_cli/onboard/progress.py` +- Test: `tests/test_onboard_progress.py` (extend) + +- [ ] **Step 1: Write the failing test (append to tests/test_onboard_progress.py)** + +```python +from aai_cli.onboard import progress + + +def test_goal_is_100() -> None: + assert progress.GOAL == 100 + + +def test_milestone_message_fires_only_at_milestones() -> None: + assert progress.milestone_message(1) is not None + assert progress.milestone_message(10) is not None + assert progress.milestone_message(50) is not None + assert progress.milestone_message(100) is not None + assert progress.milestone_message(2) is None + assert progress.milestone_message(0) is None + + +def test_render_progress_mentions_count_goal_and_usage_pointer() -> None: + rendered = progress.render_progress(7) + assert "7" in rendered + assert "100" in rendered + assert "aai usage" in rendered +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_progress.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'aai_cli.onboard'`. + +- [ ] **Step 3: Create the package and module** + +`aai_cli/onboard/__init__.py`: + +```python +from __future__ import annotations +``` + +`aai_cli/onboard/progress.py`: + +```python +from __future__ import annotations + +from aai_cli import output + +GOAL = 100 + +# Counts that earn a one-off cheer; keep keys in sync with the wizard's nudge. +_MILESTONES: dict[int, str] = { + 1: "You're activated 🎉 — your first request is in.", + 10: "10 requests in. You're getting the hang of it.", + 50: "Halfway to 100 — nice momentum.", + GOAL: "100 requests — you're off the ground. 🚀", +} + + +def milestone_message(count: int) -> str | None: + """Encouragement to show when a request count lands exactly on a milestone.""" + return _MILESTONES.get(count) + + +def render_progress(count: int) -> str: + """A Rich-markup block: 'N of 100 API requests', any milestone, the usage pointer.""" + lines = [output.success(f"{count} of {GOAL} API requests")] + cheer = milestone_message(count) + if cheer: + lines.append(" " + output.heading(cheer)) + lines.append(" " + output.hint("For your full account usage, run `aai usage`.")) + return "\n".join(lines) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_progress.py -q` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/onboard/__init__.py aai_cli/onboard/progress.py tests/test_onboard_progress.py +git commit -m "feat(onboard): progress rendering and milestone copy" +``` + +--- + +## Task 3: Wire the counter into the run commands + +**Files:** +- Modify: `aai_cli/commands/transcribe.py` (import line 23; body after line 423), `llm.py` (import line 10; after line 143), `stream.py` (import line 20; after line 389), `agent.py` (import line 19; after line 167) +- Test: `tests/test_onboard_counter.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_onboard_counter.py +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from aai_cli import client, config +from aai_cli.main import app + + +class _FakeTranscript: + id = "t_123" + status = "completed" + text = "hello world" + json_response = {"id": "t_123", "text": "hello world"} + utterances = None + + +@pytest.fixture +def tmp_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + return tmp_path + + +def test_transcribe_increments_request_counter( + tmp_config: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + monkeypatch.setattr(client, "transcribe", lambda *a, **k: _FakeTranscript()) + result = CliRunner().invoke(app, ["transcribe", "--sample"]) + assert result.exit_code == 0, result.output + assert config.get_requests_made("default") == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_counter.py -q` +Expected: FAIL with `assert 0 == 1`. + +- [ ] **Step 3: Add the increment to each run command** + +In `transcribe.py`, change the context import (line 23) to: + +```python +from aai_cli.context import AppState, resolve_profile, run_command +``` + +Immediately after the `with output.status("Transcribing…", ...)` block (after line 423, before `if output_field is not None:`), add: + +```python + config.record_request(resolve_profile(state)) +``` + +In `llm.py`, change the context import (line 10) to: + +```python +from aai_cli.context import AppState, resolve_profile, run_command +``` + +In the one-shot `body`, after `content = gateway.content_of(response)` (line 143), add: + +```python + config.record_request(resolve_profile(state)) +``` + +In `stream.py`, change the context import (line 20) to: + +```python +from aai_cli.context import AppState, resolve_profile, run_command +``` + +After `_dispatch(session, opts)` (line 389), add: + +```python + config.record_request(resolve_profile(state)) +``` + +In `agent.py`, change the context import (line 19) to: + +```python +from aai_cli.context import AppState, resolve_profile, run_command +``` + +In the `try` block, on the line after `run_session(...)` (line 167), add: + +```python + config.record_request(resolve_profile(state)) +``` + +(Note: a stream/agent session aborted with Ctrl-C before normal return is not counted — a known, acceptable MVP limitation. `--show-code` paths return earlier and are correctly not counted.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_counter.py -q` +Expected: PASS. + +- [ ] **Step 5: Run the existing command suites to confirm no regressions** + +Run: `uv run pytest tests/test_transcribe.py tests/test_stream_llm.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/commands/transcribe.py aai_cli/commands/llm.py aai_cli/commands/stream.py aai_cli/commands/agent.py tests/test_onboard_counter.py +git commit -m "feat(onboard): count CLI API requests toward the 100 goal" +``` + +--- + +## Task 4: Prompter abstraction + +**Files:** +- Create: `aai_cli/onboard/prompter.py` +- Test: `tests/test_onboard_prompter.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_onboard_prompter.py +from __future__ import annotations + +import pytest + +from aai_cli.errors import UsageError +from aai_cli.onboard.prompter import NonInteractivePrompter + + +def test_noninteractive_confirm_returns_default() -> None: + p = NonInteractivePrompter() + assert p.confirm("Run setup?", default=True) is True + assert p.confirm("Run setup?", default=False) is False + + +def test_noninteractive_select_returns_default_or_first() -> None: + p = NonInteractivePrompter() + options = [("a", "Option A"), ("b", "Option B")] + assert p.select("Pick", options) == "a" + assert p.select("Pick", options, default="b") == "b" + + +def test_noninteractive_text_requires_default() -> None: + p = NonInteractivePrompter() + assert p.text("Name?", default="x") == "x" + with pytest.raises(UsageError): + p.text("Name?") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_prompter.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'aai_cli.onboard.prompter'`. + +- [ ] **Step 3: Create the prompter module** + +`aai_cli/onboard/prompter.py`: + +```python +from __future__ import annotations + +from typing import Protocol + +import typer + +from aai_cli import output +from aai_cli.errors import UsageError + + +class WizardCancelled(Exception): + """Raised when the user aborts the wizard (Ctrl-C / empty selection).""" + + +class Prompter(Protocol): + """How the wizard asks for input — one interface, interactive or not.""" + + def section(self, title: str) -> None: ... + def note(self, message: str) -> None: ... + def confirm(self, title: str, *, default: bool = True) -> bool: ... + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: ... + def text(self, title: str, *, default: str | None = None) -> str: ... + + +class InteractivePrompter: + """Drives real terminal prompts (questionary for select, Typer for the rest).""" + + def section(self, title: str) -> None: + output.console.print("\n" + output.heading(title)) + + def note(self, message: str) -> None: + output.console.print(output.hint(message)) + + def confirm(self, title: str, *, default: bool = True) -> bool: + return typer.confirm(title, default=default) + + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: + import questionary + + choice = questionary.select( + title, + choices=[questionary.Choice(title=label, value=value) for value, label in options], + default=default, + ).ask() + if choice is None: # Ctrl-C + raise WizardCancelled + return str(choice) + + def text(self, title: str, *, default: str | None = None) -> str: + return typer.prompt(title, default=default) + + +class NonInteractivePrompter: + """Never blocks for input: returns defaults, logs choices, refuses when no default. + + Keeps the CLI pipeline-safe — `--json`, a piped stdin, or an agent run can call + the wizard without it hanging on a prompt no human will answer. + """ + + def section(self, title: str) -> None: + output.error_console.print(output.heading(title)) + + def note(self, message: str) -> None: + output.error_console.print(output.hint(message)) + + def confirm(self, title: str, *, default: bool = True) -> bool: + output.error_console.print(output.hint(f"{title} → {default} (non-interactive)")) + return default + + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: + chosen = default if default is not None else options[0][0] + output.error_console.print(output.hint(f"{title} → {chosen} (non-interactive)")) + return chosen + + def text(self, title: str, *, default: str | None = None) -> str: + if default is None: + raise UsageError( + f"'{title}' needs a value, but this is a non-interactive session.", + suggestion="Re-run `aai onboard` in an interactive terminal.", + ) + return default +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_prompter.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/onboard/prompter.py tests/test_onboard_prompter.py +git commit -m "feat(onboard): interactive/non-interactive prompter abstraction" +``` + +--- + +## Task 5: Extract reusable `run_init` from the init command + +This lets the wizard scaffold a template without launching a blocking server. + +**Files:** +- Modify: `aai_cli/commands/init.py` (extract body into `run_init`, lines 203-243) +- Test: `tests/test_init.py` (existing suite must still pass) + +- [ ] **Step 1: Add `run_init` and have the command delegate to it** + +In `aai_cli/commands/init.py`, add a module-level function (above the `init` command, after `_launch`): + +```python +def run_init( + state: AppState, + *, + template: str | None, + directory: str | None, + no_install: bool, + no_open: bool, + force: bool, + here: bool, + port: int, + json_mode: bool, + launch: bool = True, +) -> Path: + """Scaffold (and optionally install/launch) a template; return the target dir. + + `launch=False` is for callers like the onboarding wizard that must not block on a + running dev server — it stops after install and leaves the run command as a hint. + """ + if not json_mode: + output.console.print( + f"[aai.heading]AssemblyAI CLI[/aai.heading] [aai.muted]{__version__}[/aai.muted]" + ) + chosen = _resolve_template(template) + target = _resolve_target(directory, chosen, here=here, force=force) + + api_key = keys.resolve_optional_api_key(profile=state.profile) + report = _scaffold_report(chosen, target, api_key) + + use_uv = runner.has_uv() + install_rows, will_launch = _install_step( + target, no_install=no_install, api_key=api_key, use_uv=use_uv + ) + report.extend(install_rows) + + if not no_install and api_key is None: + report.append( + { + "name": "launch", + "status": "skipped", + "detail": f"no API key; run `aai login`, then: cd {target} && uv run uvicorn api.index:app", + } + ) + + 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) + + if launch and will_launch: + _launch(target, port=port, use_uv=use_uv, no_open=no_open, json_mode=json_mode) + elif not json_mode: + output.console.print( + output.hint(f"Run `cd {escape(str(target))} && uv run uvicorn api.index:app`.") + ) + return target +``` + +Replace the `init` command's `body` (lines 203-243) with a delegating call: + +```python + def body(state: AppState, json_mode: bool) -> None: + run_init( + state, + template=template, + directory=directory, + no_install=no_install, + no_open=no_open, + force=force, + here=here, + port=port, + json_mode=json_mode, + ) +``` + +- [ ] **Step 2: Run the existing init suite to verify behavior is unchanged** + +Run: `uv run pytest tests/test_init.py -q` +Expected: PASS (no behavior change for the `aai init` command). + +- [ ] **Step 3: Commit** + +```bash +git add aai_cli/commands/init.py +git commit -m "refactor(init): extract run_init for reuse by the onboarding wizard" +``` + +--- + +## Task 6: Wizard context, results, and the welcome/auth/first-request sections + +**Files:** +- Create: `aai_cli/onboard/sections.py` +- Test: `tests/test_onboard_sections.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_onboard_sections.py +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aai_cli import client, config +from aai_cli.context import AppState +from aai_cli.onboard import sections +from aai_cli.onboard.prompter import NonInteractivePrompter +from aai_cli.onboard.sections import SectionResult, WizardContext + + +class _FakeTranscript: + id = "t_1" + status = "completed" + text = "hello" + utterances = None + + +@pytest.fixture +def ctx(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> WizardContext: + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + return WizardContext(state=AppState(), profile="default", json_mode=False) + + +def test_auth_skips_when_key_already_present( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + assert sections.auth(NonInteractivePrompter(), ctx) is SectionResult.SKIPPED + + +def test_first_request_transcribes_sample_and_counts( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + monkeypatch.setattr(client, "transcribe", lambda *a, **k: _FakeTranscript()) + assert sections.first_request(NonInteractivePrompter(), ctx) is SectionResult.DONE + assert config.get_requests_made("default") == 1 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_sections.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'aai_cli.onboard.sections'`. + +- [ ] **Step 3: Create the sections module (context, results, first three sections)** + +`aai_cli/onboard/sections.py`: + +```python +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +import assemblyai as aai + +from aai_cli import client, config, environments, output, transcribe_render +from aai_cli.context import AppState, persist_browser_login +from aai_cli.errors import NotAuthenticated +from aai_cli.onboard import progress +from aai_cli.onboard.prompter import Prompter + + +class SectionResult(Enum): + DONE = "done" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass +class WizardContext: + state: AppState + profile: str + json_mode: bool + + +def _has_key(profile: str) -> bool: + try: + config.resolve_api_key(profile=profile) + except NotAuthenticated: + return False + return True + + +def welcome(prompter: Prompter, ctx: WizardContext) -> SectionResult: + count = config.get_requests_made(ctx.profile) + if count: + prompter.section("Welcome back to AssemblyAI") + output.console.print(progress.render_progress(count)) + return SectionResult.DONE + prompter.section("Welcome to AssemblyAI") + prompter.note( + "This wizard signs you in, runs your first transcription, and helps you build." + ) + return SectionResult.DONE + + +def auth(prompter: Prompter, ctx: WizardContext) -> SectionResult: + if _has_key(ctx.profile): + prompter.note("Already signed in.") + return SectionResult.SKIPPED + prompter.section("Sign in") + method = prompter.select( + "How do you want to sign in?", + [("browser", "Sign in with your browser (recommended)"), ("key", "Paste an API key")], + default="browser", + ) + env = environments.active().name + if method == "key": + key = prompter.text("Paste your AssemblyAI API key") + if not client.validate_key(key): + output.console.print(output.fail("That key was rejected.")) + return SectionResult.FAILED + config.set_api_key(ctx.profile, key) + config.set_profile_env(ctx.profile, env) + return SectionResult.DONE + prompter.note(f"No account yet? Create one at {environments.active().signup_url}") + persist_browser_login(ctx.profile, env) + return SectionResult.DONE + + +def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("Your first transcription") + api_key = config.resolve_api_key(profile=ctx.profile) + with output.status("Transcribing the sample clip…", json_mode=ctx.json_mode): + transcript = client.transcribe( + api_key, client.SAMPLE_AUDIO_URL, config=aai.TranscriptionConfig() + ) + count = config.record_request(ctx.profile) + transcribe_render.render_transcript_result(transcript, output.console) + output.console.print(progress.render_progress(count)) + return SectionResult.DONE +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_sections.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/onboard/sections.py tests/test_onboard_sections.py +git commit -m "feat(onboard): welcome, auth, and first-request sections" +``` + +--- + +## Task 7: Environment, build-path, Claude Code, and next-steps sections + +**Files:** +- Modify: `aai_cli/onboard/sections.py` +- Test: `tests/test_onboard_sections.py` (extend) + +- [ ] **Step 1: Write the failing test (append)** + +```python +from aai_cli.commands import init as init_cmd + + +def test_environment_is_non_blocking(ctx: WizardContext) -> None: + # Even if checks warn/fail, the section never blocks the wizard. + assert sections.environment(NonInteractivePrompter(), ctx) is SectionResult.DONE + + +def test_build_path_skip_choice_does_nothing( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + called = False + + def _fake_run_init(*a: object, **k: object) -> Path: + nonlocal called + called = True + return Path(".") + + monkeypatch.setattr(init_cmd, "run_init", _fake_run_init) + # NonInteractivePrompter.select returns the default; build_path's default is "skip". + assert sections.build_path(NonInteractivePrompter(), ctx) is SectionResult.SKIPPED + assert called is False + + +def test_next_steps_renders_progress(ctx: WizardContext) -> None: + assert sections.next_steps(NonInteractivePrompter(), ctx) is SectionResult.DONE +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_sections.py -q` +Expected: FAIL with `AttributeError: module 'aai_cli.onboard.sections' has no attribute 'environment'`. + +- [ ] **Step 3: Add the four sections** + +Append to `aai_cli/onboard/sections.py`. First extend the imports at the top: + +```python +from aai_cli.commands import doctor as doctor_cmd +from aai_cli.commands import init as init_cmd +from aai_cli.commands import setup as setup_cmd +``` + +Then add: + +```python +_BUILD_CHOICES = [ + ("audio-transcription", "Transcribe audio files (web app)"), + ("live-captions", "Live captions from streaming audio"), + ("voice-agent", "A two-way voice agent"), + ("skip", "Just the CLI for now"), +] + + +def environment(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("Environment check") + checks = [ + doctor_cmd._check_python(), + doctor_cmd._check_ffmpeg(), + doctor_cmd._check_audio(), + ] + output.console.print(doctor_cmd._render({"ok": True, "checks": checks})) + prompter.note("Warnings here only affect live streaming and the voice agent.") + return SectionResult.DONE + + +def build_path(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("What do you want to build?") + choice = prompter.select("Pick a starting point", _BUILD_CHOICES, default="skip") + if choice == "skip": + return SectionResult.SKIPPED + if not prompter.confirm(f"Scaffold the '{choice}' app now?", default=True): + prompter.note(f"You can run `aai init {choice}` whenever you're ready.") + return SectionResult.SKIPPED + # launch=False: never block the wizard on a running dev server. + init_cmd.run_init( + ctx.state, + template=choice, + directory=None, + no_install=False, + no_open=True, + force=False, + here=False, + port=3000, + json_mode=ctx.json_mode, + launch=False, + ) + return SectionResult.DONE + + +def claude_code(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("Coding agent (optional)") + if not prompter.confirm("Wire up Claude Code (docs MCP + skills)?", default=False): + return SectionResult.SKIPPED + steps = [ + setup_cmd._install_mcp("user", False), + setup_cmd._install_skill(False), + setup_cmd._install_cli_skill(False), + ] + output.console.print(setup_cmd._render({"steps": steps})) + return SectionResult.DONE + + +def next_steps(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("You're set up") + output.console.print(progress.render_progress(config.get_requests_made(ctx.profile))) + output.console.print(output.hint("Transcribe a file: aai transcribe ")) + output.console.print(output.hint("Stream live audio: aai stream")) + output.console.print(output.hint("Build an app: aai init")) + return SectionResult.DONE +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_sections.py -q` +Expected: PASS (all section tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/onboard/sections.py tests/test_onboard_sections.py +git commit -m "feat(onboard): environment, build-path, Claude Code, next-steps sections" +``` + +--- + +## Task 8: Wizard orchestrator + +**Files:** +- Create: `aai_cli/onboard/wizard.py` +- Test: `tests/test_onboard_wizard.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_onboard_wizard.py +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aai_cli import config +from aai_cli.context import AppState +from aai_cli.onboard import sections, wizard +from aai_cli.onboard.prompter import NonInteractivePrompter +from aai_cli.onboard.sections import SectionResult, WizardContext + + +@pytest.fixture +def ctx(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> WizardContext: + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + return WizardContext(state=AppState(), profile="default", json_mode=False) + + +def test_auth_failure_stops_the_wizard( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(sections, "welcome", lambda p, c: SectionResult.DONE) + monkeypatch.setattr(sections, "auth", lambda p, c: SectionResult.FAILED) + ran_after = False + + def _first(p: object, c: object) -> SectionResult: + nonlocal ran_after + ran_after = True + return SectionResult.DONE + + monkeypatch.setattr(sections, "first_request", _first) + code = wizard.run_onboarding(NonInteractivePrompter(), ctx) + assert code == 4 # NotAuthenticated exit code + assert ran_after is False + + +def test_happy_path_runs_all_sections( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + for name in ("welcome", "auth", "first_request", "environment", "build_path", + "claude_code", "next_steps"): + monkeypatch.setattr(sections, name, lambda p, c: SectionResult.DONE) + assert wizard.run_onboarding(NonInteractivePrompter(), ctx) == 0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_wizard.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'aai_cli.onboard.wizard'`. + +- [ ] **Step 3: Create the wizard module** + +`aai_cli/onboard/wizard.py`: + +```python +from __future__ import annotations + +from aai_cli import output +from aai_cli.errors import NotAuthenticated +from aai_cli.onboard import sections +from aai_cli.onboard.prompter import Prompter, WizardCancelled +from aai_cli.onboard.sections import SectionResult, WizardContext + + +def run_onboarding(prompter: Prompter, ctx: WizardContext) -> int: + """Run the ordered sections; return a process exit code. + + Auth is the one hard stop (no key → later sections can't run). Cancellation + (Ctrl-C / empty pick) exits cleanly. The terminal cursor is always restored. + """ + try: + sections.welcome(prompter, ctx) + if sections.auth(prompter, ctx) is SectionResult.FAILED: + output.error_console.print( + output.fail("Could not sign in. Run `aai onboard` again to retry.") + ) + return NotAuthenticated().exit_code + sections.first_request(prompter, ctx) + sections.environment(prompter, ctx) + sections.build_path(prompter, ctx) + sections.claude_code(prompter, ctx) + sections.next_steps(prompter, ctx) + return 0 + except WizardCancelled: + output.error_console.print(output.hint("Setup cancelled. Run `aai onboard` to resume.")) + return 130 + finally: + output.console.show_cursor(True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_wizard.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/onboard/wizard.py tests/test_onboard_wizard.py +git commit -m "feat(onboard): wizard orchestrator with auth hard-stop and clean cancel" +``` + +--- + +## Task 9: The `aai onboard` command + +**Files:** +- Create: `aai_cli/commands/onboard.py` +- Test: `tests/test_onboard_command.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_onboard_command.py +from __future__ import annotations + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + + +@pytest.fixture(autouse=True) +def tmp_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + return tmp_path + + +def test_status_shows_progress_without_running_wizard() -> None: + config.record_request("default") + config.record_request("default") + result = CliRunner().invoke(app, ["onboard", "--status"]) + assert result.exit_code == 0, result.output + assert "2 of 100" in result.output + + +def test_onboard_is_listed_in_help() -> None: + result = CliRunner().invoke(app, ["--help"]) + assert "onboard" in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_command.py -q` +Expected: FAIL (no `onboard` command registered yet). + +- [ ] **Step 3: Create the command** + +`aai_cli/commands/onboard.py`: + +```python +from __future__ import annotations + +import sys + +import typer + +from aai_cli import config, help_panels, output +from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.help_text import examples_epilog +from aai_cli.onboard import progress, wizard +from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter, Prompter +from aai_cli.onboard.sections import WizardContext + +app = typer.Typer() + + +def _build_prompter() -> Prompter: + """A real prompter only when both ends are a TTY; otherwise never block.""" + if sys.stdin.isatty() and sys.stdout.isatty(): + return InteractivePrompter() + return NonInteractivePrompter() + + +@app.command( + rich_help_panel=help_panels.QUICK_START, + epilog=examples_epilog( + [ + ("Run the guided setup", "aai onboard"), + ("Show your progress toward 100 requests", "aai onboard --status"), + ] + ), +) +def onboard( + ctx: typer.Context, + status: bool = typer.Option(False, "--status", help="Show request progress and exit."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Guided setup: sign in, run your first transcription, and start building.""" + + def body(state: AppState, json_mode: bool) -> None: + profile = resolve_profile(state) + if status: + count = config.get_requests_made(profile) + output.emit( + {"requests_made": count, "goal": progress.GOAL}, + lambda _d: progress.render_progress(count), + json_mode=json_mode, + ) + return + wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode) + code = wizard.run_onboarding(_build_prompter(), wiz_ctx) + if code != 0: + raise typer.Exit(code=code) + + # auto_login=False: the wizard owns the sign-in step itself. + run_command(ctx, body, json=json_out, auto_login=False) +``` + +- [ ] **Step 4: Register the command in `main.py`** + +In `aai_cli/main.py`, add `onboard` to the command imports (line 15-30 block): + +```python + onboard, +``` + +And register it (after `app.add_typer(init.app)`, line 166): + +```python +app.add_typer(onboard.app) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_command.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/commands/onboard.py aai_cli/main.py tests/test_onboard_command.py +git commit -m "feat(onboard): aai onboard command with --status" +``` + +--- + +## Task 10: Help ordering, panel comment, and bare-`aai` first-run offer + +**Files:** +- Modify: `aai_cli/main.py` (`_COMMAND_ORDER` line 39-64; root `epilog` line 101-109; `main` callback body after line 148), `aai_cli/help_panels.py` (line 15) +- Test: `tests/test_onboard_command.py` (extend) + +- [ ] **Step 1: Write the failing test (append)** + +```python +def test_onboard_sorts_first_in_quick_start() -> None: + result = CliRunner().invoke(app, ["--help"]) + # onboard should appear before init in the Quick Start panel. + assert result.output.index("onboard") < result.output.index("init") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_onboard_command.py::test_onboard_sorts_first_in_quick_start -q` +Expected: FAIL (init currently precedes onboard, which falls to alpha order). + +- [ ] **Step 3: Reorder and update help text** + +In `aai_cli/main.py`, change the start of `_COMMAND_ORDER` (line 40-41) from: + +```python + # Quick Start — zero-to-running onboarding + "init", +``` + +to: + +```python + # Quick Start — zero-to-running onboarding + "onboard", + "init", +``` + +Update the root `epilog` (lines 102-108) to lead with onboard: + +```python + epilog=examples_epilog( + [ + ("Guided setup (start here)", "aai onboard"), + ("Transcribe a file", "aai transcribe call.mp3"), + ("Scaffold a starter app", "aai init"), + ] + ) +``` + +In `aai_cli/help_panels.py` line 15, update the comment: + +```python +QUICK_START = "Quick Start" # zero-to-running onboarding: onboard, init +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_onboard_command.py -q` +Expected: PASS. + +- [ ] **Step 5: Add the bare-`aai` first-run offer** + +The root callback runs for `aai` with no subcommand (Typer then shows help because `no_args_is_help=True`). To offer the wizard first, intercept in the callback only when there's no subcommand, no credentials, and an interactive TTY. + +In `aai_cli/main.py`, at the end of the `main` callback (after line 148, the `env_override_warning` block), add: + +```python + _maybe_offer_onboarding(ctx, state) +``` + +Add this helper above `main` (after `_version_callback`, line 99): + +```python +def _maybe_offer_onboarding(ctx: typer.Context, state: AppState) -> None: + """On a bare, credential-less, interactive `aai`, offer the guided wizard. + + Never hijacks `--help` or any subcommand; declining falls through to the normal + help screen. Silent in non-interactive sessions so pipelines/agents are unaffected. + """ + import sys + + if ctx.invoked_subcommand is not None: + return + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return + from aai_cli import config + from aai_cli.errors import NotAuthenticated + + try: + config.resolve_api_key(profile=state.profile) + except NotAuthenticated: + pass + else: + return # already has a key; show normal help + if typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True): + from aai_cli.commands.onboard import _build_prompter + from aai_cli.onboard import wizard + from aai_cli.onboard.sections import WizardContext + + wiz_ctx = WizardContext( + state=state, profile=state.resolve_profile(), json_mode=False + ) + raise typer.Exit(code=wizard.run_onboarding(_build_prompter(), wiz_ctx)) +``` + +- [ ] **Step 6: Write a test for the offer (append to tests/test_onboard_command.py)** + +```python +def test_bare_aai_with_key_does_not_offer_wizard(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + # Non-interactive (CliRunner) input → the offer is skipped; help is shown. + result = CliRunner().invoke(app, []) + assert "Usage" in result.output or "Commands" in result.output +``` + +- [ ] **Step 7: Run tests** + +Run: `uv run pytest tests/test_onboard_command.py -q` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add aai_cli/main.py aai_cli/help_panels.py tests/test_onboard_command.py +git commit -m "feat(onboard): list onboard first and offer it on a bare first run" +``` + +--- + +## Task 11: Point existing entry points at `aai onboard` + +**Files:** +- Modify: `aai_cli/commands/login.py` (line 55), `install.sh` (line ~41) +- Test: `tests/test_login.py` (existing) / snapshot + +- [ ] **Step 1: Update the post-login hint** + +In `aai_cli/commands/login.py`, change line 55 from: + +```python + + output.hint("Run `aai transcribe ` to make your first transcript.") +``` + +to: + +```python + + output.hint("Run `aai onboard` to finish setup, or `aai transcribe `.") +``` + +- [ ] **Step 2: Update install.sh** + +In `install.sh`, change the final hint (line ~41) from: + +```sh +echo "Installed. Next: run 'aai login', then 'aai transcribe --sample'." +``` + +to: + +```sh +echo "Installed. Next: run 'aai onboard'." +``` + +- [ ] **Step 3: Run any login tests** + +Run: `uv run pytest tests/test_login.py -q` +Expected: PASS (update assertions if a test pins the old hint text). + +- [ ] **Step 4: Commit** + +```bash +git add aai_cli/commands/login.py install.sh tests/test_login.py +git commit -m "docs(onboard): route post-install and post-login hints to aai onboard" +``` + +--- + +## Task 12: Regenerate snapshots and run the full gate + +**Files:** +- Modify: `tests/__snapshots__/test_cli_output_snapshots.ambr` (regenerated, never hand-edited) + +- [ ] **Step 1: Regenerate snapshots** + +Run: `uv run pytest --snapshot-update -q` +Expected: snapshots updated for `aai --help` (new ordering, onboard panel entry) and any new onboard help captured by the snapshot suite. + +- [ ] **Step 2: Review the snapshot diff** + +Run: `git diff tests/__snapshots__/test_cli_output_snapshots.ambr` +Expected: only `onboard`-related additions and the Quick Start reordering; no unrelated churn. (See [[syrupy-ambr-vs-whitespace-hooks]] — never let a whitespace hook touch this file.) + +- [ ] **Step 3: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` Address anything it flags — especially `vulture` (the `doctor`/`setup` helpers the wizard now imports are no longer "unused"; if vulture flags the `_`-prefixed cross-module use, add a targeted allow or call them via small public wrappers rather than a blanket ignore), `xenon` (keep `run_onboarding` and each section under grade B), and `diff-cover` (100% patch coverage — add tests for any uncovered branch). + +- [ ] **Step 4: Commit** + +```bash +git add tests/__snapshots__/test_cli_output_snapshots.ambr +git commit -m "test(onboard): regenerate CLI help snapshots for aai onboard" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Prompter abstraction (interactive/non-interactive) → Task 4. ✓ +- Resumable sections that self-skip → auth/`_has_key` skip (Task 6), build-path skip (Task 7); welcome greets returning users (Task 6). ✓ +- Terminal restore / clean cancel → `run_onboarding` try/finally + `WizardCancelled` (Task 8). ✓ +- Seven ordered sections (welcome, auth, first-request, env, build-path, Claude Code, next-steps) → Tasks 6-7, ordered in Task 8. ✓ +- First request fires inside the wizard + counts → Task 6 `first_request`. ✓ +- Local request counter incremented by run commands → Tasks 1, 3. ✓ +- `aai onboard` + `--status` → Task 9. ✓ +- First-run autodetect (bare `aai` offer, never hijacks help) → Task 10. ✓ +- Help ordering (`onboard` first in Quick Start) + install.sh/login hints → Tasks 10, 11. ✓ +- Tests + snapshots + gate → every task + Task 12. ✓ +- Out of scope (server-side usage, conversational mode, auth backend changes, new templates) → respected; not implemented. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows real code. ✓ + +**Type consistency:** `SectionResult`/`WizardContext` defined in Task 6 and used unchanged in 7-9; `Prompter`/`NonInteractivePrompter`/`InteractivePrompter`/`WizardCancelled` defined in Task 4 and reused in 6-10; `run_init` signature defined in Task 5 and called identically in Task 7; `config.record_request`/`get_requests_made` defined in Task 1 and used in 2,3,6,7,9. ✓ + +**Known follow-ups (not blockers):** Ctrl-C'd stream/agent sessions aren't counted (Task 3 note). Build-path scaffolds without launching to avoid blocking the wizard (Task 7). +``` From 92a7e0e33342375e8ed0e24c4d95d8b270536f11 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 15:55:16 -0700 Subject: [PATCH 03/37] feat(onboard): per-profile CLI request counter in config Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/config.py | 21 +++++++++++++++++++++ tests/test_onboard_progress.py | 23 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/test_onboard_progress.py diff --git a/aai_cli/config.py b/aai_cli/config.py index 9d0ba783..2a73bcc3 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -33,6 +33,7 @@ class Profile(BaseModel): env: str | None = None account_id: int | None = None + requests_made: int | None = None class Config(BaseModel): @@ -230,6 +231,26 @@ def get_account_id(profile: str) -> int | None: return prof.account_id if prof else None +def get_requests_made(profile: str) -> int: + """How many billable API requests this profile has made through the CLI.""" + prof = _load().profiles.get(profile) + return prof.requests_made or 0 if prof else 0 + + +def record_request(profile: str) -> int: + """Increment and persist this profile's CLI request count; return the new total. + + Powers the 'N of 100 requests' onboarding nudge. Counts only requests made + through the CLI; `aai usage` is the authoritative account-wide figure. + """ + _validate_profile(profile) + cfg = _load() + prof = cfg.profiles.setdefault(profile, Profile()) + prof.requests_made = (prof.requests_made or 0) + 1 + _dump(cfg) + return prof.requests_made + + def clear_session(profile: str) -> None: with contextlib.suppress(keyring.errors.PasswordDeleteError): keyring.delete_password(KEYRING_SERVICE, _session_username(profile)) diff --git a/tests/test_onboard_progress.py b/tests/test_onboard_progress.py new file mode 100644 index 00000000..df69c448 --- /dev/null +++ b/tests/test_onboard_progress.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from aai_cli import config + + +@pytest.fixture +def tmp_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(config, "config_dir", lambda: tmp_path) + return tmp_path + + +def test_requests_made_starts_at_zero(tmp_config: Path) -> None: + assert config.get_requests_made("default") == 0 + + +def test_record_request_increments_and_persists(tmp_config: Path) -> None: + assert config.record_request("default") == 1 + assert config.record_request("default") == 2 + assert config.get_requests_made("default") == 2 From 804962173ff57dbe31b15b71595204dc3373d750 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:00:59 -0700 Subject: [PATCH 04/37] test(onboard): rely on autouse tmp_config fixture from conftest --- tests/test_onboard_progress.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/tests/test_onboard_progress.py b/tests/test_onboard_progress.py index df69c448..5362d5ea 100644 --- a/tests/test_onboard_progress.py +++ b/tests/test_onboard_progress.py @@ -1,23 +1,13 @@ from __future__ import annotations -from pathlib import Path - -import pytest - from aai_cli import config -@pytest.fixture -def tmp_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - monkeypatch.setattr(config, "config_dir", lambda: tmp_path) - return tmp_path - - -def test_requests_made_starts_at_zero(tmp_config: Path) -> None: +def test_requests_made_starts_at_zero() -> None: assert config.get_requests_made("default") == 0 -def test_record_request_increments_and_persists(tmp_config: Path) -> None: +def test_record_request_increments_and_persists() -> None: assert config.record_request("default") == 1 assert config.record_request("default") == 2 assert config.get_requests_made("default") == 2 From 54901b8aec9bc6db123ca27479821936797475a8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:02:44 -0700 Subject: [PATCH 05/37] feat(onboard): progress rendering and milestone copy Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/onboard/__init__.py | 1 + aai_cli/onboard/progress.py | 28 ++++++++++++++++++++++++++++ tests/test_onboard_progress.py | 22 ++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 aai_cli/onboard/__init__.py create mode 100644 aai_cli/onboard/progress.py diff --git a/aai_cli/onboard/__init__.py b/aai_cli/onboard/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/aai_cli/onboard/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/aai_cli/onboard/progress.py b/aai_cli/onboard/progress.py new file mode 100644 index 00000000..dc7cb1b4 --- /dev/null +++ b/aai_cli/onboard/progress.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from aai_cli import output + +GOAL = 100 + +# Counts that earn a one-off cheer; keep keys in sync with the wizard's nudge. +_MILESTONES: dict[int, str] = { + 1: "You're activated 🎉 — your first request is in.", + 10: "10 requests in. You're getting the hang of it.", + 50: "Halfway to 100 — nice momentum.", + GOAL: "100 requests — you're off the ground. 🚀", +} + + +def milestone_message(count: int) -> str | None: + """Encouragement to show when a request count lands exactly on a milestone.""" + return _MILESTONES.get(count) + + +def render_progress(count: int) -> str: + """A Rich-markup block: 'N of 100 API requests', any milestone, the usage pointer.""" + lines = [output.success(f"{count} of {GOAL} API requests")] + cheer = milestone_message(count) + if cheer: + lines.append(" " + output.heading(cheer)) + lines.append(" " + output.hint("For your full account usage, run `aai usage`.")) + return "\n".join(lines) diff --git a/tests/test_onboard_progress.py b/tests/test_onboard_progress.py index 5362d5ea..244edbc0 100644 --- a/tests/test_onboard_progress.py +++ b/tests/test_onboard_progress.py @@ -1,5 +1,7 @@ from __future__ import annotations +from aai_cli.onboard import progress + from aai_cli import config @@ -11,3 +13,23 @@ def test_record_request_increments_and_persists() -> None: assert config.record_request("default") == 1 assert config.record_request("default") == 2 assert config.get_requests_made("default") == 2 + + +def test_goal_is_100() -> None: + assert progress.GOAL == 100 + + +def test_milestone_message_fires_only_at_milestones() -> None: + assert progress.milestone_message(1) is not None + assert progress.milestone_message(10) is not None + assert progress.milestone_message(50) is not None + assert progress.milestone_message(100) is not None + assert progress.milestone_message(2) is None + assert progress.milestone_message(0) is None + + +def test_render_progress_mentions_count_goal_and_usage_pointer() -> None: + rendered = progress.render_progress(7) + assert "7" in rendered + assert "100" in rendered + assert "aai usage" in rendered From dc198093547e58fced1573a68ea3d59f28777077 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:07:10 -0700 Subject: [PATCH 06/37] fix(onboard): cover milestone branch, sort imports, use GOAL constant Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/onboard/progress.py | 2 +- tests/test_onboard_progress.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/aai_cli/onboard/progress.py b/aai_cli/onboard/progress.py index dc7cb1b4..3e8255f3 100644 --- a/aai_cli/onboard/progress.py +++ b/aai_cli/onboard/progress.py @@ -9,7 +9,7 @@ 1: "You're activated 🎉 — your first request is in.", 10: "10 requests in. You're getting the hang of it.", 50: "Halfway to 100 — nice momentum.", - GOAL: "100 requests — you're off the ground. 🚀", + GOAL: f"{GOAL} requests — you're off the ground. 🚀", } diff --git a/tests/test_onboard_progress.py b/tests/test_onboard_progress.py index 244edbc0..78760070 100644 --- a/tests/test_onboard_progress.py +++ b/tests/test_onboard_progress.py @@ -1,8 +1,7 @@ from __future__ import annotations -from aai_cli.onboard import progress - from aai_cli import config +from aai_cli.onboard import progress def test_requests_made_starts_at_zero() -> None: @@ -33,3 +32,5 @@ def test_render_progress_mentions_count_goal_and_usage_pointer() -> None: assert "7" in rendered assert "100" in rendered assert "aai usage" in rendered + milestone = progress.render_progress(1) + assert "activated" in milestone From 6caf248de5f5049e93bdeae5a0afd27ddb3be7a2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:09:57 -0700 Subject: [PATCH 07/37] feat(onboard): count CLI API requests toward the 100 goal Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/agent.py | 3 ++- aai_cli/commands/llm.py | 3 ++- aai_cli/commands/stream.py | 3 ++- aai_cli/commands/transcribe.py | 3 ++- tests/test_onboard_counter.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 tests/test_onboard_counter.py diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 83212245..b91a80fe 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -16,7 +16,7 @@ run_session, ) from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, complete_voice, format_voice_list -from aai_cli.context import AppState, run_command +from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import CLIError, UsageError from aai_cli.help_text import examples_epilog from aai_cli.streaming.sources import FileSource @@ -165,6 +165,7 @@ def body(state: AppState, json_mode: bool) -> None: ) try: run_session(api_key, renderer=renderer, player=player, mic=audio, config=run_config) + config.record_request(resolve_profile(state)) except KeyboardInterrupt: renderer.stopped() except BrokenPipeError as exc: diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index bc197894..a6a6c1c0 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -7,7 +7,7 @@ from aai_cli import choices, config, help_panels, output, stdio from aai_cli import llm as gateway -from aai_cli.context import AppState, run_command +from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer from aai_cli.help_text import examples_epilog @@ -141,6 +141,7 @@ def body(state: AppState, json_mode: bool) -> None: transcript_id=transcript_id, ) content = gateway.content_of(response) + config.record_request(resolve_profile(state)) if output_field == "text": # Just the answer, raw — so `… | aai llm -o text "…" | next` composes cleanly. output.emit_text(content) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 5836434b..2a484c84 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -17,7 +17,7 @@ output, youtube, ) -from aai_cli.context import AppState, run_command +from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer from aai_cli.help_text import examples_epilog @@ -387,5 +387,6 @@ def body(state: AppState, json_mode: bool) -> None: max_tokens=max_tokens, ) _dispatch(session, opts) + config.record_request(resolve_profile(state)) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 20c99d73..47f950c0 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -20,7 +20,7 @@ transcribe_render, youtube, ) -from aai_cli.context import AppState, run_command +from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.errors import UsageError from aai_cli.help_text import examples_epilog @@ -421,6 +421,7 @@ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) with output.status("Transcribing…", json_mode=json_mode): transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) + config.record_request(resolve_profile(state)) if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). diff --git a/tests/test_onboard_counter.py b/tests/test_onboard_counter.py new file mode 100644 index 00000000..cd20e900 --- /dev/null +++ b/tests/test_onboard_counter.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import ClassVar + +import pytest +from typer.testing import CliRunner + +from aai_cli import client, config +from aai_cli.main import app + + +class _FakeTranscript: + id = "t_123" + status = "completed" + text = "hello world" + json_response: ClassVar[dict[str, str]] = {"id": "t_123", "text": "hello world"} + utterances = None + + +def test_transcribe_increments_request_counter(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + monkeypatch.setattr(client, "transcribe", lambda *a, **k: _FakeTranscript()) + # `-o text` keeps us off the rich render path (which needs a full transcript object); + # the counter increments before the output branch either way. + result = CliRunner().invoke(app, ["transcribe", "--sample", "-o", "text"]) + assert result.exit_code == 0, result.output + assert "hello world" in result.output + assert config.get_requests_made("default") == 1 From ea0f2d629dfbc1439f8ccded3cbce37612f7c76a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:13:29 -0700 Subject: [PATCH 08/37] feat(onboard): interactive/non-interactive prompter abstraction Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/onboard/prompter.py | 87 ++++++++++++++++++++++++++++++++++ tests/test_onboard_prompter.py | 26 ++++++++++ 2 files changed, 113 insertions(+) create mode 100644 aai_cli/onboard/prompter.py create mode 100644 tests/test_onboard_prompter.py diff --git a/aai_cli/onboard/prompter.py b/aai_cli/onboard/prompter.py new file mode 100644 index 00000000..177375e4 --- /dev/null +++ b/aai_cli/onboard/prompter.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Protocol + +import typer + +from aai_cli import output +from aai_cli.errors import UsageError + + +class WizardCancelled(Exception): + """Raised when the user aborts the wizard (Ctrl-C / empty selection).""" + + +class Prompter(Protocol): + """How the wizard asks for input — one interface, interactive or not.""" + + def section(self, title: str) -> None: ... + def note(self, message: str) -> None: ... + def confirm(self, title: str, *, default: bool = True) -> bool: ... + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: ... + def text(self, title: str, *, default: str | None = None) -> str: ... + + +class InteractivePrompter: + """Drives real terminal prompts (questionary for select, Typer for the rest).""" + + def section(self, title: str) -> None: + output.console.print("\n" + output.heading(title)) + + def note(self, message: str) -> None: + output.console.print(output.hint(message)) + + def confirm(self, title: str, *, default: bool = True) -> bool: + return typer.confirm(title, default=default) + + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: + import questionary + + choice = questionary.select( + title, + choices=[questionary.Choice(title=label, value=value) for value, label in options], + default=default, + ).ask() + if choice is None: # Ctrl-C + raise WizardCancelled + return str(choice) + + def text(self, title: str, *, default: str | None = None) -> str: + return str(typer.prompt(title, default=default)) + + +class NonInteractivePrompter: + """Never blocks for input: returns defaults, logs choices, refuses when no default. + + Keeps the CLI pipeline-safe — `--json`, a piped stdin, or an agent run can call + the wizard without it hanging on a prompt no human will answer. + """ + + def section(self, title: str) -> None: + output.error_console.print(output.heading(title)) + + def note(self, message: str) -> None: + output.error_console.print(output.hint(message)) + + def confirm(self, title: str, *, default: bool = True) -> bool: + output.error_console.print(output.hint(f"{title} → {default} (non-interactive)")) + return default + + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: + chosen = default if default is not None else options[0][0] + output.error_console.print(output.hint(f"{title} → {chosen} (non-interactive)")) + return chosen + + def text(self, title: str, *, default: str | None = None) -> str: + if default is None: + raise UsageError( + f"'{title}' needs a value, but this is a non-interactive session.", + suggestion="Re-run `aai onboard` in an interactive terminal.", + ) + return default diff --git a/tests/test_onboard_prompter.py b/tests/test_onboard_prompter.py new file mode 100644 index 00000000..dfb48c4e --- /dev/null +++ b/tests/test_onboard_prompter.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import pytest + +from aai_cli.errors import UsageError +from aai_cli.onboard.prompter import NonInteractivePrompter + + +def test_noninteractive_confirm_returns_default() -> None: + p = NonInteractivePrompter() + assert p.confirm("Run setup?", default=True) is True + assert p.confirm("Run setup?", default=False) is False + + +def test_noninteractive_select_returns_default_or_first() -> None: + p = NonInteractivePrompter() + options = [("a", "Option A"), ("b", "Option B")] + assert p.select("Pick", options) == "a" + assert p.select("Pick", options, default="b") == "b" + + +def test_noninteractive_text_requires_default() -> None: + p = NonInteractivePrompter() + assert p.text("Name?", default="x") == "x" + with pytest.raises(UsageError): + p.text("Name?") From 69ce62a961652d17fc23a058e83eda61b1872a7b Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:25:59 -0700 Subject: [PATCH 09/37] feat(onboard): welcome, auth, and first-request sections Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/onboard/sections.py | 81 ++++++++++++++++++++++++++++++++++ tests/test_onboard_sections.py | 40 +++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 aai_cli/onboard/sections.py create mode 100644 tests/test_onboard_sections.py diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py new file mode 100644 index 00000000..ceca8150 --- /dev/null +++ b/aai_cli/onboard/sections.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +import assemblyai as aai + +from aai_cli import client, config, environments, output, transcribe_render +from aai_cli.context import AppState, persist_browser_login +from aai_cli.errors import NotAuthenticated +from aai_cli.onboard import progress +from aai_cli.onboard.prompter import Prompter + + +class SectionResult(Enum): + DONE = "done" + SKIPPED = "skipped" + FAILED = "failed" + + +@dataclass +class WizardContext: + state: AppState + profile: str + json_mode: bool + + +def _has_key(profile: str) -> bool: + try: + config.resolve_api_key(profile=profile) + except NotAuthenticated: + return False + return True + + +def welcome(prompter: Prompter, ctx: WizardContext) -> SectionResult: + count = config.get_requests_made(ctx.profile) + if count: + prompter.section("Welcome back to AssemblyAI") + output.console.print(progress.render_progress(count)) + return SectionResult.DONE + prompter.section("Welcome to AssemblyAI") + prompter.note("This wizard signs you in, runs your first transcription, and helps you build.") + return SectionResult.DONE + + +def auth(prompter: Prompter, ctx: WizardContext) -> SectionResult: + if _has_key(ctx.profile): + prompter.note("Already signed in.") + return SectionResult.SKIPPED + prompter.section("Sign in") + method = prompter.select( + "How do you want to sign in?", + [("browser", "Sign in with your browser (recommended)"), ("key", "Paste an API key")], + default="browser", + ) + env = environments.active().name + if method == "key": + key = prompter.text("Paste your AssemblyAI API key") + if not client.validate_key(key): + output.console.print(output.fail("That key was rejected.")) + return SectionResult.FAILED + config.set_api_key(ctx.profile, key) + config.set_profile_env(ctx.profile, env) + return SectionResult.DONE + prompter.note(f"No account yet? Create one at {environments.active().signup_url}") + persist_browser_login(ctx.profile, env) + return SectionResult.DONE + + +def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("Your first transcription") + api_key = config.resolve_api_key(profile=ctx.profile) + with output.status("Transcribing the sample clip…", json_mode=ctx.json_mode): + transcript = client.transcribe( + api_key, client.SAMPLE_AUDIO_URL, config=aai.TranscriptionConfig() + ) + count = config.record_request(ctx.profile) + transcribe_render.render_transcript_result(transcript, output.console) + output.console.print(progress.render_progress(count)) + return SectionResult.DONE diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py new file mode 100644 index 00000000..eb730c5f --- /dev/null +++ b/tests/test_onboard_sections.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import pytest + +from aai_cli import client, config, transcribe_render +from aai_cli.context import AppState +from aai_cli.onboard import sections +from aai_cli.onboard.prompter import NonInteractivePrompter +from aai_cli.onboard.sections import SectionResult, WizardContext + + +class _FakeTranscript: + id = "t_1" + status = "completed" + text = "hello" + utterances = None + + +@pytest.fixture +def ctx() -> WizardContext: + return WizardContext(state=AppState(), profile="default", json_mode=False) + + +def test_auth_skips_when_key_already_present( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + assert sections.auth(NonInteractivePrompter(), ctx) is SectionResult.SKIPPED + + +def test_first_request_transcribes_sample_and_counts( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + monkeypatch.setattr(client, "transcribe", lambda *a, **k: _FakeTranscript()) + # Stub the rich render so a minimal fake transcript suffices; we're testing the + # counter + result, not rendering. + monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) + assert sections.first_request(NonInteractivePrompter(), ctx) is SectionResult.DONE + assert config.get_requests_made("default") == 1 From a092ff963021385e27be5b86230a714fff39cae9 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:35:45 -0700 Subject: [PATCH 10/37] fix(onboard): send rejected-key message to stderr Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/onboard/sections.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index ceca8150..b83f1a44 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -58,7 +58,7 @@ def auth(prompter: Prompter, ctx: WizardContext) -> SectionResult: if method == "key": key = prompter.text("Paste your AssemblyAI API key") if not client.validate_key(key): - output.console.print(output.fail("That key was rejected.")) + output.error_console.print(output.fail("That key was rejected.")) return SectionResult.FAILED config.set_api_key(ctx.profile, key) config.set_profile_env(ctx.profile, env) From 396e777472b6d4ce1e8083f032207e9d9c6131d8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:19:58 -0700 Subject: [PATCH 11/37] refactor(init): extract run_init for reuse by the onboarding wizard Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/commands/init.py | 110 +++++++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 39 deletions(-) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index d1ebbffe..1cefdc8f 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -163,6 +163,67 @@ def _launch(target: Path, *, port: int, use_uv: bool, no_open: bool, json_mode: raise typer.Exit(code=code) +def run_init( + state: AppState, + *, + template: str | None, + directory: str | None, + no_install: bool, + no_open: bool, + force: bool, + here: bool, + port: int, + json_mode: bool, + launch: bool = True, +) -> Path: + """Scaffold (and optionally install/launch) a template; return the target dir. + + `launch=False` is for callers like the onboarding wizard that must not block on a + running dev server — it stops after install and leaves the run command as a hint. + """ + if not json_mode: + # Vercel-style banner at the top of the run. + output.console.print( + f"[aai.heading]AssemblyAI CLI[/aai.heading] [aai.muted]{__version__}[/aai.muted]" + ) + chosen = _resolve_template(template) + target = _resolve_target(directory, chosen, here=here, force=force) + + api_key = keys.resolve_optional_api_key(profile=state.profile) + report = _scaffold_report(chosen, target, api_key) + + use_uv = runner.has_uv() + install_rows, will_launch = _install_step( + target, no_install=no_install, api_key=api_key, use_uv=use_uv + ) + report.extend(install_rows) + + # Deps are installed but there's no key, so the server can't start — say so + # rather than exiting silently. + if not no_install and api_key is None: + report.append( + { + "name": "launch", + "status": "skipped", + "detail": f"no API key; run `aai login`, then: cd {target} && uv run uvicorn api.index:app", + } + ) + + 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) + + if launch and will_launch: + _launch(target, port=port, use_uv=use_uv, no_open=no_open, json_mode=json_mode) + elif not json_mode: + # Scaffolded but not launched (no key, or --no-install, or launch=False): 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`.") + ) + return target + + @app.command( rich_help_panel=help_panels.QUICK_START, epilog=examples_epilog( @@ -201,45 +262,16 @@ def init( """ def body(state: AppState, json_mode: bool) -> None: - if not json_mode: - # Vercel-style banner at the top of the run. - output.console.print( - f"[aai.heading]AssemblyAI CLI[/aai.heading] [aai.muted]{__version__}[/aai.muted]" - ) - chosen = _resolve_template(template) - target = _resolve_target(directory, chosen, here=here, force=force) - - api_key = keys.resolve_optional_api_key(profile=state.profile) - report = _scaffold_report(chosen, target, api_key) - - use_uv = runner.has_uv() - install_rows, will_launch = _install_step( - target, no_install=no_install, api_key=api_key, use_uv=use_uv + run_init( + state, + template=template, + directory=directory, + no_install=no_install, + no_open=no_open, + force=force, + here=here, + port=port, + json_mode=json_mode, ) - report.extend(install_rows) - - # Deps are installed but there's no key, so the server can't start — say so - # rather than exiting silently. - if not no_install and api_key is None: - report.append( - { - "name": "launch", - "status": "skipped", - "detail": f"no API key; run `aai login`, then: cd {target} && uv run uvicorn api.index:app", - } - ) - - 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) - - if will_launch: - _launch(target, port=port, use_uv=use_uv, no_open=no_open, json_mode=json_mode) - 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) From 51cd3ee632249a93964742237aac8d7f3c655381 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:42:58 -0700 Subject: [PATCH 12/37] feat(onboard): environment, build-path, Claude Code, next-steps sections Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/onboard/sections.py | 69 ++++++++++++++++++++++++++++++++++ tests/test_onboard_sections.py | 28 ++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index b83f1a44..515026dc 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -6,6 +6,9 @@ import assemblyai as aai from aai_cli import client, config, environments, output, transcribe_render +from aai_cli.commands import doctor as doctor_cmd +from aai_cli.commands import init as init_cmd +from aai_cli.commands import setup as setup_cmd from aai_cli.context import AppState, persist_browser_login from aai_cli.errors import NotAuthenticated from aai_cli.onboard import progress @@ -79,3 +82,69 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: transcribe_render.render_transcript_result(transcript, output.console) output.console.print(progress.render_progress(count)) return SectionResult.DONE + + +_BUILD_CHOICES = [ + ("audio-transcription", "Transcribe audio files (web app)"), + ("live-captions", "Live captions from streaming audio"), + ("voice-agent", "A two-way voice agent"), + ("skip", "Just the CLI for now"), +] + + +def environment(prompter: Prompter, _ctx: WizardContext) -> SectionResult: + prompter.section("Environment check") + checks = [ + doctor_cmd._check_python(), # pyright: ignore[reportPrivateUsage] + doctor_cmd._check_ffmpeg(), # pyright: ignore[reportPrivateUsage] + doctor_cmd._check_audio(), # pyright: ignore[reportPrivateUsage] + ] + output.console.print(doctor_cmd._render({"ok": True, "checks": checks})) # pyright: ignore[reportPrivateUsage] + prompter.note("Warnings here only affect live streaming and the voice agent.") + return SectionResult.DONE + + +def build_path(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("What do you want to build?") + choice = prompter.select("Pick a starting point", _BUILD_CHOICES, default="skip") + if choice == "skip": + return SectionResult.SKIPPED + if not prompter.confirm(f"Scaffold the '{choice}' app now?", default=True): + prompter.note(f"You can run `aai init {choice}` whenever you're ready.") + return SectionResult.SKIPPED + # launch=False: never block the wizard on a running dev server. + init_cmd.run_init( + ctx.state, + template=choice, + directory=None, + no_install=False, + no_open=True, + force=False, + here=False, + port=3000, + json_mode=ctx.json_mode, + launch=False, + ) + return SectionResult.DONE + + +def claude_code(prompter: Prompter, _ctx: WizardContext) -> SectionResult: + prompter.section("Coding agent (optional)") + if not prompter.confirm("Wire up Claude Code (docs MCP + skills)?", default=False): + return SectionResult.SKIPPED + steps = [ + setup_cmd._install_mcp("user", force=False), # pyright: ignore[reportPrivateUsage] + setup_cmd._install_skill(force=False), # pyright: ignore[reportPrivateUsage] + setup_cmd._install_cli_skill(force=False), # pyright: ignore[reportPrivateUsage] + ] + output.console.print(setup_cmd._render({"steps": steps})) # pyright: ignore[reportPrivateUsage] + return SectionResult.DONE + + +def next_steps(prompter: Prompter, ctx: WizardContext) -> SectionResult: + prompter.section("You're set up") + output.console.print(progress.render_progress(config.get_requests_made(ctx.profile))) + output.console.print(output.hint("Transcribe a file: aai transcribe ")) + output.console.print(output.hint("Stream live audio: aai stream")) + output.console.print(output.hint("Build an app: aai init")) + return SectionResult.DONE diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index eb730c5f..5fad37be 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -1,8 +1,11 @@ from __future__ import annotations +from pathlib import Path + import pytest from aai_cli import client, config, transcribe_render +from aai_cli.commands import init as init_cmd from aai_cli.context import AppState from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter @@ -38,3 +41,28 @@ def test_first_request_transcribes_sample_and_counts( monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) assert sections.first_request(NonInteractivePrompter(), ctx) is SectionResult.DONE assert config.get_requests_made("default") == 1 + + +def test_environment_is_non_blocking(ctx: WizardContext) -> None: + # Even if checks warn/fail, the section never blocks the wizard. + assert sections.environment(NonInteractivePrompter(), ctx) is SectionResult.DONE + + +def test_build_path_skip_choice_does_nothing( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + called = False + + def _fake_run_init(*a: object, **k: object) -> Path: + nonlocal called + called = True + return Path() + + monkeypatch.setattr(init_cmd, "run_init", _fake_run_init) + # NonInteractivePrompter.select returns the default; build_path's default is "skip". + assert sections.build_path(NonInteractivePrompter(), ctx) is SectionResult.SKIPPED + assert called is False + + +def test_next_steps_renders_progress(ctx: WizardContext) -> None: + assert sections.next_steps(NonInteractivePrompter(), ctx) is SectionResult.DONE From 31c1737372811e0fdb196585cd5092df34b3187f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:57:53 -0700 Subject: [PATCH 13/37] fix(init): pin template requirements + gate against unpinned deps Template requirements.txt files listed bare package names (fastapi, uvicorn, httpx2, python-dotenv, ...). SCA scanners read a starter app's requirements.txt as a lockfile and report unpinned lines as "missing versions", blocking vulnerability analysis. Pin every template dependency to a >= floor matching the project's own (pyproject deps), and add a _requirements_pin_versions check to both the template contract gate (the check.sh stage) and the parametrized pytest contract test so a future unpinned dep fails loudly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../audio-transcription/requirements.txt | 10 ++++----- .../templates/live-captions/requirements.txt | 8 +++---- .../templates/voice-agent/requirements.txt | 8 +++---- scripts/template_contract_gate.py | 22 +++++++++++++++++++ tests/test_init_template_contract.py | 21 ++++++++++++++++++ 5 files changed, 56 insertions(+), 13 deletions(-) diff --git a/aai_cli/init/templates/audio-transcription/requirements.txt b/aai_cli/init/templates/audio-transcription/requirements.txt index 67a3b4c6..1a4e5e15 100644 --- a/aai_cli/init/templates/audio-transcription/requirements.txt +++ b/aai_cli/init/templates/audio-transcription/requirements.txt @@ -1,6 +1,6 @@ -fastapi -uvicorn +fastapi>=0.115.0 +uvicorn>=0.30.0 assemblyai>=0.64,<1 -python-dotenv -python-multipart -openai +python-dotenv>=1.0.0 +python-multipart>=0.0.9 +openai>=2.41.0 diff --git a/aai_cli/init/templates/live-captions/requirements.txt b/aai_cli/init/templates/live-captions/requirements.txt index 57a6fd76..357ffaa2 100644 --- a/aai_cli/init/templates/live-captions/requirements.txt +++ b/aai_cli/init/templates/live-captions/requirements.txt @@ -1,4 +1,4 @@ -fastapi -uvicorn -httpx2 -python-dotenv +fastapi>=0.115.0 +uvicorn>=0.30.0 +httpx2>=2.0.0 +python-dotenv>=1.0.0 diff --git a/aai_cli/init/templates/voice-agent/requirements.txt b/aai_cli/init/templates/voice-agent/requirements.txt index 57a6fd76..357ffaa2 100644 --- a/aai_cli/init/templates/voice-agent/requirements.txt +++ b/aai_cli/init/templates/voice-agent/requirements.txt @@ -1,4 +1,4 @@ -fastapi -uvicorn -httpx2 -python-dotenv +fastapi>=0.115.0 +uvicorn>=0.30.0 +httpx2>=2.0.0 +python-dotenv>=1.0.0 diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index 61062c6d..bdec3940 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -108,6 +108,27 @@ def _requirements_cover_imports(template: str, path: Path) -> None: _fail(f"{template}: import {package!r} ({dist}) missing from requirements.txt") +_SPECIFIER = re.compile(r"(===|==|~=|!=|>=|<=|>|<)") + + +def _requirements_pin_versions(template: str, path: Path) -> None: + """Every requirement must carry a version specifier. + + SCA scanners read a starter app's requirements.txt as a lockfile; an unpinned + line like ``fastapi`` reports as a missing version and blocks vulnerability + analysis. Require a specifier (``>=`` floor, ``==`` pin, ...) on every line. + """ + unpinned: list[str] = [] + for raw in (path / "requirements.txt").read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + if not _SPECIFIER.search(line.split(";", 1)[0]): # ignore any env marker + unpinned.append(line) + if unpinned: + _fail(f"{template}: requirements.txt has unpinned dependencies {unpinned}") + + @contextmanager def _template_import_path(path: Path): old_path = list(sys.path) @@ -173,6 +194,7 @@ def main() -> int: _html_static_refs(template, path) _frontend_routes(template, path) _requirements_cover_imports(template, path) + _requirements_pin_versions(template, path) _parse_python_files(path) _import_api(template, path) sys.stdout.write(f"validated {len(templates.TEMPLATE_ORDER)} init templates\n") diff --git a/tests/test_init_template_contract.py b/tests/test_init_template_contract.py index 3f001761..850317f5 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -104,6 +104,27 @@ def test_requirements_cover_backend_imports(template_dir) -> None: ) +def test_requirements_pin_versions(template_dir) -> None: + """Every dependency in requirements.txt carries a version specifier. + + SCA scanners read a starter app's requirements.txt as a lockfile: an unpinned + line like ``fastapi`` reports as a missing version and blocks vulnerability + analysis. Require a specifier (``>=`` floor, ``==`` pin, ...) on every line. + """ + specifier = re.compile(r"(===|==|~=|!=|>=|<=|>|<)") + unpinned: list[str] = [] + for raw in (template_dir / "requirements.txt").read_text().splitlines(): + line = raw.split("#", 1)[0].strip() + if not line: + continue + if not specifier.search(line.split(";", 1)[0]): # ignore any env marker + unpinned.append(line) + assert not unpinned, ( + f"{template_dir.name}: requirements.txt has unpinned dependencies {unpinned}; " + f"each needs a version specifier so SCA scanners can analyze the lockfile" + ) + + def test_status_endpoint_does_not_block(template_dir): """Guard against the blocking SDK call: a poll endpoint must not wait_for_completion.""" src = (template_dir / "api" / "index.py").read_text() From 3877436f72108b5aac23b3f03b409a0524a0ecf8 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 16:58:22 -0700 Subject: [PATCH 14/37] fix(onboard): harden build_path/claude_code failures and cover all sections --- aai_cli/onboard/sections.py | 33 +++++---- tests/test_onboard_sections.py | 120 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 13 deletions(-) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 515026dc..c3e76427 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -4,13 +4,14 @@ from enum import Enum import assemblyai as aai +import typer from aai_cli import client, config, environments, output, transcribe_render from aai_cli.commands import doctor as doctor_cmd from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd from aai_cli.context import AppState, persist_browser_login -from aai_cli.errors import NotAuthenticated +from aai_cli.errors import CLIError, NotAuthenticated from aai_cli.onboard import progress from aai_cli.onboard.prompter import Prompter @@ -113,18 +114,22 @@ def build_path(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.note(f"You can run `aai init {choice}` whenever you're ready.") return SectionResult.SKIPPED # launch=False: never block the wizard on a running dev server. - init_cmd.run_init( - ctx.state, - template=choice, - directory=None, - no_install=False, - no_open=True, - force=False, - here=False, - port=3000, - json_mode=ctx.json_mode, - launch=False, - ) + try: + init_cmd.run_init( + ctx.state, + template=choice, + directory=None, + no_install=False, + no_open=True, + force=False, + here=False, + port=3000, + json_mode=ctx.json_mode, + launch=False, + ) + except (CLIError, typer.Exit): + output.error_console.print(output.fail(f"Could not scaffold '{choice}'.")) + return SectionResult.FAILED return SectionResult.DONE @@ -138,6 +143,8 @@ def claude_code(prompter: Prompter, _ctx: WizardContext) -> SectionResult: setup_cmd._install_cli_skill(force=False), # pyright: ignore[reportPrivateUsage] ] output.console.print(setup_cmd._render({"steps": steps})) # pyright: ignore[reportPrivateUsage] + if any(s["status"] == "failed" for s in steps): + return SectionResult.FAILED return SectionResult.DONE diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index 5fad37be..8b189ba8 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -3,13 +3,16 @@ from pathlib import Path import pytest +import typer from aai_cli import client, config, transcribe_render from aai_cli.commands import init as init_cmd +from aai_cli.commands import setup as setup_cmd from aai_cli.context import AppState from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter from aai_cli.onboard.sections import SectionResult, WizardContext +from aai_cli.steps import Step class _FakeTranscript: @@ -19,6 +22,32 @@ class _FakeTranscript: utterances = None +class _ScriptedPrompter: + """A Prompter test-double whose answers are pinned at construction time.""" + + def __init__(self, *, select: str = "skip", confirm: bool = True, text: str = "k") -> None: + self._select = select + self._confirm = confirm + self._text = text + + def section(self, title: str) -> None: + pass + + def note(self, message: str) -> None: + pass + + def confirm(self, title: str, *, default: bool = True) -> bool: + return self._confirm + + def select( + self, title: str, options: list[tuple[str, str]], *, default: str | None = None + ) -> str: + return self._select + + def text(self, title: str, *, default: str | None = None) -> str: + return self._text + + @pytest.fixture def ctx() -> WizardContext: return WizardContext(state=AppState(), profile="default", json_mode=False) @@ -66,3 +95,94 @@ def _fake_run_init(*a: object, **k: object) -> Path: def test_next_steps_renders_progress(ctx: WizardContext) -> None: assert sections.next_steps(NonInteractivePrompter(), ctx) is SectionResult.DONE + + +def test_welcome_returning_user(ctx: WizardContext) -> None: + config.record_request("default") + assert sections.welcome(NonInteractivePrompter(), ctx) is SectionResult.DONE + + +def test_welcome_cold_start(ctx: WizardContext) -> None: + assert sections.welcome(NonInteractivePrompter(), ctx) is SectionResult.DONE + + +def test_auth_browser_path(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sections, "persist_browser_login", lambda *a, **k: None) + assert sections.auth(_ScriptedPrompter(select="browser"), ctx) is SectionResult.DONE + + +def test_auth_key_path_valid(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(client, "validate_key", lambda *a, **k: True) + result = sections.auth(_ScriptedPrompter(select="key", text="sk_good"), ctx) + assert result is SectionResult.DONE + assert config.get_api_key("default") == "sk_good" + + +def test_auth_key_path_rejected(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(client, "validate_key", lambda *a, **k: False) + result = sections.auth(_ScriptedPrompter(select="key", text="sk_bad"), ctx) + assert result is SectionResult.FAILED + + +def test_build_path_scaffolds(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + calls = 0 + + def _fake_run_init(*a: object, **k: object) -> Path: + nonlocal calls + calls += 1 + return Path() + + monkeypatch.setattr(init_cmd, "run_init", _fake_run_init) + result = sections.build_path(_ScriptedPrompter(select="audio-transcription", confirm=True), ctx) + assert result is SectionResult.DONE + assert calls == 1 + + +def test_build_path_declined_after_select( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + called = False + + def _fake_run_init(*a: object, **k: object) -> Path: + nonlocal called + called = True + return Path() + + monkeypatch.setattr(init_cmd, "run_init", _fake_run_init) + result = sections.build_path(_ScriptedPrompter(select="voice-agent", confirm=False), ctx) + assert result is SectionResult.SKIPPED + assert called is False + + +def test_build_path_run_init_failure(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + def _boom(*a: object, **k: object) -> Path: + raise typer.Exit(code=1) + + monkeypatch.setattr(init_cmd, "run_init", _boom) + result = sections.build_path(_ScriptedPrompter(select="live-captions", confirm=True), ctx) + assert result is SectionResult.FAILED + + +def test_claude_code_skipped(ctx: WizardContext) -> None: + assert sections.claude_code(NonInteractivePrompter(), ctx) is SectionResult.SKIPPED + + +def _passing_step(*a: object, **k: object) -> Step: + return {"name": "x", "status": "installed", "detail": "ok"} + + +def test_claude_code_done(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(setup_cmd, "_install_mcp", _passing_step) + monkeypatch.setattr(setup_cmd, "_install_skill", _passing_step) + monkeypatch.setattr(setup_cmd, "_install_cli_skill", _passing_step) + assert sections.claude_code(_ScriptedPrompter(confirm=True), ctx) is SectionResult.DONE + + +def test_claude_code_failed(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + def _failing_step(*a: object, **k: object) -> Step: + return {"name": "x", "status": "failed", "detail": "no npx"} + + monkeypatch.setattr(setup_cmd, "_install_mcp", _passing_step) + monkeypatch.setattr(setup_cmd, "_install_skill", _failing_step) + monkeypatch.setattr(setup_cmd, "_install_cli_skill", _passing_step) + assert sections.claude_code(_ScriptedPrompter(confirm=True), ctx) is SectionResult.FAILED From 75d987a4384ad61fd301cb23d13f6bb3b27e3dd3 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:00:24 -0700 Subject: [PATCH 15/37] feat(onboard): wizard orchestrator with auth hard-stop and clean cancel Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/onboard/wizard.py | 34 +++++++++++++++++++++++ tests/test_onboard_wizard.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 aai_cli/onboard/wizard.py create mode 100644 tests/test_onboard_wizard.py diff --git a/aai_cli/onboard/wizard.py b/aai_cli/onboard/wizard.py new file mode 100644 index 00000000..197a8ed7 --- /dev/null +++ b/aai_cli/onboard/wizard.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from aai_cli import output +from aai_cli.errors import NotAuthenticated +from aai_cli.onboard import sections +from aai_cli.onboard.prompter import Prompter, WizardCancelled +from aai_cli.onboard.sections import SectionResult, WizardContext + + +def run_onboarding(prompter: Prompter, ctx: WizardContext) -> int: + """Run the ordered sections; return a process exit code. + + Auth is the one hard stop (no key → later sections can't run). Cancellation + (Ctrl-C / empty pick) exits cleanly. The terminal cursor is always restored. + """ + try: + sections.welcome(prompter, ctx) + if sections.auth(prompter, ctx) is SectionResult.FAILED: + output.error_console.print( + output.fail("Could not sign in. Run `aai onboard` again to retry.") + ) + return NotAuthenticated().exit_code + sections.first_request(prompter, ctx) + sections.environment(prompter, ctx) + sections.build_path(prompter, ctx) + sections.claude_code(prompter, ctx) + sections.next_steps(prompter, ctx) + except WizardCancelled: + output.error_console.print(output.hint("Setup cancelled. Run `aai onboard` to resume.")) + return 130 + else: + return 0 + finally: + output.console.show_cursor(show=True) diff --git a/tests/test_onboard_wizard.py b/tests/test_onboard_wizard.py new file mode 100644 index 00000000..c555f62e --- /dev/null +++ b/tests/test_onboard_wizard.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from aai_cli.context import AppState +from aai_cli.onboard import sections, wizard +from aai_cli.onboard.prompter import NonInteractivePrompter, WizardCancelled +from aai_cli.onboard.sections import SectionResult, WizardContext + + +@pytest.fixture +def ctx() -> WizardContext: + return WizardContext(state=AppState(), profile="default", json_mode=False) + + +def test_auth_failure_stops_the_wizard(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sections, "welcome", lambda p, c: SectionResult.DONE) + monkeypatch.setattr(sections, "auth", lambda p, c: SectionResult.FAILED) + ran_after = False + + def _first(p: object, c: object) -> SectionResult: + nonlocal ran_after + ran_after = True + return SectionResult.DONE + + monkeypatch.setattr(sections, "first_request", _first) + code = wizard.run_onboarding(NonInteractivePrompter(), ctx) + assert code == 4 # NotAuthenticated exit code + assert ran_after is False + + +def test_happy_path_runs_all_sections(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "welcome", + "auth", + "first_request", + "environment", + "build_path", + "claude_code", + "next_steps", + ): + monkeypatch.setattr(sections, name, lambda p, c: SectionResult.DONE) + assert wizard.run_onboarding(NonInteractivePrompter(), ctx) == 0 + + +def test_cancel_returns_130(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sections, "welcome", lambda p, c: SectionResult.DONE) + + def _cancel(p: object, c: object) -> SectionResult: + raise WizardCancelled + + monkeypatch.setattr(sections, "auth", _cancel) + assert wizard.run_onboarding(NonInteractivePrompter(), ctx) == 130 From e39885e15acd621b813fb4c4c31f922a0843fb28 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:07:12 -0700 Subject: [PATCH 16/37] feat(onboard): aai onboard command with --status Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/commands/onboard.py | 56 +++++++++++++++++++++++++++++++++++ aai_cli/main.py | 2 ++ tests/test_onboard_command.py | 45 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 aai_cli/commands/onboard.py create mode 100644 tests/test_onboard_command.py diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py new file mode 100644 index 00000000..bbb3ce30 --- /dev/null +++ b/aai_cli/commands/onboard.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import sys + +import typer + +from aai_cli import config, help_panels, output +from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.help_text import examples_epilog +from aai_cli.onboard import progress, wizard +from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter, Prompter +from aai_cli.onboard.sections import WizardContext + +app = typer.Typer() + + +def _build_prompter() -> Prompter: + """A real prompter only when both ends are a TTY; otherwise never block.""" + if sys.stdin.isatty() and sys.stdout.isatty(): + return InteractivePrompter() + return NonInteractivePrompter() + + +@app.command( + rich_help_panel=help_panels.QUICK_START, + epilog=examples_epilog( + [ + ("Run the guided setup", "aai onboard"), + ("Show your progress toward 100 requests", "aai onboard --status"), + ] + ), +) +def onboard( + ctx: typer.Context, + status: bool = typer.Option(False, "--status", help="Show request progress and exit."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Guided setup: sign in, run your first transcription, and start building.""" + + def body(state: AppState, json_mode: bool) -> None: + profile = resolve_profile(state) + if status: + count = config.get_requests_made(profile) + output.emit( + {"requests_made": count, "goal": progress.GOAL}, + lambda _d: progress.render_progress(count), + json_mode=json_mode, + ) + return + wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode) + code = wizard.run_onboarding(_build_prompter(), wiz_ctx) + if code != 0: + raise typer.Exit(code=code) + + # auto_login=False: the wizard owns the sign-in step itself. + run_command(ctx, body, json=json_out, auto_login=False) diff --git a/aai_cli/main.py b/aai_cli/main.py index e9e406ee..2e666d9d 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -21,6 +21,7 @@ keys, llm, login, + onboard, samples, sessions, setup, @@ -164,6 +165,7 @@ def main( app.add_typer(doctor.app) app.add_typer(samples.app, name="samples", rich_help_panel=help_panels.SETUP) app.add_typer(init.app) +app.add_typer(onboard.app) app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP) app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py new file mode 100644 index 00000000..57c0c96c --- /dev/null +++ b/tests/test_onboard_command.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.commands import onboard as onboard_cmd +from aai_cli.main import app + + +def test_status_shows_progress_without_running_wizard() -> None: + config.record_request("default") + config.record_request("default") + result = CliRunner().invoke(app, ["onboard", "--status"]) + assert result.exit_code == 0, result.output + assert "2 of 100" in result.output + + +def test_onboard_is_listed_in_help() -> None: + result = CliRunner().invoke(app, ["--help"]) + assert "onboard" in result.output + + +def test_onboard_runs_wizard_and_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(onboard_cmd.wizard, "run_onboarding", lambda p, c: 0) + result = CliRunner().invoke(app, ["onboard"]) + assert result.exit_code == 0, result.output + + +def test_onboard_propagates_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(onboard_cmd.wizard, "run_onboarding", lambda p, c: 4) + result = CliRunner().invoke(app, ["onboard"]) + assert result.exit_code == 4 + + +def test_build_prompter_interactive(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + assert isinstance(onboard_cmd._build_prompter(), onboard_cmd.InteractivePrompter) + + +def test_build_prompter_noninteractive(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + assert isinstance(onboard_cmd._build_prompter(), onboard_cmd.NonInteractivePrompter) From ed9f5e586959c620fd79d84193e85913837a8961 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:21:37 -0700 Subject: [PATCH 17/37] feat(onboard): list onboard first and offer it on a bare first run Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/onboard.py | 4 +- aai_cli/help_panels.py | 2 +- aai_cli/main.py | 43 ++++++++++++++++++--- tests/test_onboard_command.py | 70 ++++++++++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py index bbb3ce30..f7205550 100644 --- a/aai_cli/commands/onboard.py +++ b/aai_cli/commands/onboard.py @@ -14,7 +14,7 @@ app = typer.Typer() -def _build_prompter() -> Prompter: +def build_prompter() -> Prompter: """A real prompter only when both ends are a TTY; otherwise never block.""" if sys.stdin.isatty() and sys.stdout.isatty(): return InteractivePrompter() @@ -48,7 +48,7 @@ def body(state: AppState, json_mode: bool) -> None: ) return wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode) - code = wizard.run_onboarding(_build_prompter(), wiz_ctx) + code = wizard.run_onboarding(build_prompter(), wiz_ctx) if code != 0: raise typer.Exit(code=code) diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index 76319c6d..5a26e23e 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -12,7 +12,7 @@ from __future__ import annotations -QUICK_START = "Quick Start" # zero-to-running onboarding: init +QUICK_START = "Quick Start" # zero-to-running onboarding: onboard, init TRANSCRIPTION = "Transcription & AI" # the verbs you run: transcribe, stream, agent, llm HISTORY = "History" # browse past work: transcripts, sessions ACCOUNT = "Account" # auth, billing, keys: login/logout/whoami, balance/usage/limits, keys, audit diff --git a/aai_cli/main.py b/aai_cli/main.py index 2e666d9d..1358709b 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -11,7 +11,7 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from aai_cli import __version__, environments, help_panels, output, stdio +from aai_cli import __version__, config, environments, help_panels, output, stdio from aai_cli.commands import ( account, agent, @@ -30,8 +30,10 @@ transcripts, ) from aai_cli.context import AppState, env_override_warning, resolve_environment -from aai_cli.errors import CLIError +from aai_cli.errors import CLIError, NotAuthenticated from aai_cli.help_text import examples_epilog +from aai_cli.onboard import wizard +from aai_cli.onboard.sections import WizardContext # The order commands appear under `aai --help`. Commands are grouped into named # Rich panels (see `help_panels.py`); panels render in the order their first @@ -39,6 +41,7 @@ # most-common-first. Names not listed fall to the end, sorted alphabetically. _COMMAND_ORDER = ( # Quick Start — zero-to-running onboarding + "onboard", "init", # Setup & Tools — get set up & maintain; `version` last "samples", @@ -82,7 +85,6 @@ def list_commands(self, ctx: ClickContext) -> list[str]: app = typer.Typer( name="aai", help="AssemblyAI from your terminal — transcribe, stream, and build voice AI.", - no_args_is_help=True, # `aai --install-completion` / `--show-completion` for bash/zsh/fish/PowerShell, # the discoverability affordance gh/kubectl/docker users reach for. add_completion=True, @@ -99,14 +101,43 @@ def _version_callback(value: bool) -> None: raise typer.Exit() +def _profile_has_key(state: AppState) -> bool: + try: + config.resolve_api_key(profile=state.profile) + except NotAuthenticated: + return False + return True + + +def _interactive_session() -> bool: + """True only when both ends are a real TTY (so we never block a piped/CI run).""" + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _offer_or_help(ctx: typer.Context, state: AppState) -> None: + """No subcommand given: offer guided setup to a credential-less, interactive user; + otherwise print help. Never prompts in a non-interactive session, and never on + `--help` (Click handles that eagerly before the callback).""" + if ( + _interactive_session() + and not _profile_has_key(state) + and typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True) + ): + wiz_ctx = WizardContext(state=state, profile=state.resolve_profile(), json_mode=False) + raise typer.Exit(code=wizard.run_onboarding(onboard.build_prompter(), wiz_ctx)) + typer.echo(ctx.get_help()) + raise typer.Exit() + + @app.callback( + invoke_without_command=True, epilog=examples_epilog( [ - ("Sign in with your browser", "aai login"), + ("Guided setup (start here)", "aai onboard"), ("Transcribe a file", "aai transcribe call.mp3"), ("Scaffold a starter app", "aai init"), ] - ) + ), ) def main( ctx: typer.Context, @@ -147,6 +178,8 @@ def main( warning = env_override_warning(state) if warning and not quiet: output.error_console.print(output.warn(warning)) + if ctx.invoked_subcommand is None: + _offer_or_help(ctx, state) # Help-panel grouping: named sub-typers carry their panel on `add_typer`; merged diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index 57c0c96c..8b50f3cf 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -36,10 +36,76 @@ def test_onboard_propagates_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> Non def test_build_prompter_interactive(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - assert isinstance(onboard_cmd._build_prompter(), onboard_cmd.InteractivePrompter) + assert isinstance(onboard_cmd.build_prompter(), onboard_cmd.InteractivePrompter) def test_build_prompter_noninteractive(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin.isatty", lambda: False) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - assert isinstance(onboard_cmd._build_prompter(), onboard_cmd.NonInteractivePrompter) + assert isinstance(onboard_cmd.build_prompter(), onboard_cmd.NonInteractivePrompter) + + +def test_onboard_sorts_first_in_quick_start() -> None: + result = CliRunner().invoke(app, ["--help"]) + assert result.output.index("onboard") < result.output.index("init") + + +def test_bare_aai_with_key_shows_help_no_offer(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert "Usage" in result.output or "Commands" in result.output + + +def test_bare_aai_offers_wizard_when_no_key(monkeypatch: pytest.MonkeyPatch) -> None: + from aai_cli import main as main_mod + + monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr(main_mod.typer, "confirm", lambda *a, **k: True) + ran = {"called": False} + + def _fake_run(prompter: object, ctx: object) -> int: + ran["called"] = True + return 0 + + monkeypatch.setattr(main_mod.wizard, "run_onboarding", _fake_run) + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert ran["called"] is True + + +def test_bare_aai_interactive_with_key_shows_help_no_offer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from aai_cli import main as main_mod + + # Interactive session but a key is already present: _profile_has_key returns True, + # so the wizard is never offered and help is printed instead. + monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + called = {"confirm": False} + monkeypatch.setattr( + main_mod.typer, "confirm", lambda *a, **k: called.__setitem__("confirm", True) + ) + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert called["confirm"] is False + assert "Usage" in result.output or "Commands" in result.output + + +def test_bare_aai_declined_offer_shows_help(monkeypatch: pytest.MonkeyPatch) -> None: + from aai_cli import main as main_mod + + monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr(main_mod.typer, "confirm", lambda *a, **k: False) + called = {"v": False} + + def _fake_run(prompter: object, ctx: object) -> int: + called["v"] = True + return 0 + + monkeypatch.setattr(main_mod.wizard, "run_onboarding", _fake_run) + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert called["v"] is False + assert "Usage" in result.output or "Commands" in result.output From 415440a34c9e18f9ec6a2f2aa9076312df4319d5 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:24:44 -0700 Subject: [PATCH 18/37] test(onboard): expect onboard first in smoke command-order check Co-Authored-By: Claude Sonnet 4.6 --- tests/test_smoke.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c2741ae0..3e4d3d51 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -79,6 +79,7 @@ def test_help_lists_commands_in_workflow_order(): # Setup & Tools, Transcription & AI, History, then Account. assert names == [ # Quick Start + "onboard", "init", # Setup & Tools "samples", From 74c2a7966a290731d622e1a2751b03964fd7fe3f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:25:43 -0700 Subject: [PATCH 19/37] docs(onboard): route post-install and post-login hints to aai onboard Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/commands/login.py | 2 +- install.sh | 2 +- tests/test_install_sh.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index d0e3aef5..eb53e4e4 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -52,7 +52,7 @@ def body(state: AppState, json_mode: bool) -> None: lambda _d: ( output.success(f"Signed in as {escape(profile)} ({escape(env)}).") + "\n" - + output.hint("Run `aai transcribe ` to make your first transcript.") + + output.hint("Run `aai onboard` to finish setup, or `aai transcribe `.") ), json_mode=json_mode, ) diff --git a/install.sh b/install.sh index 2d9ffc06..65af4de0 100755 --- a/install.sh +++ b/install.sh @@ -38,7 +38,7 @@ fi # --- Next steps ----------------------------------------------------------- if command -v aai >/dev/null 2>&1; then - info "Installed. Next: run 'aai login', then 'aai transcribe --sample'." + info "Installed. Next: run 'aai onboard'." else info "Installed, but 'aai' isn't on your PATH yet." info "Run 'pipx ensurepath' (or add ~/.local/bin to PATH), then restart your shell." diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index b3754b43..37cab2b3 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -123,4 +123,4 @@ def test_next_steps_when_aai_present(tmp_path): _pipx_shim(tmp_path) _shim(tmp_path / "aai", "exit 0\n") result = _run(tmp_path) - assert "Installed. Next: run 'aai login'" in result.stdout + assert "Installed. Next: run 'aai onboard'" in result.stdout From 5a4412f2494a5036a74e0c0d49bf67f4c8f4f51f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:28:17 -0700 Subject: [PATCH 20/37] test(onboard): cover InteractivePrompter for full patch coverage Co-Authored-By: Claude Sonnet 4.6 --- tests/test_onboard_prompter.py | 52 +++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/test_onboard_prompter.py b/tests/test_onboard_prompter.py index dfb48c4e..d25de7ca 100644 --- a/tests/test_onboard_prompter.py +++ b/tests/test_onboard_prompter.py @@ -3,7 +3,13 @@ import pytest from aai_cli.errors import UsageError -from aai_cli.onboard.prompter import NonInteractivePrompter +from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter, WizardCancelled + + +def test_noninteractive_section_and_note() -> None: + p = NonInteractivePrompter() + p.section("Setup") # exercises NonInteractivePrompter.section() + p.note("a hint") # exercises NonInteractivePrompter.note() def test_noninteractive_confirm_returns_default() -> None: @@ -24,3 +30,47 @@ def test_noninteractive_text_requires_default() -> None: assert p.text("Name?", default="x") == "x" with pytest.raises(UsageError): p.text("Name?") + + +def test_interactive_section_and_note() -> None: + p = InteractivePrompter() + p.section("Heading") # exercises section() + p.note("a hint") # exercises note() + + +def test_interactive_confirm_delegates_to_typer(monkeypatch: pytest.MonkeyPatch) -> None: + import aai_cli.onboard.prompter as pm + + monkeypatch.setattr(pm.typer, "confirm", lambda *a, **k: True) + assert InteractivePrompter().confirm("ok?", default=True) is True + + +def test_interactive_text_delegates_to_typer(monkeypatch: pytest.MonkeyPatch) -> None: + import aai_cli.onboard.prompter as pm + + monkeypatch.setattr(pm.typer, "prompt", lambda *a, **k: "typed") + assert InteractivePrompter().text("name", default="d") == "typed" + + +def test_interactive_select_returns_chosen_value(monkeypatch: pytest.MonkeyPatch) -> None: + import questionary + + class _Q: + def ask(self) -> str: + return "b" + + monkeypatch.setattr(questionary, "select", lambda *a, **k: _Q()) + result = InteractivePrompter().select("Pick", [("a", "A"), ("b", "B")], default="a") + assert result == "b" + + +def test_interactive_select_cancel_raises(monkeypatch: pytest.MonkeyPatch) -> None: + import questionary + + class _QNone: + def ask(self) -> None: + return None + + monkeypatch.setattr(questionary, "select", lambda *a, **k: _QNone()) + with pytest.raises(WizardCancelled): + InteractivePrompter().select("Pick", [("a", "A")]) From 252fe4d422eaa3f64a43230418d5becf5079d935 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:57:34 -0700 Subject: [PATCH 21/37] test(onboard): regenerate CLI help snapshot for aai onboard Adds the per-command help snapshot for the new `aai onboard` command. The root --help ordering and login-success hint are pinned by assertion-based tests, not syrupy, so only this one snapshot changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_cli_output_snapshots.ambr | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index e867dca5..4bde67bb 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -351,6 +351,29 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[onboard] + ''' + + Usage: aai onboard [OPTIONS] + + Guided setup: sign in, run your first transcription, and start building. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --status Show request progress and exit. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Run the guided setup + $ aai onboard + Show your progress toward 100 requests + $ aai onboard --status + + + ''' # --- # name: test_command_help_matches_snapshot[samples_create] From 2e80734bfadecdc7737927d89b3d924e19bdf03d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 17:57:43 -0700 Subject: [PATCH 22/37] test(onboard): satisfy mypy reexport and mutation-gate on new code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate fixes for the onboard work: - mypy strict (no_implicit_reexport): switch monkeypatch targets to the string-target form (e.g. "aai_cli.main.typer.confirm") and import the prompter classes from their source module instead of re-importing them through the command module. - mutation gate (diff-scoped): add assertions that kill the surviving mutants on changed lines — pin the onboard exit-code guard (code != 0), auto_login=False, the request-counter `or 0` fallback, _interactive_session's `and`, the bare-aai offer's confirm default and json_mode, the prompter confirm defaults, cursor restore (show=True), the exact run_init/claude-setup kwargs, the doctor render `ok=True`, and init's `api_key is None` launch guard (via a --json step assertion). One genuinely-equivalent mutant — the Prompter Protocol stub default — is marked `# pragma: no mutate` (not a forbidden escape hatch). Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/onboard/prompter.py | 2 +- tests/test_init_command.py | 16 +++++++ tests/test_onboard_command.py | 81 +++++++++++++++++++++++++++++----- tests/test_onboard_progress.py | 7 +++ tests/test_onboard_prompter.py | 19 +++++--- tests/test_onboard_sections.py | 53 +++++++++++++++++++--- tests/test_onboard_wizard.py | 15 +++++++ 7 files changed, 171 insertions(+), 22 deletions(-) diff --git a/aai_cli/onboard/prompter.py b/aai_cli/onboard/prompter.py index 177375e4..fe861ec4 100644 --- a/aai_cli/onboard/prompter.py +++ b/aai_cli/onboard/prompter.py @@ -17,7 +17,7 @@ class Prompter(Protocol): def section(self, title: str) -> None: ... def note(self, message: str) -> None: ... - def confirm(self, title: str, *, default: bool = True) -> bool: ... + def confirm(self, title: str, *, default: bool = True) -> bool: ... # pragma: no mutate def select( self, title: str, options: list[tuple[str, str]], *, default: str | None = None ) -> str: ... diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 2b7bf534..6ec20d62 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -60,6 +60,22 @@ def test_init_logged_out_installs_but_skips_launch_with_hint(tmp_path, monkeypat assert "uvicorn api.index" in result.output +def test_init_logged_out_install_emits_launch_skipped_step_json(tmp_path, monkeypatch): + # In --json mode the only signal for the no-key launch guard is the structured + # "launch"/"skipped" step (human mode prints the uvicorn hint regardless), so this + # specifically pins the `api_key is None` half of the guard. + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 0, "", ""), + ) + result = runner.invoke(app, ["init", TEMPLATE, "app", "--json"]) # install, logged out + assert result.exit_code == 0, result.output + assert '"name": "launch"' in result.output + assert '"status": "skipped"' in result.output + assert "no API key" in result.output + + def test_init_writes_base_url_for_active_env(tmp_path, monkeypatch): # The generated .env pins the app to the CLI's active environment hosts. monkeypatch.chdir(tmp_path) diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index 8b50f3cf..7ebbf037 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -6,6 +6,7 @@ from aai_cli import config from aai_cli.commands import onboard as onboard_cmd from aai_cli.main import app +from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter def test_status_shows_progress_without_running_wizard() -> None: @@ -22,27 +23,50 @@ def test_onboard_is_listed_in_help() -> None: def test_onboard_runs_wizard_and_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(onboard_cmd.wizard, "run_onboarding", lambda p, c: 0) + monkeypatch.setattr("aai_cli.commands.onboard.wizard.run_onboarding", lambda p, c: 0) result = CliRunner().invoke(app, ["onboard"]) assert result.exit_code == 0, result.output def test_onboard_propagates_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(onboard_cmd.wizard, "run_onboarding", lambda p, c: 4) + monkeypatch.setattr("aai_cli.commands.onboard.wizard.run_onboarding", lambda p, c: 4) result = CliRunner().invoke(app, ["onboard"]) assert result.exit_code == 4 +def test_onboard_propagates_exit_code_one(monkeypatch: pytest.MonkeyPatch) -> None: + # Exit code 1 specifically pins the `code != 0` guard: a `!= 1` mutant would + # swallow this and exit 0 instead. + monkeypatch.setattr("aai_cli.commands.onboard.wizard.run_onboarding", lambda p, c: 1) + result = CliRunner().invoke(app, ["onboard"]) + assert result.exit_code == 1 + + +def test_onboard_does_not_auto_login_on_auth_error(monkeypatch: pytest.MonkeyPatch) -> None: + # auto_login=False: an unauthenticated wizard surfaces the auth error (exit 4) + # rather than kicking off a browser login. A True mutant would instead try to + # log in and never exit 4 here. + from aai_cli.errors import NotAuthenticated + + def _raise(p: object, c: object) -> int: + raise NotAuthenticated("nope") + + monkeypatch.setattr("aai_cli.commands.onboard.wizard.run_onboarding", _raise) + result = CliRunner().invoke(app, ["onboard"]) + assert result.exit_code == 4 + assert "browser login" not in result.output + + def test_build_prompter_interactive(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin.isatty", lambda: True) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - assert isinstance(onboard_cmd.build_prompter(), onboard_cmd.InteractivePrompter) + assert isinstance(onboard_cmd.build_prompter(), InteractivePrompter) def test_build_prompter_noninteractive(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr("sys.stdin.isatty", lambda: False) monkeypatch.setattr("sys.stdout.isatty", lambda: True) - assert isinstance(onboard_cmd.build_prompter(), onboard_cmd.NonInteractivePrompter) + assert isinstance(onboard_cmd.build_prompter(), NonInteractivePrompter) def test_onboard_sorts_first_in_quick_start() -> None: @@ -50,6 +74,21 @@ def test_onboard_sorts_first_in_quick_start() -> None: assert result.output.index("onboard") < result.output.index("init") +def test_interactive_session_requires_both_ends_tty(monkeypatch: pytest.MonkeyPatch) -> None: + from aai_cli import main as main_mod + + # Both TTY -> interactive. + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + assert main_mod._interactive_session() is True + # Only one end a TTY -> NOT interactive. An `or` mutant would call this interactive. + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + assert main_mod._interactive_session() is False + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + assert main_mod._interactive_session() is False + + def test_bare_aai_with_key_shows_help_no_offer(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") result = CliRunner().invoke(app, []) @@ -59,17 +98,39 @@ def test_bare_aai_with_key_shows_help_no_offer(monkeypatch: pytest.MonkeyPatch) def test_bare_aai_offers_wizard_when_no_key(monkeypatch: pytest.MonkeyPatch) -> None: from aai_cli import main as main_mod + from aai_cli.onboard.sections import WizardContext + + monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) + monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: True) + captured: dict[str, object] = {} + + def _fake_run(prompter: object, ctx: WizardContext) -> int: + captured["called"] = True + captured["json_mode"] = ctx.json_mode + return 0 + + monkeypatch.setattr("aai_cli.main.wizard.run_onboarding", _fake_run) + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert captured["called"] is True + # The wizard is built in human (non-JSON) mode; a `json_mode=True` mutant flips this. + assert captured["json_mode"] is False + + +def test_bare_aai_empty_confirm_defaults_to_yes(monkeypatch: pytest.MonkeyPatch) -> None: + # The offer prompt defaults to Yes: an empty answer runs the wizard. + # A `default=False` mutant would instead decline and print help. + from aai_cli import main as main_mod monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) - monkeypatch.setattr(main_mod.typer, "confirm", lambda *a, **k: True) ran = {"called": False} def _fake_run(prompter: object, ctx: object) -> int: ran["called"] = True return 0 - monkeypatch.setattr(main_mod.wizard, "run_onboarding", _fake_run) - result = CliRunner().invoke(app, []) + monkeypatch.setattr("aai_cli.main.wizard.run_onboarding", _fake_run) + result = CliRunner().invoke(app, [], input="\n") assert result.exit_code == 0, result.output assert ran["called"] is True @@ -85,7 +146,7 @@ def test_bare_aai_interactive_with_key_shows_help_no_offer( monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") called = {"confirm": False} monkeypatch.setattr( - main_mod.typer, "confirm", lambda *a, **k: called.__setitem__("confirm", True) + "aai_cli.main.typer.confirm", lambda *a, **k: called.__setitem__("confirm", True) ) result = CliRunner().invoke(app, []) assert result.exit_code == 0, result.output @@ -97,14 +158,14 @@ def test_bare_aai_declined_offer_shows_help(monkeypatch: pytest.MonkeyPatch) -> from aai_cli import main as main_mod monkeypatch.setattr(main_mod, "_interactive_session", lambda: True) - monkeypatch.setattr(main_mod.typer, "confirm", lambda *a, **k: False) + monkeypatch.setattr("aai_cli.main.typer.confirm", lambda *a, **k: False) called = {"v": False} def _fake_run(prompter: object, ctx: object) -> int: called["v"] = True return 0 - monkeypatch.setattr(main_mod.wizard, "run_onboarding", _fake_run) + monkeypatch.setattr("aai_cli.main.wizard.run_onboarding", _fake_run) result = CliRunner().invoke(app, []) assert result.exit_code == 0, result.output assert called["v"] is False diff --git a/tests/test_onboard_progress.py b/tests/test_onboard_progress.py index 78760070..1dd9b3d0 100644 --- a/tests/test_onboard_progress.py +++ b/tests/test_onboard_progress.py @@ -8,6 +8,13 @@ def test_requests_made_starts_at_zero() -> None: assert config.get_requests_made("default") == 0 +def test_requests_made_is_zero_for_existing_profile_with_no_count() -> None: + # A profile can exist (e.g. created by login) before any request is recorded; + # its count must read 0, not the `or 1` fallback a mutant would introduce. + config.set_profile_env("default", "production") + assert config.get_requests_made("default") == 0 + + def test_record_request_increments_and_persists() -> None: assert config.record_request("default") == 1 assert config.record_request("default") == 2 diff --git a/tests/test_onboard_prompter.py b/tests/test_onboard_prompter.py index d25de7ca..2150f6a1 100644 --- a/tests/test_onboard_prompter.py +++ b/tests/test_onboard_prompter.py @@ -18,6 +18,11 @@ def test_noninteractive_confirm_returns_default() -> None: assert p.confirm("Run setup?", default=False) is False +def test_noninteractive_confirm_defaults_to_true() -> None: + # No explicit default: the parameter default (True) decides. + assert NonInteractivePrompter().confirm("Run setup?") is True + + def test_noninteractive_select_returns_default_or_first() -> None: p = NonInteractivePrompter() options = [("a", "Option A"), ("b", "Option B")] @@ -39,16 +44,18 @@ def test_interactive_section_and_note() -> None: def test_interactive_confirm_delegates_to_typer(monkeypatch: pytest.MonkeyPatch) -> None: - import aai_cli.onboard.prompter as pm - - monkeypatch.setattr(pm.typer, "confirm", lambda *a, **k: True) + monkeypatch.setattr("aai_cli.onboard.prompter.typer.confirm", lambda *a, **k: True) assert InteractivePrompter().confirm("ok?", default=True) is True -def test_interactive_text_delegates_to_typer(monkeypatch: pytest.MonkeyPatch) -> None: - import aai_cli.onboard.prompter as pm +def test_interactive_confirm_passes_default_true(monkeypatch: pytest.MonkeyPatch) -> None: + # Called without an explicit default, confirm() must forward default=True to typer. + monkeypatch.setattr("aai_cli.onboard.prompter.typer.confirm", lambda title, *, default: default) + assert InteractivePrompter().confirm("ok?") is True + - monkeypatch.setattr(pm.typer, "prompt", lambda *a, **k: "typed") +def test_interactive_text_delegates_to_typer(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("aai_cli.onboard.prompter.typer.prompt", lambda *a, **k: "typed") assert InteractivePrompter().text("name", default="d") == "typed" diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index 8b189ba8..47662a38 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -29,6 +29,7 @@ def __init__(self, *, select: str = "skip", confirm: bool = True, text: str = "k self._select = select self._confirm = confirm self._text = text + self.confirm_defaults: list[bool] = [] def section(self, title: str) -> None: pass @@ -37,6 +38,7 @@ def note(self, message: str) -> None: pass def confirm(self, title: str, *, default: bool = True) -> bool: + self.confirm_defaults.append(default) return self._confirm def select( @@ -72,9 +74,18 @@ def test_first_request_transcribes_sample_and_counts( assert config.get_requests_made("default") == 1 -def test_environment_is_non_blocking(ctx: WizardContext) -> None: +def test_environment_is_non_blocking(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: # Even if checks warn/fail, the section never blocks the wizard. + seen: dict[str, object] = {} + + def _capture_render(payload: dict[str, object]) -> str: + seen.update(payload) + return "" + + monkeypatch.setattr("aai_cli.commands.doctor._render", _capture_render) assert sections.environment(NonInteractivePrompter(), ctx) is SectionResult.DONE + # The environment section always renders as a non-fatal report (ok=True). + assert seen["ok"] is True def test_build_path_skip_choice_does_nothing( @@ -126,16 +137,32 @@ def test_auth_key_path_rejected(ctx: WizardContext, monkeypatch: pytest.MonkeyPa def test_build_path_scaffolds(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: calls = 0 + seen: dict[str, object] = {} def _fake_run_init(*a: object, **k: object) -> Path: nonlocal calls calls += 1 + seen.update(k) return Path() monkeypatch.setattr(init_cmd, "run_init", _fake_run_init) - result = sections.build_path(_ScriptedPrompter(select="audio-transcription", confirm=True), ctx) + prompter = _ScriptedPrompter(select="audio-transcription", confirm=True) + result = sections.build_path(prompter, ctx) assert result is SectionResult.DONE assert calls == 1 + # The scaffold confirmation defaults to Yes (a False mutant would change the prompt). + assert prompter.confirm_defaults == [True] + # Pin the exact run_init kwargs the wizard relies on (each is a mutated literal): + # a non-blocking, non-opening scaffold of the chosen template on the default port. + assert seen["template"] == "audio-transcription" + assert seen["directory"] is None + assert seen["no_install"] is False + assert seen["no_open"] is True + assert seen["force"] is False + assert seen["here"] is False + assert seen["port"] == 3000 + assert seen["json_mode"] is False + assert seen["launch"] is False def test_build_path_declined_after_select( @@ -172,10 +199,26 @@ def _passing_step(*a: object, **k: object) -> Step: def test_claude_code_done(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(setup_cmd, "_install_mcp", _passing_step) - monkeypatch.setattr(setup_cmd, "_install_skill", _passing_step) - monkeypatch.setattr(setup_cmd, "_install_cli_skill", _passing_step) + forces: dict[str, object] = {} + + def _mcp(scope: str, *, force: bool) -> Step: + forces["mcp"] = force + return _passing_step() + + def _skill(*, force: bool) -> Step: + forces["skill"] = force + return _passing_step() + + def _cli_skill(*, force: bool) -> Step: + forces["cli_skill"] = force + return _passing_step() + + monkeypatch.setattr(setup_cmd, "_install_mcp", _mcp) + monkeypatch.setattr(setup_cmd, "_install_skill", _skill) + monkeypatch.setattr(setup_cmd, "_install_cli_skill", _cli_skill) assert sections.claude_code(_ScriptedPrompter(confirm=True), ctx) is SectionResult.DONE + # The wizard never force-overwrites existing installs (force=False everywhere). + assert forces == {"mcp": False, "skill": False, "cli_skill": False} def test_claude_code_failed(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_onboard_wizard.py b/tests/test_onboard_wizard.py index c555f62e..412608ba 100644 --- a/tests/test_onboard_wizard.py +++ b/tests/test_onboard_wizard.py @@ -2,6 +2,7 @@ import pytest +from aai_cli import output from aai_cli.context import AppState from aai_cli.onboard import sections, wizard from aai_cli.onboard.prompter import NonInteractivePrompter, WizardCancelled @@ -51,3 +52,17 @@ def _cancel(p: object, c: object) -> SectionResult: monkeypatch.setattr(sections, "auth", _cancel) assert wizard.run_onboarding(NonInteractivePrompter(), ctx) == 130 + + +def test_cursor_is_always_restored(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + # The finally block must re-show the cursor (show=True), even on cancellation. + shown: list[bool] = [] + monkeypatch.setattr(output.console, "show_cursor", lambda *, show: shown.append(show)) + monkeypatch.setattr(sections, "welcome", lambda p, c: SectionResult.DONE) + + def _cancel(p: object, c: object) -> SectionResult: + raise WizardCancelled + + monkeypatch.setattr(sections, "auth", _cancel) + wizard.run_onboarding(NonInteractivePrompter(), ctx) + assert shown == [True] From 280b19c8ddf82270fc92a91f83d32a2252969bba Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 18:10:06 -0700 Subject: [PATCH 23/37] feat(onboard): remove the inaccurate request counter and prompt for a source Drop the 'N of 100 requests' counter entirely (no accurate way to track request counts) and make the wizard's first transcription prompt for a file path or YouTube URL, defaulting to the hosted sample on Enter. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/agent.py | 3 +- aai_cli/commands/llm.py | 3 +- aai_cli/commands/onboard.py | 14 ++------ aai_cli/commands/stream.py | 3 +- aai_cli/commands/transcribe.py | 3 +- aai_cli/config.py | 21 ------------ aai_cli/onboard/progress.py | 28 ---------------- aai_cli/onboard/sections.py | 33 ++++++++++--------- tests/test_onboard_command.py | 9 ------ tests/test_onboard_counter.py | 28 ---------------- tests/test_onboard_progress.py | 43 ------------------------- tests/test_onboard_sections.py | 59 +++++++++++++++++++++++++++------- 12 files changed, 72 insertions(+), 175 deletions(-) delete mode 100644 aai_cli/onboard/progress.py delete mode 100644 tests/test_onboard_counter.py delete mode 100644 tests/test_onboard_progress.py diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index b91a80fe..83212245 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -16,7 +16,7 @@ run_session, ) from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, complete_voice, format_voice_list -from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import CLIError, UsageError from aai_cli.help_text import examples_epilog from aai_cli.streaming.sources import FileSource @@ -165,7 +165,6 @@ def body(state: AppState, json_mode: bool) -> None: ) try: run_session(api_key, renderer=renderer, player=player, mic=audio, config=run_config) - config.record_request(resolve_profile(state)) except KeyboardInterrupt: renderer.stopped() except BrokenPipeError as exc: diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index a6a6c1c0..bc197894 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -7,7 +7,7 @@ from aai_cli import choices, config, help_panels, output, stdio from aai_cli import llm as gateway -from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer from aai_cli.help_text import examples_epilog @@ -141,7 +141,6 @@ def body(state: AppState, json_mode: bool) -> None: transcript_id=transcript_id, ) content = gateway.content_of(response) - config.record_request(resolve_profile(state)) if output_field == "text": # Just the answer, raw — so `… | aai llm -o text "…" | next` composes cleanly. output.emit_text(content) diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py index f7205550..fb3db1e5 100644 --- a/aai_cli/commands/onboard.py +++ b/aai_cli/commands/onboard.py @@ -4,10 +4,10 @@ import typer -from aai_cli import config, help_panels, output +from aai_cli import help_panels from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.help_text import examples_epilog -from aai_cli.onboard import progress, wizard +from aai_cli.onboard import wizard from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter, Prompter from aai_cli.onboard.sections import WizardContext @@ -26,27 +26,17 @@ def build_prompter() -> Prompter: epilog=examples_epilog( [ ("Run the guided setup", "aai onboard"), - ("Show your progress toward 100 requests", "aai onboard --status"), ] ), ) def onboard( ctx: typer.Context, - status: bool = typer.Option(False, "--status", help="Show request progress and exit."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: """Guided setup: sign in, run your first transcription, and start building.""" def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) - if status: - count = config.get_requests_made(profile) - output.emit( - {"requests_made": count, "goal": progress.GOAL}, - lambda _d: progress.render_progress(count), - json_mode=json_mode, - ) - return wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode) code = wizard.run_onboarding(build_prompter(), wiz_ctx) if code != 0: diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 2a484c84..5836434b 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -17,7 +17,7 @@ output, youtube, ) -from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.follow import FollowRenderer from aai_cli.help_text import examples_epilog @@ -387,6 +387,5 @@ def body(state: AppState, json_mode: bool) -> None: max_tokens=max_tokens, ) _dispatch(session, opts) - config.record_request(resolve_profile(state)) run_command(ctx, body, json=json_out) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 47f950c0..20c99d73 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -20,7 +20,7 @@ transcribe_render, youtube, ) -from aai_cli.context import AppState, resolve_profile, run_command +from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError from aai_cli.help_text import examples_epilog @@ -421,7 +421,6 @@ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) with output.status("Transcribing…", json_mode=json_mode): transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) - config.record_request(resolve_profile(state)) if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). diff --git a/aai_cli/config.py b/aai_cli/config.py index 2a73bcc3..9d0ba783 100644 --- a/aai_cli/config.py +++ b/aai_cli/config.py @@ -33,7 +33,6 @@ class Profile(BaseModel): env: str | None = None account_id: int | None = None - requests_made: int | None = None class Config(BaseModel): @@ -231,26 +230,6 @@ def get_account_id(profile: str) -> int | None: return prof.account_id if prof else None -def get_requests_made(profile: str) -> int: - """How many billable API requests this profile has made through the CLI.""" - prof = _load().profiles.get(profile) - return prof.requests_made or 0 if prof else 0 - - -def record_request(profile: str) -> int: - """Increment and persist this profile's CLI request count; return the new total. - - Powers the 'N of 100 requests' onboarding nudge. Counts only requests made - through the CLI; `aai usage` is the authoritative account-wide figure. - """ - _validate_profile(profile) - cfg = _load() - prof = cfg.profiles.setdefault(profile, Profile()) - prof.requests_made = (prof.requests_made or 0) + 1 - _dump(cfg) - return prof.requests_made - - def clear_session(profile: str) -> None: with contextlib.suppress(keyring.errors.PasswordDeleteError): keyring.delete_password(KEYRING_SERVICE, _session_username(profile)) diff --git a/aai_cli/onboard/progress.py b/aai_cli/onboard/progress.py deleted file mode 100644 index 3e8255f3..00000000 --- a/aai_cli/onboard/progress.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from aai_cli import output - -GOAL = 100 - -# Counts that earn a one-off cheer; keep keys in sync with the wizard's nudge. -_MILESTONES: dict[int, str] = { - 1: "You're activated 🎉 — your first request is in.", - 10: "10 requests in. You're getting the hang of it.", - 50: "Halfway to 100 — nice momentum.", - GOAL: f"{GOAL} requests — you're off the ground. 🚀", -} - - -def milestone_message(count: int) -> str | None: - """Encouragement to show when a request count lands exactly on a milestone.""" - return _MILESTONES.get(count) - - -def render_progress(count: int) -> str: - """A Rich-markup block: 'N of 100 API requests', any milestone, the usage pointer.""" - lines = [output.success(f"{count} of {GOAL} API requests")] - cheer = milestone_message(count) - if cheer: - lines.append(" " + output.heading(cheer)) - lines.append(" " + output.hint("For your full account usage, run `aai usage`.")) - return "\n".join(lines) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index c3e76427..cfc97409 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -10,9 +10,9 @@ from aai_cli.commands import doctor as doctor_cmd from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd +from aai_cli.commands import transcribe as transcribe_cmd from aai_cli.context import AppState, persist_browser_login from aai_cli.errors import CLIError, NotAuthenticated -from aai_cli.onboard import progress from aai_cli.onboard.prompter import Prompter @@ -37,12 +37,7 @@ def _has_key(profile: str) -> bool: return True -def welcome(prompter: Prompter, ctx: WizardContext) -> SectionResult: - count = config.get_requests_made(ctx.profile) - if count: - prompter.section("Welcome back to AssemblyAI") - output.console.print(progress.render_progress(count)) - return SectionResult.DONE +def welcome(prompter: Prompter, _ctx: WizardContext) -> SectionResult: prompter.section("Welcome to AssemblyAI") prompter.note("This wizard signs you in, runs your first transcription, and helps you build.") return SectionResult.DONE @@ -75,13 +70,22 @@ def auth(prompter: Prompter, ctx: WizardContext) -> SectionResult: def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.section("Your first transcription") api_key = config.resolve_api_key(profile=ctx.profile) - with output.status("Transcribing the sample clip…", json_mode=ctx.json_mode): - transcript = client.transcribe( - api_key, client.SAMPLE_AUDIO_URL, config=aai.TranscriptionConfig() - ) - count = config.record_request(ctx.profile) + source = prompter.text( + "Audio file path or YouTube URL (press Enter for the sample)", default="" + ).strip() + label = source or "the sample clip" + try: + with output.status(f"Transcribing {label}…", json_mode=ctx.json_mode): + transcript = transcribe_cmd._transcribe_audio( # pyright: ignore[reportPrivateUsage] + api_key, + source or None, + sample=not source, + transcription_config=aai.TranscriptionConfig(), + ) + except CLIError as exc: + output.error_console.print(output.fail(f"Transcription failed: {exc.message}")) + return SectionResult.FAILED transcribe_render.render_transcript_result(transcript, output.console) - output.console.print(progress.render_progress(count)) return SectionResult.DONE @@ -148,9 +152,8 @@ def claude_code(prompter: Prompter, _ctx: WizardContext) -> SectionResult: return SectionResult.DONE -def next_steps(prompter: Prompter, ctx: WizardContext) -> SectionResult: +def next_steps(prompter: Prompter, _ctx: WizardContext) -> SectionResult: prompter.section("You're set up") - output.console.print(progress.render_progress(config.get_requests_made(ctx.profile))) output.console.print(output.hint("Transcribe a file: aai transcribe ")) output.console.print(output.hint("Stream live audio: aai stream")) output.console.print(output.hint("Build an app: aai init")) diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index 7ebbf037..4c0d3ef8 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -3,20 +3,11 @@ import pytest from typer.testing import CliRunner -from aai_cli import config from aai_cli.commands import onboard as onboard_cmd from aai_cli.main import app from aai_cli.onboard.prompter import InteractivePrompter, NonInteractivePrompter -def test_status_shows_progress_without_running_wizard() -> None: - config.record_request("default") - config.record_request("default") - result = CliRunner().invoke(app, ["onboard", "--status"]) - assert result.exit_code == 0, result.output - assert "2 of 100" in result.output - - def test_onboard_is_listed_in_help() -> None: result = CliRunner().invoke(app, ["--help"]) assert "onboard" in result.output diff --git a/tests/test_onboard_counter.py b/tests/test_onboard_counter.py deleted file mode 100644 index cd20e900..00000000 --- a/tests/test_onboard_counter.py +++ /dev/null @@ -1,28 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar - -import pytest -from typer.testing import CliRunner - -from aai_cli import client, config -from aai_cli.main import app - - -class _FakeTranscript: - id = "t_123" - status = "completed" - text = "hello world" - json_response: ClassVar[dict[str, str]] = {"id": "t_123", "text": "hello world"} - utterances = None - - -def test_transcribe_increments_request_counter(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") - monkeypatch.setattr(client, "transcribe", lambda *a, **k: _FakeTranscript()) - # `-o text` keeps us off the rich render path (which needs a full transcript object); - # the counter increments before the output branch either way. - result = CliRunner().invoke(app, ["transcribe", "--sample", "-o", "text"]) - assert result.exit_code == 0, result.output - assert "hello world" in result.output - assert config.get_requests_made("default") == 1 diff --git a/tests/test_onboard_progress.py b/tests/test_onboard_progress.py deleted file mode 100644 index 1dd9b3d0..00000000 --- a/tests/test_onboard_progress.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from aai_cli import config -from aai_cli.onboard import progress - - -def test_requests_made_starts_at_zero() -> None: - assert config.get_requests_made("default") == 0 - - -def test_requests_made_is_zero_for_existing_profile_with_no_count() -> None: - # A profile can exist (e.g. created by login) before any request is recorded; - # its count must read 0, not the `or 1` fallback a mutant would introduce. - config.set_profile_env("default", "production") - assert config.get_requests_made("default") == 0 - - -def test_record_request_increments_and_persists() -> None: - assert config.record_request("default") == 1 - assert config.record_request("default") == 2 - assert config.get_requests_made("default") == 2 - - -def test_goal_is_100() -> None: - assert progress.GOAL == 100 - - -def test_milestone_message_fires_only_at_milestones() -> None: - assert progress.milestone_message(1) is not None - assert progress.milestone_message(10) is not None - assert progress.milestone_message(50) is not None - assert progress.milestone_message(100) is not None - assert progress.milestone_message(2) is None - assert progress.milestone_message(0) is None - - -def test_render_progress_mentions_count_goal_and_usage_pointer() -> None: - rendered = progress.render_progress(7) - assert "7" in rendered - assert "100" in rendered - assert "aai usage" in rendered - milestone = progress.render_progress(1) - assert "activated" in milestone diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index 47662a38..c6a3861a 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -8,6 +8,7 @@ from aai_cli import client, config, transcribe_render from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd +from aai_cli.commands import transcribe as transcribe_cmd from aai_cli.context import AppState from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter @@ -62,16 +63,57 @@ def test_auth_skips_when_key_already_present( assert sections.auth(NonInteractivePrompter(), ctx) is SectionResult.SKIPPED -def test_first_request_transcribes_sample_and_counts( +def test_first_request_uses_sample_on_empty_input( ctx: WizardContext, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") - monkeypatch.setattr(client, "transcribe", lambda *a, **k: _FakeTranscript()) - # Stub the rich render so a minimal fake transcript suffices; we're testing the - # counter + result, not rendering. + seen: dict[str, object] = {} + + def _fake( + api_key: str, source: object, *, sample: bool, transcription_config: object + ) -> _FakeTranscript: + seen["source"] = source + seen["sample"] = sample + return _FakeTranscript() + + monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _fake) monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) + # NonInteractivePrompter.text returns its default ("") → Enter → sample. assert sections.first_request(NonInteractivePrompter(), ctx) is SectionResult.DONE - assert config.get_requests_made("default") == 1 + assert seen["source"] is None + assert seen["sample"] is True + + +def test_first_request_uses_custom_source( + ctx: WizardContext, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + seen: dict[str, object] = {} + + def _fake( + api_key: str, source: object, *, sample: bool, transcription_config: object + ) -> _FakeTranscript: + seen["source"] = source + seen["sample"] = sample + return _FakeTranscript() + + monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _fake) + monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) + assert sections.first_request(_ScriptedPrompter(text="meeting.mp3"), ctx) is SectionResult.DONE + assert seen["source"] == "meeting.mp3" + assert seen["sample"] is False + + +def test_first_request_handles_failure(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + from aai_cli.errors import APIError + + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + + def _boom(*a: object, **k: object) -> _FakeTranscript: + raise APIError("nope") + + monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _boom) + assert sections.first_request(_ScriptedPrompter(text="bad.mp3"), ctx) is SectionResult.FAILED def test_environment_is_non_blocking(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: @@ -104,15 +146,10 @@ def _fake_run_init(*a: object, **k: object) -> Path: assert called is False -def test_next_steps_renders_progress(ctx: WizardContext) -> None: +def test_next_steps(ctx: WizardContext) -> None: assert sections.next_steps(NonInteractivePrompter(), ctx) is SectionResult.DONE -def test_welcome_returning_user(ctx: WizardContext) -> None: - config.record_request("default") - assert sections.welcome(NonInteractivePrompter(), ctx) is SectionResult.DONE - - def test_welcome_cold_start(ctx: WizardContext) -> None: assert sections.welcome(NonInteractivePrompter(), ctx) is SectionResult.DONE From a9dad4b5019b1dbde5cc98bdd78a53fb965c2153 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 18:11:23 -0700 Subject: [PATCH 24/37] Show usage in dollars from line-item prices; handle empty rate limits The AMS usage endpoint returns total:0.0 on every window; derive each window's spend from line_items[].price (cents) instead and render it as dollars. Show a clear message when an account has no custom rate limits. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/account.py | 52 +++++++++++++++++++---------- tests/test_account_command.py | 63 ++++++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 29 deletions(-) diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index b41c163d..c3ec00bb 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -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 @@ -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")) @@ -144,41 +161,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)) @@ -215,9 +230,12 @@ 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 configured for this account.") 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")), diff --git a/tests/test_account_command.py b/tests/test_account_command.py index 7aa42379..db078c89 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -61,8 +61,8 @@ def fake_usage(jwt, start, end, window): { "start_timestamp": "2026-05-01", "end_timestamp": "2026-05-02", - "total": 12.5, - "line_items": [], + "total": 0.0, + "line_items": [{"name": "Streaming", "price": 1250.0, "quantity": 12.5}], } ] } @@ -80,8 +80,10 @@ def fake_usage(jwt, start, end, window): start_day = _dt.fromisoformat(captured["start"]).date() end_day = _dt.fromisoformat(captured["end"]).date() assert (end_day - start_day).days == 30 + # --json is a raw passthrough of the AMS response (the dead top-level `total` + # included), so downstream tooling sees exactly what the endpoint returned. data = json.loads(result.output) - assert data["usage_items"][0]["total"] == 12.5 + assert data["usage_items"][0]["line_items"][0]["price"] == 1250.0 def test_usage_renders_table_human(monkeypatch): @@ -92,20 +94,30 @@ def test_usage_renders_table_human(monkeypatch): { "start_timestamp": "2026-05-01", "end_timestamp": "2026-05-02", - "total": 12.5, - "line_items": [], + "total": 0.0, + "line_items": [{"name": "Streaming", "price": 1250.0, "quantity": 12.5}], } ] } 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 + # price (cents) is summed per window and shown as dollars, mirroring `aai balance`. + assert "2026-05-01" in result.output and "$12.50" in result.output def test_usage_helpers_format_windows_and_line_items(): assert account._usage_items({"usage_items": "bad"}) == [] assert account._usage_items({"usage_items": [{"total": 1}, "bad"]}) == [{"total": 1}] + # Window total is the sum of line-item `price` (cents); the dead top-level + # `total` field the AMS endpoint returns is ignored. + assert ( + account._window_total_cents( + {"total": 0.0, "line_items": [{"price": 1250.0}, {"price": 0.5}]} + ) + == 1250.5 + ) + assert account._window_total_cents({"total": 99.0, "line_items": []}) == 0.0 assert account._window_label({"start_timestamp": "bad"}) == "bad" assert ( account._window_label( @@ -145,8 +157,8 @@ def test_usage_human_renders_breakdown(monkeypatch): { "start_timestamp": "2026-01-01T00:00:00Z", "end_timestamp": "2026-01-02T00:00:00Z", - "total": 10, - "line_items": [{"name": "minutes", "total": 10}], + "total": 0.0, + "line_items": [{"name": "minutes", "price": 1000.0, "quantity": 10}], } ] } @@ -154,6 +166,8 @@ def test_usage_human_renders_breakdown(monkeypatch): result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "breakdown" in result.output + # The breakdown shows each product's quantity (units), distinct from the + # dollar total derived from price. assert "minutes: 10" in result.output @@ -180,15 +194,15 @@ def test_usage_human_hides_zero_windows_by_default(monkeypatch): { "start_timestamp": "2026-01-02T00:00:00Z", "end_timestamp": "2026-01-03T00:00:00Z", - "total": 12.5, - "line_items": [], + "total": 0.0, + "line_items": [{"name": "Streaming", "price": 1250.0, "quantity": 12.5}], }, ] } with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 - assert "Usage total: 12.5" in result.output + assert "Usage total: $12.50" in result.output assert "2026-01-01" not in result.output assert "2026-01-02" in result.output assert "Hidden: 1 zero-usage window" in result.output @@ -230,7 +244,7 @@ def test_usage_human_summarizes_all_zero_range(monkeypatch): with patch("aai_cli.commands.account.ams.get_usage", return_value=payload): result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 - assert "Usage total: 0" in result.output + assert "Usage total: $0.00" in result.output assert "No usage in this range" in result.output assert "2026-01-01" not in result.output @@ -267,3 +281,28 @@ def test_limits_renders_services(monkeypatch): result = runner.invoke(app, ["limits"]) assert result.exit_code == 0 assert "transcript" in result.output and "200" in result.output + + +def test_limits_human_summarizes_empty(monkeypatch): + _auth() + _human(monkeypatch) + # The AMS endpoint returns an empty array when no custom rate limits are + # configured; show a clear message instead of a bare header-only table. + with patch( + "aai_cli.commands.account.ams.get_rate_limits", + return_value={"rate_limits": []}, + ): + result = runner.invoke(app, ["limits"]) + assert result.exit_code == 0 + assert "No custom rate limits" in result.output + + +def test_limits_json_passthrough_when_empty(monkeypatch): + _auth() + with patch( + "aai_cli.commands.account.ams.get_rate_limits", + return_value={"rate_limits": []}, + ): + result = runner.invoke(app, ["limits", "--json"]) + assert result.exit_code == 0 + assert json.loads(result.output) == {"rate_limits": []} From 032065186c2a499b57be414ad8b3e9f8dfbddc08 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 18:22:14 -0700 Subject: [PATCH 25/37] test(onboard): regenerate snapshots after removing the request counter Update the onboard --help snapshot (drops the --status option and its "progress toward 100 requests" example) and assert the first-transcription status label so the diff-scoped mutation gate kills the source-vs-sample Or->And mutant on sections.py:76. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test_cli_output_snapshots.ambr | 7 ++----- tests/test_onboard_sections.py | 21 ++++++++++++++++++- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 4bde67bb..5b1b89b0 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -361,16 +361,13 @@ Guided setup: sign in, run your first transcription, and start building. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --status Show request progress and exit. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples Run the guided setup $ aai onboard - Show your progress toward 100 requests - $ aai onboard --status diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index c6a3861a..fddf4893 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -1,11 +1,13 @@ from __future__ import annotations +import contextlib +from collections.abc import Generator from pathlib import Path import pytest import typer -from aai_cli import client, config, transcribe_render +from aai_cli import client, config, output, transcribe_render from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd from aai_cli.commands import transcribe as transcribe_cmd @@ -51,6 +53,19 @@ def text(self, title: str, *, default: str | None = None) -> str: return self._text +def _capture_status(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """Record the messages passed to output.status (the transcription label).""" + messages: list[str] = [] + + @contextlib.contextmanager + def _fake_status(message: str, *, json_mode: bool) -> Generator[None]: + messages.append(message) + yield + + monkeypatch.setattr(output, "status", _fake_status) + return messages + + @pytest.fixture def ctx() -> WizardContext: return WizardContext(state=AppState(), profile="default", json_mode=False) @@ -78,10 +93,12 @@ def _fake( monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _fake) monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) + status_messages = _capture_status(monkeypatch) # NonInteractivePrompter.text returns its default ("") → Enter → sample. assert sections.first_request(NonInteractivePrompter(), ctx) is SectionResult.DONE assert seen["source"] is None assert seen["sample"] is True + assert status_messages == ["Transcribing the sample clip…"] def test_first_request_uses_custom_source( @@ -99,9 +116,11 @@ def _fake( monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _fake) monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) + status_messages = _capture_status(monkeypatch) assert sections.first_request(_ScriptedPrompter(text="meeting.mp3"), ctx) is SectionResult.DONE assert seen["source"] == "meeting.mp3" assert seen["sample"] is False + assert status_messages == ["Transcribing meeting.mp3…"] def test_first_request_handles_failure(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: From 7238d006b1ad0dee4f134f93f7b9ac30aed40168 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 18:53:32 -0700 Subject: [PATCH 26/37] feat(onboard): add welcome banner and retint help to brand blue Print an `aai` wordmark + version/tagline header on the bare-command welcome screen (suppressed by --quiet), retint Typer's cyan option/command styling to the logo blue, and drop the now-redundant top-level help line. Swap the theme's remaining cyan accents for a dark blue. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/main.py | 15 +++++++++++++-- aai_cli/output.py | 28 +++++++++++++++++++++++++++- aai_cli/theme.py | 13 ++++++++----- tests/test_onboard_command.py | 18 ++++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/aai_cli/main.py b/aai_cli/main.py index 1358709b..1159c573 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING import typer +from typer import rich_utils from typer.core import TyperGroup if TYPE_CHECKING: @@ -11,7 +12,7 @@ # context type, not the upstream click.Context. Imported for typing only. from typer._click.core import Context as ClickContext -from aai_cli import __version__, config, environments, help_panels, output, stdio +from aai_cli import __version__, config, environments, help_panels, output, stdio, theme from aai_cli.commands import ( account, agent, @@ -82,9 +83,17 @@ def list_commands(self, ctx: ClickContext) -> list[str]: ) +# Typer renders option flags and command names in "bold cyan" by default; retint +# both to the brand accent (the logo blue) so the help screen matches the rest of +# the CLI. Set before the app renders any help. +rich_utils.STYLE_OPTION = f"bold {theme.BRAND}" +rich_utils.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {theme.BRAND}" + + app = typer.Typer( name="aai", - help="AssemblyAI from your terminal — transcribe, stream, and build voice AI.", + # No top-level `help=`: the bare-`aai` welcome banner already carries the + # "AssemblyAI from your terminal" tagline, so a description here would duplicate it. # `aai --install-completion` / `--show-completion` for bash/zsh/fish/PowerShell, # the discoverability affordance gh/kubectl/docker users reach for. add_completion=True, @@ -118,6 +127,8 @@ def _offer_or_help(ctx: typer.Context, state: AppState) -> None: """No subcommand given: offer guided setup to a credential-less, interactive user; otherwise print help. Never prompts in a non-interactive session, and never on `--help` (Click handles that eagerly before the callback).""" + if not state.quiet: + output.print_banner() if ( _interactive_session() and not _profile_has_key(state) diff --git a/aai_cli/output.py b/aai_cli/output.py index 8380e1dd..41909087 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -13,7 +13,7 @@ from rich.table import Table from rich.text import Text -from aai_cli import choices, theme +from aai_cli import __version__, choices, theme if TYPE_CHECKING: from aai_cli.errors import CLIError @@ -192,6 +192,32 @@ def emit_error(err: CLIError, *, json_mode: bool) -> None: error_console.print(f"[aai.muted]Suggestion:[/aai.muted] {escape(err.suggestion)}") +# The `aai` wordmark for the bare-command welcome screen: a compact two-row +# half-block letterform, tinted to the brand accent (see theme.BRAND). Interior +# spaces are load-bearing (they separate the glyphs); trailing spaces are not, so +# they're dropped to survive whitespace-trimming tooling. +_BANNER = """\ +▄▀█ ▄▀█ █ +█▀█ █▀█ █""" + +# A one-line header: emoji + product + version, then the product tagline. +_TAGLINE = "AssemblyAI from your terminal" + + +def print_banner() -> None: + """Print the welcome header — a version + tagline line, then the `aai` wordmark in + the brand accent (the bare-command welcome screen).""" + # highlight=False so Rich's repr-highlighter doesn't recolor the version digits or + # the quoted tagline — the line stays a single muted tone behind the brand label. + console.print( + f"[aai.brand]🎙️ AssemblyAI CLI[/aai.brand] " + f"[aai.muted]{__version__} — {_TAGLINE}[/aai.muted]", + highlight=False, + ) + console.print() + console.print(Text(_BANNER, style="aai.brand")) + + def print_code(code: str, *, language: str = "python") -> None: """Print generated source: syntax-highlighted for an interactive human, raw text otherwise. Piping/redirecting (or an agent) yields plain text with no ANSI, so diff --git a/aai_cli/theme.py b/aai_cli/theme.py index 16206db5..64c55de2 100644 --- a/aai_cli/theme.py +++ b/aai_cli/theme.py @@ -7,6 +7,9 @@ # AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here. BRAND = "#2545D3" +# Secondary accent — a darker blue used where a second hue is needed (agent label, +# links, the second speaker), kept in the blue family rather than the old cyan. +DARK_BLUE = "#1E3A8A" # A fixed affordance vocabulary used across all human-facing output, mirroring the # Vercel/Supabase convention of a small, consistent status alphabet: one glyph per @@ -34,10 +37,10 @@ # Conversation labels: the human keeps the brand accent, the agent gets a # distinct hue so "you:" and "agent:" are easy to tell apart at a glance. "aai.you": BRAND, - "aai.agent": "cyan", - # Links/URLs in cyan, the convention both the Vercel and Supabase CLIs use so - # a clickable target stands out from prose without shouting. - "aai.url": "cyan", + "aai.agent": DARK_BLUE, + # Links/URLs in the dark-blue secondary accent so a clickable target stands out + # from prose without shouting (Vercel/Supabase use a cool accent for the same). + "aai.url": DARK_BLUE, # Semantic status colors. Success is bold so the ✓ reads as a confident "done" # (Supabase-style); error/warn follow the universal red/yellow; muted secondary # text stays dim so it recedes (the Vercel "quiet by default" look). @@ -46,7 +49,7 @@ "aai.warn": "yellow", "aai.muted": "dim", "aai.speaker.0": BRAND, - "aai.speaker.1": "cyan", + "aai.speaker.1": DARK_BLUE, "aai.speaker.2": "magenta", "aai.speaker.3": "green", "aai.speaker.4": "yellow", diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index 4c0d3ef8..f8c3a2cc 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -87,6 +87,24 @@ def test_bare_aai_with_key_shows_help_no_offer(monkeypatch: pytest.MonkeyPatch) assert "Usage" in result.output or "Commands" in result.output +def test_bare_aai_prints_wordmark_banner(monkeypatch: pytest.MonkeyPatch) -> None: + # The welcome screen leads with the `aai` wordmark; its block glyphs are present. + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + result = CliRunner().invoke(app, []) + assert result.exit_code == 0, result.output + assert "▄▀█ ▄▀█" in result.output + + +def test_bare_aai_quiet_suppresses_banner(monkeypatch: pytest.MonkeyPatch) -> None: + # `--quiet` drops the decorative banner but still prints help. A mutant that + # ignores `quiet` (always banners) would leave the wordmark in the output. + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "sk_test") + result = CliRunner().invoke(app, ["--quiet"]) + assert result.exit_code == 0, result.output + assert "▄▀█ ▄▀█" not in result.output + assert "Usage" in result.output or "Commands" in result.output + + def test_bare_aai_offers_wizard_when_no_key(monkeypatch: pytest.MonkeyPatch) -> None: from aai_cli import main as main_mod from aai_cli.onboard.sections import WizardContext From abe5b0376f6c35b61d566a6c5e7c3bd6400789aa Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 19:22:30 -0700 Subject: [PATCH 27/37] feat(cli): remove samples + version commands, polish onboarding - Remove the `samples` command and all related code (module, tests, help snapshots, import-linter contract, README/AGENTS/skill docs). - Remove the `version` command; keep `--version`/`-V`. Repoint smoke, __main__, and install-script tests to the flag. - Shorten the `--show-completion` and `init` help text so they stop wrapping. - Onboarding: browser-only sign-in (drop the API-key paste path so a secret never lands in scrollback/history), fix the duplicated "Environment check" heading, space the setup prompt off the banner, and reword the source and build-path prompts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .importlinter | 1 - AGENTS.md | 2 +- README.md | 1 - aai_cli/commands/init.py | 2 +- aai_cli/commands/samples.py | 110 ----------------- aai_cli/help_panels.py | 2 +- aai_cli/main.py | 38 +++--- aai_cli/onboard/sections.py | 38 +++--- aai_cli/skills/aai-cli/SKILL.md | 6 +- aai_cli/skills/aai-cli/references/setup.md | 41 +------ .../test_cli_output_snapshots.ambr | 64 +--------- tests/test_help_examples_coverage.py | 6 - tests/test_install_script_smoke.py | 2 +- tests/test_main_module.py | 2 +- tests/test_onboard_sections.py | 18 +-- tests/test_samples.py | 112 ------------------ tests/test_smoke.py | 20 +--- 17 files changed, 54 insertions(+), 411 deletions(-) delete mode 100644 aai_cli/commands/samples.py delete mode 100644 tests/test_samples.py diff --git a/.importlinter b/.importlinter index 3545018b..2712113b 100644 --- a/.importlinter +++ b/.importlinter @@ -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 diff --git a/AGENTS.md b/AGENTS.md index bb73d1f1..afe6abad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) diff --git a/README.md b/README.md index 8e066a18..c1286515 100644 --- a/README.md +++ b/README.md @@ -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 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 ` | 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. diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index 1cefdc8f..ad6bf3d2 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -254,7 +254,7 @@ def init( port: int = typer.Option(3000, "--port", help="Local server port."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Build a new app: pick a template, scaffold it, install deps, launch it, open the browser. + """Scaffold and launch a starter app from a template. This is the starting point for creating an app — including a voice agent app ('aai init voice-agent'). The 'aai agent' command only runs a live mic diff --git a/aai_cli/commands/samples.py b/aai_cli/commands/samples.py deleted file mode 100644 index e601995a..00000000 --- a/aai_cli/commands/samples.py +++ /dev/null @@ -1,110 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -import typer -from assemblyai.streaming.v3 import SpeechModel -from rich.markup import escape - -from aai_cli import client, code_gen, output -from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT -from aai_cli.agent.voices import DEFAULT_VOICE -from aai_cli.context import AppState, run_command -from aai_cli.errors import CLIError -from aai_cli.help_text import examples_epilog -from aai_cli.streaming.sources import TARGET_RATE - -app = typer.Typer( - help="Scaffold runnable AssemblyAI starter scripts.", - no_args_is_help=True, -) - -SAMPLES = ("transcribe", "stream", "agent") - - -def _generate(name: str) -> str: - """Render a starter script via the same generator behind `--show-code`.""" - if name == "transcribe": - return code_gen.transcribe({}, client.SAMPLE_AUDIO_URL) - if name == "stream": - return code_gen.stream( - { - "sample_rate": TARGET_RATE, - "format_turns": True, - "speech_model": SpeechModel.u3_rt_pro, - } - ) - return code_gen.agent(DEFAULT_VOICE, DEFAULT_PROMPT, DEFAULT_GREETING) - - -@app.command( - name="list", - epilog=examples_epilog( - [ - ("List available starter scripts", "aai samples list"), - ] - ), -) -def list_( - ctx: typer.Context, - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """List available sample scripts.""" - - def body(_state: AppState, json_mode: bool) -> None: - output.emit( - list(SAMPLES), - lambda d: "Available samples:\n" + "\n".join(f" - {n}" for n in d), - json_mode=json_mode, - ) - - run_command(ctx, body, json=json_out) - - -@app.command( - epilog=examples_epilog( - [ - ("Scaffold a transcribe starter script", "aai samples create transcribe"), - ("Overwrite an existing script", "aai samples create transcribe --force"), - ] - ) -) -def create( - ctx: typer.Context, - name: str = typer.Argument(..., help="Sample name."), - force: bool = typer.Option(False, "--force", help="Overwrite an existing sample file."), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), -) -> None: - """Scaffold a runnable starter script (reads ASSEMBLYAI_API_KEY from the environment).""" - - def body(_state: AppState, json_mode: bool) -> None: - if name not in SAMPLES: - raise CLIError( - f"Unknown sample '{name}'.", - error_type="unknown_sample", - exit_code=1, - suggestion=f"Try one of: {', '.join(SAMPLES)}.", - ) - target_dir = Path.cwd() / name - target_dir.mkdir(parents=True, exist_ok=True) - target = target_dir / f"{name}.py" - if target.exists() and not force: - raise CLIError( - f"{target} already exists.", - error_type="file_exists", - exit_code=1, - suggestion="Delete it or pass --force to overwrite.", - ) - target.write_text(_generate(name)) - - output.emit( - {"created": str(target)}, - lambda d: ( - f"Created {escape(d['created'])}\n" - f'Set your key (export ASSEMBLYAI_API_KEY="…"), then run: ' - f"python {escape(d['created'])}" - ), - json_mode=json_mode, - ) - - run_command(ctx, body, json=json_out) diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index 5a26e23e..5a1392b3 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -16,7 +16,7 @@ TRANSCRIPTION = "Transcription & AI" # the verbs you run: transcribe, stream, agent, llm HISTORY = "History" # browse past work: transcripts, sessions ACCOUNT = "Account" # auth, billing, keys: login/logout/whoami, balance/usage/limits, keys, audit -SETUP = "Setup & Tools" # get set up & maintain: samples, doctor, claude, version +SETUP = "Setup & Tools" # get set up & maintain: doctor, setup # Option panels group a single command's flags within its own ``--help``. The # `transcribe` command exposes 40+ options; without panels they render as one diff --git a/aai_cli/main.py b/aai_cli/main.py index 1159c573..cbe24c80 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING import typer -from typer import rich_utils +from typer import completion, rich_utils from typer.core import TyperGroup if TYPE_CHECKING: @@ -23,7 +23,6 @@ llm, login, onboard, - samples, sessions, setup, stream, @@ -44,11 +43,9 @@ # Quick Start — zero-to-running onboarding "onboard", "init", - # Setup & Tools — get set up & maintain; `version` last - "samples", + # Setup & Tools — get set up & maintain "doctor", "setup", - "version", # Transcription & AI — the verbs you run "transcribe", "stream", @@ -73,7 +70,7 @@ class _OrderedGroup(TyperGroup): """Lists commands in `_COMMAND_ORDER` rather than registration order. Typer renders all direct commands before sub-typer groups, so registration - order alone can't place `version` last; sorting here controls help output. + order alone can't control the panel layout; sorting here drives help output. """ def list_commands(self, ctx: ClickContext) -> list[str]: @@ -89,6 +86,15 @@ def list_commands(self, ctx: ClickContext) -> list[str]: rich_utils.STYLE_OPTION = f"bold {theme.BRAND}" rich_utils.STYLE_COMMANDS_TABLE_FIRST_COLUMN = f"bold {theme.BRAND}" +# Typer's built-in `--show-completion` help is long enough to wrap several lines in +# the options panel. Trim it so it fits on fewer rows. The OptionInfo objects live on +# the completion placeholder's parameter defaults; reach the (underscore-prefixed) +# placeholder through the module dict so it isn't flagged as private-attribute use. +_completion_placeholder = vars(completion)["_install_completion_placeholder_function"] +for _opt in _completion_placeholder.__defaults__ or (): + if isinstance(_opt.help, str) and _opt.help.startswith("Show completion"): + _opt.help = "Show completion for the current shell." + app = typer.Typer( name="aai", @@ -129,13 +135,12 @@ def _offer_or_help(ctx: typer.Context, state: AppState) -> None: `--help` (Click handles that eagerly before the callback).""" if not state.quiet: output.print_banner() - if ( - _interactive_session() - and not _profile_has_key(state) - and typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True) - ): - wiz_ctx = WizardContext(state=state, profile=state.resolve_profile(), json_mode=False) - raise typer.Exit(code=wizard.run_onboarding(onboard.build_prompter(), wiz_ctx)) + if _interactive_session() and not _profile_has_key(state): + if not state.quiet: + output.console.print() # blank line so the prompt isn't flush against the banner + if typer.confirm("Welcome to AssemblyAI. Run guided setup now?", default=True): + wiz_ctx = WizardContext(state=state, profile=state.resolve_profile(), json_mode=False) + raise typer.Exit(code=wizard.run_onboarding(onboard.build_prompter(), wiz_ctx)) typer.echo(ctx.get_help()) raise typer.Exit() @@ -207,19 +212,12 @@ def main( 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", rich_help_panel=help_panels.SETUP) app.add_typer(init.app) app.add_typer(onboard.app) app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP) app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) -@app.command(rich_help_panel=help_panels.SETUP) -def version() -> None: - """Show the CLI version.""" - typer.echo(__version__) - - def run() -> None: """Console-script entry point: run the app, exiting cleanly on a closed pipe. diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index cfc97409..a2102188 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -6,7 +6,7 @@ import assemblyai as aai import typer -from aai_cli import client, config, environments, output, transcribe_render +from aai_cli import config, environments, output, transcribe_render from aai_cli.commands import doctor as doctor_cmd from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd @@ -48,22 +48,10 @@ def auth(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.note("Already signed in.") return SectionResult.SKIPPED prompter.section("Sign in") - method = prompter.select( - "How do you want to sign in?", - [("browser", "Sign in with your browser (recommended)"), ("key", "Paste an API key")], - default="browser", - ) - env = environments.active().name - if method == "key": - key = prompter.text("Paste your AssemblyAI API key") - if not client.validate_key(key): - output.error_console.print(output.fail("That key was rejected.")) - return SectionResult.FAILED - config.set_api_key(ctx.profile, key) - config.set_profile_env(ctx.profile, env) - return SectionResult.DONE + # Browser sign-in only: we deliberately don't offer an API-key paste here so a + # secret never lands in the terminal scrollback or shell history. prompter.note(f"No account yet? Create one at {environments.active().signup_url}") - persist_browser_login(ctx.profile, env) + persist_browser_login(ctx.profile, environments.active().name) return SectionResult.DONE @@ -71,7 +59,8 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.section("Your first transcription") api_key = config.resolve_api_key(profile=ctx.profile) source = prompter.text( - "Audio file path or YouTube URL (press Enter for the sample)", default="" + "Audio file path or YouTube URL (or press Enter to transcribe a sample clip)", + default="", ).strip() label = source or "the sample clip" try: @@ -90,20 +79,23 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: _BUILD_CHOICES = [ - ("audio-transcription", "Transcribe audio files (web app)"), - ("live-captions", "Live captions from streaming audio"), - ("voice-agent", "A two-way voice agent"), - ("skip", "Just the CLI for now"), + ("audio-transcription", "Audio transcription web app"), + ("live-captions", "Live captions web app"), + ("voice-agent", "Voice agent web app"), + ("skip", "Skip — just the CLI for now"), ] def environment(prompter: Prompter, _ctx: WizardContext) -> SectionResult: - prompter.section("Environment check") checks = [ doctor_cmd._check_python(), # pyright: ignore[reportPrivateUsage] doctor_cmd._check_ffmpeg(), # pyright: ignore[reportPrivateUsage] doctor_cmd._check_audio(), # pyright: ignore[reportPrivateUsage] ] + # `_render` already prints its own "Environment check" heading, so we don't call + # prompter.section here (that would show the title twice); just space it from the + # previous section with a blank line. + output.console.print() output.console.print(doctor_cmd._render({"ok": True, "checks": checks})) # pyright: ignore[reportPrivateUsage] prompter.note("Warnings here only affect live streaming and the voice agent.") return SectionResult.DONE @@ -111,7 +103,7 @@ def environment(prompter: Prompter, _ctx: WizardContext) -> SectionResult: def build_path(prompter: Prompter, ctx: WizardContext) -> SectionResult: prompter.section("What do you want to build?") - choice = prompter.select("Pick a starting point", _BUILD_CHOICES, default="skip") + choice = prompter.select("Pick a starting point (or skip)", _BUILD_CHOICES, default="skip") if choice == "skip": return SectionResult.SKIPPED if not prompter.confirm(f"Scaffold the '{choice}' app now?", default=True): diff --git a/aai_cli/skills/aai-cli/SKILL.md b/aai_cli/skills/aai-cli/SKILL.md index 4dac3b4e..1012d985 100644 --- a/aai_cli/skills/aai-cli/SKILL.md +++ b/aai_cli/skills/aai-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: aai-cli -description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold starter apps and SDK samples (init/samples); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command. +description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold a starter app (init); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command. --- # AssemblyAI CLI (`aai`) @@ -77,8 +77,8 @@ agent," reach for `aai init voice-agent`, not `aai agent`. - **Browse past transcripts or streaming sessions** → `references/history.md` - **Sign in/out, identity, balance, usage, rate limits, API keys, audit log** → `references/account.md` -- **Scaffold apps/samples (`init`, `samples`), diagnose setup (`doctor`), set up - your coding agent's MCP + skills (`setup`), version** → `references/setup.md` +- **Scaffold a starter app (`init`), diagnose setup (`doctor`), set up + your coding agent's MCP + skills (`setup`)** → `references/setup.md` ## Anti-patterns diff --git a/aai_cli/skills/aai-cli/references/setup.md b/aai_cli/skills/aai-cli/references/setup.md index 0bc13697..57e16c34 100644 --- a/aai_cli/skills/aai-cli/references/setup.md +++ b/aai_cli/skills/aai-cli/references/setup.md @@ -36,41 +36,6 @@ aai init audio-transcription my-app aai init audio-transcription --here ``` -## `aai samples` — scaffold runnable starter scripts - -Sub-app for listing and scaffolding single-file Python starter scripts that read -`ASSEMBLYAI_API_KEY` from the environment. - -### `aai samples list` - -List the available sample script names. - -Key options: - -- `--json` — machine-readable output. - -Examples: - -```bash -aai samples list -``` - -### `aai samples create NAME` - -Scaffold a named starter script into the current directory. - -Key options: - -- `--force` — overwrite an existing file. -- `--json` — machine-readable output. - -Examples: - -```bash -aai samples create transcribe -aai samples create transcribe --force -``` - ## `aai doctor` — environment health check Verifies that your environment is ready to use AssemblyAI (checks credentials, @@ -143,12 +108,12 @@ Examples: aai setup remove ``` -## `aai version` — show CLI version +## `aai --version` — show CLI version -Prints the installed `aai` version string. +Prints the installed `aai` version string and exits. Examples: ```bash -aai version +aai --version ``` diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 5b1b89b0..b15db98f 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -136,8 +136,7 @@ Usage: aai init [OPTIONS] [TEMPLATE] [DIRECTORY] - Build a new app: pick a template, scaffold it, install deps, launch it, open - the browser. + Scaffold and launch a starter app from a template. This is the starting point for creating an app — including a voice agent app ('aai init voice-agent'). The 'aai agent' command only runs a live mic @@ -371,53 +370,6 @@ - ''' -# --- -# name: test_command_help_matches_snapshot[samples_create] - ''' - - Usage: aai samples create [OPTIONS] NAME - - Scaffold a runnable starter script (reads ASSEMBLYAI_API_KEY from the - environment). - - ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ - │ * name TEXT Sample name. [required] │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --force Overwrite an existing sample file. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - Examples - Scaffold a transcribe starter script - $ aai samples create transcribe - Overwrite an existing script - $ aai samples create transcribe --force - - - - ''' -# --- -# name: test_command_help_matches_snapshot[samples_list] - ''' - - Usage: aai samples list [OPTIONS] - - List available sample scripts. - - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - Examples - List available starter scripts - $ aai samples list - - - ''' # --- # name: test_command_help_matches_snapshot[sessions_get] @@ -857,20 +809,6 @@ - ''' -# --- -# name: test_command_help_matches_snapshot[version] - ''' - - Usage: aai version [OPTIONS] - - Show the CLI version. - - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - ''' # --- # name: test_command_help_matches_snapshot[whoami] diff --git a/tests/test_help_examples_coverage.py b/tests/test_help_examples_coverage.py index fdccc468..10c4c940 100644 --- a/tests/test_help_examples_coverage.py +++ b/tests/test_help_examples_coverage.py @@ -1,15 +1,9 @@ from tests._cli_tree import leaf_command_items -# `version` is a trivial command with no flags; examples would be noise. -_EXEMPT = {"version"} - def test_every_leaf_command_has_examples_epilog(): missing = [] for path, cmd in leaf_command_items(): - name = path[-1] if path else cmd.name - if name in _EXEMPT: - continue epilog = getattr(cmd, "epilog", None) if not (epilog and "Examples" in epilog): missing.append(" ".join(path)) diff --git a/tests/test_install_script_smoke.py b/tests/test_install_script_smoke.py index caa04dfa..7ff7aad5 100644 --- a/tests/test_install_script_smoke.py +++ b/tests/test_install_script_smoke.py @@ -78,7 +78,7 @@ def built_wheel(tmp_path_factory) -> Path: def _assert_aai_runs(aai_bin: Path) -> None: assert aai_bin.is_file(), f"install.sh did not produce {aai_bin}" - result = subprocess.run([str(aai_bin), "version"], capture_output=True, text=True) + result = subprocess.run([str(aai_bin), "--version"], capture_output=True, text=True) assert result.returncode == 0, result.stderr assert result.stdout.strip() == __version__ diff --git a/tests/test_main_module.py b/tests/test_main_module.py index a40db37a..af06e645 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -45,7 +45,7 @@ def test_python_dash_m_entrypoint_runs(): def test_python_dash_m_version(): result = subprocess.run( - [sys.executable, "-m", "aai_cli", "version"], + [sys.executable, "-m", "aai_cli", "--version"], capture_output=True, text=True, ) diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index fddf4893..beb95ec2 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -7,7 +7,7 @@ import pytest import typer -from aai_cli import client, config, output, transcribe_render +from aai_cli import output, transcribe_render from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd from aai_cli.commands import transcribe as transcribe_cmd @@ -174,21 +174,9 @@ def test_welcome_cold_start(ctx: WizardContext) -> None: def test_auth_browser_path(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: + # Onboarding signs in via the browser only — there is no API-key paste path. monkeypatch.setattr(sections, "persist_browser_login", lambda *a, **k: None) - assert sections.auth(_ScriptedPrompter(select="browser"), ctx) is SectionResult.DONE - - -def test_auth_key_path_valid(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(client, "validate_key", lambda *a, **k: True) - result = sections.auth(_ScriptedPrompter(select="key", text="sk_good"), ctx) - assert result is SectionResult.DONE - assert config.get_api_key("default") == "sk_good" - - -def test_auth_key_path_rejected(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(client, "validate_key", lambda *a, **k: False) - result = sections.auth(_ScriptedPrompter(select="key", text="sk_bad"), ctx) - assert result is SectionResult.FAILED + assert sections.auth(_ScriptedPrompter(), ctx) is SectionResult.DONE def test_build_path_scaffolds(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_samples.py b/tests/test_samples.py deleted file mode 100644 index acddbd31..00000000 --- a/tests/test_samples.py +++ /dev/null @@ -1,112 +0,0 @@ -from pathlib import Path - -from typer.testing import CliRunner - -from aai_cli.main import app - -runner = CliRunner() - -_ENV_KEY = 'os.environ["ASSEMBLYAI_API_KEY"]' - - -def test_samples_list_shows_transcribe(): - result = runner.invoke(app, ["samples", "list"]) - assert result.exit_code == 0 - assert "transcribe" in result.output - - -def test_samples_list_human_mode_renders_bullets(monkeypatch): - # Force human (non-agentic) rendering so the bullet-list branch runs; pins the - # string concatenation in the human renderer (a `-` there would raise TypeError). - monkeypatch.setattr("aai_cli.output.resolve_json", lambda *, explicit: False) - result = runner.invoke(app, ["samples", "list"]) - assert result.exit_code == 0 - assert "Available samples:" in result.output - assert "- transcribe" in result.output - - -def test_samples_list_shows_templates(): - result = runner.invoke(app, ["samples", "list"]) - assert result.exit_code == 0 - assert "transcribe" in result.output - assert "stream" in result.output - assert "agent" in result.output - - -def test_samples_create_agent_uses_env_key(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["samples", "create", "agent"]) - assert result.exit_code == 0 - body = Path(tmp_path, "agent", "agent.py").read_text() - assert _ENV_KEY in body # reads the key from the environment, no secret in the file - assert "session.update" in body # the voice-agent handshake - assert "sounddevice" in body # audio backend (PortAudio bundled in the wheel) - assert "pyaudio" not in body - - -def test_samples_no_subcommand_lists_commands(): - # Bare `aai samples` should show its commands instead of erroring out. - result = runner.invoke(app, ["samples"]) - assert "list" in result.output and "create" in result.output - - -def test_samples_create_stream_uses_env_key(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["samples", "create", "stream"]) - assert result.exit_code == 0 - body = Path(tmp_path, "stream", "stream.py").read_text() - assert _ENV_KEY in body - assert "MicrophoneStream" in body - assert "format_turns=True" in body # the stream sample requests formatted turns - - -def test_samples_create_transcribe_uses_env_key(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["samples", "create", "transcribe"]) - assert result.exit_code == 0 - body = Path(tmp_path, "transcribe", "transcribe.py").read_text() - assert _ENV_KEY in body - assert "import assemblyai as aai" in body - - -def test_samples_create_needs_no_auth(tmp_path, monkeypatch): - # Scaffolding writes no secret, so it works without being logged in. - monkeypatch.chdir(tmp_path) - result = runner.invoke(app, ["samples", "create", "transcribe"]) - assert result.exit_code == 0 - - -def test_samples_create_unknown_name_errors(): - result = runner.invoke(app, ["samples", "create", "nope"]) - assert result.exit_code == 1 - - -def test_samples_create_refuses_existing_without_force(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 - # Second run without --force must refuse. - result = runner.invoke(app, ["samples", "create", "transcribe"]) - assert result.exit_code == 1 - - -def test_samples_create_force_overwrites(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - assert runner.invoke(app, ["samples", "create", "transcribe"]).exit_code == 0 - result = runner.invoke(app, ["samples", "create", "transcribe", "--force"]) - assert result.exit_code == 0 - - -def test_samples_create_is_valid_python(tmp_path, monkeypatch): - import ast - - monkeypatch.chdir(tmp_path) - for name in ("transcribe", "stream", "agent"): - assert runner.invoke(app, ["samples", "create", name]).exit_code == 0 - ast.parse(Path(tmp_path, name, f"{name}.py").read_text()) # generated code parses - - -def test_unknown_sample_has_suggestion(): - # Drive the command body directly through the Typer app for a clean error. - result = runner.invoke(app, ["samples", "create", "nope", "--json"]) - assert result.exit_code == 1 - assert "Try one of" in result.output diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 3e4d3d51..30d7067a 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -11,14 +11,6 @@ def test_help_runs(): assert "AssemblyAI" in result.output -def test_version_command(): - from aai_cli import __version__ - - result = runner.invoke(app, ["version"]) - assert result.exit_code == 0 - assert result.output.strip() == __version__ - - def test_version_flag_prints_and_exits(): # `aai --version` / `-V` is the reflex every CLI answers; the eager callback prints # the version and exits before any command runs. @@ -36,8 +28,10 @@ def test_quiet_suppresses_env_override_warning(monkeypatch): config.set_api_key("default", "sk_live") config.set_profile_env("default", "production") - noisy = runner.invoke(app, ["--env", "sandbox000", "version"]) - quiet = runner.invoke(app, ["--quiet", "--env", "sandbox000", "version"]) + # The warning is emitted by the root callback (before any subcommand), so a bare + # invocation that falls through to the help screen exercises it without network. + noisy = runner.invoke(app, ["--env", "sandbox000"]) + quiet = runner.invoke(app, ["--quiet", "--env", "sandbox000"]) assert "may be rejected" in noisy.output assert "may be rejected" not in quiet.output @@ -56,8 +50,8 @@ def test_shell_completion_is_available(monkeypatch): def test_global_flags_parse(): - # --profile is a global option accepted before a subcommand - assert runner.invoke(app, ["--profile", "staging", "version"]).exit_code == 0 + # --profile is a global option accepted before the eager --version flag + assert runner.invoke(app, ["--profile", "staging", "--version"]).exit_code == 0 def test_stream_registered_top_level(): @@ -82,10 +76,8 @@ def test_help_lists_commands_in_workflow_order(): "onboard", "init", # Setup & Tools - "samples", "doctor", "setup", - "version", # Transcription & AI "transcribe", "stream", From c5b5e369bd71cb5bbebb5be22e4edf61db900825 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 19:25:56 -0700 Subject: [PATCH 28/37] feat(help): action-verb help panels (Build an app vs Run AssemblyAI) Rename the "Transcription & AI" panel to "Run AssemblyAI" and give `init` its own "Build an app" panel, so the help screen distinguishes commands that use AssemblyAI directly from the one that scaffolds a new project. Reword init's help to "Scaffold a new project from a template, then launch it." Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/init.py | 4 ++-- aai_cli/help_panels.py | 5 +++-- aai_cli/main.py | 3 ++- tests/__snapshots__/test_cli_output_snapshots.ambr | 2 +- tests/test_smoke.py | 7 ++++--- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index ad6bf3d2..8123cc34 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -225,7 +225,7 @@ def run_init( @app.command( - rich_help_panel=help_panels.QUICK_START, + rich_help_panel=help_panels.BUILD, epilog=examples_epilog( [ ("Scaffold a new app interactively", "aai init"), @@ -254,7 +254,7 @@ def init( port: int = typer.Option(3000, "--port", help="Local server port."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Scaffold and launch a starter app from a template. + """Scaffold a new project from a template, then launch it. This is the starting point for creating an app — including a voice agent app ('aai init voice-agent'). The 'aai agent' command only runs a live mic diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index 5a1392b3..e4699c1b 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -12,8 +12,9 @@ from __future__ import annotations -QUICK_START = "Quick Start" # zero-to-running onboarding: onboard, init -TRANSCRIPTION = "Transcription & AI" # the verbs you run: transcribe, stream, agent, llm +QUICK_START = "Quick Start" # zero-to-running onboarding: onboard +BUILD = "Build an app" # scaffold a new project: init +TRANSCRIPTION = "Run AssemblyAI" # use AssemblyAI directly: transcribe, stream, agent, llm HISTORY = "History" # browse past work: transcripts, sessions ACCOUNT = "Account" # auth, billing, keys: login/logout/whoami, balance/usage/limits, keys, audit SETUP = "Setup & Tools" # get set up & maintain: doctor, setup diff --git a/aai_cli/main.py b/aai_cli/main.py index cbe24c80..ed7aa828 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -42,11 +42,12 @@ _COMMAND_ORDER = ( # Quick Start — zero-to-running onboarding "onboard", + # Build an app — scaffold a new project "init", # Setup & Tools — get set up & maintain "doctor", "setup", - # Transcription & AI — the verbs you run + # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", "stream", "agent", diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index b15db98f..a1083a5b 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -136,7 +136,7 @@ Usage: aai init [OPTIONS] [TEMPLATE] [DIRECTORY] - Scaffold and launch a starter app from a template. + Scaffold a new project from a template, then launch it. This is the starting point for creating an app — including a voice agent app ('aai init voice-agent'). The 'aai agent' command only runs a live mic diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 30d7067a..4c84bb2c 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -69,16 +69,17 @@ def test_help_lists_commands_in_workflow_order(): assert isinstance(cmd, TyperGroup) ctx = cmd.make_context("aai", [], resilient_parsing=True) names = cmd.list_commands(ctx) # the order shown under --help - # Grouped into Rich help panels (see help_panels.py): Quick Start, - # Setup & Tools, Transcription & AI, History, then Account. + # Grouped into Rich help panels (see help_panels.py): Quick Start, Build an app, + # Setup & Tools, Run AssemblyAI, History, then Account. assert names == [ # Quick Start "onboard", + # Build an app "init", # Setup & Tools "doctor", "setup", - # Transcription & AI + # Run AssemblyAI "transcribe", "stream", "agent", From 9c6c25700a6ea2efafd77525828099c13dab88ac Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 19:26:47 -0700 Subject: [PATCH 29/37] feat(help): order Setup & Tools below Run AssemblyAI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the doctor/setup panel beneath the Run AssemblyAI commands so the help screen flows onboard → build → run → maintain. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/main.py | 6 +++--- tests/test_smoke.py | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/aai_cli/main.py b/aai_cli/main.py index ed7aa828..f10f654d 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -44,14 +44,14 @@ "onboard", # Build an app — scaffold a new project "init", - # Setup & Tools — get set up & maintain - "doctor", - "setup", # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", "stream", "agent", "llm", + # Setup & Tools — get set up & maintain + "doctor", + "setup", # History — browse past work "transcripts", "sessions", diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 4c84bb2c..653d0061 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -70,20 +70,20 @@ def test_help_lists_commands_in_workflow_order(): ctx = cmd.make_context("aai", [], resilient_parsing=True) names = cmd.list_commands(ctx) # the order shown under --help # Grouped into Rich help panels (see help_panels.py): Quick Start, Build an app, - # Setup & Tools, Run AssemblyAI, History, then Account. + # Run AssemblyAI, Setup & Tools, History, then Account. assert names == [ # Quick Start "onboard", # Build an app "init", - # Setup & Tools - "doctor", - "setup", # Run AssemblyAI "transcribe", "stream", "agent", "llm", + # Setup & Tools + "doctor", + "setup", # History "transcripts", "sessions", From 8d76216d6606bacf65a73bd63e66fc337c3772f7 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 19:27:35 -0700 Subject: [PATCH 30/37] fix(help): Title-case the "Build an App" panel Match the casing of the other multi-word panels (Quick Start, Setup & Tools). Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/help_panels.py | 2 +- aai_cli/main.py | 2 +- tests/test_smoke.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/aai_cli/help_panels.py b/aai_cli/help_panels.py index e4699c1b..0f5faaf9 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -13,7 +13,7 @@ from __future__ import annotations QUICK_START = "Quick Start" # zero-to-running onboarding: onboard -BUILD = "Build an app" # scaffold a new project: init +BUILD = "Build an App" # scaffold a new project: init TRANSCRIPTION = "Run AssemblyAI" # use AssemblyAI directly: transcribe, stream, agent, llm HISTORY = "History" # browse past work: transcripts, sessions ACCOUNT = "Account" # auth, billing, keys: login/logout/whoami, balance/usage/limits, keys, audit diff --git a/aai_cli/main.py b/aai_cli/main.py index f10f654d..c1616940 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -42,7 +42,7 @@ _COMMAND_ORDER = ( # Quick Start — zero-to-running onboarding "onboard", - # Build an app — scaffold a new project + # Build an App — scaffold a new project "init", # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 653d0061..298e76f9 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -69,12 +69,12 @@ def test_help_lists_commands_in_workflow_order(): assert isinstance(cmd, TyperGroup) ctx = cmd.make_context("aai", [], resilient_parsing=True) names = cmd.list_commands(ctx) # the order shown under --help - # Grouped into Rich help panels (see help_panels.py): Quick Start, Build an app, + # Grouped into Rich help panels (see help_panels.py): Quick Start, Build an App, # Run AssemblyAI, Setup & Tools, History, then Account. assert names == [ # Quick Start "onboard", - # Build an app + # Build an App "init", # Run AssemblyAI "transcribe", From e6958a3dac18e4a8c35fb07411b9b42552991367 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 20:39:31 -0700 Subject: [PATCH 31/37] feat(ux): act on multi-persona UX/DX review of the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the prioritized recommendations from the persona journey review: - transcribe: add --out FILE to save a clean text artifact; annotate -o values; document the human-by-default --json policy; lead the docstring with the simplest invocation; --show-code now works with no source (emits a placeholder path). - usage: show the per-product breakdown in dollars (from line_items[].price), aggregated by product and reconciling to the window total — no more raw quantities mixed with dollars. - env: the mismatch warning now also fires for AAI_ENV (not just --env) and names the source, catching silent sandbox/prod key swaps. - errors: root-callback failures (e.g. bad --env) honor a --json/-o json request and emit the uniform {"error": ...} shape; soften the not-signed-in message and point it at `aai onboard`. - stream/agent: document stdin raw-PCM streaming (`aai stream -` + ffmpeg), cross-reference the in-process vs piped LLM paths, surface the headphones caveat in `agent --help`. - setup: enumerate the three installed artifacts (docs MCP, AssemblyAI skill, bundled aai-cli skill) in help. - limits: clarify the empty state (standard limits apply). - root help: document that global options go before the subcommand. - templates: comment the intentional httpx2 import so integrators don't "fix" it. - AGENTS.md: fix the stale `aai claude` reference (now `aai setup`). Snapshots regenerated; new tests added; full gate (ruff/mypy/pyright/vulture/ deptry/import-linter/diff-cover 100%/diff-scoped mutation) green. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- aai_cli/commands/account.py | 46 ++++++------ aai_cli/commands/agent.py | 5 +- aai_cli/commands/setup.py | 7 +- aai_cli/commands/stream.py | 17 +++-- aai_cli/commands/transcribe.py | 65 ++++++++++++++--- aai_cli/context.py | 20 ++++-- aai_cli/errors.py | 6 +- .../init/templates/live-captions/api/index.py | 2 + .../init/templates/voice-agent/api/index.py | 2 + aai_cli/main.py | 38 +++++++++- aai_cli/output.py | 2 +- .../test_cli_output_snapshots.ambr | 71 +++++++++++++------ tests/test_account_command.py | 42 +++++++++-- tests/test_completion.py | 12 ++++ tests/test_context.py | 25 +++++++ tests/test_errors.py | 7 +- tests/test_login.py | 23 +++--- tests/test_main_module.py | 18 +++++ tests/test_transcribe.py | 71 +++++++++++++++++++ 20 files changed, 389 insertions(+), 92 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afe6abad..2cf1fad2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index c3ec00bb..a414a7f4 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -67,8 +67,9 @@ 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") @@ -76,30 +77,25 @@ def _line_item_label(line_item: Mapping[str, object]) -> str: ), "", ) - 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.") @@ -233,7 +229,9 @@ def body(state: AppState, json_mode: bool) -> None: 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 configured for this account.") + return output.muted( + "No custom rate limits — this account uses AssemblyAI's standard limits." + ) table = output.data_table("service", "limit") for limit in limits: table.add_row( diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 83212245..950662ee 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -117,8 +117,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. diff --git a/aai_cli/commands/setup.py b/aai_cli/commands/setup.py index f4d77429..d649c374 100644 --- a/aai_cli/commands/setup.py +++ b/aai_cli/commands/setup.py @@ -306,7 +306,12 @@ def install( force: bool = typer.Option(False, "--force", help="Reinstall even if already present."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Install the AssemblyAI docs MCP server and skills into your coding agent.""" + """Set up your coding agent for AssemblyAI by installing three things: + + the assemblyai-docs MCP server (live API docs, via `claude mcp add`), the AssemblyAI + skill (via `npx skills add`), and the bundled aai-cli skill (copied from this package, + no network). Each step is idempotent and skipped if already present unless --force. + """ def body(_state: AppState, json_mode: bool) -> None: steps = [_install_mcp(scope, force), _install_skill(force), _install_cli_skill(force)] diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index 5836434b..c16d2ad0 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -78,6 +78,10 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None: ("Stream from your microphone", "aai stream"), ("Stream mic + system audio on macOS", "aai stream --system-audio"), ("Stream the hosted sample", "aai stream --sample"), + ( + "Stream raw PCM from a pipe (16 kHz mono s16le)", + "ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream -", + ), ( "Summarize action items live as you talk", 'aai stream --llm "summarize action items"', @@ -90,7 +94,8 @@ def stream( ctx: typer.Context, source: str | None = typer.Argument( None, - help="Audio file path, URL, or YouTube URL to stream. Omit to use the microphone.", + help="Audio file path, URL, or YouTube URL to stream. Use - for raw PCM16/mono/16k " + "on stdin. Omit to use the microphone.", ), sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), # audio capture @@ -312,11 +317,13 @@ def stream( help="Print the equivalent Python SDK code and exit (does not stream).", ), ) -> None: - """Transcribe live audio in real time — from your mic, a file, or a URL. + """Transcribe live audio in real time — from your mic, a file, a URL, or a pipe. - --prompt biases the speech model. --llm runs a prompt over the live transcript - through LLM Gateway, refreshing the answer on every finalized turn (e.g. - "summarize action items"). + Pass - as the source to read raw PCM16/mono/16k audio on stdin, e.g. + ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream -. --prompt biases the + speech model. --llm runs a prompt over the live transcript in-process, refreshing the + answer on every finalized turn; for a separate step instead, pipe the text out with + -o text | aai llm -f "…". """ def body(state: AppState, json_mode: bool) -> None: diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index 20c99d73..f794f31a 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -1,11 +1,13 @@ from __future__ import annotations +import json import tempfile from pathlib import Path from typing import Any import assemblyai as aai import typer +from rich.markup import escape from aai_cli import ( choices, @@ -35,6 +37,21 @@ def _render_transform_steps(d: dict[str, Any]) -> str: return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) +def _out_payload( + transcript: aai.Transcript, + output_field: choices.TranscriptOutput | None, + *, + json_mode: bool, +) -> str: + """The text to write for ``--out``: the chosen ``-o`` field, the ``--json`` payload, + or the plain transcript text — the same content stdout would get, as a file artifact.""" + if output_field is not None: + return client.select_transcript_field(transcript, output_field) + if json_mode: + return json.dumps(client.transcript_json_payload(transcript), default=str) + return client.select_transcript_field(transcript, choices.TranscriptOutput.text) + + def _transcribe_audio( api_key: str, source: str | None, @@ -338,12 +355,23 @@ def transcribe( help="Max tokens.", rich_help_panel=help_panels.OPT_LLM, ), - json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + json_out: bool = typer.Option( + False, + "--json", + help="Output the full result as JSON. Text stays the default even when piped; " + "opt in here (same as -o json).", + ), output_field: choices.TranscriptOutput | None = typer.Option( None, "-o", "--output", - help="Print one field of the result.", + help="Print one field: text, id, status, utterances, srt (captions), or json.", + ), + out: Path | None = typer.Option( + None, + "--out", + help="Save the result to a file instead of printing it (clean text; pairs with -o).", + dir_okay=False, ), show_code: bool = typer.Option( False, @@ -353,9 +381,10 @@ def transcribe( ) -> None: """Transcribe an audio file, URL, or YouTube link. - A YouTube URL is downloaded first, then transcribed. Curated flags cover common - features; --config KEY=VALUE and --config-file reach every other field. Analysis - results (summary, chapters, sentiment, ...) render automatically in human mode. + Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo). Save with + --out FILE, or pipe one field with -o text. A YouTube URL is downloaded first, then + transcribed. Curated flags cover common features; --config KEY=VALUE and --config-file + reach every other field. Analysis (summary, chapters, ...) renders in human mode. """ def body(state: AppState, json_mode: bool) -> None: @@ -403,15 +432,26 @@ def body(state: AppState, json_mode: bool) -> None: } flags.update(config_builder.auth_header_flags(webhook_auth_header)) + if out is not None and llm_prompt: + # --out captures the transcript itself; an LLM transform is a separate step. + raise UsageError( + "--out can't be combined with --llm.", + suggestion='Pipe the transform instead, e.g. -o text | aai llm -f "…".', + ) + merged = config_builder.merge_transcribe_config( flags=flags, overrides=config_kv, config_file=config_file ) if show_code: - # Print-only: build the equivalent script from the flags and exit without - # transcribing or authenticating. Raw stdout so `--show-code > script.py` - # yields a runnable file. - audio = client.resolve_audio_source(source, sample=sample) + # Print-only: build the equivalent script and exit without transcribing or + # authenticating (raw stdout, so `--show-code > script.py` runs). No + # source/--sample needed — fall back to a placeholder path for a pure snippet. + audio = ( + client.resolve_audio_source(source, sample=sample) + if source or sample + else "your-audio-file.mp3" + ) gateway = code_gen.gateway_options(list(llm_prompt or []), model, max_tokens) output.print_code(code_gen.transcribe(merged, audio, llm_gateway=gateway)) return @@ -422,6 +462,13 @@ def body(state: AppState, json_mode: bool) -> None: with output.status("Transcribing…", json_mode=json_mode): transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) + if out is not None: + # Write a clean file artifact and confirm on stderr; stdout stays empty. + out.write_text(_out_payload(transcript, output_field, json_mode=json_mode) + "\n") + if not state.quiet: + output.error_console.print(output.success(f"Saved to {escape(str(out))}")) + return + if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). output.emit_text(client.select_transcript_field(transcript, output_field)) diff --git a/aai_cli/context.py b/aai_cli/context.py index 5a25e169..b605c2b9 100644 --- a/aai_cli/context.py +++ b/aai_cli/context.py @@ -55,20 +55,26 @@ def resolve_session(self) -> tuple[int, str]: return account_id, session["jwt"] def env_override_warning(self) -> str | None: - """A warning when an explicit --env contradicts the profile's stored env. + """A warning when the selected environment contradicts the profile's stored env. - The stored key was minted against the profile's environment, so forcing a - different --env points the client at hosts that key won't authenticate to. + The stored key was minted against the profile's environment, so pointing the + client at a different one sends it to hosts that key won't authenticate to. This + catches both an explicit ``--env`` and the easier-to-miss case of an inherited + ``AAI_ENV`` silently swapping the environment, and names which source selected it. """ - if self.env is None: + if self.env is not None: + source, selected = "--env", self.env + elif (from_env := os.environ.get("AAI_ENV")) is not None: + source, selected = "AAI_ENV", from_env + else: return None profile = self.resolve_profile() profile_env = config.get_profile_env(profile) - if profile_env is None or profile_env == self.env: + if profile_env is None or profile_env == selected: return None return ( - f"Using --env {self.env}, but profile '{profile}' was set up for " - f"{profile_env}; its stored key may be rejected by {self.env}." + f"Using {source} {selected}, but profile '{profile}' was set up for " + f"{profile_env}; its stored key may be rejected by {selected}." ) diff --git a/aai_cli/errors.py b/aai_cli/errors.py index bedd81c8..ec458b53 100644 --- a/aai_cli/errors.py +++ b/aai_cli/errors.py @@ -36,9 +36,11 @@ class NotAuthenticated(CLIError): # same split gh uses. def __init__( self, - message: str = "Not authenticated.", + message: str = "You're not signed in.", *, - suggestion: str | None = "Run 'aai login'.", + suggestion: str | None = ( + "Run 'aai onboard' for guided setup, or 'aai login' if you have an account." + ), ) -> None: super().__init__( message, error_type="not_authenticated", exit_code=4, suggestion=suggestion diff --git a/aai_cli/init/templates/live-captions/api/index.py b/aai_cli/init/templates/live-captions/api/index.py index 6da4180a..22d7b634 100644 --- a/aai_cli/init/templates/live-captions/api/index.py +++ b/aai_cli/init/templates/live-captions/api/index.py @@ -11,6 +11,8 @@ from pathlib import Path +# httpx2 is Pydantic's maintained fork of httpx (API-identical, just renamed) — not a +# typo. Keep the "2"; see requirements.txt. import httpx2 from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse diff --git a/aai_cli/init/templates/voice-agent/api/index.py b/aai_cli/init/templates/voice-agent/api/index.py index 5144773e..26e6473d 100644 --- a/aai_cli/init/templates/voice-agent/api/index.py +++ b/aai_cli/init/templates/voice-agent/api/index.py @@ -11,6 +11,8 @@ from pathlib import Path +# httpx2 is Pydantic's maintained fork of httpx (API-identical, just renamed) — not a +# typo. Keep the "2"; see requirements.txt. import httpx2 from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse diff --git a/aai_cli/main.py b/aai_cli/main.py index c1616940..d57757c8 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -80,6 +80,14 @@ def list_commands(self, ctx: ClickContext) -> list[str]: super().list_commands(ctx), key=lambda name: (rank.get(name, len(rank)), name) ) + def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]: + # Stash the full token list before anything is parsed, so the root callback can + # tell whether the (not-yet-parsed) subcommand opted into JSON — see + # `_command_line_requests_json`. Recorded here because Click clears the pending + # args off the context before the group callback runs. + ctx.meta[_RAW_ARGS_META_KEY] = list(args) + return super().parse_args(ctx, args) + # Typer renders option flags and command names in "bold cyan" by default; retint # both to the brand accent (the logo blue) so the help screen matches the rest of @@ -130,6 +138,27 @@ def _interactive_session() -> bool: return sys.stdin.isatty() and sys.stdout.isatty() +_RAW_ARGS_META_KEY = "aai_raw_args" + + +def _command_line_requests_json(raw_args: list[str]) -> bool: + """Whether the token list opts into JSON (``--json``, ``-o json``, ``--output json``, + or their glued forms). + + The root callback runs before the subcommand parses its own ``--json``, so a failure + raised here (e.g. a bad ``--env``) would otherwise always render human text — leaving a + ``… --json`` pipeline without the uniform ``{"error": …}`` shape it relies on. The group + stashes the raw token list in ``ctx.meta`` (see ``_OrderedGroup.parse_args``) before the + callback runs, so sniffing it lets every failure class honor the request. + """ + for index, token in enumerate(raw_args): + if token in ("--json", "--output=json", "-ojson"): + return True + if token in ("-o", "--output") and raw_args[index + 1 : index + 2] == ["json"]: + return True + return False + + def _offer_or_help(ctx: typer.Context, state: AppState) -> None: """No subcommand given: offer guided setup to a credential-less, interactive user; otherwise print help. Never prompts in a non-interactive session, and never on @@ -153,6 +182,7 @@ def _offer_or_help(ctx: typer.Context, state: AppState) -> None: ("Guided setup (start here)", "aai onboard"), ("Transcribe a file", "aai transcribe call.mp3"), ("Scaffold a starter app", "aai init"), + ("Global options go before the command", "aai --sandbox transcribe call.mp3"), ] ), ) @@ -184,9 +214,11 @@ def main( env = "sandbox000" state = AppState(profile=profile, env=env, quiet=quiet) ctx.obj = state - # The command's own --json flag isn't parsed yet, and output is human-by-default, so a - # root-callback (e.g. bad --env) error renders as human text on stderr. - json_mode = output.resolve_json(explicit=False) + # The command's own --json flag isn't parsed yet, so sniff the pending command line: + # a root-callback failure (e.g. bad --env) still emits the JSON error shape when the + # invocation opted into JSON, and renders human text on stderr otherwise. + raw_args: list[str] = ctx.meta.get(_RAW_ARGS_META_KEY, []) + json_mode = output.resolve_json(explicit=_command_line_requests_json(raw_args)) try: environments.set_active(resolve_environment(state)) except CLIError as err: diff --git a/aai_cli/output.py b/aai_cli/output.py index 41909087..a83cf992 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -212,7 +212,7 @@ def print_banner() -> None: console.print( f"[aai.brand]🎙️ AssemblyAI CLI[/aai.brand] " f"[aai.muted]{__version__} — {_TAGLINE}[/aai.muted]", - highlight=False, + highlight=False, # pragma: no mutate (purely cosmetic: toggles Rich repr coloring, not text) ) console.print() console.print(Text(_BANNER, style="aai.brand")) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index a1083a5b..24fae685 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -6,8 +6,12 @@ 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. @@ -424,7 +428,14 @@ Usage: aai setup install [OPTIONS] - Install the AssemblyAI docs MCP server and skills into your coding agent. + Set up your coding agent for AssemblyAI by installing three things: + + the assemblyai-docs MCP server (live API docs, via `claude mcp add`), the + AssemblyAI + skill (via `npx skills add`), and the bundled aai-cli skill (copied from this + package, + no network). Each step is idempotent and skipped if already present unless + --force. ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --scope [user|project|local] Config scope to register the MCP under. │ @@ -494,15 +505,21 @@ Usage: aai stream [OPTIONS] [SOURCE] - Transcribe live audio in real time — from your mic, a file, or a URL. + Transcribe live audio in real time — from your mic, a file, a URL, or a pipe. - --prompt biases the speech model. --llm runs a prompt over the live transcript - through LLM Gateway, refreshing the answer on every finalized turn (e.g. - "summarize action items"). + Pass - as the source to read raw PCM16/mono/16k audio on stdin, e.g. + ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream -. --prompt biases + the + speech model. --llm runs a prompt over the live transcript in-process, + refreshing the + answer on every finalized turn; for a separate step instead, pipe the text out + with + -o text | aai llm -f "…". ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ - │ source [SOURCE] Audio file path, URL, or YouTube URL to stream. Omit │ - │ to use the microphone. │ + │ source [SOURCE] Audio file path, URL, or YouTube URL to stream. Use │ + │ - for raw PCM16/mono/16k on stdin. Omit to use the │ + │ microphone. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --sample Stream the hosted wildfires.mp3 sample. │ @@ -598,6 +615,8 @@ $ aai stream --system-audio Stream the hosted sample $ aai stream --sample + Stream raw PCM from a pipe (16 kHz mono s16le) + $ ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream - Summarize action items live as you talk $ aai stream --llm "summarize action items" Print equivalent Python instead of running @@ -614,11 +633,13 @@ Transcribe an audio file, URL, or YouTube link. - A YouTube URL is downloaded first, then transcribed. Curated flags cover - common - features; --config KEY=VALUE and --config-file reach every other field. - Analysis - results (summary, chapters, sentiment, ...) render automatically in human + Quickest start: aai transcribe call.mp3 (or --sample for the hosted demo). + Save with + --out FILE, or pipe one field with -o text. A YouTube URL is downloaded first, + then + transcribed. Curated flags cover common features; --config KEY=VALUE and + --config-file + reach every other field. Analysis (summary, chapters, ...) renders in human mode. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ @@ -627,9 +648,18 @@ ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --sample Use the hosted │ │ wildfires.mp3 sample. │ - │ --json Output raw JSON. │ - │ --output -o [text|id|status|utterances Print one field of the │ - │ |srt|json] result. │ + │ --json Output the full result as │ + │ JSON. Text stays the │ + │ default even when piped; │ + │ opt in here (same as -o │ + │ json). │ + │ --output -o [text|id|status|utterances Print one field: text, id, │ + │ |srt|json] status, utterances, srt │ + │ (captions), or json. │ + │ --out FILE Save the result to a file │ + │ instead of printing it │ + │ (clean text; pairs with │ + │ -o). │ │ --show-code Print the equivalent Python │ │ SDK code and exit (does not │ │ transcribe). │ @@ -839,8 +869,9 @@ # --- # name: test_error_human_render_matches_snapshot[not_authenticated] ''' - Error: Not authenticated. - Suggestion: Run 'aai login'. + Error: You're not signed in. + Suggestion: Run 'aai onboard' for guided setup, or 'aai login' if you have an + account. ''' # --- @@ -872,7 +903,7 @@ # --- # name: test_error_json_render_matches_snapshot[not_authenticated] ''' - {"error": {"type": "not_authenticated", "message": "Not authenticated.", "suggestion": "Run 'aai login'."}} + {"error": {"type": "not_authenticated", "message": "You're not signed in.", "suggestion": "Run 'aai onboard' for guided setup, or 'aai login' if you have an account."}} ''' # --- diff --git a/tests/test_account_command.py b/tests/test_account_command.py index db078c89..0b4567ef 100644 --- a/tests/test_account_command.py +++ b/tests/test_account_command.py @@ -142,10 +142,38 @@ def test_usage_helpers_format_windows_and_line_items(): ) == "2026-01-01" ) - assert account._line_item_label({"name": "minutes", "total": "12.500"}) == "minutes: 12.5" - assert account._line_item_label({"product": "streaming"}) == "streaming" - assert account._line_item_label({"quantity": 3}) == "3" - assert account._line_item_label({}) == "" + # Every recognized label key resolves (pins each entry in the lookup tuple). + for key in ("name", "product", "service", "feature", "model", "type", "description"): + assert account._line_item_name({key: "X"}) == "X" + assert account._line_item_name({"name": "minutes", "total": "12.500"}) == "minutes" + assert account._line_item_name({"product": "streaming"}) == "streaming" + assert account._line_item_name({"quantity": 3}) == "" + assert account._line_item_name({}) == "" + # Breakdown aggregates by product and shows dollars (from `price` cents), biggest + # first, so the line items sum to the window total and reconcile with it. + assert ( + account._line_items_summary( + { + "line_items": [ + {"name": "minutes", "price": 1000.0}, + {"name": "streaming", "price": 2500.0}, + {"name": "minutes", "price": 250.0}, + ] + } + ) + == "streaming: $25.00, minutes: $12.50" + ) + # Equal-dollar products break the tie by name (pins the nc[0] secondary sort key). + assert ( + account._line_items_summary( + {"line_items": [{"name": "zeta", "price": 500.0}, {"name": "alpha", "price": 500.0}]} + ) + == "alpha: $5.00, zeta: $5.00" + ) + # A line item with no recognizable product label is grouped under "other". + assert account._line_items_summary({"line_items": [{"price": 500.0}]}) == "other: $5.00" + # Zero-dollar products are dropped (they only add noise and still reconcile to 0). + assert account._line_items_summary({"line_items": [{"name": "free", "price": 0.0}]}) == "" assert account._line_items_summary({"line_items": "bad"}) == "" @@ -166,9 +194,9 @@ def test_usage_human_renders_breakdown(monkeypatch): result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "breakdown" in result.output - # The breakdown shows each product's quantity (units), distinct from the - # dollar total derived from price. - assert "minutes: 10" in result.output + # The breakdown shows each product's spend in dollars (1000 cents = $10.00), the + # same unit as the `total` column, so the two reconcile. + assert "minutes: $10.00" in result.output def test_usage_human_summarizes_empty_range(monkeypatch): diff --git a/tests/test_completion.py b/tests/test_completion.py index bf5231e3..8c1f61b2 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -16,6 +16,18 @@ def test_shell_completion_is_enabled(): assert "--install-completion" in option_names +def test_show_completion_help_is_trimmed_and_scoped(): + # main.py trims the long built-in --show-completion help to one line; that trim must + # actually fire (the `for … or ()` loop) and target only --show-completion, not + # --install-completion (the `isinstance(...) and startswith(...)` guard). + command = typer.main.get_command(app) + help_by_opt = { + opt: getattr(param, "help", None) for param in command.params for opt in param.opts + } + assert help_by_opt.get("--show-completion") == "Show completion for the current shell." + assert help_by_opt.get("--install-completion") != "Show completion for the current shell." + + def test_complete_model_filters_by_prefix(): suggestions = complete_model("gpt") assert suggestions # at least one gpt-* model is known diff --git a/tests/test_context.py b/tests/test_context.py index 977bccec..00bb4bb4 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -163,6 +163,31 @@ def test_env_override_warning_none_when_profile_has_no_env(): assert env_override_warning(AppState(env="production")) is None +def test_env_override_warning_when_aai_env_contradicts_profile(monkeypatch): + # A silent AAI_ENV swap points a profile's key at the wrong hosts just like --env + # does, so it must warn too — and name AAI_ENV as the source. + config.set_profile_env("default", "sandbox000") + monkeypatch.setenv("AAI_ENV", "production") + warning = env_override_warning(AppState(env=None)) + assert warning is not None + assert "AAI_ENV" in warning + + +def test_env_override_warning_flag_beats_aai_env(monkeypatch): + # An explicit --env wins precedence and is the named source, not AAI_ENV. + config.set_profile_env("default", "sandbox000") + monkeypatch.setenv("AAI_ENV", "sandbox000") + warning = env_override_warning(AppState(env="production")) + assert warning is not None + assert "--env" in warning + + +def test_env_override_warning_none_when_aai_env_matches_profile(monkeypatch): + config.set_profile_env("default", "sandbox000") + monkeypatch.setenv("AAI_ENV", "sandbox000") + assert env_override_warning(AppState(env=None)) is None + + def test_resolve_session_returns_account_and_jwt(): from aai_cli import config from aai_cli.context import AppState, resolve_session diff --git a/tests/test_errors.py b/tests/test_errors.py index a6bd934e..80bf8d45 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -5,8 +5,11 @@ def test_not_authenticated_defaults(): err = NotAuthenticated() assert err.exit_code == 4 assert err.error_type == "not_authenticated" - assert err.message == "Not authenticated." - assert err.suggestion == "Run 'aai login'." + assert err.message == "You're not signed in." + assert ( + err.suggestion + == "Run 'aai onboard' for guided setup, or 'aai login' if you have an account." + ) def test_api_error_carries_fields(): diff --git a/tests/test_login.py b/tests/test_login.py index efd3f848..9e305cb0 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,3 +1,4 @@ +import json from unittest.mock import patch from typer.testing import CliRunner @@ -14,7 +15,6 @@ def _fake_login_result(key="sk_from_oauth"): def test_login_with_api_key_flag_stores_key(): - import json with patch("aai_cli.commands.login.client.validate_key", return_value=True): result = runner.invoke(app, ["login", "--api-key", "sk_flag", "--json"]) @@ -38,7 +38,6 @@ def test_login_stores_under_named_profile(): def test_whoami_reports_authenticated(): - import json config.set_api_key("default", "sk_1234567890") with patch("aai_cli.commands.login.client.validate_key", return_value=True): @@ -73,7 +72,6 @@ def test_whoami_unauthenticated_runs_login(monkeypatch): def test_logout_clears_key(): - import json config.set_api_key("default", "sk_1234567890") result = runner.invoke(app, ["logout", "--json"]) @@ -163,7 +161,6 @@ def test_sandbox_flag_is_shortcut_for_env(monkeypatch): def test_whoami_reports_env(): - import json config.set_api_key("default", "sk_1234567890") with patch("aai_cli.commands.login.client.validate_key", return_value=True): @@ -176,7 +173,6 @@ def test_whoami_reports_env(): def test_root_callback_keeps_profile_env_without_sandbox(): # Without --sandbox the profile's own env must stand (pins `sandbox and env is # None`: an `or` would force sandbox000 onto every default invocation). - import json config.set_api_key("default", "sk_1234567890") config.set_profile_env("default", "production") @@ -189,7 +185,6 @@ def test_root_callback_keeps_profile_env_without_sandbox(): def test_root_callback_sandbox_overrides_profile_env(): # --sandbox forces sandbox000 even when the profile is bound elsewhere (pins the # `env is None` arm: an `is not None` would leave the profile env in place). - import json config.set_api_key("default", "sk_1234567890") config.set_profile_env("default", "production") @@ -216,6 +211,20 @@ def test_unknown_env_exits_2(): assert '"error"' not in result.output # never the JSON shape +def test_root_callback_error_honors_json_request(): + # A callback-level failure (bad --env) runs before the subcommand parses its own + # --json, but a `… --json` pipeline still expects the uniform {"error": …} shape, so + # the callback sniffs the pending command line and emits JSON for every failure class. + for args in ( + ["--env", "bogus", "whoami", "--json"], + ["--env", "bogus", "transcripts", "list", "--json"], + ): + result = runner.invoke(app, args) + assert result.exit_code == 2 + payload = json.loads(result.output) + assert payload["error"]["type"] == "invalid_environment" + + def test_env_override_prints_warning_to_stderr(): # The root callback warns when an explicit --env contradicts the profile's stored # env (the stored key was minted for a different environment). @@ -237,7 +246,6 @@ def test_rejected_api_key_has_suggestion(monkeypatch): 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) @@ -250,7 +258,6 @@ def test_whoami_reports_session_and_account(): 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): diff --git a/tests/test_main_module.py b/tests/test_main_module.py index af06e645..f316fcc1 100644 --- a/tests/test_main_module.py +++ b/tests/test_main_module.py @@ -6,6 +6,24 @@ import aai_cli.main as main_mod +def test_command_line_requests_json_recognizes_every_form(): + f = main_mod._command_line_requests_json + assert f(["whoami", "--json"]) + assert f(["transcribe", "a.mp3", "-o", "json"]) + assert f(["transcribe", "a.mp3", "--output", "json"]) + assert f(["transcribe", "a.mp3", "--output=json"]) + assert f(["transcribe", "a.mp3", "-ojson"]) + # `-o json` is detected even when more tokens follow (pins the 1-element slice width). + assert f(["transcribe", "-o", "json", "--speaker-labels"]) + + +def test_command_line_requests_json_false_for_text_and_bare(): + f = main_mod._command_line_requests_json + assert not f(["transcribe", "a.mp3", "-o", "text"]) + assert not f(["transcribe", "a.mp3"]) + assert not f([]) + + def test_run_exits_clean_on_broken_pipe(monkeypatch): """A closed downstream pipe (`| head`) is success, not an error traceback.""" diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 57842344..3342bb02 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -378,6 +378,77 @@ def test_transcribe_show_code_prints_without_transcribing(monkeypatch): assert "import assemblyai as aai" in result.output assert "TranscriptionConfig(" in result.output assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.output + # --sample resolves to the hosted sample URL (not the no-source placeholder). + assert "wildfires" in result.output + assert "your-audio-file.mp3" not in result.output + + +def test_transcribe_show_code_without_source_uses_placeholder(monkeypatch): + # --show-code never transcribes, so it must not demand a source/--sample; it emits + # runnable code with a clearly-marked placeholder path instead of erroring. + def _boom(*a, **k): + raise AssertionError("must not transcribe") + + monkeypatch.setattr("aai_cli.commands.transcribe.client.transcribe", _boom) + result = runner.invoke(app, ["transcribe", "--show-code"]) + assert result.exit_code == 0 + assert "import assemblyai as aai" in result.output + assert "your-audio-file.mp3" in result.output + + +def test_transcribe_out_writes_text_file(tmp_path): + _auth() + out = tmp_path / "episode.txt" + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--out", str(out)]) + assert result.exit_code == 0 + assert out.read_text() == "hello world\n" + # The transcript went to the file, not the terminal — stdout stays clean. + assert "hello world" not in result.output + # A confirmation is shown on stderr so the user knows where it landed. + assert "Saved to" in result.output + + +def test_transcribe_out_quiet_suppresses_confirmation(tmp_path): + # -q silences the "Saved to" confirmation, but the file is still written. + _auth() + out = tmp_path / "episode.txt" + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + result = runner.invoke(app, ["-q", "transcribe", "audio.mp3", "--out", str(out)]) + assert result.exit_code == 0 + assert out.read_text() == "hello world\n" + assert "Saved to" not in result.output + + +def test_transcribe_out_with_output_field_writes_that_field(tmp_path): + _auth() + out = tmp_path / "id.txt" + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "id", "--out", str(out)]) + assert result.exit_code == 0 + assert out.read_text() == "t_1\n" + + +def test_transcribe_out_with_json_writes_json_file(tmp_path): + _auth() + out = tmp_path / "t.json" + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--json", "--out", str(out)]) + assert result.exit_code == 0 + assert json.loads(out.read_text())["id"] == "t_1" + + +def test_transcribe_out_with_llm_is_a_usage_error(tmp_path): + # --out captures the transcript; chaining an LLM transform into a file isn't + # supported (pipe it instead), so the combination is rejected up front. + _auth() + out = tmp_path / "x.txt" + with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): + result = runner.invoke( + app, ["transcribe", "audio.mp3", "--llm", "summarize", "--out", str(out)] + ) + assert result.exit_code == 2 + assert not out.exists() def test_transcribe_show_code_ignores_json_flag(monkeypatch): From 75f0bd3d26bb4393e635acf603172137a65c0871 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 20:52:57 -0700 Subject: [PATCH 32/37] feat(help): expand and sharpen --help examples across commands Audited every command's `--help` Examples panel and reworked them to surface high-value capabilities that were previously undiscoverable: - root: lead with breadth (transcribe/stream/agent) + a `--llm` one-liner - transcribe: add YouTube input, speaker labels, PII redaction, summarization, and `--llm` Q&A; drop the redundant `-o text` example - stream: add file/URL source, speaker labels, and keyterm prompts - agent: show `--system-prompt` persona - llm: add model + `--system` example, reword transcript-id example - transcripts/sessions: add `--json | jq` pipelines and id-chaining - account/keys/audit: add `--json | jq` scripting recipes; fix the `audit --limit 20` example that showed the default as if required - init: show the voice-agent template and `--no-install` - doctor/setup: add `--json` and `--force`/`--scope` examples No piping into `aai llm` and no `--sandbox` examples per review. Snapshots regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/account.py | 7 ++ aai_cli/commands/agent.py | 1 + aai_cli/commands/audit.py | 4 +- aai_cli/commands/doctor.py | 1 + aai_cli/commands/init.py | 5 ++ aai_cli/commands/keys.py | 5 ++ aai_cli/commands/llm.py | 9 +- aai_cli/commands/sessions.py | 15 +++- aai_cli/commands/setup.py | 3 + aai_cli/commands/stream.py | 7 +- aai_cli/commands/transcribe.py | 11 ++- aai_cli/commands/transcripts.py | 9 ++ aai_cli/main.py | 8 +- .../test_cli_output_snapshots.ambr | 89 +++++++++++++++---- 14 files changed, 145 insertions(+), 29 deletions(-) diff --git a/aai_cli/commands/account.py b/aai_cli/commands/account.py index a414a7f4..b363add9 100644 --- a/aai_cli/commands/account.py +++ b/aai_cli/commands/account.py @@ -106,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'"), ] ), ) @@ -134,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'", + ), ] ), ) @@ -213,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"), ] ), ) diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 950662ee..00b5084c 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -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"), ] diff --git a/aai_cli/commands/audit.py b/aai_cli/commands/audit.py index 0dd8bf51..33b261ce 100644 --- a/aai_cli/commands/audit.py +++ b/aai_cli/commands/audit.py @@ -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"), ] ), ) diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index f648b125..2ae8185f 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -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"), ] ), ) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index 8123cc34..51ed721a 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -233,7 +233,12 @@ def run_init( "Scaffold an audio transcription app into ./my-app", "aai init audio-transcription my-app", ), + ("Scaffold a voice agent app", "aai init voice-agent"), ("Scaffold into the current directory", "aai init audio-transcription --here"), + ( + "Scaffold only, without installing or launching", + "aai init audio-transcription --no-install", + ), ] ), ) diff --git a/aai_cli/commands/keys.py b/aai_cli/commands/keys.py index cb4543c8..06ea514e 100644 --- a/aai_cli/commands/keys.py +++ b/aai_cli/commands/keys.py @@ -47,6 +47,7 @@ def _default_project_id(account_id: int, jwt: str) -> int: [ ("List your API keys (masked)", "aai keys list"), ("As JSON for scripting", "aai keys list --json"), + ("Get key ids to use with rename", "aai keys list --json | jq '.[].id'"), ] ), ) @@ -96,6 +97,10 @@ def render(data: list[dict[str, object]]) -> Table: [ ("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"), + ( + "Capture the new key into an env var", + "export ASSEMBLYAI_API_KEY=$(aai keys create --name ci --json | jq -r '.api_key')", + ), ] ) ) diff --git a/aai_cli/commands/llm.py b/aai_cli/commands/llm.py index bc197894..cf0c2e68 100644 --- a/aai_cli/commands/llm.py +++ b/aai_cli/commands/llm.py @@ -46,8 +46,15 @@ def _validate_follow_args( rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( [ - ("Summarize a past transcript", 'aai llm "summarize" --transcript-id 5551234-abcd'), + ( + "Ask about a past transcript", + 'aai llm "summarize the key decisions" --transcript-id 5551234-abcd', + ), ("Pipe any text in", 'echo "meeting notes" | aai llm "turn into action items"'), + ( + "Pick a model and add a system prompt", + 'aai llm "draft a follow-up email" --model claude-opus-4-7 --system "Be concise."', + ), ("See available models", "aai llm --list-models"), ] ), diff --git a/aai_cli/commands/sessions.py b/aai_cli/commands/sessions.py index 4e833fca..a4414283 100644 --- a/aai_cli/commands/sessions.py +++ b/aai_cli/commands/sessions.py @@ -36,7 +36,15 @@ def _session_rows(value: object) -> list[dict[str, object]]: epilog=examples_epilog( [ ("List recent streaming sessions", "aai sessions list"), - ("Only completed sessions", "aai sessions list --status completed"), + ("Find failed sessions", "aai sessions list --status error"), + ( + "Inspect the most recent session", + "aai sessions get $(aai sessions list --json | jq -r '.[0].session_id')", + ), + ( + "Total audio across recent sessions (seconds)", + "aai sessions list --json | jq '[.[].audio_duration_sec] | add'", + ), ] ), ) @@ -83,6 +91,11 @@ def render(data: list[dict[str, object]]) -> Table: epilog=examples_epilog( [ ("Show one session's details", "aai sessions get "), + ("Raw JSON for one session", "aai sessions get --json"), + ( + "Drill into the latest session", + "aai sessions get $(aai sessions list --json | jq -r '.[0].session_id')", + ), ] ) ) diff --git a/aai_cli/commands/setup.py b/aai_cli/commands/setup.py index d649c374..498d9b40 100644 --- a/aai_cli/commands/setup.py +++ b/aai_cli/commands/setup.py @@ -293,6 +293,7 @@ def _render(data: dict[str, list[Step]]) -> str: [ ("Set up your coding agent for AssemblyAI", "aai setup install"), ("Install for the current project only", "aai setup install --scope project"), + ("Reinstall everything even if already present", "aai setup install --force"), ] ) ) @@ -326,6 +327,7 @@ def body(_state: AppState, json_mode: bool) -> None: epilog=examples_epilog( [ ("Show what's set up", "aai setup status"), + ("Print status as JSON", "aai setup status --json"), ] ) ) @@ -346,6 +348,7 @@ def body(_state: AppState, json_mode: bool) -> None: epilog=examples_epilog( [ ("Remove the AssemblyAI MCP server and skills", "aai setup remove"), + ("Remove only from the project scope", "aai setup remove --scope project"), ] ) ) diff --git a/aai_cli/commands/stream.py b/aai_cli/commands/stream.py index c16d2ad0..fd70f5bb 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -76,11 +76,12 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None: epilog=examples_epilog( [ ("Stream from your microphone", "aai stream"), - ("Stream mic + system audio on macOS", "aai stream --system-audio"), + ("Stream a file or URL in real time", "aai stream recording.wav"), ("Stream the hosted sample", "aai stream --sample"), + ("Label speakers in the live transcript", "aai stream --speaker-labels"), ( - "Stream raw PCM from a pipe (16 kHz mono s16le)", - "ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream -", + "Boost domain terms with keyterm prompts", + 'aai stream --keyterms-prompt "AssemblyAI" --keyterms-prompt "Claude"', ), ( "Summarize action items live as you talk", diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index f794f31a..f5377008 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -85,12 +85,11 @@ def _transcribe_audio( [ ("Transcribe a local file", "aai transcribe call.mp3"), ("Try it with the hosted sample", "aai transcribe --sample"), - ( - "Diarize two speakers and redact PII", - "aai transcribe call.mp3 --speaker-labels --speakers-expected 2 --redact-pii", - ), - ("Get just the text for a pipeline", "aai transcribe call.mp3 -o text"), - ("Print equivalent Python instead of running", "aai transcribe call.mp3 --show-code"), + ("Transcribe a YouTube video", "aai transcribe https://youtu.be/dtp6b76pMak"), + ("Label who said what", "aai transcribe call.mp3 --speaker-labels"), + ("Redact PII for compliance", "aai transcribe call.mp3 --redact-pii"), + ("Summarize a recording", "aai transcribe call.mp3 --summarization"), + ("Ask about the transcript", 'aai transcribe call.mp3 --llm "List the action items"'), ] ), ) diff --git a/aai_cli/commands/transcripts.py b/aai_cli/commands/transcripts.py index 15838a82..3d80069d 100644 --- a/aai_cli/commands/transcripts.py +++ b/aai_cli/commands/transcripts.py @@ -17,6 +17,8 @@ epilog=examples_epilog( [ ("Fetch a transcript's text by id", "aai transcripts get 5551234-abcd"), + ("Speaker-labeled turns", "aai transcripts get 5551234-abcd -o utterances"), + ("Save SRT subtitles", "aai transcripts get 5551234-abcd -o srt > captions.srt"), ("Get the raw JSON", "aai transcripts get 5551234-abcd --json"), ] ) @@ -60,6 +62,13 @@ def body(state: AppState, json_mode: bool) -> None: epilog=examples_epilog( [ ("List your recent transcripts", "aai transcripts list"), + ("Show more at once", "aai transcripts list --limit 50"), + ("Grab the latest transcript id", "aai transcripts list --json | jq -r '.[0].id'"), + ( + "Summarize your latest transcript", + 'aai llm "summarize" --transcript-id ' + "$(aai transcripts list --json | jq -r '.[0].id')", + ), ] ), ) diff --git a/aai_cli/main.py b/aai_cli/main.py index d57757c8..6d541e3a 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -181,8 +181,12 @@ def _offer_or_help(ctx: typer.Context, state: AppState) -> None: [ ("Guided setup (start here)", "aai onboard"), ("Transcribe a file", "aai transcribe call.mp3"), - ("Scaffold a starter app", "aai init"), - ("Global options go before the command", "aai --sandbox transcribe call.mp3"), + ("Stream live audio in real time", "aai stream"), + ("Talk to a voice agent", "aai agent"), + ( + "Summarize while transcribing", + 'aai transcribe call.mp3 --llm "summarize action items"', + ), ] ), ) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 24fae685..90b4fc20 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -58,6 +58,8 @@ $ 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 @@ -85,11 +87,15 @@ Examples Recent audit-log entries - $ aai audit --limit 20 + $ 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 @@ -110,6 +116,8 @@ Examples Show your remaining balance $ aai balance + Get the raw cents for scripting + $ aai balance --json | jq '.balance_in_cents' @@ -130,6 +138,8 @@ Examples Check your environment is ready $ aai doctor + Output results as JSON + $ aai doctor --json @@ -166,8 +176,12 @@ $ aai init Scaffold an audio transcription app into ./my-app $ aai init audio-transcription my-app + Scaffold a voice agent app + $ aai init voice-agent Scaffold into the current directory $ aai init audio-transcription --here + Scaffold only, without installing or launching + $ aai init audio-transcription --no-install @@ -193,6 +207,9 @@ $ aai keys create --name ci-pipeline Create a key in a specific project $ aai keys create --name prod --project 7 + Capture the new key into an env var + $ export ASSEMBLYAI_API_KEY=$(aai keys create --name ci --json | jq -r + '.api_key') @@ -215,6 +232,8 @@ $ aai keys list As JSON for scripting $ aai keys list --json + Get key ids to use with rename + $ aai keys list --json | jq '.[].id' @@ -259,6 +278,8 @@ Examples Show rate limits per service $ aai limits + As JSON for scripting + $ aai limits --json @@ -302,10 +323,13 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples - Summarize a past transcript - $ aai llm "summarize" --transcript-id 5551234-abcd + Ask about a past transcript + $ aai llm "summarize the key decisions" --transcript-id 5551234-abcd Pipe any text in $ echo "meeting notes" | aai llm "turn into action items" + Pick a model and add a system prompt + $ aai llm "draft a follow-up email" --model claude-opus-4-7 --system "Be + concise." See available models $ aai llm --list-models @@ -394,6 +418,10 @@ Examples Show one session's details $ aai sessions get + Raw JSON for one session + $ aai sessions get --json + Drill into the latest session + $ aai sessions get $(aai sessions list --json | jq -r '.[0].session_id') @@ -416,8 +444,12 @@ Examples List recent streaming sessions $ aai sessions list - Only completed sessions - $ aai sessions list --status completed + Find failed sessions + $ aai sessions list --status error + Inspect the most recent session + $ aai sessions get $(aai sessions list --json | jq -r '.[0].session_id') + Total audio across recent sessions (seconds) + $ aai sessions list --json | jq '[.[].audio_duration_sec] | add' @@ -451,6 +483,8 @@ $ aai setup install Install for the current project only $ aai setup install --scope project + Reinstall everything even if already present + $ aai setup install --force @@ -474,6 +508,8 @@ Examples Remove the AssemblyAI MCP server and skills $ aai setup remove + Remove only from the project scope + $ aai setup remove --scope project @@ -495,6 +531,8 @@ Examples Show what's set up $ aai setup status + Print status as JSON + $ aai setup status --json @@ -611,12 +649,14 @@ Examples Stream from your microphone $ aai stream - Stream mic + system audio on macOS - $ aai stream --system-audio + Stream a file or URL in real time + $ aai stream recording.wav Stream the hosted sample $ aai stream --sample - Stream raw PCM from a pipe (16 kHz mono s16le) - $ ffmpeg -i input.mp4 -f s16le -ar 16000 -ac 1 - | aai stream - + Label speakers in the live transcript + $ aai stream --speaker-labels + Boost domain terms with keyterm prompts + $ aai stream --keyterms-prompt "AssemblyAI" --keyterms-prompt "Claude" Summarize action items live as you talk $ aai stream --llm "summarize action items" Print equivalent Python instead of running @@ -756,12 +796,16 @@ $ aai transcribe call.mp3 Try it with the hosted sample $ aai transcribe --sample - Diarize two speakers and redact PII - $ aai transcribe call.mp3 --speaker-labels --speakers-expected 2 --redact-pii - Get just the text for a pipeline - $ aai transcribe call.mp3 -o text - Print equivalent Python instead of running - $ aai transcribe call.mp3 --show-code + Transcribe a YouTube video + $ aai transcribe https://youtu.be/dtp6b76pMak + Label who said what + $ aai transcribe call.mp3 --speaker-labels + Redact PII for compliance + $ aai transcribe call.mp3 --redact-pii + Summarize a recording + $ aai transcribe call.mp3 --summarization + Ask about the transcript + $ aai transcribe call.mp3 --llm "List the action items" @@ -787,6 +831,10 @@ Examples Fetch a transcript's text by id $ aai transcripts get 5551234-abcd + Speaker-labeled turns + $ aai transcripts get 5551234-abcd -o utterances + Save SRT subtitles + $ aai transcripts get 5551234-abcd -o srt > captions.srt Get the raw JSON $ aai transcripts get 5551234-abcd --json @@ -810,6 +858,13 @@ Examples List your recent transcripts $ aai transcripts list + Show more at once + $ aai transcripts list --limit 50 + Grab the latest transcript id + $ aai transcripts list --json | jq -r '.[0].id' + Summarize your latest transcript + $ aai llm "summarize" --transcript-id $(aai transcripts list --json | jq -r + '.[0].id') @@ -836,6 +891,10 @@ $ 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' From ba6f3df6225f745d0573cdaaedc6121bd88f03fa Mon Sep 17 00:00:00 2001 From: Alex Kroman <12372+alexkroman@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:54:12 -0700 Subject: [PATCH 33/37] Update aai_cli/commands/transcribe.py Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- aai_cli/commands/transcribe.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index f794f31a..1c89f971 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -464,6 +464,8 @@ def body(state: AppState, json_mode: bool) -> None: if out is not None: # Write a clean file artifact and confirm on stderr; stdout stays empty. + if ".." in str(out): + raise Exception("Invalid file path") out.write_text(_out_payload(transcript, output_field, json_mode=json_mode) + "\n") if not state.quiet: output.error_console.print(output.success(f"Saved to {escape(str(out))}")) From 000d50f2dd135dfd2b7118d176534aae6345f063 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 20:58:01 -0700 Subject: [PATCH 34/37] fix(init): raise template dependency floors to clear Aikido SCA CVEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The starter-app requirements.txt pins used floors low enough to admit known-vulnerable releases (python-multipart>=0.0.9, python-dotenv>=1.0.0), and fastapi>=0.115.0 transitively pins starlette<0.39.0 — pulling a starlette with CVE-2024-47874 / CVE-2025-54121 / CVE-2026-48710. Raise fastapi/dotenv/multipart/httpx2 floors to current patched releases and add an explicit starlette>=1.2.1 pin (FastAPI's own starlette>=0.46.0 floor still admits CVE'd versions). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../init/templates/audio-transcription/requirements.txt | 9 ++++++--- aai_cli/init/templates/live-captions/requirements.txt | 9 ++++++--- aai_cli/init/templates/voice-agent/requirements.txt | 9 ++++++--- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/aai_cli/init/templates/audio-transcription/requirements.txt b/aai_cli/init/templates/audio-transcription/requirements.txt index 1a4e5e15..abb70ce0 100644 --- a/aai_cli/init/templates/audio-transcription/requirements.txt +++ b/aai_cli/init/templates/audio-transcription/requirements.txt @@ -1,6 +1,9 @@ -fastapi>=0.115.0 +fastapi>=0.136.3 uvicorn>=0.30.0 assemblyai>=0.64,<1 -python-dotenv>=1.0.0 -python-multipart>=0.0.9 +python-dotenv>=1.2.2 +python-multipart>=0.0.32 openai>=2.41.0 +# Pin starlette directly: FastAPI's own floor (starlette>=0.46.0) still admits +# versions with known CVEs, so raise the transitive floor above them. +starlette>=1.2.1 diff --git a/aai_cli/init/templates/live-captions/requirements.txt b/aai_cli/init/templates/live-captions/requirements.txt index 357ffaa2..7d5bb7a2 100644 --- a/aai_cli/init/templates/live-captions/requirements.txt +++ b/aai_cli/init/templates/live-captions/requirements.txt @@ -1,4 +1,7 @@ -fastapi>=0.115.0 +fastapi>=0.136.3 uvicorn>=0.30.0 -httpx2>=2.0.0 -python-dotenv>=1.0.0 +httpx2>=2.3.0 +python-dotenv>=1.2.2 +# Pin starlette directly: FastAPI's own floor (starlette>=0.46.0) still admits +# versions with known CVEs, so raise the transitive floor above them. +starlette>=1.2.1 diff --git a/aai_cli/init/templates/voice-agent/requirements.txt b/aai_cli/init/templates/voice-agent/requirements.txt index 357ffaa2..7d5bb7a2 100644 --- a/aai_cli/init/templates/voice-agent/requirements.txt +++ b/aai_cli/init/templates/voice-agent/requirements.txt @@ -1,4 +1,7 @@ -fastapi>=0.115.0 +fastapi>=0.136.3 uvicorn>=0.30.0 -httpx2>=2.0.0 -python-dotenv>=1.0.0 +httpx2>=2.3.0 +python-dotenv>=1.2.2 +# Pin starlette directly: FastAPI's own floor (starlette>=0.46.0) still admits +# versions with known CVEs, so raise the transitive floor above them. +starlette>=1.2.1 From 8441627fe591459d9ffa1795940f2cd10d7b4da1 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 21:14:51 -0700 Subject: [PATCH 35/37] refactor(transcribe): extract execution into transcribe_exec; harden --out Move the transcription-execution + output-shaping helpers out of the transcribe command into a new core module aai_cli/transcribe_exec.py (run_transcription, out_payload, render_transform_steps). This keeps the command a thin option surface, drops it back under the 500-line gate, and lets onboard import run_transcription from a core module instead of reaching into the command's private _transcribe_audio (removing a reportPrivateUsage suppression). Also harden the merged-in --out path-traversal guard: replace the bare `raise Exception` with a clean UsageError (exit 2, no traceback) and check `".." in out.parts` (path components) instead of a substring match, so a filename merely containing ".." isn't wrongly rejected. Tests: split the --out cluster into tests/test_transcribe_out.py (keeps test_transcribe.py under 500) and add a path-traversal rejection test. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/transcribe.py | 70 ++++------------------ aai_cli/onboard/sections.py | 5 +- aai_cli/transcribe_exec.py | 68 ++++++++++++++++++++++ tests/test_onboard_sections.py | 9 ++- tests/test_transcribe.py | 57 +----------------- tests/test_transcribe_out.py | 102 +++++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 124 deletions(-) create mode 100644 aai_cli/transcribe_exec.py create mode 100644 tests/test_transcribe_out.py diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index da4a1df3..aa8b271c 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -1,9 +1,6 @@ from __future__ import annotations -import json -import tempfile from pathlib import Path -from typing import Any import assemblyai as aai import typer @@ -18,9 +15,8 @@ help_panels, llm, output, - stdio, + transcribe_exec, transcribe_render, - youtube, ) from aai_cli.context import AppState, run_command from aai_cli.errors import UsageError @@ -29,56 +25,6 @@ app = typer.Typer() -def _render_transform_steps(d: dict[str, Any]) -> str: - """Human view of chained LLM-Gateway steps: the lone output, or each step labeled.""" - steps = d["transform"]["steps"] - if len(steps) == 1: - return str(steps[0]["output"]) - return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) - - -def _out_payload( - transcript: aai.Transcript, - output_field: choices.TranscriptOutput | None, - *, - json_mode: bool, -) -> str: - """The text to write for ``--out``: the chosen ``-o`` field, the ``--json`` payload, - or the plain transcript text — the same content stdout would get, as a file artifact.""" - if output_field is not None: - return client.select_transcript_field(transcript, output_field) - if json_mode: - return json.dumps(client.transcript_json_payload(transcript), default=str) - return client.select_transcript_field(transcript, choices.TranscriptOutput.text) - - -def _transcribe_audio( - api_key: str, - source: str | None, - *, - sample: bool, - transcription_config: aai.TranscriptionConfig, -) -> aai.Transcript: - if source == "-": - # Audio piped on stdin (e.g. `ffmpeg -i v.mp4 -f wav - | aai transcribe -`). - # The SDK uploads a path, so buffer the bytes to a temp file first. - data = stdio.read_binary_stdin() - if not data: - raise UsageError("No audio received on stdin.") - with tempfile.TemporaryDirectory(prefix="aai-stdin-") as td: - local = Path(td) / "audio" - local.write_bytes(data) - return client.transcribe(api_key, str(local), config=transcription_config) - - audio = client.resolve_audio_source(source, sample=sample) - if youtube.is_youtube_url(audio): - # Fetch first; AssemblyAI can't read a YouTube watch URL itself. - with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: - local = youtube.download_audio(audio, Path(td)) - return client.transcribe(api_key, str(local), config=transcription_config) - return client.transcribe(api_key, audio, config=transcription_config) - - @app.command( rich_help_panel=help_panels.TRANSCRIPTION, epilog=examples_epilog( @@ -459,13 +405,17 @@ def body(state: AppState, json_mode: bool) -> None: api_key = config.resolve_api_key(profile=state.profile) with output.status("Transcribing…", json_mode=json_mode): - transcript = _transcribe_audio(api_key, source, sample=sample, transcription_config=tc) + transcript = transcribe_exec.run_transcription( + api_key, source, sample=sample, transcription_config=tc + ) if out is not None: # Write a clean file artifact and confirm on stderr; stdout stays empty. - if ".." in str(out): - raise Exception("Invalid file path") - out.write_text(_out_payload(transcript, output_field, json_mode=json_mode) + "\n") + if ".." in out.parts: # reject path-traversal segments in --out + raise UsageError(f"--out path can't contain '..': {out}") + out.write_text( + transcribe_exec.out_payload(transcript, output_field, json_mode=json_mode) + "\n" + ) if not state.quiet: output.error_console.print(output.success(f"Saved to {escape(str(out))}")) return @@ -488,7 +438,7 @@ def body(state: AppState, json_mode: bool) -> None: output.emit( client.transcript_summary(transcript) | {"transform": {"model": model, "steps": steps}}, - _render_transform_steps, + transcribe_exec.render_transform_steps, json_mode=json_mode, ) return diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index a2102188..3e7a274c 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -6,11 +6,10 @@ import assemblyai as aai import typer -from aai_cli import config, environments, output, transcribe_render +from aai_cli import config, environments, output, transcribe_exec, transcribe_render from aai_cli.commands import doctor as doctor_cmd from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd -from aai_cli.commands import transcribe as transcribe_cmd from aai_cli.context import AppState, persist_browser_login from aai_cli.errors import CLIError, NotAuthenticated from aai_cli.onboard.prompter import Prompter @@ -65,7 +64,7 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: label = source or "the sample clip" try: with output.status(f"Transcribing {label}…", json_mode=ctx.json_mode): - transcript = transcribe_cmd._transcribe_audio( # pyright: ignore[reportPrivateUsage] + transcript = transcribe_exec.run_transcription( api_key, source or None, sample=not source, diff --git a/aai_cli/transcribe_exec.py b/aai_cli/transcribe_exec.py new file mode 100644 index 00000000..b0367c50 --- /dev/null +++ b/aai_cli/transcribe_exec.py @@ -0,0 +1,68 @@ +"""Transcription execution and non-Rich output shaping for the ``transcribe`` command. + +Kept out of ``commands/transcribe.py`` so the command stays a thin option surface, and +so ``run_transcription`` lives in a core module that ``onboard`` can import directly +(rather than reaching into a command module's internals). +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import Any + +import assemblyai as aai + +from aai_cli import choices, client, stdio, youtube +from aai_cli.errors import UsageError + + +def render_transform_steps(d: dict[str, Any]) -> str: + """Human view of chained LLM-Gateway steps: the lone output, or each step labeled.""" + steps = d["transform"]["steps"] + if len(steps) == 1: + return str(steps[0]["output"]) + return "\n\n".join(f"Step {i} — {s['prompt']}:\n{s['output']}" for i, s in enumerate(steps, 1)) + + +def out_payload( + transcript: aai.Transcript, + output_field: choices.TranscriptOutput | None, + *, + json_mode: bool, +) -> str: + """The text to write for ``--out``: the chosen ``-o`` field, the ``--json`` payload, + or the plain transcript text — the same content stdout would get, as a file artifact.""" + if output_field is not None: + return client.select_transcript_field(transcript, output_field) + if json_mode: + return json.dumps(client.transcript_json_payload(transcript), default=str) + return client.select_transcript_field(transcript, choices.TranscriptOutput.text) + + +def run_transcription( + api_key: str, + source: str | None, + *, + sample: bool, + transcription_config: aai.TranscriptionConfig, +) -> aai.Transcript: + if source == "-": + # Audio piped on stdin (e.g. `ffmpeg -i v.mp4 -f wav - | aai transcribe -`). + # The SDK uploads a path, so buffer the bytes to a temp file first. + data = stdio.read_binary_stdin() + if not data: + raise UsageError("No audio received on stdin.") + with tempfile.TemporaryDirectory(prefix="aai-stdin-") as td: + local = Path(td) / "audio" + local.write_bytes(data) + return client.transcribe(api_key, str(local), config=transcription_config) + + audio = client.resolve_audio_source(source, sample=sample) + if youtube.is_youtube_url(audio): + # Fetch first; AssemblyAI can't read a YouTube watch URL itself. + with tempfile.TemporaryDirectory(prefix="aai-yt-") as td: + local = youtube.download_audio(audio, Path(td)) + return client.transcribe(api_key, str(local), config=transcription_config) + return client.transcribe(api_key, audio, config=transcription_config) diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index beb95ec2..7b547fac 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -7,10 +7,9 @@ import pytest import typer -from aai_cli import output, transcribe_render +from aai_cli import output, transcribe_exec, transcribe_render from aai_cli.commands import init as init_cmd from aai_cli.commands import setup as setup_cmd -from aai_cli.commands import transcribe as transcribe_cmd from aai_cli.context import AppState from aai_cli.onboard import sections from aai_cli.onboard.prompter import NonInteractivePrompter @@ -91,7 +90,7 @@ def _fake( seen["sample"] = sample return _FakeTranscript() - monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _fake) + monkeypatch.setattr(transcribe_exec, "run_transcription", _fake) monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) status_messages = _capture_status(monkeypatch) # NonInteractivePrompter.text returns its default ("") → Enter → sample. @@ -114,7 +113,7 @@ def _fake( seen["sample"] = sample return _FakeTranscript() - monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _fake) + monkeypatch.setattr(transcribe_exec, "run_transcription", _fake) monkeypatch.setattr(transcribe_render, "render_transcript_result", lambda *a, **k: None) status_messages = _capture_status(monkeypatch) assert sections.first_request(_ScriptedPrompter(text="meeting.mp3"), ctx) is SectionResult.DONE @@ -131,7 +130,7 @@ def test_first_request_handles_failure(ctx: WizardContext, monkeypatch: pytest.M def _boom(*a: object, **k: object) -> _FakeTranscript: raise APIError("nope") - monkeypatch.setattr(transcribe_cmd, "_transcribe_audio", _boom) + monkeypatch.setattr(transcribe_exec, "run_transcription", _boom) assert sections.first_request(_ScriptedPrompter(text="bad.mp3"), ctx) is SectionResult.FAILED diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 3342bb02..f305b7d5 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -356,7 +356,7 @@ def test_transcribe_youtube_url_downloads_then_transcribes(monkeypatch, tmp_path _auth() fake = tmp_path / "vid.m4a" fake.write_bytes(b"x") - monkeypatch.setattr("aai_cli.commands.transcribe.youtube.download_audio", lambda url, d: fake) + monkeypatch.setattr("aai_cli.transcribe_exec.youtube.download_audio", lambda url, d: fake) with patch( "aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() ) as tx: @@ -396,61 +396,6 @@ def _boom(*a, **k): assert "your-audio-file.mp3" in result.output -def test_transcribe_out_writes_text_file(tmp_path): - _auth() - out = tmp_path / "episode.txt" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--out", str(out)]) - assert result.exit_code == 0 - assert out.read_text() == "hello world\n" - # The transcript went to the file, not the terminal — stdout stays clean. - assert "hello world" not in result.output - # A confirmation is shown on stderr so the user knows where it landed. - assert "Saved to" in result.output - - -def test_transcribe_out_quiet_suppresses_confirmation(tmp_path): - # -q silences the "Saved to" confirmation, but the file is still written. - _auth() - out = tmp_path / "episode.txt" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["-q", "transcribe", "audio.mp3", "--out", str(out)]) - assert result.exit_code == 0 - assert out.read_text() == "hello world\n" - assert "Saved to" not in result.output - - -def test_transcribe_out_with_output_field_writes_that_field(tmp_path): - _auth() - out = tmp_path / "id.txt" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "id", "--out", str(out)]) - assert result.exit_code == 0 - assert out.read_text() == "t_1\n" - - -def test_transcribe_out_with_json_writes_json_file(tmp_path): - _auth() - out = tmp_path / "t.json" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke(app, ["transcribe", "audio.mp3", "--json", "--out", str(out)]) - assert result.exit_code == 0 - assert json.loads(out.read_text())["id"] == "t_1" - - -def test_transcribe_out_with_llm_is_a_usage_error(tmp_path): - # --out captures the transcript; chaining an LLM transform into a file isn't - # supported (pipe it instead), so the combination is rejected up front. - _auth() - out = tmp_path / "x.txt" - with patch("aai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript()): - result = runner.invoke( - app, ["transcribe", "audio.mp3", "--llm", "summarize", "--out", str(out)] - ) - assert result.exit_code == 2 - assert not out.exists() - - def test_transcribe_show_code_ignores_json_flag(monkeypatch): # --show-code is print-only; --json does not suppress or wrap it. def _boom(*a, **k): diff --git a/tests/test_transcribe_out.py b/tests/test_transcribe_out.py new file mode 100644 index 00000000..45582411 --- /dev/null +++ b/tests/test_transcribe_out.py @@ -0,0 +1,102 @@ +import json +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + +_TRANSCRIBE = "aai_cli.commands.transcribe.client.transcribe" + + +def _auth(): + config.set_api_key("default", "sk_live") + + +def _fake_transcript(): + t = MagicMock() + t.id = "t_1" + t.text = "hello world" + t.status = "completed" + t.json_response = {"id": "t_1", "text": "hello world", "status": "completed"} + for attr in ( + "summary", + "chapters", + "auto_highlights", + "sentiment_analysis", + "entities", + "iab_categories", + "content_safety", + ): + setattr(t, attr, None) + t.utterances = None + return t + + +def test_transcribe_out_writes_text_file(tmp_path): + _auth() + out = tmp_path / "episode.txt" + with patch(_TRANSCRIBE, return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--out", str(out)]) + assert result.exit_code == 0 + assert out.read_text() == "hello world\n" + # The transcript went to the file, not the terminal — stdout stays clean. + assert "hello world" not in result.output + # A confirmation is shown on stderr so the user knows where it landed. + assert "Saved to" in result.output + + +def test_transcribe_out_quiet_suppresses_confirmation(tmp_path): + # -q silences the "Saved to" confirmation, but the file is still written. + _auth() + out = tmp_path / "episode.txt" + with patch(_TRANSCRIBE, return_value=_fake_transcript()): + result = runner.invoke(app, ["-q", "transcribe", "audio.mp3", "--out", str(out)]) + assert result.exit_code == 0 + assert out.read_text() == "hello world\n" + assert "Saved to" not in result.output + + +def test_transcribe_out_with_output_field_writes_that_field(tmp_path): + _auth() + out = tmp_path / "id.txt" + with patch(_TRANSCRIBE, return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "-o", "id", "--out", str(out)]) + assert result.exit_code == 0 + assert out.read_text() == "t_1\n" + + +def test_transcribe_out_with_json_writes_json_file(tmp_path): + _auth() + out = tmp_path / "t.json" + with patch(_TRANSCRIBE, return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--json", "--out", str(out)]) + assert result.exit_code == 0 + assert json.loads(out.read_text())["id"] == "t_1" + + +def test_transcribe_out_with_llm_is_a_usage_error(tmp_path): + # --out captures the transcript; chaining an LLM transform into a file isn't + # supported (pipe it instead), so the combination is rejected up front. + _auth() + out = tmp_path / "x.txt" + with patch(_TRANSCRIBE, return_value=_fake_transcript()): + result = runner.invoke( + app, ["transcribe", "audio.mp3", "--llm", "summarize", "--out", str(out)] + ) + assert result.exit_code == 2 + assert not out.exists() + + +def test_transcribe_out_rejects_path_traversal(tmp_path): + # A --out path with a `..` segment is rejected with a clean usage error, + # before anything is written. + _auth() + out = tmp_path / ".." / "evil.txt" + with patch(_TRANSCRIBE, return_value=_fake_transcript()): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--out", str(out)]) + assert result.exit_code == 2 + assert "can't contain" in result.output + assert not out.exists() From 51d9969f0fd5c585a79d173f93a0d2847f244861 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 21:23:44 -0700 Subject: [PATCH 36/37] refactor(onboard): call public doctor/setup APIs instead of private internals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The onboard wizard reused command internals by reaching into private functions with reportPrivateUsage suppressions. Promote exactly the reused functions to public API and call those instead: - doctor: check_python / check_ffmpeg / check_audio / render - setup: install_mcp / install_skill / install_cli_skill / render (_check_api_key, _check_coding_agent and the install helpers onboard doesn't use stay private — minimal public surface.) Removes all eight reportPrivateUsage suppressions in onboard/sections.py; the wizard now depends only on each command module's intended public surface. Test patch targets updated to the public names. Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/doctor.py | 18 +++++++++--------- aai_cli/commands/setup.py | 16 ++++++++-------- aai_cli/onboard/sections.py | 18 +++++++++--------- tests/setup_helpers.py | 2 +- tests/test_doctor.py | 8 ++++---- tests/test_onboard_sections.py | 14 +++++++------- tests/test_setup.py | 4 ++-- tests/test_setup_render.py | 4 ++-- 8 files changed, 42 insertions(+), 42 deletions(-) diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index 2ae8185f..ebfe15cb 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -36,7 +36,7 @@ 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"), @@ -44,7 +44,7 @@ def query_devices(self) -> Sequence[Mapping[str, object]]: ... } -def _check_python() -> Check: +def check_python() -> Check: v = sys.version_info version = f"{v.major}.{v.minor}.{v.micro}" if v >= (3, 12): @@ -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 @@ -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() @@ -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: @@ -236,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) diff --git a/aai_cli/commands/setup.py b/aai_cli/commands/setup.py index 498d9b40..9feeaf50 100644 --- a/aai_cli/commands/setup.py +++ b/aai_cli/commands/setup.py @@ -71,7 +71,7 @@ def _mcp_present() -> bool: return _run(["claude", "mcp", "get", MCP_NAME]).returncode == 0 -def _install_mcp(scope: str, force: bool) -> Step: +def install_mcp(scope: str, force: bool) -> Step: if shutil.which("claude") is None: return { "name": "mcp", @@ -140,7 +140,7 @@ def _skill_installed() -> bool: return (_skill_dir() / "SKILL.md").exists() -def _install_skill(force: bool) -> Step: +def install_skill(force: bool) -> Step: if shutil.which("npx") is None: return { "name": "skill", @@ -235,7 +235,7 @@ def _copy_tree(node: Traversable, dest: Path) -> None: out.write_bytes(child.read_bytes()) -def _install_cli_skill(force: bool) -> Step: +def install_cli_skill(force: bool) -> Step: # Bundled in the package, so no network/npx — just copy it into the agent's # skills dir. Idempotent: skip the copy when already present and not --force. dest = _cli_skill_dir() @@ -284,7 +284,7 @@ def _remove_cli_skill() -> Step: return {"name": "aai-cli skill", "status": "removed", "detail": str(dest)} -def _render(data: dict[str, list[Step]]) -> str: +def render(data: dict[str, list[Step]]) -> str: return render_steps(data["steps"], heading=_STEPS_HEADING) @@ -315,8 +315,8 @@ def install( """ def body(_state: AppState, json_mode: bool) -> None: - steps = [_install_mcp(scope, force), _install_skill(force), _install_cli_skill(force)] - output.emit({"steps": steps}, _render, json_mode=json_mode) + steps = [install_mcp(scope, force), install_skill(force), install_cli_skill(force)] + output.emit({"steps": steps}, render, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): raise typer.Exit(code=1) @@ -339,7 +339,7 @@ def status( def body(_state: AppState, json_mode: bool) -> None: steps = [_mcp_status(), _skill_status(), _cli_skill_status()] - output.emit({"steps": steps}, _render, json_mode=json_mode) + output.emit({"steps": steps}, render, json_mode=json_mode) run_command(ctx, body, json=json_out) @@ -368,7 +368,7 @@ def remove( def body(_state: AppState, json_mode: bool) -> None: steps = [_remove_mcp(scope), _remove_skill(), _remove_cli_skill()] - output.emit({"steps": steps}, _render, json_mode=json_mode) + output.emit({"steps": steps}, render, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): raise typer.Exit(code=1) diff --git a/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py index 3e7a274c..0b27de47 100644 --- a/aai_cli/onboard/sections.py +++ b/aai_cli/onboard/sections.py @@ -87,15 +87,15 @@ def first_request(prompter: Prompter, ctx: WizardContext) -> SectionResult: def environment(prompter: Prompter, _ctx: WizardContext) -> SectionResult: checks = [ - doctor_cmd._check_python(), # pyright: ignore[reportPrivateUsage] - doctor_cmd._check_ffmpeg(), # pyright: ignore[reportPrivateUsage] - doctor_cmd._check_audio(), # pyright: ignore[reportPrivateUsage] + doctor_cmd.check_python(), + doctor_cmd.check_ffmpeg(), + doctor_cmd.check_audio(), ] - # `_render` already prints its own "Environment check" heading, so we don't call + # `render` already prints its own "Environment check" heading, so we don't call # prompter.section here (that would show the title twice); just space it from the # previous section with a blank line. output.console.print() - output.console.print(doctor_cmd._render({"ok": True, "checks": checks})) # pyright: ignore[reportPrivateUsage] + output.console.print(doctor_cmd.render({"ok": True, "checks": checks})) prompter.note("Warnings here only affect live streaming and the voice agent.") return SectionResult.DONE @@ -133,11 +133,11 @@ def claude_code(prompter: Prompter, _ctx: WizardContext) -> SectionResult: if not prompter.confirm("Wire up Claude Code (docs MCP + skills)?", default=False): return SectionResult.SKIPPED steps = [ - setup_cmd._install_mcp("user", force=False), # pyright: ignore[reportPrivateUsage] - setup_cmd._install_skill(force=False), # pyright: ignore[reportPrivateUsage] - setup_cmd._install_cli_skill(force=False), # pyright: ignore[reportPrivateUsage] + setup_cmd.install_mcp("user", force=False), + setup_cmd.install_skill(force=False), + setup_cmd.install_cli_skill(force=False), ] - output.console.print(setup_cmd._render({"steps": steps})) # pyright: ignore[reportPrivateUsage] + output.console.print(setup_cmd.render({"steps": steps})) if any(s["status"] == "failed" for s in steps): return SectionResult.FAILED return SectionResult.DONE diff --git a/tests/setup_helpers.py b/tests/setup_helpers.py index 2f91211d..0f282ac2 100644 --- a/tests/setup_helpers.py +++ b/tests/setup_helpers.py @@ -25,7 +25,7 @@ class FakeRun: `returncodes` maps a command prefix tuple (the first N argv tokens) to a return code; the longest matching prefix wins, default 0. To mimic the real `skills` CLI, a successful `npx … add` materializes the assemblyai skill under - HOME (so `_install_skill`'s filesystem check passes) and `npx … remove` + HOME (so `install_skill`'s filesystem check passes) and `npx … remove` deletes it — toggle with `creates_skill` / `removes_skill`. The aai-cli skill is bundled and copied directly (no subprocess), so it never goes through here. """ diff --git a/tests/test_doctor.py b/tests/test_doctor.py index cb9dc0e0..ad5dc319 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -133,7 +133,7 @@ def test_doctor_listed_in_help(): def test_check_python_flags_old_interpreter(monkeypatch): VI = namedtuple("VI", "major minor micro releaselevel serial") monkeypatch.setattr(doctor.sys, "version_info", VI(3, 9, 0, "final", 0)) - check = doctor._check_python() + check = doctor.check_python() assert check["status"] == "fail" assert "3.9.0" in check["detail"] @@ -143,7 +143,7 @@ def boom(): raise OSError("PortAudio library not found") monkeypatch.setattr(doctor, "_probe_input_devices", boom) - check = doctor._check_audio() + check = doctor.check_audio() assert check["status"] == "warn" assert "PortAudio" in check["detail"] @@ -169,7 +169,7 @@ def test_render_ok_payload_shows_ready() -> None: {"name": "python", "status": "ok", "affects": [], "detail": "3.12", "fix": None} ], } - text = doctor._render(payload) + text = doctor.render(payload) assert "python" in text assert "Everything looks good." in text @@ -187,7 +187,7 @@ def test_render_problem_payload_shows_fix_and_problem_banner() -> None: } ], } - text = doctor._render(payload) + text = doctor.render(payload) assert "fix:" in text assert "Run 'aai login'." in text assert "1 problem found" in text diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py index 7b547fac..5c3c2234 100644 --- a/tests/test_onboard_sections.py +++ b/tests/test_onboard_sections.py @@ -142,7 +142,7 @@ def _capture_render(payload: dict[str, object]) -> str: seen.update(payload) return "" - monkeypatch.setattr("aai_cli.commands.doctor._render", _capture_render) + monkeypatch.setattr("aai_cli.commands.doctor.render", _capture_render) assert sections.environment(NonInteractivePrompter(), ctx) is SectionResult.DONE # The environment section always renders as a non-fatal report (ok=True). assert seen["ok"] is True @@ -256,9 +256,9 @@ def _cli_skill(*, force: bool) -> Step: forces["cli_skill"] = force return _passing_step() - monkeypatch.setattr(setup_cmd, "_install_mcp", _mcp) - monkeypatch.setattr(setup_cmd, "_install_skill", _skill) - monkeypatch.setattr(setup_cmd, "_install_cli_skill", _cli_skill) + monkeypatch.setattr(setup_cmd, "install_mcp", _mcp) + monkeypatch.setattr(setup_cmd, "install_skill", _skill) + monkeypatch.setattr(setup_cmd, "install_cli_skill", _cli_skill) assert sections.claude_code(_ScriptedPrompter(confirm=True), ctx) is SectionResult.DONE # The wizard never force-overwrites existing installs (force=False everywhere). assert forces == {"mcp": False, "skill": False, "cli_skill": False} @@ -268,7 +268,7 @@ def test_claude_code_failed(ctx: WizardContext, monkeypatch: pytest.MonkeyPatch) def _failing_step(*a: object, **k: object) -> Step: return {"name": "x", "status": "failed", "detail": "no npx"} - monkeypatch.setattr(setup_cmd, "_install_mcp", _passing_step) - monkeypatch.setattr(setup_cmd, "_install_skill", _failing_step) - monkeypatch.setattr(setup_cmd, "_install_cli_skill", _passing_step) + monkeypatch.setattr(setup_cmd, "install_mcp", _passing_step) + monkeypatch.setattr(setup_cmd, "install_skill", _failing_step) + monkeypatch.setattr(setup_cmd, "install_cli_skill", _passing_step) assert sections.claude_code(_ScriptedPrompter(confirm=True), ctx) is SectionResult.FAILED diff --git a/tests/test_setup.py b/tests/test_setup.py index b515e844..d33450de 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -203,7 +203,7 @@ def test_install_cli_skill_fails_when_bundle_missing(monkeypatch, tmp_path): from aai_cli.commands import setup monkeypatch.setattr(setup, "_bundled_cli_skill", lambda: tmp_path / "nonexistent") - step = setup._install_cli_skill(force=False) + step = setup.install_cli_skill(force=False) assert step["status"] == "failed" assert "packaging bug" in step["detail"] @@ -214,7 +214,7 @@ def test_install_cli_skill_fails_when_copy_lacks_skill_md(monkeypatch, tmp_path) empty = tmp_path / "emptybundle" empty.mkdir() monkeypatch.setattr(setup, "_bundled_cli_skill", lambda: empty) - step = setup._install_cli_skill(force=False) + step = setup.install_cli_skill(force=False) assert step["status"] == "failed" assert "SKILL.md" in step["detail"] diff --git a/tests/test_setup_render.py b/tests/test_setup_render.py index 7b3c3c24..9c4df89f 100644 --- a/tests/test_setup_render.py +++ b/tests/test_setup_render.py @@ -1,7 +1,7 @@ import io from aai_cli import theme -from aai_cli.commands.setup import _render +from aai_cli.commands.setup import render from aai_cli.steps import Step @@ -12,7 +12,7 @@ def test_render_steps_colors_status() -> None: {"name": "skill", "status": "failed", "detail": "nope"}, ] } - rendered = _render(data) + rendered = render(data) # The markup string carries the semantic style tags per status... assert "[aai.success]installed[/aai.success]" in rendered assert "[aai.error]failed[/aai.error]" in rendered From b9bfe79e03e3ad07357eafc0002d206bbee71632 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Mon, 8 Jun 2026 21:34:15 -0700 Subject: [PATCH 37/37] fix(ci): assert `aai --version`, not the removed `version` subcommand The `version` subcommand was removed; the root callback exposes `--version`. Update the brew-install CI step and the formula's `test do` block (plus the explanatory comments) so the smoke test stops failing with "No such command 'version'". Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 6 +++--- Formula/aai.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1b473bc..87485419 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' @@ -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 diff --git a/Formula/aai.rb b/Formula/aai.rb index 2017fe25..748a722d 100644 --- a/Formula/aai.rb +++ b/Formula/aai.rb @@ -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