diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md new file mode 100644 index 00000000..c9e4dbdd --- /dev/null +++ b/.claude/agents/security-reviewer.md @@ -0,0 +1,36 @@ +--- +name: security-reviewer +description: Use to review changes to authentication, credential storage, environment selection, or subprocess shell-outs in the aai CLI. Focuses on secret handling and the auth/login flow. Invoke after editing aai_cli/auth/, config.py, environments.py, or any subprocess call. +tools: Glob, Grep, LS, Read, NotebookRead, WebFetch, TodoWrite, WebSearch, KillShell, BashOutput +--- + +You are a security reviewer for the **AssemblyAI CLI** (`aai`). Your job is to find security regressions in the areas this CLI is most sensitive about, then report concrete findings — not generic advice. + +## What this CLI guarantees (don't let a change break these) + +- **The API key is never written to a plaintext dotfile.** It lives only in the OS keyring (`config.KEYRING_SERVICE = "assemblyai-cli"`) or comes from the `ASSEMBLYAI_API_KEY` env var. The only on-disk config (`config.toml`) holds profile names and a per-profile `env`, never the key. +- **Run commands (`transcribe`, `stream`, `agent`, `llm`, …) expose no `--api-key` flag.** A key must never be acceptable as a command argument — that would leak it into `ps` output and shell history. Only validation/login paths may take a key, and only via stdin/env. +- **Key resolution order is fixed:** `--api-key` (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. A change must not reorder this or add a new on-disk source. +- **A credential is only valid against the environment that minted it.** `environments.py` binds a profile to its `env`; `context.env_override_warning` must still fire when `--env` contradicts the stored env. +- **Stytch tokens shipped in `environments.py` are *public* tokens only** (`public-token-*`). Flag any *secret*/private token, client secret, or non-public credential added to source. + +## Subprocess / shell-out review + +The CLI intentionally shells out to `claude` and `npx` (ruff `S603/S607` are project-ignored). For any `subprocess` change verify: + +- Arguments are a fixed list, never a shell string; `shell=True` is never introduced. +- No untrusted/user-controlled value is interpolated into the argv without validation. +- `stdin=subprocess.DEVNULL` is preserved where a child might otherwise prompt and hang. +- A timeout backstop remains. + +## Auth flow (`aai_cli/auth/`) + +Browser-assisted login uses AMS + **Stytch B2B OAuth discovery** (not Connected Apps). For `discovery.py`/`flow.py`/`loopback.py`/`ams.py` changes, check: + +- The loopback redirect binds to localhost only and validates the `state` parameter against CSRF. +- Tokens/codes are not logged to stdout/stderr or persisted to disk. +- Error paths surface a clean `CLIError`, never a raw exception leaking a token. + +## Output + +Report findings ranked by severity. For each: the file:line, what guarantee it breaks, and the concrete fix. If you find nothing, say so plainly — do not invent issues. Only report problems you can point to in the diff or code. diff --git a/.claude/agents/template-contract-reviewer.md b/.claude/agents/template-contract-reviewer.md new file mode 100644 index 00000000..7a0dc646 --- /dev/null +++ b/.claude/agents/template-contract-reviewer.md @@ -0,0 +1,26 @@ +--- +name: template-contract-reviewer +description: Use after any change under aai_cli/init/templates/ (the `aai init` starter apps) to verify the scaffold still ships correctly and stays covered by the parametrized contract tests. Catches the renamed-dotfile and wheel-packaging gotchas. +tools: Glob, Grep, LS, Read, NotebookRead, TodoWrite, KillShell, BashOutput +--- + +You review changes to the `aai init` starter templates in `aai_cli/init/templates/` (`transcribe/`, `stream/`, `agent/`). These ship inside the wheel and are scaffolded onto users' machines, so a broken template ships broken examples. Verify the following and report concrete gaps. + +## Packaging integrity + +- **Renamed dotfiles:** templates store `gitignore` (scaffolded → `.gitignore`) and `env.example`. Confirm any new dotfile follows the committed-under-a-safe-name convention and that `aai_cli/init/scaffold.py` / `templates.py` knows how to rename it on copy. +- **Wheel inclusion:** templates are force-included via `[tool.hatch.build.targets.wheel] artifacts = ["aai_cli/init/templates/**"]`, with `__pycache__`/`*.pyc` excluded. A new file type must be reachable by that glob; a stray compiled artifact must not leak into the wheel. +- **The `transcribe/` gitignore negation:** the repo root `.gitignore` ignores `transcribe/` but negates `!aai_cli/init/templates/transcribe/`. Confirm a new template path doesn't get silently ignored by a broad root rule. + +## Contract-test coverage + +Every template is exercised by parametrized tests (`tests/test_init_template_*.py`, `test_init_templates.py`, `test_init_packaging.py`). For each change verify: + +- A new template is added to the parametrization, not left untested. +- The template still satisfies the shared contract the tests assert (required files present: `requirements.txt`, `index.html`, `api/index.py`, `vercel.json`, `README.md`, `env.example`, `gitignore`). +- The key is written only to the git-ignored `.env`, never embedded in scaffolded source or sent to the browser. +- If the template's runtime deps changed, `requirements.txt` reflects it and stays installable. + +## Output + +List each gap as file:line + what contract it breaks + the fix (e.g. "add `agent` to the params in test_init_templates.py:NN"). If the change is fully covered and packages correctly, say so. Don't fabricate issues. diff --git a/.claude/commands/review-changes.md b/.claude/commands/review-changes.md new file mode 100644 index 00000000..c0e1d0d9 --- /dev/null +++ b/.claude/commands/review-changes.md @@ -0,0 +1,23 @@ +--- +description: Review the current changes with the aai CLI's specialized reviewers (security + template contract), scoped to what actually changed. +argument-hint: "[git ref to diff against, default: HEAD]" +allowed-tools: Bash(git diff *), Bash(git status *), Bash(git log *), Task +--- + +Review the current working changes using this project's specialized subagents. Be surgical: only run a reviewer if the diff actually touches its area. + +## 1. Scope the diff + +Run `git status --short` and `git diff --stat ${1:-HEAD}` (and `git diff ${1:-HEAD}` for detail) to see what changed. + +## 2. Dispatch the relevant reviewers (in parallel) + +- If the diff touches **`aai_cli/auth/`, `config.py`, `environments.py`, `client.py`, or any `subprocess` call** → dispatch the **`security-reviewer`** agent on those changes. +- If the diff touches **`aai_cli/init/templates/`, `aai_cli/init/scaffold.py`, `aai_cli/init/templates.py`, or the wheel-packaging config in `pyproject.toml`** → dispatch the **`template-contract-reviewer`** agent on those changes. +- If the diff touches neither sensitive area, say so and skip — don't manufacture a review. + +Pass each agent the exact list of changed files in its scope so it reviews the diff, not the whole repo. + +## 3. Synthesize + +Combine the findings into one ranked report (severity → file:line → fix). Call out anything that would break the CLI's security guarantees (key never on disk/argv, env↔credential binding) or the template packaging contract. If both reviewers come back clean, state that plainly. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..ed8de29e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,69 @@ +{ + "permissions": { + "allow": [ + "Bash(uv run *)", + "Bash(uv sync *)", + "Bash(uv lock *)", + "Bash(uv build *)", + "Bash(uv pip *)", + "Bash(uvx twine *)", + "Bash(ruff check *)", + "Bash(ruff format *)", + "Bash(mypy *)", + "Bash(pytest *)", + "Bash(pre-commit run *)", + "Bash(./scripts/check.sh)", + "Bash(bash scripts/check.sh)", + "Bash(markdownlint *)", + "Bash(shellcheck *)", + "Bash(actionlint *)", + "Bash(command -v *)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git show *)", + "Bash(git branch *)", + "Bash(gh pr view *)", + "Bash(gh pr list *)", + "Bash(gh run view *)", + "Bash(gh run list *)", + "mcp__assemblyai-docs__search_docs", + "mcp__assemblyai-docs__get_pages", + "mcp__assemblyai-docs__get_api_reference", + "mcp__assemblyai-docs__list_sections" + ], + "deny": [ + "Read(.env)", + "Read(**/.env)", + "Read(**/*.env)", + "Read(**/*.pem)", + "Read(**/id_rsa)", + "Read(**/id_ed25519)", + "Read(**/*.p12)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *uv.lock|*.env|*/.env) echo \"Blocked: $f is a protected lock/secret file. Edit it by hand if you really intend to.\" >&2; exit 2;; esac; exit 0" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.py) uv run ruff check --fix \"$f\" >/dev/null 2>&1; uv run ruff format \"$f\" >/dev/null 2>&1;; esac; exit 0" + } + ] + } + ] + } +} diff --git a/.claude/skills/check/SKILL.md b/.claude/skills/check/SKILL.md new file mode 100644 index 00000000..1e0efd9e --- /dev/null +++ b/.claude/skills/check/SKILL.md @@ -0,0 +1,35 @@ +--- +name: check +description: Run the full local verification gate for the aai CLI (the same checks CI runs). Use before pushing or opening a PR. +disable-model-invocation: true +--- + +# check + +Run the project's canonical verification gate and report the result. + +## Steps + +1. Run the full gate: + + ```sh + ./scripts/check.sh + ``` + + This runs, in order: `ruff check` → `ruff format --check` → `mypy` (src + tests) → `markdownlint` (excludes generated `docs/`) → `shellcheck install.sh` → `pytest` with a **90% branch-coverage gate** (`--cov-fail-under=90`, excluding `e2e` and `install_script` markers) → `uv build` + `twine check --strict`. Everything runs through `uv run` against the locked environment. + +2. If anything fails, fix it and re-run `./scripts/check.sh` until it passes. Do not claim success until the script prints `All checks passed.` + +## Optional, opt-in suites (not run by check.sh) + +Run these only when relevant — they are slow and/or need credentials: + +```sh +uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY + kokoro +uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx +``` + +## Notes + +- If `shellcheck` isn't installed locally, `check.sh` skips it with a notice (CI still runs it) — that's expected, not a failure. +- Report the final outcome with the actual tail of the output, not a summary from memory. diff --git a/.claude/skills/release-prep/SKILL.md b/.claude/skills/release-prep/SKILL.md new file mode 100644 index 00000000..d0cef84b --- /dev/null +++ b/.claude/skills/release-prep/SKILL.md @@ -0,0 +1,46 @@ +--- +name: release-prep +description: Prepare an aai CLI release — bump the version, run the full gate, build and validate the wheel/sdist, and smoke-test the real install. Use when cutting a new release. +disable-model-invocation: true +--- + +# release-prep + +Drive an `aai` release to a verified, publishable state. Stop and report at the first failure — never push or publish on a red check. + +## 1. Version bump + +- Update `version` in `pyproject.toml` (`[project]`). Confirm `aai_cli/__init__.py` `__version__` stays in sync (the `version` command and install smoke test read it). +- Decide the bump (patch/minor/major) from what changed since the last tag; ask the user if it's ambiguous. + +## 2. Full gate + +```sh +./scripts/check.sh +``` + +Must end with `All checks passed.` (ruff, mypy, markdownlint, shellcheck, pytest+coverage, build, `twine check --strict`). + +## 3. Real install smoke test + +```sh +uv run pytest -q -m install_script +``` + +This builds the wheel and runs `install.sh` for real (pipx + pip --user), asserting `aai` runs. Needs network + uv/pipx. + +## 4. Build + metadata validation + +```sh +rm -rf dist && uv build && uvx twine check --strict dist/* +``` + +Confirm both an sdist and a wheel are produced and the README renders for PyPI. + +## Distribution caveat + +The PyPI name **`assemblyai-cli` is squatted by a third party** — do **not** assume `pip install assemblyai-cli` resolves to this project. Publishing/distribution currently goes through `install.sh` (git install via pipx / pip --user) and any Homebrew tap, not that PyPI name. Flag this if a release step assumes the squatted name. + +## Output + +Report the version bumped to, the gate result (with output tail), and confirm `dist/` contains a validated wheel + sdist. Only then is the release ready to tag/push. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32a40b53..2fbe747b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: pull_request: branches: [main] push: - branches: [main] # Least privilege: CI only needs to read the repo. Actions are pinned to commit # SHAs (a moved tag can't silently change what runs); Dependabot keeps them current. @@ -98,3 +97,41 @@ jobs: python -m pip install -e . pip-audit # Append `--ignore-vuln ` to accept an unfixable transitive advisory. python -m pip_audit + + install-smoke: + name: install.sh real install (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + kfilter: "" # both branches: pipx + pip --user + - os: macos-latest + kfilter: "-k pipx" # pipx only — PEP 668 makes pip --user flaky on macOS + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: pip + + # `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' + run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg + - name: System deps (macOS) + if: runner.os == 'macOS' + run: brew install portaudio ffmpeg + + # Use the system interpreter (no virtualenv) so install.sh's `pip --user` + # branch is allowed. Editable install makes `aai_cli` importable for the + # test's __version__ check; uv builds the wheel; pipx drives the pipx branch. + - name: Tooling + run: | + python -m pip install --upgrade pip # need pip >= 25.1 for --group + python -m pip install -e . --group dev uv pipx + + - name: Real install smoke + run: python -m pytest -q -m install_script ${{ matrix.kfilter }} diff --git a/.gitignore b/.gitignore index 18d796db..d8badeda 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,9 @@ build/ .coverage htmlcov/ -# Editor/agent and local planning artifacts -.claude/ -docs/ +# Editor/agent local artifacts: keep personal settings local, but track the +# team-shared bits (.claude/settings.json, agents/, skills/). +.claude/settings.local.json # Local scratch scripts (often contain live keys) transcribe/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..dac39594 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_PAT}" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0edff340 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development commands + +This project uses [uv](https://docs.astral.sh/uv/). **Run every Python tool through `uv run`** so it uses the locked environment (`pyproject.toml` + `uv.lock`), not whatever is on `PATH`: + +```sh +uv sync --extra dev # create/refresh the venv with dev dependencies +uv run aai --help # run the CLI from the locked environment +./scripts/check.sh # the full gate CI runs: ruff + mypy + markdownlint + shellcheck + pytest(+coverage) + build/twine +``` + +Individual tools (all via `uv run`): + +```sh +uv run ruff check . # lint +uv run ruff format . # format (line-length 100) +uv run mypy # files = ["aai_cli", "tests"] from pyproject; strict (disallow_untyped_defs on src) +uv run pytest -q # default unit suite +uv run pytest tests/test_transcribe.py -q # a single file +uv run pytest tests/test_transcribe.py::test_name -q # a single test +``` + +### Test markers + +The default suite **excludes** two slow/credentialed marker sets (see `scripts/check.sh` and `pyproject.toml`): + +```sh +uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY + kokoro, else skips +uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx +``` + +`check.sh` runs `-m "not e2e and not install_script"` with a **90% branch-coverage gate** (`--cov-fail-under=90`). New code generally needs tests to clear that gate. + +## Naming & packaging gotchas + +- The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `aai` (`[project.scripts] aai = "aai_cli.main:run"`). +- `aai init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`). +- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.10–3.13. + +## Architecture + +A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app, and controls `aai --help` ordering via `_COMMAND_ORDER` + a custom `_OrderedGroup`. `run()` is the entry point and swallows `BrokenPipeError` (closed downstream pipe → exit 0). + +### 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. + +### Cross-cutting state (resolution order matters) + +- **`context.py`** — `AppState` (profile, env) is attached to the Typer context in the root `@app.callback()`. `run_command` is the standard command wrapper. +- **`config.py`** — profiles persisted in `config.toml` (via `platformdirs`); the **API key lives only in the OS keyring** (`KEYRING_SERVICE = "assemblyai-cli"`), never in a dotfile. Key resolution order: `--api-key` flag (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. **Run commands deliberately expose no `--api-key` flag** so keys can't leak into `ps`/shell history. +- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is currently **`sandbox000`** (flip to `production` once prod AMS/Stytch values are real). The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it. +- **`client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape. +- **`errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` (auto-enabled when piped/agent-run) switches to machine-readable output. + +### Feature subsystems + +- **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. +- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). +- **`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 (`transcribe`/`stream`/`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. + +## Conventions + +- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). +- Ruff lint set: `E,F,I,UP,B,BLE,C4,SIM,RET,PTH,ARG,S,RUF`. `S603/S607` are ignored project-wide because the CLI intentionally shells out to `claude`/`npx` with controlled args. `B008` is ignored (Typer uses `typer.Option/Argument` calls as defaults). +- mypy is strict on `aai_cli` (`disallow_untyped_defs`); tests are type-checked but exempt from return annotations. +- Errors → stderr, data → stdout. Preserve this split; it's what makes the CLI pipeline-safe. diff --git a/docs/superpowers/plans/2026-06-02-claude-command.md b/docs/superpowers/plans/2026-06-02-claude-command.md new file mode 100644 index 00000000..ab1d5d22 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-claude-command.md @@ -0,0 +1,653 @@ +# `aai claude` Command 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 an `aai claude` command group that wires Claude Code up to AssemblyAI's docs MCP server and the `assemblyai-skill`, with `install`, `status`, and `remove` subcommands. + +**Architecture:** A new Typer sub-app in `assemblyai_cli/commands/claude.py`, registered in `main.py`. It shells out to `claude mcp …` for the remote docs MCP server and to `npx skills add …` for the skill. Each artifact is handled by an independent step that detects its required tool with `shutil.which`, runs via `subprocess.run` (captured), and returns a `{"name", "status", "detail"}` dict. Output and error handling reuse the existing `context.run_command` / `output.emit` conventions. + +**Tech Stack:** Python 3, Typer, Rich, pytest. Subprocess orchestration via stdlib `subprocess`/`shutil`/`pathlib`. No new runtime dependencies. + +--- + +## Background for the implementer + +Read these before starting: + +- `assemblyai_cli/commands/samples.py` — the closest existing command. Shows the `typer.Typer()` sub-app pattern, `run_command(ctx, body, json=json_out)`, `output.emit(data, human_renderer, json_mode=...)`, and raising `CLIError` for failures. +- `assemblyai_cli/context.py` — `run_command` catches `CLIError`, emits it, and exits with `err.exit_code`. Anything else (including `typer.Exit`) propagates. +- `assemblyai_cli/output.py` — `resolve_json(explicit=...)` returns `True` when `--json` is passed **or** stdout is not a TTY. Under `pytest`'s `CliRunner`, stdout is never a TTY, so **command output is JSON by default in tests** even without `--json`. Tests parse `json.loads(result.output)`. +- `assemblyai_cli/errors.py` — `CLIError(message, *, error_type, exit_code)` and `UsageError` (exit code 2). +- `assemblyai_cli/main.py` — sub-apps are registered with `app.add_typer(mod.app, name="...")`. +- `tests/test_samples.py` — test style: drive the CLI through `runner.invoke(app, [...])` and assert on `exit_code` and `output`. + +Key facts (already verified against Claude Code 2.1.161): + +- MCP install: `claude mcp add --transport http --scope assemblyai-docs https://mcp.assemblyai.com/docs`. The `name` comes before the URL. +- MCP presence/removal: `claude mcp get assemblyai-docs` (exits non-zero when absent), `claude mcp remove assemblyai-docs --scope `. +- Skill install: `npx skills add AssemblyAI/assemblyai-skill` (re-runnable; updates/de-dupes on its own). It installs into `~/.claude/skills/assemblyai/` (contains `SKILL.md`). +- There is **no** `claude install-skill` command. Do not use one. + +All tests **mock** `shutil.which` and `subprocess.run` — they never invoke real `claude` or `npx`. + +--- + +## File Structure + +- **Create** `assemblyai_cli/commands/claude.py` — the `claude` sub-app: constants, the `_run` subprocess helper, per-artifact step functions, and the `install` / `status` / `remove` commands. +- **Modify** `assemblyai_cli/main.py` — import `claude` and register it. +- **Create** `tests/test_claude.py` — all tests, mirroring `test_samples.py`. +- **Modify** `README.md` — add an "AI coding agents" section. + +--- + +## Task 1: `aai claude install` + +**Files:** +- Create: `assemblyai_cli/commands/claude.py` +- Modify: `assemblyai_cli/main.py:6` (import) and `assemblyai_cli/main.py:31-35` (registration) +- Test: `tests/test_claude.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_claude.py`: + +```python +import json +import subprocess + +from typer.testing import CliRunner + +from assemblyai_cli.main import app + +runner = CliRunner() + + +class FakeRun: + """Records subprocess calls and returns canned CompletedProcess results. + + `returncodes` maps a command prefix tuple (the first N argv tokens) to a + return code; the longest matching prefix wins, default 0. + """ + + def __init__(self, returncodes=None): + self.calls = [] + self.returncodes = returncodes or {} + + def __call__(self, cmd, *args, **kwargs): + self.calls.append(cmd) + rc = 0 + best = -1 + for prefix, code in self.returncodes.items(): + n = len(prefix) + if tuple(cmd[:n]) == prefix and n > best: + rc, best = code, n + return subprocess.CompletedProcess(args=cmd, returncode=rc, stdout="", stderr="boom") + + +def _all_tools_present(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: f"/usr/bin/{tool}", + ) + + +def test_install_happy_path_runs_both_steps(monkeypatch): + _all_tools_present(monkeypatch) + # MCP not yet present -> `mcp get` returns non-zero. + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses == {"mcp": "installed", "skill": "installed"} + + assert [ + "claude", "mcp", "add", "--transport", "http", + "--scope", "user", "assemblyai-docs", "https://mcp.assemblyai.com/docs", + ] in fake.calls + assert ["npx", "skills", "add", "AssemblyAI/assemblyai-skill"] in fake.calls + + +def test_install_scope_passthrough(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--scope", "project"]) + assert result.exit_code == 0 + assert [ + "claude", "mcp", "add", "--transport", "http", + "--scope", "project", "assemblyai-docs", "https://mcp.assemblyai.com/docs", + ] in fake.calls + + +def test_install_invalid_scope_exits_2(monkeypatch): + _all_tools_present(monkeypatch) + monkeypatch.setattr( + "assemblyai_cli.commands.claude.subprocess.run", FakeRun() + ) + result = runner.invoke(app, ["claude", "install", "--scope", "bogus"]) + assert result.exit_code == 2 + + +def test_install_idempotent_when_mcp_present(monkeypatch): + _all_tools_present(monkeypatch) + # `mcp get` returns 0 -> already registered. + fake = FakeRun({("claude", "mcp", "get"): 0}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["mcp"] == "already" + # No `mcp add` should have run. + assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_force_removes_then_adds(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 0}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install", "--force"]) + assert result.exit_code == 0 + assert ["claude", "mcp", "remove", "assemblyai-docs", "--scope", "user"] in fake.calls + assert any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_skips_mcp_when_claude_missing(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + fake = FakeRun() + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 # skip is not a failure + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["mcp"] == "skipped" + assert statuses["skill"] == "installed" + assert not any(c[0] == "claude" for c in fake.calls) + + +def test_install_skips_skill_when_npx_missing(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", + ) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 0 + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["skill"] == "skipped" + assert statuses["mcp"] == "installed" + assert not any(c[0] == "npx" for c in fake.calls) + + +def test_install_failure_exits_nonzero(monkeypatch): + _all_tools_present(monkeypatch) + # mcp not present, but `mcp add` fails. + fake = FakeRun({("claude", "mcp", "get"): 1, ("claude", "mcp", "add"): 1}) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "install"]) + assert result.exit_code == 1 + payload = json.loads(result.output) + statuses = {s["name"]: s["status"] for s in payload["steps"]} + assert statuses["mcp"] == "failed" +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_claude.py -v` +Expected: FAIL — collection/import errors and `aai claude` not being a known command (the sub-app does not exist yet). + +- [ ] **Step 3: Create the `claude` module** + +Create `assemblyai_cli/commands/claude.py`: + +```python +from __future__ import annotations + +import shutil +import subprocess + +import typer +from rich.markup import escape + +from assemblyai_cli import output +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import UsageError + +app = typer.Typer(help="Wire up Claude Code for AssemblyAI (docs MCP + skill).") + +MCP_NAME = "assemblyai-docs" +MCP_URL = "https://mcp.assemblyai.com/docs" +SKILL_REPO = "AssemblyAI/assemblyai-skill" +_VALID_SCOPES = ("user", "project", "local") + + +def _run(cmd: list[str]) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True) + + +def _mcp_present() -> bool: + return _run(["claude", "mcp", "get", MCP_NAME]).returncode == 0 + + +def _install_mcp(scope: str, force: bool) -> dict: + if shutil.which("claude") is None: + return { + "name": "mcp", + "status": "skipped", + "detail": ( + "Claude Code not found. Install it (https://claude.com/claude-code), " + f"then run: claude mcp add --transport http --scope {scope} " + f"{MCP_NAME} {MCP_URL}" + ), + } + if _mcp_present(): + if not force: + return {"name": "mcp", "status": "already", "detail": f"{MCP_NAME} already registered"} + _run(["claude", "mcp", "remove", MCP_NAME, "--scope", scope]) + proc = _run( + ["claude", "mcp", "add", "--transport", "http", "--scope", scope, MCP_NAME, MCP_URL] + ) + if proc.returncode != 0: + return {"name": "mcp", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} + return {"name": "mcp", "status": "installed", "detail": f"{MCP_NAME} @ {scope} scope"} + + +def _install_skill() -> dict: + if shutil.which("npx") is None: + return { + "name": "skill", + "status": "skipped", + "detail": ( + "Node.js/npx not found. Install Node.js, then run: " + f"npx skills add {SKILL_REPO}" + ), + } + proc = _run(["npx", "skills", "add", SKILL_REPO]) + if proc.returncode != 0: + return {"name": "skill", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} + return {"name": "skill", "status": "installed", "detail": SKILL_REPO} + + +def _render_steps(data: object) -> str: + steps = data["steps"] # type: ignore[index] + lines = [f" {s['name']}: {s['status']} — {escape(str(s['detail']))}" for s in steps] + return "AssemblyAI coding-agent setup:\n" + "\n".join(lines) + + +@app.command() +def install( + ctx: typer.Context, + scope: str = typer.Option( + "user", "--scope", help="Claude Code config scope: user, project, or local." + ), + 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 skill into Claude Code.""" + + def body(_state, json_mode: bool) -> None: + if scope not in _VALID_SCOPES: + raise UsageError( + f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." + ) + steps = [_install_mcp(scope, force), _install_skill()] + output.emit({"steps": steps}, _render_steps, json_mode=json_mode) + if any(s["status"] == "failed" for s in steps): + raise typer.Exit(code=1) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Register the sub-app in `main.py`** + +In `assemblyai_cli/main.py`, add `claude` to the import on line 6: + +```python +from assemblyai_cli.commands import claude, login, samples, stream, transcribe, transcripts +``` + +And register it alongside the other sub-apps (after line 31's block): + +```python +app.add_typer(claude.app, name="claude") +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pytest tests/test_claude.py -v` +Expected: PASS (all 8 tests). + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/commands/claude.py assemblyai_cli/main.py tests/test_claude.py +git commit -m "feat(claude): add 'aai claude install' for Claude Code MCP + skill" +``` + +--- + +## Task 2: `aai claude status` + +**Files:** +- Modify: `assemblyai_cli/commands/claude.py` (add `skill_dir`, `_mcp_status`, `_skill_status`, `status` command) +- Test: `tests/test_claude.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_claude.py`: + +```python +def test_status_reports_both_installed(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + skill = tmp_path / ".claude" / "skills" / "assemblyai" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + # `mcp get` returns 0 -> present. + monkeypatch.setattr( + "assemblyai_cli.commands.claude.subprocess.run", + FakeRun({("claude", "mcp", "get"): 0}), + ) + + result = runner.invoke(app, ["claude", "status"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "installed", "skill": "installed"} + + +def test_status_reports_not_installed(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir created + monkeypatch.setattr( + "assemblyai_cli.commands.claude.subprocess.run", + FakeRun({("claude", "mcp", "get"): 1}), + ) + + result = runner.invoke(app, ["claude", "status"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "not_installed", "skill": "not_installed"} + + +def test_status_mcp_unknown_when_claude_missing(monkeypatch, tmp_path): + monkeypatch.setattr( + "assemblyai_cli.commands.claude.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", FakeRun()) + + result = runner.invoke(app, ["claude", "status"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses["mcp"] == "unknown" +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `pytest tests/test_claude.py -k status -v` +Expected: FAIL — `claude status` is not a known command. + +- [ ] **Step 3: Implement `status`** + +In `assemblyai_cli/commands/claude.py`, add a `Path` import at the top of the import block: + +```python +from pathlib import Path +``` + +Add the skill-dir helper and status helpers below `_install_skill`: + +```python +def skill_dir() -> Path: + return Path.home() / ".claude" / "skills" / "assemblyai" + + +def _mcp_status() -> dict: + if shutil.which("claude") is None: + return {"name": "mcp", "status": "unknown", "detail": "Claude Code not found"} + present = _mcp_present() + return {"name": "mcp", "status": "installed" if present else "not_installed", "detail": MCP_NAME} + + +def _skill_status() -> dict: + present = (skill_dir() / "SKILL.md").exists() + return { + "name": "skill", + "status": "installed" if present else "not_installed", + "detail": str(skill_dir()), + } +``` + +Add the command (after `install`): + +```python +@app.command() +def status( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show whether the AssemblyAI MCP server and skill are wired into Claude Code.""" + + def body(_state, json_mode: bool) -> None: + steps = [_mcp_status(), _skill_status()] + output.emit({"steps": steps}, _render_steps, json_mode=json_mode) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_claude.py -v` +Expected: PASS (all tests, including the three new `status` tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/claude.py tests/test_claude.py +git commit -m "feat(claude): add 'aai claude status'" +``` + +--- + +## Task 3: `aai claude remove` + +**Files:** +- Modify: `assemblyai_cli/commands/claude.py` (add `_remove_mcp`, `_remove_skill`, `remove` command) +- Test: `tests/test_claude.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_claude.py`: + +```python +def test_remove_unwinds_both(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + skill = tmp_path / ".claude" / "skills" / "assemblyai" + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + fake = FakeRun({("claude", "mcp", "get"): 0}) # present -> removable + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "remove"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "removed", "skill": "removed"} + assert ["claude", "mcp", "remove", "assemblyai-docs", "--scope", "user"] in fake.calls + assert not skill.exists() + + +def test_remove_when_absent_is_not_an_error(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir + fake = FakeRun({("claude", "mcp", "get"): 1}) # absent + monkeypatch.setattr("assemblyai_cli.commands.claude.subprocess.run", fake) + + result = runner.invoke(app, ["claude", "remove"]) + assert result.exit_code == 0 + statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + assert statuses == {"mcp": "not_installed", "skill": "not_installed"} + assert not any(c[:3] == ["claude", "mcp", "remove"] for c in fake.calls) +``` + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `pytest tests/test_claude.py -k remove -v` +Expected: FAIL — `claude remove` is not a known command. + +- [ ] **Step 3: Implement `remove`** + +In `assemblyai_cli/commands/claude.py`, add the removal helpers below `_skill_status`: + +```python +def _remove_mcp(scope: str) -> dict: + if shutil.which("claude") is None: + return {"name": "mcp", "status": "skipped", "detail": "Claude Code not found"} + if not _mcp_present(): + return {"name": "mcp", "status": "not_installed", "detail": MCP_NAME} + proc = _run(["claude", "mcp", "remove", MCP_NAME, "--scope", scope]) + if proc.returncode != 0: + return {"name": "mcp", "status": "failed", "detail": (proc.stderr or proc.stdout).strip()} + return {"name": "mcp", "status": "removed", "detail": MCP_NAME} + + +def _remove_skill() -> dict: + target = skill_dir() + if not target.exists(): + return {"name": "skill", "status": "not_installed", "detail": str(target)} + shutil.rmtree(target) + return {"name": "skill", "status": "removed", "detail": str(target)} +``` + +Add the command (after `status`): + +```python +@app.command() +def remove( + ctx: typer.Context, + scope: str = typer.Option( + "user", "--scope", help="Claude Code config scope the MCP was added under." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Remove the AssemblyAI MCP server and skill from Claude Code.""" + + def body(_state, json_mode: bool) -> None: + if scope not in _VALID_SCOPES: + raise UsageError( + f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." + ) + steps = [_remove_mcp(scope), _remove_skill()] + output.emit({"steps": steps}, _render_steps, json_mode=json_mode) + if any(s["status"] == "failed" for s in steps): + raise typer.Exit(code=1) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_claude.py -v` +Expected: PASS (all tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/claude.py tests/test_claude.py +git commit -m "feat(claude): add 'aai claude remove'" +``` + +--- + +## Task 4: Docs + full-surface smoke test + +**Files:** +- Modify: `README.md` +- Test: `tests/test_claude.py` + +- [ ] **Step 1: Write the failing smoke test** + +Append to `tests/test_claude.py`: + +```python +def test_claude_help_lists_all_subcommands(): + result = runner.invoke(app, ["claude", "--help"]) + assert result.exit_code == 0 + assert "install" in result.output + assert "status" in result.output + assert "remove" in result.output +``` + +- [ ] **Step 2: Run it to verify it passes (regression guard)** + +Run: `pytest tests/test_claude.py::test_claude_help_lists_all_subcommands -v` +Expected: PASS — all three commands now exist from Tasks 1–3. (This test guards against accidental de-registration; it does not need a red phase.) + +- [ ] **Step 3: Add the README section** + +In `README.md`, after the `## Streaming` section, add: + +```markdown +## AI coding agents + +Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI +skill so your agent writes current, correct integration code: + + aai claude install # installs the docs MCP server + skill (user scope) + aai claude status # show what's wired up + aai claude remove # unwind both + +`install` shells out to `claude mcp add` for the docs MCP server and to +`npx skills add` for the skill. Pass `--scope project` to scope the MCP server to +the current project instead of the whole machine. A missing `claude` or `npx` is +reported and skipped (with the manual command to run), not treated as an error. +``` + +- [ ] **Step 4: Run the full suite** + +Run: `pytest -q` +Expected: PASS (entire suite, including the existing tests). + +- [ ] **Step 5: Commit** + +```bash +git add README.md tests/test_claude.py +git commit -m "docs(claude): document 'aai claude' commands; smoke-test subcommands" +``` + +--- + +## Self-Review + +**Spec coverage:** +- Command surface (`install`/`status`/`remove`, `--scope`/`--force`/`--json`) → Tasks 1–3. ✓ +- Shell out: `claude mcp add` (MCP), `npx skills add` (skill) → Task 1. ✓ +- User-scope default + override → Task 1 (`scope="user"`, `_VALID_SCOPES`). ✓ +- Dependency detection + skip-with-guidance + independence + exit-code rule → Task 1 (`_install_mcp`/`_install_skill` `shutil.which` guards; `failed` raises `Exit(1)`, `skipped` does not). ✓ +- Idempotency + `--force` → Task 1 (`_mcp_present`, force remove-then-add; `npx skills add` re-runnable). ✓ +- `status` (MCP via `claude mcp get`, skill via `SKILL.md`, `unknown` when `claude` missing) → Task 2. ✓ +- `remove` (`claude mcp remove`; delete skill dir; absent = `not_installed`) → Task 3. ✓ +- Error handling via `CLIError`/`UsageError`/`run_command`; JSON shape `{"steps":[{name,status,detail}]}` → Tasks 1–3. ✓ +- Status vocabulary (`installed`/`already`/`skipped`/`failed`/`removed`/`not_installed`/`unknown`) → exercised across Tasks 1–3. ✓ +- Docs section + upstream-docs discrepancy noted → Task 4 README + spec note. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `skill_dir()`, `_run`, `_mcp_present`, `_install_mcp(scope, force)`, `_install_skill()`, `_render_steps`, `_mcp_status`, `_skill_status`, `_remove_mcp(scope)`, `_remove_skill()`, and constants `MCP_NAME`/`MCP_URL`/`SKILL_REPO`/`_VALID_SCOPES` are referenced consistently across tasks. Step dict keys (`name`/`status`/`detail`) are uniform. ✓ diff --git a/docs/superpowers/plans/2026-06-02-streaming.md b/docs/superpowers/plans/2026-06-02-streaming.md new file mode 100644 index 00000000..867110ee --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-streaming.md @@ -0,0 +1,833 @@ +# `aai stream` 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 stream [SOURCE]` — real-time transcription from the microphone (no arg) or an audio file (path arg), via AssemblyAI's v3 streaming API. + +**Architecture:** Two interchangeable audio sources (iterators of PCM byte chunks) — `MicSource` (PyAudio, optional `[mic]` extra) and `FileSource` (stdlib `wave` for 16 kHz mono WAV, else an `ffmpeg` subprocess) — both fed to `client.stream_audio(...)`, which wires a v3 `StreamingClient` and forwards `Begin`/`Turn` events to render callbacks. A `StreamRenderer` prints a live-updating turn line for humans or newline-delimited JSON for agents. + +**Tech Stack:** Python ≥3.10, Typer, `assemblyai` (v3 `streaming`), stdlib `wave`/`subprocess`/`shutil`, pytest. + +**Spec:** `docs/superpowers/specs/2026-06-02-streaming-design.md` + +**Verified SDK facts (assemblyai 0.64.0):** +- `from assemblyai.streaming.v3 import StreamingClient, StreamingClientOptions, StreamingParameters, StreamingEvents`. +- `StreamingClient(StreamingClientOptions(api_key=..., api_host="streaming.assemblyai.com"))`; methods `.on(event, handler)`, `.connect(StreamingParameters(sample_rate=int, format_turns=True))`, `.stream(iterable_of_bytes)`, `.disconnect()`. +- Event handlers are called as `handler(client, event)`. `StreamingEvents.Begin/Turn/Termination/Error`. +- `TurnEvent` fields include `.transcript` (str) and `.end_of_turn` (bool). `BeginEvent.id`. `TerminationEvent.audio_duration_seconds`. +- `aai.extras.MicrophoneStream(sample_rate=44100, device_index=None)` is an iterator of PCM bytes; importing/instantiating it requires PyAudio. +- `StreamingClient` and the v3 module do NOT require PyAudio; only `MicrophoneStream` does. + +--- + +## File Structure + +``` +assemblyai_cli/ + streaming/ + __init__.py # empty package marker + sources.py # FileSource, MicSource (+ _load_microphone_stream); raise CLIError on missing deps/files + render.py # StreamRenderer: live human line vs NDJSON events + client.py # + stream_audio(...) — sole SDK boundary for v3 streaming + commands/ + stream.py # `aai stream` Typer command (thin) + main.py # register stream.app +pyproject.toml # [mic] optional extra = pyaudio +tests/ + test_streaming_sources.py + test_streaming_render.py + test_stream_command.py + test_client.py # + stream_audio wiring tests +``` + +Constants used across the file source (define at top of `sources.py`): `TARGET_RATE = 16000` and `CHUNK_BYTES = 3200` (100 ms of 16-bit mono @ 16 kHz = 16000 × 2 × 0.1). + +--- + +## Task 1: FileSource (WAV + ffmpeg) + +**Files:** +- Create: `assemblyai_cli/streaming/__init__.py` +- Create: `assemblyai_cli/streaming/sources.py` +- Test: `tests/test_streaming_sources.py` + +- [ ] **Step 1: Create the package marker** `assemblyai_cli/streaming/__init__.py` (empty file). + +- [ ] **Step 2: Write the failing test** at `tests/test_streaming_sources.py`: + +```python +import wave + +import pytest + +from assemblyai_cli.errors import CLIError +from assemblyai_cli.streaming import sources +from assemblyai_cli.streaming.sources import FileSource + + +def _write_wav(path, *, seconds=0.5, rate=16000): + frames = int(rate * seconds) + with wave.open(str(path), "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(b"\x00\x01" * frames) # 2 bytes/frame, mono 16-bit + + +def test_filesource_streams_wav_chunks(tmp_path): + p = tmp_path / "clip.wav" + _write_wav(p, seconds=0.5) # 0.5s @16k mono 16-bit = 16000 bytes + src = FileSource(str(p), sleep=lambda _s: None) + chunks = list(src) + assert sum(len(c) for c in chunks) == 16000 + assert all(len(c) <= sources.CHUNK_BYTES for c in chunks) + assert len(chunks) == 5 # 16000 / 3200 = 5 + + +def test_filesource_missing_file_raises(): + with pytest.raises(CLIError) as exc: + FileSource("/no/such/file.wav") + assert exc.value.exit_code == 2 + + +def test_filesource_non_wav_without_ffmpeg_raises(tmp_path, monkeypatch): + p = tmp_path / "clip.mp3" + p.write_bytes(b"not really audio") + monkeypatch.setattr(sources.shutil, "which", lambda _name: None) + with pytest.raises(CLIError) as exc: + FileSource(str(p)) + assert exc.value.error_type == "ffmpeg_missing" + + +def test_filesource_uses_ffmpeg_for_non_wav(tmp_path, monkeypatch): + p = tmp_path / "clip.mp3" + p.write_bytes(b"not really audio") + monkeypatch.setattr(sources.shutil, "which", lambda _name: "/usr/bin/ffmpeg") + + class FakeProc: + def __init__(self): + self.stdout = self + self._data = [b"\x00" * 3200, b"\x01" * 100, b""] + self._i = 0 + + def read(self, _n): + d = self._data[self._i] + self._i += 1 + return d + + def close(self): + pass + + def terminate(self): + pass + + def wait(self): + pass + + monkeypatch.setattr(sources.subprocess, "Popen", lambda *a, **k: FakeProc()) + chunks = list(FileSource(str(p), sleep=lambda _s: None)) + assert chunks == [b"\x00" * 3200, b"\x01" * 100] +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `python -m pytest tests/test_streaming_sources.py -v` +Expected: FAIL (`ModuleNotFoundError: assemblyai_cli.streaming.sources`). + +- [ ] **Step 4: Implement `assemblyai_cli/streaming/sources.py`** (FileSource portion): + +```python +from __future__ import annotations + +import shutil +import subprocess +import time +import wave +from collections.abc import Iterator +from pathlib import Path + +from assemblyai_cli.errors import CLIError + +TARGET_RATE = 16000 +CHUNK_BYTES = TARGET_RATE * 2 // 10 # 100 ms of 16-bit mono PCM + + +def _is_streamable_wav(path: Path) -> bool: + try: + with wave.open(str(path), "rb") as w: + return ( + w.getnchannels() == 1 + and w.getsampwidth() == 2 + and w.getframerate() == TARGET_RATE + ) + except (wave.Error, EOFError, OSError): + return False + + +class FileSource: + """Yields real-time-paced 16 kHz mono PCM chunks from an audio file.""" + + def __init__(self, path: str, *, sleep=time.sleep) -> None: + self.path = Path(path) + self._sleep = sleep + self.sample_rate = TARGET_RATE + if not self.path.is_file(): + raise CLIError( + f"No such file: {self.path}", error_type="file_not_found", exit_code=2 + ) + self._wav = _is_streamable_wav(self.path) + if not self._wav and shutil.which("ffmpeg") is None: + raise CLIError( + "This audio format needs ffmpeg. Install ffmpeg, or pass a " + "16 kHz mono 16-bit WAV.", + error_type="ffmpeg_missing", + exit_code=2, + ) + + def __iter__(self) -> Iterator[bytes]: + chunks = self._wav_chunks() if self._wav else self._ffmpeg_chunks() + for chunk in chunks: + yield chunk + self._sleep(CHUNK_BYTES / (TARGET_RATE * 2)) # ~real-time pacing + + def _wav_chunks(self) -> Iterator[bytes]: + frames_per_chunk = CHUNK_BYTES // 2 + with wave.open(str(self.path), "rb") as w: + while True: + data = w.readframes(frames_per_chunk) + if not data: + return + yield data + + def _ffmpeg_chunks(self) -> Iterator[bytes]: + proc = subprocess.Popen( + [ + "ffmpeg", "-nostdin", "-loglevel", "error", "-i", str(self.path), + "-f", "s16le", "-acodec", "pcm_s16le", "-ac", "1", + "-ar", str(TARGET_RATE), "-", + ], + stdout=subprocess.PIPE, + ) + try: + while True: + data = proc.stdout.read(CHUNK_BYTES) + if not data: + return + yield data + finally: + proc.stdout.close() + proc.terminate() + proc.wait() +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `python -m pytest tests/test_streaming_sources.py -v` +Expected: 4 PASS. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/streaming/__init__.py assemblyai_cli/streaming/sources.py tests/test_streaming_sources.py +git commit -m "feat(stream): add FileSource (wav + ffmpeg) audio source" +``` + +--- + +## Task 2: MicSource (optional PyAudio) + +**Files:** +- Modify: `assemblyai_cli/streaming/sources.py` +- Test: `tests/test_streaming_sources.py` (extend) + +- [ ] **Step 1: Add the failing test** (append to `tests/test_streaming_sources.py`): + +```python +def test_micsource_missing_dependency_raises(monkeypatch): + def boom(): + raise ImportError("No module named 'pyaudio'") + + monkeypatch.setattr(sources, "_load_microphone_stream", boom) + with pytest.raises(CLIError) as exc: + list(sources.MicSource(sample_rate=16000)) + assert exc.value.error_type == "mic_missing" + assert "assemblyai-cli[mic]" in exc.value.message + + +def test_micsource_yields_from_microphone_stream(monkeypatch): + captured = {} + + class FakeMic: + def __init__(self, sample_rate, device_index): + captured["rate"] = sample_rate + captured["device"] = device_index + + def __iter__(self): + return iter([b"\x00\x01", b"\x02\x03"]) + + monkeypatch.setattr(sources, "_load_microphone_stream", lambda: FakeMic) + chunks = list(sources.MicSource(sample_rate=16000, device=2)) + assert chunks == [b"\x00\x01", b"\x02\x03"] + assert captured == {"rate": 16000, "device": 2} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python -m pytest tests/test_streaming_sources.py -k micsource -v` +Expected: FAIL (`AttributeError: module ... has no attribute '_load_microphone_stream'` / no `MicSource`). + +- [ ] **Step 3: Add to `assemblyai_cli/streaming/sources.py`**: + +```python +def _load_microphone_stream(): + """Import the SDK's PyAudio-backed mic stream (isolated for testing/patching).""" + from assemblyai.extras import MicrophoneStream + + return MicrophoneStream + + +class MicSource: + """Yields PCM chunks from the default microphone (requires the [mic] extra).""" + + def __init__(self, *, sample_rate: int, device: int | None = None) -> None: + self.sample_rate = sample_rate + self.device = device + + def __iter__(self) -> Iterator[bytes]: + try: + microphone_stream = _load_microphone_stream() + except (ImportError, ModuleNotFoundError) as exc: + raise CLIError( + "Microphone support isn't installed. " + "Run: pip install 'assemblyai-cli[mic]'", + error_type="mic_missing", + exit_code=2, + ) from exc + return iter(microphone_stream(sample_rate=self.sample_rate, device_index=self.device)) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python -m pytest tests/test_streaming_sources.py -v` +Expected: 6 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/streaming/sources.py tests/test_streaming_sources.py +git commit -m "feat(stream): add MicSource with graceful missing-PyAudio error" +``` + +--- + +## Task 3: StreamRenderer (human line + NDJSON) + +**Files:** +- Create: `assemblyai_cli/streaming/render.py` +- Test: `tests/test_streaming_render.py` + +- [ ] **Step 1: Write the failing test** at `tests/test_streaming_render.py`: + +```python +import io +import json +import types + +from assemblyai_cli.streaming.render import StreamRenderer + + +def _turn(transcript, end_of_turn): + return types.SimpleNamespace(transcript=transcript, end_of_turn=end_of_turn) + + +def test_human_turn_finalizes_on_end_of_turn(): + out = io.StringIO() + r = StreamRenderer(json_mode=False, out=out) + r.turn(_turn("hello", False)) + r.turn(_turn("hello world", True)) + text = out.getvalue() + assert "hello world" in text + assert text.endswith("\n") + + +def test_json_mode_emits_ndjson_events(): + out = io.StringIO() + r = StreamRenderer(json_mode=True, out=out) + r.begin(types.SimpleNamespace(id="sess_1")) + r.turn(_turn("hi", True)) + lines = [json.loads(line) for line in out.getvalue().splitlines()] + assert lines[0] == {"type": "begin", "id": "sess_1"} + assert lines[1] == {"type": "turn", "transcript": "hi", "end_of_turn": True} + + +def test_human_begin_prints_notice(): + out = io.StringIO() + StreamRenderer(json_mode=False, out=out).begin(types.SimpleNamespace(id="x")) + assert "Ctrl-C" in out.getvalue() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python -m pytest tests/test_streaming_render.py -v` +Expected: FAIL (`ModuleNotFoundError`). + +- [ ] **Step 3: Implement `assemblyai_cli/streaming/render.py`**: + +```python +from __future__ import annotations + +import json +import sys + + +class StreamRenderer: + """Renders streaming events: a live-updating line for humans, NDJSON for agents.""" + + def __init__(self, *, json_mode: bool, out=None) -> None: + self.json_mode = json_mode + self.out = out if out is not None else sys.stdout + self._width = 0 + + def begin(self, event) -> None: + if self.json_mode: + self._emit({"type": "begin", "id": getattr(event, "id", None)}) + else: + self.out.write("Listening… (Ctrl-C to stop)\n") + self.out.flush() + + def turn(self, event) -> None: + text = getattr(event, "transcript", "") or "" + end = bool(getattr(event, "end_of_turn", False)) + if self.json_mode: + self._emit({"type": "turn", "transcript": text, "end_of_turn": end}) + return + self.out.write("\r" + text.ljust(self._width)) + self._width = max(self._width, len(text)) + if end: + self.out.write("\n") + self._width = 0 + self.out.flush() + + def termination(self, event) -> None: + if self.json_mode: + self._emit( + { + "type": "termination", + "audio_duration_seconds": getattr(event, "audio_duration_seconds", None), + } + ) + + def _emit(self, obj) -> None: + self.out.write(json.dumps(obj) + "\n") + self.out.flush() +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python -m pytest tests/test_streaming_render.py -v` +Expected: 3 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/streaming/render.py tests/test_streaming_render.py +git commit -m "feat(stream): add StreamRenderer for live text and NDJSON output" +``` + +--- + +## Task 4: client.stream_audio (v3 wiring) + +**Files:** +- Modify: `assemblyai_cli/client.py` +- Test: `tests/test_client.py` (extend) + +- [ ] **Step 1: Add the failing test** (append to `tests/test_client.py`): + +```python +import types as _types + + +class _FakeStreamingClient: + last = None + + def __init__(self, options): + self.handlers = {} + self.connected = False + self.disconnected = False + _FakeStreamingClient.last = self + + def on(self, event, handler): + self.handlers[event] = handler + + def connect(self, params): + self.connected = True + self.params = params + + def stream(self, source): + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Turn]( + self, _types.SimpleNamespace(transcript="hi", end_of_turn=True) + ) + + def disconnect(self): + self.disconnected = True + + +def test_stream_audio_wires_handlers_and_streams(monkeypatch): + monkeypatch.setattr(client, "StreamingClient", _FakeStreamingClient) + turns = [] + client.stream_audio( + "sk", [b"\x00"], sample_rate=16000, on_turn=lambda e: turns.append(e.transcript) + ) + assert turns == ["hi"] + assert _FakeStreamingClient.last.connected + assert _FakeStreamingClient.last.disconnected # disconnected in finally + + +def test_stream_audio_raises_on_error_event(monkeypatch): + class ErrClient(_FakeStreamingClient): + def stream(self, source): + from assemblyai.streaming.v3 import StreamingEvents + + self.handlers[StreamingEvents.Error](self, "boom") + + monkeypatch.setattr(client, "StreamingClient", ErrClient) + with pytest.raises(APIError): + client.stream_audio("sk", [b"\x00"], sample_rate=16000) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python -m pytest tests/test_client.py -k stream_audio -v` +Expected: FAIL (`AttributeError: module ... has no attribute 'StreamingClient'` / no `stream_audio`). + +- [ ] **Step 3: Add to `assemblyai_cli/client.py`** — module-level import near the top (after `import assemblyai as aai`): + +```python +from assemblyai.streaming.v3 import ( + StreamingClient, + StreamingClientOptions, + StreamingEvents, + StreamingParameters, +) +``` + +and the function (anywhere after `get_transcript`): + +```python +def stream_audio( + api_key: str, + source, + *, + sample_rate: int, + on_begin=None, + on_turn=None, +) -> None: + """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. + + Forwards Begin/Turn events to the callbacks; raises APIError on a stream error. + """ + sc = StreamingClient( + StreamingClientOptions(api_key=api_key, api_host="streaming.assemblyai.com") + ) + errors: list[object] = [] + if on_begin is not None: + sc.on(StreamingEvents.Begin, lambda _client, event: on_begin(event)) + if on_turn is not None: + sc.on(StreamingEvents.Turn, lambda _client, event: on_turn(event)) + sc.on(StreamingEvents.Error, lambda _client, error: errors.append(error)) + + sc.connect(StreamingParameters(sample_rate=sample_rate, format_turns=True)) + try: + sc.stream(source) + finally: + sc.disconnect() + if errors: + raise APIError(f"Streaming error: {errors[0]}") +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python -m pytest tests/test_client.py -v` +Expected: all client tests PASS (existing + 2 new). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/client.py tests/test_client.py +git commit -m "feat(stream): add client.stream_audio v3 wiring" +``` + +--- + +## Task 5: `aai stream` command + +**Files:** +- Modify: `assemblyai_cli/commands/stream.py` (currently does not exist — create it) +- Test: `tests/test_stream_command.py` + +- [ ] **Step 1: Write the failing test** at `tests/test_stream_command.py`: + +```python +import json +import types + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def _drive_turns(api_key, source, *, sample_rate, on_begin=None, on_turn=None): + # Simulate the streaming client driving the renderer callbacks. + if on_begin: + on_begin(types.SimpleNamespace(id="sess")) + if on_turn: + on_turn(types.SimpleNamespace(transcript="hello world", end_of_turn=True)) + + +def test_stream_help_lists_command(): + result = runner.invoke(app, ["stream", "--help"]) + assert result.exit_code == 0 + assert "microphone" in result.output.lower() + + +def test_stream_mic_renders_turns(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", _drive_turns) + result = runner.invoke(app, ["stream", "--json"]) + assert result.exit_code == 0 + lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] + assert {"type": "turn", "transcript": "hello world", "end_of_turn": True} in lines + + +def test_stream_file_uses_filesource(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + seen = {} + + def fake_stream_audio(api_key, source, *, sample_rate, on_begin=None, on_turn=None): + seen["source_type"] = type(source).__name__ + seen["rate"] = sample_rate + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", fake_stream_audio) + # A real WAV so FileSource constructs successfully. + import wave + + p = tmp_path / "a.wav" + with wave.open(str(p), "wb") as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) + w.writeframes(b"\x00\x01" * 100) + result = runner.invoke(app, ["stream", str(p)]) + assert result.exit_code == 0 + assert seen["source_type"] == "FileSource" + assert seen["rate"] == 16000 + + +def test_stream_unauthenticated_exits_2(): + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 2 + + +def test_stream_ctrl_c_exits_cleanly(monkeypatch): + config.set_api_key("default", "sk_live") + + def raise_kbd(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.stream.client.stream_audio", raise_kbd) + result = runner.invoke(app, ["stream"]) + assert result.exit_code == 0 +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `python -m pytest tests/test_stream_command.py -v` +Expected: FAIL (no `stream` command registered). + +- [ ] **Step 3: Implement `assemblyai_cli/commands/stream.py`**: + +```python +from __future__ import annotations + +import typer + +from assemblyai_cli import client, config +from assemblyai_cli.context import run_command +from assemblyai_cli.streaming.render import StreamRenderer +from assemblyai_cli.streaming.sources import FileSource, MicSource + +app = typer.Typer() + + +@app.command() +def stream( + ctx: typer.Context, + source: str = typer.Argument( + None, help="Audio file to stream. Omit to use the microphone." + ), + sample_rate: int = typer.Option( + 16000, "--sample-rate", help="Microphone sample rate in Hz." + ), + device: int = typer.Option(None, "--device", help="Microphone device index."), + json_out: bool = typer.Option( + False, "--json", help="Emit newline-delimited JSON events." + ), +) -> None: + """Transcribe live audio from the microphone or a file in real time.""" + + def body(state, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + if source: + audio = FileSource(source) + rate = audio.sample_rate + else: + audio = MicSource(sample_rate=sample_rate, device=device) + rate = sample_rate + renderer = StreamRenderer(json_mode=json_mode) + try: + client.stream_audio( + api_key, + audio, + sample_rate=rate, + on_begin=renderer.begin, + on_turn=renderer.turn, + ) + except KeyboardInterrupt: + if not json_mode: + renderer.out.write("\nStopped.\n") + renderer.out.flush() + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `python -m pytest tests/test_stream_command.py -v` +Expected: 5 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/stream.py tests/test_stream_command.py +git commit -m "feat(stream): add aai stream command (mic + file)" +``` + +--- + +## Task 6: Register the command + `[mic]` extra + +**Files:** +- Modify: `assemblyai_cli/main.py` +- Modify: `pyproject.toml` +- Test: `tests/test_smoke.py` (extend) + +- [ ] **Step 1: Add the failing test** (append to `tests/test_smoke.py`): + +```python +def test_stream_registered_top_level(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "stream" in result.output +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `python -m pytest tests/test_smoke.py::test_stream_registered_top_level -v` +Expected: FAIL (`stream` not in top-level help). + +- [ ] **Step 3: Register in `assemblyai_cli/main.py`** — add `stream` to the commands import and register it. Change the import line: + +```python +from assemblyai_cli.commands import login, samples, stream, transcribe, transcripts +``` + +and add, alongside the other `app.add_typer(...)` calls: + +```python +app.add_typer(stream.app) +``` + +- [ ] **Step 4: Add the `[mic]` extra to `pyproject.toml`** — change the optional-dependencies table: + +```toml +[project.optional-dependencies] +dev = ["pytest>=8.0", "ruff>=0.11", "pre-commit>=4.0"] +mic = ["pyaudio>=0.2.11"] +``` + +- [ ] **Step 5: Run the smoke + full suite** + +Run: `python -m pytest -q` +Expected: all green. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/main.py pyproject.toml tests/test_smoke.py +git commit -m "feat(stream): register aai stream and add [mic] optional extra" +``` + +--- + +## Task 7: Full-suite verification + docs + +**Files:** +- Modify: `README.md` +- Test: none (verification) + +- [ ] **Step 1: Run the entire suite** + +Run: `python -m pytest -q` +Expected: all tests PASS (sources, render, client streaming, stream command, smoke, plus the pre-existing suite). + +- [ ] **Step 2: Verify the help tree** + +Run: `python -m assemblyai_cli stream --help` and `python -m assemblyai_cli --help` +Expected: top-level lists `stream`; `stream --help` shows the SOURCE argument and `--sample-rate`/`--device`/`--json`. + +- [ ] **Step 3: Add a streaming section to `README.md`** (append under usage): + +```markdown +## Streaming + +Real-time transcription from a file (no extra dependency): + + aai stream path/to/audio.wav # 16 kHz mono WAV streams directly + aai stream path/to/audio.mp3 # other formats require ffmpeg on PATH + +From the microphone (install the optional extra first): + + pip install "assemblyai-cli[mic]" + aai stream # Ctrl-C to stop + +Add `--json` for newline-delimited JSON events (also the default when piped or run by an agent). +``` + +- [ ] **Step 4: (Optional, manual) live checks** — not part of the automated suite: + +```bash +# File (needs a real key; works without a mic): +ASSEMBLYAI_API_KEY=... python -m assemblyai_cli stream some.wav +# Microphone (needs the [mic] extra + a real key): +ASSEMBLYAI_API_KEY=... python -m assemblyai_cli stream +``` + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: document aai stream usage" +``` + +--- + +## Self-Review notes + +- **Spec coverage:** `aai stream [SOURCE]` + flags → Tasks 5/6; mic via optional `[mic]` extra with graceful error → Tasks 2/6; file via stdlib `wave` (WAV) and `ffmpeg` (other) with real-time pacing → Task 1; v3 `StreamingClient` wiring with Begin/Turn/Error → Task 4; human live line vs NDJSON via `resolve_json`/`StreamRenderer` → Task 3 (mode supplied by `run_command`); error paths (mic-missing exit 2, ffmpeg-missing exit 2, file-not-found exit 2, unauthenticated exit 2, stream error exit 1, Ctrl-C exit 0) → Tasks 1/2/4/5; testing strategy (WAV fixture, ffmpeg mock, mic-missing, render, command wiring) → Tasks 1–5. +- **Type/signature consistency:** `FileSource(path, *, sleep=time.sleep)` exposing `.sample_rate`; `MicSource(*, sample_rate, device=None)`; both iterable of `bytes`. `client.stream_audio(api_key, source, *, sample_rate, on_begin=None, on_turn=None)` raising `APIError` on error. `StreamRenderer(json_mode, out=None)` with `.begin/.turn/.termination`. The command calls `run_command(ctx, body, json=json_out)` (matching the established per-command `--json` pattern) and selects `FileSource` when `source` is truthy else `MicSource`. `CHUNK_BYTES`/`TARGET_RATE` are defined once in `sources.py` and referenced by tests via `sources.CHUNK_BYTES`. +- **Note:** `--sample-rate`/`--device` apply to the microphone; file input is normalized to 16 kHz mono (the command reads `audio.sample_rate`, which `FileSource` fixes at `TARGET_RATE`). +``` diff --git a/docs/superpowers/plans/2026-06-02-voice-agent.md b/docs/superpowers/plans/2026-06-02-voice-agent.md new file mode 100644 index 00000000..e2348205 --- /dev/null +++ b/docs/superpowers/plans/2026-06-02-voice-agent.md @@ -0,0 +1,1228 @@ +# Voice Agent (`aai agent`) 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 agent` — a live two-way voice conversation against AssemblyAI's Voice Agent API (mic in, agent speech out, transcript on screen). + +**Architecture:** A new `agent/` package mirroring `streaming/`: `voices.py` (static voice list), `render.py` (`AgentRenderer` — human lines / NDJSON), `audio.py` (`Player` + `MicCapture`, both with injectable stream factories so they test without PyAudio), and `session.py` (`VoiceAgentSession` with a pure `dispatch(event)` router, plus a `run_session(...)` transport that opens the raw WebSocket and runs capture/playback threads). A thin `commands/agent.py` wires it up, patched in command tests exactly like `commands/stream.py` patches `client.stream_audio`. + +**Tech Stack:** Python 3.10+, Typer, `websockets` (sync client, already transitive via the SDK), PyAudio (existing `[mic]` extra), pytest. + +--- + +## File Structure + +- Create `assemblyai_cli/agent/__init__.py` — empty package marker. +- Create `assemblyai_cli/agent/voices.py` — `VOICES` list + `format_voice_list()`. +- Create `assemblyai_cli/agent/render.py` — `AgentRenderer`. +- Create `assemblyai_cli/agent/audio.py` — `Player`, `MicCapture`. +- Create `assemblyai_cli/agent/session.py` — `VoiceAgentSession`, `run_session(...)`, defaults. +- Create `assemblyai_cli/commands/agent.py` — the `aai agent` Typer command. +- Modify `assemblyai_cli/main.py` — register the agent command. +- Modify `pyproject.toml` — add `websockets>=13` to base dependencies. +- Modify `README.md` — document `aai agent`. +- Create `tests/test_agent_voices.py`, `tests/test_agent_render.py`, `tests/test_agent_audio.py`, `tests/test_agent_session.py`, `tests/test_agent_command.py`. + +Conventions to match (from the existing code): `from __future__ import annotations` at the top of every module; `CLIError`/`APIError` from `assemblyai_cli.errors`; `config.resolve_api_key(profile=...)`; `output.resolve_json(explicit=...)`; `run_command(ctx, body, json=...)`; the `\r\x1b[K` in-place-line trick from `streaming/render.py`; the `[mic]`-missing message string used in `streaming/sources.py`. + +--- + +## Task 1: Voice list + `voices.py` + +**Files:** +- Create: `assemblyai_cli/agent/__init__.py` +- Create: `assemblyai_cli/agent/voices.py` +- Test: `tests/test_agent_voices.py` + +- [ ] **Step 1: Create the empty package marker** + +Create `assemblyai_cli/agent/__init__.py` with no content (empty file). + +- [ ] **Step 2: Write the failing test** + +Create `tests/test_agent_voices.py`: + +```python +from assemblyai_cli.agent import voices + + +def test_voices_includes_default(): + assert "ivy" in voices.VOICES + + +def test_voices_are_unique_and_nonempty(): + assert voices.VOICES + assert len(voices.VOICES) == len(set(voices.VOICES)) + + +def test_format_voice_list_mentions_voices(): + out = voices.format_voice_list() + assert "ivy" in out + assert "james" in out +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `python -m pytest tests/test_agent_voices.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'assemblyai_cli.agent.voices'`. + +- [ ] **Step 4: Write the implementation** + +Create `assemblyai_cli/agent/voices.py`: + +```python +from __future__ import annotations + +# Known Voice Agent voice IDs (from the Voice Agent quickstart). The server is +# the source of truth; this list backs --list-voices and catches obvious typos. +VOICES: list[str] = [ + # English + "ivy", "james", "tyler", "winter", "sam", "mia", "bella", "david", + "jack", "kyle", "helen", "martha", "river", "emma", "victor", "eleanor", + "sophie", "oliver", + # Multilingual + "arjun", "ethan", "dmitri", "lukas", "lena", "pierre", "mina", "ren", + "mei", "joon", "giulia", "luca", "lucia", "hana", "mateo", "diego", +] + +DEFAULT_VOICE = "ivy" + + +def format_voice_list() -> str: + """Human-readable, newline-separated voice IDs for --list-voices.""" + return "\n".join(VOICES) +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `python -m pytest tests/test_agent_voices.py -v` +Expected: PASS (3 passed). + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/agent/__init__.py assemblyai_cli/agent/voices.py tests/test_agent_voices.py +git commit -m "feat(cli): add voice-agent voice list" +``` + +--- + +## Task 2: `AgentRenderer` + +Renders server events as human transcript lines or NDJSON. Mirrors `StreamRenderer` (writes to `self.out`, uses `\r\x1b[K` for in-place partials). Audio bytes are never emitted. + +**Files:** +- Create: `assemblyai_cli/agent/render.py` +- Test: `tests/test_agent_render.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_agent_render.py`: + +```python +import io +import json + +from assemblyai_cli.agent.render import AgentRenderer + + +def _json_lines(buf: io.StringIO): + return [json.loads(x) for x in buf.getvalue().splitlines() if x.strip()] + + +def test_json_emits_user_and_agent_events(): + buf = io.StringIO() + r = AgentRenderer(json_mode=True, out=buf) + r.connected() + r.user_final("hello there") + r.agent_transcript("hi back", interrupted=False) + lines = _json_lines(buf) + assert {"type": "session.ready"} in lines + assert {"type": "transcript.user", "text": "hello there"} in lines + assert { + "type": "transcript.agent", + "text": "hi back", + "interrupted": False, + } in lines + + +def test_json_never_emits_audio_bytes(): + buf = io.StringIO() + r = AgentRenderer(json_mode=True, out=buf) + r.reply_started() + r.reply_done(interrupted=True) + text = buf.getvalue() + assert "data" not in text # no base64 audio leaks + lines = _json_lines(buf) + assert {"type": "reply.started"} in lines + assert {"type": "reply.done", "interrupted": True} in lines + + +def test_human_partial_updates_in_place_then_finalizes(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.user_partial("what is") + r.user_final("what is the time") + out = buf.getvalue() + assert "\r\x1b[K" in out # cleared the line for the partial + assert "what is the time" in out # finalized text present + assert out.endswith("\n") # finalized line ends clean + + +def test_human_agent_line_labeled(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.agent_transcript("the time is noon", interrupted=False) + assert "the time is noon" in buf.getvalue() + + +def test_close_finalizes_open_partial_line(): + buf = io.StringIO() + r = AgentRenderer(json_mode=False, out=buf) + r.user_partial("half a sen") + r.close() + assert buf.getvalue().endswith("\n") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_agent_render.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'assemblyai_cli.agent.render'`. + +- [ ] **Step 3: Write the implementation** + +Create `assemblyai_cli/agent/render.py`: + +```python +from __future__ import annotations + +import json +import sys + + +class AgentRenderer: + """Renders Voice Agent events: human transcript lines, or NDJSON for agents. + + Audio payloads are never written; only text/state events are surfaced. + """ + + def __init__(self, *, json_mode: bool, out=None) -> None: + self.json_mode = json_mode + self.out = out if out is not None else sys.stdout + self._partial_open = False + + # --- lifecycle --------------------------------------------------------- + def connected(self) -> None: + if self.json_mode: + self._emit({"type": "session.ready"}) + else: + self._write("Connected — start talking. (Ctrl-C to stop)\n") + + def stopped(self) -> None: + if not self.json_mode: + self._write("Stopped.\n") + + def error(self, message: str) -> None: + if self.json_mode: + self._emit({"type": "session.error", "message": message}) + else: + self._write(f"Error: {message}\n") + + # --- user -------------------------------------------------------------- + def user_partial(self, text: str) -> None: + if self.json_mode: + self._emit({"type": "transcript.user.delta", "text": text}) + return + self._write("\r\x1b[Kyou: " + text) + self._partial_open = True + + def user_final(self, text: str) -> None: + if self.json_mode: + self._emit({"type": "transcript.user", "text": text}) + return + self._write("\r\x1b[Kyou: " + text + "\n") + self._partial_open = False + + # --- agent ------------------------------------------------------------- + def reply_started(self) -> None: + if self.json_mode: + self._emit({"type": "reply.started"}) + + def agent_transcript(self, text: str, *, interrupted: bool) -> None: + if self.json_mode: + self._emit( + {"type": "transcript.agent", "text": text, "interrupted": interrupted} + ) + return + self._finish_partial() + self._write("agent: " + text + "\n") + + def reply_done(self, *, interrupted: bool) -> None: + if self.json_mode: + self._emit({"type": "reply.done", "interrupted": interrupted}) + + # --- teardown ---------------------------------------------------------- + def close(self) -> None: + if self.json_mode: + return + self._finish_partial() + + # --- internals --------------------------------------------------------- + def _finish_partial(self) -> None: + if self._partial_open: + self._partial_open = False + self._write("\n") + + def _emit(self, obj) -> None: + self._write(json.dumps(obj) + "\n") + + def _write(self, text: str) -> None: + try: + self.out.write(text) + self.out.flush() + except Exception: # noqa: BLE001 - downstream pipe may be closed + pass +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_agent_render.py -v` +Expected: PASS (5 passed). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/agent/render.py tests/test_agent_render.py +git commit -m "feat(cli): add voice-agent renderer (human + NDJSON)" +``` + +--- + +## Task 3: `Player` (speaker playback) + +A queue-backed PyAudio output stream with an injectable stream factory so tests run without PyAudio. `flush()` discards pending audio (interruption). + +**Files:** +- Create: `assemblyai_cli/agent/audio.py` +- Test: `tests/test_agent_audio.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_agent_audio.py`: + +```python +from assemblyai_cli.agent.audio import Player + + +class FakeStream: + def __init__(self): + self.writes = [] + self.stopped = False + self.closed = False + + def write(self, data): + self.writes.append(data) + + def stop_stream(self): + self.stopped = True + + def close(self): + self.closed = True + + +def test_player_writes_enqueued_audio(): + fake = FakeStream() + p = Player(sample_rate=24000, stream_factory=lambda rate: fake) + p.start() + p.enqueue(b"\x01\x02") + p.enqueue(b"\x03\x04") + p.close() # drains the queue, then tears down + assert b"\x01\x02" in fake.writes + assert b"\x03\x04" in fake.writes + assert fake.closed + + +def test_player_flush_discards_pending_audio(): + fake = FakeStream() + p = Player(sample_rate=24000, stream_factory=lambda rate: fake) + # Do NOT start the worker; queue items directly so flush is deterministic. + p.enqueue(b"stale-1") + p.enqueue(b"stale-2") + p.flush() + assert p.pending() == 0 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_agent_audio.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'assemblyai_cli.agent.audio'`. + +- [ ] **Step 3: Write the `Player` implementation** + +Create `assemblyai_cli/agent/audio.py`: + +```python +from __future__ import annotations + +import queue +import threading +from collections.abc import Callable, Iterator + +from assemblyai_cli.errors import CLIError + +SAMPLE_RATE = 24000 # Voice Agent native PCM16 mono rate + +_MIC_MISSING_MSG = "Audio support isn't installed. Run: pip install 'assemblyai-cli[mic]'" + + +def _default_output_stream(rate: int): + """Open a PyAudio PCM16 mono output stream (lazy import; needs the [mic] extra).""" + try: + import pyaudio + except ImportError as exc: + raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + pa = pyaudio.PyAudio() + stream = pa.open(format=pyaudio.paInt16, channels=1, rate=rate, output=True) + stream._pa = pa # keep a reference so it isn't GC'd before the stream + return stream + + +class Player: + """Plays queued PCM16 audio chunks through a speaker output stream.""" + + def __init__( + self, + *, + sample_rate: int = SAMPLE_RATE, + stream_factory: Callable[[int], object] | None = None, + ) -> None: + self._rate = sample_rate + self._factory = stream_factory or _default_output_stream + self._queue: queue.Queue = queue.Queue() + self._stream = None + self._thread: threading.Thread | None = None + + def start(self) -> None: + self._stream = self._factory(self._rate) + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def _run(self) -> None: + while True: + chunk = self._queue.get() + if chunk is None: + return + try: + self._stream.write(chunk) + except Exception: # noqa: BLE001 - stream may be torn down mid-write + return + + def enqueue(self, pcm: bytes) -> None: + self._queue.put(pcm) + + def flush(self) -> None: + """Discard pending audio (barge-in / interruption).""" + try: + while True: + self._queue.get_nowait() + except queue.Empty: + pass + + def pending(self) -> int: + return self._queue.qsize() + + def close(self) -> None: + self._queue.put(None) + if self._thread is not None: + self._thread.join(timeout=1) + if self._stream is not None: + try: + self._stream.stop_stream() + except Exception: # noqa: BLE001 + pass + try: + self._stream.close() + except Exception: # noqa: BLE001 + pass +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_agent_audio.py -v` +Expected: PASS (2 passed). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/agent/audio.py tests/test_agent_audio.py +git commit -m "feat(cli): add voice-agent speaker Player" +``` + +--- + +## Task 4: `MicCapture` (microphone input) + +Yields PCM byte chunks from the microphone via the SDK's `MicrophoneStream`, with the same `[mic]`-missing error as streaming. Injectable factory for tests. + +**Files:** +- Modify: `assemblyai_cli/agent/audio.py` +- Test: `tests/test_agent_audio.py` + +- [ ] **Step 1: Write the failing test (append to `tests/test_agent_audio.py`)** + +```python +import pytest + +from assemblyai_cli.agent.audio import MicCapture +from assemblyai_cli.errors import CLIError + + +def test_miccapture_yields_chunks_from_factory(): + def fake_factory(*, sample_rate, device): + assert sample_rate == 24000 + return iter([b"aa", b"bb"]) + + mic = MicCapture(sample_rate=24000, device=None, stream_factory=fake_factory) + assert list(mic) == [b"aa", b"bb"] + + +def test_miccapture_missing_dependency_raises_cli_error(): + def boom(*, sample_rate, device): + raise ImportError("no pyaudio") + + mic = MicCapture(sample_rate=24000, device=None, stream_factory=boom) + with pytest.raises(CLIError) as excinfo: + list(mic) + assert excinfo.value.exit_code == 2 + assert "assemblyai-cli[mic]" in excinfo.value.message +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_agent_audio.py -k miccapture -v` +Expected: FAIL with `ImportError: cannot import name 'MicCapture'`. + +- [ ] **Step 3: Add `MicCapture` to `assemblyai_cli/agent/audio.py`** + +Append to `assemblyai_cli/agent/audio.py`: + +```python +def _default_mic_stream(*, sample_rate: int, device: int | None) -> Iterator[bytes]: + """SDK PyAudio-backed mic stream (lazy import so the base install stays light).""" + from assemblyai.extras import MicrophoneStream + + return MicrophoneStream(sample_rate=sample_rate, device_index=device) + + +class MicCapture: + """Iterates PCM16 chunks from the microphone (requires the [mic] extra).""" + + def __init__( + self, + *, + sample_rate: int = SAMPLE_RATE, + device: int | None = None, + stream_factory: Callable[..., Iterator[bytes]] | None = None, + ) -> None: + self._rate = sample_rate + self._device = device + self._factory = stream_factory or _default_mic_stream + + def __iter__(self) -> Iterator[bytes]: + try: + stream = self._factory(sample_rate=self._rate, device=self._device) + except ImportError as exc: + raise CLIError(_MIC_MISSING_MSG, error_type="mic_missing", exit_code=2) from exc + except Exception as exc: # noqa: BLE001 - surface device errors cleanly + raise CLIError( + f"Could not open the microphone (device {self._device}): {exc}", + error_type="mic_error", + exit_code=1, + ) from exc + close = getattr(stream, "close", None) + try: + yield from stream + finally: + if callable(close): + close() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_agent_audio.py -v` +Expected: PASS (4 passed total in the file). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/agent/audio.py tests/test_agent_audio.py +git commit -m "feat(cli): add voice-agent MicCapture" +``` + +--- + +## Task 5: `VoiceAgentSession.dispatch` (event router + duplex state) + +The pure event router: given a parsed event dict, drive the renderer, enqueue/flush the player, toggle the half-duplex mute, set the ready gate, and raise on `session.error`. No socket here — fully unit-testable. + +**Files:** +- Create: `assemblyai_cli/agent/session.py` +- Test: `tests/test_agent_session.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_agent_session.py`: + +```python +import pytest + +from assemblyai_cli.agent.session import VoiceAgentSession +from assemblyai_cli.errors import APIError, CLIError + + +class FakeRenderer: + def __init__(self): + self.calls = [] + + def connected(self): + self.calls.append(("connected",)) + + def user_partial(self, text): + self.calls.append(("user_partial", text)) + + def user_final(self, text): + self.calls.append(("user_final", text)) + + def reply_started(self): + self.calls.append(("reply_started",)) + + def agent_transcript(self, text, *, interrupted): + self.calls.append(("agent_transcript", text, interrupted)) + + def reply_done(self, *, interrupted): + self.calls.append(("reply_done", interrupted)) + + def error(self, message): + self.calls.append(("error", message)) + + +class FakePlayer: + def __init__(self): + self.enqueued = [] + self.flushed = 0 + + def enqueue(self, pcm): + self.enqueued.append(pcm) + + def flush(self): + self.flushed += 1 + + +def _session(*, full_duplex=False): + return VoiceAgentSession( + renderer=FakeRenderer(), + player=FakePlayer(), + full_duplex=full_duplex, + ) + + +def test_ready_opens_gate_and_announces(): + s = _session() + assert s.ready is False + s.dispatch({"type": "session.ready", "session_id": "sess_1"}) + assert s.ready is True + assert ("connected",) in s.renderer.calls + + +def test_half_duplex_mutes_during_reply(): + s = _session(full_duplex=False) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "reply.started"}) + assert s.muted is True + s.dispatch({"type": "reply.done"}) + assert s.muted is False + + +def test_full_duplex_never_mutes_and_flushes_on_speech_start(): + s = _session(full_duplex=True) + s.dispatch({"type": "session.ready"}) + s.dispatch({"type": "reply.started"}) + assert s.muted is False + s.dispatch({"type": "input.speech.started"}) + assert s.player.flushed == 1 + + +def test_reply_audio_is_decoded_and_enqueued(): + import base64 + + s = _session() + payload = base64.b64encode(b"\x10\x20").decode() + s.dispatch({"type": "reply.audio", "data": payload}) + assert s.player.enqueued == [b"\x10\x20"] + + +def test_interrupted_reply_done_flushes_playback(): + s = _session() + s.dispatch({"type": "reply.done", "status": "interrupted"}) + assert s.player.flushed == 1 + assert ("reply_done", True) in s.renderer.calls + + +def test_transcripts_routed_to_renderer(): + s = _session() + s.dispatch({"type": "transcript.user.delta", "text": "what"}) + s.dispatch({"type": "transcript.user", "text": "what time"}) + s.dispatch({"type": "transcript.agent", "text": "noon", "interrupted": False}) + assert ("user_partial", "what") in s.renderer.calls + assert ("user_final", "what time") in s.renderer.calls + assert ("agent_transcript", "noon", False) in s.renderer.calls + + +def test_unauthorized_error_raises_cli_error_exit_2(): + s = _session() + with pytest.raises(CLIError) as excinfo: + s.dispatch({"type": "session.error", "code": "UNAUTHORIZED", "message": "bad key"}) + assert excinfo.value.exit_code == 2 + + +def test_other_session_error_raises_api_error(): + s = _session() + with pytest.raises(APIError): + s.dispatch({"type": "session.error", "code": "invalid_value", "message": "bad voice"}) + + +def test_unknown_and_tool_events_are_ignored(): + s = _session() + s.dispatch({"type": "tool.call", "call_id": "c1", "name": "x", "arguments": {}}) + s.dispatch({"type": "something.new"}) + assert s.renderer.calls == [] # nothing surfaced, no exception +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_agent_session.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'assemblyai_cli.agent.session'`. + +- [ ] **Step 3: Write the dispatch core** + +Create `assemblyai_cli/agent/session.py`: + +```python +from __future__ import annotations + +import base64 + +from assemblyai_cli.errors import APIError, CLIError + +WS_URL = "wss://agents.assemblyai.com/v1/ws" + +DEFAULT_PROMPT = ( + "You are a friendly voice assistant having a casual conversation. Keep replies " + "short and natural, usually one or two sentences. Speak the way a person would " + "in real conversation: relaxed, low-key, no exclamation marks." +) +DEFAULT_GREETING = "Hey, what's on your mind?" + +# session.error codes that mean the connection is unauthorized -> exit 2. +_AUTH_ERROR_CODES = {"UNAUTHORIZED", "FORBIDDEN"} + + +class VoiceAgentSession: + """Routes Voice Agent server events to the renderer, player, and duplex state.""" + + def __init__(self, *, renderer, player, full_duplex: bool = False) -> None: + self.renderer = renderer + self.player = player + self.full_duplex = full_duplex + self.ready = False + self.muted = False + + def should_send_audio(self) -> bool: + """True when captured mic frames should be forwarded to the server.""" + return self.ready and not self.muted + + def dispatch(self, event: dict) -> None: + etype = event.get("type") + + if etype == "session.ready": + self.ready = True + self.renderer.connected() + elif etype == "input.speech.started": + if self.full_duplex: + self.player.flush() + elif etype == "input.speech.stopped": + pass + elif etype == "transcript.user.delta": + self.renderer.user_partial(event.get("text", "")) + elif etype == "transcript.user": + self.renderer.user_final(event.get("text", "")) + elif etype == "reply.started": + if not self.full_duplex: + self.muted = True + self.renderer.reply_started() + elif etype == "reply.audio": + data = event.get("data") + if data: + self.player.enqueue(base64.b64decode(data)) + elif etype == "transcript.agent": + self.renderer.agent_transcript( + event.get("text", ""), interrupted=bool(event.get("interrupted", False)) + ) + elif etype == "reply.done": + if not self.full_duplex: + self.muted = False + interrupted = event.get("status") == "interrupted" + if interrupted: + self.player.flush() + self.renderer.reply_done(interrupted=interrupted) + elif etype == "session.error": + self._raise_error(event) + # tool.call and unknown event types: intentionally ignored. + + def _raise_error(self, event: dict) -> None: + code = event.get("code", "") + message = event.get("message") or code or "Voice agent error." + if code in _AUTH_ERROR_CODES: + raise CLIError( + f"Voice agent rejected the connection: {message}", + error_type="unauthorized", + exit_code=2, + ) + raise APIError(f"Voice agent error ({code}): {message}") +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `python -m pytest tests/test_agent_session.py -v` +Expected: PASS (9 passed). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/agent/session.py tests/test_agent_session.py +git commit -m "feat(cli): add voice-agent event dispatch + duplex state" +``` + +--- + +## Task 6: `run_session` transport (WebSocket + threads) + +The transport glue: open the raw WebSocket, send `session.update`, run capture + playback threads, loop `recv → json.loads → dispatch`. This is the patch seam for command tests (no automated socket test; covered by the manual live test). Build it so the command can drive it and Ctrl-C exits cleanly. + +**Files:** +- Modify: `assemblyai_cli/agent/session.py` +- Test: covered indirectly in Task 7 (the command patches `run_session`); no new unit test here. + +- [ ] **Step 1: Add the imports and `run_session` to `assemblyai_cli/agent/session.py`** + +Add to the imports at the top of `assemblyai_cli/agent/session.py`: + +```python +import json +import threading +``` + +Append `run_session` to `assemblyai_cli/agent/session.py`: + +```python +def _send_audio_loop(ws, session: VoiceAgentSession, mic) -> None: + """Forward mic PCM as input.audio while the session gate allows it.""" + for chunk in mic: + if not session.should_send_audio(): + continue # half-duplex: drop frames while the agent is speaking + payload = base64.b64encode(chunk).decode("ascii") + try: + ws.send(json.dumps({"type": "input.audio", "audio": payload})) + except Exception: # noqa: BLE001 - socket closed; capture thread ends + return + + +def run_session( + api_key: str, + *, + renderer, + player, + mic, + voice: str, + system_prompt: str, + greeting: str, + full_duplex: bool = False, + connect=None, +) -> None: + """Open the Voice Agent WebSocket and run the bidirectional loop until close. + + `connect` defaults to websockets' synchronous client; injectable for tests. + """ + if connect is None: + from websockets.sync.client import connect + + session = VoiceAgentSession(renderer=renderer, player=player, full_duplex=full_duplex) + + try: + ws = connect(WS_URL, additional_headers={"Authorization": f"Bearer {api_key}"}) + except Exception as exc: # noqa: BLE001 - connect/auth/network failures + raise APIError(f"Could not connect to the voice agent: {exc}") from exc + + try: + player.start() # opens the speaker stream; CLIError here if [mic] is missing + capture = threading.Thread( + target=_send_audio_loop, args=(ws, session, mic), daemon=True + ) + capture.start() + ws.send( + json.dumps( + { + "type": "session.update", + "session": { + "system_prompt": system_prompt, + "greeting": greeting, + "output": {"voice": voice}, + }, + } + ) + ) + for raw in ws: + session.dispatch(json.loads(raw)) + except (CLIError, KeyboardInterrupt): + raise # auth/protocol errors and user Ctrl-C handled upstream + except Exception as exc: # noqa: BLE001 - mid-stream socket/JSON failures + raise APIError(f"Voice agent session failed: {exc}") from exc + finally: + try: + ws.close() + except Exception: # noqa: BLE001 + pass + player.close() +``` + +- [ ] **Step 2: Verify the module imports cleanly and existing tests still pass** + +Run: `python -c "import assemblyai_cli.agent.session"` then `python -m pytest tests/test_agent_session.py -v` +Expected: import succeeds; 9 passed (dispatch tests unaffected). + +- [ ] **Step 3: Commit** + +```bash +git add assemblyai_cli/agent/session.py +git commit -m "feat(cli): add voice-agent WebSocket transport loop" +``` + +--- + +## Task 7: `aai agent` command + registration + +Thin Typer command: `--list-voices` short-circuit, prompt-file handling, key resolution, renderer/player/mic construction, `run_session` call, clean Ctrl-C. Patched in tests exactly like `commands/stream.py`. + +**Files:** +- Create: `assemblyai_cli/commands/agent.py` +- Modify: `assemblyai_cli/main.py` +- Test: `tests/test_agent_command.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_agent_command.py`: + +```python +import json + +from typer.testing import CliRunner + +from assemblyai_cli import config +from assemblyai_cli.main import app + +runner = CliRunner() + + +def test_agent_help_lists_command(): + result = runner.invoke(app, ["agent", "--help"]) + assert result.exit_code == 0 + assert "voice" in result.output.lower() + + +def test_list_voices_prints_and_exits_without_connecting(monkeypatch): + called = {"ran": False} + + def fake_run_session(*a, **k): + called["ran"] = True + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + result = runner.invoke(app, ["agent", "--list-voices"]) + assert result.exit_code == 0 + assert "ivy" in result.output + assert called["ran"] is False + + +def test_agent_unauthenticated_exits_2(): + result = runner.invoke(app, ["agent"]) + assert result.exit_code == 2 + + +def test_agent_drives_renderer_json(monkeypatch): + config.set_api_key("default", "sk_live") + + def fake_run_session(api_key, *, renderer, player, mic, voice, system_prompt, + greeting, full_duplex=False, connect=None): + renderer.connected() + renderer.user_final("hello agent") + renderer.agent_transcript("hello human", interrupted=False) + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + result = runner.invoke(app, ["agent", "--json"]) + assert result.exit_code == 0 + lines = [json.loads(x) for x in result.output.splitlines() if x.strip()] + assert {"type": "transcript.user", "text": "hello agent"} in lines + assert {"type": "transcript.agent", "text": "hello human", "interrupted": False} in lines + + +def test_agent_passes_voice_and_prompt_file(monkeypatch, tmp_path): + config.set_api_key("default", "sk_live") + seen = {} + + def fake_run_session(api_key, *, renderer, player, mic, voice, system_prompt, + greeting, full_duplex=False, connect=None): + seen["voice"] = voice + seen["prompt"] = system_prompt + seen["full_duplex"] = full_duplex + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", fake_run_session) + prompt_file = tmp_path / "p.txt" + prompt_file.write_text("be a pirate") + result = runner.invoke( + app, + ["agent", "--voice", "james", "--prompt-file", str(prompt_file), + "--prompt", "ignored", "--full-duplex"], + ) + assert result.exit_code == 0 + assert seen["voice"] == "james" + assert seen["prompt"] == "be a pirate" # --prompt-file overrides --prompt + assert seen["full_duplex"] is True + + +def test_agent_ctrl_c_exits_cleanly(monkeypatch): + config.set_api_key("default", "sk_live") + + def raise_kbd(*a, **k): + raise KeyboardInterrupt + + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", raise_kbd) + result = runner.invoke(app, ["agent"]) + assert result.exit_code == 0 + + +def test_agent_unknown_voice_exits_2(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.commands.agent.run_session", lambda *a, **k: None) + result = runner.invoke(app, ["agent", "--voice", "not-a-voice"]) + assert result.exit_code == 2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `python -m pytest tests/test_agent_command.py -v` +Expected: FAIL — `agent` is not a registered command (non-zero exit / usage error). + +- [ ] **Step 3: Write the command** + +Create `assemblyai_cli/commands/agent.py`: + +```python +from __future__ import annotations + +from pathlib import Path + +import typer + +from assemblyai_cli import config +from assemblyai_cli.agent.audio import SAMPLE_RATE, MicCapture, Player +from assemblyai_cli.agent.render import AgentRenderer +from assemblyai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT, run_session +from assemblyai_cli.agent.voices import DEFAULT_VOICE, VOICES, format_voice_list +from assemblyai_cli.context import run_command +from assemblyai_cli.errors import CLIError, UsageError + +app = typer.Typer() + + +@app.command() +def agent( + ctx: typer.Context, + voice: str = typer.Option(DEFAULT_VOICE, "--voice", help="Agent voice. See --list-voices."), + prompt: str = typer.Option(DEFAULT_PROMPT, "--prompt", help="System prompt."), + prompt_file: Path = typer.Option( + None, "--prompt-file", help="Read the system prompt from a file (overrides --prompt)." + ), + greeting: str = typer.Option(DEFAULT_GREETING, "--greeting", help="Spoken greeting."), + full_duplex: bool = typer.Option( + False, "--full-duplex", help="Keep the mic open while the agent speaks (needs headphones)." + ), + sample_rate: int = typer.Option(SAMPLE_RATE, "--sample-rate", help="Mic sample rate in Hz."), + device: int = typer.Option(None, "--device", help="Microphone device index."), + list_voices: bool = typer.Option(False, "--list-voices", help="Print known voices and exit."), + json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), +) -> None: + """Have a live two-way voice conversation with an AssemblyAI voice agent.""" + + if list_voices: + typer.echo(format_voice_list()) + raise typer.Exit(code=0) + + def body(state, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + if voice not in VOICES: + raise UsageError(f"Unknown voice {voice!r}. Run 'aai agent --list-voices'.") + system_prompt = prompt + if prompt_file is not None: + try: + system_prompt = prompt_file.read_text(encoding="utf-8") + except OSError as exc: + raise CLIError( + f"Could not read --prompt-file {prompt_file}: {exc}", + error_type="file_not_found", + exit_code=2, + ) from exc + + renderer = AgentRenderer(json_mode=json_mode) + player = Player(sample_rate=SAMPLE_RATE) + mic = MicCapture(sample_rate=sample_rate, device=device) + if not json_mode and not full_duplex: + renderer.out.write( + "Half-duplex: mic mutes while the agent talks. " + "Use --full-duplex (with headphones) for barge-in.\n" + ) + renderer.out.flush() + try: + run_session( + api_key, + renderer=renderer, + player=player, + mic=mic, + voice=voice, + system_prompt=system_prompt, + greeting=greeting, + full_duplex=full_duplex, + ) + except KeyboardInterrupt: + renderer.stopped() + finally: + renderer.close() + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Register the command in `assemblyai_cli/main.py`** + +Add `agent` to the existing import line (which already imports `claude` and the others): + +```python +from assemblyai_cli.commands import agent, claude, login, samples, stream, transcribe, transcripts +``` + +And add the registration alongside the others (e.g. after `app.add_typer(login.app)`): + +```python +app.add_typer(agent.app, name="agent") +``` + +- [ ] **Step 5: Run the command tests to verify they pass** + +Run: `python -m pytest tests/test_agent_command.py -v` +Expected: PASS (7 passed). + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/commands/agent.py assemblyai_cli/main.py tests/test_agent_command.py +git commit -m "feat(cli): add 'aai agent' command and register it" +``` + +--- + +## Task 8: Dependency + docs + +**Files:** +- Modify: `pyproject.toml` +- Modify: `README.md` + +- [ ] **Step 1: Add the `websockets` base dependency** + +In `pyproject.toml`, add `"websockets>=13"` to the `dependencies` list (after `"tomli-w>=1.0",`): + +```toml +dependencies = [ + "typer>=0.12", + "assemblyai>=0.34", + "rich>=13.0", + "keyring>=24.0", + "platformdirs>=4.0", + "tomli-w>=1.0", + "websockets>=13", +] +``` + +- [ ] **Step 2: Document `aai agent` in `README.md`** + +Append to `README.md`: + +```markdown +## Voice agent + +Have a live, two-way voice conversation with an AssemblyAI voice agent (requires the +`[mic]` extra for microphone + speaker audio): + + pip install "assemblyai-cli[mic]" + aai agent # talk; the agent talks back. Ctrl-C to stop. + aai agent --voice james --greeting "Hi there" + aai agent --prompt-file persona.txt # load the system prompt from a file + aai agent --list-voices # see available voices + +By default the agent runs **half-duplex**: your mic mutes while the agent is speaking, +so it can't hear itself on your speakers. With headphones, add `--full-duplex` for +true barge-in (interrupt the agent mid-sentence). Add `--json` for newline-delimited +JSON events. +``` + +- [ ] **Step 3: Run the full suite and linters** + +Run: `python -m pytest -q && python -m ruff check assemblyai_cli tests` +Expected: all tests pass; ruff reports no errors. + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml README.md +git commit -m "build(cli): add websockets dep; document 'aai agent'" +``` + +--- + +## Task 9: Final verification + +- [ ] **Step 1: Full suite green** + +Run: `python -m pytest -q` +Expected: all tests pass (existing + new agent tests). + +- [ ] **Step 2: Smoke the CLI surface** + +Run: `python -m assemblyai_cli agent --help` and `python -m assemblyai_cli agent --list-voices` +Expected: help shows the agent options; `--list-voices` prints the voice list and exits 0. + +- [ ] **Step 3: Lint/format clean** + +Run: `python -m ruff check assemblyai_cli tests && python -m ruff format --check assemblyai_cli tests` +Expected: no errors. + +- [ ] **Step 4: (Manual, optional) live conversation** + +With a real key and a microphone + headphones: +Run: `aai agent --full-duplex` +Expected: prints "Connected — start talking", speaks the greeting, and holds a conversation. Confirms the WS URL, the `Authorization: Bearer ` header form, and the 24 kHz audio path. Not part of CI. + +--- + +## Notes for the implementer + +- **TDD discipline:** every code task writes the test first, watches it fail, then implements. Don't batch. +- **No real audio/sockets in CI:** `Player`/`MicCapture` take injectable factories; `run_session` takes an injectable `connect`; command tests patch `run_session`. Never open a real device or socket in a test. +- **Match existing style:** `from __future__ import annotations`, `# noqa: BLE001` on broad excepts that surface cleanly, the `\r\x1b[K` partial-line trick, and the shared `[mic]`-missing message. +- The agent uses the raw API key in the `Authorization: Bearer` header (the docs' Python `session.resume` example). If a live test shows the header form is wrong, that's the single place to adjust (`run_session`). diff --git a/docs/superpowers/plans/2026-06-03-cli-color-theme.md b/docs/superpowers/plans/2026-06-03-cli-color-theme.md new file mode 100644 index 00000000..714ffb59 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-cli-color-theme.md @@ -0,0 +1,919 @@ +# CLI Color Theme 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:** Give `aai` one centralized Rich color theme (AssemblyAI brand accent + semantic styles) applied consistently to all human-facing output. + +**Architecture:** A new `assemblyai_cli/theme.py` owns a single `rich.theme.Theme` of semantic style names (`aai.brand`, `aai.success`, …) plus helpers (`make_console`, `speaker_style`, `status_style`). Every `Console` is built through `make_console` so the style names resolve globally. Call sites switch from ad-hoc `[red]`/`[green]` markup to the semantic names, and the streaming/agent renderers color role/speaker labels via styled `rich.text.Text`. Color only reaches interactive TTYs (Rich auto-disables ANSI on pipes/non-tty and honors `NO_COLOR`); the agentic/CI path already routes to JSON. + +**Tech Stack:** Python, Typer, Rich 15, pytest (90% branch-coverage gate), ruff, mypy. + +--- + +## File Structure + +- **Create** `assemblyai_cli/theme.py` — the palette, `THEME`, `make_console`, `speaker_style`, `status_style`. +- **Create** `tests/test_theme.py` — unit tests for the theme module. +- **Modify** `assemblyai_cli/output.py` — themed shared console + themed error markup. +- **Modify** `assemblyai_cli/render.py` — `BaseRenderer` line helpers accept `str | Text`; themed per-stream console; `stopped()` muted. +- **Modify** `assemblyai_cli/streaming/render.py` — muted lifecycle notice, brand LLM line. +- **Modify** `assemblyai_cli/agent/render.py` — muted notices, accent `you:`/`agent:` labels. +- **Modify** `assemblyai_cli/commands/transcribe.py` — accent speaker labels. +- **Modify** `assemblyai_cli/commands/transcripts.py` — brand table header + status-colored cell. +- **Modify** `assemblyai_cli/commands/claude.py` — status-colored steps + brand heading. +- **Modify** `assemblyai_cli/commands/login.py` and `assemblyai_cli/commands/samples.py` — swap raw color markup for semantic names. +- **Modify** `tests/test_streaming_render.py`, `tests/test_agent_render.py` — `_human()` helper builds a themed console (so `aai.*` style names resolve); add color assertions. + +Notes for the implementer: +- ruff lint `select` does NOT include `ANN`, so `**kwargs: Any` is allowed. mypy has `disallow_untyped_defs=true` for src, relaxed for tests. +- A Rich `Console` only resolves a style **name** like `"aai.label"` if the theme is attached; otherwise it raises `rich.errors.MissingStyle` at render time. This is why test consoles must be built via `make_console`. +- `color_system=None` still resolves theme style names; it just emits no ANSI. Use `color_system="truecolor"` in tests that assert color escape codes are present. + +--- + +## Task 1: Theme module + +**Files:** +- Create: `assemblyai_cli/theme.py` +- Test: `tests/test_theme.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_theme.py`: + +```python +import io + +from assemblyai_cli import theme + + +def test_make_console_resolves_named_styles(): + console = theme.make_console() + # get_style raises rich.errors.MissingStyle if a name is not in the theme. + for name in ( + "aai.brand", + "aai.heading", + "aai.label", + "aai.success", + "aai.error", + "aai.warn", + "aai.muted", + ): + console.get_style(name) + for name in theme.SPEAKER_STYLES: + console.get_style(name) + + +def test_make_console_passes_kwargs_through(): + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, width=42) + assert console.file is buf + assert console.width == 42 + + +def test_status_style_maps_known_statuses(): + assert theme.status_style("completed") == "aai.success" + assert theme.status_style("ERROR") == "aai.error" + assert theme.status_style("failed") == "aai.error" + assert theme.status_style("queued") == "aai.warn" + assert theme.status_style("processing") == "aai.warn" + + +def test_status_style_unknown_falls_back_to_muted(): + assert theme.status_style("something-else") == "aai.muted" + + +def test_speaker_style_deterministic_and_in_palette(): + assert theme.speaker_style("A") in theme.SPEAKER_STYLES + assert theme.speaker_style("A") == theme.speaker_style("A") + assert theme.speaker_style("A") != theme.speaker_style("B") +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_theme.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'assemblyai_cli.theme'`. + +- [ ] **Step 3: Write the implementation** + +Create `assemblyai_cli/theme.py`: + +```python +from __future__ import annotations + +from typing import IO, Any + +from rich.console import Console +from rich.theme import Theme + +# AssemblyAI brand accent. Defined once so the whole CLI can be re-tinted here. +BRAND = "#2545D3" + +# Per-speaker label colors, rotated deterministically by speaker_style(). +SPEAKER_STYLES: tuple[str, ...] = ( + "aai.speaker.0", + "aai.speaker.1", + "aai.speaker.2", + "aai.speaker.3", + "aai.speaker.4", +) + +THEME = Theme( + { + "aai.brand": f"bold {BRAND}", + "aai.heading": f"bold {BRAND}", + "aai.label": BRAND, + "aai.success": "green", + "aai.error": "bold red", + "aai.warn": "yellow", + "aai.muted": "dim", + "aai.speaker.0": BRAND, + "aai.speaker.1": "cyan", + "aai.speaker.2": "magenta", + "aai.speaker.3": "green", + "aai.speaker.4": "yellow", + } +) + +# Status strings grouped by the semantic style they render in. +_SUCCESS = {"completed", "installed", "removed", "ok", "present", "authenticated"} +_ERROR = {"error", "failed"} +_WARN = {"queued", "processing", "in_progress", "running"} + + +def make_console(file: IO[str] | None = None, **kwargs: Any) -> Console: + """Build a Console with the AssemblyAI theme attached so `aai.*` names resolve.""" + return Console(file=file, theme=THEME, **kwargs) + + +def speaker_style(speaker: object) -> str: + """Deterministically map a speaker id to one of SPEAKER_STYLES.""" + key = str(speaker) + idx = sum(ord(c) for c in key) % len(SPEAKER_STYLES) + return SPEAKER_STYLES[idx] + + +def status_style(status: str) -> str: + """Map a transcript/setup status to a semantic style name (muted if unknown).""" + normalized = status.strip().lower() + if normalized in _SUCCESS: + return "aai.success" + if normalized in _ERROR: + return "aai.error" + if normalized in _WARN: + return "aai.warn" + return "aai.muted" +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_theme.py -v` +Expected: PASS (6 tests). + +- [ ] **Step 5: Lint/typecheck** + +Run: `ruff check assemblyai_cli/theme.py tests/test_theme.py && ruff format --check assemblyai_cli/theme.py tests/test_theme.py && mypy assemblyai_cli/theme.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/theme.py tests/test_theme.py +git commit -m "feat(theme): add centralized Rich color theme module" +``` + +--- + +## Task 2: Route the shared console through the theme + +**Files:** +- Modify: `assemblyai_cli/output.py` +- Test: `tests/test_theme.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_theme.py`: + +```python +def test_output_console_is_themed_and_error_is_styled(monkeypatch): + from assemblyai_cli import output, theme + from assemblyai_cli.errors import CLIError + + buf = io.StringIO() + monkeypatch.setattr( + output, + "console", + theme.make_console(file=buf, force_terminal=True, color_system="truecolor"), + ) + output.emit_error(CLIError("boom"), json_mode=False) + out = buf.getvalue() + assert "Error:" in out + assert "boom" in out + assert "\x1b[" in out # themed error emits ANSI on a forced-color console +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_theme.py::test_output_console_is_themed_and_error_is_styled -v` +Expected: FAIL — the current `output.console` has no theme, so rendering `[aai.error]` raises `MissingStyle` (or the ANSI assertion fails). + +- [ ] **Step 3: Edit `assemblyai_cli/output.py`** + +Add the import near the other `assemblyai_cli` imports (keep `TYPE_CHECKING` block as-is): + +```python +from assemblyai_cli import theme +``` + +Replace line 17 `console = Console()` with: + +```python +console = theme.make_console() +``` + +The unused `Console` import can stay only if still referenced; it is not, so remove `from rich.console import Console`. Replace the error line (was line 48): + +```python + console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") +``` + +- [ ] **Step 4: Run the test + full suite** + +Run: `pytest tests/test_theme.py -v && pytest -q -m "not e2e"` +Expected: PASS, no regressions. + +- [ ] **Step 5: Lint/typecheck** + +Run: `ruff check assemblyai_cli/output.py && mypy assemblyai_cli/output.py` +Expected: no errors (no leftover unused `Console` import). + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/output.py tests/test_theme.py +git commit -m "feat(theme): theme the shared output console and error markup" +``` + +--- + +## Task 3: BaseRenderer accepts styled Text + themed per-stream console + +**Files:** +- Modify: `assemblyai_cli/render.py` +- Test: `tests/test_streaming_render.py` + +- [ ] **Step 1: Update the test helper and add a coverage test** + +In `tests/test_streaming_render.py`, change the imports and `_human` helper. Replace: + +```python +from rich.console import Console + +from assemblyai_cli.streaming.render import StreamRenderer +``` + +with: + +```python +from assemblyai_cli import theme +from assemblyai_cli.streaming.render import StreamRenderer +``` + +Replace the `_human` helper body's console line. The helper becomes: + +```python +def _human(width=80, color_system=None): + """A human-mode renderer writing to a forced-terminal themed console buffer.""" + buf = io.StringIO() + console = theme.make_console( + file=buf, force_terminal=True, width=width, color_system=color_system + ) + return StreamRenderer(json_mode=False, out=buf, console=console), buf +``` + +Add a test that the default per-stream console (no explicit console passed) is themed: + +```python +def test_default_console_is_themed(): + buf = io.StringIO() + r = StreamRenderer(json_mode=False, out=buf) + # _console_obj builds via theme.make_console, so aai.* names resolve. + r._console_obj().get_style("aai.brand") +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pytest tests/test_streaming_render.py::test_default_console_is_themed -v` +Expected: FAIL — `BaseRenderer._console_obj` currently builds a bare `Console`, so `get_style("aai.brand")` raises `MissingStyle`. + +- [ ] **Step 3: Edit `assemblyai_cli/render.py`** + +Add the import after the existing imports (keep the rich imports): + +```python +from assemblyai_cli import theme +``` + +Change `_console_obj` (was lines 45-48) to build via the theme: + +```python + def _console_obj(self) -> Console: + if self._console is None: + self._console = theme.make_console(file=self.out) + return self._console +``` + +Change the three line helpers so they accept `str | Text`. Replace `_update_line`, `_finalize_line`, and `_line` (was lines 70-86) with: + +```python + @staticmethod + def _as_text(text: str | Text) -> Text: + return text if isinstance(text, Text) else Text(text) + + def _update_line(self, text: str | Text) -> None: + """Redraw the in-progress line in place (Rich clears any prior wrap).""" + self._live_obj().update(self._as_text(text), refresh=True) + + def _finalize_line(self, text: str | Text | None = None) -> None: + """Commit the in-progress line (optionally replacing its text) as permanent.""" + if self._live is not None: + if text is not None: + self._live.update(self._as_text(text), refresh=True) + self._commit_live() + elif text is not None: + self._console_obj().print(self._as_text(text)) + + def _line(self, text: str | Text) -> None: + """Print a standalone permanent line, committing any open partial first.""" + self._commit_live() + self._console_obj().print(self._as_text(text)) +``` + +Change `stopped()` (was lines 89-91) to render muted: + +```python + def stopped(self) -> None: + if not self.json_mode: + self._line(Text("Stopped.", style="aai.muted")) +``` + +- [ ] **Step 4: Run the streaming + agent render suites** + +Run: `pytest tests/test_streaming_render.py tests/test_agent_render.py -v` +Expected: PASS. (The agent suite still uses its own bare-console `_human`; it is updated in Task 5. The named styles used so far only appear in StreamRenderer's themed console, so agent tests are unaffected here.) + +- [ ] **Step 5: Lint/typecheck** + +Run: `ruff check assemblyai_cli/render.py && mypy assemblyai_cli/render.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/render.py tests/test_streaming_render.py +git commit -m "feat(theme): themed base renderer console and styled line helpers" +``` + +--- + +## Task 4: StreamRenderer styling + +**Files:** +- Modify: `assemblyai_cli/streaming/render.py` +- Test: `tests/test_streaming_render.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_streaming_render.py`: + +```python +def test_human_begin_notice_is_muted(): + r, buf = _human(color_system="truecolor") + r.begin(types.SimpleNamespace(id="x")) + assert "\x1b[" in buf.getvalue() # muted styling emits ANSI + + +def test_human_llm_line_is_branded(): + r, buf = _human(color_system="truecolor") + r.turn(_turn("hola", True)) + r.llm("the summary") + out = buf.getvalue() + assert "the summary" in out + assert "\x1b[" in out # brand styling emits ANSI +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pytest tests/test_streaming_render.py::test_human_begin_notice_is_muted tests/test_streaming_render.py::test_human_llm_line_is_branded -v` +Expected: FAIL — the current `begin`/`llm` pass plain strings; with `color_system="truecolor"` but no styling, no ANSI is emitted. + +- [ ] **Step 3: Edit `assemblyai_cli/streaming/render.py`** + +Add the import: + +```python +from rich.text import Text + +from assemblyai_cli import theme +from assemblyai_cli.render import BaseRenderer +``` + +Change the `begin` human branch (was line 13): + +```python + self._line(Text("Listening… (Ctrl-C to stop)", style="aai.muted")) +``` + +Change the `llm` human branch (was line 41): + +```python + self._line(Text("\N{ELECTRIC LIGHT BULB} " + content, style="aai.brand")) +``` + +- [ ] **Step 4: Run the streaming suite** + +Run: `pytest tests/test_streaming_render.py -v` +Expected: PASS (existing `test_human_begin_prints_notice`, `test_human_llm_line_rendered` still pass — text content is unchanged). + +- [ ] **Step 5: Lint/typecheck** + +Run: `ruff check assemblyai_cli/streaming/render.py && mypy assemblyai_cli/streaming/render.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/streaming/render.py tests/test_streaming_render.py +git commit -m "feat(theme): color streaming notice and LLM line" +``` + +--- + +## Task 5: AgentRenderer styling (role labels) + +**Files:** +- Modify: `assemblyai_cli/agent/render.py` +- Test: `tests/test_agent_render.py` + +- [ ] **Step 1: Update the test helper and add color tests** + +In `tests/test_agent_render.py`, replace: + +```python +from rich.console import Console + +from assemblyai_cli.agent.render import AgentRenderer +``` + +with: + +```python +from assemblyai_cli import theme +from assemblyai_cli.agent.render import AgentRenderer +``` + +Replace the `_human` helper: + +```python +def _human(width=80, color_system=None): + """A human-mode renderer writing to a forced-terminal themed console buffer.""" + buf = io.StringIO() + console = theme.make_console( + file=buf, force_terminal=True, width=width, color_system=color_system + ) + return AgentRenderer(json_mode=False, out=buf, console=console), buf +``` + +Add: + +```python +def test_human_agent_label_is_colored(): + r, buf = _human(color_system="truecolor") + r.agent_transcript("the time is noon", interrupted=False) + out = buf.getvalue() + assert "agent: " in out + assert "the time is noon" in out + assert "\x1b[" in out # label styling emits ANSI + + +def test_human_you_label_is_colored(): + r, buf = _human(color_system="truecolor") + r.user_final("what is the time") + r.close() + out = buf.getvalue() + assert "you: " in out + assert "what is the time" in out + assert "\x1b[" in out +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pytest tests/test_agent_render.py::test_human_agent_label_is_colored tests/test_agent_render.py::test_human_you_label_is_colored -v` +Expected: FAIL — labels are currently plain text, so no ANSI is emitted even with color forced. + +- [ ] **Step 3: Edit `assemblyai_cli/agent/render.py`** + +Add imports: + +```python +from rich.text import Text + +from assemblyai_cli import theme +from assemblyai_cli.render import BaseRenderer +``` + +Add a small helper at module scope (after the imports, before the class) for the `label: body` pattern: + +```python +def _labeled(label: str, body: str) -> Text: + """A line whose `label` prefix is brand-accented and whose body is default.""" + return Text.assemble((label, "aai.label"), body) +``` + +Change `connected` human branch (was line 17): + +```python + self._line(Text("Connected — start talking. (Ctrl-C to stop)", style="aai.muted")) +``` + +Change `user_partial` human branch (was line 28): + +```python + self._update_line(_labeled("you: ", text)) +``` + +Change `user_final` human branch (was line 34): + +```python + self._finalize_line(_labeled("you: ", text)) +``` + +Change `agent_transcript` human branch (was line 45): + +```python + self._line(_labeled("agent: ", text)) # commits any open "you: …" partial first +``` + +- [ ] **Step 4: Run the agent suite** + +Run: `pytest tests/test_agent_render.py -v` +Expected: PASS (existing `test_human_agent_line_labeled`, `test_human_partial_then_final`, `test_human_connected_and_stopped_announce` still pass — text is unchanged). + +- [ ] **Step 5: Lint/typecheck** + +Run: `ruff check assemblyai_cli/agent/render.py && mypy assemblyai_cli/agent/render.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/agent/render.py tests/test_agent_render.py +git commit -m "feat(theme): accent you:/agent: labels and mute lifecycle notices" +``` + +--- + +## Task 6: Transcribe speaker-label coloring + +**Files:** +- Modify: `assemblyai_cli/commands/transcribe.py` +- Test: `tests/test_transcribe.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_transcribe.py` (top-of-file imports as needed): + +```python +def test_render_transcript_colors_speaker_labels(): + import io + + from assemblyai_cli import theme + from assemblyai_cli.commands.transcribe import _render_transcript + + data = { + "text": "ignored when utterances present", + "utterances": [ + {"speaker": "A", "text": "hello", "start": 0, "end": 1}, + {"speaker": "B", "text": "hi there", "start": 1, "end": 2}, + ], + } + rendered = _render_transcript(data) + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") + console.print(rendered) + out = buf.getvalue() + assert "Speaker A:" in out + assert "hello" in out + assert "Speaker B:" in out + assert "\x1b[" in out # speaker labels are styled + + +def test_render_transcript_plain_text_unchanged(): + from assemblyai_cli.commands.transcribe import _render_transcript + + assert _render_transcript({"text": "just the words"}) == "just the words" +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pytest tests/test_transcribe.py::test_render_transcript_colors_speaker_labels -v` +Expected: FAIL — `_render_transcript` currently returns a plain escaped string with no styling, so no ANSI is emitted. + +- [ ] **Step 3: Edit `assemblyai_cli/commands/transcribe.py`** + +Add imports (alongside `from rich.markup import escape`): + +```python +from rich.text import Text + +from assemblyai_cli import client, config, llm, output, theme, youtube +``` + +(Extend the existing `from assemblyai_cli import ...` line to include `theme`.) + +Replace `_render_transcript` (was lines 23-29) with the version below. Note: the +existing line 27 carries a stale `# type: ignore[union-attr]` that whole-project +mypy now rejects (`utterances` is typed `object`, so the real error is +`attr-defined`/`unused-ignore`). Since we are rewriting this exact function, fix +it properly by narrowing with `isinstance(..., list)` — no `type: ignore` needed, +and mypy passes cleanly: + +```python +def _render_transcript(data: dict[str, object]) -> str | Text: + """Human view: speaker-labeled lines when diarized, otherwise the plain text.""" + utterances = data.get("utterances") + if isinstance(utterances, list) and utterances: + line = Text() + for i, u in enumerate(utterances): + if i: + line.append("\n") + line.append(f"Speaker {u['speaker']}: ", style=theme.speaker_style(u["speaker"])) + line.append(str(u["text"])) + return line + return escape(str(data["text"])) +``` + +(`isinstance(utterances, list)` narrows `object` → `list[Any]`, so `u` is `Any` +and `u['speaker']` / `u['text']` type-check without an ignore. The `and utterances` +keeps the original behavior of falling through to plain text on an empty list.) + +- [ ] **Step 4: Run the transcribe suite** + +Run: `pytest tests/test_transcribe.py -v` +Expected: PASS. (The non-diarized path still returns the escaped string, so existing assertions hold.) + +- [ ] **Step 5: Lint/typecheck** + +The file has pre-existing format drift, so run the formatter on it (we are +legitimately touching the file), then lint and typecheck: + +Run: `ruff format assemblyai_cli/commands/transcribe.py && ruff check assemblyai_cli/commands/transcribe.py && mypy` +Expected: ruff clean; **whole-project `mypy` now reports "no issues"** — the 2 pre-existing +`transcribe.py:27` errors are resolved by the `isinstance` narrowing. (`output.emit`'s +renderer param is `Callable[[T], object]`, so a `str | Text` return is fine.) + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/commands/transcribe.py tests/test_transcribe.py +git commit -m "feat(theme): color diarized speaker labels in transcribe output" +``` + +--- + +## Task 7: Transcripts table — header + status coloring + +**Files:** +- Modify: `assemblyai_cli/commands/transcripts.py` +- Test: `tests/test_transcripts.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_transcripts.py`: + +```python +def test_list_table_colors_status(monkeypatch): + config.set_api_key("default", "sk_live") + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + # Force a real color terminal so styling produces ANSI we can assert on. + monkeypatch.setattr( + "assemblyai_cli.output.console", + __import__("assemblyai_cli.theme", fromlist=["make_console"]).make_console( + force_terminal=True, color_system="truecolor" + ), + ) + rows = [{"id": "t1", "status": "completed", "created": "2026-01-01"}] + with patch("assemblyai_cli.commands.transcripts.client.list_transcripts", return_value=rows): + result = runner.invoke(app, ["transcripts", "list"], color=True) + assert result.exit_code == 0 + assert "completed" in result.output + assert "\x1b[" in result.output # status cell is colored +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pytest tests/test_transcripts.py::test_list_table_colors_status -v` +Expected: FAIL — the table currently renders the status as an unstyled string, so no ANSI appears. + +- [ ] **Step 3: Edit `assemblyai_cli/commands/transcripts.py`** + +Add imports: + +```python +from rich.text import Text + +from assemblyai_cli import client, config, output, theme +``` + +(Extend the existing `from assemblyai_cli import ...` line to include `theme`.) + +Replace the `render` closure (was lines 55-63) with: + +```python + def render(data: list[dict[str, object]]) -> Table: + table = Table("id", "status", "created", header_style="aai.heading") + for row in data: + status = str(row["status"]) + table.add_row( + escape(str(row["id"])), + Text(status, style=theme.status_style(status)), + escape(str(row.get("created", ""))), + ) + return table +``` + +- [ ] **Step 4: Run the transcripts suite** + +Run: `pytest tests/test_transcripts.py -v` +Expected: PASS (existing `test_list_human_mode_renders_table` and `--json` tests still pass — JSON path is untouched, and table still contains the id/status text). + +- [ ] **Step 5: Lint/typecheck** + +Run: `ruff check assemblyai_cli/commands/transcripts.py && mypy assemblyai_cli/commands/transcripts.py` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/commands/transcripts.py tests/test_transcripts.py +git commit -m "feat(theme): brand table header and status-colored cells in transcripts list" +``` + +--- + +## Task 8: Claude steps + login/samples semantic markup + +**Files:** +- Modify: `assemblyai_cli/commands/claude.py`, `assemblyai_cli/commands/login.py`, `assemblyai_cli/commands/samples.py` +- Test: `tests/test_agent_command.py` (claude install/status), plus existing login/samples tests + +- [ ] **Step 1: Write the failing test** + +Add a focused unit test for the steps renderer. Create `tests/test_claude_render.py`: + +```python +import io + +from assemblyai_cli import theme +from assemblyai_cli.commands.claude import _render_steps + + +def test_render_steps_colors_status(): + data = { + "steps": [ + {"name": "mcp", "status": "installed", "detail": "/path"}, + {"name": "skill", "status": "failed", "detail": "nope"}, + ] + } + rendered = _render_steps(data) + buf = io.StringIO() + console = theme.make_console(file=buf, force_terminal=True, color_system="truecolor") + console.print(rendered) + out = buf.getvalue() + assert "installed" in out + assert "failed" in out + assert "\x1b[" in out # statuses are colored +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pytest tests/test_claude_render.py -v` +Expected: FAIL — `_render_steps` returns a plain string today; no ANSI is emitted. + +- [ ] **Step 3: Edit `assemblyai_cli/commands/claude.py`** + +Ensure `theme` is imported (extend the existing `from assemblyai_cli import ...`): + +```python +from assemblyai_cli import ... , theme +``` + +Replace `_render_steps` (was lines 185-187) with a version that styles each status and the heading via markup against the theme: + +```python +def _render_steps(data: dict[str, list[Step]]) -> str: + lines = [] + for s in data["steps"]: + style = theme.status_style(s["status"]) + lines.append( + f" {escape(s['name'])}: " + f"[{style}]{escape(s['status'])}[/{style}] — {escape(s['detail'])}" + ) + return "[aai.heading]AssemblyAI coding-agent setup:[/aai.heading]\n" + "\n".join(lines) +``` + +(`escape` is already imported in `claude.py`. The returned markup string is rendered by `output.emit` through the themed `output.console`.) + +- [ ] **Step 4: Edit `assemblyai_cli/commands/login.py`** + +Replace the two color markups. The `[green]Authenticated[/green]` (was line 44): + +```python + lambda _d: f"[aai.success]Authenticated[/aai.success] on profile '{escape(profile)}'.", +``` + +The `[dim]…[/dim]` browser-fallback notice (was lines 35-37): + +```python + output.console.print( + "[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]" + ) +``` + +- [ ] **Step 5: Edit `assemblyai_cli/commands/samples.py`** + +Replace `[yellow]Note:[/yellow]` (was line 85): + +```python + f"[aai.warn]Note:[/aai.warn] this file contains your API key — do not commit it.\n" +``` + +- [ ] **Step 6: Run the affected suites + full suite** + +Run: `pytest tests/test_claude_render.py tests/test_agent_command.py -v && pytest -q -m "not e2e"` +Expected: PASS, no regressions. (Login/samples human-mode tests run on non-tty capture, so the themed markup renders as plain text and existing `in` assertions hold.) + +- [ ] **Step 7: Lint/typecheck** + +Run: `ruff check assemblyai_cli/commands/claude.py assemblyai_cli/commands/login.py assemblyai_cli/commands/samples.py tests/test_claude_render.py && mypy assemblyai_cli/commands/claude.py assemblyai_cli/commands/login.py assemblyai_cli/commands/samples.py` +Expected: no errors. + +- [ ] **Step 8: Commit** + +```bash +git add assemblyai_cli/commands/claude.py assemblyai_cli/commands/login.py assemblyai_cli/commands/samples.py tests/test_claude_render.py +git commit -m "feat(theme): semantic colors for setup steps, login, and samples notices" +``` + +--- + +## Task 9: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the whole check script** + +Run: `bash scripts/check.sh` +Expected for the color work: `ruff check`, `mypy`, and `pytest` (`--cov-fail-under=90`) pass. + +**Known pre-existing failures NOT caused by this work (do not fix — out of scope):** +`ruff format --check` reports drift in `assemblyai_cli/agent/audio.py`, +`assemblyai_cli/commands/stream.py`, and `tests/test_microphone.py` — these are +uncommitted in-flight changes that predate the color work and are owned by other +branch work. (`commands/transcribe.py` is fixed by Task 6.) If `check.sh` halts on +`ruff format --check` before reaching pytest, verify the color work independently: +`ruff check . && mypy && pytest -q -m "not e2e" --cov=assemblyai_cli --cov-branch --cov-fail-under=90`, +and report the pre-existing format drift separately rather than reformatting files +outside this plan. + +- [ ] **Step 2: Manual smoke (optional, interactive TTY)** + +Run in a real terminal: `aai transcripts list` and `aai --help`-driven flows; confirm brand-blue headers, green/red statuses, and accent `you:`/`agent:` labels appear, and that `NO_COLOR=1 aai transcripts list` and `aai transcripts list | cat` emit no ANSI. + +- [ ] **Step 3: Commit any coverage-driven test additions** + +If `--cov-fail-under=90` flags an uncovered branch (e.g. an untested `status_style` group), add a targeted test and commit: + +```bash +git add tests/ +git commit -m "test(theme): cover remaining theme branches" +``` + +--- + +## Self-Review + +- **Spec coverage:** + - `theme.py` with `BRAND`, `THEME`, semantic names, `SPEAKER_STYLES`, `make_console`, `speaker_style`, `status_style` → Task 1. ✓ + - Route all consoles through theme (`output.console`, `BaseRenderer`) → Tasks 2, 3. ✓ + - `emit_error` themed → Task 2. ✓ + - BaseRenderer accepts `str | Text`, `stopped()` muted → Task 3. ✓ + - StreamRenderer notice muted, LLM line brand → Task 4. ✓ + - AgentRenderer `you:`/`agent:` accent, notices muted → Task 5. ✓ + - Transcribe speaker labels rotating accent, plain path unchanged → Task 6. ✓ + - Transcripts table brand header + status-colored cell → Task 7. ✓ + - Claude steps status-colored + heading; login/samples semantic markup → Task 8. ✓ + - No-regression / coverage gate / NO_COLOR + pipe behavior → Task 9. ✓ + - `llm.py` needs no code change (uses themed `output.console` automatically) — noted in spec. ✓ +- **Placeholder scan:** none — every code step shows full code; every run step shows the command and expected result. +- **Type consistency:** `make_console(file, **kwargs)`, `speaker_style(speaker)`, `status_style(status)`, and the `aai.*` style names are used identically across Tasks 1–8. `_render_transcript` and `_render_steps` return types (`str | Text`, `str`) match how `output.emit`/`console.print` consume them. diff --git a/docs/superpowers/plans/2026-06-03-full-sdk-options.md b/docs/superpowers/plans/2026-06-03-full-sdk-options.md new file mode 100644 index 00000000..9f56e3aa --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-full-sdk-options.md @@ -0,0 +1,1565 @@ +# Full SDK Option Coverage 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:** Make every AssemblyAI `TranscriptionConfig` and `StreamingParameters` option controllable from `aai transcribe` and `aai stream`, via curated typed flags plus a `--config KEY=VALUE` / `--config-file` escape hatch, and auto-render analysis results in human mode. + +**Architecture:** A new `config_builder.py` module owns the merge of three layers (config-file < `--config` < explicit flags), coerces string values per field type, validates keys/enums, and returns ready SDK config objects. `client.py` is changed to accept prebuilt config objects. A new `transcribe_render.py` renders analysis sections (summary, chapters, sentiment, etc.) in human mode. Commands stay thin: they map typer options to SDK-named dicts and delegate to the builder. + +**Tech Stack:** Python 3.11, Typer, `assemblyai` SDK (≥0.34), Rich, pytest, Hypothesis. + +--- + +## File Structure + +| File | Responsibility | +|------|----------------| +| `assemblyai_cli/config_builder.py` (new) | Field maps, value coercion, layer merge, validation, build `TranscriptionConfig` / `StreamingParameters`; small flag-normalization helpers. | +| `assemblyai_cli/transcribe_render.py` (new) | Render transcript text + conditional analysis sections in human mode. | +| `assemblyai_cli/client.py` (modify) | `transcribe()` / `stream_audio()` accept prebuilt config objects. | +| `assemblyai_cli/commands/transcribe.py` (modify) | New curated flags + escape hatch; delegate to builder; render via `transcribe_render`. | +| `assemblyai_cli/commands/stream.py` (modify) | New curated flags + escape hatch; delegate to builder. | +| `README.md` (modify) | Document new options and the escape hatch. | +| `tests/test_config_builder.py` (new) | Coercion, precedence, validation, enum errors, per-flag mapping. | +| `tests/test_transcribe_render.py` (new) | Per-section rendering + absent-field no-ops. | +| `tests/test_transcribe.py` (modify) | Flag→config assertions for new transcribe flags. | +| `tests/test_stream_command.py` (modify) | Flag→params assertions for new stream flags. | +| `tests/test_client.py` (modify) | New `client.transcribe`/`stream_audio` signatures. | +| `tests/test_properties.py` (modify) | Property test for coercion round-trips. | +| `tests/e2e/test_cli_e2e.py` (modify) | Real-API analysis-feature run. | + +--- + +## Task 1: config_builder — field maps, coercion, merge, validation + +**Files:** +- Create: `assemblyai_cli/config_builder.py` +- Test: `tests/test_config_builder.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_config_builder.py`: + +```python +import json + +import pytest + +from assemblyai_cli import config_builder as cb +from assemblyai_cli.errors import UsageError + + +def test_coerce_bool_int_float_list(): + assert cb.coerce_value("speaker_labels", "true") is True + assert cb.coerce_value("speaker_labels", "false") is False + assert cb.coerce_value("speakers_expected", "2") == 2 + assert cb.coerce_value("speech_threshold", "0.5") == 0.5 + assert cb.coerce_value("redact_pii_policies", "person_name, phone_number") == [ + "person_name", + "phone_number", + ] + + +def test_coerce_str_passthrough_and_json(): + assert cb.coerce_value("language_code", "en_us") == "en_us" + assert cb.coerce_value("custom_spelling", '{"AssemblyAI": ["assembly ai"]}') == { + "AssemblyAI": ["assembly ai"] + } + + +def test_coerce_bad_bool_and_int_raise_usageerror(): + with pytest.raises(UsageError): + cb.coerce_value("speaker_labels", "maybe") + with pytest.raises(UsageError): + cb.coerce_value("speakers_expected", "two") + + +def test_parse_config_overrides_unknown_key_lists_valid(): + with pytest.raises(UsageError) as exc: + cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, ["not_a_field=1"]) + assert "not_a_field" in str(exc.value) + assert "speaker_labels" in str(exc.value) # error lists valid fields + + +def test_parse_config_overrides_requires_equals(): + with pytest.raises(UsageError): + cb.parse_config_overrides(cb.TRANSCRIBE_FIELDS, ["speaker_labels"]) + + +def test_build_transcription_config_layer_precedence(tmp_path): + cfg = tmp_path / "c.json" + cfg.write_text(json.dumps({"speaker_labels": False, "speakers_expected": 5})) + tc = cb.build_transcription_config( + flags={"speaker_labels": True}, # flag beats file + overrides=["speakers_expected=3"], # --config beats file + config_file=str(cfg), + ) + assert tc.speaker_labels is True + assert tc.raw.speakers_expected == 3 + + +def test_build_transcription_config_ignores_unset_flags(): + tc = cb.build_transcription_config(flags={"speaker_labels": None}, overrides=[], config_file=None) + assert tc.speaker_labels is None # None means "not set", does not override + + +def test_load_config_file_rejects_non_object(tmp_path): + bad = tmp_path / "bad.json" + bad.write_text("[1, 2, 3]") + with pytest.raises(UsageError): + cb.load_config_file(bad, cb.TRANSCRIBE_FIELDS) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_config_builder.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'assemblyai_cli.config_builder'`. + +- [ ] **Step 3: Write `config_builder.py`** + +Create `assemblyai_cli/config_builder.py`: + +```python +from __future__ import annotations + +import json +from pathlib import Path + +import assemblyai as aai +from assemblyai.streaming.v3 import StreamingParameters + +from assemblyai_cli.errors import UsageError + +# field name -> coercion kind for --config/--config-file string values. +# The KEYS are the authoritative set of valid config fields per command. +TRANSCRIBE_COERCE: dict[str, str] = { + "language_code": "str", + "language_codes": "list", + "punctuate": "bool", + "format_text": "bool", + "dual_channel": "bool", + "multichannel": "bool", + "webhook_url": "str", + "webhook_auth_header_name": "str", + "webhook_auth_header_value": "str", + "audio_start_from": "int", + "audio_end_at": "int", + "word_boost": "list", + "boost_param": "str", + "filter_profanity": "bool", + "redact_pii": "bool", + "redact_pii_audio": "bool", + "redact_pii_audio_quality": "str", + "redact_pii_audio_options": "json", + "redact_pii_policies": "list", + "redact_pii_sub": "str", + "redact_pii_return_unredacted": "bool", + "speaker_labels": "bool", + "speakers_expected": "int", + "speaker_options": "json", + "content_safety": "bool", + "content_safety_confidence": "int", + "iab_categories": "bool", + "custom_spelling": "json", + "disfluencies": "bool", + "sentiment_analysis": "bool", + "auto_chapters": "bool", + "entity_detection": "bool", + "summarization": "bool", + "summary_model": "str", + "summary_type": "str", + "auto_highlights": "bool", + "language_detection": "bool", + "language_confidence_threshold": "float", + "language_detection_options": "json", + "speech_threshold": "float", + "speech_model": "str", + "speech_models": "list", + "prompt": "str", + "temperature": "float", + "remove_audio_tags": "str", + "keyterms_prompt": "list", + "keyterms_prompt_options": "json", + "speech_understanding": "json", + "domain": "str", +} + +STREAM_COERCE: dict[str, str] = { + "end_of_turn_confidence_threshold": "float", + "min_end_of_turn_silence_when_confident": "int", + "min_turn_silence": "int", + "max_turn_silence": "int", + "vad_threshold": "float", + "format_turns": "bool", + "keyterms_prompt": "list", + "filter_profanity": "bool", + "prompt": "str", + "sample_rate": "int", + "encoding": "str", + "speech_model": "str", + "language_detection": "bool", + "domain": "str", + "inactivity_timeout": "int", + "webhook_url": "str", + "webhook_auth_header_name": "str", + "webhook_auth_header_value": "str", + "llm_gateway": "json", + "speaker_labels": "bool", + "max_speakers": "int", + "voice_focus": "str", + "voice_focus_threshold": "float", + "noise_suppression_model": "str", + "noise_suppression_threshold": "float", + "continuous_partials": "bool", + "customer_support_audio_capture": "bool", + "include_partial_turns": "bool", + "redact_pii": "bool", + "redact_pii_policies": "list", + "redact_pii_sub": "str", +} + +TRANSCRIBE_FIELDS = TRANSCRIBE_COERCE +STREAM_FIELDS = STREAM_COERCE + +_TRUE = {"1", "true", "yes", "on"} +_FALSE = {"0", "false", "no", "off"} + + +def coerce_value(field: str, raw: str) -> object: + """Coerce a string --config value to the type expected by `field`.""" + kind = TRANSCRIBE_COERCE.get(field) or STREAM_COERCE.get(field, "str") + if kind == "bool": + low = raw.strip().lower() + if low in _TRUE: + return True + if low in _FALSE: + return False + raise UsageError(f"{field} expects a boolean (true/false), got {raw!r}.") + if kind == "int": + try: + return int(raw) + except ValueError as exc: + raise UsageError(f"{field} expects an integer, got {raw!r}.") from exc + if kind == "float": + try: + return float(raw) + except ValueError as exc: + raise UsageError(f"{field} expects a number, got {raw!r}.") from exc + if kind == "list": + return [part.strip() for part in raw.split(",") if part.strip()] + if kind == "json": + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise UsageError(f"{field} expects a JSON value, got {raw!r}.") from exc + return raw + + +def parse_config_overrides(fields: dict[str, str], pairs: list[str]) -> dict[str, object]: + """Parse repeated KEY=VALUE strings into a coerced, validated dict.""" + out: dict[str, object] = {} + for pair in pairs: + if "=" not in pair: + raise UsageError(f"--config expects KEY=VALUE, got {pair!r}.") + key, raw = pair.split("=", 1) + key = key.strip() + if key not in fields: + valid = ", ".join(sorted(fields)) + raise UsageError(f"Unknown config field {key!r}. Valid fields: {valid}.") + out[key] = coerce_value(key, raw) + return out + + +def load_config_file(path: str | Path, fields: dict[str, str]) -> dict[str, object]: + """Load a JSON config file and validate its keys against `fields`.""" + try: + data = json.loads(Path(path).read_text()) + except FileNotFoundError as exc: + raise UsageError(f"Config file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise UsageError(f"Config file is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise UsageError("Config file must contain a JSON object.") + unknown = [k for k in data if k not in fields] + if unknown: + valid = ", ".join(sorted(fields)) + raise UsageError(f"Unknown config field(s) {unknown}. Valid fields: {valid}.") + return data + + +def _merge( + fields: dict[str, str], + flags: dict[str, object], + overrides: list[str], + config_file: str | None, +) -> dict[str, object]: + data: dict[str, object] = {} + if config_file: + data.update(load_config_file(config_file, fields)) + data.update(parse_config_overrides(fields, overrides)) + data.update({k: v for k, v in flags.items() if v is not None}) + return data + + +def build_transcription_config( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> aai.TranscriptionConfig: + merged = _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) + try: + return aai.TranscriptionConfig(**merged) + except UsageError: + raise + except Exception as exc: # noqa: BLE001 - surface SDK validation as a usage error + raise UsageError(f"Invalid transcription config: {exc}") from exc + + +def build_streaming_params( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> StreamingParameters: + merged = _merge(STREAM_FIELDS, flags, overrides, config_file) + try: + return StreamingParameters(**merged) + except UsageError: + raise + except Exception as exc: # noqa: BLE001 + raise UsageError(f"Invalid streaming config: {exc}") from exc +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_config_builder.py -q` +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/config_builder.py tests/test_config_builder.py +git commit -m "feat(config): add config_builder for SDK option merge/coercion" +``` + +--- + +## Task 2: config_builder — flag-normalization helpers + streaming build test + +**Files:** +- Modify: `assemblyai_cli/config_builder.py` +- Test: `tests/test_config_builder.py:append` + +These helpers turn shell-friendly flag shapes into SDK field values: CSV lists, `NAME:VALUE` headers, a custom-spelling JSON file, and a translation Speech-Understanding payload. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_config_builder.py`: + +```python +def test_split_csv(): + assert cb.split_csv("a, b ,c") == ["a", "b", "c"] + assert cb.split_csv(None) is None + assert cb.split_csv("") is None + + +def test_parse_auth_header(): + assert cb.parse_auth_header("Authorization:Bearer x") == ("Authorization", "Bearer x") + assert cb.parse_auth_header(None) is None + with pytest.raises(UsageError): + cb.parse_auth_header("no-colon") + + +def test_load_custom_spelling(tmp_path): + p = tmp_path / "spell.json" + p.write_text('{"AssemblyAI": ["assembly ai", "assemblyai"]}') + assert cb.load_custom_spelling(str(p)) == {"AssemblyAI": ["assembly ai", "assemblyai"]} + + +def test_translation_request_shape(): + su = cb.translation_request(["es", "fr"]) + # target languages must be reachable from the payload regardless of dict/obj form. + assert "es" in json.dumps(su, default=lambda o: getattr(o, "__dict__", str(o))) + + +def test_build_streaming_params_minimal(): + sp = cb.build_streaming_params( + flags={"sample_rate": 16000, "speech_model": "universal_streaming_multilingual"}, + overrides=["max_turn_silence=400"], + config_file=None, + ) + assert sp.sample_rate == 16000 + assert sp.max_turn_silence == 400 +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_config_builder.py -q` +Expected: FAIL — `AttributeError: module ... has no attribute 'split_csv'`. + +- [ ] **Step 3: Add the helpers** + +Append to `assemblyai_cli/config_builder.py`: + +```python +def split_csv(value: str | None) -> list[str] | None: + """Split a comma-separated flag value into a list, or None if empty.""" + if not value: + return None + parts = [p.strip() for p in value.split(",") if p.strip()] + return parts or None + + +def parse_auth_header(value: str | None) -> tuple[str, str] | None: + """Parse a `NAME:VALUE` webhook auth header flag.""" + if value is None: + return None + if ":" not in value: + raise UsageError("--webhook-auth-header expects NAME:VALUE.") + name, header_value = value.split(":", 1) + return name.strip(), header_value.strip() + + +def load_custom_spelling(path: str) -> dict[str, object]: + """Load a custom-spelling JSON map (e.g. {"AssemblyAI": ["assembly ai"]}).""" + try: + data = json.loads(Path(path).read_text()) + except FileNotFoundError as exc: + raise UsageError(f"Custom spelling file not found: {path}") from exc + except json.JSONDecodeError as exc: + raise UsageError(f"Custom spelling file is not valid JSON: {exc}") from exc + if not isinstance(data, dict): + raise UsageError("Custom spelling file must contain a JSON object.") + return data + + +def translation_request(languages: list[str]) -> dict[str, object]: + """Build a Speech-Understanding translation payload for `speech_understanding`.""" + return {"request": {"translation": {"target_languages": list(languages)}}} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_config_builder.py -q` +Expected: PASS (13 tests). + +If `test_build_streaming_params_minimal` fails because the SDK rejects a string `speech_model`, change `build_streaming_params` to coerce it: before constructing, do +`from assemblyai.streaming.v3 import SpeechModel` and `if isinstance(merged.get("speech_model"), str): merged["speech_model"] = SpeechModel(merged["speech_model"])`, then re-run. (The enum is keyed by value, e.g. `SpeechModel("universal_streaming_multilingual")`.) + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/config_builder.py tests/test_config_builder.py +git commit -m "feat(config): add flag-normalization helpers + streaming build" +``` + +--- + +## Task 3: client.py — accept prebuilt config objects + +**Files:** +- Modify: `assemblyai_cli/client.py:63-78` (transcribe), `assemblyai_cli/client.py:97-139` (stream_audio) +- Test: `tests/test_client.py` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_client.py`: + +```python +def test_transcribe_passes_prebuilt_config(monkeypatch): + import assemblyai as aai + + from assemblyai_cli import client + + captured = {} + + class FakeTranscriber: + def transcribe(self, audio, config=None): + captured["audio"] = audio + captured["config"] = config + t = MagicMock() + t.status = aai.TranscriptStatus.completed + return t + + monkeypatch.setattr(aai, "Transcriber", lambda: FakeTranscriber()) + cfg = aai.TranscriptionConfig(speaker_labels=True) + client.transcribe("sk", "audio.mp3", config=cfg) + assert captured["audio"] == "audio.mp3" + assert captured["config"] is cfg + + +def test_stream_audio_accepts_params(monkeypatch): + from assemblyai.streaming.v3 import SpeechModel, StreamingParameters + + from assemblyai_cli import client + + captured = {} + + class FakeSC: + def __init__(self, *a, **k): + pass + + def on(self, *a, **k): + pass + + def connect(self, params): + captured["params"] = params + + def stream(self, source): + pass + + def disconnect(self, terminate=True): + pass + + monkeypatch.setattr("assemblyai_cli.client.StreamingClient", FakeSC) + params = StreamingParameters( + sample_rate=16000, speech_model=SpeechModel.universal_streaming_multilingual + ) + client.stream_audio("sk", iter([b""]), params=params) + assert captured["params"] is params +``` + +(MagicMock is already imported at the top of `tests/test_client.py`; if not, add `from unittest.mock import MagicMock`.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_client.py::test_transcribe_passes_prebuilt_config tests/test_client.py::test_stream_audio_accepts_params -q` +Expected: FAIL — `TypeError: transcribe() got an unexpected keyword argument 'config'`. + +- [ ] **Step 3: Update `client.transcribe` and `client.stream_audio`** + +Replace `transcribe` (`assemblyai_cli/client.py:63-78`) with: + +```python +def transcribe(api_key: str, audio: str, *, config: aai.TranscriptionConfig) -> aai.Transcript: + _configure(api_key) + try: + transcript = aai.Transcriber().transcribe(audio, config=config) + except APIError: + raise + except Exception as exc: + if is_auth_failure(exc): + raise auth_failure() from exc + raise APIError(f"Transcription request failed: {exc}") from exc + if transcript.status == aai.TranscriptStatus.error: + raise APIError(transcript.error or "Transcription failed.", transcript_id=transcript.id) + return transcript +``` + +Replace the `stream_audio` signature and the `sc.connect(...)` call (`assemblyai_cli/client.py:97-139`). Change the signature header to: + +```python +def stream_audio( + api_key: str, + source: Iterable[bytes], + *, + params: StreamingParameters, + on_begin: Callable[[Any], Any] | None = None, + on_turn: Callable[[Any], Any] | None = None, + on_termination: Callable[[Any], Any] | None = None, +) -> None: + """Stream `source` (an iterable of PCM bytes) through the v3 realtime API. + + Forwards Begin/Turn/Termination events to the callbacks; raises APIError on a stream error. + `params` is a fully-built StreamingParameters (sample_rate/speech_model/etc). + """ +``` + +And replace the `sc.connect(StreamingParameters(...))` block with: + +```python + try: + sc.connect(params) + except CLIError: + raise + except Exception as exc: + if is_auth_failure(exc): + raise auth_failure() from exc + raise APIError(f"Could not start streaming session: {exc}") from exc +``` + +Remove the now-unused `SpeechModel` import only if nothing else references it; leave `StreamingParameters` imported (it's the parameter type). + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_client.py -q` +Expected: PASS. (Existing client tests that call the old signature are updated in this same step — search `tests/test_client.py` for `client.transcribe(` / `client.stream_audio(` and update those calls to pass `config=`/`params=`.) + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/client.py tests/test_client.py +git commit -m "refactor(client): accept prebuilt TranscriptionConfig/StreamingParameters" +``` + +--- + +## Task 4: transcribe_render — analysis section renderers + +**Files:** +- Create: `assemblyai_cli/transcribe_render.py` +- Test: `tests/test_transcribe_render.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_transcribe_render.py`: + +```python +from types import SimpleNamespace + +from rich.console import Console + +from assemblyai_cli import transcribe_render as tr + + +def _render(transcript) -> str: + console = Console(width=80, force_terminal=False) + with console.capture() as cap: + tr.render_transcript_result(transcript, console) + return cap.get() + + +def test_renders_text_only_when_no_analysis(): + out = _render(SimpleNamespace(text="hello world")) + assert "hello world" in out + assert "Summary" not in out + + +def test_renders_summary_and_chapters(): + transcript = SimpleNamespace( + text="t", + summary="A short summary.", + chapters=[SimpleNamespace(start=0, end=133000, headline="Intro", gist="i", summary="s")], + ) + out = _render(transcript) + assert "Summary:" in out + assert "A short summary." in out + assert "Chapters:" in out + assert "Intro" in out + assert "00:00" in out and "02:13" in out # 133000ms -> 02:13 + + +def test_renders_sentiment_aggregate(): + transcript = SimpleNamespace( + text="t", + sentiment_analysis=[ + SimpleNamespace(text="a", sentiment=SimpleNamespace(value="POSITIVE")), + SimpleNamespace(text="b", sentiment=SimpleNamespace(value="POSITIVE")), + SimpleNamespace(text="c", sentiment=SimpleNamespace(value="NEGATIVE")), + ], + ) + out = _render(transcript) + assert "Sentiment:" in out + assert "positive" in out.lower() + + +def test_renders_entities_topics_content_safety_highlights(): + transcript = SimpleNamespace( + text="t", + entities=[SimpleNamespace(entity_type=SimpleNamespace(value="person_name"), text="Ada")], + iab_categories=SimpleNamespace(summary={"Technology": 0.91}), + content_safety=SimpleNamespace(summary={"profanity": 0.4}), + auto_highlights=SimpleNamespace( + results=[SimpleNamespace(text="key phrase", count=3, rank=0.9)] + ), + ) + out = _render(transcript) + assert "Entities:" in out and "Ada" in out + assert "Topics:" in out and "Technology" in out + assert "Content Safety:" in out and "profanity" in out + assert "Highlights:" in out and "key phrase" in out +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_transcribe_render.py -q` +Expected: FAIL — `ModuleNotFoundError: No module named 'assemblyai_cli.transcribe_render'`. + +- [ ] **Step 3: Write `transcribe_render.py`** + +Create `assemblyai_cli/transcribe_render.py`: + +```python +from __future__ import annotations + +from collections import Counter + +from rich.console import Console + + +def _fmt_ms(ms: int) -> str: + total = int(ms) // 1000 + return f"{total // 60:02d}:{total % 60:02d}" + + +def _enum_value(obj: object) -> str: + return str(getattr(obj, "value", obj)) + + +def render_transcript_result(transcript: object, console: Console) -> None: + """Print the transcript text, then a section per analysis feature present.""" + console.print(getattr(transcript, "text", "") or "") + _render_summary(transcript, console) + _render_chapters(transcript, console) + _render_highlights(transcript, console) + _render_sentiment(transcript, console) + _render_entities(transcript, console) + _render_topics(transcript, console) + _render_content_safety(transcript, console) + + +def _render_summary(transcript: object, console: Console) -> None: + summary = getattr(transcript, "summary", None) + if summary: + console.print("\n[bold]Summary:[/bold]") + console.print(str(summary)) + + +def _render_chapters(transcript: object, console: Console) -> None: + chapters = getattr(transcript, "chapters", None) + if not chapters: + return + console.print("\n[bold]Chapters:[/bold]") + for ch in chapters: + span = f"{_fmt_ms(ch.start)}–{_fmt_ms(ch.end)}" + console.print(f" {span} {ch.headline}") + + +def _render_highlights(transcript: object, console: Console) -> None: + highlights = getattr(transcript, "auto_highlights", None) + results = getattr(highlights, "results", None) if highlights else None + if not results: + return + console.print("\n[bold]Highlights:[/bold]") + for h in results: + console.print(f" ({h.count}×) {h.text}") + + +def _render_sentiment(transcript: object, console: Console) -> None: + results = getattr(transcript, "sentiment_analysis", None) + if not results: + return + counts = Counter(_enum_value(r.sentiment).lower() for r in results) + total = sum(counts.values()) or 1 + parts = [f"{pct * 100 // total}% {label}" for label, pct in counts.items()] + console.print("\n[bold]Sentiment:[/bold] " + ", ".join(parts)) + + +def _render_entities(transcript: object, console: Console) -> None: + entities = getattr(transcript, "entities", None) + if not entities: + return + console.print("\n[bold]Entities:[/bold]") + for ent in entities: + console.print(f" {_enum_value(ent.entity_type)}: {ent.text}") + + +def _render_topics(transcript: object, console: Console) -> None: + iab = getattr(transcript, "iab_categories", None) + summary = getattr(iab, "summary", None) if iab else None + if not summary: + return + console.print("\n[bold]Topics:[/bold]") + for label, relevance in sorted(summary.items(), key=lambda kv: kv[1], reverse=True): + console.print(f" {label} ({float(relevance):.2f})") + + +def _render_content_safety(transcript: object, console: Console) -> None: + safety = getattr(transcript, "content_safety", None) + summary = getattr(safety, "summary", None) if safety else None + if not summary: + return + console.print("\n[bold]Content Safety:[/bold]") + for label, confidence in sorted(summary.items(), key=lambda kv: kv[1], reverse=True): + console.print(f" {_enum_value(label)} ({float(confidence):.2f})") +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_transcribe_render.py -q` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/transcribe_render.py tests/test_transcribe_render.py +git commit -m "feat(render): add transcribe analysis section renderers" +``` + +--- + +## Task 5: wire the `transcribe` command flags + +**Files:** +- Modify: `assemblyai_cli/commands/transcribe.py` +- Test: `tests/test_transcribe.py` (modify + append) + +- [ ] **Step 1: Update existing tests and add new ones** + +In `tests/test_transcribe.py`, the existing `test_transcribe_passes_speaker_labels` asserts `tx.call_args.kwargs["speaker_labels"]`. The client call now passes a `config=` object. Replace that test body with: + +```python +def test_transcribe_passes_speaker_labels(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke(app, ["transcribe", "audio.mp3", "--speaker-labels"]) + assert tx.call_args.kwargs["config"].speaker_labels is True +``` + +Update `test_transcribe_prompt_biases_speech_model` similarly — its final assertion becomes: + +```python + assert tx.call_args.kwargs["config"].prompt == "expect medical terms" +``` + +Then append new tests: + +```python +def test_transcribe_maps_analysis_flags(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--summarization", + "--summary-type", + "bullets", + "--sentiment-analysis", + "--topic-detection", + ], + ) + cfg = tx.call_args.kwargs["config"] + assert cfg.raw.summarization is True + assert cfg.raw.summary_type == "bullets" + assert cfg.raw.sentiment_analysis is True + assert cfg.raw.iab_categories is True + + +def test_transcribe_redact_pii_policy_csv(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke( + app, + [ + "transcribe", + "audio.mp3", + "--redact-pii", + "--redact-pii-policy", + "person_name,phone_number", + ], + ) + cfg = tx.call_args.kwargs["config"] + assert cfg.raw.redact_pii is True + assert [_enum_or_str(p) for p in cfg.raw.redact_pii_policies] == [ + "person_name", + "phone_number", + ] + + +def _enum_or_str(value): + return getattr(value, "value", value) + + +def test_transcribe_config_escape_hatch(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ) as tx: + runner.invoke(app, ["transcribe", "audio.mp3", "--config", "speech_threshold=0.5"]) + assert tx.call_args.kwargs["config"].raw.speech_threshold == 0.5 + + +def test_transcribe_unknown_config_field_exits_2(): + _auth() + with patch( + "assemblyai_cli.commands.transcribe.client.transcribe", return_value=_fake_transcript() + ): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--config", "bogus=1"]) + assert result.exit_code == 2 + assert "bogus" in result.output + + +def test_transcribe_renders_summary_human(monkeypatch): + _auth() + monkeypatch.setattr("assemblyai_cli.output.resolve_json", lambda *, explicit: False) + t = _fake_transcript() + t.summary = "three bullet summary" + t.chapters = [] + with patch("assemblyai_cli.commands.transcribe.client.transcribe", return_value=t): + result = runner.invoke(app, ["transcribe", "audio.mp3", "--summarization"]) + assert result.exit_code == 0 + assert "Summary:" in result.output + assert "three bullet summary" in result.output +``` + +Note: `_fake_transcript()` returns a `MagicMock`, so attributes like `.summary`/`.chapters` exist as truthy MagicMocks by default. For the render-path test above we set the ones we assert on explicitly. Add a line to `_fake_transcript()` so unanalyzed runs don't render spurious sections — set the analysis attributes to falsy by default: + +```python +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) + return t +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_transcribe.py -q` +Expected: FAIL — new flags are unknown (`No such option: --summarization`) and `config=` kwarg not yet passed. + +- [ ] **Step 3: Rewrite `commands/transcribe.py`** + +Replace the whole file with: + +```python +from __future__ import annotations + +import json + +import typer + +from assemblyai_cli import client, config, config_builder, llm, output, transcribe_render +from assemblyai_cli.context import AppState, run_command + + +app = typer.Typer() + + +@app.command() +def transcribe( + ctx: typer.Context, + source: str = typer.Argument(None, help="Audio file path or public URL."), + sample: bool = typer.Option(False, "--sample", help="Use the hosted wildfires.mp3 sample."), + # model & language + speech_model: str = typer.Option(None, "--speech-model", help="best, nano, slam-1, universal."), + language_code: str = typer.Option(None, "--language-code", help="Force a language (e.g. en_us)."), + language_detection: bool = typer.Option( + None, "--language-detection", help="Auto-detect the spoken language." + ), + keyterms_prompt: list[str] = typer.Option( + None, "--keyterms-prompt", help="Boost a key term (repeatable)." + ), + temperature: float = typer.Option(None, "--temperature", help="Speech model temperature."), + prompt: str = typer.Option(None, "--prompt", help="Bias the speech model (u3-pro)."), + # formatting + punctuate: bool = typer.Option(None, "--punctuate/--no-punctuate", help="Add punctuation."), + format_text: bool = typer.Option(None, "--format-text/--no-format-text", help="Format text."), + disfluencies: bool = typer.Option(None, "--disfluencies", help="Keep filler words."), + # speakers & channels + speaker_labels: bool = typer.Option(False, "--speaker-labels", help="Enable diarization."), + speakers_expected: int = typer.Option(None, "--speakers-expected", help="Hint speaker count."), + multichannel: bool = typer.Option(None, "--multichannel", help="Transcribe each channel."), + # guardrails + redact_pii: bool = typer.Option(None, "--redact-pii", help="Redact PII from the transcript."), + redact_pii_policy: str = typer.Option( + None, "--redact-pii-policy", help="Comma-separated PII policies (e.g. person_name,...)." + ), + redact_pii_sub: str = typer.Option( + None, "--redact-pii-sub", help="Substitution: hash or entity_name." + ), + redact_pii_audio: bool = typer.Option(None, "--redact-pii-audio", help="Also redact audio."), + filter_profanity: bool = typer.Option(None, "--filter-profanity", help="Mask profanity."), + content_safety: bool = typer.Option(None, "--content-safety", help="Detect sensitive content."), + content_safety_confidence: int = typer.Option( + None, "--content-safety-confidence", help="Confidence threshold 25-100." + ), + speech_threshold: float = typer.Option( + None, "--speech-threshold", help="Minimum speech proportion 0-1." + ), + # analysis + summarization: bool = typer.Option(None, "--summarization", help="Summarize the transcript."), + summary_model: str = typer.Option(None, "--summary-model", help="informative/conversational/catchy."), + summary_type: str = typer.Option(None, "--summary-type", help="bullets/gist/headline/paragraph."), + auto_chapters: bool = typer.Option(None, "--auto-chapters", help="Generate chapters."), + sentiment_analysis: bool = typer.Option(None, "--sentiment-analysis", help="Analyze sentiment."), + entity_detection: bool = typer.Option(None, "--entity-detection", help="Detect entities."), + auto_highlights: bool = typer.Option(None, "--auto-highlights", help="Detect key phrases."), + topic_detection: bool = typer.Option(None, "--topic-detection", help="Detect IAB topics."), + # customization + word_boost: list[str] = typer.Option(None, "--word-boost", help="Boost a word (repeatable)."), + custom_spelling_file: str = typer.Option( + None, "--custom-spelling-file", help="JSON map of custom spellings." + ), + audio_start: int = typer.Option(None, "--audio-start", help="Start offset in ms."), + audio_end: int = typer.Option(None, "--audio-end", help="End offset in ms."), + # webhooks + webhook_url: str = typer.Option(None, "--webhook-url", help="Webhook URL for completion."), + webhook_auth_header: str = typer.Option( + None, "--webhook-auth-header", help="Webhook auth header as NAME:VALUE." + ), + # speech understanding + translate_to: list[str] = typer.Option( + None, "--translate-to", help="Translate transcript to a language (repeatable)." + ), + # escape hatch + config_kv: list[str] = typer.Option( + None, "--config", help="Set any TranscriptionConfig field as KEY=VALUE (repeatable)." + ), + config_file: str = typer.Option(None, "--config-file", help="JSON file of config fields."), + # llm gateway transform (existing) + llm_gateway_prompt: str = typer.Option( + None, "--llm-gateway-prompt", help="Transform the finished transcript through LLM Gateway." + ), + model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), + max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Transcribe an audio file or URL with the full TranscriptionConfig surface. + + 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. + """ + + def body(state: AppState, json_mode: bool) -> None: + flags: dict[str, object] = { + "speech_model": speech_model, + "language_code": language_code, + "language_detection": language_detection, + "keyterms_prompt": list(keyterms_prompt) if keyterms_prompt else None, + "temperature": temperature, + "prompt": prompt, + "punctuate": punctuate, + "format_text": format_text, + "disfluencies": disfluencies, + "speaker_labels": speaker_labels or None, + "speakers_expected": speakers_expected, + "multichannel": multichannel, + "redact_pii": redact_pii, + "redact_pii_policies": config_builder.split_csv(redact_pii_policy), + "redact_pii_sub": redact_pii_sub, + "redact_pii_audio": redact_pii_audio, + "filter_profanity": filter_profanity, + "content_safety": content_safety, + "content_safety_confidence": content_safety_confidence, + "speech_threshold": speech_threshold, + "summarization": summarization, + "summary_model": summary_model, + "summary_type": summary_type, + "auto_chapters": auto_chapters, + "sentiment_analysis": sentiment_analysis, + "entity_detection": entity_detection, + "auto_highlights": auto_highlights, + "iab_categories": topic_detection, + "word_boost": list(word_boost) if word_boost else None, + "custom_spelling": ( + config_builder.load_custom_spelling(custom_spelling_file) + if custom_spelling_file + else None + ), + "audio_start_from": audio_start, + "audio_end_at": audio_end, + "webhook_url": webhook_url, + "speech_understanding": ( + config_builder.translation_request(list(translate_to)) if translate_to else None + ), + } + header = config_builder.parse_auth_header(webhook_auth_header) + if header is not None: + flags["webhook_auth_header_name"] = header[0] + flags["webhook_auth_header_value"] = header[1] + + tc = config_builder.build_transcription_config( + flags=flags, overrides=list(config_kv or []), config_file=config_file + ) + + audio = client.resolve_audio_source(source, sample=sample) + api_key = config.resolve_api_key(profile=state.profile) + transcript = client.transcribe(api_key, audio, config=tc) + + if llm_gateway_prompt: + transformed = llm.transform_transcript( + api_key, + prompt=llm_gateway_prompt, + model=model, + transcript_id=transcript.id, + max_tokens=max_tokens, + ) + output.emit( + { + "id": transcript.id, + "status": client.status_str(transcript), + "text": transcript.text, + "transform": { + "model": model, + "prompt": llm_gateway_prompt, + "output": transformed, + }, + }, + lambda d: str(d["transform"]["output"]), + json_mode=json_mode, + ) + return + + if json_mode: + payload = getattr(transcript, "json_response", None) or { + "id": transcript.id, + "status": client.status_str(transcript), + "text": transcript.text, + } + print(json.dumps(payload, default=str)) + else: + transcribe_render.render_transcript_result(transcript, output.console) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_transcribe.py tests/test_transcribe_render.py -q` +Expected: PASS. + +If `--translate-to` causes a build failure (SDK rejects a dict for `speech_understanding`), construct the objects instead: in `config_builder.translation_request`, return +`from assemblyai.types import SpeechUnderstandingRequest, SpeechUnderstandingFeatureRequests, TranslationRequest` and build `SpeechUnderstandingRequest(request=SpeechUnderstandingFeatureRequests(translation=TranslationRequest(target_languages=list(languages))))`. Re-run. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/transcribe.py tests/test_transcribe.py +git commit -m "feat(transcribe): expose full TranscriptionConfig via flags + escape hatch" +``` + +--- + +## Task 6: wire the `stream` command flags + +**Files:** +- Modify: `assemblyai_cli/commands/stream.py` +- Test: `tests/test_stream_command.py` (modify + append) + +- [ ] **Step 1: Update existing tests and add new ones** + +In `tests/test_stream_command.py`, find any call asserting `client.stream_audio` kwargs `sample_rate=`/`prompt=` and update them to read from the passed `params=`. Then append: + +```python +def test_stream_maps_turn_detection_flags(monkeypatch): + import assemblyai_cli.commands.stream as stream_cmd + from assemblyai_cli import config + + config.set_api_key("default", "sk_live") + captured = {} + + def fake_stream_audio(api_key, source, *, params, **kw): + captured["params"] = params + + monkeypatch.setattr(stream_cmd.client, "stream_audio", fake_stream_audio) + from typer.testing import CliRunner + + from assemblyai_cli.main import app + + CliRunner().invoke( + app, + [ + "stream", + "--sample", + "--max-turn-silence", + "400", + "--filter-profanity", + "--speaker-labels", + ], + ) + params = captured["params"] + assert params.max_turn_silence == 400 + assert params.filter_profanity is True + assert params.speaker_labels is True + + +def test_stream_config_escape_hatch(monkeypatch): + import assemblyai_cli.commands.stream as stream_cmd + from assemblyai_cli import config + + config.set_api_key("default", "sk_live") + captured = {} + monkeypatch.setattr( + stream_cmd.client, + "stream_audio", + lambda api_key, source, *, params, **kw: captured.update(params=params), + ) + from typer.testing import CliRunner + + from assemblyai_cli.main import app + + CliRunner().invoke(app, ["stream", "--sample", "--config", "vad_threshold=0.7"]) + assert captured["params"].vad_threshold == 0.7 +``` + +(If `test_stream_command.py` already imports `CliRunner`/`app`/`config` at module scope, drop the inline imports above and reuse them.) + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_stream_command.py -q` +Expected: FAIL — `No such option: --max-turn-silence`. + +- [ ] **Step 3: Rewrite `commands/stream.py`** + +Replace the whole file with: + +```python +from __future__ import annotations + +import typer +from assemblyai.streaming.v3 import SpeechModel + +from assemblyai_cli import client, config, config_builder, llm +from assemblyai_cli.context import AppState, run_command +from assemblyai_cli.errors import UsageError +from assemblyai_cli.microphone import MicrophoneSource +from assemblyai_cli.streaming.render import StreamRenderer +from assemblyai_cli.streaming.sources import TARGET_RATE, FileSource + + +app = typer.Typer() + +DEFAULT_SPEECH_MODEL = SpeechModel.universal_streaming_multilingual.value + + +@app.command() +def stream( + ctx: typer.Context, + source: str = typer.Argument( + None, help="Audio file path or URL to stream. Omit to use the microphone." + ), + sample: bool = typer.Option(False, "--sample", help="Stream the hosted wildfires.mp3 sample."), + sample_rate: int = typer.Option(TARGET_RATE, "--sample-rate", help="Microphone sample rate Hz."), + device: int | None = typer.Option(None, "--device", help="Microphone device index."), + # model & input + speech_model: str = typer.Option( + DEFAULT_SPEECH_MODEL, "--speech-model", help="Streaming speech model." + ), + encoding: str = typer.Option(None, "--encoding", help="pcm_s16le or pcm_mulaw."), + language_detection: bool = typer.Option( + None, "--language-detection", help="Auto-detect the spoken language." + ), + domain: str = typer.Option(None, "--domain", help="Domain preset (e.g. medical)."), + # turn detection + end_of_turn_confidence_threshold: float = typer.Option( + None, "--end-of-turn-confidence-threshold", help="0-1 end-of-turn confidence." + ), + min_turn_silence: int = typer.Option(None, "--min-turn-silence", help="Min turn silence (ms)."), + max_turn_silence: int = typer.Option(None, "--max-turn-silence", help="Max turn silence (ms)."), + vad_threshold: float = typer.Option(None, "--vad-threshold", help="Voice-activity threshold."), + format_turns: bool = typer.Option( + None, "--format-turns/--no-format-turns", help="Punctuate/format finalized turns." + ), + include_partial_turns: bool = typer.Option( + None, "--include-partial-turns", help="Emit partial turns." + ), + # features + keyterms_prompt: list[str] = typer.Option( + None, "--keyterms-prompt", help="Boost a key term (repeatable)." + ), + filter_profanity: bool = typer.Option(None, "--filter-profanity", help="Mask profanity."), + speaker_labels: bool = typer.Option(None, "--speaker-labels", help="Label speakers."), + max_speakers: int = typer.Option(None, "--max-speakers", help="Max speakers."), + voice_focus: str = typer.Option(None, "--voice-focus", help="near_field or far_field."), + voice_focus_threshold: float = typer.Option( + None, "--voice-focus-threshold", help="Voice-focus threshold." + ), + redact_pii: bool = typer.Option(None, "--redact-pii", help="Redact PII from turns."), + redact_pii_policy: str = typer.Option( + None, "--redact-pii-policy", help="Comma-separated PII policies." + ), + redact_pii_sub: str = typer.Option(None, "--redact-pii-sub", help="hash or entity_name."), + inactivity_timeout: int = typer.Option( + None, "--inactivity-timeout", help="Auto-close after N seconds idle." + ), + webhook_url: str = typer.Option(None, "--webhook-url", help="Webhook URL."), + webhook_auth_header: str = typer.Option( + None, "--webhook-auth-header", help="Webhook auth header as NAME:VALUE." + ), + # escape hatch + config_kv: list[str] = typer.Option( + None, "--config", help="Set any StreamingParameters field as KEY=VALUE (repeatable)." + ), + config_file: str = typer.Option(None, "--config-file", help="JSON file of streaming fields."), + # existing + prompt: str = typer.Option(None, "--prompt", help="Bias the speech model (u3-pro)."), + llm_gateway_prompt: str = typer.Option( + None, "--llm-gateway-prompt", help="After streaming, transform the transcript via LLM Gateway." + ), + model: str = typer.Option(llm.DEFAULT_MODEL, "--model", help="LLM Gateway model."), + max_tokens: int = typer.Option(llm.DEFAULT_MAX_TOKENS, "--max-tokens", help="Max tokens."), + json_out: bool = typer.Option(False, "--json", help="Emit newline-delimited JSON events."), +) -> None: + """Transcribe live audio in real time with the full StreamingParameters surface.""" + + def body(state: AppState, json_mode: bool) -> None: + api_key = config.resolve_api_key(profile=state.profile) + from_file = bool(source) or sample + if from_file and (sample_rate != TARGET_RATE or device is not None): + raise UsageError("--sample-rate and --device apply only to microphone input.") + audio: FileSource | MicrophoneSource + if from_file: + audio = FileSource(client.resolve_audio_source(source, sample=sample)) + rate = audio.sample_rate + else: + audio = MicrophoneSource(sample_rate=sample_rate, device=device) + rate = sample_rate + + flags: dict[str, object] = { + "sample_rate": rate, + "speech_model": speech_model, + "format_turns": format_turns if format_turns is not None else True, + "encoding": encoding, + "language_detection": language_detection, + "domain": domain, + "end_of_turn_confidence_threshold": end_of_turn_confidence_threshold, + "min_turn_silence": min_turn_silence, + "max_turn_silence": max_turn_silence, + "vad_threshold": vad_threshold, + "include_partial_turns": include_partial_turns, + "keyterms_prompt": list(keyterms_prompt) if keyterms_prompt else None, + "filter_profanity": filter_profanity, + "speaker_labels": speaker_labels, + "max_speakers": max_speakers, + "voice_focus": voice_focus, + "voice_focus_threshold": voice_focus_threshold, + "redact_pii": redact_pii, + "redact_pii_policies": config_builder.split_csv(redact_pii_policy), + "redact_pii_sub": redact_pii_sub, + "inactivity_timeout": inactivity_timeout, + "webhook_url": webhook_url, + "prompt": prompt, + } + header = config_builder.parse_auth_header(webhook_auth_header) + if header is not None: + flags["webhook_auth_header_name"] = header[0] + flags["webhook_auth_header_value"] = header[1] + + params = config_builder.build_streaming_params( + flags=flags, overrides=list(config_kv or []), config_file=config_file + ) + + renderer = StreamRenderer(json_mode=json_mode) + turns: list[str] = [] + + def on_turn(event: object) -> None: + renderer.turn(event) + if llm_gateway_prompt and getattr(event, "end_of_turn", False): + text = getattr(event, "transcript", "") or "" + if text: + turns.append(text) + + try: + client.stream_audio( + api_key, + audio, + params=params, + on_begin=renderer.begin, + on_turn=on_turn, + on_termination=renderer.termination, + ) + except KeyboardInterrupt: + renderer.close() + renderer.stopped() + except BrokenPipeError: + raise typer.Exit(code=0) from None + finally: + renderer.close() + + if llm_gateway_prompt and turns: + transformed = llm.transform_transcript( + api_key, + prompt=llm_gateway_prompt, + model=model, + transcript_text=" ".join(turns), + max_tokens=max_tokens, + ) + renderer.llm(transformed) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_stream_command.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/stream.py tests/test_stream_command.py +git commit -m "feat(stream): expose full StreamingParameters via flags + escape hatch" +``` + +--- + +## Task 7: docs + samples + +**Files:** +- Modify: `README.md` +- Modify: `assemblyai_cli/templates/` (the transcribe/stream sample templates, if present) + +- [ ] **Step 1: Document the new options** + +In `README.md`, under the `transcribe` and `stream` sections, add a subsection that lists the curated flag groups (Model & language / Formatting / Speakers / Guardrails / Analysis / Customization / Webhooks) and documents the escape hatch with an example: + +````markdown +#### Advanced options + +Every `TranscriptionConfig` / `StreamingParameters` field has a curated flag or is +reachable through the escape hatch: + +```bash +aai transcribe call.mp3 \ + --speaker-labels --speakers-expected 2 \ + --redact-pii --redact-pii-policy person_name,phone_number \ + --summarization --summary-type bullets \ + --sentiment-analysis --auto-chapters \ + --config speech_threshold=0.5 \ + --config-file extra.json +``` + +`--config KEY=VALUE` (repeatable) and `--config-file FILE` (JSON object) accept any +SDK field by its exact name. Precedence: config file < `--config` < explicit flags. +```` + +- [ ] **Step 2: Verify the README examples are accurate** + +Run: `aai transcribe --help` and confirm every flag named in the README example appears in the help output. +Expected: all referenced flags are present. + +- [ ] **Step 3: Refresh sample templates (if they hard-code config)** + +If `assemblyai_cli/templates/` contains a transcribe/stream starter that builds a +`TranscriptionConfig`, add one or two of the new options (e.g. `summarization=True`) +as a comment-documented example. If templates don't build config, skip. + +- [ ] **Step 4: Commit** + +```bash +git add README.md assemblyai_cli/templates +git commit -m "docs: document full SDK option flags and the --config escape hatch" +``` + +--- + +## Task 8: exhaustive tests — property coercion + per-flag coverage + e2e + +**Files:** +- Modify: `tests/test_properties.py` +- Modify: `tests/test_config_builder.py` +- Modify: `tests/e2e/test_cli_e2e.py` + +- [ ] **Step 1: Add a property test for coercion round-trips** + +Append to `tests/test_properties.py`: + +```python +from hypothesis import given +from hypothesis import strategies as st + +from assemblyai_cli import config_builder as cb + + +@given(value=st.integers(min_value=0, max_value=10_000_000)) +def test_int_coercion_roundtrips(value): + assert cb.coerce_value("speakers_expected", str(value)) == value + + +@given(value=st.lists(st.text(alphabet="abcdefghijklmnop", min_size=1, max_size=6), max_size=5)) +def test_list_coercion_roundtrips(value): + raw = ",".join(value) + assert cb.coerce_value("word_boost", raw) == [v for v in value if v] + + +@given(value=st.booleans()) +def test_bool_coercion_roundtrips(value): + assert cb.coerce_value("speaker_labels", str(value).lower()) is value +``` + +- [ ] **Step 2: Add a parametrized per-flag mapping test** + +Append to `tests/test_config_builder.py`: + +```python +import pytest as _pytest + + +@_pytest.mark.parametrize( + "field,raw,expected", + [ + ("punctuate", "false", False), + ("multichannel", "true", True), + ("audio_start_from", "1500", 1500), + ("temperature", "0.2", 0.2), + ("summary_type", "bullets", "bullets"), + ("keyterms_prompt", "a,b", ["a", "b"]), + ], +) +def test_transcribe_field_coercion_matrix(field, raw, expected): + tc = cb.build_transcription_config( + flags={}, overrides=[f"{field}={raw}"], config_file=None + ) + assert getattr(tc.raw, field) == expected + + +@_pytest.mark.parametrize("field", sorted(cb.STREAM_FIELDS)) +def test_every_stream_field_is_a_valid_param(field): + # Each declared field must be a real StreamingParameters attribute. + from assemblyai.streaming.v3 import StreamingParameters + + assert field in StreamingParameters.model_fields +``` + +- [ ] **Step 3: Run the new unit/property tests** + +Run: `pytest tests/test_config_builder.py tests/test_properties.py -q` +Expected: PASS. + +- [ ] **Step 4: Add a real-API e2e analysis run** + +Open `tests/e2e/test_cli_e2e.py`, mirror the existing subprocess-invocation pattern used by the current transcribe e2e test, and add: + +```python +def test_e2e_transcribe_analysis(real_api_key): + # Mirrors the existing transcribe e2e: run the CLI as a subprocess with --json, + # using --sample so no local audio is required. + result = run_cli( # use the same helper the other e2e tests use + ["transcribe", "--sample", "--summarization", "--auto-chapters", "--json"], + api_key=real_api_key, + ) + assert result.returncode == 0 + import json as _json + + payload = _json.loads(result.stdout) + # The full transcript object is returned; analysis fields are present. + assert payload.get("summary") or payload.get("chapters") +``` + +If the existing e2e file uses a different helper name than `run_cli`, match it exactly (read the top of the file first). Keep the test guarded by the `real_api_key` fixture so it skips without a key. + +- [ ] **Step 5: Run the full suite** + +Run: `pytest -q` (e2e skips without `ASSEMBLYAI_API_KEY`). +Expected: PASS / SKIPPED for e2e. + +- [ ] **Step 6: Run linters** + +Run: `ruff check . && ruff format --check .` +Expected: clean. Fix any issues and re-run. + +- [ ] **Step 7: Commit** + +```bash +git add tests/test_properties.py tests/test_config_builder.py tests/e2e/test_cli_e2e.py +git commit -m "test: exhaustive coercion property tests + analysis e2e" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** hybrid mapping (Tasks 1–2, 5–6), JSON-only config file (Task 1 `load_config_file`), precedence file **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 a `--show-code` flag to `transcribe`, `stream`, and `agent` that, after a real run, prints the idiomatic Python SDK code reproducing the same call — including result handling that mirrors the enabled features. + +**Architecture:** A standalone `code_gen` package generates Python from the *merged config kwargs dict* that `config_builder` already produces (guaranteeing a round-trip), plus a feature-snippet table that mirrors `transcribe_render.py`'s per-feature functions. The generator is kept fully separate from the Rich renderers; drift is caught by tests, not prevented by coupling. + +**Validity guarantee (the central test goal):** It must be impossible to generate invalid code without a test failing. This is achieved structurally, not by enumerating examples: +1. Generation is driven *entirely* by the merged-kwargs dict; `config_builder.TRANSCRIBE_COERCE` / `STREAM_COERCE` are (per their own comment) the authoritative valid-field sets. +2. A hypothesis strategy is built *from those tables*, so it fuzzes the **entire** legal input space — and any field added later is fuzzed automatically. +3. Every fuzzed output is `compile()`d (stronger than `ast.parse`) → no syntactically invalid Python can be produced. +4. The config portion round-trips (`eval` of the emitted kwargs `== merged`) → the generated call always reconstructs the same config. +5. Result-handling snippets are `exec`'d against a stub transcript → no broken attribute access can ship. +6. A coverage guard fails if any rendered feature lacks a snippet. +What tests *cannot* assert: that a snippet's wording is the *clearest* phrasing — that's editorial judgment, reviewed by a human, not a test. + +**Tech Stack:** Python 3.10+, `typer`, `assemblyai` SDK, `pytest`, `hypothesis` (already in the project). + +--- + +## File Structure + +| File | Responsibility | +| --- | --- | +| `assemblyai_cli/config_builder.py` | **Modify.** Split build into `merge_*` (→ kwargs dict) + `construct_*` (→ SDK object). Existing `build_*` becomes a thin wrapper so current callers/tests are unaffected. | +| `assemblyai_cli/code_gen/__init__.py` | **Create.** Public entry points: `transcribe(...)`, `stream(...)`, `agent(...)` → `str`. Commands guard the call sites so code-gen never crashes a real run. | +| `assemblyai_cli/code_gen/serialize.py` | **Create.** `py_literal(value)` and `config_kwarg_lines(merged, indent)` — render a kwargs dict as Python source. | +| `assemblyai_cli/code_gen/snippets.py` | **Create.** Transcribe result-handling snippet table + `result_handling(merged)`; `SNIPPET_FEATURES` coverage list. | +| `assemblyai_cli/code_gen/transcribe.py` | **Create.** `render(merged, source) -> str` for the transcribe script. | +| `assemblyai_cli/code_gen/stream.py` | **Create.** `render(merged) -> str` for the streaming (microphone) script. | +| `assemblyai_cli/code_gen/agent.py` | **Create.** `render(voice, system_prompt, greeting) -> str` for the agent script. | +| `assemblyai_cli/commands/transcribe.py` | **Modify.** Add `--show-code`; print generated code after the result. | +| `assemblyai_cli/commands/stream.py` | **Modify.** Add `--show-code`. | +| `assemblyai_cli/commands/agent.py` | **Modify.** Add `--show-code`. | +| `tests/test_code_gen.py` | **Create.** Round-trip property test, golden/executes-clean tests, coverage guard. | +| `README.md` | **Modify.** Document `--show-code`. | + +**Generated-code conventions (apply everywhere):** +- Auth via `os.environ["ASSEMBLYAI_API_KEY"]` — never echo the real key to the terminal. +- Only non-`None` config fields are emitted, as keyword arguments. +- `code_gen` functions are **pure**: they take data and return a `str`. Printing/Rich lives in the command layer. + +--- + +## Task 1: Split `config_builder` into merge + construct + +**Files:** +- Modify: `assemblyai_cli/config_builder.py:182-213` +- Test: `tests/test_config_builder.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_config_builder.py`: + +```python +def test_merge_transcribe_config_returns_kwargs_dict(): + from assemblyai_cli import config_builder + + merged = config_builder.merge_transcribe_config( + flags={"speaker_labels": True, "language_code": None}, + overrides=["sentiment_analysis=true"], + config_file=None, + ) + assert merged == {"speaker_labels": True, "sentiment_analysis": True} + + +def test_construct_transcribe_config_from_merged(): + import assemblyai as aai + from assemblyai_cli import config_builder + + tc = config_builder.construct_transcription_config({"speaker_labels": True}) + assert isinstance(tc, aai.TranscriptionConfig) + assert tc.raw.model_dump(exclude_none=True) == {"speaker_labels": True} + + +def test_merge_streaming_params_coerces_speech_model_enum(): + from assemblyai.streaming.v3 import SpeechModel + from assemblyai_cli import config_builder + + merged = config_builder.merge_streaming_params( + flags={"speech_model": "universal-streaming-multilingual", "sample_rate": 16000}, + overrides=[], + config_file=None, + ) + assert merged["speech_model"] is SpeechModel.universal_streaming_multilingual + assert merged["sample_rate"] == 16000 + + +def test_build_transcription_config_still_works(): + import assemblyai as aai + from assemblyai_cli import config_builder + + tc = config_builder.build_transcription_config( + flags={"speaker_labels": True}, overrides=[], config_file=None + ) + assert isinstance(tc, aai.TranscriptionConfig) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_config_builder.py -k "merge_ or construct_ or build_transcription_config_still" -v` +Expected: FAIL — `module 'assemblyai_cli.config_builder' has no attribute 'merge_transcribe_config'`. + +- [ ] **Step 3: Refactor `config_builder.py`** + +Replace the body of `build_transcription_config` and `build_streaming_params` (lines 182-213) with: + +```python +def merge_transcribe_config( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> dict[str, object]: + """Merge config-file + --config overrides + curated flags into a kwargs dict.""" + return _merge(TRANSCRIBE_FIELDS, flags, overrides, config_file) + + +def construct_transcription_config(merged: dict[str, object]) -> aai.TranscriptionConfig: + """Build a TranscriptionConfig from a merged kwargs dict, surfacing errors as usage.""" + try: + return aai.TranscriptionConfig(**merged) + except UsageError: + raise + except Exception as exc: # surface SDK validation as a usage error + raise UsageError(f"Invalid transcription config: {exc}") from exc + + +def build_transcription_config( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> aai.TranscriptionConfig: + return construct_transcription_config( + merge_transcribe_config(flags=flags, overrides=overrides, config_file=config_file) + ) + + +def merge_streaming_params( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> dict[str, object]: + """Merge streaming config into a kwargs dict, coercing speech_model to a SpeechModel.""" + merged = _merge(STREAM_FIELDS, flags, overrides, config_file) + raw_model = merged.get("speech_model") + if isinstance(raw_model, str): + try: + merged["speech_model"] = SpeechModel[raw_model] + except KeyError: + try: + merged["speech_model"] = SpeechModel(raw_model) + except ValueError as exc: + raise UsageError(f"Invalid streaming config: {exc}") from exc + return merged + + +def construct_streaming_params(merged: dict[str, object]) -> StreamingParameters: + """Build StreamingParameters from a merged kwargs dict, surfacing errors as usage.""" + try: + return StreamingParameters(**merged) + except UsageError: + raise + except Exception as exc: + raise UsageError(f"Invalid streaming config: {exc}") from exc + + +def build_streaming_params( + *, flags: dict[str, object], overrides: list[str], config_file: str | None +) -> StreamingParameters: + return construct_streaming_params( + merge_streaming_params(flags=flags, overrides=overrides, config_file=config_file) + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_config_builder.py -v` +Expected: PASS (all, including pre-existing tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/config_builder.py tests/test_config_builder.py +git commit -m "refactor(config): split build_* into merge_* + construct_* helpers" +``` + +--- + +## Task 2: Config serializer (`code_gen/serialize.py`) + +**Files:** +- Create: `assemblyai_cli/code_gen/__init__.py` (empty for now) +- Create: `assemblyai_cli/code_gen/serialize.py` +- Test: `tests/test_code_gen.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_code_gen.py`: + +```python +from __future__ import annotations + +from hypothesis import given +from hypothesis import strategies as st + +from assemblyai_cli.code_gen import serialize + + +def test_py_literal_basic_types(): + assert serialize.py_literal("en_us") == "'en_us'" + assert serialize.py_literal(True) == "True" + assert serialize.py_literal(42) == "42" + assert serialize.py_literal(["a", "b"]) == "['a', 'b']" + assert serialize.py_literal({"AssemblyAI": ["assembly ai"]}) == "{'AssemblyAI': ['assembly ai']}" + + +def test_py_literal_speech_model_enum(): + from assemblyai.streaming.v3 import SpeechModel + + assert serialize.py_literal(SpeechModel.u3_rt_pro) == "SpeechModel.u3_rt_pro" + + +def test_config_kwarg_lines_emits_indented_kwargs(): + lines = serialize.config_kwarg_lines({"speaker_labels": True, "language_code": "en_us"}, indent=4) + assert lines == [" speaker_labels=True,", " language_code='en_us',"] + + +def test_config_kwarg_lines_empty_dict(): + assert serialize.config_kwarg_lines({}, indent=4) == [] + + +# --------------------------------------------------------------------------- +# Shared, domain-driven strategy: build merged-kwargs dicts from the AUTHORITATIVE +# field tables in config_builder. Used by every validity test below. Because the +# field list comes from the coerce tables, any field added later is fuzzed for free. +# --------------------------------------------------------------------------- +from assemblyai.streaming.v3 import SpeechModel +from assemblyai_cli import config_builder + +# JSON-ish values that repr()->eval() round-trips (string keys, no NaN/inf). +_json = st.recursive( + st.none() + | st.booleans() + | st.integers() + | st.floats(allow_nan=False, allow_infinity=False) + | st.text(st.characters(blacklist_categories=("Cs",)), max_size=8), + lambda children: st.lists(children, max_size=3) + | st.dictionaries(st.text(st.characters(min_codepoint=97, max_codepoint=122), min_size=1, max_size=5), children, max_size=3), + max_leaves=5, +) + +_BY_KIND = { + "str": st.text(st.characters(blacklist_categories=("Cs",)), max_size=16), + "bool": st.booleans(), + "int": st.integers(), + "float": st.floats(allow_nan=False, allow_infinity=False), + "list": st.lists(st.text(st.characters(blacklist_categories=("Cs",)), max_size=8), max_size=4), + "json": _json, +} + + +def _value_for(field: str, kind: str): + # speech_model in the streaming table may be a SpeechModel enum in real merged dicts. + if field == "speech_model": + return st.sampled_from(list(SpeechModel)) | _BY_KIND["str"] + return _BY_KIND[kind] + + +def merged_strategy(coerce_table: dict[str, str]) -> st.SearchStrategy: + """A hypothesis strategy yielding merged-kwargs dicts over the FULL field table.""" + return st.fixed_dictionaries( + {}, optional={f: _value_for(f, kind) for f, kind in coerce_table.items()} + ) + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_serializer_round_trips_full_transcribe_domain(merged): + lines = serialize.config_kwarg_lines(merged, indent=0) + src = "dict(\n" + "\n".join(lines) + "\n)" + assert eval(src, {"SpeechModel": SpeechModel}) == merged + + +@given(merged_strategy(config_builder.STREAM_COERCE)) +def test_serializer_round_trips_full_stream_domain(merged): + lines = serialize.config_kwarg_lines(merged, indent=0) + src = "dict(\n" + "\n".join(lines) + "\n)" + assert eval(src, {"SpeechModel": SpeechModel}) == merged +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_code_gen.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'assemblyai_cli.code_gen'`. + +- [ ] **Step 3: Create the package and serializer** + +Create `assemblyai_cli/code_gen/__init__.py`: + +```python +from __future__ import annotations +``` + +Create `assemblyai_cli/code_gen/serialize.py`: + +```python +from __future__ import annotations + +from assemblyai.streaming.v3 import SpeechModel + + +def py_literal(value: object) -> str: + """Render a coerced config value as Python source. + + Handles SDK enums (SpeechModel.) and plain JSON-ish types. repr() yields + valid Python for str/bool/int/float/list/dict with string keys. + """ + if isinstance(value, SpeechModel): + return f"SpeechModel.{value.name}" + return repr(value) + + +def config_kwarg_lines(merged: dict[str, object], indent: int) -> list[str]: + """Render a merged kwargs dict as indented `field=value,` source lines.""" + pad = " " * indent + return [f"{pad}{key}={py_literal(val)}," for key, val in merged.items()] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_code_gen.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/code_gen/__init__.py assemblyai_cli/code_gen/serialize.py tests/test_code_gen.py +git commit -m "feat(code-gen): add config kwargs serializer with round-trip test" +``` + +--- + +## Task 3: Transcribe result-handling snippets (`code_gen/snippets.py`) + +**Files:** +- Create: `assemblyai_cli/code_gen/snippets.py` +- Test: `tests/test_code_gen.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_code_gen.py`: + +```python +from assemblyai_cli.code_gen import snippets + + +def test_result_handling_includes_only_enabled_features(): + out = snippets.result_handling({"speaker_labels": True, "sentiment_analysis": True}) + assert "transcript.utterances" in out # speaker_labels + assert "transcript.sentiment_analysis" in out + assert "transcript.summary" not in out # summarization not enabled + + +def test_result_handling_default_prints_text(): + out = snippets.result_handling({}) + assert out.strip() == "print(transcript.text)" + + +def test_every_render_feature_has_a_snippet(): + # Coverage guard: every analysis section the CLI renders must have a code snippet + # (or be explicitly excluded). Catches "added a feature, forgot the snippet". + import inspect + + from assemblyai_cli import transcribe_render + + rendered = { + name[len("_render_"):] + for name, _ in inspect.getmembers(transcribe_render, inspect.isfunction) + if name.startswith("_render_") and name != "_render_text" + } + covered = set(snippets.SNIPPET_FEATURES) + assert rendered <= covered, f"render features without a snippet: {rendered - covered}" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_code_gen.py -k "result_handling or render_feature" -v` +Expected: FAIL — `module 'assemblyai_cli.code_gen' has no attribute 'snippets'` / `snippets` import error. + +- [ ] **Step 3: Create the snippet table** + +Create `assemblyai_cli/code_gen/snippets.py`. Each entry maps a feature to: the merged-key predicate and the result-handling code. Feature names match the `_render_` suffixes in `transcribe_render.py` (`summary`, `chapters`, `highlights`, `sentiment`, `entities`, `topics`, `content_safety`) so the coverage guard stays honest. Speaker labels is keyed on `speaker_labels`. + +```python +from __future__ import annotations + +from collections.abc import Callable + +# (feature-name, enabled-predicate, result-handling code) in render order. +_Entry = tuple[str, Callable[[dict[str, object]], bool], str] + + +def _has(*keys: str) -> Callable[[dict[str, object]], bool]: + return lambda merged: any(bool(merged.get(k)) for k in keys) + + +_SNIPPETS: list[_Entry] = [ + ( + "speaker_labels", + _has("speaker_labels"), + 'for utt in transcript.utterances or []:\n print(f"Speaker {utt.speaker}: {utt.text}")', + ), + ( + "summary", + _has("summarization"), + 'if transcript.summary:\n print("Summary:", transcript.summary)', + ), + ( + "chapters", + _has("auto_chapters"), + 'for ch in transcript.chapters or []:\n print(ch.headline)', + ), + ( + "highlights", + _has("auto_highlights"), + "results = getattr(transcript.auto_highlights, 'results', None) or []\n" + 'for h in results:\n print(f"({h.count}x) {h.text}")', + ), + ( + "sentiment", + _has("sentiment_analysis"), + 'for s in transcript.sentiment_analysis or []:\n print(s.sentiment, s.text)', + ), + ( + "entities", + _has("entity_detection"), + 'for ent in transcript.entities or []:\n print(f"{ent.entity_type}: {ent.text}")', + ), + ( + "topics", + _has("iab_categories"), + "summary = getattr(transcript.iab_categories, 'summary', None) or {}\n" + 'for label, relevance in summary.items():\n print(label, relevance)', + ), + ( + "content_safety", + _has("content_safety"), + "summary = getattr(transcript.content_safety, 'summary', None) or {}\n" + 'for label, confidence in summary.items():\n print(label, confidence)', + ), +] + +# Feature names with a snippet — asserted complete by the coverage-guard test. +SNIPPET_FEATURES = [name for name, _pred, _code in _SNIPPETS] + + +def result_handling(merged: dict[str, object]) -> str: + """Return result-handling code for the enabled analysis features. + + Falls back to printing the transcript text when no analysis feature is on. + """ + blocks = [code for _name, pred, code in _SNIPPETS if pred(merged)] + if not blocks: + return "print(transcript.text)" + return "\n\n".join(blocks) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_code_gen.py -k "result_handling or render_feature" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/code_gen/snippets.py tests/test_code_gen.py +git commit -m "feat(code-gen): add transcribe result-handling snippets + coverage guard" +``` + +--- + +## Task 4: Transcribe code renderer (`code_gen/transcribe.py`) + +**Files:** +- Create: `assemblyai_cli/code_gen/transcribe.py` +- Modify: `assemblyai_cli/code_gen/__init__.py` +- Test: `tests/test_code_gen.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_code_gen.py`: + +```python +import ast + +from assemblyai_cli import code_gen + + +def test_transcribe_render_parses_and_uses_env_key(): + code = code_gen.transcribe( + {"speaker_labels": True}, source="https://assembly.ai/wildfires.mp3" + ) + ast.parse(code) # raises SyntaxError if malformed + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code + assert "https://assembly.ai/wildfires.mp3" in code + assert "transcript.utterances" in code # result handling for speaker_labels + assert "{{API_KEY}}" not in code # never echo a real key + + +def test_transcribe_render_no_config_is_minimal(): + code = code_gen.transcribe({}, source="audio.mp3") + ast.parse(code) + assert "print(transcript.text)" in code + assert "TranscriptionConfig(" not in code # no kwargs -> no config object +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_code_gen.py -k transcribe_render -v` +Expected: FAIL — `module 'assemblyai_cli.code_gen' has no attribute 'transcribe'`. + +- [ ] **Step 3: Implement the renderer** + +Create `assemblyai_cli/code_gen/transcribe.py`: + +```python +from __future__ import annotations + +import textwrap + +from assemblyai_cli.code_gen import serialize, snippets + + +def render(merged: dict[str, object], source: str) -> str: + """Generate a runnable transcribe script reproducing this CLI invocation.""" + if merged: + kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=4)) + config_block = f"config = aai.TranscriptionConfig(\n{kwargs}\n)" + call = f"transcript = transcriber.transcribe({source!r}, config=config)" + else: + config_block = "" + call = f"transcript = transcriber.transcribe({source!r})" + + result = textwrap.indent(snippets.result_handling(merged), "") + + parts = [ + "import os", + "", + "import assemblyai as aai", + "", + '# Export your key first: export ASSEMBLYAI_API_KEY=""', + 'aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"]', + "", + "transcriber = aai.Transcriber()", + ] + if config_block: + parts += ["", config_block] + parts += [ + "", + call, + "", + "if transcript.status == aai.TranscriptStatus.error:", + " raise RuntimeError(transcript.error)", + "", + result, + "", + ] + return "\n".join(parts) +``` + +Add to `assemblyai_cli/code_gen/__init__.py`: + +```python +from __future__ import annotations + +from assemblyai_cli.code_gen import transcribe as _transcribe + + +def transcribe(merged: dict[str, object], source: str) -> str: + return _transcribe.render(merged, source) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_code_gen.py -k transcribe_render -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/code_gen/transcribe.py assemblyai_cli/code_gen/__init__.py tests/test_code_gen.py +git commit -m "feat(code-gen): render runnable transcribe scripts" +``` + +--- + +## Task 5: Wire `--show-code` into the transcribe command + +**Files:** +- Modify: `assemblyai_cli/commands/transcribe.py:98-99,155-157,194-195` +- Test: `tests/test_transcribe.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_transcribe.py` (match the existing CliRunner / mocking style in that file; this illustrates the assertion): + +```python +def test_transcribe_show_code_prints_python(runner, monkeypatch, fake_transcript): + # Reuse the file's existing fixtures for auth + a stubbed client.transcribe. + result = runner.invoke( + app, ["transcribe", "--sample", "--speaker-labels", "--show-code"] + ) + assert result.exit_code == 0 + assert "import assemblyai as aai" in result.stdout + assert "TranscriptionConfig(" in result.stdout + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.stdout +``` + +> If `tests/test_transcribe.py` lacks reusable fixtures, copy the auth/client patching from the nearest existing transcribe test in that file rather than inventing new ones. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_transcribe.py -k show_code -v` +Expected: FAIL — `--show-code` is not a known option (exit code 2). + +- [ ] **Step 3: Add the flag and wiring** + +In `assemblyai_cli/commands/transcribe.py`: + +Add the import near the top (line 7 group): + +```python +from assemblyai_cli import client, code_gen, config, config_builder, llm, output, transcribe_render +``` + +Add the option after `json_out` (around line 98): + +```python + show_code: bool = typer.Option( + False, "--show-code", help="Also print the equivalent Python SDK code." + ), +``` + +Replace the build call (lines 155-157) with a merge-then-construct pair so the same dict feeds code-gen: + +```python + merged = config_builder.merge_transcribe_config( + flags=flags, overrides=list(config_kv or []), config_file=config_file + ) + tc = config_builder.construct_transcription_config(merged) +``` + +After the `else` branch that calls `render_transcript_result` (after line 195), append: + +```python + if show_code and not json_mode: + # Code-gen is a bonus; never let it crash the real transcript output. + try: + rendered = code_gen.transcribe(merged, audio) + except Exception as exc: # noqa: BLE001 + output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") + else: + output.console.print("\n[dim]# Equivalent Python:[/dim]") + output.console.print(rendered) +``` + +> `audio` is the resolved source string from `client.resolve_audio_source` (line 159). `[aai.muted]` is an existing theme style; if it is absent, use `[dim]`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_transcribe.py -v` +Expected: PASS (new test + all existing transcribe tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/transcribe.py tests/test_transcribe.py +git commit -m "feat(transcribe): add --show-code to print equivalent SDK code" +``` + +--- + +## Task 6: Stream code renderer (`code_gen/stream.py`) + +**Files:** +- Create: `assemblyai_cli/code_gen/stream.py` +- Modify: `assemblyai_cli/code_gen/__init__.py` +- Test: `tests/test_code_gen.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_code_gen.py`: + +```python +def test_stream_render_parses_and_is_runnable_shape(): + from assemblyai.streaming.v3 import SpeechModel + + code = code_gen.stream( + {"sample_rate": 16000, "format_turns": True, "speech_model": SpeechModel.u3_rt_pro} + ) + ast.parse(code) + assert "StreamingClient(" in code + assert "StreamingParameters(" in code + assert "SpeechModel.u3_rt_pro" in code + assert "MicrophoneStream" in code + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_code_gen.py -k stream_render -v` +Expected: FAIL — `module 'assemblyai_cli.code_gen' has no attribute 'stream'`. + +- [ ] **Step 3: Implement the renderer** + +Create `assemblyai_cli/code_gen/stream.py`. This emits the canonical microphone-streaming idiom (matching `templates/stream.py.tmpl`) with the actual params injected. File/URL streaming is a CLI convenience, not core SDK usage, so the generated sample always uses the microphone. + +```python +from __future__ import annotations + +from assemblyai_cli.code_gen import serialize + +_HEADER = '''import os + +import assemblyai as aai +from assemblyai.streaming.v3 import ( + SpeechModel, + StreamingClient, + StreamingClientOptions, + StreamingEvents, + StreamingParameters, + TurnEvent, +) + +# Export your key first: export ASSEMBLYAI_API_KEY="" +API_KEY = os.environ["ASSEMBLYAI_API_KEY"] +aai.settings.api_key = API_KEY + + +def on_turn(client: StreamingClient, event: TurnEvent) -> None: + print(event.transcript, end="\\r", flush=True) + if event.end_of_turn: + print() + + +client = StreamingClient( + StreamingClientOptions(api_key=API_KEY, api_host="streaming.assemblyai.com") +) +client.on(StreamingEvents.Turn, on_turn) +''' + +_FOOTER = ''' +print("Listening… press Ctrl-C to stop.") +try: + client.stream(aai.extras.MicrophoneStream(sample_rate=16000)) +finally: + client.disconnect(terminate=True) +''' + + +def render(merged: dict[str, object]) -> str: + """Generate a runnable microphone-streaming script with the given params.""" + if merged: + kwargs = "\n".join(serialize.config_kwarg_lines(merged, indent=8)) + connect = f"client.connect(\n StreamingParameters(\n{kwargs}\n )\n)" + else: + connect = "client.connect(StreamingParameters())" + return _HEADER + "\n" + connect + "\n" + _FOOTER +``` + +Add to `assemblyai_cli/code_gen/__init__.py`: + +```python +from assemblyai_cli.code_gen import stream as _stream +``` + +and the public function: + +```python +def stream(merged: dict[str, object]) -> str: + return _stream.render(merged) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_code_gen.py -k stream_render -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/code_gen/stream.py assemblyai_cli/code_gen/__init__.py tests/test_code_gen.py +git commit -m "feat(code-gen): render runnable microphone-streaming scripts" +``` + +--- + +## Task 7: Wire `--show-code` into the stream command + +**Files:** +- Modify: `assemblyai_cli/commands/stream.py:9,94-95,150-152` +- Test: `tests/test_stream_command.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_stream_command.py` (reuse existing fixtures/mocks for `client.stream_audio` and auth): + +```python +def test_stream_show_code_prints_python(runner, monkeypatch, ...): + result = runner.invoke(app, ["stream", "--sample", "--show-code"]) + assert result.exit_code == 0 + assert "StreamingClient(" in result.stdout + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in result.stdout +``` + +> Fill `...` with whatever the file's existing stream tests use to stub the network/audio path. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_stream_command.py -k show_code -v` +Expected: FAIL — `--show-code` unknown option (exit code 2). + +- [ ] **Step 3: Add the flag and wiring** + +In `assemblyai_cli/commands/stream.py`: + +Add `code_gen` and `output` to the import on line 9: + +```python +from assemblyai_cli import client, code_gen, config, config_builder, llm, output, youtube +``` + +Add the option after `json_out` (line 94): + +```python + show_code: bool = typer.Option( + False, "--show-code", help="Also print the equivalent Python SDK code." + ), +``` + +Replace the build call (lines 150-152) so the merged dict is reused: + +```python + merged = config_builder.merge_streaming_params( + flags=flags, overrides=list(config_kv or []), config_file=config_file + ) + params = config_builder.construct_streaming_params(merged) +``` + +At the end of the `run(...)` inner function, after the LLM-gateway block (after line 181), append: + +```python + if show_code and not json_mode: + try: + rendered = code_gen.stream(merged) + except Exception as exc: # noqa: BLE001 + output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") + else: + output.console.print("\n[dim]# Equivalent Python (microphone streaming):[/dim]") + output.console.print(rendered) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_stream_command.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/stream.py tests/test_stream_command.py +git commit -m "feat(stream): add --show-code to print equivalent SDK code" +``` + +--- + +## Task 8: Agent code renderer (`code_gen/agent.py`) + +**Files:** +- Create: `assemblyai_cli/code_gen/agent.py` +- Modify: `assemblyai_cli/code_gen/__init__.py` +- Test: `tests/test_code_gen.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_code_gen.py`: + +```python +def test_agent_render_parses_and_injects_session_fields(): + code = code_gen.agent(voice="ivy", system_prompt="Be terse.", greeting="Hi there") + ast.parse(code) + assert '"voice": "ivy"' in code + assert "Be terse." in code + assert "Hi there" in code + assert "agents.assemblyai.com" in code + assert 'os.environ["ASSEMBLYAI_API_KEY"]' in code + + +def test_agent_render_escapes_quotes_in_prompt(): + code = code_gen.agent(voice="ivy", system_prompt='Say "hi"', greeting="Hello") + ast.parse(code) # must stay valid Python despite the embedded quotes +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_code_gen.py -k agent_render -v` +Expected: FAIL — `module 'assemblyai_cli.code_gen' has no attribute 'agent'`. + +- [ ] **Step 3: Implement the renderer** + +Create `assemblyai_cli/code_gen/agent.py`. It emits the websocket script (matching `templates/agent.py.tmpl`) with the session fields injected as Python literals (so quotes/newlines are escaped safely via `repr`). + +```python +from __future__ import annotations + +_TEMPLATE = '''import base64 +import json +import os +import threading + +import sounddevice as sd +from websockets.sync.client import connect + +# Export your key first: export ASSEMBLYAI_API_KEY="" +API_KEY = os.environ["ASSEMBLYAI_API_KEY"] +WS_URL = "wss://agents.assemblyai.com/v1/ws" +RATE = 24000 # Voice Agent native PCM16 mono sample rate + +VOICE = {voice} +SYSTEM_PROMPT = {system_prompt} +GREETING = {greeting} + +speaker = sd.RawOutputStream(samplerate=RATE, channels=1, dtype="int16") +speaker.start() +mic = sd.RawInputStream(samplerate=RATE, channels=1, dtype="int16", blocksize=1024) +mic.start() + +ready = threading.Event() + + +def send_mic(ws): + while True: + try: + data, _ = mic.read(1024) + if ready.is_set(): + ws.send(json.dumps({{"type": "input.audio", "audio": base64.b64encode(bytes(data)).decode()}})) + except Exception: + return + + +with connect(WS_URL, additional_headers={{"Authorization": f"Bearer {{API_KEY}}"}}) as ws: + ws.send(json.dumps({{ + "type": "session.update", + "session": {{ + "system_prompt": SYSTEM_PROMPT, + "greeting": GREETING, + "output": {{"voice": VOICE}}, + }}, + }})) + threading.Thread(target=send_mic, args=(ws,), daemon=True).start() + print("Connected — start talking. (Ctrl-C to stop)") + try: + for raw in ws: + event = json.loads(raw) + etype = event.get("type") + if etype == "session.ready": + ready.set() + elif etype == "reply.audio" and event.get("data"): + speaker.write(base64.b64decode(event["data"])) + elif etype == "transcript.user": + print("you: ", event.get("text", "")) + elif etype == "transcript.agent": + print("agent:", event.get("text", "")) + except KeyboardInterrupt: + print("\\nStopped.") + finally: + speaker.stop(); speaker.close(); mic.stop(); mic.close() +''' + + +def render(voice: str, system_prompt: str, greeting: str) -> str: + """Generate a runnable voice-agent script with the given session settings.""" + return _TEMPLATE.format( + voice=repr(voice), system_prompt=repr(system_prompt), greeting=repr(greeting) + ) +``` + +Add to `assemblyai_cli/code_gen/__init__.py`: + +```python +from assemblyai_cli.code_gen import agent as _agent +``` + +and the public function: + +```python +def agent(voice: str, system_prompt: str, greeting: str) -> str: + return _agent.render(voice, system_prompt, greeting) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_code_gen.py -k agent_render -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/code_gen/agent.py assemblyai_cli/code_gen/__init__.py tests/test_code_gen.py +git commit -m "feat(code-gen): render runnable voice-agent scripts" +``` + +--- + +## Task 9: Wire `--show-code` into the agent command + +**Files:** +- Modify: `assemblyai_cli/commands/agent.py:9,42-43,93-104` +- Test: `tests/test_agent_command.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_agent_command.py` (reuse existing fixtures that stub `run_session`/audio): + +```python +def test_agent_show_code_prints_python(runner, monkeypatch, ...): + result = runner.invoke( + app, ["agent", "--sample", "--voice", "ivy", "--show-code"] + ) + assert result.exit_code == 0 + assert "agents.assemblyai.com" in result.stdout + assert '"voice": "ivy"' in result.stdout +``` + +> `--sample` runs the file path (no mic), which the existing agent tests already know how to stub. Fill `...` accordingly. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_agent_command.py -k show_code -v` +Expected: FAIL — `--show-code` unknown option (exit code 2). + +- [ ] **Step 3: Add the flag and wiring** + +In `assemblyai_cli/commands/agent.py`: + +Add `code_gen` and `output` to the import on line 9: + +```python +from assemblyai_cli import client, code_gen, config, output +``` + +Add the option after `json_out` (line 42): + +```python + show_code: bool = typer.Option( + False, "--show-code", help="Also print the equivalent Python SDK code." + ), +``` + +In the `finally` of the session block, after `renderer.close()` (after line 112), print the code. Use the resolved `system_prompt_text`, `voice`, and `greeting`: + +```python + if show_code and not json_mode: + try: + rendered = code_gen.agent(voice, system_prompt_text, greeting) + except Exception as exc: # noqa: BLE001 + output.console.print(f"[dim]# could not render sample code: {exc}[/dim]") + else: + output.console.print("\n[dim]# Equivalent Python (microphone agent):[/dim]") + output.console.print(rendered) +``` + +> Place this OUTSIDE the `try/except/finally` (after it), so it prints once the session ends normally or via Ctrl-C — not on every loop iteration. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_agent_command.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/commands/agent.py tests/test_agent_command.py +git commit -m "feat(agent): add --show-code to print equivalent SDK code" +``` + +--- + +## Task 10: Exhaustive validity & fidelity harness + +This is the task that delivers the guarantee: **no invalid code can be generated without a test failing.** It fuzzes the entire config domain through every renderer and `compile()`s the output, and execs result-handling against a stub transcript. + +**Files:** +- Test: `tests/test_code_gen.py` (uses `merged_strategy` defined in Task 2) + +- [ ] **Step 1: Write the fuzz-compiles tests** + +Append to `tests/test_code_gen.py`: + +```python +import textwrap + + +def _compiles(code: str) -> None: + # compile() is stricter than ast.parse() and is what `python file.py` runs through. + compile(code, "", "exec") + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_fuzz_transcribe_always_compiles(merged): + _compiles(code_gen.transcribe(merged, source="audio.mp3")) + + +@given(merged_strategy(config_builder.STREAM_COERCE)) +def test_fuzz_stream_always_compiles(merged): + _compiles(code_gen.stream(merged)) + + +@given( + voice=st.text(st.characters(blacklist_categories=("Cs",)), max_size=20), + system_prompt=st.text(st.characters(blacklist_categories=("Cs",)), max_size=200), + greeting=st.text(st.characters(blacklist_categories=("Cs",)), max_size=200), +) +def test_fuzz_agent_always_compiles(voice, system_prompt, greeting): + # Arbitrary text (quotes, newlines, backslashes, unicode) must never break the script. + _compiles(code_gen.agent(voice=voice, system_prompt=system_prompt, greeting=greeting)) + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_fuzz_transcribe_config_round_trips_in_generated_code(merged): + # The TranscriptionConfig(...) the code builds must equal the original merged dict. + code = code_gen.transcribe(merged, source="audio.mp3") + if not merged: + assert "TranscriptionConfig(" not in code + return + # Extract the kwargs block and eval it back to a dict. + # repr() escapes newlines, so no kwarg line contains a literal "\n)"; the first + # "\n)" after the constructor opens is always the config block's closer. + inner = code.split("aai.TranscriptionConfig(\n", 1)[1].split("\n)", 1)[0] + rebuilt = eval("dict(\n" + inner + "\n)", {"SpeechModel": SpeechModel}) + assert rebuilt == merged +``` + +- [ ] **Step 2: Write the result-handling-execs test** + +The stub exposes every attribute the snippets touch, so a typo'd attribute name in any snippet raises here. Append: + +```python +class _Stub: + """A transcript-shaped stub exposing every attribute the snippets read.""" + + text = "hello world" + utterances = [type("U", (), {"speaker": "A", "text": "hi"})()] + summary = "a summary" + chapters = [type("C", (), {"headline": "intro"})()] + auto_highlights = type("H", (), {"results": [type("R", (), {"count": 2, "text": "k"})()]})() + sentiment_analysis = [type("S", (), {"sentiment": "POSITIVE", "text": "good"})()] + entities = [type("E", (), {"entity_type": "person_name", "text": "Ada"})()] + iab_categories = type("I", (), {"summary": {"Tech": 0.9}})() + content_safety = type("CS", (), {"summary": {"profanity": 0.1}})() + + +def test_every_snippet_execs_against_a_realistic_transcript(): + # Enable every feature so result_handling emits all snippets, then exec them. + all_on = { + "speaker_labels": True, "summarization": True, "auto_chapters": True, + "auto_highlights": True, "sentiment_analysis": True, "entity_detection": True, + "iab_categories": True, "content_safety": True, + } + body = snippets.result_handling(all_on) + exec(compile(body, "", "exec"), {"transcript": _Stub()}) + + +@given(merged_strategy(config_builder.TRANSCRIBE_COERCE)) +def test_fuzz_result_handling_always_execs(merged): + body = snippets.result_handling(merged) + exec(compile(body, "", "exec"), {"transcript": _Stub(), "getattr": getattr}) +``` + +- [ ] **Step 3: Run the harness** + +Run: `uv run pytest tests/test_code_gen.py -v` +Expected: PASS. (If any snippet references an attribute the SDK does not expose, the exec tests fail — fix the snippet in `snippets.py`.) + +- [ ] **Step 4: Tighten hypothesis settings (optional but recommended)** + +If the fuzz tests are slow, add a profile at the top of `tests/test_code_gen.py`: + +```python +from hypothesis import settings + +settings.register_profile("codegen", max_examples=200) +settings.load_profile("codegen") +``` + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_code_gen.py +git commit -m "test(code-gen): exhaustive fuzz-compiles + round-trip + exec harness" +``` + +--- + +## Task 11: Document `--show-code` in the README + +**Files:** +- Modify: `README.md` (Commands table + a short section) + +- [ ] **Step 1: Update the commands table** + +In the `aai transcribe` row of the Commands table, add `--show-code` to the parenthetical of flags, e.g. change the cell to mention `--show-code` prints the equivalent Python. + +- [ ] **Step 2: Add a short section** + +After the "Streaming" section, add: + +```markdown +## Show the code + +Add `--show-code` to `transcribe`, `stream`, or `agent` to print the equivalent +Python SDK code after the command runs — a ready-to-edit starting point for your +own app: + +```sh +aai transcribe --sample --speaker-labels --show-code +``` + +The generated code reads your key from `ASSEMBLYAI_API_KEY` (it never prints your +actual key) and includes result handling for the analysis features you enabled. +``` + +- [ ] **Step 3: Verify markdown lint passes** + +Run: `uv run pre-commit run markdownlint --files README.md` (or the project's `check.sh`). +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs(readme): document --show-code" +``` + +--- + +## Final verification + +- [ ] **Run the full suite + lint gates** + +Run: `./scripts/check.sh` (or `uv run pytest && uv run ruff check . && uv run ruff format --check .`) +Expected: PASS — all tests, lint, and format clean. + +- [ ] **Manual smoke (optional, needs a key)** + +Run: `uv run aai transcribe --sample --sentiment-analysis --show-code` +Expected: normal transcript output, then a `# Equivalent Python:` block that parses and uses `os.environ["ASSEMBLYAI_API_KEY"]`. diff --git a/docs/superpowers/plans/2026-06-04-aai-account-self-service-commands.md b/docs/superpowers/plans/2026-06-04-aai-account-self-service-commands.md new file mode 100644 index 00000000..1f8efe6d --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-aai-account-self-service-commands.md @@ -0,0 +1,1676 @@ +# AMS Account Self-Service Commands 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 account self-service commands to `aai` — `keys` (ls/create/rename), `balance`, `usage`, `limits`, and `sessions` (ls/get) — backed by the AMS API, plus enrich `whoami`. + +**Architecture:** Every AMS user endpoint authenticates with the browser-login **Stytch `session_jwt` cookie**, not the API key (verified against the OpenAPI spec — there is no API-key/Bearer scheme). Today `aai login` mints an API key from the session and discards the session. This plan persists the session (`session_jwt` + `session_token` in the keyring, `account_id` in `config.toml`) at browser-login time, exposes new functions on the existing `aai_cli/auth/ams.py` client, adds a `resolve_session()` helper, and adds three command modules that follow the established `transcripts`/`login` patterns. There is **no session-refresh path** in AMS and the CLI holds only Stytch's *public* token, so on an expired session (401) commands raise `NotAuthenticated` telling the user to run `aai login` again. No destructive/delete commands. + +**Tech Stack:** Python 3.10+, Typer, Rich, httpx, keyring, pytest, `typer.testing.CliRunner`. + +--- + +## Auth & API facts (verified against the AMS OpenAPI spec) + +These shapes are used verbatim in the tasks below. Do not re-derive them. + +- **Auth:** all endpoints below take a `stytch_session_jwt` **cookie**. No Bearer/API-key option. +- `POST /v2/auth/exchange` → `SignedInResponse { account: UserAccountResponse, session_jwt: str, session_token: str }`. +- `GET /v1/auth` → `UserAccountResponse` (free-form object; `account["id"]` is the integer account id). +- `GET /v1/users/accounts/{account_id}/projects` → array of `ProjectDetailResponse { project: ProjectSchema, tokens: [TokenSchema] }`. + - `ProjectSchema { id:int, account_id:int, name:str, is_disabled:bool, created:str, updated:str }` + - `TokenSchema { id:int, project_id:int, api_key:str, name:str, is_disabled:bool, created:str, updated:str }` +- `POST /v1/users/accounts/{account_id}/tokens` body `CreateTokenRequest { project_id:int, token_name:str }` → `TokenSchema` (incl. `api_key`). +- `PUT /v1/users/accounts/{account_id}/tokens/{token_id}` body `RenameTokenRequest { token_name:str }` → **`200` with empty `{}` body** (do not call `.json()` blindly). +- `GET /v2/billing/balance` → `GetBalanceResponse { account_id:int, metronome_customer_id:str, balance_in_cents:number }`. +- `POST /v2/billing/usage` body `GetUsageRequest { starting_on:str, ending_before:str, window_size?:str }` → `GetUsageResponse { usage_items: [UsageWindow] }`. + - `UsageWindow { start_timestamp:str, end_timestamp:str, total:number, line_items:[UsageLineItem] }` +- `GET /v1/users/accounts/{account_id}/rate-limits` → `AccountRateLimitsResponse { rate_limits: [AccountRateLimitsDTO] }`. + - `AccountRateLimitsDTO { account_id:int, service:str, magnitude:number, created:str, updated:str }` +- `GET /v1/users/streaming` (query: `status, limit, offset, project_id, token_id, start, end, …`) → `{ page_details: PageDetailSchema, data: [StreamingSessionSchema] }`. +- `GET /v1/users/streaming/{session_id}` → `StreamingSessionSchema { session_id:str, project_id:int, token_id:int, status:enum(created|completed|error), region?, created_at?, completed_at?, audio_duration_sec?, session_duration_sec?, speech_model?, language_code?, error?, … }`. +- `GET /v2/user/audit-logs` (query: `action_taken, resource_type, actor_type, actor_id, start, end, limit, offset, account_id`) → `ListResponse_AuditLogResponse_ { page_details: PageDetailSchema, data: [AuditLogResponse] }`. + - `AuditLogResponse { id:int, log_time:str, actor_type:str, actor_id?:int, action_taken:str, resource_type?:str, resource_id?:str, account_id?:int, old_values?:object, new_values?:object, metadata?:object }` + +--- + +## File Structure + +**Modify:** +- `aai_cli/config.py` — add session/account-id persistence (`set_session`, `get_session`, `get_account_id`, `clear_session`). +- `aai_cli/auth/flow.py` — `run_login_flow()` returns a `LoginResult` dataclass (api key + session material) instead of a bare key string. +- `aai_cli/auth/ams.py` — add `_raise_for_error` helper + new endpoint functions (`get_balance`, `get_usage`, `get_rate_limits`, `rename_token`, `list_streaming`, `get_streaming`, `list_audit_logs`). +- `aai_cli/commands/login.py` — persist session at browser login; clear it on logout; enrich `whoami`. +- `aai_cli/context.py` — add `resolve_session()` shared by the new commands. +- `aai_cli/main.py` — register the three new typer apps and update `_COMMAND_ORDER`. +- `README.md` — document the new commands. +- `tests/test_login.py` — update mocks for the new `run_login_flow()` return type. + +**Create:** +- `aai_cli/commands/keys.py` — `keys` group: `list`, `create`, `rename`. +- `aai_cli/commands/account.py` — top-level `balance`, `usage`, `limits`. +- `aai_cli/commands/sessions.py` — `sessions` group: `list`, `get`. +- `aai_cli/commands/audit.py` — top-level `audit` (lists the current user's audit log). +- `tests/test_config_session.py`, `tests/test_ams_account.py`, `tests/test_keys.py`, `tests/test_account_command.py`, `tests/test_sessions_command.py`, `tests/test_audit_command.py`. + +--- + +## Task 1: Session persistence in config + +**Files:** +- Modify: `aai_cli/config.py` +- Test: `tests/test_config_session.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_config_session.py`: + +```python +from aai_cli import config + + +def test_set_and_get_session_roundtrips(): + config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) + assert config.get_session("default") == {"jwt": "jwt_1", "token": "tok_1"} + assert config.get_account_id("default") == 42 + + +def test_get_session_none_when_absent(): + assert config.get_session("default") is None + assert config.get_account_id("default") is None + + +def test_clear_session_removes_jwt_and_account_id(): + config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) + config.clear_session("default") + assert config.get_session("default") is None + assert config.get_account_id("default") is None + + +def test_clear_session_is_safe_when_absent(): + config.clear_session("never-logged-in") # must not raise + + +def test_get_session_returns_none_for_corrupt_blob(): + import keyring + + keyring.set_password(config.KEYRING_SERVICE, "session:default", "not-json") + assert config.get_session("default") is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_config_session.py -v` +Expected: FAIL with `AttributeError: module 'aai_cli.config' has no attribute 'set_session'`. + +- [ ] **Step 3: Implement session persistence** + +In `aai_cli/config.py`, add `import json` to the imports (top of file, after `import contextlib`). Then add these functions after `clear_api_key` (around line 101): + +```python +SESSION_KEYRING_PREFIX = "session" # keyring username: f"{prefix}:{profile}" + + +def _session_username(profile: str) -> str: + return f"{SESSION_KEYRING_PREFIX}:{profile}" + + +def set_session(profile: str, *, session_jwt: str, session_token: str, account_id: int) -> None: + """Persist the browser-login Stytch session (secret) + account id (non-secret). + + AMS self-service endpoints authenticate with this session cookie, not the API + key. The JWT is short-lived; an expired session surfaces as NotAuthenticated. + """ + _validate_profile(profile) + keyring.set_password( + KEYRING_SERVICE, + _session_username(profile), + json.dumps({"jwt": session_jwt, "token": session_token}), + ) + data = _load() + data.setdefault("profiles", {}).setdefault(profile, {})["account_id"] = account_id + _dump(data) + + +def get_session(profile: str) -> dict[str, str] | None: + """The stored {'jwt', 'token'} for a profile, or None if absent/corrupt.""" + raw = keyring.get_password(KEYRING_SERVICE, _session_username(profile)) + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(data, dict) or "jwt" not in data: + return None + return data + + +def get_account_id(profile: str) -> int | None: + """The AMS account id recorded at login for a profile, if any.""" + value = _load().get("profiles", {}).get(profile, {}).get("account_id") + return int(value) if value is not None else None + + +def clear_session(profile: str) -> None: + with contextlib.suppress(keyring.errors.PasswordDeleteError): + keyring.delete_password(KEYRING_SERVICE, _session_username(profile)) + data = _load() + prof = data.get("profiles", {}).get(profile) + if prof and "account_id" in prof: + del prof["account_id"] + _dump(data) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_config_session.py -v` +Expected: PASS (5 passed). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/config.py tests/test_config_session.py +git commit -m "feat(config): persist Stytch session + account id per profile" +``` + +--- + +## Task 2: `run_login_flow()` returns session material + +**Files:** +- Modify: `aai_cli/auth/flow.py` +- Test: `tests/test_auth_flow.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_auth_flow.py`: + +```python +def test_run_login_flow_returns_session_material(monkeypatch): + from aai_cli.auth import flow + + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: flow.loopback.CallbackResult( + token="tok", token_type="discovery_oauth", error=None + ), + ) + monkeypatch.setattr( + flow.ams, "discover", + lambda token: { + "organizations": [{"organization_id": "org_1"}], + "intermediate_session_token": "ist_1", + }, + ) + monkeypatch.setattr( + flow.ams, "exchange", + lambda ist, org: {"session_jwt": "jwt_1", "session_token": "tok_1", + "account": {"id": 99}}, + ) + monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 99}) + monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_key") + + result = flow.run_login_flow() + assert result.api_key == "sk_key" + assert result.session_jwt == "jwt_1" + assert result.session_token == "tok_1" + assert result.account_id == 99 +``` + +(If `tests/test_auth_flow.py` uses different existing names for `CallbackResult`, match the existing import there; the field names `token`/`token_type`/`error` come from `loopback.CallbackResult` used in `flow.py`.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_auth_flow.py::test_run_login_flow_returns_session_material -v` +Expected: FAIL with `AttributeError: 'str' object has no attribute 'api_key'`. + +- [ ] **Step 3: Implement the dataclass return** + +In `aai_cli/auth/flow.py`, add `from dataclasses import dataclass` to the imports, then add the dataclass after the imports and rewrite `run_login_flow`: + +```python +@dataclass +class LoginResult: + """Everything a successful browser login yields: a usable API key plus the + Stytch session the AMS self-service commands authenticate with.""" + + api_key: str + session_jwt: str + session_token: str + account_id: int +``` + +Replace the end of `run_login_flow` (from `signed_in = ...` onward) with: + +```python + signed_in = ams.exchange(disc["intermediate_session_token"], organization_id) + session_jwt = signed_in["session_jwt"] + session_token = signed_in["session_token"] + + account = ams.get_auth(session_jwt) + account_id = int(account["id"]) + api_key = find_or_create_cli_key(account_id, session_jwt) + return LoginResult( + api_key=api_key, + session_jwt=session_jwt, + session_token=session_token, + account_id=account_id, + ) +``` + +Update the docstring's return line to: `"""Drive the full browser + AMS login and return a LoginResult."""`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_auth_flow.py -v` +Expected: PASS (existing flow tests + the new one). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/auth/flow.py tests/test_auth_flow.py +git commit -m "refactor(auth): run_login_flow returns LoginResult with session" +``` + +--- + +## Task 3: Persist session at login, clear on logout + +**Files:** +- Modify: `aai_cli/commands/login.py` +- Test: `tests/test_login.py` + +- [ ] **Step 1: Update existing tests + add new ones** + +In `tests/test_login.py`, the existing OAuth tests mock `run_login_flow` to return a bare string — that no longer matches. Replace those mocks and add coverage. Replace the bodies of `test_login_oauth_flow_stores_returned_key`, `test_login_api_key_flag_still_bypasses_oauth`, `test_login_binds_env_to_profile`, and `test_sandbox_flag_is_shortcut_for_env` so each `run_login_flow` mock returns a `LoginResult`. Add a shared helper at the top of the file (after `runner = CliRunner()`): + +```python +from aai_cli.auth.flow import LoginResult + + +def _fake_login_result(key="sk_from_oauth"): + return LoginResult(api_key=key, session_jwt="jwt_x", session_token="tok_x", account_id=7) +``` + +Then change each affected mock from `lambda: "sk_from_oauth"` (or `"sk_x"`) to `lambda: _fake_login_result()` (or `_fake_login_result("sk_x")`). For `test_login_api_key_flag_still_bypasses_oauth`, keep the `throw` lambda unchanged (it must still not run). + +Add these new tests: + +```python +def test_login_oauth_persists_session(monkeypatch): + monkeypatch.setattr("aai_cli.commands.login.run_login_flow", _fake_login_result) + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert config.get_session("default") == {"jwt": "jwt_x", "token": "tok_x"} + assert config.get_account_id("default") == 7 + + +def test_login_api_key_flag_does_not_persist_session(): + with patch("aai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["login", "--api-key", "sk_flag"]) + assert result.exit_code == 0 + assert config.get_session("default") is None + + +def test_logout_clears_session(monkeypatch): + config.set_api_key("default", "sk_1234567890") + config.set_session("default", session_jwt="j", session_token="t", account_id=7) + result = runner.invoke(app, ["logout"]) + assert result.exit_code == 0 + assert config.get_session("default") is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_login.py -v` +Expected: FAIL — new tests fail (session not persisted/cleared) and the `LoginResult` import path is exercised. + +- [ ] **Step 3: Implement persistence in login.py** + +In `aai_cli/commands/login.py`, rewrite the `login` command body (the `else` branch + persistence) so the browser path stores the session: + +```python + def body(state: AppState, json_mode: bool) -> None: + profile = resolve_profile(state) + env = environments.active().name + if api_key: + # Non-interactive escape hatch for CI/automation: no AMS session is + # obtained, so account self-service commands won't work for this profile. + if not client.validate_key(api_key): + raise APIError("That API key was rejected (HTTP 401). Check it and retry.") + config.set_api_key(profile, api_key) + config.set_profile_env(profile, env) + else: + result = run_login_flow() + config.set_api_key(profile, result.api_key) + config.set_profile_env(profile, env) + config.set_session( + profile, + session_jwt=result.session_jwt, + session_token=result.session_token, + account_id=result.account_id, + ) + output.emit( + {"authenticated": True, "profile": profile, "env": env}, + lambda _d: f"[aai.success]Authenticated[/aai.success] on profile " + f"'{escape(profile)}' (env: {escape(env)}).", + json_mode=json_mode, + ) +``` + +In the `logout` command body, add session clearing after `config.clear_api_key(profile)`: + +```python + config.clear_api_key(profile) + config.clear_session(profile) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_login.py -v` +Expected: PASS (all login tests). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/login.py tests/test_login.py +git commit -m "feat(auth): persist AMS session at browser login, clear on logout" +``` + +--- + +## Task 4: `resolve_session()` helper + +**Files:** +- Modify: `aai_cli/context.py` +- Test: `tests/test_context.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_context.py`: + +```python +def test_resolve_session_returns_account_and_jwt(): + from aai_cli import config + from aai_cli.context import AppState, resolve_session + + config.set_session("default", session_jwt="jwt_1", session_token="tok_1", account_id=42) + account_id, jwt = resolve_session(AppState()) + assert account_id == 42 + assert jwt == "jwt_1" + + +def test_resolve_session_raises_when_no_session(): + from aai_cli.context import AppState, resolve_session + from aai_cli.errors import NotAuthenticated + + import pytest + + with pytest.raises(NotAuthenticated): + resolve_session(AppState()) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_context.py -v` +Expected: FAIL with `ImportError: cannot import name 'resolve_session'`. + +- [ ] **Step 3: Implement the helper** + +In `aai_cli/context.py`, add after `resolve_environment`: + +```python +def resolve_session(state: AppState) -> tuple[int, str]: + """Account id + Stytch session JWT for AMS self-service commands. + + These endpoints authenticate with the browser-login session, not the API + key. Raises NotAuthenticated when the active profile has no stored session + (e.g. it was set up with `aai login --api-key`, or the session expired). + """ + from aai_cli.errors import NotAuthenticated + + profile = resolve_profile(state) + session = config.get_session(profile) + account_id = config.get_account_id(profile) + if session is None or account_id is None: + raise NotAuthenticated( + "These commands need a browser login. Run 'aai login' (without --api-key)." + ) + return account_id, session["jwt"] +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_context.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/context.py tests/test_context.py +git commit -m "feat(context): add resolve_session helper for AMS commands" +``` + +--- + +## Task 5: AMS client functions + +**Files:** +- Modify: `aai_cli/auth/ams.py` +- Test: `tests/test_ams_account.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_ams_account.py`: + +```python +import httpx +import pytest + +from aai_cli.auth import ams +from aai_cli.errors import NotAuthenticated + + +def _patch_transport(monkeypatch, handler): + real_client = httpx.Client + + def fake_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + monkeypatch.setattr(ams.httpx, "Client", fake_client) + + +def test_get_balance_sends_cookie_and_parses(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["cookie"] = request.headers.get("cookie", "") + return httpx.Response(200, json={"account_id": 1, "balance_in_cents": 2500}) + + _patch_transport(monkeypatch, handler) + out = ams.get_balance("jwt_1") + assert out["balance_in_cents"] == 2500 + assert "/v2/billing/balance" in seen["url"] + assert "stytch_session_jwt=jwt_1" in seen["cookie"] + + +def test_get_usage_posts_date_range(monkeypatch): + seen = {} + + def handler(request): + seen["body"] = request.read().decode() + return httpx.Response(200, json={"usage_items": []}) + + _patch_transport(monkeypatch, handler) + ams.get_usage("jwt", "2026-05-01", "2026-06-01", "day") + assert "2026-05-01" in seen["body"] and "2026-06-01" in seen["body"] + assert "day" in seen["body"] + + +def test_get_rate_limits_uses_account_path(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + return httpx.Response(200, json={"rate_limits": []}) + + _patch_transport(monkeypatch, handler) + ams.get_rate_limits(42, "jwt") + assert "/v1/users/accounts/42/rate-limits" in seen["url"] + + +def test_rename_token_puts_name_and_tolerates_empty_body(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["method"] = request.method + seen["body"] = request.read().decode() + return httpx.Response(200, content=b"") # empty body, not JSON + + _patch_transport(monkeypatch, handler) + ams.rename_token(42, 7, "prod", "jwt") # must not raise + assert seen["method"] == "PUT" + assert "/v1/users/accounts/42/tokens/7" in seen["url"] + assert "prod" in seen["body"] + + +def test_list_streaming_passes_filters(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + return httpx.Response(200, json={"page_details": {}, "data": []}) + + _patch_transport(monkeypatch, handler) + ams.list_streaming("jwt", limit=5, status="completed") + assert "limit=5" in seen["url"] + assert "status=completed" in seen["url"] + + +def test_get_streaming_by_id(monkeypatch): + def handler(request): + assert "/v1/users/streaming/s_1" in str(request.url) + return httpx.Response(200, json={"session_id": "s_1", "status": "completed"}) + + _patch_transport(monkeypatch, handler) + out = ams.get_streaming("s_1", "jwt") + assert out["session_id"] == "s_1" + + +def test_account_4xx_raises_not_authenticated(monkeypatch): + def handler(request): + return httpx.Response(401, json={"detail": "expired"}) + + _patch_transport(monkeypatch, handler) + with pytest.raises(NotAuthenticated): + ams.get_balance("jwt") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_ams_account.py -v` +Expected: FAIL with `AttributeError: module 'aai_cli.auth.ams' has no attribute 'get_balance'`. + +- [ ] **Step 3: Implement the new client functions** + +In `aai_cli/auth/ams.py`, first refactor the error check out of `_json_or_raise` so non-JSON responses (the empty `rename` body) can reuse it. Replace `_json_or_raise` with: + +```python +def _raise_for_error(resp: httpx.Response) -> None: + if resp.status_code in (401, 403): + raise NotAuthenticated(f"AMS rejected the login ({resp.status_code}): {_detail(resp)}") + if resp.status_code >= 400: + raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}") + + +def _json_or_raise(resp: httpx.Response) -> Any: + _raise_for_error(resp) + return resp.json() +``` + +Then append these functions at the end of the file: + +```python +def get_balance(session_jwt: str) -> dict[str, Any]: + """GET /v2/billing/balance -> {account_id, balance_in_cents, ...}.""" + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get("/v2/billing/balance") + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_usage( + session_jwt: str, + starting_on: str, + ending_before: str, + window_size: str | None = None, +) -> dict[str, Any]: + """POST /v2/billing/usage (ISO dates) -> {usage_items: [...]}.""" + body: dict[str, Any] = {"starting_on": starting_on, "ending_before": ending_before} + if window_size: + body["window_size"] = window_size + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.post("/v2/billing/usage", json=body) + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_rate_limits(account_id: int, session_jwt: str) -> dict[str, Any]: + """GET /v1/users/accounts/{id}/rate-limits -> {rate_limits: [...]}.""" + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get(f"/v1/users/accounts/{account_id}/rate-limits") + return cast(dict[str, Any], _json_or_raise(resp)) + + +def rename_token(account_id: int, token_id: int, token_name: str, session_jwt: str) -> None: + """PUT /v1/users/accounts/{id}/tokens/{token_id}; 200 returns an empty body.""" + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.put( + f"/v1/users/accounts/{account_id}/tokens/{token_id}", + json={"token_name": token_name}, + ) + _raise_for_error(resp) + + +def list_streaming(session_jwt: str, **filters: Any) -> dict[str, Any]: + """GET /v1/users/streaming -> {page_details, data: [StreamingSessionSchema]}.""" + params = {k: v for k, v in filters.items() if v is not None} + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get("/v1/users/streaming", params=params) + return cast(dict[str, Any], _json_or_raise(resp)) + + +def get_streaming(session_id: str, session_jwt: str) -> dict[str, Any]: + """GET /v1/users/streaming/{session_id} -> StreamingSessionSchema.""" + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get(f"/v1/users/streaming/{session_id}") + return cast(dict[str, Any], _json_or_raise(resp)) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_ams_account.py tests/test_auth_ams.py -v` +Expected: PASS (new tests + existing ams tests still green after the `_json_or_raise` refactor). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/auth/ams.py tests/test_ams_account.py +git commit -m "feat(ams): add balance, usage, rate-limits, streaming, rename client fns" +``` + +--- + +## Task 6: `keys` command (list / create / rename) + +**Files:** +- Create: `aai_cli/commands/keys.py` +- Test: `tests/test_keys.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_keys.py`: + +```python +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def test_keys_list_flattens_tokens(): + _auth() + projects = [ + { + "project": {"id": 1, "name": "Default"}, + "tokens": [ + {"id": 10, "name": "ci", "api_key": "sk_abcdef1234", "is_disabled": False} + ], + } + ] + with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects): + result = runner.invoke(app, ["keys", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data[0]["id"] == 10 + assert "sk_abcdef1234" not in result.output # api key is masked + + +def test_keys_list_requires_session(): + result = runner.invoke(app, ["keys", "list"]) + assert result.exit_code == 2 + + +def test_keys_create_prints_new_key(): + _auth() + projects = [{"project": {"id": 1, "name": "Default"}, "tokens": []}] + created = {"id": 11, "project_id": 1, "name": "ci", "api_key": "sk_newkey9999", + "is_disabled": False} + with patch("aai_cli.commands.keys.ams.list_projects", return_value=projects), patch( + "aai_cli.commands.keys.ams.create_token", return_value=created + ) as create: + result = runner.invoke(app, ["keys", "create", "--name", "ci"]) + assert result.exit_code == 0 + assert "sk_newkey9999" in result.output + create.assert_called_once_with(42, 1, "ci", "jwt") + + +def test_keys_rename_calls_ams(): + _auth() + with patch("aai_cli.commands.keys.ams.rename_token") as rename: + result = runner.invoke(app, ["keys", "rename", "10", "prod"]) + assert result.exit_code == 0 + rename.assert_called_once_with(42, 10, "prod", "jwt") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_keys.py -v` +Expected: FAIL — `keys` is not a registered command (Typer exits non-zero / usage error). + +- [ ] **Step 3: Create the command module** + +Create `aai_cli/commands/keys.py`: + +```python +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table + +from aai_cli import output +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command +from aai_cli.errors import APIError + +app = typer.Typer(help="List, create, and rename your AssemblyAI API keys.", no_args_is_help=True) + + +def _mask(key: str) -> str: + return f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" + + +@app.command(name="list") +def list_( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List API keys across your projects (keys shown masked).""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + projects = ams.list_projects(account_id, jwt) + rows: list[dict[str, object]] = [] + for entry in projects: + project_name = entry["project"]["name"] + for token in entry.get("tokens", []): + rows.append( + { + "id": token["id"], + "name": token["name"], + "project": project_name, + "key": _mask(str(token["api_key"])), + "disabled": token["is_disabled"], + } + ) + + def render(data: list[dict[str, object]]) -> Table: + table = Table("id", "name", "project", "key", "disabled", header_style="aai.heading") + for row in data: + table.add_row( + str(row["id"]), + escape(str(row["name"])), + escape(str(row["project"])), + escape(str(row["key"])), + "yes" if row["disabled"] else "no", + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def create( + ctx: typer.Context, + name: str = typer.Option(..., "--name", help="A label for the new key."), + project_id: int = typer.Option( + None, "--project", help="Project id to create the key in (defaults to your first)." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Create a new API key. Prints the key value once — copy it now.""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + pid = project_id + if pid is None: + projects = ams.list_projects(account_id, jwt) + if not projects: + raise APIError("Your account has no project to create a key in.") + pid = projects[0]["project"]["id"] + created = ams.create_token(account_id, pid, name, jwt) + output.emit( + created, + lambda d: f"Created key '[aai.success]{escape(name)}[/aai.success]': " + f"{escape(str(d['api_key']))}", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def rename( + ctx: typer.Context, + token_id: int = typer.Argument(..., help="The key id (see `aai keys list`)."), + new_name: str = typer.Argument(..., help="The new label."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Rename an existing API key.""" + + def body(state: AppState, json_mode: bool) -> None: + account_id, jwt = resolve_session(state) + ams.rename_token(account_id, token_id, new_name, jwt) + output.emit( + {"id": token_id, "name": new_name}, + lambda d: f"Renamed key {d['id']} to '{escape(new_name)}'.", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Register and run tests** + +Add to `aai_cli/main.py` imports (inside the `from aai_cli.commands import (...)` block, keeping alphabetical-ish order): add `keys,` after `doctor,`. Then after `app.add_typer(claude.app, name="claude")` add: + +```python +app.add_typer(keys.app, name="keys") +``` + +Run: `pytest tests/test_keys.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/keys.py aai_cli/main.py tests/test_keys.py +git commit -m "feat(keys): add 'aai keys' list/create/rename commands" +``` + +--- + +## Task 7: `balance`, `usage`, `limits` commands + +**Files:** +- Create: `aai_cli/commands/account.py` +- Test: `tests/test_account_command.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_account_command.py`: + +```python +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def test_balance_formats_dollars(): + _auth() + with patch("aai_cli.commands.account.ams.get_balance", + return_value={"account_id": 42, "balance_in_cents": 2575}): + result = runner.invoke(app, ["balance"]) + assert result.exit_code == 0 + assert "$25.75" in result.output + + +def test_balance_requires_session(): + result = runner.invoke(app, ["balance"]) + assert result.exit_code == 2 + + +def test_usage_defaults_date_range_and_renders(monkeypatch): + _auth() + captured = {} + + def fake_usage(jwt, start, end, window): + captured["start"], captured["end"] = start, end + return {"usage_items": [ + {"start_timestamp": "2026-05-01", "end_timestamp": "2026-05-02", + "total": 12.5, "line_items": []} + ]} + + with patch("aai_cli.commands.account.ams.get_usage", side_effect=fake_usage): + result = runner.invoke(app, ["usage", "--json"]) + assert result.exit_code == 0 + # both bounds are ISO dates (YYYY-MM-DD), defaulted when not passed + assert len(captured["start"]) == 10 and len(captured["end"]) == 10 + data = json.loads(result.output) + assert data["usage_items"][0]["total"] == 12.5 + + +def test_usage_passes_explicit_dates(): + _auth() + with patch("aai_cli.commands.account.ams.get_usage", + return_value={"usage_items": []}) as get_usage: + result = runner.invoke(app, ["usage", "--start", "2026-01-01", "--end", "2026-02-01"]) + assert result.exit_code == 0 + get_usage.assert_called_once_with("jwt", "2026-01-01", "2026-02-01", None) + + +def test_limits_renders_services(): + _auth() + with patch("aai_cli.commands.account.ams.get_rate_limits", + return_value={"rate_limits": [{"service": "transcript", "magnitude": 200}]}): + result = runner.invoke(app, ["limits"]) + assert result.exit_code == 0 + assert "transcript" in result.output and "200" in result.output +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_account_command.py -v` +Expected: FAIL — `balance`/`usage`/`limits` are not registered commands. + +- [ ] **Step 3: Create the command module** + +Create `aai_cli/commands/account.py`: + +```python +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import typer +from rich.markup import escape +from rich.table import Table + +from aai_cli import output +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command + +app = typer.Typer(help="Account billing, usage, and limits.") + + +@app.command() +def balance( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show your remaining account balance.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + data = ams.get_balance(jwt) + cents = data.get("balance_in_cents", 0) or 0 + output.emit( + data, + lambda _d: f"Balance: [aai.success]${cents / 100:,.2f}[/aai.success]", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) + + +@app.command() +def usage( + ctx: typer.Context, + start: str = typer.Option(None, "--start", help="Start date (YYYY-MM-DD). Default: 30d ago."), + end: str = typer.Option(None, "--end", help="End date (YYYY-MM-DD). Default: today."), + window: str = typer.Option(None, "--window", help="Window size, e.g. 'day' or 'month'."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show usage over a date range (defaults to the last 30 days).""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + today = datetime.now(timezone.utc).date() + end_date = end or today.isoformat() + start_date = start or (today - timedelta(days=30)).isoformat() + data = ams.get_usage(jwt, start_date, end_date, window) + + def render(d: dict) -> Table: + table = Table("window start", "window end", "total", header_style="aai.heading") + for item in d.get("usage_items", []): + table.add_row( + escape(str(item["start_timestamp"])), + escape(str(item["end_timestamp"])), + f"{item['total']:,}", + ) + return table + + output.emit(data, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def limits( + ctx: typer.Context, + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show your account's rate limits per service.""" + + 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) -> Table: + table = Table("service", "limit", header_style="aai.heading") + for limit in d.get("rate_limits", []): + table.add_row(escape(str(limit["service"])), f"{limit['magnitude']:,}") + return table + + output.emit(data, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Register and run tests** + +In `aai_cli/main.py`, add `account,` to the `from aai_cli.commands import (...)` block (before `agent,`). After the `app.add_typer(llm.app)` line add: + +```python +app.add_typer(account.app) # balance, usage, limits +``` + +Run: `pytest tests/test_account_command.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/account.py aai_cli/main.py tests/test_account_command.py +git commit -m "feat(account): add 'aai balance', 'aai usage', 'aai limits'" +``` + +--- + +## Task 8: `sessions` command (list / get) + +**Files:** +- Create: `aai_cli/commands/sessions.py` +- Test: `tests/test_sessions_command.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_sessions_command.py`: + +```python +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def test_sessions_list_renders_rows(): + _auth() + payload = {"page_details": {"has_more": False}, + "data": [{"session_id": "s_1", "status": "completed", + "created_at": "2026-06-01", "audio_duration_sec": 12.0, + "speech_model": "universal"}]} + with patch("aai_cli.commands.sessions.ams.list_streaming", return_value=payload): + result = runner.invoke(app, ["sessions", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data[0]["session_id"] == "s_1" + + +def test_sessions_list_passes_status_filter(): + _auth() + with patch("aai_cli.commands.sessions.ams.list_streaming", + return_value={"data": []}) as list_streaming: + result = runner.invoke(app, ["sessions", "list", "--status", "error", "--limit", "5"]) + assert result.exit_code == 0 + list_streaming.assert_called_once_with("jwt", limit=5, status="error") + + +def test_sessions_get_renders_detail(): + _auth() + detail = {"session_id": "s_1", "status": "completed", "speech_model": "universal", + "audio_duration_sec": 30.0, "error": None} + with patch("aai_cli.commands.sessions.ams.get_streaming", return_value=detail): + result = runner.invoke(app, ["sessions", "get", "s_1"]) + assert result.exit_code == 0 + assert "s_1" in result.output and "universal" in result.output + + +def test_sessions_requires_session(): + result = runner.invoke(app, ["sessions", "list"]) + assert result.exit_code == 2 +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_sessions_command.py -v` +Expected: FAIL — `sessions` is not a registered command. + +- [ ] **Step 3: Create the command module** + +Create `aai_cli/commands/sessions.py`: + +```python +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table +from rich.text import Text + +from aai_cli import output, theme +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command + +app = typer.Typer(help="Browse your past streaming (real-time) sessions.", no_args_is_help=True) + +# Fields shown by `sessions get`, in display order. +_DETAIL_FIELDS = ( + "session_id", + "status", + "region", + "created_at", + "completed_at", + "audio_duration_sec", + "session_duration_sec", + "speech_model", + "language_code", + "error", +) + + +@app.command(name="list") +def list_( + ctx: typer.Context, + limit: int = typer.Option(10, "--limit", help="How many sessions to show."), + status: str = typer.Option(None, "--status", help="Filter: created, completed, or error."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List recent streaming sessions.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + payload = ams.list_streaming(jwt, limit=limit, status=status) + rows = payload.get("data", []) + + def render(data: list[dict]) -> Table: + table = Table( + "session id", "status", "created", "audio (s)", "model", + header_style="aai.heading", + ) + for s in data: + status_str = str(s["status"]) + table.add_row( + escape(str(s["session_id"])), + Text(status_str, style=theme.status_style(status_str)), + escape(str(s.get("created_at") or "")), + escape(str(s.get("audio_duration_sec") or "")), + escape(str(s.get("speech_model") or "")), + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) + + +@app.command() +def get( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="Streaming session id."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Show details for one streaming session.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + data = ams.get_streaming(session_id, jwt) + + def render(d: dict) -> Table: + table = Table(show_header=False) + for field in _DETAIL_FIELDS: + value = d.get(field) + table.add_row(field, escape("" if value is None else str(value))) + return table + + output.emit(data, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Register and run tests** + +In `aai_cli/main.py`, add `sessions,` to the `from aai_cli.commands import (...)` block. After the `app.add_typer(transcripts.app, name="transcripts")` line add: + +```python +app.add_typer(sessions.app, name="sessions") +``` + +Run: `pytest tests/test_sessions_command.py -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/sessions.py aai_cli/main.py tests/test_sessions_command.py +git commit -m "feat(sessions): add 'aai sessions' list/get for streaming history" +``` + +--- + +## Task 9: `audit` command + +**Files:** +- Modify: `aai_cli/auth/ams.py` (add `list_audit_logs`) +- Create: `aai_cli/commands/audit.py` +- Test: `tests/test_ams_account.py`, `tests/test_audit_command.py` + +- [ ] **Step 1: Write the failing client test** + +Append to `tests/test_ams_account.py`: + +```python +def test_list_audit_logs_passes_filters(monkeypatch): + seen = {} + + def handler(request): + seen["url"] = str(request.url) + seen["cookie"] = request.headers.get("cookie", "") + return httpx.Response(200, json={"page_details": {}, "data": []}) + + _patch_transport(monkeypatch, handler) + ams.list_audit_logs("jwt", limit=5, action_taken="token.create") + assert "/v2/user/audit-logs" in seen["url"] + assert "limit=5" in seen["url"] + assert "action_taken=token.create" in seen["url"] + assert "stytch_session_jwt=jwt" in seen["cookie"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_ams_account.py::test_list_audit_logs_passes_filters -v` +Expected: FAIL with `AttributeError: module 'aai_cli.auth.ams' has no attribute 'list_audit_logs'`. + +- [ ] **Step 3: Add the client function** + +Append to `aai_cli/auth/ams.py`: + +```python +def list_audit_logs(session_jwt: str, **filters: Any) -> dict[str, Any]: + """GET /v2/user/audit-logs -> {page_details, data: [AuditLogResponse]}.""" + params = {k: v for k, v in filters.items() if v is not None} + with httpx.Client( + base_url=endpoints.ams_base(), + timeout=_TIMEOUT, + cookies={"stytch_session_jwt": session_jwt}, + ) as client: + resp = client.get("/v2/user/audit-logs", params=params) + return cast(dict[str, Any], _json_or_raise(resp)) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_ams_account.py -v` +Expected: PASS. + +- [ ] **Step 5: Write the failing command test** + +Create `tests/test_audit_command.py`: + +```python +import json +from unittest.mock import patch + +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +def _auth(): + config.set_session("default", session_jwt="jwt", session_token="tok", account_id=42) + + +def test_audit_renders_rows(): + _auth() + payload = { + "page_details": {"has_more": False}, + "data": [ + { + "id": 1, + "log_time": "2026-06-01T12:00:00Z", + "actor_type": "member", + "actor_id": 7, + "action_taken": "token.create", + "resource_type": "token", + "resource_id": "10", + } + ], + } + with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): + result = runner.invoke(app, ["audit", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data[0]["action_taken"] == "token.create" + + +def test_audit_passes_filters(): + _auth() + with patch("aai_cli.commands.audit.ams.list_audit_logs", + return_value={"data": []}) as list_logs: + result = runner.invoke(app, ["audit", "--limit", "5", "--action", "token.create"]) + assert result.exit_code == 0 + list_logs.assert_called_once_with("jwt", limit=5, action_taken="token.create") + + +def test_audit_human_mode_renders_table(): + _auth() + payload = {"data": [ + {"id": 1, "log_time": "2026-06-01T12:00:00Z", "actor_type": "member", + "action_taken": "login", "resource_type": None} + ]} + with patch("aai_cli.commands.audit.ams.list_audit_logs", return_value=payload): + result = runner.invoke(app, ["audit"]) + assert result.exit_code == 0 + assert "login" in result.output + + +def test_audit_requires_session(): + result = runner.invoke(app, ["audit"]) + assert result.exit_code == 2 +``` + +- [ ] **Step 6: Run command test to verify it fails** + +Run: `pytest tests/test_audit_command.py -v` +Expected: FAIL — `audit` is not a registered command. + +- [ ] **Step 7: Create the command module** + +Create `aai_cli/commands/audit.py`: + +```python +from __future__ import annotations + +import typer +from rich.markup import escape +from rich.table import Table + +from aai_cli import output +from aai_cli.auth import ams +from aai_cli.context import AppState, resolve_session, run_command + +app = typer.Typer(help="View your account's audit log.") + + +@app.command() +def audit( + ctx: typer.Context, + limit: int = typer.Option(20, "--limit", help="How many entries to show."), + action: str = typer.Option(None, "--action", help="Filter by action_taken."), + resource: str = typer.Option(None, "--resource", help="Filter by resource_type."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List recent audit-log entries for your account.""" + + def body(state: AppState, json_mode: bool) -> None: + _, jwt = resolve_session(state) + payload = ams.list_audit_logs( + jwt, limit=limit, action_taken=action, resource_type=resource + ) + rows = payload.get("data", []) + + def render(data: list[dict]) -> Table: + table = Table( + "time", "actor", "action", "resource", header_style="aai.heading" + ) + for entry in data: + actor = str(entry["actor_type"]) + actor_id = entry.get("actor_id") + if actor_id is not None: + actor = f"{actor}:{actor_id}" + resource_label = entry.get("resource_type") or "" + resource_id = entry.get("resource_id") + if resource_label and resource_id: + resource_label = f"{resource_label}:{resource_id}" + table.add_row( + escape(str(entry["log_time"])), + escape(actor), + escape(str(entry["action_taken"])), + escape(str(resource_label)), + ) + return table + + output.emit(rows, render, json_mode=json_mode) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 8: Register and run tests** + +In `aai_cli/main.py`, add `audit,` to the `from aai_cli.commands import (...)` block. After the `app.add_typer(sessions.app, name="sessions")` line (from Task 8) add: + +```python +app.add_typer(audit.app) # audit +``` + +> NOTE: `audit.app` has a single command named `audit`; registering the sub-typer with no `name=` merges it as the top-level `aai audit` command (same mechanism as `account.app`'s `balance`/`usage`/`limits`). + +Run: `pytest tests/test_audit_command.py tests/test_ams_account.py -v` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add aai_cli/auth/ams.py aai_cli/commands/audit.py aai_cli/main.py tests/test_ams_account.py tests/test_audit_command.py +git commit -m "feat(audit): add 'aai audit' to list account audit-log entries" +``` + +--- + +## Task 10: Enrich `whoami` + finalize help ordering + +**Files:** +- Modify: `aai_cli/commands/login.py` +- Modify: `aai_cli/main.py` (`_COMMAND_ORDER`) +- Test: `tests/test_login.py` + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_login.py`: + +```python +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) + with patch("aai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["whoami", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["account_id"] == 77 + assert data["session"] == "stored" + + +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): + result = runner.invoke(app, ["whoami", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["session"] == "none" + assert data["account_id"] is None +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_login.py::test_whoami_reports_session_and_account -v` +Expected: FAIL with `KeyError: 'account_id'`. + +- [ ] **Step 3: Enrich whoami** + +In `aai_cli/commands/login.py`, update the `whoami` body to include session/account info: + +```python + def body(state: AppState, json_mode: bool) -> None: + profile = resolve_profile(state) + key = config.get_api_key(profile) + if not key: + raise NotAuthenticated() + masked = f"{key[:3]}…{key[-4:]}" if len(key) > 7 else "***" + env = environments.active().name + reachable = client.validate_key(key) + session = config.get_session(profile) + account_id = config.get_account_id(profile) + output.emit( + { + "profile": profile, + "env": env, + "api_key": masked, + "reachable": reachable, + "account_id": account_id, + "session": "stored" if session else "none", + }, + lambda _d: f"profile={escape(profile)} env={escape(env)} " + f"key={escape(masked)} reachable={reachable} " + f"account={account_id} session={'stored' if session else 'none'}", + json_mode=json_mode, + ) +``` + +- [ ] **Step 4: Update help ordering** + +In `aai_cli/main.py`, update `_COMMAND_ORDER` to place the new commands sensibly (account group after `llm`, `keys`/`sessions` near related commands): + +```python +_COMMAND_ORDER = ( + "transcribe", + "stream", + "transcripts", + "sessions", + "agent", + "llm", + "balance", + "usage", + "limits", + "keys", + "audit", + "login", + "logout", + "whoami", + "doctor", + "samples", + "claude", + "version", +) +``` + +- [ ] **Step 5: Run tests** + +Run: `pytest tests/test_login.py -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/commands/login.py aai_cli/main.py tests/test_login.py +git commit -m "feat(whoami): report account id + session; order new commands in help" +``` + +--- + +## Task 11: README docs + full suite + smoke check + +**Files:** +- Modify: `README.md` +- Test: full suite + +- [ ] **Step 1: Document the new commands** + +In `README.md`, after the existing command/usage section, add a subsection: + +```markdown +## Account self-service + +These commands use your browser login session (run `aai login` without +`--api-key`), not your API key: + +```sh +aai keys list # list API keys (masked) across projects +aai keys create --name ci-pipeline # mint a new key (printed once) +aai keys rename 123 "prod" # relabel a key + +aai balance # remaining account balance +aai usage --start 2026-05-01 --end 2026-06-01 +aai limits # rate limits per service + +aai sessions list --status completed +aai sessions get # one streaming session's details + +aai audit --limit 20 # recent account audit-log entries +aai audit --action token.create # filter by action +``` + +If a command reports it needs a browser login, your session has expired — run +`aai login` again. (AMS sessions are short-lived and cannot be refreshed +silently.) +``` + +- [ ] **Step 2: Run the full test suite** + +Run: `pytest -q` +Expected: PASS (all tests, including the new modules and updated login tests). + +- [ ] **Step 3: Lint / type-check (match the repo's tooling)** + +Run: `ruff check aai_cli tests && ruff format --check aai_cli tests` +Expected: clean. (If the repo uses a different command — check `pyproject.toml`/`Makefile` — run that instead. Fix any findings.) + +- [ ] **Step 4: Smoke-check the help output** + +Run: `python -m aai_cli --help` +Expected: `balance`, `usage`, `limits`, `keys`, `audit`, `sessions` appear in the command list in the order defined by `_COMMAND_ORDER`. + +Run: `python -m aai_cli keys --help` and `python -m aai_cli sessions --help` +Expected: subcommands `list`, `create`, `rename` and `list`, `get` respectively. + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: document account self-service commands" +``` + +--- + +## Notes & open items for the implementer + +- **Units of `usage.total` and `balance`:** `balance_in_cents` is unambiguous (rendered as dollars). `UsageWindow.total` has no documented unit in the spec — it is rendered as a raw number. If product confirms a unit, format it then. +- **Expired sessions:** surface as `NotAuthenticated` (exit 2) via `ams._raise_for_error` on 401/403. The message already tells the user to run `aai login`. This is the agreed v1 behavior (persist + re-login on expiry); no silent refresh. +- **`--api-key` logins** never get a session, so account self-service commands will (correctly) report they need a browser login. This is expected and documented. +- **Reused existing code:** `ams.list_projects` and `ams.create_token` already exist (used by the login flow) — `keys` reuses them rather than adding duplicates. +``` diff --git a/docs/superpowers/plans/2026-06-04-help-examples-and-error-suggestions.md b/docs/superpowers/plans/2026-06-04-help-examples-and-error-suggestions.md new file mode 100644 index 00000000..380c45ef --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-help-examples-and-error-suggestions.md @@ -0,0 +1,1641 @@ +# Help Examples & Structured Error Suggestions 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:** Bring the `aai` CLI to parity with the Supabase CLI on two onboarding dimensions — embed copy-pasteable `EXAMPLES` in every command's `--help`, and give every actionable error a structured, separately-rendered `Suggestion:` line (in both human and JSON output). + +**Architecture:** Two independent feature areas. +- **Examples:** A single helper (`aai_cli/help_text.py`) turns a list of `(description, command)` pairs into a Typer `epilog` string. Each command passes its examples via `epilog=`. The app already runs `rich_markup_mode="rich"`, which reflows single newlines, so the helper separates entries with blank lines and escapes markup. A reflection test walks the command tree and fails if any leaf command lacks an epilog — so future commands can't silently skip examples. +- **Errors:** The existing `CLIError` base + central `emit_error()` renderer already separate message from machine-type and route to stderr. We add one optional `suggestion` field that flows through `to_dict()` (JSON) and gets its own dimmed `Suggestion:` line (human). Centralized error helpers (`auth_failure()`, `NotAuthenticated`, `audio_missing_error()`) are upgraded once and auto-cover ~20 call sites; the remaining actionable sites split their embedded hints into `suggestion=`. + +**Tech Stack:** Python 3.10+, Typer 0.25/0.26 (vendors its own click as `typer._click`), Rich 15, pytest. Tests live in `tests/`, run with `pytest`. Lint/type with `ruff` and `mypy`. + +**Scoping decision (errors):** A `suggestion` is added only where there is a *distinct corrective action beyond restating the message*. Pure input-validation errors whose message already states the constraint are intentionally left unchanged: `aai_cli/config_builder.py` (e.g. "expects an integer, got X"), the mutually-exclusive-flag `UsageError`s in `stream.py`/`agent.py`/`llm.py` (e.g. "--device applies only to microphone input"), `auth/ams.py` raw server errors, `client.py` network/API-wrap errors (the auth cases there already route through `auth_failure()`), `commands/transcripts.py:34`, `commands/claude.py` scope errors, and `youtube.py` download failures (no fixed remedy). The mechanism is global; the *content* migration is scoped to actionable sites. + +--- + +## Part A — Examples in `--help` + +### Task A1: Examples helper + +**Files:** +- Create: `aai_cli/help_text.py` +- Test: `tests/test_help_text.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_help_text.py +from aai_cli.help_text import examples_epilog + + +def test_examples_epilog_has_header_and_entries(): + epi = examples_epilog([("Do a thing", "aai do --thing")]) + assert "[bold]Examples[/bold]" in epi + assert "[dim]Do a thing[/dim]" in epi + assert "$ aai do --thing" in epi + + +def test_examples_epilog_blank_line_separates_entries(): + # rich_markup_mode="rich" reflows single newlines; blank lines keep each + # entry on its own row. + epi = examples_epilog([("First", "aai a"), ("Second", "aai b")]) + assert "\n\n" in epi + assert epi.count("\n\n") >= 3 # header + 2 descs + 2 cmds, joined by blanks + + +def test_examples_epilog_escapes_markup_in_commands(): + # Brackets in example commands (jq filters, arrays) must not be parsed as + # rich markup tags. + epi = examples_epilog([("Filter JSON", "aai transcribe x -o json | jq '.utterances[]'")]) + assert "jq '.utterances\\[]'" in epi +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pytest tests/test_help_text.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'aai_cli.help_text'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# aai_cli/help_text.py +from __future__ import annotations + +from collections.abc import Sequence + +from rich.markup import escape + +# An (description, command) pair shown under a command's `--help`. +Example = tuple[str, str] + + +def examples_epilog(examples: Sequence[Example]) -> str: + """Build a Typer ``epilog`` that renders each example on its own line. + + The app runs with ``rich_markup_mode="rich"``, which reflows single newlines + into one paragraph but treats a blank line as a paragraph break. We join every + line with a blank line so each renders on its own row, dim the descriptions so + the commands stand out, and escape both so brackets in example commands (e.g. + ``jq '.x[]'``) are not parsed as rich markup tags. + """ + blocks = ["[bold]Examples[/bold]"] + for description, command in examples: + blocks.append(f"[dim]{escape(description)}[/dim]") + blocks.append(f"$ {escape(command)}") + return "\n\n".join(blocks) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pytest tests/test_help_text.py -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/help_text.py tests/test_help_text.py +git commit -m "feat(help): add examples_epilog helper for --help examples + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A2: Examples on `transcribe` and `stream` + +**Files:** +- Modify: `aai_cli/commands/transcribe.py:33` (the `@app.command()` above `def transcribe`) +- Modify: `aai_cli/commands/stream.py:22` (the `@app.command()` above `def stream`) +- Test: `tests/test_transcribe.py`, `tests/test_stream_command.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_transcribe.py`: + +```python +def test_transcribe_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["transcribe", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output + assert "aai transcribe" in result.output +``` + +Add to `tests/test_stream_command.py`: + +```python +def test_stream_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["stream", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_transcribe.py::test_transcribe_help_has_examples tests/test_stream_command.py::test_stream_help_has_examples -v` +Expected: FAIL — `"Examples" in result.output` is False. + +- [ ] **Step 3: Implement** + +In `aai_cli/commands/transcribe.py`, add the import near the top (after the existing `from aai_cli...` imports): + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` (line 33, above `def transcribe`) with: + +```python +@app.command( + 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"), + ] + ) +) +``` + +In `aai_cli/commands/stream.py`, add the import near the top: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` (line 22, above `def stream`) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Stream from your microphone", "aai stream"), + ("Stream the hosted sample", "aai stream --sample"), + ("Summarize action items live as you talk", 'aai stream --llm "summarize action items"'), + ("Print equivalent Python instead of running", "aai stream --show-code"), + ] + ) +) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_transcribe.py::test_transcribe_help_has_examples tests/test_stream_command.py::test_stream_help_has_examples -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/transcribe.py aai_cli/commands/stream.py tests/test_transcribe.py tests/test_stream_command.py +git commit -m "feat(help): add --help examples to transcribe and stream + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A3: Examples on `transcripts get` / `transcripts list` + +**Files:** +- Modify: `aai_cli/commands/transcripts.py:15` (`@app.command()` above `def get`) and `:51` (`@app.command(name="list")` above `def list_`) +- Test: `tests/test_transcripts.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_transcripts.py`: + +```python +def test_transcripts_get_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["transcripts", "get", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output + + +def test_transcripts_list_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["transcripts", "list", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_transcripts.py -k help_has_examples -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +In `aai_cli/commands/transcripts.py`, add near the top imports: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` (line 15, above `def get`) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Fetch a transcript's text by id", "aai transcripts get 5551234-abcd"), + ("Get the raw JSON", "aai transcripts get 5551234-abcd --json"), + ] + ) +) +``` + +Replace `@app.command(name="list")` (line 51, above `def list_`) with: + +```python +@app.command( + name="list", + epilog=examples_epilog( + [ + ("List your recent transcripts", "aai transcripts list"), + ] + ), +) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_transcripts.py -k help_has_examples -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/transcripts.py tests/test_transcripts.py +git commit -m "feat(help): add --help examples to transcripts get/list + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A4: Examples on `agent` and `llm` + +**Files:** +- Modify: `aai_cli/commands/agent.py:21` (`@app.command()` above `def agent`) +- Modify: `aai_cli/commands/llm.py:15` (`@app.command()` above `def llm`) +- Test: `tests/test_agent_command.py`, `tests/test_llm_command.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_agent_command.py`: + +```python +def test_agent_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["agent", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +Add to `tests/test_llm_command.py`: + +```python +def test_llm_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["llm", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_agent_command.py::test_agent_help_has_examples tests/test_llm_command.py::test_llm_help_has_examples -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +In `aai_cli/commands/agent.py`, add near the top imports: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` (line 21, above `def agent`) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Start a live voice conversation", "aai agent"), + ("Pick a voice and opening line", 'aai agent --voice james --greeting "Hi there"'), + ("See available voices", "aai agent --list-voices"), + ("Print equivalent Python instead of running", "aai agent --show-code"), + ] + ) +) +``` + +In `aai_cli/commands/llm.py`, add near the top imports: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` (line 15, above `def llm`) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Summarize a past transcript", 'aai llm "summarize" --transcript-id 5551234-abcd'), + ("Pipe any text in", 'echo "meeting notes" | aai llm "turn into action items"'), + ("See available models", "aai llm --list-models"), + ] + ) +) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_agent_command.py::test_agent_help_has_examples tests/test_llm_command.py::test_llm_help_has_examples -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/agent.py aai_cli/commands/llm.py tests/test_agent_command.py tests/test_llm_command.py +git commit -m "feat(help): add --help examples to agent and llm + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A5: Examples on `login`, `logout`, `whoami` + +**Files:** +- Modify: `aai_cli/commands/login.py:14` (`@app.command()` above `def login`), `:41` (above `def logout`), `:60` (above `def whoami`) +- Test: `tests/test_login.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_login.py`: + +```python +import pytest + + +@pytest.mark.parametrize("cmd", ["login", "logout", "whoami"]) +def test_auth_commands_help_has_examples(cmd): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, [cmd, "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_login.py::test_auth_commands_help_has_examples -v` +Expected: FAIL (3 params) + +- [ ] **Step 3: Implement** + +In `aai_cli/commands/login.py`, add to the imports (after the existing `from aai_cli...` lines): + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` above `def login` (line 14) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Log in with your browser", "aai login"), + ("Log in non-interactively (CI)", "aai login --api-key sk_..."), + ] + ) +) +``` + +Replace `@app.command()` above `def logout` (line 41) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Clear stored credentials for the active profile", "aai logout"), + ] + ) +) +``` + +Replace `@app.command()` above `def whoami` (line 60) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Show the active profile and whether its key works", "aai whoami"), + ] + ) +) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_login.py::test_auth_commands_help_has_examples -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/login.py tests/test_login.py +git commit -m "feat(help): add --help examples to login/logout/whoami + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A6: Examples on `doctor` and `samples list`/`create` + +**Files:** +- Modify: `aai_cli/commands/doctor.py:190` (`@app.command()` above `def doctor`) +- Modify: `aai_cli/commands/samples.py:39` (`@app.command(name="list")`) and `:56` (`@app.command()` above `def create`) +- Test: `tests/test_doctor.py`, `tests/test_samples.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_doctor.py`: + +```python +def test_doctor_help_has_examples(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["doctor", "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +Add to `tests/test_samples.py`: + +```python +import pytest + + +@pytest.mark.parametrize("argv", [["samples", "list"], ["samples", "create"]]) +def test_samples_help_has_examples(argv): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, [*argv, "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_doctor.py::test_doctor_help_has_examples tests/test_samples.py::test_samples_help_has_examples -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +In `aai_cli/commands/doctor.py`, add to imports: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` above `def doctor` (line 190) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Check your environment is ready", "aai doctor"), + ] + ) +) +``` + +In `aai_cli/commands/samples.py`, add to imports: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command(name="list")` (line 39) with: + +```python +@app.command( + name="list", + epilog=examples_epilog( + [ + ("List available starter scripts", "aai samples list"), + ] + ), +) +``` + +Replace `@app.command()` above `def create` (line 56) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Scaffold a transcribe starter script", "aai samples create transcribe"), + ("Overwrite an existing script", "aai samples create transcribe --force"), + ] + ) +) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_doctor.py::test_doctor_help_has_examples tests/test_samples.py::test_samples_help_has_examples -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/doctor.py aai_cli/commands/samples.py tests/test_doctor.py tests/test_samples.py +git commit -m "feat(help): add --help examples to doctor and samples + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A7: Examples on `claude install`/`status`/`remove` + +**Files:** +- Modify: `aai_cli/commands/claude.py:205` (`@app.command()` above `def install`), `:234` (above `def status`), `:248` (above `def remove`) +- Test: `tests/test_claude.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_claude.py`: + +```python +import pytest + + +@pytest.mark.parametrize("sub", ["install", "status", "remove"]) +def test_claude_help_has_examples(sub): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["claude", sub, "--help"]) + assert result.exit_code == 0 + assert "Examples" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_claude.py::test_claude_help_has_examples -v` +Expected: FAIL (3 params) + +- [ ] **Step 3: Implement** + +In `aai_cli/commands/claude.py`, add to imports: + +```python +from aai_cli.help_text import examples_epilog +``` + +Replace `@app.command()` above `def install` (line 205) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Wire AssemblyAI docs + skill into Claude Code", "aai claude install"), + ("Install for the current project only", "aai claude install --scope project"), + ] + ) +) +``` + +Replace `@app.command()` above `def status` (line 234) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Show whether Claude Code is wired up", "aai claude status"), + ] + ) +) +``` + +Replace `@app.command()` above `def remove` (line 248) with: + +```python +@app.command( + epilog=examples_epilog( + [ + ("Remove the AssemblyAI MCP server and skill", "aai claude remove"), + ] + ) +) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_claude.py::test_claude_help_has_examples -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/claude.py tests/test_claude.py +git commit -m "feat(help): add --help examples to claude install/status/remove + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task A8: Reflection guard — every leaf command has examples + +**Files:** +- Test: `tests/test_help_examples_coverage.py` (create) + +This capstone test walks the real command tree and fails if any leaf command (except `version`) is missing an epilog — so a future command can't ship without examples. + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_help_examples_coverage.py +import typer + +from aai_cli.main import app + +# `version` is a trivial command with no flags; examples would be noise. +_EXEMPT = {"version"} + + +def _leaf_commands(click_cmd, prefix=()): + """Yield (path_tuple, command) for every non-group command in the tree.""" + sub = getattr(click_cmd, "commands", None) + if not sub: + yield prefix, click_cmd + return + for name, child in sub.items(): + yield from _leaf_commands(child, (*prefix, name)) + + +def test_every_leaf_command_has_examples_epilog(): + root = typer.main.get_command(app) + missing = [] + for path, cmd in _leaf_commands(root): + 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)) + assert not missing, f"commands missing --help examples: {missing}" +``` + +- [ ] **Step 2: Run to verify it passes** + +By this point Tasks A2–A7 have added epilogs to every leaf command. Run: + +Run: `pytest tests/test_help_examples_coverage.py -v` +Expected: PASS. If it lists any command, add an `epilog=examples_epilog([...])` to that command following the Task A2 pattern, then re-run. + +- [ ] **Step 3: Run the full help-examples test slice** + +Run: `pytest tests/ -k "help_has_examples or examples_epilog or every_leaf_command" -v` +Expected: PASS (all) + +- [ ] **Step 4: Lint & type-check the touched files** + +Run: `ruff check aai_cli tests && mypy aai_cli` +Expected: no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_help_examples_coverage.py +git commit -m "test(help): guard that every leaf command ships --help examples + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Part B — Structured error suggestions + +### Task B1: Add `suggestion` to the error model + +**Files:** +- Modify: `aai_cli/errors.py:4-40` (the `CLIError`, `NotAuthenticated`, `APIError`, `UsageError` classes) +- Test: `tests/test_errors.py` + +- [ ] **Step 1: Write/adjust the failing tests** + +In `tests/test_errors.py`, replace `test_not_authenticated_defaults` (lines 4-8) and `test_to_dict_omits_none_transcript_id` (lines 20-22), and add new cases: + +```python +def test_not_authenticated_defaults(): + err = NotAuthenticated() + assert err.exit_code == 2 + assert err.error_type == "not_authenticated" + assert err.message == "Not authenticated." + assert err.suggestion == "Run 'aai login'." + + +def test_to_dict_includes_suggestion_when_present(): + err = CLIError("nope", error_type="generic", exit_code=1, suggestion="do this") + assert err.to_dict() == { + "error": {"type": "generic", "message": "nope", "suggestion": "do this"} + } + + +def test_to_dict_omits_none_transcript_id_and_suggestion(): + err = CLIError("nope", error_type="generic", exit_code=1) + assert err.to_dict() == {"error": {"type": "generic", "message": "nope"}} + + +def test_api_error_carries_suggestion(): + err = APIError("boom", suggestion="retry") + assert err.to_dict() == { + "error": {"type": "api_error", "message": "boom", "suggestion": "retry"} + } +``` + +Add `UsageError` to the import line at the top of `tests/test_errors.py`: + +```python +from aai_cli.errors import APIError, CLIError, NotAuthenticated, UsageError, is_auth_failure +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_errors.py -v` +Expected: FAIL — `CLIError.__init__` has no `suggestion` kwarg; `err.message` still includes "Run 'aai login'". + +- [ ] **Step 3: Implement** + +Replace `aai_cli/errors.py` lines 4-40 (the four classes) with: + +```python +class CLIError(Exception): + """Base error carrying an exit code, a machine-readable type, and an optional + human suggestion for how to fix it.""" + + def __init__( + self, + message: str, + *, + error_type: str = "error", + exit_code: int = 1, + transcript_id: str | None = None, + suggestion: str | None = None, + ) -> None: + super().__init__(message) + self.message = message + self.error_type = error_type + self.exit_code = exit_code + self.transcript_id = transcript_id + self.suggestion = suggestion + + def to_dict(self) -> dict[str, object]: + body: dict[str, object] = {"type": self.error_type, "message": self.message} + if self.suggestion is not None: + body["suggestion"] = self.suggestion + if self.transcript_id is not None: + body["transcript_id"] = self.transcript_id + return {"error": body} + + +class NotAuthenticated(CLIError): + def __init__( + self, + message: str = "Not authenticated.", + *, + suggestion: str | None = "Run 'aai login'.", + ) -> None: + super().__init__( + message, error_type="not_authenticated", exit_code=2, suggestion=suggestion + ) + + +class APIError(CLIError): + def __init__( + self, + message: str, + *, + transcript_id: str | None = None, + suggestion: str | None = None, + ) -> None: + super().__init__( + message, + error_type="api_error", + exit_code=1, + transcript_id=transcript_id, + suggestion=suggestion, + ) + + +class UsageError(CLIError): + def __init__(self, message: str, *, suggestion: str | None = None) -> None: + super().__init__(message, error_type="usage_error", exit_code=2, suggestion=suggestion) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_errors.py -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/errors.py tests/test_errors.py +git commit -m "feat(errors): add optional suggestion field to CLIError model + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B2: Render the `Suggestion:` line in human output + +**Files:** +- Modify: `aai_cli/output.py:71-76` (`emit_error`) +- Test: `tests/test_output.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_output.py`: + +```python +def test_emit_error_renders_suggestion_line(capsys): + import types + + err = types.SimpleNamespace( + message="bad thing", + suggestion="try this instead", + to_dict=lambda: {"error": {}}, + ) + output.emit_error(err, json_mode=False) + captured = capsys.readouterr() + assert "Error:" in captured.err + assert "bad thing" in captured.err + assert "Suggestion:" in captured.err + assert "try this instead" in captured.err + assert captured.out == "" + + +def test_emit_error_no_suggestion_line_when_absent(capsys): + import types + + err = types.SimpleNamespace(message="bad thing", suggestion=None, to_dict=lambda: {"error": {}}) + output.emit_error(err, json_mode=False) + captured = capsys.readouterr() + assert "Suggestion:" not in captured.err +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_output.py -k suggestion -v` +Expected: FAIL — no "Suggestion:" line emitted. + +- [ ] **Step 3: Implement** + +Replace `aai_cli/output.py` lines 71-76 (`emit_error`) with: + +```python +def emit_error(err: CLIError, *, json_mode: bool) -> None: + # Always to stderr, so stdout stays clean for `aai … | next-tool` pipelines. + if json_mode: + print(json.dumps(err.to_dict(), default=str), file=sys.stderr) + else: + error_console.print(f"[aai.error]Error:[/aai.error] {escape(err.message)}") + suggestion = getattr(err, "suggestion", None) + if suggestion: + error_console.print(f"[aai.muted]Suggestion:[/aai.muted] {escape(suggestion)}") +``` + +(The `aai.muted` style already exists in the theme — `commands/doctor.py` uses `[aai.muted]fix:[/aai.muted]`.) + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_output.py -k suggestion -v` +Expected: PASS + +- [ ] **Step 5: Verify JSON output already carries the suggestion** + +The JSON branch calls `err.to_dict()`, which Task B1 already taught to include `suggestion`. Confirm with the existing error model test plus: + +Run: `pytest tests/test_output.py tests/test_errors.py -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/output.py tests/test_output.py +git commit -m "feat(errors): render Suggestion line in human error output + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B3: Upgrade centralized error helpers (auto-covers ~20 sites) + +These three helpers are reused across the codebase; splitting them once fixes every call site (all `auth_failure()` calls in `client.py`/`llm.py`/`agent/session.py`, every default `NotAuthenticated()` in `config.py`/`commands/login.py`/`commands/whoami`, and both audio import paths). + +**Files:** +- Modify: `aai_cli/errors.py:57-70` (`REJECTED_KEY_MESSAGE`, `auth_failure`) +- Modify: `aai_cli/microphone.py:20-26` (`audio_missing_error`) +- Test: `tests/test_errors.py`, `tests/test_microphone.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_errors.py`: + +```python +def test_auth_failure_splits_message_and_suggestion(): + from aai_cli.errors import auth_failure + + err = auth_failure() + assert err.error_type == "not_authenticated" + assert err.message == "Your API key was rejected." + assert "aai login" in (err.suggestion or "") + assert "ASSEMBLYAI_API_KEY" in (err.suggestion or "") +``` + +Add to `tests/test_microphone.py` (mirror its existing import style): + +```python +def test_audio_missing_error_has_reinstall_suggestion(): + from aai_cli.microphone import audio_missing_error + + err = audio_missing_error() + assert "sounddevice" in err.message + assert err.suggestion is not None + assert "pip install" in err.suggestion +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_errors.py::test_auth_failure_splits_message_and_suggestion tests/test_microphone.py::test_audio_missing_error_has_reinstall_suggestion -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +Replace `aai_cli/errors.py` lines 57-70 (from `REJECTED_KEY_MESSAGE = (` through the end of `auth_failure`) with: + +```python +REJECTED_KEY_MESSAGE = "Your API key was rejected." +REJECTED_KEY_SUGGESTION = "Run 'aai login' with a valid key, or set ASSEMBLYAI_API_KEY." + + +def is_auth_failure(exc: object) -> bool: + """Heuristic: does this exception/error indicate rejected/invalid credentials?""" + text = str(exc).lower() + return any(hint in text for hint in _AUTH_FAILURE_HINTS) + + +def auth_failure() -> NotAuthenticated: + """A NotAuthenticated for the 'key present but rejected by the server' case.""" + return NotAuthenticated(REJECTED_KEY_MESSAGE, suggestion=REJECTED_KEY_SUGGESTION) +``` + +(Note: `is_auth_failure` already exists at lines 62-65; this replacement keeps a single definition — when applying, ensure there is exactly one `is_auth_failure` afterward. If your edit range excludes the existing `is_auth_failure`, do not re-add it.) + +Replace `aai_cli/microphone.py` lines 20-26 (`audio_missing_error`) with: + +```python +def audio_missing_error() -> CLIError: + """The shared 'sounddevice can't be imported' error for mic and speaker paths.""" + return CLIError( + "Audio support (sounddevice) is unavailable.", + error_type="mic_missing", + exit_code=2, + suggestion="Reinstall it: pip install --force-reinstall sounddevice", + ) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_errors.py tests/test_microphone.py -v` +Expected: PASS + +- [ ] **Step 5: Run the full suite to catch any test asserting the old combined strings** + +Run: `pytest -q` +Expected: PASS. If a test fails because it asserted the old combined message (e.g. `"Run 'aai login'" in str(err)` or `"--force-reinstall" in err.message`), update that assertion to check `err.message`/`err.suggestion` separately. Find candidates with: + +Run: `grep -rn "aai login\|force-reinstall\|API key was rejected" tests/` + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/errors.py aai_cli/microphone.py tests/test_errors.py tests/test_microphone.py +git commit -m "feat(errors): split suggestion out of auth and audio error helpers + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B4: `config.py` actionable errors + +**Files:** +- Modify: `aai_cli/config.py:28-32` (invalid profile) and `:94` (empty key) +- Test: `tests/test_config.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_config.py` (use its existing import idiom; `config` and `CLIError` are imported there): + +```python +def test_invalid_profile_name_has_suggestion(): + import pytest + + from aai_cli import config + from aai_cli.errors import CLIError + + with pytest.raises(CLIError) as exc: + config.set_active_profile("bad name!") + assert exc.value.message.startswith("Invalid profile name") + assert exc.value.suggestion == "Use only letters, digits, '-' or '_'." +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_config.py::test_invalid_profile_name_has_suggestion -v` +Expected: FAIL — `suggestion` is None. + +- [ ] **Step 3: Implement** + +Replace `aai_cli/config.py` lines 28-32 with: + +```python + raise CLIError( + f"Invalid profile name {name!r}.", + error_type="invalid_profile", + exit_code=2, + suggestion="Use only letters, digits, '-' or '_'.", + ) +``` + +Replace `aai_cli/config.py` line 94 with: + +```python + raise CLIError( + "Empty --api-key provided.", + error_type="invalid_key", + exit_code=2, + suggestion="Pass a non-empty key, e.g. --api-key sk_...", + ) +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_config.py::test_invalid_profile_name_has_suggestion -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/config.py tests/test_config.py +git commit -m "feat(errors): add suggestions to config profile/key errors + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B5: `commands/agent.py` actionable errors + +**Files:** +- Modify: `aai_cli/commands/agent.py:68` (unknown voice) and `:73-77` (unreadable system-prompt file) +- Test: `tests/test_agent_command.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_agent_command.py`: + +```python +def test_unknown_voice_suggests_list_voices(): + import pytest + + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["agent", "--voice", "not-a-voice", "--json"]) + assert result.exit_code == 2 + # JSON error on stderr carries the structured suggestion. + assert "--list-voices" in result.output +``` + +(If the project's `CliRunner` is configured with `mix_stderr=False` in this test module, assert against `result.stderr` instead of `result.output`; check the top of `tests/test_agent_command.py` for the existing convention.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_agent_command.py::test_unknown_voice_suggests_list_voices -v` +Expected: FAIL — suggestion not present. + +- [ ] **Step 3: Implement** + +Replace `aai_cli/commands/agent.py` line 68 with: + +```python + raise UsageError( + f"Unknown voice {voice!r}.", + suggestion="Run 'aai agent --list-voices' to see the options.", + ) +``` + +Replace `aai_cli/commands/agent.py` lines 73-77 (the `CLIError(...)` for the unreadable file) with: + +```python + raise CLIError( + f"Could not read --system-prompt-file {system_prompt_file}: {exc}", + error_type="file_not_found", + exit_code=2, + suggestion="Check the path and that the file is readable.", + ) from exc +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_agent_command.py::test_unknown_voice_suggests_list_voices -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/agent.py tests/test_agent_command.py +git commit -m "feat(errors): add suggestions to agent voice/system-prompt errors + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B6: `commands/samples.py` actionable errors + +**Files:** +- Modify: `aai_cli/commands/samples.py:67-71` (unknown sample) and `:76-80` (file exists) +- Test: `tests/test_samples.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_samples.py`: + +```python +def test_unknown_sample_has_suggestion(): + import pytest + + from aai_cli.commands import samples as samples_mod + from aai_cli.errors import CLIError + + # Drive the command body directly through the Typer app for a clean error. + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["samples", "create", "nope", "--json"]) + assert result.exit_code == 1 + assert "Try one of" in result.output +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pytest tests/test_samples.py::test_unknown_sample_has_suggestion -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +Replace `aai_cli/commands/samples.py` lines 67-71 (unknown sample) with: + +```python + raise CLIError( + f"Unknown sample '{name}'.", + error_type="unknown_sample", + exit_code=1, + suggestion=f"Try one of: {', '.join(SAMPLES)}.", + ) +``` + +Replace the file-exists `CLIError` (starting at line 76) — preserve its existing `error_type`/`exit_code` lines that follow — so it reads: + +```python + raise CLIError( + f"{target} already exists.", + error_type="file_exists", + exit_code=1, + suggestion="Delete it or pass --force to overwrite.", + ) +``` + +(If the original spanned `error_type="file_exists"` and `exit_code=...` on following lines, replace the whole `raise CLIError(...)` block.) + +- [ ] **Step 4: Run to verify it passes** + +Run: `pytest tests/test_samples.py::test_unknown_sample_has_suggestion -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/samples.py tests/test_samples.py +git commit -m "feat(errors): add suggestions to samples create errors + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B7: `commands/llm.py` and `commands/login.py` actionable errors + +**Files:** +- Modify: `aai_cli/commands/llm.py:98` (provide a prompt / list-models) +- Modify: `aai_cli/commands/login.py:27` (rejected key) +- Test: `tests/test_llm_command.py`, `tests/test_login.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_llm_command.py`: + +```python +def test_no_prompt_suggests_list_models(): + from typer.testing import CliRunner + from aai_cli.main import app + + result = CliRunner().invoke(app, ["llm", "--json"]) + assert result.exit_code == 2 + assert "--list-models" in result.output +``` + +Add to `tests/test_login.py`: + +```python +def test_rejected_api_key_has_suggestion(monkeypatch): + from typer.testing import CliRunner + from aai_cli import client + from aai_cli.main import app + + monkeypatch.setattr(client, "validate_key", lambda key: False) + result = CliRunner().invoke(app, ["login", "--api-key", "sk_bad", "--json"]) + assert result.exit_code == 1 + assert "Check the key and retry" in result.output +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_llm_command.py::test_no_prompt_suggests_list_models tests/test_login.py::test_rejected_api_key_has_suggestion -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +Replace `aai_cli/commands/llm.py` line 98 with: + +```python + raise UsageError( + "Provide a prompt.", + suggestion="Or pass --list-models to see available models.", + ) +``` + +Replace `aai_cli/commands/login.py` line 27 with: + +```python + raise APIError( + "That API key was rejected (HTTP 401).", + suggestion="Check the key and retry.", + ) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_llm_command.py::test_no_prompt_suggests_list_models tests/test_login.py::test_rejected_api_key_has_suggestion -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/llm.py aai_cli/commands/login.py tests/test_llm_command.py tests/test_login.py +git commit -m "feat(errors): add suggestions to llm prompt and login key errors + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B8: `auth/flow.py` actionable errors + +**Files:** +- Modify: `aai_cli/auth/flow.py:29` (no project), `:47` (timeout), `:49` (invalid oauth) +- Test: `tests/test_auth_flow.py` + +- [ ] **Step 1: Write the failing tests** + +Inspect `tests/test_auth_flow.py` for how it triggers these paths (it already exercises `run_login_flow`). Add assertions that the raised `APIError` carries the new suggestion. Add: + +```python +def test_login_timeout_suggests_retry(): + # Mirror the existing timeout-path test setup in this module; the raised + # APIError should now split message and suggestion. + from aai_cli.errors import APIError + + err = APIError("Login timed out waiting for the browser.", suggestion="Run 'aai login' again.") + assert err.suggestion == "Run 'aai login' again." +``` + +> Note: this unit-level assertion documents the contract. If `tests/test_auth_flow.py` already has a test that drives the real timeout path and asserts the combined string, update that assertion to check `exc.value.message` and `exc.value.suggestion` separately after Step 3. + +- [ ] **Step 2: Run to verify current state** + +Run: `pytest tests/test_auth_flow.py -v` +Expected: existing tests PASS; any asserting the old combined "… Run 'aai login' again." string will FAIL after Step 3 and must be updated. + +- [ ] **Step 3: Implement** + +Replace `aai_cli/auth/flow.py` line 29 with: + +```python + raise APIError( + "Your account has no project to create an API key in.", + suggestion="Create a project in the AssemblyAI dashboard, then run 'aai login' again.", + ) +``` + +Replace `aai_cli/auth/flow.py` line 47 with: + +```python + raise APIError( + "Login timed out waiting for the browser.", + suggestion="Run 'aai login' again.", + ) +``` + +Replace `aai_cli/auth/flow.py` line 49 with: + +```python + raise APIError( + "Login did not return a valid OAuth token.", + suggestion="Run 'aai login' again.", + ) +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_auth_flow.py -v` +Expected: PASS (update any stale combined-string assertions per Step 2). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/auth/flow.py tests/test_auth_flow.py +git commit -m "feat(errors): add suggestions to login flow errors + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B9: Source/IO errors — `client.py`, `streaming/sources.py`, `agent/audio.py`, `youtube.py` + +**Files:** +- Modify: `aai_cli/client.py:29` (no audio arg) +- Modify: `aai_cli/streaming/sources.py:45-46` (file not found), `:52-56` (ffmpeg), and the empty-audio `CLIError` (~`:66`) +- Modify: `aai_cli/agent/audio.py:35-39` and `:153-157` (audio output device) +- Modify: `aai_cli/youtube.py:31-35` (yt-dlp missing) +- Test: `tests/test_streaming_sources.py`, `tests/test_youtube.py`, `tests/test_client.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_streaming_sources.py`: + +```python +def test_missing_ffmpeg_suggests_install(monkeypatch, tmp_path): + import shutil + + import pytest + + from aai_cli.errors import CLIError + from aai_cli.streaming import sources + + # A non-WAV file with ffmpeg absent must raise with an actionable suggestion. + f = tmp_path / "audio.mp3" + f.write_bytes(b"not really audio") + monkeypatch.setattr(shutil, "which", lambda name: None) + with pytest.raises(CLIError) as exc: + sources.FileSource(str(f)) # adjust to the actual constructor in this module + assert "ffmpeg" in exc.value.message + assert exc.value.suggestion is not None + assert "WAV" in exc.value.suggestion or "ffmpeg" in exc.value.suggestion +``` + +> Adjust the constructor/class name to match `streaming/sources.py` (read the file to confirm the public entry that runs the `shutil.which("ffmpeg")` check around line 51). + +Add to `tests/test_youtube.py`: + +```python +def test_missing_ytdlp_suggests_install(monkeypatch): + import builtins + + import pytest + + from aai_cli.errors import CLIError + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "yt_dlp": + raise ImportError("no yt_dlp") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + from aai_cli import youtube + + with pytest.raises(CLIError) as exc: + youtube.download_audio("https://youtu.be/x", __import__("pathlib").Path("/tmp")) # adjust to real fn signature + assert "yt-dlp" in exc.value.message + assert "pip install yt-dlp" in (exc.value.suggestion or "") +``` + +> Confirm the real function name/signature in `youtube.py` (the `import yt_dlp` guard at line 29) and adjust the call. + +Add to `tests/test_client.py`: + +```python +def test_no_audio_source_suggests_sample(): + import pytest + + from aai_cli.errors import UsageError + + # Reproduce the "neither path nor --sample" guard at client.py:29. + err = UsageError("Provide an audio path or URL.", suggestion="Or pass --sample to use the hosted demo file.") + assert err.suggestion is not None +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `pytest tests/test_streaming_sources.py::test_missing_ffmpeg_suggests_install tests/test_youtube.py::test_missing_ytdlp_suggests_install -v` +Expected: FAIL + +- [ ] **Step 3: Implement** + +`aai_cli/client.py` line 29 → : + +```python + raise UsageError( + "Provide an audio path or URL.", + suggestion="Or pass --sample to use the hosted demo file.", + ) +``` + +`aai_cli/streaming/sources.py` — file-not-found (lines 45-46) → : + +```python + raise CLIError( + f"No such file: {self._path}", + error_type="file_not_found", + exit_code=2, + suggestion="Check the path, or pass a URL or YouTube link instead.", + ) +``` + +ffmpeg-missing (lines 52-56) → : + +```python + raise CLIError( + "This audio source needs ffmpeg.", + error_type="ffmpeg_missing", + exit_code=2, + suggestion="Install ffmpeg, or pass a 16 kHz mono 16-bit WAV.", + ) +``` + +empty-audio `CLIError` (~line 66) → : + +```python + raise CLIError( + f"No audio data in {self.source}.", + error_type="empty_audio", + exit_code=2, + suggestion="Check the file isn't empty or silent.", + ) +``` + +`aai_cli/agent/audio.py` — both `CLIError` blocks (lines 35-39 and 153-157) → add the same suggestion. For the block at line 35: + +```python + raise CLIError( + f"Could not open the audio output device: {exc}", + error_type="audio_output_error", + exit_code=1, + suggestion="Check your speaker/output device, then run 'aai doctor'.", + ) from exc +``` + +For the block at line 153: + +```python + raise CLIError( + f"Could not open the audio device: {exc}", + error_type="audio_output_error", + exit_code=1, + suggestion="Check your microphone/output device, then run 'aai doctor'.", + ) from exc +``` + +`aai_cli/youtube.py` lines 31-35 → : + +```python + raise CLIError( + "YouTube support needs yt-dlp.", + error_type="ytdlp_missing", + exit_code=2, + suggestion="Install it: pip install yt-dlp", + ) from exc +``` + +- [ ] **Step 4: Run to verify they pass** + +Run: `pytest tests/test_streaming_sources.py tests/test_youtube.py tests/test_client.py -v` +Expected: PASS (adjust test constructor/function names per the notes if needed). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/client.py aai_cli/streaming/sources.py aai_cli/agent/audio.py aai_cli/youtube.py tests/test_streaming_sources.py tests/test_youtube.py tests/test_client.py +git commit -m "feat(errors): add suggestions to source/IO and dependency errors + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task B10: Full verification & wrap-up + +**Files:** none (verification only) + +- [ ] **Step 1: Run the complete test suite** + +Run: `pytest -q` +Expected: PASS. If any test fails because it asserted an old combined error string, update it to assert `err.message` and `err.suggestion` separately (do not re-merge the strings). + +- [ ] **Step 2: Lint and type-check** + +Run: `ruff check aai_cli tests && mypy aai_cli` +Expected: no errors. + +- [ ] **Step 3: Eyeball the two features end to end** + +Run: +```bash +python -m aai_cli transcribe --help # shows an Examples block +python -m aai_cli agent --voice nope --json # JSON error includes "suggestion" +python -m aai_cli agent --voice nope # human error shows "Suggestion:" line +``` +Expected: examples render one-per-line under `Examples`; the bad-voice error prints `Error: Unknown voice 'nope'.` then `Suggestion: Run 'aai agent --list-voices' to see the options.` (and the JSON form carries a `"suggestion"` key). + +- [ ] **Step 4: Final commit (if Step 1 required test edits)** + +```bash +git add -A +git commit -m "test: update assertions for split error message/suggestion + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review + +**Spec coverage:** +- "Every command has embedded EXAMPLES in --help" → Tasks A2–A7 cover all 15 leaf commands (transcribe, stream, transcripts get/list, agent, llm, login/logout/whoami, doctor, samples list/create, claude install/status/remove); Task A8 adds a reflection guard so none can be missed and future ones can't skip. `version` is intentionally exempt (documented in A8). +- "Every error is a tagged type with a Suggestion: line" → Task B1 adds the field to the model (JSON via `to_dict`), B2 renders the human `Suggestion:` line, B3 upgrades the three shared helpers (auto-covering the ~20 auth/audio sites), and B4–B9 migrate the actionable per-site errors. The deliberately-excluded categories are listed in the top-level "Scoping decision." + +**Placeholder scan:** Each code step shows complete code. Test steps that depend on module-specific constructor names (`streaming/sources.py`, `youtube.py`, `CliRunner` stderr convention) carry an explicit "adjust to the actual signature" note rather than a guessed call — these are the only soft spots and are flagged inline. + +**Type consistency:** `examples_epilog(Sequence[Example]) -> str` is defined in A1 and called identically in A2–A7. `CLIError.__init__(..., suggestion: str | None = None)` is defined in B1 and every subclass (`NotAuthenticated`, `APIError`, `UsageError`) and call site forwards `suggestion=` consistently. `to_dict()` emits `"suggestion"` only when present (B1), matching the human renderer's `if suggestion:` guard (B2). diff --git a/docs/superpowers/plans/2026-06-04-homebrew-tap.md b/docs/superpowers/plans/2026-06-04-homebrew-tap.md new file mode 100644 index 00000000..e1046997 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-homebrew-tap.md @@ -0,0 +1,458 @@ +# Homebrew Tap (virtualenv formula, in-repo) 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:** Distribute the `aai` CLI via `brew install` using a Homebrew `Language::Python::Virtualenv` formula that lives inside this repo (the repo *is* the tap), sourcing the package from a GitHub release tag and never touching PyPI for our own package. + +**Architecture:** A `Formula/aai.rb` file is committed to `AssemblyAI/cli`. Users run `brew tap assemblyai/cli https://github.com/AssemblyAI/cli` then `brew install aai`. Homebrew builds an isolated virtualenv from our package's source tarball (the `v0.1.0` git-tag tarball) and installs all runtime dependencies from pinned `resource` stanzas (sourced from PyPI — those are third-party packages, we publish nothing). Native dependencies (`portaudio`, `openssl`) are declared via `depends_on` and provided by Homebrew rather than bundled. + +**Tech Stack:** Homebrew (Ruby formula DSL), `Language::Python::Virtualenv`, Python 3.13, `brew update-python-resources`, hatchling (our build backend), GitHub Releases. + +--- + +## Decisions & Risks (read before starting) + +- **Source artifact = GitHub tag tarball.** `url` → `https://github.com/AssemblyAI/cli/archive/refs/tags/v0.1.0.tar.gz` (the tarball GitHub auto-generates per tag). Idiomatic (httpie/azure-cli), zero per-release asset upload. Not PyPI. +- **Release is on the critical path (bootstrap only).** The main `sha256` requires the tag to exist, and `brew update-python-resources` requires a resolvable `url`. So Task 2 (cut `v0.1.0` *by hand*) gates Tasks 3–4. This manual step happens **once**; from then on Task 6's pipeline cuts every release automatically on merge to `main` (Conventional Commits → SemVer via `python-semantic-release`, then an auto formula bump). No PyPI publish in either the bootstrap or the steady state. +- **Resources come from `brew update-python-resources`, not a lockfile dump.** It correctly handles platform-conditional deps. The 48-package runtime closure includes Linux-only (`jeepney`, `secretstorage`) and Windows-only (`pywin32-ctypes`) packages that must NOT be unconditional `resource` blocks. Appendix A has an offline `uv.lock` generator as a fallback, with this caveat called out. +- **Native build dependencies.** Closure contains Rust-built (`pydantic-core`, `jiter`, `cryptography`) and C-extension (`cffi`) packages, plus `sounddevice` which needs PortAudio. Formula declares `rust`/`pkgconf` (build), `openssl@3`, `portaudio`, `python@3.13`. The `cryptography` source build is slow; Appendix B documents the `depends_on "cryptography" => :no_linkage` optimization if the build is too painful. +- **RISK — `aai` binary name collision.** Your `alexkroman/aai` tap installs a command also named `aai`. Both cannot coexist in one Homebrew prefix. This plan keeps `aai` (matches `[project.scripts] aai = "assemblyai_cli.main:run"`). Resolving the collision (rename command, or rename one tap's formula) is out of scope and tracked as an open product decision. +- **RISK — `sounddevice` runtime linkage.** Built from sdist, `sounddevice` binds PortAudio via cffi/`ctypes.util.find_library`. With `depends_on "portaudio"` it should resolve, but this is the most likely runtime failure; Task 4 explicitly tests the audio import path, not just `--version`. +- **Branch hygiene.** You are on `stytch-oauth-cli-login` with an uncommitted `auth/endpoints.py`. The release tag (Task 2) must come off `main`. Do all formula/script work on a normal feature branch; do not tag from the feature branch. + +--- + +## File Structure + +- `Formula/aai.rb` — the Homebrew formula (created). Class `Aai`. Homebrew auto-discovers `Formula/` in a tapped repo. No explicit `version` line — Homebrew parses it from the tag in `url`, so the auto-bump only rewrites `url` + `sha256`. +- `scripts/generate_brew_resources.py` — offline fallback generator from `uv.lock` (created; Appendix A). Not the primary path. +- `README.md` — add a Homebrew install section (modified). +- `pyproject.toml` — add `[tool.semantic_release]` config (modified; Task 6). +- `.github/workflows/release.yml` — release-on-merge pipeline: semantic-release cuts the version, then a guarded job bumps the formula (created; Task 6). + +--- + +## Task 1: Scaffold the in-repo tap and a buildable formula skeleton (no resources yet) + +**Files:** +- Create: `Formula/aai.rb` + +- [ ] **Step 1: Create the formula skeleton** + +Create `Formula/aai.rb` exactly: + +```ruby +class Aai < Formula + include Language::Python::Virtualenv + + desc "Command-line interface for AssemblyAI" + homepage "https://github.com/AssemblyAI/cli" + url "https://github.com/AssemblyAI/cli/archive/refs/tags/v0.1.0.tar.gz" + sha256 "0" * 64 # FILLED IN TASK 2 once the tag exists + license "MIT" + + depends_on "pkgconf" => :build # cffi / cryptography native builds + depends_on "rust" => :build # pydantic-core, jiter, cryptography + depends_on "openssl@3" # cryptography linkage + depends_on "portaudio" # sounddevice (audio capture) + depends_on "python@3.13" + + # RESOURCE STANZAS INSERTED IN TASK 3 (brew update-python-resources) + + def install + virtualenv_install_with_resources + end + + test do + assert_match version.to_s, shell_output("#{bin}/aai version") + end +end +``` + +- [ ] **Step 2: Lint the skeleton for style** + +Run: `brew style ./Formula/aai.rb` +Expected: PASS (0 offenses). If `brew style` reports offenses, fix exactly as it prints (it auto-describes each). + +- [ ] **Step 3: Confirm the placeholder is the only audit blocker** + +Run: `brew audit --new --formula ./Formula/aai.rb 2>&1 | head -20` +Expected: complaints about the dummy `sha256` and/or "no resources" — these are expected at this stage. There should be NO Ruby syntax errors. If you see "syntax error" or "uninitialized constant", the formula body is malformed — fix before continuing. + +- [ ] **Step 4: Commit** + +```bash +git add Formula/aai.rb +git commit -m "build(brew): add virtualenv formula skeleton for aai tap" +``` + +--- + +## Task 2: Cut the v0.1.0 release and fill the main sha256 + +**Files:** +- Modify: `Formula/aai.rb` (the `sha256` line) + +> **Outward-facing — performed by the maintainer. One-time bootstrap.** This is the only hand-cut release; Task 6 automates all subsequent ones. Tag from `main`, not the feature branch. The `pyproject.toml` version is already `0.1.0`, so the tag matches; `python-semantic-release` will compute the *next* version by diffing commits against this `v0.1.0` tag. + +- [ ] **Step 1: Tag and create the release off main** + +```bash +git checkout main +git pull --ff-only +git tag v0.1.0 +git push origin v0.1.0 +gh release create v0.1.0 --title "v0.1.0" --notes "Initial Homebrew release" +``` + +Expected: `gh release create` prints the release URL. Return to your working branch afterward: `git checkout -`. + +- [ ] **Step 2: Compute the tarball sha256** + +```bash +curl -fsSL "https://github.com/AssemblyAI/cli/archive/refs/tags/v0.1.0.tar.gz" | shasum -a 256 +``` + +Expected: a 64-hex-char digest followed by `-`. Copy the digest. + +- [ ] **Step 3: Insert the digest into the formula** + +In `Formula/aai.rb`, replace the line `sha256 "0" * 64 # FILLED IN TASK 2 once the tag exists` with: + +```ruby + sha256 "" +``` + +- [ ] **Step 4: Verify Homebrew accepts the download** + +Run: `brew fetch --formula ./Formula/aai.rb 2>&1 | tail -5` +Expected: "Downloaded to ..." with no checksum mismatch. A "SHA256 mismatch" error means the wrong digest was pasted — recompute. + +- [ ] **Step 5: Commit** + +```bash +git add Formula/aai.rb +git commit -m "build(brew): pin v0.1.0 source tarball checksum" +``` + +--- + +## Task 3: Generate the dependency resource stanzas + +**Files:** +- Modify: `Formula/aai.rb` (insert `resource` blocks) + +- [ ] **Step 1: Generate resources with the official tool** + +Run: `brew update-python-resources --print-only ./Formula/aai.rb > /tmp/aai-resources.rb` +Expected: stdout-captured `resource "..." do ... end` blocks for the runtime closure (≈40+ blocks). The tool downloads our tag tarball, resolves deps, and emits PyPI sdist URLs + sha256. +If it errors with "could not find ... on PyPI" for *our* package `aai-cli`, that is fine to ignore — it resolves the dependencies, not our root package (which comes from `url`). If it cannot resolve at all, fall back to Appendix A. + +- [ ] **Step 2: Insert the blocks into the formula** + +Replace the line ` # RESOURCE STANZAS INSERTED IN TASK 3 (brew update-python-resources)` in `Formula/aai.rb` with the contents of `/tmp/aai-resources.rb` (indented two spaces to sit inside the class body). + +- [ ] **Step 3: Verify platform-specific deps are scoped, not unconditional** + +Run: `grep -nE "jeepney|secretstorage|pywin32-ctypes" Formula/aai.rb` +Expected: `pywin32-ctypes` should be ABSENT (Windows-only; Homebrew has no Windows). `jeepney`/`secretstorage` should either be absent or wrapped in `on_linux do ... end`. If `brew update-python-resources` emitted them unconditionally, move them inside an `on_linux do` block and delete `pywin32-ctypes`. (This is exactly why we do not use a raw lockfile dump.) + +- [ ] **Step 4: Style + structural audit** + +Run: `brew style ./Formula/aai.rb && brew audit --formula ./Formula/aai.rb 2>&1 | grep -vE "GitHub|stable|bottle" | head -20` +Expected: `brew style` PASS; audit shows no "duplicate resource" or "resource ... should be ..." naming errors. Fix any reported resource-name casing mismatches by matching the name the audit suggests. + +- [ ] **Step 5: Commit** + +```bash +git add Formula/aai.rb +git commit -m "build(brew): add pinned dependency resource stanzas" +``` + +--- + +## Task 4: Build, install, and functionally validate locally + +**Files:** +- Modify: `Formula/aai.rb` (only if build reveals missing deps) + +- [ ] **Step 1: Build from source** + +Run: `brew install --build-from-source --verbose ./Formula/aai.rb 2>&1 | tail -30` +Expected: ends with "🍺 .../aai/0.1.0: ... files". +If a resource fails to build: +- `error: can't find Rust compiler` → ensure `depends_on "rust" => :build` present. +- `openssl`/`cryptography` build failure → confirm `depends_on "openssl@3"`; if still failing, apply Appendix B (`cryptography => :no_linkage`). +- `pkg-config`/`ffi` errors building `cffi` → confirm `depends_on "pkgconf" => :build`. +Add the missing `depends_on`, then re-run this step. + +- [ ] **Step 2: Smoke-test the entrypoint** + +Run: `aai version` +Expected: prints the version string (matches `0.1.0`). If `aai: command not found`, run `brew link aai` or check `$(brew --prefix)/bin` is on PATH. + +- [ ] **Step 3: Test the audio path (the real risk)** + +Run: `aai doctor 2>&1 | tail -20` +Expected: doctor output runs without an `ImportError`/`OSError: PortAudio library not found`. The critical check is that `import sounddevice` succeeds inside the bottled venv. +If PortAudio is not found at runtime: set the formula to expose it — add to `def install` before `virtualenv_install_with_resources`: +```ruby + ENV["CFLAGS"] = "-I#{Formula["portaudio"].opt_include}" + ENV["LDFLAGS"] = "-L#{Formula["portaudio"].opt_lib}" +``` +Re-run Step 1, then this step. + +- [ ] **Step 4: Run the formula's own test block** + +Run: `brew test --verbose ./Formula/aai.rb` +Expected: PASS. + +- [ ] **Step 5: Strict audit** + +Run: `brew audit --strict --online --formula ./Formula/aai.rb 2>&1 | tail -20` +Expected: no errors. `--online` validates the URLs/licenses. Address any "audit" (not "style") errors it lists. + +- [ ] **Step 6: Clean uninstall to verify no leftovers** + +Run: `brew uninstall aai && echo "uninstalled clean"` +Expected: "uninstalled clean". Confirms the formula owns its full lifecycle (unlike a PyApp-style runtime cache). + +- [ ] **Step 7: Commit any dep/install changes** + +```bash +git add Formula/aai.rb +git commit -m "build(brew): finalize build deps and portaudio linkage" +``` + +--- + +## Task 5: Document the tap install path + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a Homebrew section to README** + +Insert under the existing install instructions: + +```markdown +## Install with Homebrew + +```sh +brew tap assemblyai/cli https://github.com/AssemblyAI/cli +brew install aai +``` + +Upgrade with `brew upgrade aai`; remove with `brew uninstall aai`. +``` + +- [ ] **Step 2: Verify the documented commands work from a clean tap** + +```bash +brew untap assemblyai/cli 2>/dev/null || true +brew tap assemblyai/cli https://github.com/AssemblyAI/cli +brew install aai +aai version +``` +Expected: installs from the tapped repo and prints the version. (Requires Tasks 2–4 committed and pushed.) + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "docs: document Homebrew install via the assemblyai/cli tap" +``` + +--- + +## Task 6: Release-on-merge pipeline (python-semantic-release + guarded formula bump) + +Every merge to `main` that contains a `feat:`/`fix:`/`perf:` (or `BREAKING CHANGE`) commit cuts a new SemVer release; `chore:`/`docs:`/`ci:`/`test:`/`build:`-only merges release nothing (by design — each release rebuilds the tap). Versions are derived from Conventional Commits, which your history already follows. No PyPI publish occurs. + +**Files:** +- Modify: `pyproject.toml` (add `[tool.semantic_release]`) +- Create: `.github/workflows/release.yml` + +- [ ] **Step 1: Add semantic-release config to pyproject.toml** + +Append to `pyproject.toml`: + +```toml +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +commit_parser = "conventional" +build_command = "" # no wheel/sdist build — the formula builds from the tag tarball +tag_format = "v{version}" +allow_zero_version = true # stay in 0.x +major_on_zero = false # a breaking change bumps 0.x minor, not 1.0, until you opt in + +[tool.semantic_release.branches.main] +match = "main" +``` + +- [ ] **Step 2: Verify the config parses and resolves the version** + +Run: `python3 -c "import tomllib; d=tomllib.load(open('pyproject.toml','rb')); print(d['tool']['semantic_release']['version_toml'])"` +Expected: `['pyproject.toml:project.version']` (confirms valid TOML and the key exists). + +- [ ] **Step 3: Create the release workflow** + +Create `.github/workflows/release.yml`: + +```yaml +name: release +on: + push: + branches: [main] + paths-ignore: # the bot's own formula commit must not re-trigger us + - "Formula/**" + - "docs/**" + - "**/*.md" +concurrency: + group: release + cancel-in-progress: false +permissions: + contents: write +jobs: + release: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]')" + outputs: + released: ${{ steps.psr.outputs.released }} + version: ${{ steps.psr.outputs.version }} + tag: ${{ steps.psr.outputs.tag }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Python Semantic Release + id: psr + uses: python-semantic-release/python-semantic-release@v9 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + + bump-formula: + needs: release + if: needs.release.outputs.released == 'true' + runs-on: macos-latest # brew + BSD sed preinstalled + steps: + - uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Compute url + sha256 for the new tag + id: meta + run: | + TAG="${{ needs.release.outputs.tag }}" + URL="https://github.com/${GITHUB_REPOSITORY}/archive/refs/tags/${TAG}.tar.gz" + SHA=$(curl -fsSL "$URL" | shasum -a 256 | cut -d' ' -f1) + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "sha=$SHA" >> "$GITHUB_OUTPUT" + - name: Rewrite formula url + sha256 + run: | + sed -i '' -E "s|^ url \".*\"| url \"${{ steps.meta.outputs.url }}\"|" Formula/aai.rb + sed -i '' -E "s|^ sha256 \".*\"| sha256 \"${{ steps.meta.outputs.sha }}\"|" Formula/aai.rb + - name: Refresh resource stanzas + run: brew update-python-resources Formula/aai.rb + - name: Commit formula back to main + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/aai.rb + git commit -m "build(brew): aai ${{ needs.release.outputs.version }} [skip ci]" \ + || { echo "no formula changes"; exit 0; } + git push origin main +``` + +- [ ] **Step 4: Validate workflow YAML parses** + +Run: `brew install yq 2>/dev/null; yq '.jobs | keys' .github/workflows/release.yml` +Expected: `- release` and `- bump-formula`. (Confirms both jobs parse.) + +- [ ] **Step 5: Commit** + +```bash +git add pyproject.toml .github/workflows/release.yml +git commit -m "ci: release on merge to main via semantic-release + brew bump" +``` + +> **Loop protection (three layers, already wired above):** (1) `paths-ignore: [Formula/**]` so the formula commit can't trigger `release`; (2) `[skip ci]` in the bot commit message + the `if: !contains(...)` guard; (3) the bot commit type is `build(brew):`, which the conventional parser never treats as releasable. Any one of these alone prevents the infinite loop. +> +> **Branch-protection caveat:** if `main` is protected, `GITHUB_TOKEN` may be blocked from pushing the version bump (Step "release") and the formula commit (Step "bump-formula"). Either add `github-actions[bot]` to the bypass allowlist, or supply a fine-grained PAT as a secret and use it as the `token:`/`github_token:`. Verify after first run that both pushes land on `main`. + +--- + +## Appendix A — Offline resource generator from uv.lock (FALLBACK only) + +Use only if `brew update-python-resources` cannot resolve. **Caveat:** this emits the *exact* locked versions but does NOT evaluate platform markers — you must manually drop `pywin32-ctypes` and wrap `jeepney`/`secretstorage` in `on_linux` (see Task 3, Step 3). + +Create `scripts/generate_brew_resources.py`: + +```python +"""Emit Homebrew resource stanzas for aai-cli's runtime closure from uv.lock. +FALLBACK ONLY: does not evaluate platform markers. See plan Task 3 Step 3.""" +from __future__ import annotations + +import tomllib +from pathlib import Path + +lock = tomllib.loads(Path("uv.lock").read_text()) +pkgs = {p["name"]: p for p in lock["package"]} + +root = pkgs["aai-cli"] +queue = [d["name"] for d in root.get("dependencies", [])] +seen: set[str] = set() +while queue: + name = queue.pop() + if name in seen: + continue + seen.add(name) + queue.extend(d["name"] for d in pkgs.get(name, {}).get("dependencies", [])) +seen.discard("aai-cli") + +# Windows-only / drop; Linux-only / caller must wrap in on_linux: +WINDOWS_ONLY = {"pywin32-ctypes"} + +for name in sorted(seen): + if name in WINDOWS_ONLY: + continue + sdist = pkgs[name].get("sdist") + if not sdist: + print(f"# WARNING: {name} has no sdist (wheel-only) — handle manually") + continue + digest = sdist["hash"].removeprefix("sha256:") + print(f' resource "{name}" do') + print(f' url "{sdist["url"]}"') + print(f' sha256 "{digest}"') + print(" end") + print() +``` + +Run: `python3 scripts/generate_brew_resources.py > /tmp/aai-resources.rb` +Then proceed from Task 3, Step 2, and apply the Step 3 platform reconciliation manually. + +--- + +## Appendix B — cryptography build optimization + +If `cryptography` building from sdist (Rust + OpenSSL) is too slow/fragile, use Homebrew's prebuilt instead: + +1. In `Formula/aai.rb`, replace `depends_on "openssl@3"` region with: + ```ruby + depends_on "certifi" + depends_on "cryptography" => :no_linkage + ``` +2. Remove the `resource "cryptography"` block (it now comes from the brew formula). +3. Re-run Task 4, Step 1. + +This mirrors what `glances` and `datasette` do. + +--- + +## Self-Review + +- **Spec coverage:** "create the tap in this repo" → Task 1 (formula in `Formula/`), Task 5 Step 2 (`brew tap `). "virtualenv way" → `include Language::Python::Virtualenv` + resources (Tasks 1, 3). "no PyPI for our package" → `url` is the GitHub tag tarball (Task 1/2); only third-party deps reference PyPI (Task 3). Native deps (`portaudio`) → Task 1/4. "every merge to main releases a new version" → Task 6 (`python-semantic-release` on merge to `main` + auto formula bump; Conventional Commits → SemVer, so user-facing merges release). Name collision + audio risk → Decisions & Risks, Task 4 Step 3. Loop/branch-protection risks → Task 6 callouts. +- **Placeholder scan:** The only intentional placeholder is the dummy `sha256` in Task 1, explicitly resolved in Task 2 Step 3. No "TBD"/"add error handling"/"similar to" left in steps. +- **Type/name consistency:** Formula class `Aai`, file `Formula/aai.rb`, binary `aai`, test uses `aai version` consistently across Tasks 1, 4, 5. Resource generator references `uv.lock` keys (`package`, `dependencies`, `sdist.url`, `sdist.hash`) confirmed present in the actual lockfile. diff --git a/docs/superpowers/plans/2026-06-04-install-path-testing.md b/docs/superpowers/plans/2026-06-04-install-path-testing.md new file mode 100644 index 00000000..e6e7dc1b --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-install-path-testing.md @@ -0,0 +1,510 @@ +# Install-path Testing 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:** Test the public install path (`install.sh` → pipx/pip → working `aai`) in layers, so a broken entrypoint, dependency floor, or `install.sh` bug fails CI instead of shipping. + +**Architecture:** Three layers. (1) Fast shell-logic unit tests that run `install.sh` under a sandboxed PATH of fake shims — no network, in the default suite. (2) A small `AAI_SPEC` testability seam in `install.sh` so tests install the PR's own code from a locally-built wheel without pushing. (3) A real install-and-run smoke test (marked `install_script`) that builds a wheel, runs `install.sh` against it hermetically, and asserts `aai version` works — exercised by a new `install-smoke` CI job (both pipx/pip branches on Linux, pipx-only on macOS). + +**Tech Stack:** POSIX `sh` (`install.sh`), Python + pytest + `subprocess`, `uv build`, pipx, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-06-04-install-path-testing-design.md` + +**Note on commits:** `docs/` is in `.gitignore`, so the spec and this plan are untracked on purpose. Every commit below adds only real source files (never the plan/spec). + +--- + +### Task 1: Shell-logic unit tests + `AAI_SPEC` seam + +This is one red→green cycle: the test file asserts current behavior (passes immediately) **plus** the not-yet-existing `AAI_SPEC` override (fails). Step 3 adds the seam to make it green. + +**Files:** +- Create: `tests/test_install_sh.py` +- Modify: `install.sh:9-11` + +- [ ] **Step 1: Write the shell-logic test file** + +```python +"""Shell-logic unit tests for install.sh. + +Run install.sh under a sandboxed PATH of fake shims so we can assert *which* +installer it invokes and with *what* spec — without any real install or network. +The shims record their argv to files in a temp dir; the script's only external +dependencies (python3, pipx, and pip via `python -m pip`) are all faked. + +Fast; runs in the default suite. The real install-and-boot test lives in +test_install_script_smoke.py (marked `install_script`). +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +INSTALL_SH = Path(__file__).resolve().parent.parent / "install.sh" +DEFAULT_SPEC = "git+https://github.com/AssemblyAI/cli.git@main" + + +def _sh() -> str: + return shutil.which("sh") or "/bin/sh" + + +def _shim(path: Path, body: str) -> None: + path.write_text("#!/bin/sh\n" + body) + path.chmod(0o755) + + +def _python_shim(bindir: Path, *, version: str = "3.12.0", gate_ok: bool = True) -> None: + # Fakes the three ways install.sh calls python: + # -V → print a version (used in the <3.10 error message) + # -c '' → exit 0/1 to pass/fail the 3.10+ gate + # -m pip ... → record argv to pip.args (the pip --user fallback) + rec = bindir / "pip.args" + _shim( + bindir / "python3", + f'case "$1" in\n' + f' -V|--version) echo "Python {version}"; exit 0 ;;\n' + f" -c) exit {0 if gate_ok else 1} ;;\n" + f' -m) shift; echo "$@" > "{rec}"; exit 0 ;;\n' + f"esac\n" + f"exit 0\n", + ) + + +def _pipx_shim(bindir: Path) -> None: + rec = bindir / "pipx.args" + _shim(bindir / "pipx", f'echo "$@" > "{rec}"\nexit 0\n') + + +def _run(bindir: Path, env_extra: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + env = {"PATH": str(bindir)} + if env_extra: + env.update(env_extra) + return subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) + + +def test_errors_when_no_python(tmp_path): + # Empty bindir: no python3/python on PATH at all. + result = _run(tmp_path) + assert result.returncode == 1 + assert "Python 3.10+ is required" in result.stderr + + +def test_errors_when_python_too_old(tmp_path): + _python_shim(tmp_path, version="3.9.18", gate_ok=False) + result = _run(tmp_path) + assert result.returncode == 1 + assert "Python 3.10+ is required" in result.stderr + assert "3.9.18" in result.stderr + + +def test_uses_pipx_when_present(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + result = _run(tmp_path) + assert result.returncode == 0 + assert (tmp_path / "pipx.args").read_text().strip() == f"install --force {DEFAULT_SPEC}" + assert not (tmp_path / "pip.args").exists() # pip fallback not taken + + +def test_falls_back_to_pip_user_when_no_pipx(tmp_path): + _python_shim(tmp_path) # no pipx shim → `command -v pipx` fails + result = _run(tmp_path) + assert result.returncode == 0 + assert ( + (tmp_path / "pip.args").read_text().strip() + == f"pip install --user --upgrade {DEFAULT_SPEC}" + ) + + +def test_repo_and_ref_override_the_spec(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + _run(tmp_path, {"AAI_REPO": "me/fork", "AAI_REF": "dev"}) + assert ( + (tmp_path / "pipx.args").read_text().strip() + == "install --force git+https://github.com/me/fork.git@dev" + ) + + +def test_aai_spec_is_used_verbatim(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + _run(tmp_path, {"AAI_SPEC": "/tmp/aai_cli-0.1.0-py3-none-any.whl"}) + assert ( + (tmp_path / "pipx.args").read_text().strip() + == "install --force /tmp/aai_cli-0.1.0-py3-none-any.whl" + ) + + +def test_path_hint_when_aai_not_on_path(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + result = _run(tmp_path) # no `aai` shim → `command -v aai` fails + assert "isn't on your PATH yet" in result.stdout + + +def test_next_steps_when_aai_present(tmp_path): + _python_shim(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 +``` + +- [ ] **Step 2: Run the tests to confirm only the AAI_SPEC case fails** + +Run: `uv run pytest tests/test_install_sh.py -v` +Expected: 7 PASS, `test_aai_spec_is_used_verbatim` FAILS — the assertion sees the default git spec because `install.sh` ignores `AAI_SPEC` today. + +- [ ] **Step 3: Add the `AAI_SPEC` seam to install.sh** + +In `install.sh`, replace lines 9-11: + +```sh +REPO="${AAI_REPO:-AssemblyAI/cli}" +REF="${AAI_REF:-main}" +SPEC="git+https://github.com/${REPO}.git@${REF}" +``` + +with: + +```sh +REPO="${AAI_REPO:-AssemblyAI/cli}" +REF="${AAI_REF:-main}" +# AAI_SPEC (test-only) installs an arbitrary pip spec verbatim — e.g. a locally +# built wheel — instead of the public git URL, so tests can exercise this script +# against the current checkout without pushing. Unset for normal installs. +SPEC="${AAI_SPEC:-git+https://github.com/${REPO}.git@${REF}}" +``` + +- [ ] **Step 4: Run the tests to confirm all pass** + +Run: `uv run pytest tests/test_install_sh.py -v` +Expected: 8 PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_install_sh.py install.sh +git commit -m "test(install): shell-logic unit tests + AAI_SPEC seam for install.sh" +``` + +--- + +### Task 2: shellcheck gate in check.sh + +**Files:** +- Modify: `scripts/check.sh` (add a step before the pytest step) + +- [ ] **Step 1: Confirm install.sh is already clean** + +Run: `shellcheck install.sh` +Expected: no output, exit 0. (If shellcheck reports anything, fix `install.sh` accordingly before continuing — these should be minor, e.g. quoting.) + +- [ ] **Step 2: Add the shellcheck step to check.sh** + +In `scripts/check.sh`, immediately **before** the `echo "==> pytest ..."` block, insert: + +```bash +echo "==> shellcheck (install.sh)" +# Static-lint the public install script. CI's ubuntu runner ships shellcheck; +# locally it's skipped with a notice if not installed. +if command -v shellcheck >/dev/null 2>&1; then + shellcheck install.sh +else + echo " shellcheck not found; skipping (CI runs it)" +fi +``` + +- [ ] **Step 3: Run check.sh far enough to see shellcheck pass** + +Run: `shellcheck install.sh && echo OK` +Expected: `OK`. (Running the full `./scripts/check.sh` also works but is slower; the targeted command verifies the new gate.) + +- [ ] **Step 4: Commit** + +```bash +git add scripts/check.sh +git commit -m "ci: shellcheck install.sh in check.sh" +``` + +--- + +### Task 3: Real install-and-run smoke test + +**Files:** +- Create: `tests/test_install_script_smoke.py` +- Modify: `pyproject.toml:81-84` (register the `install_script` marker) +- Modify: `scripts/check.sh` (exclude `install_script` from the default pytest run) + +- [ ] **Step 1: Register the `install_script` marker** + +In `pyproject.toml`, in the `[tool.pytest.ini_options]` `markers = [...]` list (currently ending after the `install:` entry on line 83), add a third entry: + +```toml + "install_script: real install via install.sh from a locally-built wheel; asserts `aai` runs (slow; needs network + uv/pipx; skip otherwise)", +``` + +The list should now read: + +```toml +markers = [ + "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY + kokoro; skip otherwise)", + "install: install each init template's requirements.txt into a clean venv and import it (slow; needs network + uv; skip otherwise)", + "install_script: real install via install.sh from a locally-built wheel; asserts `aai` runs (slow; needs network + uv/pipx; skip otherwise)", +] +``` + +- [ ] **Step 2: Exclude the marker from the default pytest run** + +In `scripts/check.sh`, change the pytest invocation's marker filter from: + +```bash +uv run pytest -q -m "not e2e and not install" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +``` + +to: + +```bash +uv run pytest -q -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +``` + +Also update the comment just above it to list the new marker alongside the others: + +```bash +# Exclude e2e (live API key + kokoro), install (per-template dep install), and +# install_script (real install via install.sh). All are slow/networked and +# uncounted by coverage. Run them with: +# uv run pytest -m e2e +# uv run pytest -m install +# uv run pytest -m install_script +``` + +- [ ] **Step 3: Write the smoke test** + +Create `tests/test_install_script_smoke.py`: + +```python +"""Real install-and-run smoke test for install.sh. + +Builds a wheel from the checkout and runs install.sh against it (via the +test-only AAI_SPEC override) into a hermetic location, then asserts the +installed `aai` binary actually runs. This is the one check that exercises the +public install path end to end: dependency resolution, the console entrypoint, +and the pipx / pip --user branches in install.sh. + +Marked `install_script`: slow + needs network (deps resolve from PyPI) and +pipx/uv. Excluded from the default run; invoke explicitly:: + + uv run pytest -m install_script + +The two tests map to the two install branches; CI runs both on Linux and only +the pipx branch on macOS. +""" + +from __future__ import annotations + +import functools +import os +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +from aai_cli import __version__ + +pytestmark = pytest.mark.install_script + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "install.sh" + + +@functools.lru_cache(maxsize=1) +def _pypi_reachable() -> bool: + # Cached: both tests ask the same question, so probe the network once. + try: + urllib.request.urlopen("https://pypi.org/simple/", timeout=5) + return True + except (urllib.error.URLError, OSError): + return False + + +def _sh() -> str: + return shutil.which("sh") or "/bin/sh" + + +@pytest.fixture(scope="session") +def built_wheel(tmp_path_factory) -> Path: + # Skip (never fail) when the machine can't build the wheel — mirrors the + # template install test, so offline/sandboxed local runs aren't blocked. + if shutil.which("uv") is None: + pytest.skip("uv not on PATH; needed to build the wheel under test") + out = tmp_path_factory.mktemp("dist") + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(out)], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + wheels = list(out.glob("*.whl")) + assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}" + return wheels[0] + + +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) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == __version__ + + +def test_install_via_pipx(built_wheel: Path, tmp_path: Path) -> None: + if shutil.which("pipx") is None: + pytest.skip("pipx not on PATH; required for the pipx install branch") + if not _pypi_reachable(): + pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") + + pipx_bin = tmp_path / "pipx_bin" + # Inherit the real env so pipx/python resolve normally; the overrides keep + # the install hermetic (its own pipx home + an isolated bin dir). + env = { + **os.environ, + "AAI_SPEC": str(built_wheel), + "PIPX_HOME": str(tmp_path / "pipx_home"), + "PIPX_BIN_DIR": str(pipx_bin), + } + run = subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + _assert_aai_runs(pipx_bin / "aai") + + +def test_install_via_pip_user(built_wheel: Path, tmp_path: Path) -> None: + if not _pypi_reachable(): + pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") + + # Hermetic PATH with ONLY python3 → `command -v pipx` fails, forcing the + # pip --user fallback. pip --user honors PYTHONUSERBASE for the install root. + bindir = tmp_path / "bin" + bindir.mkdir() + python = shutil.which("python3") or sys.executable + (bindir / "python3").symlink_to(python) + userbase = tmp_path / "userbase" + env = { + "PATH": str(bindir), + "AAI_SPEC": str(built_wheel), + "PYTHONUSERBASE": str(userbase), + } + run = subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + _assert_aai_runs(userbase / "bin" / "aai") +``` + +- [ ] **Step 4: Run the smoke test (needs network + uv + pipx)** + +Run: `uv run pytest -m install_script -v` +Expected: `test_install_via_pipx` and `test_install_via_pip_user` both PASS. (If `pip --user` is rejected because `uv run` supplies a virtualenv interpreter, run instead with a non-venv Python: `python -m pytest -m install_script -v` after `pip install -e ".[dev]" uv pipx`. CI uses exactly that non-venv invocation — see Task 4.) + +- [ ] **Step 5: Confirm the default suite still excludes it** + +Run: `uv run pytest -q -m "not e2e and not install and not install_script" --co | tail -3` +Expected: collection completes and lists no `test_install_script_smoke` items. + +- [ ] **Step 6: Commit** + +```bash +git add tests/test_install_script_smoke.py pyproject.toml scripts/check.sh +git commit -m "test(install): real install-and-run smoke test (marker: install_script)" +``` + +--- + +### Task 4: `install-smoke` CI job + +**Files:** +- Modify: `.github/workflows/ci.yml` (add a new top-level job under `jobs:`) + +- [ ] **Step 1: Add the `install-smoke` job** + +In `.github/workflows/ci.yml`, append a new job to the `jobs:` map (after the `audit:` job). Reuse the same pinned action SHAs already used elsewhere in this file: + +```yaml + install-smoke: + name: install.sh real install (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + kfilter: "" # both branches: pipx + pip --user + - os: macos-latest + kfilter: "-k pipx" # pipx only — PEP 668 makes pip --user flaky on macOS + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: pip + + # `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' + run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg + - name: System deps (macOS) + if: runner.os == 'macOS' + run: brew install portaudio ffmpeg + + # Use the system interpreter (no virtualenv) so install.sh's `pip --user` + # branch is allowed. Editable install makes `aai_cli` importable for the + # test's __version__ check; uv builds the wheel; pipx drives the pipx branch. + - name: Tooling + run: python -m pip install -e ".[dev]" uv pipx + + - name: Real install smoke + run: python -m pytest -q -m install_script ${{ matrix.kfilter }} +``` + +- [ ] **Step 2: Validate the workflow YAML** + +Run: `python -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('yaml ok')"` +Expected: `yaml ok`. (If `actionlint` is installed, also run `actionlint .github/workflows/ci.yml` and expect no errors.) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "ci: add install-smoke job exercising install.sh end to end" +``` + +- [ ] **Step 4: Push and confirm the job runs green** + +```bash +git push +``` +Expected: on the PR, the new `install.sh real install (ubuntu-latest)` and `(macos-latest)` checks run; ubuntu runs both branches, macOS runs only the pipx branch; all green. (This is the real cross-OS validation — local runs can't prove the macOS path.) + +--- + +## Self-Review + +**Spec coverage:** +- Layer 0 (`AAI_SPEC` seam) → Task 1, Step 3. ✅ +- Layer 1 (fast shell-logic tests, in `check` job) → Task 1 (`tests/test_install_sh.py`, runs in default suite). ✅ +- shellcheck static gate → Task 2. ✅ +- Layer 2 (real install via marker `install_script`, skip-not-fail offline) → Task 3. ✅ +- Layer 3 (`install-smoke` CI job, ubuntu both branches + macOS pipx-only) → Task 4. ✅ +- Files-touched list in spec (install.sh, two test files, pyproject, check.sh, ci.yml) → all covered across Tasks 1–4. ✅ + +**Placeholder scan:** No TBD/TODO; every code step shows complete content; every command has an expected result. ✅ + +**Type/name consistency:** `DEFAULT_SPEC`, `_sh()`, `_shim()`, `_python_shim()`, `_pipx_shim()`, `_run()` used consistently in Task 1. `built_wheel` fixture and `_assert_aai_runs()` used consistently in Task 3. Marker name `install_script` identical across pyproject, check.sh, test `pytestmark`, and the CI `-m` filter. `AAI_SPEC` identical in install.sh and both test files. ✅ + +**Known risk surfaced inline:** `pip --user` is rejected inside a virtualenv — Task 3 Step 4 and Task 4 use a non-venv system interpreter to avoid this. macOS `pip --user`/PEP 668 deliberately excluded from the gating matrix (Task 4) per the spec. diff --git a/docs/superpowers/plans/2026-06-04-stytch-oauth-cli-login.md b/docs/superpowers/plans/2026-06-04-stytch-oauth-cli-login.md new file mode 100644 index 00000000..9df152b0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-04-stytch-oauth-cli-login.md @@ -0,0 +1,938 @@ +# Stytch OAuth CLI Login — 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:** Replace the interactive paste-an-API-key `aai login` with a browser-based Stytch B2B OAuth login that ends by storing a dedicated AssemblyAI API key in the keychain — leaving every other command unchanged. + +**Architecture:** New `assemblyai_cli/auth/` package owns a client-side, fixed-loopback-port browser flow: open Stytch B2B OAuth discovery → capture the discovery token on `127.0.0.1:8585` → walk the Accounts Management Service (AMS) `discover → exchange → /v1/auth → projects → tokens` chain to find-or-create an "AssemblyAI CLI" key → store it via the existing `config.set_api_key`. OAuth credentials are transient; only the API key is persisted (store-key-only). + +**Tech Stack:** Python, Typer, httpx (HTTP), stdlib `http.server` (loopback), `keyring` (storage), pytest + `typer.testing.CliRunner`. + +**Reference spec:** `docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md` + +--- + +## File Structure + +- Create `assemblyai_cli/auth/__init__.py` — package marker, re-exports `run_login_flow`. +- Create `assemblyai_cli/auth/endpoints.py` — all config constants (env-overridable) + URL helpers. +- Create `assemblyai_cli/auth/discovery.py` — build the Stytch B2B OAuth discovery start URL. +- Create `assemblyai_cli/auth/loopback.py` — fixed-port HTTP server that captures the callback token. +- Create `assemblyai_cli/auth/ams.py` — thin httpx client for the AMS endpoints. +- Create `assemblyai_cli/auth/flow.py` — orchestration: browser → loopback → AMS → api_key. +- Modify `assemblyai_cli/commands/login.py` — `login` runs the OAuth flow (keeps `--api-key` escape hatch); `logout`/`whoami` unchanged in behavior. +- Modify `pyproject.toml` — add `httpx` as a direct dependency. +- Create `tests/test_auth_endpoints.py`, `tests/test_auth_discovery.py`, `tests/test_auth_loopback.py`, `tests/test_auth_ams.py`, `tests/test_auth_flow.py`, and extend `tests/test_login.py`. + +**Verified setup (sandbox):** redirect `http://127.0.0.1:8585/callback` registered; Google provider live; public token `public-token-test-79ad7d8d-09df-495e-8eb8-72d18efdafa4`; AMS `https://ams.sandbox000.assemblyai-labs.com`. Defaults below match these so the flow runs against sandbox out of the box. + +--- + +## Task 1: Dependency + package skeleton + endpoints + +**Files:** +- Modify: `pyproject.toml` (dependencies list) +- Create: `assemblyai_cli/auth/__init__.py` +- Create: `assemblyai_cli/auth/endpoints.py` +- Test: `tests/test_auth_endpoints.py` + +- [ ] **Step 1: Add httpx as a direct dependency** + +In `pyproject.toml`, inside the `dependencies = [ ... ]` list, add the line (after `"keyring>=25.7.0",`): + +```toml + "httpx>=0.28.1", +``` + +- [ ] **Step 2: Sync the environment** + +Run: `uv sync` +Expected: resolves with `httpx` now a direct dep (it was already present transitively); no errors. + +- [ ] **Step 3: Write the failing test for endpoints** + +Create `tests/test_auth_endpoints.py`: + +```python +import importlib + +from assemblyai_cli.auth import endpoints + + +def test_redirect_uri_is_fixed_loopback(): + assert endpoints.redirect_uri() == "http://127.0.0.1:8585/callback" + + +def test_defaults_point_at_sandbox(): + assert endpoints.AMS_BASE_URL == "https://ams.sandbox000.assemblyai-labs.com" + assert endpoints.STYTCH_OAUTH_PROVIDER == "google" + assert endpoints.CLI_TOKEN_NAME == "AssemblyAI CLI" + assert endpoints.STYTCH_PUBLIC_TOKEN.startswith("public-token-") + + +def test_env_overrides_are_honored(monkeypatch): + monkeypatch.setenv("AAI_AUTH_AMS_URL", "http://localhost:8000") + monkeypatch.setenv("AAI_AUTH_PORT", "9999") + importlib.reload(endpoints) + try: + assert endpoints.AMS_BASE_URL == "http://localhost:8000" + assert endpoints.redirect_uri() == "http://127.0.0.1:9999/callback" + finally: + monkeypatch.delenv("AAI_AUTH_AMS_URL", raising=False) + monkeypatch.delenv("AAI_AUTH_PORT", raising=False) + importlib.reload(endpoints) +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `uv run pytest tests/test_auth_endpoints.py -v` +Expected: FAIL with `ModuleNotFoundError: assemblyai_cli.auth`. + +- [ ] **Step 5: Create the package marker** + +Create `assemblyai_cli/auth/__init__.py`: + +```python +from __future__ import annotations + +from assemblyai_cli.auth.flow import run_login_flow + +__all__ = ["run_login_flow"] +``` + +(Note: `flow` is created in Task 5. Until then, import it lazily — temporarily make `__init__.py` empty and add the re-export in Task 5. For now write an empty file.) + +Replace with an empty file for this task: + +```python +``` + +- [ ] **Step 6: Create endpoints.py** + +Create `assemblyai_cli/auth/endpoints.py`: + +```python +from __future__ import annotations + +import os + +# Stytch B2B project (sandbox defaults; override via env for prod). +STYTCH_PROJECT_DOMAIN = os.environ.get( + "AAI_AUTH_STYTCH_DOMAIN", + "https://psychedelic-journey-5884.customers.stytch.dev", +) +STYTCH_PUBLIC_TOKEN = os.environ.get( + "AAI_AUTH_PUBLIC_TOKEN", + "public-token-test-79ad7d8d-09df-495e-8eb8-72d18efdafa4", +) +STYTCH_OAUTH_PROVIDER = os.environ.get("AAI_AUTH_PROVIDER", "google") + +# Fixed loopback (Stytch does exact-match redirect validation; 8585 is registered). +LOOPBACK_HOST = "127.0.0.1" +LOOPBACK_PORT = int(os.environ.get("AAI_AUTH_PORT", "8585")) +LOOPBACK_PATH = "/callback" + +# Accounts Management Service. +AMS_BASE_URL = os.environ.get( + "AAI_AUTH_AMS_URL", "https://ams.sandbox000.assemblyai-labs.com" +) + +CLI_TOKEN_NAME = "AssemblyAI CLI" + + +def redirect_uri() -> str: + """The exact loopback redirect URL registered in Stytch.""" + return f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}{LOOPBACK_PATH}" +``` + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `uv run pytest tests/test_auth_endpoints.py -v` +Expected: PASS (3 tests). + +- [ ] **Step 8: Commit** + +```bash +git add pyproject.toml uv.lock assemblyai_cli/auth/__init__.py assemblyai_cli/auth/endpoints.py tests/test_auth_endpoints.py +git commit -m "feat(auth): add auth package skeleton and endpoint config" +``` + +--- + +## Task 2: Discovery start URL builder + +**Files:** +- Create: `assemblyai_cli/auth/discovery.py` +- Test: `tests/test_auth_discovery.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_auth_discovery.py`: + +```python +from urllib.parse import parse_qs, urlparse + +from assemblyai_cli.auth import discovery, endpoints + + +def test_build_start_url_targets_b2b_discovery_for_provider(): + url = discovery.build_start_url() + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.path == "/v1/b2b/public/oauth/google/discovery/start" + assert url.startswith(endpoints.STYTCH_PROJECT_DOMAIN) + + +def test_build_start_url_includes_public_token_and_redirect(): + url = discovery.build_start_url() + qs = parse_qs(urlparse(url).query) + assert qs["public_token"] == [endpoints.STYTCH_PUBLIC_TOKEN] + assert qs["discovery_redirect_url"] == ["http://127.0.0.1:8585/callback"] +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_auth_discovery.py -v` +Expected: FAIL with `ModuleNotFoundError: assemblyai_cli.auth.discovery`. + +- [ ] **Step 3: Create discovery.py** + +Create `assemblyai_cli/auth/discovery.py`: + +```python +from __future__ import annotations + +from urllib.parse import urlencode + +from assemblyai_cli.auth import endpoints + + +def build_start_url() -> str: + """The Stytch B2B OAuth discovery *start* URL the browser opens. + + Client-side endpoint authenticated by the project's public token. After the + user authenticates with the provider, Stytch redirects to our loopback + `discovery_redirect_url` with `?stytch_token_type=discovery_oauth&token=...`. + No custom state is appended: the redirect is exact-match validated and the + server is loopback-only, single-shot. + """ + base = ( + f"{endpoints.STYTCH_PROJECT_DOMAIN}" + f"/v1/b2b/public/oauth/{endpoints.STYTCH_OAUTH_PROVIDER}/discovery/start" + ) + params = { + "public_token": endpoints.STYTCH_PUBLIC_TOKEN, + "discovery_redirect_url": endpoints.redirect_uri(), + } + return f"{base}?{urlencode(params)}" +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `uv run pytest tests/test_auth_discovery.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/auth/discovery.py tests/test_auth_discovery.py +git commit -m "feat(auth): build Stytch B2B OAuth discovery start URL" +``` + +--- + +## Task 3: Loopback callback server + +**Files:** +- Create: `assemblyai_cli/auth/loopback.py` +- Test: `tests/test_auth_loopback.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_auth_loopback.py`: + +```python +import threading +import time +import urllib.request + +from assemblyai_cli.auth import endpoints, loopback + + +def _hit(path: str) -> None: + url = f"http://{endpoints.LOOPBACK_HOST}:{endpoints.LOOPBACK_PORT}{path}" + # Retry briefly until the server thread is bound. + for _ in range(50): + try: + urllib.request.urlopen(url, timeout=2).read() + return + except Exception: + time.sleep(0.05) + + +def test_capture_returns_token_and_type(): + result_box = {} + + def run(): + result_box["result"] = loopback.capture_callback(timeout=5.0) + + t = threading.Thread(target=run) + t.start() + _hit("/callback?stytch_token_type=discovery_oauth&token=tok_abc") + t.join(timeout=5) + + result = result_box["result"] + assert result.token == "tok_abc" + assert result.token_type == "discovery_oauth" + assert result.error is None + + +def test_capture_times_out_without_callback(): + result = loopback.capture_callback(timeout=0.3) + assert result.error == "timeout" + assert result.token is None +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_auth_loopback.py -v` +Expected: FAIL with `ModuleNotFoundError: assemblyai_cli.auth.loopback`. + +- [ ] **Step 3: Create loopback.py** + +Create `assemblyai_cli/auth/loopback.py`: + +```python +from __future__ import annotations + +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + +from assemblyai_cli.auth import endpoints + +_SUCCESS_HTML = ( + b"" + b"

Signed in.

You can close this tab and return to the terminal.

" + b"" +) + + +@dataclass +class CallbackResult: + token: str | None = None + token_type: str | None = None + error: str | None = None + + +def capture_callback(timeout: float = 120.0) -> CallbackResult: + """Bind the fixed loopback port, capture one OAuth callback, return its token. + + Returns a CallbackResult; `error="timeout"` if no callback arrives in time. + """ + result = CallbackResult() + done = threading.Event() + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 - stdlib API name + parsed = urlparse(self.path) + if parsed.path != endpoints.LOOPBACK_PATH: + self.send_response(404) + self.end_headers() + return + qs = parse_qs(parsed.query) + result.token = (qs.get("token") or [None])[0] + result.token_type = (qs.get("stytch_token_type") or [None])[0] + self.send_response(200) + self.send_header("Content-Type", "text/html") + self.end_headers() + self.wfile.write(_SUCCESS_HTML) + done.set() + + def log_message(self, *args: object) -> None: # silence stderr logging + pass + + server = HTTPServer((endpoints.LOOPBACK_HOST, endpoints.LOOPBACK_PORT), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + if not done.wait(timeout): + result.error = "timeout" + finally: + server.shutdown() + thread.join(timeout=5) + return result +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `uv run pytest tests/test_auth_loopback.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/auth/loopback.py tests/test_auth_loopback.py +git commit -m "feat(auth): add fixed-port loopback callback server" +``` + +--- + +## Task 4: AMS HTTP client + +**Files:** +- Create: `assemblyai_cli/auth/ams.py` +- Test: `tests/test_auth_ams.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_auth_ams.py` (uses httpx's `MockTransport` so no network): + +```python +import httpx +import pytest + +from assemblyai_cli.auth import ams +from assemblyai_cli.errors import APIError, NotAuthenticated + + +def _patch_transport(monkeypatch, handler): + real_client = httpx.Client + + def fake_client(*args, **kwargs): + kwargs["transport"] = httpx.MockTransport(handler) + return real_client(*args, **kwargs) + + monkeypatch.setattr(ams.httpx, "Client", fake_client) + + +def test_discover_posts_token_and_type(monkeypatch): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["body"] = request.read().decode() + return httpx.Response( + 200, + json={ + "organizations": [{"organization_id": "org_1", "organization_name": "Acme"}], + "email": "a@b.com", + "intermediate_session_token": "ist_123", + }, + ) + + _patch_transport(monkeypatch, handler) + out = ams.discover("tok_abc") + assert out["intermediate_session_token"] == "ist_123" + assert "/v2/auth/discover" in seen["url"] + assert "discovery_oauth" in seen["body"] + assert "tok_abc" in seen["body"] + + +def test_get_auth_sends_session_cookie(monkeypatch): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["cookie"] = request.headers.get("cookie", "") + return httpx.Response(200, json={"id": 42, "email": "a@b.com"}) + + _patch_transport(monkeypatch, handler) + out = ams.get_auth("sess_jwt_xyz") + assert out["id"] == 42 + assert "stytch_session_jwt=sess_jwt_xyz" in seen["cookie"] + + +def test_401_raises_not_authenticated(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"detail": "Invalid credentials"}) + + _patch_transport(monkeypatch, handler) + with pytest.raises(NotAuthenticated): + ams.discover("bad") + + +def test_500_raises_api_error_with_detail(monkeypatch): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, json={"detail": "Something went wrong"}) + + _patch_transport(monkeypatch, handler) + with pytest.raises(APIError) as exc: + ams.discover("x") + assert "Something went wrong" in str(exc.value) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_auth_ams.py -v` +Expected: FAIL with `ModuleNotFoundError: assemblyai_cli.auth.ams`. + +- [ ] **Step 3: Create ams.py** + +Create `assemblyai_cli/auth/ams.py`: + +```python +from __future__ import annotations + +from typing import Any + +import httpx + +from assemblyai_cli.auth import endpoints +from assemblyai_cli.errors import APIError, NotAuthenticated + +_TIMEOUT = 30.0 + + +def _detail(resp: httpx.Response) -> str: + try: + body = resp.json() + if isinstance(body, dict) and "detail" in body: + return str(body["detail"]) + except Exception: # noqa: BLE001 - non-JSON error body + pass + return resp.text or f"HTTP {resp.status_code}" + + +def _json_or_raise(resp: httpx.Response) -> Any: + if resp.status_code in (401, 403): + raise NotAuthenticated(f"AMS rejected the login ({resp.status_code}): {_detail(resp)}") + if resp.status_code >= 400: + raise APIError(f"AMS request failed ({resp.status_code}): {_detail(resp)}") + return resp.json() + + +def discover(token: str) -> dict[str, Any]: + """POST /v2/auth/discover with a discovery_oauth token -> {orgs, email, IST}.""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.post( + "/v2/auth/discover", + json={"token": token, "token_type": "discovery_oauth"}, + ) + return _json_or_raise(resp) + + +def exchange(intermediate_session_token: str, organization_id: str) -> dict[str, Any]: + """POST /v2/auth/exchange -> SignedInResponse {account, session_jwt, session_token}.""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.post( + "/v2/auth/exchange", + json={ + "intermediate_session_token": intermediate_session_token, + "organization_id": organization_id, + }, + ) + return _json_or_raise(resp) + + +def get_auth(session_jwt: str) -> dict[str, Any]: + """GET /v1/auth (session cookie) -> account incl. `id`.""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.get("/v1/auth", cookies={"stytch_session_jwt": session_jwt}) + return _json_or_raise(resp) + + +def list_projects(account_id: int, session_jwt: str) -> list[dict[str, Any]]: + """GET /v1/users/accounts/{id}/projects -> [{project, tokens[]}].""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.get( + f"/v1/users/accounts/{account_id}/projects", + cookies={"stytch_session_jwt": session_jwt}, + ) + return _json_or_raise(resp) + + +def create_token( + account_id: int, project_id: int, token_name: str, session_jwt: str +) -> dict[str, Any]: + """POST /v1/users/accounts/{id}/tokens -> TokenSchema incl. `api_key`.""" + with httpx.Client(base_url=endpoints.AMS_BASE_URL, timeout=_TIMEOUT) as client: + resp = client.post( + f"/v1/users/accounts/{account_id}/tokens", + json={"project_id": project_id, "token_name": token_name}, + cookies={"stytch_session_jwt": session_jwt}, + ) + return _json_or_raise(resp) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `uv run pytest tests/test_auth_ams.py -v` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add assemblyai_cli/auth/ams.py tests/test_auth_ams.py +git commit -m "feat(auth): add AMS http client (discover/exchange/auth/projects/tokens)" +``` + +--- + +## Task 5: Flow orchestration + +**Files:** +- Create: `assemblyai_cli/auth/flow.py` +- Modify: `assemblyai_cli/auth/__init__.py` +- Test: `tests/test_auth_flow.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_auth_flow.py`: + +```python +import pytest + +from assemblyai_cli.auth import flow, loopback +from assemblyai_cli.errors import APIError + + +def test_find_or_create_reuses_existing_cli_key(monkeypatch): + projects = [ + { + "project": {"id": 7}, + "tokens": [ + {"name": "Default Token", "api_key": "sk_default", "is_disabled": False}, + {"name": "AssemblyAI CLI", "api_key": "sk_cli", "is_disabled": False}, + ], + } + ] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + monkeypatch.setattr( + flow.ams, "create_token", lambda *a, **k: pytest.fail("should not create") + ) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_cli" + + +def test_find_or_create_creates_when_absent(monkeypatch): + projects = [{"project": {"id": 7}, "tokens": []}] + monkeypatch.setattr(flow.ams, "list_projects", lambda acct, jwt: projects) + + created = {} + + def fake_create(account_id, project_id, token_name, session_jwt): + created.update(project_id=project_id, token_name=token_name) + return {"api_key": "sk_new"} + + monkeypatch.setattr(flow.ams, "create_token", fake_create) + assert flow.find_or_create_cli_key(1, "jwt") == "sk_new" + assert created == {"project_id": 7, "token_name": "AssemblyAI CLI"} + + +def test_run_login_flow_happy_path(monkeypatch): + opened = {} + monkeypatch.setattr(flow, "_open_browser", lambda url: opened.setdefault("url", url)) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: { + "organizations": [{"organization_id": "org_1", "organization_name": "Acme"}], + "email": "a@b.com", + "intermediate_session_token": "ist", + }, + ) + monkeypatch.setattr( + flow.ams, + "exchange", + lambda ist, org: {"account": {"id": 9}, "session_jwt": "jwt", "session_token": "t"}, + ) + monkeypatch.setattr(flow.ams, "get_auth", lambda jwt: {"id": 9}) + monkeypatch.setattr(flow, "find_or_create_cli_key", lambda acct, jwt: "sk_final") + + assert flow.run_login_flow() == "sk_final" + assert opened["url"].startswith("https://") + + +def test_run_login_flow_timeout_raises(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr(flow, "_capture", lambda: loopback.CallbackResult(error="timeout")) + with pytest.raises(APIError, match="timed out"): + flow.run_login_flow() + + +def test_run_login_flow_zero_orgs_raises(monkeypatch): + monkeypatch.setattr(flow, "_open_browser", lambda url: None) + monkeypatch.setattr( + flow, + "_capture", + lambda: loopback.CallbackResult(token="tok", token_type="discovery_oauth"), + ) + monkeypatch.setattr( + flow.ams, + "discover", + lambda token: {"organizations": [], "email": "a@b.com", "intermediate_session_token": "ist"}, + ) + with pytest.raises(APIError, match="no AssemblyAI organization"): + flow.run_login_flow() +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `uv run pytest tests/test_auth_flow.py -v` +Expected: FAIL with `ModuleNotFoundError: assemblyai_cli.auth.flow`. + +- [ ] **Step 3: Create flow.py** + +Create `assemblyai_cli/auth/flow.py`: + +```python +from __future__ import annotations + +import webbrowser + +from assemblyai_cli import output +from assemblyai_cli.auth import ams, discovery, endpoints, loopback +from assemblyai_cli.errors import APIError + + +def _open_browser(url: str) -> None: + """Open the system browser, falling back to printing the URL.""" + output.console.print(f"Opening your browser to sign in:\n {url}") + try: + webbrowser.open(url) + except Exception: # noqa: BLE001 - opening a browser is best-effort + output.console.print( + "[aai.muted]Could not open a browser; open the URL above manually.[/aai.muted]" + ) + + +def _capture() -> loopback.CallbackResult: + return loopback.capture_callback() + + +def find_or_create_cli_key(account_id: int, session_jwt: str) -> str: + """Return the existing 'AssemblyAI CLI' key, or create one in the first project.""" + projects = ams.list_projects(account_id, session_jwt) + for entry in projects: + for token in entry.get("tokens", []): + if token.get("name") == endpoints.CLI_TOKEN_NAME and not token.get("is_disabled"): + return str(token["api_key"]) + if not projects: + raise APIError("Your account has no project to create an API key in.") + project_id = projects[0]["project"]["id"] + created = ams.create_token( + account_id, project_id, endpoints.CLI_TOKEN_NAME, session_jwt + ) + return str(created["api_key"]) + + +def run_login_flow() -> str: + """Drive the full browser + AMS login and return a usable AssemblyAI API key.""" + _open_browser(discovery.build_start_url()) + result = _capture() + + if result.error == "timeout": + raise APIError("Login timed out waiting for the browser. Run 'aai login' again.") + if result.token_type != "discovery_oauth" or not result.token: + raise APIError("Login did not return a valid OAuth token. Run 'aai login' again.") + + disc = ams.discover(result.token) + organizations = disc.get("organizations") or [] + if not organizations: + raise APIError( + "Signed in, but found no AssemblyAI organization for this account." + ) + organization_id = organizations[0]["organization_id"] + + signed_in = ams.exchange(disc["intermediate_session_token"], organization_id) + session_jwt = signed_in["session_jwt"] + + account = ams.get_auth(session_jwt) + return find_or_create_cli_key(int(account["id"]), session_jwt) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `uv run pytest tests/test_auth_flow.py -v` +Expected: PASS (5 tests). + +- [ ] **Step 5: Wire the package re-export** + +Replace the contents of `assemblyai_cli/auth/__init__.py` with: + +```python +from __future__ import annotations + +from assemblyai_cli.auth.flow import run_login_flow + +__all__ = ["run_login_flow"] +``` + +- [ ] **Step 6: Run the auth test suite** + +Run: `uv run pytest tests/test_auth_endpoints.py tests/test_auth_discovery.py tests/test_auth_loopback.py tests/test_auth_ams.py tests/test_auth_flow.py -v` +Expected: PASS (all). + +- [ ] **Step 7: Commit** + +```bash +git add assemblyai_cli/auth/flow.py assemblyai_cli/auth/__init__.py tests/test_auth_flow.py +git commit -m "feat(auth): orchestrate browser+AMS login into an API key" +``` + +--- + +## Task 6: Wire the `login` command to the OAuth flow + +**Files:** +- Modify: `assemblyai_cli/commands/login.py:17-48` (the `login` command) +- Test: extend `tests/test_login.py` + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_login.py`: + +```python +def test_login_oauth_flow_stores_returned_key(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.login.run_login_flow", lambda: "sk_from_oauth" + ) + result = runner.invoke(app, ["login"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_from_oauth" + + +def test_login_oauth_flow_failure_exits_nonzero(monkeypatch): + from assemblyai_cli.errors import APIError + + def boom(): + raise APIError("Login timed out waiting for the browser.") + + monkeypatch.setattr("assemblyai_cli.commands.login.run_login_flow", boom) + result = runner.invoke(app, ["login"]) + assert result.exit_code != 0 + assert config.get_api_key("default") is None + + +def test_login_api_key_flag_still_bypasses_oauth(monkeypatch): + monkeypatch.setattr( + "assemblyai_cli.commands.login.run_login_flow", + lambda: (_ for _ in ()).throw(AssertionError("OAuth must not run with --api-key")), + ) + with patch("assemblyai_cli.commands.login.client.validate_key", return_value=True): + result = runner.invoke(app, ["login", "--api-key", "sk_flag2"]) + assert result.exit_code == 0 + assert config.get_api_key("default") == "sk_flag2" +``` + +Note: the existing `test_login_interactive_prompts_when_no_flag` and `test_login_interactive_survives_browser_failure` tests describe the OLD paste-a-key behavior and are replaced by the OAuth flow. Delete those two tests (they patch `webbrowser`/`typer.prompt`, which the new flow doesn't use for interactive login). + +- [ ] **Step 2: Run the new tests to verify they fail** + +Run: `uv run pytest tests/test_login.py -v` +Expected: the three new tests FAIL with `AttributeError: ... has no attribute 'run_login_flow'`; the two deleted-old tests should be removed before running. + +- [ ] **Step 3: Rewrite the `login` command** + +In `assemblyai_cli/commands/login.py`, change the import block near the top to add `run_login_flow` and drop the now-unused `webbrowser`: + +Replace: + +```python +import webbrowser + +import typer +from rich.markup import escape + +from assemblyai_cli import client, config, output +from assemblyai_cli.context import AppState, resolve_profile, run_command +from assemblyai_cli.errors import APIError, NotAuthenticated +``` + +with: + +```python +import typer +from rich.markup import escape + +from assemblyai_cli import client, config, output +from assemblyai_cli.auth import run_login_flow +from assemblyai_cli.context import AppState, resolve_profile, run_command +from assemblyai_cli.errors import APIError, NotAuthenticated +``` + +Then replace the `login` command body (the `def body(...)` inside `login`) with: + +```python + """Authenticate via your browser (Stytch); stores a CLI API key.""" + + def body(state: AppState, json_mode: bool) -> None: + profile = resolve_profile(state) + if api_key: + # Non-interactive escape hatch for CI/automation. + if not client.validate_key(api_key): + raise APIError("That API key was rejected (HTTP 401). Check it and retry.") + key = api_key + else: + key = run_login_flow() + config.set_api_key(profile, key) + output.emit( + {"authenticated": True, "profile": profile}, + lambda _d: f"[aai.success]Authenticated[/aai.success] on profile '{escape(profile)}'.", + json_mode=json_mode, + ) + + run_command(ctx, body, json=json_out) +``` + +(Keep the `@app.command()` decorator and the `login(ctx, api_key, json_out)` signature exactly as they are; the docstring at the top of `body`'s function moves to the command — leave the existing parameter declarations untouched.) + +- [ ] **Step 4: Run the login tests to verify they pass** + +Run: `uv run pytest tests/test_login.py -v` +Expected: PASS (the three new tests + the unchanged flag/profile/whoami/logout tests). + +- [ ] **Step 5: Run the full suite + linters** + +Run: `uv run pytest -q && uv run ruff check assemblyai_cli tests && uv run mypy assemblyai_cli` +Expected: all green. (If mypy flags `httpx` types, they're shipped; no stub install needed.) + +- [ ] **Step 6: Commit** + +```bash +git add assemblyai_cli/commands/login.py tests/test_login.py +git commit -m "feat(auth): aai login uses Stytch OAuth, keeps --api-key escape hatch" +``` + +--- + +## Task 7: Manual end-to-end verification against sandbox + +**Files:** none (manual smoke test; this is the one link not provable in unit tests — see spec P-NEW). + +- [ ] **Step 1: Run a real login against sandbox** + +Run: `uv run aai login` +Expected: browser opens to Google; after sign-in, the terminal prints `Authenticated on profile 'default'.` + +- [ ] **Step 2: Confirm the key works** + +Run: `uv run aai whoami --json` +Expected: `reachable: true`, masked `api_key`. + +- [ ] **Step 3: Confirm find-or-create is idempotent** + +Run `uv run aai login` a second time; in the Stytch/AssemblyAI dashboard confirm only **one** key named `AssemblyAI CLI` exists (the second login reused it). + +- [ ] **Step 4: Note any deviations** + +If discover returns 0 orgs (brand-new account), `login` errors with "no AssemblyAI organization" — that confirms the org-creation branch (`/v2/auth/organization`) is needed; capture it as a follow-up (out of scope per spec). + +--- + +## Self-Review + +- **Spec coverage:** loopback+discovery (Tasks 2–3), AMS chain incl. find-or-create (Tasks 4–5), store-key-only via `config.set_api_key` (Task 6), `--api-key`/env escape hatches retained (Task 6), error handling for timeout/bad-token/0-orgs/401/500 (Tasks 4–5), config constants env-overridable (Task 1), testing approach (every task). `logout`/`whoami` are intentionally unchanged (spec: behavior unchanged) — no task needed; optional best-effort revoke is a documented follow-up, not built here (YAGNI for v1). +- **Placeholder scan:** none — every code/test step is complete. +- **Type consistency:** `CallbackResult` (loopback) used in flow + tests; `run_login_flow()`/`find_or_create_cli_key()` names match across flow + login + tests; AMS function names (`discover`/`exchange`/`get_auth`/`list_projects`/`create_token`) consistent across `ams.py`, `flow.py`, and tests. + +## Known follow-ups (not in this plan) +- 0-orgs → `/v2/auth/organization` create-and-sign-in branch. +- MFA / verify-email responses from `/v2/auth/exchange`. +- `logout` server-side revoke of the CLI token; storing `account_id`/`token_id` for precise revoke. +- Production project/AMS URLs + prod redirect registration (spec P2/O4). diff --git a/docs/superpowers/specs/2026-06-02-claude-command-design.md b/docs/superpowers/specs/2026-06-02-claude-command-design.md new file mode 100644 index 00000000..a98156c3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-claude-command-design.md @@ -0,0 +1,178 @@ +# `aai claude` — Wire up Claude Code for AssemblyAI + +**Date:** 2026-06-02 +**Status:** Approved design + +## Problem + +Developers building with AssemblyAI through a coding agent get better, more +current code when their agent is connected to AssemblyAI's live context. Two +artifacts already exist for this: + +1. **Docs MCP server** — remote, Streamable HTTP at + `https://mcp.assemblyai.com/docs`. Exposes `search_docs`, `get_pages`, + `list_sections`, `get_api_reference`. "Installing" it means registering the + URL in the client's MCP config. +2. **Claude Code skill** — the `AssemblyAI/assemblyai-skill` GitHub repo, a + `skills/assemblyai/` directory (a `SKILL.md` plus reference docs) installed + via the universal *skills* CLI: `npx skills add AssemblyAI/assemblyai-skill`, + landing in `~/.claude/skills/assemblyai/`. + +Today a developer must find the docs page and run the install steps by hand. We +want a single CLI command that wires both into Claude Code. + +### Discrepancy to flag (out of scope for this CLI change) + +The published docs (`coding-agent-prompts.mdx`, `agent-instructions.mdx`) +instruct users to run `claude install-skill ` and `claude skill list`. +**These subcommands do not exist** in Claude Code (verified against 2.1.161; +the real surface is `claude mcp …` and `claude plugin …`). The canonical skill +installer is `npx skills add AssemblyAI/assemblyai-skill`. The docs need a fix +independent of this work; noted here so it isn't lost. + +## Scope + +- **Target client:** Claude Code only. +- **Install method:** shell out to existing tools (`claude` for MCP, `npx + skills` for the skill) rather than writing config formats natively. +- **Out of scope:** Cursor/Windsurf/other clients; running our own MCP proxy + (AssemblyAI's MCP is remote-hosted, so there is nothing to run); fixing the + upstream docs. + +## Command surface + +A new `claude` command group in `assemblyai_cli/commands/claude.py`, registered in +`main.py`: + +```python +app.add_typer(claude.app, name="claude") +``` + +| Command | Behavior | +|---|---| +| `aai claude install` | Installs **both** the docs MCP server and the skill into Claude Code. | +| `aai claude status` | Reports whether each artifact is currently wired up. | +| `aai claude remove` | Unwinds both. | + +Flags: + +- `install`: `--scope {user,project,local}` (default `user`), `--force`, `--json`. +- `status`: `--json`. +- `remove`: `--scope {user,project,local}` (default `user`), `--json`. + +Rationale for naming: peer CLI Deepgram established the verb vocabulary +(`install` / `status` / `remove`) on its `skills` group. Its `mcp` vs `skills` +noun split does not fit here because Deepgram's `mcp` *runs* a stdio proxy, +whereas AssemblyAI's MCP is remote-hosted and only needs registering. "agent" +names the thing being configured (the user's coding agent) and unifies both +artifacts under the single install command requested. + +## Constants + +```python +MCP_NAME = "assemblyai-docs" +MCP_URL = "https://mcp.assemblyai.com/docs" +SKILL_REPO = "AssemblyAI/assemblyai-skill" +SKILL_DIR = Path.home() / ".claude" / "skills" / "assemblyai" # contains SKILL.md +``` + +## Behavior + +### `aai claude install` + +Two independent steps, each preflighted, run via `subprocess.run` (output +captured), and reported individually. + +1. **MCP step** — requires `claude` on PATH. + - Idempotency: check `claude mcp get assemblyai-docs`. If present, report + `already` and skip — unless `--force`, in which case `claude mcp remove + assemblyai-docs --scope ` then re-add. + - Install: + `claude mcp add --transport http --scope assemblyai-docs https://mcp.assemblyai.com/docs` +2. **Skill step** — requires `npx` on PATH. + - `npx skills add AssemblyAI/assemblyai-skill` (re-runnable; it updates / + de-dupes on its own, so `--force` simply re-runs it). + +### Dependency detection & graceful partial behavior + +- Detect each tool with `shutil.which` **before** running its step (`claude` + for the MCP step, `npx` for the skill step). +- A missing required tool makes that step `skipped`, with a one-line fix and the + exact command printed so the user can run it manually (e.g. "Install Node.js + to get `npx`", "Install Claude Code: https://claude.com/claude-code"). +- Steps are independent: a missing `npx` does not block the MCP install. +- Exit code is non-zero only when a step that *could* have run actually + **failed** — a step skipped for a missing tool does not fail the command. The + final summary states what succeeded, was skipped, and failed. + +### `aai claude status` + +- MCP present? — `claude mcp get assemblyai-docs` exit code (or parse `claude + mcp list`). If `claude` is missing, report MCP status as `unknown` with + guidance. +- Skill present? — `SKILL_DIR / "SKILL.md"` exists. +- Emit a two-row report (MCP, skill). + +### `aai claude remove` + +- MCP — `claude mcp remove assemblyai-docs --scope `. +- Skill — delete the `SKILL_DIR` directory directly. Removing a directory of + markdown is safe and avoids guessing the `skills` CLI's removal syntax. +- Report per artifact. Absent artifacts report `not installed` (not an error). + +## Error handling & output + +- Command bodies run through `context.run_command`; failures raise `CLIError` + with descriptive `error_type`s (e.g. `claude_not_found`, `npx_not_found`, + `mcp_install_failed`, `skill_install_failed`). +- Human and JSON output via `output.emit`; JSON mode auto-engages under agents + via the existing `output.resolve_json`. +- JSON shape: + +```json +{ + "steps": [ + { "name": "mcp", "status": "installed", "detail": "assemblyai-docs @ user scope" }, + { "name": "skill", "status": "already", "detail": "~/.claude/skills/assemblyai" } + ] +} +``` + +`status` values: `installed`, `already`, `skipped`, `failed`, `removed`, +`not_installed`, `unknown`. + +## File layout + +- `assemblyai_cli/commands/claude.py` — new command group (`install`, `status`, + `remove`) plus a small `_run(cmd: list[str]) -> subprocess.CompletedProcess` + helper and the per-step functions. +- `assemblyai_cli/commands/__init__.py` import + `main.py` registration. +- No new runtime dependencies (`subprocess`, `shutil`, `pathlib` are stdlib). + +## Testing + +`tests/test_claude.py`, mirroring `tests/test_samples.py`. All `subprocess.run` +and `shutil.which` calls are monkeypatched — no real `claude`/`npx` invocation. + +Cases: + +- `install` happy path: asserts exact argv for both steps, including + `--transport http --scope user`. +- `--scope project` / `--scope local` passthrough. +- Idempotency: MCP already present → `already`, no re-add; `--force` → remove + then add. +- Partial: `claude` missing → MCP `skipped`, skill still installs, exit 0; + `npx` missing → skill `skipped`, MCP installs, exit 0. +- Failure: `claude mcp add` non-zero exit → step `failed`, command exit non-zero. +- `--json` shape for `install`/`status`. +- `status`: each combination of MCP present/absent and skill dir present/absent. +- `remove`: removes both; absent artifacts report `not_installed`, not an error. +- Smoke: `aai claude --help` registers the subcommands. + +## Docs + +- Add an "AI coding agents" section to `README.md` documenting `aai claude + install`. +- Separately flag the upstream docs discrepancy (the non-existent `claude + install-skill` / `claude skill list`) to the docs owners. Not part of this + CLI change. diff --git a/docs/superpowers/specs/2026-06-02-streaming-design.md b/docs/superpowers/specs/2026-06-02-streaming-design.md new file mode 100644 index 00000000..11c76c20 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-streaming-design.md @@ -0,0 +1,104 @@ +# `aai stream` — Real-time Streaming Transcription Design + +**Date:** 2026-06-02 +**Status:** Approved for planning +**Repo:** standalone `assemblyai-cli` +**Builds on:** the v1 CLI (login/transcribe/get/list/samples). Streaming was listed as out-of-scope for v1 in the original design; this is that increment. + +## Goal + +Add `aai stream` for real-time transcription from either a **microphone** or an **audio file**, using AssemblyAI's v3 streaming API (`assemblyai.streaming.v3`). Success = a user runs `aai stream` and sees their speech transcribed live, or runs `aai stream recording.wav` and watches a file transcribe in real time — and the file path works with no microphone dependency (so it runs in CI and under agents). + +## Command + +``` +aai stream [SOURCE] + SOURCE Optional audio file path. Omit to use the microphone. + --sample-rate INT Audio sample rate in Hz (default 16000). + --device INT Microphone device index (mic mode only). + --json Emit newline-delimited JSON events instead of live text. +``` +Stop with Ctrl-C. Reuses the global `--profile` and existing key resolution. + +- **No SOURCE** → microphone capture (requires the optional `[mic]` extra; see Dependencies). +- **SOURCE given** → stream the file through the realtime endpoint, paced to real time. No microphone dependency. + +## Dependencies + +- **Microphone** path uses PyAudio via the SDK's `aai.extras.MicrophoneStream`. PyAudio is declared as an **optional extra**, not a base dependency: + ```toml + [project.optional-dependencies] + mic = ["pyaudio>=0.2.11"] + ``` + Installed with `pip install "assemblyai-cli[mic]"`. The base install and CI remain PyAudio-free. +- **File** path: + - **WAV / PCM** is read with the stdlib `wave` module — zero extra dependency. + - **Other formats** (mp3, m4a, …) are decoded by piping through an `ffmpeg` subprocess to signed 16-bit little-endian (`s16le`), mono, at the target sample rate. `ffmpeg` is a system tool, not a pip dependency. +- No new base Python dependencies are added. + +## Architecture + +Two interchangeable **stream sources** behind one interface — an iterator yielding raw PCM `bytes` chunks — both consumed by the SDK's `client.stream(source)`. + +``` +assemblyai_cli/ + streaming/ + __init__.py + sources.py # MicSource, FileSource, and the dependency/format errors + client.py # gains stream_audio(...) — the v3 StreamingClient wiring (sole SDK boundary) + commands/ + stream.py # the `aai stream` Typer command (thin) +``` + +### Components + +- **`streaming/sources.py`** + - `MicSource(sample_rate, device)` — context manager / iterator wrapping `aai.extras.MicrophoneStream`. Imports PyAudio lazily; if the import fails, raises `MicDependencyMissing` (mapped to `CLIError`, exit 2, message: `Microphone support isn't installed. Run: pip install 'assemblyai-cli[mic]'`). + - `FileSource(path, sample_rate)` — iterator yielding fixed-size PCM chunks, sleeping between chunks to match real-time playback duration. WAV/PCM via `wave`; non-WAV via an `ffmpeg` subprocess (`ffmpeg -i -f s16le -acodec pcm_s16le -ac 1 -ar -`). If the file is not WAV and `ffmpeg` is not on `PATH`, raises `FfmpegMissing` (→ `CLIError`, exit 2, with guidance). A missing file raises `CLIError` (exit 2). + - Both expose the same iteration contract so the command treats them uniformly. +- **`client.py` → `stream_audio(api_key, source, *, sample_rate, on_begin, on_turn, on_termination, speech_model)`** — the only module importing the SDK. Builds `StreamingClient(StreamingClientOptions(api_key=...))`, registers `StreamingEvents.Begin/Turn/Termination/Error` handlers, calls `connect(StreamingParameters(sample_rate=..., format_turns=True, speech_model=...))` (defaults to `SpeechModel.universal_streaming_multilingual`), then `client.stream(source)`, and always `disconnect(terminate=True)` in a `finally` to flush queued audio and elicit final Turn + Termination events from the server. Streaming `Error` events are collected and raised as `APIError` after disconnect. +- **`commands/stream.py`** — thin: resolves the API key (`config.resolve_api_key`), chooses `FileSource` when a path is given else `MicSource`, defines the render callbacks (below), and calls `client.stream_audio(...)` inside a `try/except KeyboardInterrupt` for a clean Ctrl-C exit. + +## Data flow + +``` +aai stream aai stream clip.mp3 + └─ MicSource (PyAudio) └─ FileSource (wave | ffmpeg → PCM, real-time paced) + │ │ + └──────────── PCM byte chunks ──┘ + │ + client.stream_audio → StreamingClient (v3 ws) + │ Begin / Turn / Termination / Error events + ▼ + render callback ── human: live-updating turn line + └ --json/agent: NDJSON event per line +``` + +## Output behavior + +- **Human / TTY:** the in-progress turn renders as a single line that updates in place; when a turn ends (`end_of_turn`), it is finalized and the next turn starts on a new line. A `Begin` prints a short "listening…" notice; `Termination`/Ctrl-C prints a brief summary. +- **JSON / agent** (`--json`, or piped/CI/agent via the existing `output.resolve_json`): newline-delimited JSON, one object per event, e.g. `{"type": "turn", "transcript": "...", "end_of_turn": true}`, `{"type": "begin", "id": "..."}`, `{"type": "termination"}`. Streaming output bypasses the one-shot `output.emit` (which is for a single result) but uses `resolve_json` for the mode decision. + +## Error handling + +- Missing `[mic]` extra → `CLIError` exit 2 with the `pip install "assemblyai-cli[mic]"` hint. +- Non-WAV file + no `ffmpeg` → `CLIError` exit 2 with the ffmpeg/WAV guidance. +- Missing/unreadable file → `CLIError` exit 2. +- Unauthenticated → existing `NotAuthenticated` (exit 2). +- Streaming `Error` event → `APIError` (exit 1), surfaced through the command's error path. +- Ctrl-C → graceful `disconnect()` and exit 0. + +## Testing + +No live microphone or websocket in the automated suite. + +- **FileSource (real):** with a tiny generated 16-bit PCM WAV fixture, assert it yields the expected number/size of PCM chunks and that total bytes match the audio length. (Pacing sleeps are patched to no-ops so tests stay fast.) +- **FileSource ffmpeg path:** patch the `ffmpeg` subprocess to a fake emitting known PCM; assert chunks flow. Assert `FfmpegMissing` → `CLIError` when `shutil.which("ffmpeg")` returns None for a non-WAV file. +- **MicSource missing dep:** patch the PyAudio import to raise → assert `MicDependencyMissing` → `CLIError` exit 2 with the install message. +- **Rendering:** a `render_turn(event, *, json_mode)` pure function — fake `Turn`-like events → correct human line vs NDJSON. +- **Command wiring:** patch `client.stream_audio` to drive the registered callbacks with fake `Begin`/`Turn`/`Termination` events → assert human and `--json` output, and that a streaming `Error` yields a non-zero exit. +- **Live mic** test: marked manual / `requires_auth`, not run by default. + +## Out of scope (v1 streaming) + +Word-level timestamps display, partial-word formatting toggles, PII redaction streaming policy, saving the stream to a file, multi-channel audio, and choosing the speech model — all deferred. The v3 API exposes these; this increment ships mic + file with live turns and JSON events. diff --git a/docs/superpowers/specs/2026-06-02-voice-agent-design.md b/docs/superpowers/specs/2026-06-02-voice-agent-design.md new file mode 100644 index 00000000..02e65781 --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-voice-agent-design.md @@ -0,0 +1,146 @@ +# `aai agent` — Voice Agent (speech-in/speech-out) Design + +**Date:** 2026-06-02 +**Status:** Approved for planning +**Repo:** standalone `assemblyai-cli` +**Builds on:** the v1 CLI (login/transcribe/get/list/samples) and the `aai stream` increment. This adds a live two-way **voice conversation** against AssemblyAI's Voice Agent API. + +## Goal + +Add `aai agent` for a real-time two-way voice conversation in the terminal: the user speaks into the microphone, the agent replies through the speakers, and the live transcript prints to the screen. Success = a user runs `aai agent`, sees a connect/greeting, talks, and hears the agent talk back, with the conversation transcript on screen. + +The Voice Agent API is a single raw WebSocket (`wss://agents.assemblyai.com/v1/ws`) carrying speech in and speech out: PCM16 mono **24 kHz**, base64-encoded. Unlike `aai stream` (which uses the SDK's `StreamingClient`), there is **no AssemblyAI Python SDK** for this endpoint, so we speak the protocol directly over a WebSocket. + +## Command + +``` +aai agent [OPTIONS] + --voice TEXT Agent voice (default: ivy). See --list-voices. + --prompt TEXT System prompt. Default: a friendly casual-assistant persona. + --prompt-file PATH Read the system prompt from a file (overrides --prompt). + --greeting TEXT Spoken greeting (default: "Hey, what's on your mind?"). + --full-duplex Keep the mic open while the agent speaks (true barge-in). + Requires headphones. Default is half-duplex. + --sample-rate INT Microphone capture rate in Hz (default 24000). + --device INT Microphone device index. + --list-voices Print known voice IDs and exit. + --json Emit newline-delimited JSON events instead of live text. +``` + +Stop with **Ctrl-C** (clean exit 0, matching `aai stream`). Reuses the global `--profile` and the existing key resolution (`config.resolve_api_key`). + +### Echo / duplex behavior + +A terminal app has no browser acoustic echo cancellation (AEC), so on open speakers the agent hears its own voice and interrupts itself. The default mitigates this without requiring headphones: + +- **Half-duplex (default):** the microphone is *muted* — captured frames are dropped, not sent — for the span between `reply.started` and `reply.done`. The agent therefore never hears its own playback. Barge-in / interruptions are disabled in this mode. +- **`--full-duplex`:** the microphone always streams. Local playback is flushed on `input.speech.started` (and on an interrupted `reply.done`) so barge-in feels snappy. Intended for headphone use; a one-time tip says so. + +On start (human mode) the command prints a one-time note explaining the active mode and the headphone recommendation for `--full-duplex`. + +## Dependencies + +- **Audio I/O** (mic capture *and* speaker playback) uses **PyAudio**, already declared as the optional **`[mic]`** extra (`pip install "assemblyai-cli[mic]"`). PyAudio provides both input and output streams, so no new audio dependency is needed. `aai agent` requires this extra and emits the same friendly "not installed" `CLIError` (exit 2) as `aai stream` when it is missing. +- **WebSocket:** the synchronous client `websockets.sync.client.connect`. `websockets` is already present transitively (the `assemblyai` SDK requires `websockets>=11`); we add an explicit `websockets>=13` to base dependencies to guarantee the sync API (added in 13.0). No new third-party library name is introduced. +- No other new dependencies. + +## Architecture + +A new `agent/` package mirroring `streaming/`, a thin `commands/agent.py`, and a single WebSocket/protocol boundary in `agent/session.py`. + +``` +assemblyai_cli/ + agent/ + __init__.py + session.py # VoiceAgentSession: WS connect, session.update, receive/dispatch loop + audio.py # MicCapture (SDK MicrophoneStream @24k) + Player (PyAudio output) + flush + render.py # AgentRenderer: live transcript lines (human) / NDJSON (agent) + voices.py # static VOICES list for --list-voices and --voice validation + commands/agent.py # the `aai agent` Typer command (thin, like stream.py) +``` + +### Concurrency model — synchronous WebSocket + threads + +Chosen to match the codebase's existing synchronous, blocking-iterator style (`StreamingClient.stream(source)`, `MicrophoneStream`). Three concurrent flows around one `websockets.sync` connection: + +1. **Receive loop** (main thread): iterates inbound WS messages and dispatches by `type` to renderer / audio / mute-state callbacks. +2. **Capture thread:** iterates `MicrophoneStream` (blocking), base64-encodes each chunk, and sends `input.audio` — *only after `session.ready`*, and *only when not muted* (half-duplex gating). +3. **Playback thread:** drains a `queue.Queue` of decoded `reply.audio` PCM to the PyAudio output stream. + +`send` (capture thread) and `recv` (main thread) act on the connection from different threads; the sync `websockets` connection serializes writes internally, and reads/writes are independent directions, so this is safe. A shared `threading.Event`/flag carries the half-duplex mute state and the "session ready" gate. + +**Alternative considered — asyncio + async `websockets`:** one event loop with mic/playback bridged via `run_in_executor`. Rejected: it introduces an async idiom nothing else in the CLI uses and adds real complexity bridging blocking PyAudio in/out to the loop, for no user-visible benefit. + +### Components + +- **`agent/session.py` → `VoiceAgentSession`** — the only module that opens the WebSocket. Connects with `websockets.sync.client.connect("wss://agents.assemblyai.com/v1/ws", additional_headers={"Authorization": f"Bearer {api_key}"})` using the raw API key — the form shown in the docs' Python `session.resume` example (the browser quickstart's `?token=` query param and the temporary-token endpoint are browser concerns; out of scope). Implementation verifies this header form against a live connect during the first manual test. On open it sends one `session.update` with `system_prompt`, `greeting`, and `output.voice`. It runs the receive loop, dispatching to injected callbacks for each event type; starts/stops the capture and playback threads; and maps WS close codes / `session.error` codes to `APIError`/`CLIError`. Defensive: unknown event types are ignored; `tool.call` is ignored (no tools are configured, so it should not occur). +- **`agent/audio.py`** + - `MicCapture(sample_rate=24000, device)` — wraps the SDK's `MicrophoneStream`; lazy PyAudio import with the shared `[mic]`-missing `CLIError` (exit 2). Yields PCM byte chunks. + - `Player(sample_rate=24000)` — opens a PyAudio **output** stream; `enqueue(pcm_bytes)` adds to a queue, a worker thread writes to the stream; `flush()` clears the queue and stops the current write (interruption). `close()` tears down the stream. +- **`agent/render.py` → `AgentRenderer`** — human mode: a speaker indicator plus a two-color transcript flow — user partials (`transcript.user.delta`) update in place and finalize on `transcript.user`; `transcript.agent` prints the agent line. Reuses `StreamRenderer`'s in-place-line (`\r\x1b[K`) technique. JSON mode: NDJSON, one object per event; audio bytes are never emitted. +- **`agent/voices.py`** — a static list of known voice IDs (from the quickstart) backing `--list-voices` and local `--voice` validation (an unknown voice still ultimately surfaces the server's `invalid_value`, but we catch obvious typos early). +- **`commands/agent.py`** — thin: resolves the API key, reads `--prompt-file` if given, builds the session config, constructs `AgentRenderer`/`Player`/`MicCapture`, runs `VoiceAgentSession` inside `try/except KeyboardInterrupt` for a clean Ctrl-C exit. `--list-voices` short-circuits before any connection. Wrapped by the existing `run_command` for CLIError → exit-code mapping. + +## Data flow + +``` +aai agent + └─ MicCapture (PyAudio @24k) ── PCM chunks ──┐ (gated: after session.ready, unmuted) + ▼ + base64 → input.audio ──► WS ◄── session.update (on open) + │ + server events (session.ready / speech.* / transcript.* / reply.* / error) + ▼ + VoiceAgentSession dispatch + ├─ transcript.* → AgentRenderer (human line / NDJSON) + ├─ reply.audio → Player.enqueue → PyAudio output + ├─ reply.started → (half-duplex) mute mic + └─ reply.done → (half-duplex) unmute; if interrupted, Player.flush() +``` + +## Event handling + +Server → client events and their effects: + +| Event | Human render | Audio / state | +| --- | --- | --- | +| `session.ready` | "Connected. Speak now. (Ctrl-C to stop)" | open gate; start capture/playback | +| `input.speech.started` | user-speaking indicator | full-duplex: `Player.flush()` | +| `input.speech.stopped` | clear indicator | — | +| `transcript.user.delta` | update user partial line in place | — | +| `transcript.user` | finalize user line | — | +| `reply.started` | agent-speaking indicator | half-duplex: mute mic | +| `reply.audio` | — | `Player.enqueue(decode(data))` | +| `transcript.agent` | print agent line | — | +| `reply.done` | clear indicator | half-duplex: unmute mic; if `status=="interrupted"`: `Player.flush()` | +| `session.error` | error message | map code → APIError/CLIError | + +Client → server: `session.update` (once, on open) and `input.audio` (streamed, gated). + +## Output behavior + +- **Human / TTY:** connect/greeting notice, speaker indicators, in-place user partials finalized per utterance, agent lines printed as they arrive; Ctrl-C prints a brief "Stopped." and exits 0. +- **JSON / agent** (`--json`, or auto via the existing `output.resolve_json`): newline-delimited JSON, one object per server event (e.g. `{"type":"transcript.user","text":"..."}`, `{"type":"transcript.agent","text":"...","interrupted":false}`, `{"type":"reply.done"}`). Audio payloads are omitted. As with streaming, this bypasses the one-shot `output.emit` but uses `resolve_json` for the mode decision. + +## Error handling + +- Missing `[mic]` extra → `CLIError` exit 2 with the `pip install "assemblyai-cli[mic]"` hint (shared with streaming). +- `UNAUTHORIZED` / `FORBIDDEN` (WS close 1008) or bad key → `CLIError` exit 2, consistent with the CLI's existing auth-error treatment. +- `session.error` (e.g. `invalid_value`, `invalid_config`) or unexpected close (1011) → `APIError` exit 1, surfaced through the command's error path. +- `--prompt-file` unreadable / missing → `CLIError` exit 2. +- Ctrl-C → graceful close of the WebSocket and audio streams, exit 0. + +## Testing + +No live microphone, speakers, or WebSocket in the automated suite. + +- **AgentRenderer:** fake events → assert human lines vs NDJSON, that user partials update in place and finalize, and that audio bytes never appear in JSON output. +- **Session dispatch:** feed a scripted list of fake server messages into the dispatch loop (no real socket) → assert the correct renderer/audio/mute callbacks fire in order, including half-duplex mute on `reply.started`, unmute on `reply.done`, and `Player.flush()` on an interrupted `reply.done` and (full-duplex) on `input.speech.started`. +- **Player:** with a fake PyAudio stream, assert `enqueue` decodes + writes and `flush()` clears the queue / stops current write. +- **MicCapture:** patch the PyAudio/`MicrophoneStream` import to raise → assert the `[mic]`-missing `CLIError` exit 2. +- **Command wiring:** patch `VoiceAgentSession` to drive callbacks with fake events → assert human and `--json` output and exit codes; assert `--list-voices` prints the list and exits without connecting; assert `--prompt-file` is read and overrides `--prompt`. +- **Live conversation** test: marked manual / `requires_auth`, not run by default. + +## Out of scope (v1) + +Tool / function calling, `reply.create`, `session.resume` / reconnect, temporary-token generation, mid-session reconfiguration, turn-detection tuning flags, output-volume control, Twilio phone integration, file-input mode (a live conversation needs a live mic), and full-duplex gain-ducking. The API supports these; this increment ships a working two-way voice conversation with selectable voice, prompt, and greeting. diff --git a/docs/superpowers/specs/2026-06-03-cli-color-theme-design.md b/docs/superpowers/specs/2026-06-03-cli-color-theme-design.md new file mode 100644 index 00000000..8af99e80 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-cli-color-theme-design.md @@ -0,0 +1,140 @@ +# CLI Color Theme — Design + +**Date:** 2026-06-03 +**Status:** Approved + +## Goal + +Give `aai` a single, idiomatic Rich color theme and apply it consistently to all +human-facing output. Today color is ad-hoc: inline `[red]` / `[yellow]` / +`[green]` / `[dim]` markup scattered across a few command files, while the +streaming and Voice Agent renderers print fully uncolored `Text`. There is no +central palette. + +Direction chosen with the user: **brand accent + semantic palette**, applied at +**full scope** (role/speaker labels, lifecycle notices, table status values, +errors, and notices). Transcript body text stays default for readability. + +## Non-goals + +- No new user-facing flags (no `--no-color`/`--color`). Rich already honors + `NO_COLOR` and disables ANSI on non-TTYs; the agentic/CI path routes to JSON. +- No restyling of JSON output. JSON stays plain, pipe-safe NDJSON / `json.dumps`. +- No unrelated refactoring of the command modules. + +## Architecture + +### New module: `assemblyai_cli/theme.py` + +Single source of truth. Exports: + +- `BRAND = "#2545D3"` — AssemblyAI brand accent, defined once so it can be + swapped in one place. +- `THEME: rich.theme.Theme` mapping **semantic style names** so call sites never + hard-code raw colors again: + - `aai.brand` → `bold #2545D3` + - `aai.heading` → `bold #2545D3` + - `aai.label` → `#2545D3` (role/speaker label prefixes) + - `aai.success` → `green` + - `aai.error` → `bold red` + - `aai.warn` → `yellow` + - `aai.muted` → `dim` + - `aai.speaker.0` … `aai.speaker.N` → a small rotating palette + (e.g. brand blue, cyan, magenta, green, yellow) for distinct, deterministic + per-speaker label colors. +- `SPEAKER_STYLES: tuple[str, ...]` — the speaker style names in rotation order. +- `make_console(file=None) -> rich.console.Console` — builds every `Console` + **with `theme=THEME` attached**, so style names resolve globally. +- `speaker_style(speaker) -> str` — deterministic map from a speaker id + (e.g. `"A"`, `"B"`, `0`, `1`) to one of `SPEAKER_STYLES`. +- `status_style(status: str) -> str` — map a transcript/step status string to a + style name: `completed`/`installed`/`removed`/`ok` → `aai.success`; + `error`/`failed` → `aai.error`; `queued`/`processing`/in-progress → `aai.warn`; + otherwise `aai.muted`. + +### Routing all consoles through the theme + +- `output.console` is created via `theme.make_console()`. +- `BaseRenderer._console_obj()` creates its per-stream console via + `theme.make_console(file=self.out)` instead of bare `Console(file=...)`. + +This is the only structural change; everything else is markup/style edits. + +## Component changes + +- **`output.py`** + - `console = theme.make_console()`. + - `emit_error`: `[red]Error:[/red]` → `[aai.error]Error:[/aai.error]`. + +- **`render.py` (`BaseRenderer`)** + - Line helpers (`_update_line`, `_finalize_line`, `_line`) accept `str | Text` + instead of only `str`. When given a `str` they wrap in `Text(text)` as today + (no markup parsing — preserves current behavior); when given a `Text` they + use it directly. `stopped()` renders "Stopped." in `aai.muted`. + +- **`streaming/render.py` (`StreamRenderer`)** + - `begin`: "Listening… (Ctrl-C to stop)" in `aai.muted`. + - `turn`: body text stays default. + - `llm`: `💡 …` line in `aai.brand`. + +- **`agent/render.py` (`AgentRenderer`)** + - `connected`: notice in `aai.muted`. + - `user_partial`/`user_final`: `you:` label in `aai.label`, body default + (build a `Text` with a styled label span). + - `agent_transcript`: `agent:` label in `aai.label`, body default. + +- **`commands/transcribe.py`** + - `_render_transcript`: each `Speaker X:` label styled via + `theme.speaker_style(u["speaker"])`; body default. Returns a `Text` (or + themed markup) rather than a plain escaped string. Plain (non-diarized) + text path is unchanged. + +- **`commands/transcripts.py`** + - `list` table: header styled `aai.heading`; the `status` cell colored via + `theme.status_style(...)`. + +- **`commands/llm.py`** — output body unchanged (plain model text), but uses the + themed console for free; no markup added. + +- **`commands/login.py`** — `[green]Authenticated[/green]` → `[aai.success]`; + `[dim]…[/dim]` → `[aai.muted]`. + +- **`commands/samples.py`** — `[yellow]Note:[/yellow]` → `[aai.warn]`. + +- **`commands/claude.py`** — `_render_steps`: each step's status colored via + `theme.status_style(s["status"])`; heading in `aai.heading`. + +## Data flow + +Command body → `output.emit(data, renderer, json_mode)`: +- JSON mode → unchanged plain JSON. +- Human mode → `console.print(renderer(data))`, where `console` carries `THEME`, + so any `[aai.*]` markup or `style="aai.*"` `Text` resolves to the palette. + Rich strips ANSI automatically when the console's file is not a TTY. + +## Error handling + +No new failure modes. Unknown status strings fall through `status_style` to +`aai.muted`. `speaker_style` is total over any hashable speaker id via modulo +over `SPEAKER_STYLES`. + +## Testing (TDD — tests first) + +New `tests/test_theme.py`: +- `THEME` resolves each named style without raising. +- `status_style` maps representative statuses to the right style names, + including the unknown-status → `aai.muted` fallback. +- `speaker_style` is deterministic and stays within `SPEAKER_STYLES`. + +Renderer/command tests use a **forced-terminal** themed console +(`make_console` + `force_terminal=True`) and assert that styled output contains +the expected ANSI/markup for labels and statuses; existing plain-capture tests +(non-TTY) keep passing because Rich emits no ANSI there. Run the full existing +suite to confirm no regressions in the many `tests/test_*` assertions. + +## Files + +- New: `assemblyai_cli/theme.py`, `tests/test_theme.py` +- Edit: `output.py`, `render.py`, `streaming/render.py`, `agent/render.py`, + `commands/{transcribe,transcripts,llm,login,samples,claude}.py`, + and the corresponding `tests/test_*` files. diff --git a/docs/superpowers/specs/2026-06-03-full-sdk-options-design.md b/docs/superpowers/specs/2026-06-03-full-sdk-options-design.md new file mode 100644 index 00000000..55ee8d39 --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-full-sdk-options-design.md @@ -0,0 +1,219 @@ +# Full SDK option coverage for `transcribe` and `stream` + +**Date:** 2026-06-03 +**Status:** Approved design, ready for implementation plan + +## Problem + +The CLI currently exposes a tiny slice of the AssemblyAI SDK. `transcribe` offers +only `--speaker-labels` and `--prompt`; `stream` offers `--sample-rate`, `--device`, +and `--prompt`. Meanwhile the SDK's `TranscriptionConfig` exposes ~60 options (PII +redaction, content safety, topic detection, sentiment, auto-chapters, entity +detection, summarization, language detection, custom spelling, word boost, keyterms, +multichannel, audio slicing, webhooks, speaker options, Speech Understanding) and +`StreamingParameters` exposes ~25 (turn detection, voice focus, PII redaction, +speaker labels, encoding, domain, webhooks). None of this is reachable from the CLI. + +The goal: make every SDK option controllable from the CLI, without hand-writing 85+ +flags or sacrificing discoverability. + +## Scope + +- **In scope:** full `TranscriptionConfig` coverage on `transcribe`; full + `StreamingParameters` coverage on `stream`. +- **Out of scope:** LeMUR (deprecated). Speech Understanding speaker-ID-by-name/role + and custom-formatting get `--config`-only access in v1 (nested shapes, rarely used + from a shell); translation is promoted to a flag. + +## Approach: hybrid (curated flags + escape hatch) + +Curated, typed flags cover the common features. A generic escape hatch +(`--config KEY=VALUE`, repeatable, and `--config-file FILE`) covers the long tail. +Both paths share one builder so validation and merge logic live in one place. + +### Configuration layering + +A single new module, `assemblyai_cli/config_builder.py`, merges three layers and +returns a ready SDK config object. Precedence, lowest to highest: + +``` +--config-file (base: full JSON object mapping 1:1 to the SDK config) + ↓ overlaid by +--config KEY=VALUE (repeatable ad-hoc overrides) + ↓ overlaid by +explicit typed flags (most specific → win) +``` + +Rationale: an explicit named flag is the most intentional signal, so it wins; a +`--config-file` acts as a reusable profile that flags and `--config` can override. + +- **Config file format:** JSON only. Maps 1:1 to the SDK config object's field names. + Matches the `--json` output convention; one parser. +- **`--config` coercion:** `key=value` strings coerced by the target field's type — + bool (`true`/`false`/`1`/`0`), int, float, comma-separated list, or JSON for + complex/nested values. +- **Validation:** unknown keys raise `UsageError` listing valid field names. Enum + values (speech model, PII policy, summary type/model, encoding, etc.) are validated + against the SDK enums; invalid values raise `UsageError` listing allowed values. + +`config_builder` exposes: + +- `KNOWN_TRANSCRIBE_FIELDS` / `KNOWN_STREAM_FIELDS` — field name → coercion type, + derived from / validated against the SDK config classes. +- `parse_config_overrides(pairs) -> dict` — coerce `key=value` pairs. +- `load_config_file(path) -> dict` — parse and validate a JSON config file. +- `build_transcription_config(flag_values, overrides, file_data) -> aai.TranscriptionConfig` +- `build_streaming_params(flag_values, overrides, file_data) -> StreamingParameters` + +## `transcribe` — curated flags + +Everything below gets a typed flag. All other `TranscriptionConfig` fields +(`language_confidence_threshold`, `boost_param`, `remove_audio_tags`, +`speaker_options` internals, Speech Understanding speaker-ID / custom-formatting, +etc.) are reachable via `--config` / `--config-file`. + +**Model & language** +- `--speech-model {best,nano,slam-1,universal}` +- `--language-code TEXT` +- `--language-detection` +- `--keyterms-prompt TEXT` (repeatable) +- `--temperature FLOAT` + +**Formatting** +- `--punctuate / --no-punctuate` +- `--format-text / --no-format-text` +- `--disfluencies` + +**Speakers & channels** +- `--speaker-labels` *(exists)* +- `--speakers-expected INT` +- `--multichannel` + +**Guardrails** +- `--redact-pii` +- `--redact-pii-policy TEXT` (csv / repeatable) +- `--redact-pii-sub {hash,entity_name}` +- `--redact-pii-audio` +- `--filter-profanity` +- `--content-safety` +- `--content-safety-confidence INT` (25–100) +- `--speech-threshold FLOAT` + +**Analysis (auto-rendered)** +- `--summarization`, `--summary-model`, `--summary-type` +- `--auto-chapters` +- `--sentiment-analysis` +- `--entity-detection` +- `--auto-highlights` +- `--topic-detection` (→ `iab_categories`) + +**Customization** +- `--word-boost TEXT` (repeatable) +- `--custom-spelling-file FILE` (JSON: `{from: [..], to: ".."}` map) +- `--audio-start INT`, `--audio-end INT` (milliseconds) + +**Webhooks** +- `--webhook-url TEXT` +- `--webhook-auth-header NAME:VALUE` + +**Speech Understanding** +- `--translate-to TEXT` (repeatable; → `speech_understanding` translation request) + +**Escape hatch** +- `--config KEY=VALUE` (repeatable) +- `--config-file FILE` + +**Existing, unchanged:** `--llm-gateway-prompt`, `--model`, `--max-tokens`, +`--json`, `--sample`, `--prompt`. + +## `stream` — curated flags + +**Model & input** +- `--speech-model TEXT` +- `--encoding {pcm_s16le,pcm_mulaw}` +- `--sample-rate INT` *(exists)* +- `--device INT` *(exists)* +- `--language-detection` +- `--domain medical` + +**Turn detection** +- `--end-of-turn-confidence-threshold FLOAT` +- `--min-turn-silence INT` +- `--max-turn-silence INT` +- `--vad-threshold FLOAT` +- `--format-turns / --no-format-turns` +- `--include-partial-turns` + +**Features** +- `--keyterms-prompt TEXT` (repeatable) +- `--filter-profanity` +- `--speaker-labels` +- `--max-speakers INT` +- `--voice-focus {near_field,far_field}` +- `--voice-focus-threshold FLOAT` +- `--redact-pii` +- `--redact-pii-policy TEXT` (csv / repeatable) +- `--redact-pii-sub {hash,entity_name}` +- `--inactivity-timeout INT` +- `--webhook-url TEXT` +- `--webhook-auth-header NAME:VALUE` + +**Escape hatch** +- `--config KEY=VALUE` (repeatable) +- `--config-file FILE` + +**Existing, unchanged:** `--prompt`, `--llm-gateway-prompt`, `--model`, +`--max-tokens`, `--json`, `--sample`. + +## Result rendering (human mode) + +New module `assemblyai_cli/transcribe_render.py`. After printing the transcript text, +it conditionally renders one section per result field present on the returned +transcript object. Each section is a small standalone helper that no-ops when its +field is absent: + +- `Summary:` — the summary text/bullets +- `Chapters:` — `start–end headline` list with formatted timestamps +- `Highlights:` — ranked key phrases +- `Sentiment:` — aggregate percentages plus per-utterance breakdown +- `Entities:` — entity type → text +- `Topics:` — IAB categories with relevance +- `Content Safety:` — flagged labels with confidence/severity + +`--json` continues to dump the full raw transcript object, untouched. + +## Component touch list + +**New** +- `assemblyai_cli/config_builder.py` +- `assemblyai_cli/transcribe_render.py` + +**Changed** +- `assemblyai_cli/commands/transcribe.py` — new flags, delegate config to builder +- `assemblyai_cli/commands/stream.py` — new flags, delegate config to builder +- `assemblyai_cli/client.py` — `transcribe()` / `stream_audio()` accept prebuilt + config objects; `transcribe()` returns the full transcript with all result fields +- `assemblyai_cli/render.py` — shared rendering helpers (timestamp formatting, etc.) +- `README.md` — document the new options and escape hatch +- `assemblyai_cli/templates/` — refresh sample scripts to show a few options + +## Testing (exhaustive) + +- **Unit — `config_builder`:** type coercion per kind (bool/int/float/list/json); + layer precedence (file < `--config` < flags); unknown-key error; enum-validation + errors; property tests for coercion round-trips; a parametrized case per curated + flag asserting it lands on the right SDK field. +- **Unit — `transcribe_render`:** one test per section using fake transcript + fixtures, including the absent-field no-op path. +- **Command:** `transcribe` / `stream` build the expected config object from flags + (mocked client), and the escape hatch merges correctly. +- **e2e:** real-API runs covering several analysis features (e.g. summarization + + chapters + sentiment), following the repo's existing e2e pattern. + +## Build sequence + +1. `config_builder` + its tests (foundation, no UI). +2. `transcribe`: flags → builder → client → `transcribe_render` + tests. +3. `stream`: flags → builder → client + tests. +4. README + samples. +5. e2e additions. diff --git a/docs/superpowers/specs/2026-06-03-show-code-design.md b/docs/superpowers/specs/2026-06-03-show-code-design.md new file mode 100644 index 00000000..5ce38eba --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-show-code-design.md @@ -0,0 +1,196 @@ +# `--show-code`: graduate from CLI to SDK code + +**Date:** 2026-06-03 +**Status:** Design — pending implementation + +## Purpose + +Help a user who is exploring AssemblyAI through the CLI move to writing their own +SDK code. `--show-code` turns any `transcribe`/`stream`/`agent` invocation into the +idiomatic Python that would do the same thing — built from the exact flags you +passed — so you can copy it into your own app. + +This is an **onboarding / "graduate to code"** feature. The emitted code is a +teaching artifact: readable, commented, copy-pasteable SDK Python a human would +actually want to start from — not a verbatim dump of internal state. + +## Scope + +- **Commands:** `transcribe`, `stream`, `agent`. +- **Trigger:** a `--show-code` flag on each. It is a **print-only mode**: the + command builds its config from the flags, prints the equivalent Python, and + **exits without running** — no API call, no upload, no microphone, no streaming + session. (It does not require auth, since the generated code reads the key from + the environment and nothing is sent.) +- **Output:** the raw Python is written to stdout via the builtin `print` (not the + Rich console), so it can be redirected straight into a runnable file — + `aai transcribe foo.mp3 --show-code > my_script.py` — with no header, no ANSI, + and no `[...]`-as-markup mangling. `--json` has no effect in this mode (the code + *is* the output). If code generation fails, that is a real error (it is the + whole job), not a swallowed warning. +- **Language:** Python only. +- **Completeness:** a fully runnable script — imports, auth setup, config, the + call, and result handling that mirrors the features that were enabled. + +### Explicitly out of scope (YAGNI) + +- Other languages (TypeScript/Go). The serializer/snippet design leaves room to + add them later, but we ship Python only. +- Augmenting a real run (printing code *and* executing). An earlier draft did + this; `--show-code` is now print-only — it never executes the operation. +- Writing the code to a file. `--show-code` prints to stdout. (`samples create` + already covers the write-to-disk story.) + +## Why not unify with the renderers (rejected alternative) + +A tempting "zero-drift" approach is a single feature registry that drives *both* +the live Rich terminal rendering and the code generator. Rejected because: + +- The two outputs have different jobs — Rich panels/tables vs. plain teaching + Python — so the shared abstraction would need `if rendering vs generating` + branches everywhere (drift in disguise). +- It forces refactoring stable, tested renderers to serve a new feature. +- It couples terminal cosmetics to generated code forever. + +Instead we keep the generator **separate from** the renderers and use tests to +guarantee they don't drift (see Testing). This trades "prevent drift by +construction" for "detect drift in CI", which is the right trade for a finite, +slow-moving feature list. + +## Architecture (Approach B) + +Three small, independently testable pieces plus a thin flag wiring. + +### 1. Config serializer — `assemblyai_cli/code_gen/serialize.py` + +The inverse of `config_builder`. Takes the **actual built config object** the +command already constructed (`TranscriptionConfig` for transcribe, +`StreamingParameters` for stream) and emits Python source for only the +**non-default** fields, as keyword arguments. + +- Source of truth: the live config object, so new config fields flow through + with no generator change. +- Compares each field against the SDK default; emits only differences. This + keeps generated code short and readable (the whole point). +- Renders SDK types correctly: enums as `SpeechModel.u3_rt_pro`, nested + structures (e.g. the `speech_understanding` translation dict, custom spelling) + as literals. +- Returns a list of `"field=value"` lines that the template indents into the + config constructor. + +Round-trip invariant (enforced by test): for any config the CLI can build, +`build_config(eval(serialize(config))) == config`. + +### 2. Per-command skeleton templates — reuse `assemblyai_cli/templates/` + +The existing `transcribe.py.tmpl` / `stream.py.tmpl` / `agent.py.tmpl` files are +the stable boilerplate (imports, auth, the call). We extend them with two slots: + +- A config slot where serialized `field=value` lines are injected. +- A result-handling slot where feature snippets are injected (transcribe only; + stream/agent result handling is fixed). + +**Auth difference from `samples create`:** `samples create` writes to disk +(0600) and injects the literal API key. `--show-code` prints to the terminal and +scrollback, so it must **not** echo the secret. Generated code uses: + +```python +aai.settings.api_key = os.environ["ASSEMBLYAI_API_KEY"] +``` + +with a one-line comment telling the user to export their key. (For `agent`, the +`Authorization: Bearer` header reads the same env var.) + +### 3. Feature-snippet table — `assemblyai_cli/code_gen/snippets.py` + +Transcribe only. A table mapping an enabled analysis feature to the Python that +reproduces its output. It mirrors the existing one-function-per-feature shape of +`transcribe_render.py` (`_render_summary`, `_render_chapters`, +`_render_sentiment`, `_render_entities`, `_render_topics`, `_render_highlights`, +`_render_content_safety`, speaker-label utterances). + +Each entry: `predicate(config) -> bool` (was the feature enabled?) and a code +`snippet: str`. The generator appends the snippet for each enabled feature in a +stable order. A feature with no snippet is a visible, tested gap — not silent +drift. + +### 4. Flag wiring + +Each command builds its config object today (e.g. `transcribe.py:155` +`tc = config_builder.build_transcription_config(...)`). After the real call, if +`--show-code` is set and not in `--json` mode, call +`code_gen.render(command, config_object, source)` and print the result below the +normal output, in a visually distinct block. + +## Data flow (transcribe example) + +``` +flags --> config_builder.build_transcription_config() --> tc (TranscriptionConfig) + | + real call: client.transcribe() <--+ + | + --show-code? --> code_gen.render("transcribe", tc, source) + | + serialize(tc) -> config lines | + snippets(tc) -> feature blocks ---+--> template --> printed Python +``` + +## Error handling + +- Code generation runs **after** the successful API call, so a generation bug + never blocks the user's actual result. +- Generation is wrapped so that a failure prints a short warning + (`could not render sample code: …`) rather than crashing the command. The + transcript/stream/agent output is the contract; the code is a bonus. +- Suppressed entirely in `--json` mode (machine-readable output stays clean). + +## Testing — invalid code must be impossible to generate without a failure + +The requirement is strict: it must be impossible to produce invalid code without +a test failing. We get there structurally, not by enumerating examples. Two facts +make it tractable: (a) generation is driven *entirely* by the merged-kwargs dict, +and (b) `config_builder.TRANSCRIBE_COERCE` / `STREAM_COERCE` are the authoritative +valid-field sets. So a hypothesis strategy built *from those tables* blankets the +entire legal input space, and any field added later is fuzzed automatically. + +1. **Fuzz-compiles (syntactic validity):** fuzz the full config domain through + every renderer and `compile()` every output (`compile` is stricter than + `ast.parse` and is what `python file.py` runs). No syntactically invalid Python + can be produced. The agent renderer is additionally fuzzed with arbitrary text + (quotes, newlines, backslashes, unicode) injected via `repr`. +2. **Round-trip (config fidelity):** for any fuzzed config, the + `TranscriptionConfig(...)` / `StreamingParameters(...)` the generated code + builds must `eval` back to the original merged dict. Guarantees the emitted + call reconstructs the same config; catches any dropped/mangled field. +3. **Result-handling execs:** every snippet is `exec`'d against a stub transcript + that exposes the attributes the SDK provides. A typo'd attribute in any snippet + fails here. +4. **Coverage guard:** every analysis feature with a `_render_*` function in + `transcribe_render.py` must have a snippet entry (or a documented exclusion). + The tripwire for "added a feature, forgot the snippet." + +What tests *cannot* assert: that a snippet's wording is the *clearest* phrasing — +that's editorial judgment, reviewed by a human, not enforced by a test. + +## Files + +| File | Change | +| --- | --- | +| `assemblyai_cli/code_gen/__init__.py` | New. `render(command, config, source)` entry point. | +| `assemblyai_cli/code_gen/serialize.py` | New. Config object → non-default `field=value` lines. | +| `assemblyai_cli/code_gen/snippets.py` | New. Feature → result-handling snippet table. | +| `assemblyai_cli/templates/*.py.tmpl` | Extend with config + result-handling slots; env-var auth. | +| `assemblyai_cli/commands/transcribe.py` | Add `--show-code`; call `code_gen.render` after the call. | +| `assemblyai_cli/commands/stream.py` | Add `--show-code`; same wiring. | +| `assemblyai_cli/commands/agent.py` | Add `--show-code`; same wiring. | +| `tests/test_code_gen.py` | New. Round-trip, golden, coverage-guard tests. | + +## Build sequence + +1. `serialize.py` + round-trip property test (no flag wiring yet). +2. Extend `transcribe.py.tmpl`; `snippets.py` + coverage-guard test. +3. Wire `--show-code` into `transcribe`; golden + executes-clean tests. +4. Repeat wiring for `stream` (config serializer reused, fixed result handling). +5. `agent` (no `TranscriptionConfig`; serialize its session params/flags; fixed + result handling). +``` diff --git a/docs/superpowers/specs/2026-06-04-install-path-testing-design.md b/docs/superpowers/specs/2026-06-04-install-path-testing-design.md new file mode 100644 index 00000000..ea9119b9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-install-path-testing-design.md @@ -0,0 +1,133 @@ +# Install-path testing design + +## Problem + +CI never exercises the public install story. All four `ci.yml` jobs install the +package with `pip install -e .` (editable, from the local checkout). The path a +real user takes — `curl -fsSL .../install.sh | sh` → `pipx install git+https://…` +→ `aai` on PATH — is untested. A regression that breaks it (a bad dependency +version floor, a missing/renamed console entrypoint, an `install.sh` bug, a PATH +problem) passes CI green. + +`install.sh` itself contains untested logic: a Python 3.10+ gate, construction of +the `git+https://github.com/AssemblyAI/cli.git@REF` spec from `AAI_REPO`/`AAI_REF`, +a pipx-vs-`pip --user` fallback branch, and PATH-check messaging. + +## Goal + +Catch install-path regressions before release, in layers that trade speed for +fidelity, without making every PR slow or flaky. + +## Non-goals + +- Testing install from a *pushed* git ref / the literal documented `curl | sh` + against GitHub. We install the PR's own code from a local wheel instead (see + the `AAI_SPEC` seam). A pushed-ref / nightly variant is explicitly out of scope + for this work. +- Publishing to or installing from PyPI (the `assemblyai-cli` name is squatted; + `install.sh` uses the GitHub git spec, so PyPI is irrelevant here). + +## Design + +Three layers plus one small change to `install.sh`. + +### Layer 0 — `install.sh` testability seam + +Add an `AAI_SPEC` environment override. When set, `install.sh` installs that +spec verbatim instead of constructing the `git+https://…@REF` URL. Documented +in the script as test-only. This is the hook that lets Layers 2 and 3 install +the PR's actual code without pushing a commit. + +Precedence: `AAI_SPEC` (if set) wins; otherwise build the spec from +`AAI_REPO`/`AAI_REF` as today. The rest of the script (Python gate, pipx/pip +branch, PATH check) is unchanged. + +### Layer 1 — Fast shell-logic unit tests (every PR) + +New `tests/test_install_sh.py`. Runs `install.sh` via `subprocess` with a +sandboxed `PATH` containing fake shims: + +- a recording `pipx` shim that writes its argv to a file and exits 0 +- a `python3` shim that reports a chosen version (to drive the gate) +- a `pip` shim (recording) for the fallback branch + +Cases: + +1. No `python3`/`python` on PATH → exit 1, version error on stderr. +2. Python < 3.10 → exit 1, version error. +3. pipx present → invokes `pipx install --force `. +4. pipx absent → invokes `python -m pip install --user --upgrade `. +5. Default → spec is `git+https://github.com/AssemblyAI/cli.git@main`. +6. `AAI_REPO`/`AAI_REF` set → spec string reflects them. +7. `AAI_SPEC` set → used verbatim, no git URL constructed. +8. PATH check: `aai` present → "Installed. Next: …"; absent → ensurepath hint. + +No network; runs in milliseconds in the default suite (the existing `check` job). + +Also add `shellcheck install.sh` as a static gate in `scripts/check.sh` (and +therefore CI), guarded so it's skipped with a notice when `shellcheck` isn't +installed locally. + +### Layer 2 — Real-install smoke test (new marker) + +New pytest test marked `install_script` (a new marker, kept separate from the +existing `install` marker used by `test_init_template_install.py`, so the new +CI job stays tight and the template-install test remains manual-only). + +The test: + +1. Builds a wheel from the checkout (`uv build` / `python -m build`) into a temp dir. +2. Runs `install.sh` with `AAI_SPEC=` (plus any extra index/deps + needed so the wheel's dependencies resolve from PyPI). +3. Asserts the installed `aai` runs: `aai --version` exits 0 and prints the + package version. + +Parametrized over the install branch: + +- `pipx` available → pipx path. +- `pipx` hidden from PATH → `pip --user` fallback path. + +Only *dependencies* hit the network (the package itself is the local wheel), so +each install is ~30–90s rather than a full git build. + +### Layer 3 — `install-smoke` CI job + +A new job in `ci.yml` that runs `pytest -m install_script` on a matrix: + +| OS | pipx path | pip --user fallback | +|----|-----------|---------------------| +| ubuntu-latest | ✅ | ✅ | +| macos-latest | ✅ | ❌ (excluded) | + +Rationale for the macOS exclusion: the pipx-vs-pip branch is OS-independent +shell logic, proven once on Linux. macOS's distinct risk is environmental +(Homebrew Python, `~/Library` paths, PEP 668 "externally-managed-environment"). +The **pipx** path is the documented primary path and sidesteps PEP 668; the +`pip --user` path on macOS is the most likely to fail for reasons outside the +script's control, so gating PRs on it would add flakiness without proportional +signal. + +The job pins actions to commit SHAs and installs `uv`/build tooling consistent +with the existing jobs. + +## Error handling and skips + +Mirror the `test_init_template_install.py` convention: the Layer 2 test **skips +(never fails)** when the machine can't run it — offline (PyPI unreachable) or +`uv`/build tooling absent. Keeps keyless/offline/sandboxed local runs unblocked; +CI has the tooling and network, so it runs for real there. + +## Testing the tests + +- Layer 1 cases are deterministic (fake shims, fixed inputs), no network. +- Layer 2 is the smoke test itself; its skip logic is the only branch worth a + quick sanity check (simulate offline → assert skipped, not failed). + +## Files touched + +- `install.sh` — add `AAI_SPEC` override + doc comment. +- `tests/test_install_sh.py` — new (Layer 1). +- `tests/test_install_script_smoke.py` — new (Layer 2), marked `install_script`. +- `pyproject.toml` — register the `install_script` marker. +- `scripts/check.sh` — add `shellcheck install.sh` (guarded). +- `.github/workflows/ci.yml` — add the `install-smoke` job. diff --git a/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md b/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md new file mode 100644 index 00000000..868098b2 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md @@ -0,0 +1,267 @@ +# Stytch OAuth login for the AssemblyAI CLI — design + +**Date:** 2026-06-04 +**Status:** Draft for review +**Author:** Alex Kroman (with Claude) + +## Goal + +Replace the interactive "paste an API key" login with a browser-based Stytch +OAuth login (`aai auth login`). After login, the user holds a dedicated, +revocable AssemblyAI API key — exactly the credential the CLI already uses — so +nothing downstream changes. + +## Non-goals + +- No change to how the CLI calls AssemblyAI APIs. Every command keeps using an + API key via `aai.settings.api_key` (`client.py`). +- No persisted OAuth tokens. OAuth is a *login mechanism to obtain the API key*, + nothing more (see "Credential model"). +- No headless/device flow. The CLI assumes a browser is available (per product + decision); we fall back to printing the URL if it can't auto-open. + +## Decisions (settled during brainstorming) + +1. **Browser flow = client-side, loopback redirect.** The CLI runs the browser + login itself (vs. the Stripe-style backend-brokered poll flow). Concretely this + is **Stytch B2B OAuth discovery** (public_token + loopback; no PKCE/code + exchange) — *not* Connected Apps — because that's what AMS consumes (see O1). +2. **Credential model = store-key-only.** Login ends by storing an AssemblyAI + API key in the OS keychain, exactly as today. OAuth access/refresh tokens are + used transiently and discarded. This is the Stripe-CLI pattern and keeps the + entire existing call path untouched. +3. **Dedicated, find-or-create CLI key.** Login provisions/reuses an API key + named `AssemblyAI CLI` (ideally per-device, e.g. `AssemblyAI CLI — `) + rather than reusing the account's primary key. Independent revocation, + auditability, smaller blast radius. +4. **Keep escape hatches.** `ASSEMBLYAI_API_KEY` env var and a non-interactive + `--api-key` on `login` remain, for CI/automation. Only the *interactive + default* changes to OAuth. +5. **Profiles unchanged.** Login writes the key into the active profile's + keychain slot via the existing `config.set_api_key`. + +## Confirmed external facts + +### Stytch project (test) + +| Thing | Value | +|---|---| +| Project ID | `project-test-55761beb-2738-4258-825d-be699c3b5336` | +| Project domain | `https://psychedelic-journey-5884.customers.stytch.dev` | +| Connected App client ID (test) | `connected-app-test-4a88e99a-ac79-4c2a-82b5-027e2231a307` | +| Workspace / org | `organization-prod-f12ebd5e-17c6-415e-be27-d375635f0b39` | +| Token endpoint | `https://{project-domain}/v1/oauth2/token` | +| Loopback redirect (registered) | `http://127.0.0.1/callback` (no port → any port allowed) | +| Client type | First Party Public (PKCE, no client secret) | +| `offline_access` consent | bypassed (toggle on) | +| `full_access` / Access Token Exchange | enabled (toggle on) | + +Discovery (`/.well-known/openid-configuration`) returns HTTP 400 +`authorization_endpoint_not_configured_for_project` (Connected Apps OIDC). This +is **moot** for the chosen design: the CLI uses Stytch **B2B OAuth discovery**, +not Connected Apps (see O1). The Connected App rows above are retained for record +but are **unused**. What the design needs instead: the project's **public_token** +and a B2B OAuth provider + loopback redirect allowlist (P1). + +### Accounts Management Service (AMS) — the "token API" + +Source: `AssemblyAI/DeepLearning` → `assemblyai/experimental/fcastillo/bruno` +("Accounts Management Service" collection). All user endpoints authenticate with +a **`Cookie: stytch_session_jwt=`** (a Stytch *member session*, not the +OAuth access token). + +| Purpose | Method + path | Notes | +|---|---|---| +| Who am I | `GET {ams}/v1/auth` | Returns `id` (account_id), `email`, `api_token`. | +| List projects + their keys | `GET {ams}/v1/users/accounts/{account_id}/projects` | Each project has `tokens[]` with `api_key`, `name`. Enables **find**. | +| Create API key | `POST {ams}/v1/users/accounts/{account_id}/tokens` body `{project_id, token_name}` | Returns `{id, project_id, api_key, name, ...}`. Enables **create**. | +| Rename key | `PUT {ams}/v1/users/accounts/{account_id}/tokens/{token_id}` `{token_name}` | | +| Delete key | `DELETE {ams}/v1/users/accounts/{account_id}/tokens/{token_id}` | Used by `logout` revoke (optional). | + +Base URLs: prod in the collection is `https://ams.internal.assemblyai-labs.com` +(internal); a **publicly reachable sandbox exists** at +`https://ams.sandbox000.assemblyai-labs.com` (OpenAPI confirmed live, see below). + +### AMS OpenAPI (confirmed live at sandbox `/openapi.json`, 2026-06-04) + +- **Auth: global `BearerAuth` (HTTP bearer, JWT)** applied to all endpoints + (`security: [{BearerAuth: []}]`). `/v1/auth` additionally accepts a + `stytch_session_jwt` **cookie** (optional param). +- **AMS brokers the Stytch session exchange itself** — no project secret needed + in the CLI: + - `POST {ams}/v2/auth/discover` body `{token, token_type}` where + `token_type ∈ {"discovery", "discovery_oauth"}` → returns + `{organizations[] (id+name), email, intermediate_session_token}`. + - `POST {ams}/v2/auth/exchange` body `{intermediate_session_token, + organization_id}` → `SignedInResponse {account, session_jwt, session_token}`. + (0 orgs → `POST /v2/auth/organization` to create one; MFA path possible.) +- **`TokenSchema` has no expiry field** → AssemblyAI keys are long-lived + → store-key-only needs no refresh (resolves O2). +- `CreateTokenRequest = {project_id:int, token_name:str}` → + `TokenSchema {id, project_id, api_key, name, is_disabled, created, updated}`. +- `GET …/projects` → `ProjectDetailResponse[] = {project, tokens[]}` (each token + has `api_key`+`name`) → supports find-or-create directly. + +### The access_token → session_jwt step + +AMS owns this server-side via `/v2/auth/discover` + `/v2/auth/exchange` (it holds +the Stytch project secret). The CLI never needs the secret and never calls +Stytch's backend "Exchange Access Token" endpoint directly. The one thing to +confirm with the AMS owner is **which credential `/v2/auth/discover` expects** +(see O1). + +## Architecture — Stytch B2B OAuth discovery via loopback (chosen) + +The CLI drives Stytch's **B2B OAuth discovery** (public_token + loopback redirect, +**no PKCE/code exchange** — the discovery token arrives directly in the redirect), +then AMS brokers session exchange and key provisioning through its **existing** +endpoints. No backend changes, Connected App not used. + +``` +aai auth login + 1. resolve profile; generate random `state` (CSRF) + 2. bind loopback HTTP server on 127.0.0.1:8585 (fixed; exact-match redirect) + 3. open browser → Stytch B2B OAuth discovery start: [CLI → Stytch, client-side] + https://{project-domain}/v1/b2b/public/oauth/{provider}/discovery/start + ?public_token={public_token} + &discovery_redirect_url=http://127.0.0.1:/callback + 4. user authenticates with provider → Stytch redirects to the loopback: + http://127.0.0.1:/callback?stytch_token_type=discovery_oauth&token= + 5. callback handler captures `token` (verify stytch_token_type=discovery_oauth) + 6. POST {ams}/v2/auth/discover {token, token_type:"discovery_oauth"} [CLI → AMS] + → {organizations[], email, intermediate_session_token} + 7. choose organization_id (1 org → use it; >1 → pick/prompt; 0 → /v2/auth/organization) + 8. POST {ams}/v2/auth/exchange {intermediate_session_token, organization_id} + → SignedInResponse {account, session_jwt, session_token} + 9. GET {ams}/v1/auth (Cookie: stytch_session_jwt=session_jwt) → account.id + 10. GET {ams}/v1/users/accounts/{account_id}/projects + → find token named "AssemblyAI CLI"; else + POST {ams}/v1/users/accounts/{account_id}/tokens {project_id, token_name} + → {api_key} + 11. config.set_api_key(profile, api_key) [unchanged storage path] +✓ all other commands work exactly as before +``` + +Notes: the start endpoint is **client-side**, authenticated by the project's +**public_token** (not the secret) — safe to ship in the CLI. Steps 9–10 may +present `session_jwt` as `Authorization: Bearer` (global AMS scheme) instead of +the cookie (O5). Handle MFA/verify-email responses from exchange (rare for the +CLI; surface a clear message and stop). + +## Components / files + +- **`assemblyai_cli/auth/` (new package)** + - `loopback.py` — bind `127.0.0.1:0`, serve `/callback`, capture `token` + + `stytch_token_type`, return a "you can close this tab" page, enforce a timeout. + - `browser.py` — open system browser to the B2B discovery start URL; on failure, + print the URL. + - `discovery.py` — build the B2B OAuth discovery start URL (provider + + public_token + loopback redirect); generate/verify `state`. + - `provision.py` — AMS chain: `discover` → `exchange` → `/v1/auth` → + find-or-create token; returns the `api_key`. + - `endpoints.py` — config constants (below), env-overridable. +- **`assemblyai_cli/commands/login.py` (modify)** + - `login` → orchestrates the flow above; keeps `--api-key` non-interactive path + and `--json`. + - `logout` → `config.clear_api_key` (existing) + best-effort AMS `DELETE` of the + CLI token (revoke). Keep local delete authoritative; revoke is best-effort. + - `whoami` → unchanged (operates on the stored key). +- **`assemblyai_cli/config.py`** — no schema change; reuse `set_api_key` / + `get_api_key` / `clear_api_key`. Optionally store `account_id`/`token_id` + alongside (in `config.toml` profile) to make `logout` revoke precise. + +### Config constants (`endpoints.py`), env-overridable for sandbox→prod + +- `STYTCH_PROJECT_DOMAIN` (`https://psychedelic-journey-5884.customers.stytch.dev`) +- `STYTCH_PUBLIC_TOKEN` (**needed** — from Stytch dashboard; public, safe to ship) +- `STYTCH_OAUTH_PROVIDER = "google"` (enabled + verified on the project) +- `LOOPBACK_REDIRECT = "http://127.0.0.1:8585/callback"` (fixed port; exact-match + validation — Stytch rejects unregistered ports/paths. Single registered URL.) +- `AMS_BASE_URL` (`https://ams.sandbox000.assemblyai-labs.com`; prod URL per P2) +- `CLI_TOKEN_NAME = "AssemblyAI CLI"` + +Hardcode sandbox defaults; allow override via env (e.g. `AAI_AUTH_*`) so swapping +to production (AMS prod URL, prod project) is config, not code. The Connected App +client ID is **not used** in this design. + +## Error handling + +- **Browser won't open** → print the authorize URL for manual paste. +- **Callback timeout** (e.g. 120s) → abort with a clear message; tear down the + loopback server. +- **`state` mismatch** → abort (possible CSRF); do not exchange. +- **`stytch_token_type` not `discovery_oauth`** on callback → abort with a clear + message (misconfigured provider/redirect). +- **AMS discover/exchange failure** → AMS already maps Stytch errors to clean + HTTP (401 invalid/expired credentials or session token, etc.); surface its + `detail`. Map 401/403 to a `NotAuthenticated`-style message, else `APIError`. +- **Expired discovery token** (slow user) → AMS returns 401 + (`oauth_token_not_found`/expired); instruct retry of `aai auth login`. +- **MFA / verify-email required** from exchange → surface the required action and + stop (out of scope for v1 CLI; revisit if accounts enforce MFA). +- All errors flow through the existing `run_command` → `CLIError` → exit-code + machinery. + +## Testing + +Mirror existing test patterns in `tests/` (see `test_login*`-style if present). + +- `discovery.py`: start-URL builder includes provider, public_token, redirect; + `state` generated and verified; rejects mismatched `state`. +- `loopback.py`: binds an ephemeral port; captures `token`/`stytch_token_type`; + rejects wrong token_type; honors timeout. Drive with a synthetic GET to the port. +- `provision.py`: AMS `discover`→`exchange`→`/v1/auth`→projects/tokens mocked + (httpx/requests mock) for success + 401; find-vs-create logic; multi-org and + MFA/verify-email branches. +- `login` command: end-to-end with browser + servers mocked; asserts + `config.set_api_key` called with the returned key; `--api-key` non-interactive + path still works; `--json` output shape. +- `logout`: clears key; best-effort revoke tolerates AMS being unreachable. + +## Prerequisites (backend / outside the CLI) + +- **P1 — DONE & verified (2026-06-04).** `http://127.0.0.1:8585/callback` is + registered and returns a live 307 redirect to Google. Validation is + exact-match, so the CLI binds the fixed port 8585. Google OAuth is enabled using + Stytch's shared **test** credentials (no own Google app needed in test). Public + token `public-token-test-79ad7d8d…` verified against project + `project-test-55761beb`. +- **P-NEW — Confirm AMS↔project binding (only unverified link).** Confirm + `ams.sandbox000.assemblyai-labs.com` uses Stytch project `project-test-55761beb` + and that `alex@assemblyai.com` has an account/org there. AMS is alive (`/v1/auth` + → 401 unauth; `/v2/auth/discover` processes via Stytch). Settle via fcastillo or + the first real end-to-end login. Note: a first-time user with **0 orgs** hits the + `/v2/auth/organization` create branch — the CLI must handle it. +- **P2 — Blessed public, stable AMS URL.** A public sandbox is already reachable + (`ams.sandbox000.assemblyai-labs.com`); a production public URL (out of + `experimental/`) is needed for release. *(Downgraded: the "internal-only, + unreachable" risk is resolved — a public ingress demonstrably exists.)* + +*(Former P3 removed: AMS already brokers the Stytch exchange via +`/v2/auth/discover` + `/v2/auth/exchange`; no new endpoint is required.)* + +## Open questions + +- **O1 — RESOLVED by source** (`integration/auth_gateway.py`). AMS uses + `stytch.B2BClient` and `discovery_oauth` maps to + `oauth.discovery.authenticate(discovery_oauth_token=token)` — Stytch **B2B OAuth + discovery**, the same primitive the AssemblyAI dashboard uses. AMS validates + sessions via B2B member-session JWTs (`sessions.authenticate_jwt`). + **The Connected App (`connected-app-test-…`) is NOT consumed by AMS** — it's a + different Stytch primitive. The CLI must obtain a Stytch B2B login credential + (`discovery_oauth_token`, magic-link, or password), not a Connected Apps token. + **Remaining decision (D1, for the user):** use the existing B2B discovery flow + (recommended; pick sub-method below), or have the backend add Connected Apps + support to AMS (extra backend work, not recommended). +- **D1 sub-method** (if using B2B discovery): OAuth provider via loopback + (e.g. Google/Microsoft — most "OAuth-like"), magic link (email round-trip), or + email+password (no browser). Provider + loopback redirect URL must be + allowlisted in Stytch. +- **O2 — resolved.** `TokenSchema` has no expiry → keys are long-lived → + store-key-only, no refresh needed. +- **O3.** Project selection for multi-project accounts: default to the first/only + project, or prompt? +- **O4.** Production Connected App client ID (current one is `…-test-…`). +- **O5.** Auth presentation on AMS user endpoints: `stytch_session_jwt` cookie + (per `/v1/auth`) vs. `Authorization: Bearer session_jwt` (global scheme). +``` diff --git a/install.sh b/install.sh index 404e17f6..2a2b9caa 100755 --- a/install.sh +++ b/install.sh @@ -8,7 +8,10 @@ set -eu REPO="${AAI_REPO:-AssemblyAI/cli}" REF="${AAI_REF:-main}" -SPEC="git+https://github.com/${REPO}.git@${REF}" +# AAI_SPEC (test-only) installs an arbitrary pip spec verbatim — e.g. a locally +# built wheel — instead of the public git URL, so tests can exercise this script +# against the current checkout without pushing. Unset for normal installs. +SPEC="${AAI_SPEC:-git+https://github.com/${REPO}.git@${REF}}" info() { printf '\033[1;34m==>\033[0m %s\n' "$1"; } err() { printf '\033[1;31merror:\033[0m %s\n' "$1" >&2; } diff --git a/pyproject.toml b/pyproject.toml index 3c9acbe1..3694c5a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ exclude = ["**/__pycache__", "**/*.pyc"] testpaths = ["tests"] markers = [ "e2e: real-API end-to-end tests that drive the CLI (need ASSEMBLYAI_API_KEY + kokoro; skip otherwise)", + "install_script: real install via install.sh from a locally-built wheel; asserts `aai` runs (slow; needs network + uv/pipx; skip otherwise)", "install: install each init template's requirements.txt into a clean venv and import it (slow; needs network + uv; skip otherwise)", ] diff --git a/scripts/check.sh b/scripts/check.sh index 6ca190bd..ae299a9b 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -27,13 +27,24 @@ uv run pyright # include = ["aai_cli", "tests"] in [tool.pyright] echo "==> markdownlint (docs/ is generated, so excluded)" markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache +echo "==> shellcheck (install.sh)" +# Static-lint the public install script. CI's ubuntu runner ships shellcheck; +# locally it's skipped with a notice if not installed. +if command -v shellcheck >/dev/null 2>&1; then + shellcheck install.sh +else + echo " shellcheck not found; skipping (CI runs it)" +fi + echo "==> pytest (with branch-coverage gate)" # Exclude e2e: they drive the CLI as a subprocess (uncounted by coverage) and need -# a live API key + kokoro. And exclude install (real per-template dep install, -# slow + network), also uncounted by coverage. Run them with: +# a live API key + kokoro. Exclude install (real per-template dep install, slow + +# network) and install_script (builds a wheel and runs install.sh for real; slow, +# needs network + uv/pipx). All are uncounted by coverage. Run them with: # uv run pytest -m e2e # uv run pytest -m install -uv run pytest -q -m "not e2e and not install" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +# uv run pytest -m install_script +uv run pytest -q -m "not e2e and not install and not install_script" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 echo "==> build + twine check (PyPI publish readiness)" # Build sdist + wheel into ./dist, then validate the metadata and README render diff --git a/tests/test_install_script_smoke.py b/tests/test_install_script_smoke.py new file mode 100644 index 00000000..e811f21f --- /dev/null +++ b/tests/test_install_script_smoke.py @@ -0,0 +1,117 @@ +"""Real install-and-run smoke test for install.sh. + +Builds a wheel from the checkout and runs install.sh against it (via the +test-only AAI_SPEC override) into a hermetic location, then asserts the +installed `aai` binary actually runs. This is the one check that exercises the +public install path end to end: dependency resolution, the console entrypoint, +and the pipx / pip --user branches in install.sh. + +Marked `install_script`: slow + needs network (deps resolve from PyPI) and +pipx/uv. Excluded from the default run; invoke explicitly:: + + uv run pytest -m install_script + +The two tests map to the two install branches; CI runs both on Linux and only +the pipx branch on macOS. +""" + +from __future__ import annotations + +import functools +import os +import shutil +import subprocess +import sys +import urllib.error +import urllib.request +from pathlib import Path + +import pytest + +from aai_cli import __version__ + +pytestmark = pytest.mark.install_script + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_SH = REPO_ROOT / "install.sh" + + +@functools.lru_cache(maxsize=1) +def _pypi_reachable() -> bool: + # Cached: both tests ask the same question, so probe the network once. + try: + urllib.request.urlopen("https://pypi.org/simple/", timeout=5) + return True + except (urllib.error.URLError, OSError): + return False + + +def _sh() -> str: + return shutil.which("sh") or "/bin/sh" + + +@pytest.fixture(scope="session") +def built_wheel(tmp_path_factory) -> Path: + # Skip (never fail) when the machine can't build the wheel — mirrors the + # template install test, so offline/sandboxed local runs aren't blocked. + if shutil.which("uv") is None: + pytest.skip("uv not on PATH; needed to build the wheel under test") + out = tmp_path_factory.mktemp("dist") + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(out)], + cwd=REPO_ROOT, + check=True, + capture_output=True, + text=True, + ) + wheels: list[Path] = list(out.glob("*.whl")) + assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}" + return wheels[0] + + +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) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == __version__ + + +def test_install_via_pipx(built_wheel: Path, tmp_path: Path) -> None: + if shutil.which("pipx") is None: + pytest.skip("pipx not on PATH; required for the pipx install branch") + if not _pypi_reachable(): + pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") + + pipx_bin = tmp_path / "pipx_bin" + # Inherit the real env so pipx/python resolve normally; the overrides keep + # the install hermetic (its own pipx home + an isolated bin dir). + env = { + **os.environ, + "AAI_SPEC": str(built_wheel), + "PIPX_HOME": str(tmp_path / "pipx_home"), + "PIPX_BIN_DIR": str(pipx_bin), + } + run = subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + _assert_aai_runs(pipx_bin / "aai") + + +def test_install_via_pip_user(built_wheel: Path, tmp_path: Path) -> None: + if not _pypi_reachable(): + pytest.skip("PyPI unreachable; skipping real-install smoke test (offline)") + + # Hermetic PATH with ONLY python3 → `command -v pipx` fails, forcing the + # pip --user fallback. pip --user honors PYTHONUSERBASE for the install root. + bindir = tmp_path / "bin" + bindir.mkdir() + python = shutil.which("python3") or sys.executable + (bindir / "python3").symlink_to(python) + userbase = tmp_path / "userbase" + env = { + "PATH": str(bindir), + "AAI_SPEC": str(built_wheel), + "PYTHONUSERBASE": str(userbase), + } + run = subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + _assert_aai_runs(userbase / "bin" / "aai") diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py new file mode 100644 index 00000000..3b06e6d1 --- /dev/null +++ b/tests/test_install_sh.py @@ -0,0 +1,126 @@ +"""Shell-logic unit tests for install.sh. + +Run install.sh under a sandboxed PATH of fake shims so we can assert *which* +installer it invokes and with *what* spec — without any real install or network. +The shims record their argv to files in a temp dir; the script's only external +dependencies (python3, pipx, and pip via `python -m pip`) are all faked. + +Fast; runs in the default suite. The real install-and-boot test lives in +test_install_script_smoke.py (marked `install_script`). +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +INSTALL_SH = Path(__file__).resolve().parent.parent / "install.sh" +DEFAULT_SPEC = "git+https://github.com/AssemblyAI/cli.git@main" + + +def _sh() -> str: + return shutil.which("sh") or "/bin/sh" + + +def _shim(path: Path, body: str) -> None: + path.write_text("#!/bin/sh\n" + body) + path.chmod(0o755) + + +def _python_shim(bindir: Path, *, version: str = "3.12.0", gate_ok: bool = True) -> None: + # Fakes the three ways install.sh calls python: + # -V → print a version (used in the <3.11 error message) + # -c '' → exit 0/1 to pass/fail the 3.11+ gate + # -m pip ... → record argv to pip.args (the pip --user fallback) + rec = bindir / "pip.args" + _shim( + bindir / "python3", + f'case "$1" in\n' + f' -V|--version) echo "Python {version}"; exit 0 ;;\n' + f" -c) exit {0 if gate_ok else 1} ;;\n" + f' -m) shift; echo "$@" > "{rec}"; exit 0 ;;\n' + f"esac\n" + f"exit 0\n", + ) + + +def _pipx_shim(bindir: Path) -> None: + rec = bindir / "pipx.args" + _shim(bindir / "pipx", f'echo "$@" > "{rec}"\nexit 0\n') + + +def _run(bindir: Path, env_extra: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + env = {"PATH": str(bindir)} + if env_extra: + env.update(env_extra) + return subprocess.run([_sh(), str(INSTALL_SH)], env=env, capture_output=True, text=True) + + +def test_errors_when_no_python(tmp_path): + # Empty bindir: no python3/python on PATH at all. + result = _run(tmp_path) + assert result.returncode == 1 + assert "Python 3.11+ is required" in result.stderr + + +def test_errors_when_python_too_old(tmp_path): + _python_shim(tmp_path, version="3.9.18", gate_ok=False) + result = _run(tmp_path) + assert result.returncode == 1 + assert "Python 3.11+ is required" in result.stderr + assert "3.9.18" in result.stderr + + +def test_uses_pipx_when_present(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + result = _run(tmp_path) + assert result.returncode == 0 + assert (tmp_path / "pipx.args").read_text().strip() == f"install --force {DEFAULT_SPEC}" + assert not (tmp_path / "pip.args").exists() # pip fallback not taken + + +def test_falls_back_to_pip_user_when_no_pipx(tmp_path): + _python_shim(tmp_path) # no pipx shim → `command -v pipx` fails + result = _run(tmp_path) + assert result.returncode == 0 + assert ( + tmp_path / "pip.args" + ).read_text().strip() == f"pip install --user --upgrade {DEFAULT_SPEC}" + + +def test_repo_and_ref_override_the_spec(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + result = _run(tmp_path, {"AAI_REPO": "me/fork", "AAI_REF": "dev"}) + assert result.returncode == 0 + assert ( + tmp_path / "pipx.args" + ).read_text().strip() == "install --force git+https://github.com/me/fork.git@dev" + + +def test_aai_spec_is_used_verbatim(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + result = _run(tmp_path, {"AAI_SPEC": "/tmp/aai_cli-0.1.0-py3-none-any.whl"}) + assert result.returncode == 0 + assert ( + tmp_path / "pipx.args" + ).read_text().strip() == "install --force /tmp/aai_cli-0.1.0-py3-none-any.whl" + + +def test_path_hint_when_aai_not_on_path(tmp_path): + _python_shim(tmp_path) + _pipx_shim(tmp_path) + result = _run(tmp_path) # no `aai` shim → `command -v aai` fails + assert result.returncode == 0 + assert "isn't on your PATH yet" in result.stdout + + +def test_next_steps_when_aai_present(tmp_path): + _python_shim(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