From f4c5609024764174bc00a31c4631243b4ab0f95d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 19:49:34 -0700 Subject: [PATCH 01/12] test(install): shell-logic unit tests + AAI_SPEC seam for install.sh Co-Authored-By: Claude Opus 4.8 (1M context) --- install.sh | 5 +- tests/test_install_sh.py | 126 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 tests/test_install_sh.py diff --git a/install.sh b/install.sh index 3a1b193a..39df0267 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/tests/test_install_sh.py b/tests/test_install_sh.py new file mode 100644 index 00000000..b52f24f4 --- /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.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) + 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 From d39405b04c123176c04891e922b196bce2f9dde9 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 19:55:49 -0700 Subject: [PATCH 02/12] ci: shellcheck install.sh in check.sh Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/scripts/check.sh b/scripts/check.sh index d72f11f1..79fac1fe 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -20,6 +20,15 @@ uv run mypy # files = ["aai_cli", "tests"] in pyproject.toml 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. Run them with: uv run pytest -m e2e From 76eb467c5fbac72a8a7539be86a9fd78a503556c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:00:14 -0700 Subject: [PATCH 03/12] test(install): real install-and-run smoke test (marker: install_script) Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 1 + scripts/check.sh | 4 +- tests/test_install_script_smoke.py | 117 +++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 tests/test_install_script_smoke.py diff --git a/pyproject.toml b/pyproject.toml index 5bd62099..fd9e4fff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,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)", ] [tool.mypy] diff --git a/scripts/check.sh b/scripts/check.sh index 79fac1fe..39849492 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -32,7 +32,9 @@ 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. Run them with: uv run pytest -m e2e -uv run pytest -q -m "not e2e" --cov=aai_cli --cov-branch --cov-report=term-missing --cov-fail-under=90 +# Exclude install_script: it builds a wheel and runs install.sh for real (slow; needs +# network + uv/pipx). Run it with: uv run pytest -m install_script +uv run pytest -q -m "not e2e 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..d8a7f9c6 --- /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(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") From 023a214deb23a87c6c0d6ae21aff9a972feafd09 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:04:09 -0700 Subject: [PATCH 04/12] ci: add install-smoke job exercising install.sh end to end Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c00e94d5..1dca0648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,3 +94,39 @@ 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 -e ".[dev]" uv pipx + + - name: Real install smoke + run: python -m pytest -q -m install_script ${{ matrix.kfilter }} From 3eb963fd8a48e9a7e18991e0f7a1debf31bd61c1 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:07:36 -0700 Subject: [PATCH 05/12] fix(test): annotate wheels list to satisfy mypy warn_return_any Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_install_script_smoke.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_install_script_smoke.py b/tests/test_install_script_smoke.py index d8a7f9c6..e811f21f 100644 --- a/tests/test_install_script_smoke.py +++ b/tests/test_install_script_smoke.py @@ -64,7 +64,7 @@ def built_wheel(tmp_path_factory) -> Path: capture_output=True, text=True, ) - wheels = list(out.glob("*.whl")) + wheels: list[Path] = list(out.glob("*.whl")) assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}" return wheels[0] From 8ef9a0c322a32cfbf6ae8a7109dd242a7aa88590 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:10:16 -0700 Subject: [PATCH 06/12] ci: run on every push, not just main Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dca0648..1d4687cd 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. From fc05a37e79525707cf0a169ed07a15a14747c7fd Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:25:25 -0700 Subject: [PATCH 07/12] chore: track docs/ (plans + specs) in version control These design plans and specs were gitignored, so the rationale behind major features lived on disk but not in history. Tracking them makes the "why" available via git and to anyone who clones the repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 +- .../plans/2026-06-02-claude-command.md | 653 +++++++ .../superpowers/plans/2026-06-02-streaming.md | 833 ++++++++ .../plans/2026-06-02-voice-agent.md | 1228 ++++++++++++ .../plans/2026-06-03-cli-color-theme.md | 919 +++++++++ .../plans/2026-06-03-full-sdk-options.md | 1565 +++++++++++++++ .../superpowers/plans/2026-06-03-show-code.md | 1251 ++++++++++++ ...06-04-aai-account-self-service-commands.md | 1676 +++++++++++++++++ ...-04-help-examples-and-error-suggestions.md | 1641 ++++++++++++++++ .../plans/2026-06-04-homebrew-tap.md | 458 +++++ .../plans/2026-06-04-install-path-testing.md | 510 +++++ .../2026-06-04-stytch-oauth-cli-login.md | 938 +++++++++ .../specs/2026-06-02-claude-command-design.md | 178 ++ .../specs/2026-06-02-streaming-design.md | 104 + .../specs/2026-06-02-voice-agent-design.md | 146 ++ .../2026-06-03-cli-color-theme-design.md | 140 ++ .../2026-06-03-full-sdk-options-design.md | 219 +++ .../specs/2026-06-03-show-code-design.md | 196 ++ .../2026-06-04-install-path-testing-design.md | 133 ++ ...2026-06-04-stytch-oauth-cli-auth-design.md | 267 +++ 20 files changed, 13056 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-02-claude-command.md create mode 100644 docs/superpowers/plans/2026-06-02-streaming.md create mode 100644 docs/superpowers/plans/2026-06-02-voice-agent.md create mode 100644 docs/superpowers/plans/2026-06-03-cli-color-theme.md create mode 100644 docs/superpowers/plans/2026-06-03-full-sdk-options.md create mode 100644 docs/superpowers/plans/2026-06-03-show-code.md create mode 100644 docs/superpowers/plans/2026-06-04-aai-account-self-service-commands.md create mode 100644 docs/superpowers/plans/2026-06-04-help-examples-and-error-suggestions.md create mode 100644 docs/superpowers/plans/2026-06-04-homebrew-tap.md create mode 100644 docs/superpowers/plans/2026-06-04-install-path-testing.md create mode 100644 docs/superpowers/plans/2026-06-04-stytch-oauth-cli-login.md create mode 100644 docs/superpowers/specs/2026-06-02-claude-command-design.md create mode 100644 docs/superpowers/specs/2026-06-02-streaming-design.md create mode 100644 docs/superpowers/specs/2026-06-02-voice-agent-design.md create mode 100644 docs/superpowers/specs/2026-06-03-cli-color-theme-design.md create mode 100644 docs/superpowers/specs/2026-06-03-full-sdk-options-design.md create mode 100644 docs/superpowers/specs/2026-06-03-show-code-design.md create mode 100644 docs/superpowers/specs/2026-06-04-install-path-testing-design.md create mode 100644 docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md diff --git a/.gitignore b/.gitignore index 18d796db..1b17394d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,8 @@ build/ .coverage htmlcov/ -# Editor/agent and local planning artifacts +# Editor/agent local artifacts .claude/ -docs/ # Local scratch scripts (often contain live keys) transcribe/ 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..66d186ff --- /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. +``` \ No newline at end of file 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). +``` From 6e3787edad3c733b6f8a2b566a908d0e4d108028 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:27:03 -0700 Subject: [PATCH 08/12] docs: add CLAUDE.md agent/dev guide Captures the dev commands (uv run + check.sh), test markers and the 90% coverage gate, naming/packaging gotchas (aai_cli vs aai-cli vs aai, committed templates with renamed dotfiles), architecture, and the errors-to-stderr/data-to-stdout convention so a fresh agent has the in-repo context that previously lived only outside the repo. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 CLAUDE.md 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. From 3f82ca481c9df2f1676a0cb55a4a8a9696ba4b37 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:35:17 -0700 Subject: [PATCH 09/12] chore(claude): add team-shared Claude Code automations Track the shareable parts of .claude/ (settings, agents, skills, commands) and add .mcp.json, narrowing .gitignore to only ignore settings.local.json. - settings.json: format/lint-on-edit (PostToolUse ruff) and a lock/secret-file write guard (PreToolUse); a conservative committed permission allowlist (uv/ruff/mypy/pytest/pre-commit/check.sh + read-only git/gh + docs MCP) plus a deny block on reading .env/keys. - agents/: security-reviewer (auth/credential/subprocess) and template-contract-reviewer (aai init templates + wheel packaging). - skills/: /check (full gate) and /release-prep (version+build+smoke), user-only. - commands/: /review-changes wires both reviewers, scoped to the diff. - .mcp.json: GitHub remote MCP server (per-user OAuth, no token committed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/agents/security-reviewer.md | 36 ++++++++++ .claude/agents/template-contract-reviewer.md | 26 ++++++++ .claude/commands/review-changes.md | 23 +++++++ .claude/settings.json | 69 ++++++++++++++++++++ .claude/skills/check/SKILL.md | 35 ++++++++++ .claude/skills/release-prep/SKILL.md | 46 +++++++++++++ .gitignore | 5 +- .mcp.json | 8 +++ 8 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 .claude/agents/security-reviewer.md create mode 100644 .claude/agents/template-contract-reviewer.md create mode 100644 .claude/commands/review-changes.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/check/SKILL.md create mode 100644 .claude/skills/release-prep/SKILL.md create mode 100644 .mcp.json 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/.gitignore b/.gitignore index 1b17394d..d8badeda 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,9 @@ build/ .coverage htmlcov/ -# Editor/agent local artifacts -.claude/ +# 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..209568c4 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} From 838c1e2fbc2c0bd705af3b95b736b67f845a1e3e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:39:24 -0700 Subject: [PATCH 10/12] fix(mcp): authenticate GitHub MCP server via PAT header The bare http config made Claude Code attempt OAuth dynamic client registration, which api.githubcopilot.com rejects ("does not support dynamic client registration"). Pass a token via the Authorization header instead, read from ${GITHUB_PAT} so no credential is committed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .mcp.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.mcp.json b/.mcp.json index 209568c4..dac39594 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,7 +2,10 @@ "mcpServers": { "github": { "type": "http", - "url": "https://api.githubcopilot.com/mcp/" + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer ${GITHUB_PAT}" + } } } } From 95cc1f42a0de06aa294b131e8ac804e6d9f82175 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:46:57 -0700 Subject: [PATCH 11/12] ci(install-smoke): install dev group via --group, not the nonexistent [dev] extra MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev deps are a PEP 735 dependency-group, not an optional extra, so `pip install -e ".[dev]"` installed no dev deps (pip just warns the extra is missing) and pytest was absent — `python -m pytest` failed with "No module named pytest". Mirror the lint/test job: upgrade pip (>= 25.1 for --group) and install with `--group dev`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9193c690..2fbe747b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,7 +129,9 @@ jobs: # 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 + 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 }} From 3a374ecc998a5ca9e7385e35e4b0013994ff9365 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Thu, 4 Jun 2026 20:50:32 -0700 Subject: [PATCH 12/12] fix(ci): align install.sh tests with 3.11 floor; fix doc EOF newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge with main raised the minimum Python to 3.11 (install.sh + pyproject), but the install.sh shell-logic tests still asserted the old "Python 3.10+ is required" message — update them to 3.11. Also add the trailing newline end-of-file-fixer wanted on the account-commands plan doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-04-aai-account-self-service-commands.md | 2 +- tests/test_install_sh.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 index 66d186ff..1f8efe6d 100644 --- 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 @@ -1673,4 +1673,4 @@ git commit -m "docs: document account self-service commands" - **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. -``` \ No newline at end of file +``` diff --git a/tests/test_install_sh.py b/tests/test_install_sh.py index b52f24f4..3b06e6d1 100644 --- a/tests/test_install_sh.py +++ b/tests/test_install_sh.py @@ -30,8 +30,8 @@ def _shim(path: Path, body: str) -> None: 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 + # -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( @@ -61,14 +61,14 @@ 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 + 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.10+ is required" in result.stderr + assert "Python 3.11+ is required" in result.stderr assert "3.9.18" in result.stderr