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/.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..2cf1fad2 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) @@ -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/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 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/account.py b/aai_cli/commands/account.py index b41c163d..b363add9 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")) @@ -50,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") @@ -59,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.") @@ -93,6 +106,7 @@ def _line_items_summary(item: Mapping[str, object]) -> str: epilog=examples_epilog( [ ("Show your remaining balance", "aai balance"), + ("Get the raw cents for scripting", "aai balance --json | jq '.balance_in_cents'"), ] ), ) @@ -121,6 +135,11 @@ def body(state: AppState, json_mode: bool) -> None: [ ("Usage over the last 30 days", "aai usage"), ("A specific date range", "aai usage --start 2026-05-01 --end 2026-06-01"), + ("Break spend down by month", "aai usage --window month"), + ( + "Total spend in cents for scripting", + "aai usage --json | jq '[.usage_items[].line_items[].price] | add'", + ), ] ), ) @@ -144,41 +163,39 @@ def body(state: AppState, json_mode: bool) -> None: data = ams.get_usage(jwt, start_date, end_date, window) def render(d: dict[str, object]) -> object: - items = _usage_items(d) - shown = ( - items - if include_zero - else [item for item in items if jsonshape.as_float(item.get("total"))] - ) - total = sum(jsonshape.as_float(item.get("total")) for item in items) + windows = [(item, _window_total_cents(item)) for item in _usage_items(d)] + shown = windows if include_zero else [w for w in windows if w[1]] + total = sum(cents for _, cents in windows) range_label = ( f"{timeparse.format_utc_day(start_date)} to " f"{timeparse.format_utc_day(end_date)} (UTC)" ) summary = Text( - f"Usage total: {_format_usage_number(total)} for {range_label}", + f"Usage total: {_format_dollars(total)} for {range_label}", style="aai.heading", ) if not shown: message = ( "No usage in this range." - if items + if windows else "No usage windows returned for this range." ) return output.stack(summary, output.muted(message)) - shown_with_breakdown = [(item, _line_items_summary(item)) for item in shown] - show_breakdown = any(summary for _, summary in shown_with_breakdown) + shown_with_breakdown = [ + (item, cents, _line_items_summary(item)) for item, cents in shown + ] + show_breakdown = any(breakdown for _, _, breakdown in shown_with_breakdown) table = ( output.data_table("period", "total", "breakdown") if show_breakdown else output.data_table("period", "total") ) - hidden_count = len(items) - len(shown) - for item, breakdown in shown_with_breakdown: + hidden_count = len(windows) - len(shown) + for item, cents, breakdown in shown_with_breakdown: row = [ escape(_window_label(item)), - _format_usage_number(item.get("total")), + _format_dollars(cents), ] if show_breakdown: row.append(escape(breakdown)) @@ -202,6 +219,7 @@ def render(d: dict[str, object]) -> object: epilog=examples_epilog( [ ("Show rate limits per service", "aai limits"), + ("As JSON for scripting", "aai limits --json"), ] ), ) @@ -215,9 +233,14 @@ def body(state: AppState, json_mode: bool) -> None: account_id, jwt = resolve_session(state) data = ams.get_rate_limits(account_id, jwt) - def render(d: dict[str, object]) -> Table: + def render(d: dict[str, object]) -> object: + limits = jsonshape.mapping_list(d.get("rate_limits")) + if not limits: + return output.muted( + "No custom rate limits — this account uses AssemblyAI's standard limits." + ) table = output.data_table("service", "limit") - for limit in jsonshape.mapping_list(d.get("rate_limits")): + for limit in limits: table.add_row( escape(str(limit.get("service", ""))), _format_usage_number(limit.get("magnitude")), diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 83212245..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"), ] @@ -117,8 +118,9 @@ def agent( ) -> None: """Have a live two-way voice conversation with an AssemblyAI voice agent. - Pass an audio file/URL (or --sample) to speak a recorded clip to the agent - instead of the microphone; the session then ends after the agent's reply. + Use headphones: the mic stays open while the agent speaks, so on speakers it would + hear itself and loop. Pass an audio file/URL (or --sample) to speak a recorded clip to + the agent instead of the microphone; the session then ends after the agent's reply. This only runs a conversation in the terminal — it writes no code. To build a voice agent app, run 'aai init voice-agent' instead. 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..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: @@ -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"), ] ), ) @@ -235,15 +236,15 @@ def doctor( def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) checks = [ - _check_python(), + check_python(), _check_api_key(profile), - _check_ffmpeg(), - _check_audio(), + check_ffmpeg(), + check_audio(), _check_coding_agent(), ] ok = not any(c["status"] == "fail" for c in checks) payload: DoctorResult = {"ok": ok, "checks": checks} - output.emit(payload, _render, json_mode=json_mode) + output.emit(payload, render, json_mode=json_mode) if not ok: raise typer.Exit(code=1) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index d1ebbffe..51ed721a 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -163,8 +163,69 @@ 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, + rich_help_panel=help_panels.BUILD, epilog=examples_epilog( [ ("Scaffold a new app interactively", "aai init"), @@ -172,7 +233,12 @@ def _launch(target: Path, *, port: int, use_uv: bool, no_open: bool, json_mode: "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,7 +259,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 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 @@ -201,45 +267,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) 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/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/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py new file mode 100644 index 00000000..fb3db1e5 --- /dev/null +++ b/aai_cli/commands/onboard.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys + +import typer + +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 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"), + ] + ), +) +def onboard( + ctx: typer.Context, + 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) + 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/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/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 f4d77429..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) @@ -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"), ] ) ) @@ -306,11 +307,16 @@ 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)] - 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) @@ -321,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"), ] ) ) @@ -332,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) @@ -341,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"), ] ) ) @@ -360,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/commands/stream.py b/aai_cli/commands/stream.py index 76fd81ba..12c9e68b 100644 --- a/aai_cli/commands/stream.py +++ b/aai_cli/commands/stream.py @@ -76,8 +76,13 @@ 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"), + ( + "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"', @@ -90,7 +95,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 @@ -323,11 +329,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..aa8b271c 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -1,11 +1,10 @@ from __future__ import annotations -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, @@ -16,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 @@ -27,53 +25,17 @@ 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 _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( [ ("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"'), ] ), ) @@ -338,12 +300,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 +326,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 +377,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 @@ -420,7 +405,20 @@ 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 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 if output_field is not None: # Raw single-field output for pipelines (overrides --json and analysis render). @@ -440,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/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/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/help_panels.py b/aai_cli/help_panels.py index 76319c6d..0f5faaf9 100644 --- a/aai_cli/help_panels.py +++ b/aai_cli/help_panels.py @@ -12,11 +12,12 @@ from __future__ import annotations -QUICK_START = "Quick Start" # zero-to-running onboarding: 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: 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/init/templates/audio-transcription/requirements.txt b/aai_cli/init/templates/audio-transcription/requirements.txt index 67a3b4c6..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 -uvicorn +fastapi>=0.136.3 +uvicorn>=0.30.0 assemblyai>=0.64,<1 -python-dotenv -python-multipart -openai +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/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/live-captions/requirements.txt b/aai_cli/init/templates/live-captions/requirements.txt index 57a6fd76..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 -uvicorn -httpx2 -python-dotenv +fastapi>=0.136.3 +uvicorn>=0.30.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/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/init/templates/voice-agent/requirements.txt b/aai_cli/init/templates/voice-agent/requirements.txt index 57a6fd76..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 -uvicorn -httpx2 -python-dotenv +fastapi>=0.136.3 +uvicorn>=0.30.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/main.py b/aai_cli/main.py index e9e406ee..6d541e3a 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 completion, 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__, environments, help_panels, output, stdio +from aai_cli import __version__, config, environments, help_panels, output, stdio, theme from aai_cli.commands import ( account, agent, @@ -21,7 +22,7 @@ keys, llm, login, - samples, + onboard, sessions, setup, stream, @@ -29,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 @@ -38,17 +41,17 @@ # most-common-first. Names not listed fall to the end, sorted alphabetically. _COMMAND_ORDER = ( # Quick Start — zero-to-running onboarding + "onboard", + # Build an App — scaffold a new project "init", - # Setup & Tools — get set up & maintain; `version` last - "samples", - "doctor", - "setup", - "version", - # Transcription & AI — the verbs you run + # 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", @@ -68,7 +71,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]: @@ -77,11 +80,35 @@ 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 +# 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}" + +# 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", - help="AssemblyAI from your terminal — transcribe, stream, and build voice AI.", - no_args_is_help=True, + # 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, @@ -98,14 +125,70 @@ 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() + + +_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 + `--help` (Click handles that eagerly before the callback).""" + if not state.quiet: + output.print_banner() + 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() + + @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"), + ("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"', + ), ] - ) + ), ) def main( ctx: typer.Context, @@ -135,9 +218,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: @@ -146,6 +231,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 @@ -162,18 +249,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/__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/prompter.py b/aai_cli/onboard/prompter.py new file mode 100644 index 00000000..fe861ec4 --- /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: ... # pragma: no mutate + 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/aai_cli/onboard/sections.py b/aai_cli/onboard/sections.py new file mode 100644 index 00000000..0b27de47 --- /dev/null +++ b/aai_cli/onboard/sections.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +import assemblyai as aai +import typer + +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.context import AppState, persist_browser_login +from aai_cli.errors import CLIError, NotAuthenticated +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: + 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") + # 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, environments.active().name) + 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) + source = prompter.text( + "Audio file path or YouTube URL (or press Enter to transcribe a sample clip)", + default="", + ).strip() + label = source or "the sample clip" + try: + with output.status(f"Transcribing {label}…", json_mode=ctx.json_mode): + transcript = transcribe_exec.run_transcription( + 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) + return SectionResult.DONE + + +_BUILD_CHOICES = [ + ("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: + checks = [ + 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 + # 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})) + 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 (or skip)", _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. + 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 + + +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), + setup_cmd.install_skill(force=False), + setup_cmd.install_cli_skill(force=False), + ] + output.console.print(setup_cmd.render({"steps": steps})) + if any(s["status"] == "failed" for s in steps): + return SectionResult.FAILED + return SectionResult.DONE + + +def next_steps(prompter: Prompter, _ctx: WizardContext) -> SectionResult: + prompter.section("You're set up") + 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/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/aai_cli/output.py b/aai_cli/output.py index 8380e1dd..a83cf992 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, # pragma: no mutate (purely cosmetic: toggles Rich repr coloring, not text) + ) + 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/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/aai_cli/theme.py b/aai_cli/theme.py index 8ebcf269..fcee2d67 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), 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 @@ -37,10 +40,10 @@ # for a diarized speaker, see SPEAKER_STYLES), 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). 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/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). +``` 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. 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/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/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index a64bdb72..2ecb53cd 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. @@ -54,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 @@ -81,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 @@ -106,6 +116,8 @@ Examples Show your remaining balance $ aai balance + Get the raw cents for scripting + $ aai balance --json | jq '.balance_in_cents' @@ -126,6 +138,8 @@ Examples Check your environment is ready $ aai doctor + Output results as JSON + $ aai doctor --json @@ -136,8 +150,7 @@ Usage: aai init [OPTIONS] [TEMPLATE] [DIRECTORY] - Build a new app: pick a template, scaffold it, install deps, launch it, open - the browser. + 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 @@ -163,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 @@ -190,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') @@ -212,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' @@ -256,6 +278,8 @@ Examples Show rate limits per service $ aai limits + As JSON for scripting + $ aai limits --json @@ -299,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 @@ -353,39 +380,12 @@ ''' # --- -# 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] +# name: test_command_help_matches_snapshot[onboard] ''' - Usage: aai samples list [OPTIONS] + Usage: aai onboard [OPTIONS] - List available sample scripts. + Guided setup: sign in, run your first transcription, and start building. ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --json Output raw JSON. │ @@ -393,8 +393,8 @@ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples - List available starter scripts - $ aai samples list + Run the guided setup + $ aai onboard @@ -418,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') @@ -440,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' @@ -452,7 +460,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. │ @@ -468,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 @@ -491,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 @@ -512,6 +531,8 @@ Examples Show what's set up $ aai setup status + Print status as JSON + $ aai setup status --json @@ -522,15 +543,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. │ @@ -632,10 +659,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 + 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 @@ -652,11 +683,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 ──────────────────────────────────────────────────────────────────╮ @@ -665,9 +698,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). │ @@ -764,12 +806,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" @@ -795,6 +841,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 @@ -818,6 +868,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') @@ -844,23 +901,13 @@ $ 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' - ''' -# --- -# 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] @@ -891,8 +938,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. ''' # --- @@ -924,7 +972,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/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_account_command.py b/tests/test_account_command.py index 7aa42379..0b4567ef 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( @@ -130,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"}) == "" @@ -145,8 +185,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,7 +194,9 @@ def test_usage_human_renders_breakdown(monkeypatch): result = runner.invoke(app, ["usage"]) assert result.exit_code == 0 assert "breakdown" in result.output - 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): @@ -180,15 +222,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 +272,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 +309,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": []} 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_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_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_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_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_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() 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_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 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 a40db37a..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.""" @@ -45,7 +63,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_command.py b/tests/test_onboard_command.py new file mode 100644 index 00000000..f8c3a2cc --- /dev/null +++ b/tests/test_onboard_command.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import pytest +from typer.testing import CliRunner + +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_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("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("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(), 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(), 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_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, []) + assert result.exit_code == 0, result.output + 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 + + 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) + ran = {"called": False} + + def _fake_run(prompter: object, ctx: object) -> int: + ran["called"] = True + return 0 + + 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 + + +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( + "aai_cli.main.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("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("aai_cli.main.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 diff --git a/tests/test_onboard_prompter.py b/tests/test_onboard_prompter.py new file mode 100644 index 00000000..2150f6a1 --- /dev/null +++ b/tests/test_onboard_prompter.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import pytest + +from aai_cli.errors import UsageError +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: + p = NonInteractivePrompter() + assert p.confirm("Run setup?", default=True) is True + 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")] + 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?") + + +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: + monkeypatch.setattr("aai_cli.onboard.prompter.typer.confirm", lambda *a, **k: True) + assert InteractivePrompter().confirm("ok?", default=True) is True + + +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 + + +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" + + +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")]) diff --git a/tests/test_onboard_sections.py b/tests/test_onboard_sections.py new file mode 100644 index 00000000..5c3c2234 --- /dev/null +++ b/tests/test_onboard_sections.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import contextlib +from collections.abc import Generator +from pathlib import Path + +import pytest +import typer + +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.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: + id = "t_1" + status = "completed" + text = "hello" + 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 + self.confirm_defaults: list[bool] = [] + + def section(self, title: str) -> None: + pass + + 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( + 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 + + +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) + + +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_uses_sample_on_empty_input( + 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_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. + 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( + 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_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 + 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 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_exec, "run_transcription", _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: + # 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( + 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(ctx: WizardContext) -> None: + assert sections.next_steps(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: + # 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(), ctx) is SectionResult.DONE + + +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) + 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( + 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: + 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: + 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 diff --git a/tests/test_onboard_wizard.py b/tests/test_onboard_wizard.py new file mode 100644 index 00000000..412608ba --- /dev/null +++ b/tests/test_onboard_wizard.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +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 +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 + + +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] 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_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 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c2741ae0..298e76f9 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(): @@ -75,21 +69,21 @@ 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, + # Run AssemblyAI, Setup & Tools, History, then Account. assert names == [ # Quick Start + "onboard", + # Build an App "init", - # Setup & Tools - "samples", - "doctor", - "setup", - "version", - # Transcription & AI + # Run AssemblyAI "transcribe", "stream", "agent", "llm", + # Setup & Tools + "doctor", + "setup", # History "transcripts", "sessions", diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 57842344..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: @@ -378,6 +378,22 @@ 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_show_code_ignores_json_flag(monkeypatch): 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()