From 27e0b2600c2b130c39eeb9084bc3d2757c7e4578 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 10:57:26 -0700 Subject: [PATCH 01/40] docs: spec for aai dev command Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-09-aai-dev-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-aai-dev-design.md diff --git a/docs/superpowers/specs/2026-06-09-aai-dev-design.md b/docs/superpowers/specs/2026-06-09-aai-dev-design.md new file mode 100644 index 00000000..d51150b6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-aai-dev-design.md @@ -0,0 +1,161 @@ +# `aai dev` — launch a scaffolded template's dev server + +**Date:** 2026-06-09 +**Status:** Approved (design) + +## Problem + +After `aai init` scaffolds a template, re-running the app means remembering and +hand-typing `uvicorn api.index:app --reload --port 3000`. Every template README +and `AGENTS.md` documents that raw command, and `aai init`'s sign-off hints point +at it too. We want a first-class `aai dev` that "just works" from inside a +scaffolded project, and we want the docs/hints to advertise `aai dev` instead. + +## Goals + +- `aai dev`, run from a scaffolded template directory, installs dependencies if + needed and launches the FastAPI dev server with live reload, opening the + browser. +- Replace the hand-typed `uvicorn …` command everywhere it's advertised + (template `README.md` + `AGENTS.md`, `aai init` hints) with `aai dev`. + +## Non-goals + +- Detecting *which* template is running. All three templates share the identical + serve contract (`api/index:app`), so `dev` is template-agnostic. +- Walking up parent directories to find a project root. Detection is + current-directory-only (see Decisions). +- Production serving / deploy. `dev` is a local development server only. + +## Decisions + +1. **Detection: current directory only.** `aai dev` runs iff + `Path.cwd() / "api" / "index.py"` exists. No parent-directory walk, no + per-template marker file. The user must be at the project root (the directory + `aai init` created / `--here` targeted). +2. **Auto-install then launch.** If dependencies aren't set up, `dev` runs the + existing `runner.run_setup` (uv `venv` + `uv pip install -r requirements.txt`, + or stdlib `venv` + `pip` when uv is absent) before launching. A `--no-install` + flag skips this for users who manage their own environment. +3. **Live reload on by default.** `dev` always passes `--reload` to uvicorn so + edits hot-reload. This matches what every template README already documents. + `aai init`'s launch path keeps reload **off** (unchanged behavior). + +## Architecture + +### `aai_cli/init/runner.py` (one additive change) + +Thread a `reload` flag through the serve path: + +- `serve_command(target, *, port, use_uv, reload=False)` — append `--reload` to + the uvicorn argv when `reload` is true. Default `False` preserves `init`'s + current behavior with no change to its call site. +- `launch_and_open(target, *, port, use_uv, open_browser, reload=False)` — + forward `reload` to `serve_command`. + +Everything else in `runner.py` (`run_setup`, `find_free_port`, `wait_for_port`, +`has_uv`) is reused as-is. + +### `aai_cli/commands/dev.py` (new) + +Single flattened Typer command, mirroring `init.py`'s +`app = typer.Typer()` + `app.add_typer(..., no name)` pattern. + +Flags: + +| Flag | Default | Meaning | +|------|---------|---------| +| `--port` | `3000` | Local server port (first free port at/above this). | +| `--no-open` | `False` | Launch but don't open the browser. | +| `--no-install` | `False` | Skip the auto-install step; launch directly. | +| `--json` | `False` | Machine-readable output. | + +Command body (run through `context.run_command`): + +1. **Locate the app.** `app_file = Path.cwd() / "api" / "index.py"`. If it doesn't + exist → `CLIError(error_type="usage_error", exit_code=1)` whose message tells + the user to `cd` into a directory created by `aai init`, or run `aai init` + first. +2. **Resolve runner.** `use_uv = runner.has_uv()`. +3. **Install (unless `--no-install`).** `runner.run_setup(cwd, use_uv=use_uv)`. + On non-zero return, emit a failed `install` step and exit non-zero (mirrors + `init`'s `_install_step` failure shape). Emit an `installed`/`skipped` step + otherwise. +4. **Launch.** `port = runner.find_free_port(port)`; print the + `Starting http://localhost:PORT (Ctrl-C to stop)` banner (non-JSON only, + reusing `init`'s `_launch` styling); call + `runner.launch_and_open(cwd, port=port, use_uv=use_uv, open_browser=not no_open, reload=True)`. + Propagate a non-zero server exit code via `typer.Exit`. + +Because the install/launch styling already exists in `init.py`, the shared bits +(`_launch` banner, `_install_step` failure row) are small enough to duplicate +cleanly in `dev.py` rather than extracting a shared helper prematurely; if a +third caller appears, extract then. + +### `aai_cli/main.py` (registration) + +- Add `dev` to the `from aai_cli.commands import (...)` block. +- `app.add_typer(dev.app)` alongside the other sub-apps. +- Insert `"dev"` into `_COMMAND_ORDER` immediately after `"init"`. +- File `dev` under the **Build an App** panel via + `rich_help_panel=help_panels.BUILD` on the command. + +## Documentation / hint changes + +Replace the advertised raw command with `aai dev`: + +- `aai_cli/init/templates/audio-transcription/README.md` +- `aai_cli/init/templates/live-captions/README.md` +- `aai_cli/init/templates/voice-agent/README.md` +- `aai_cli/init/templates/audio-transcription/AGENTS.md` +- `aai_cli/init/templates/live-captions/AGENTS.md` +- `aai_cli/init/templates/voice-agent/AGENTS.md` + +In each "Run locally" block, the +`uvicorn api.index:app --reload --port 3000` / `# open http://localhost:3000` +lines become a single `aai dev` line (keeping a note that it serves on +`http://localhost:3000` and reads `ASSEMBLYAI_API_KEY` from `.env`). + +`aai_cli/commands/init.py` two hints: + +- The "no API key" launch-skipped detail: + `… run \`aai login\`, then: cd {target} && aai dev`. +- The scaffold-only sign-off hint: + `Run \`cd {target} && aai dev\`.` + +## Errors + +- Not in a template dir → `usage_error`, exit 1, actionable message. +- Install failure → failed step row + exit 1 (no server start). +- Clean Ctrl-C → exit 0 (handled by `launch_and_open`'s `KeyboardInterrupt`). +- Non-zero server exit → propagated as the command's exit code. + +## Testing + +New `tests/test_dev.py`, mocking at the `runner` boundary per the repo's +pytest-mock convention (no real subprocess / network): + +- Launch happy path: `api/index.py` present, install succeeds, `launch_and_open` + called with `reload=True` and the resolved free port; browser opened unless + `--no-open`. +- `--no-open` → `open_browser=False`. +- `--no-install` → `run_setup` not called; still launches. +- Missing `api/index.py` → `usage_error`, exit 1, `run_setup`/`launch_and_open` + never called. +- Install failure → exit 1, `launch_and_open` never called. +- `--json` path emits structured steps. + +Gate consequences to handle in the same change: + +- Regenerate the `aai --help` syrupy snapshot (`--snapshot-update`) — a new + command and panel entry appears. +- Update any template-contract test (`tests/test_init_template_*.py`) that pins + the old `uvicorn …` string in README/AGENTS content. +- Maintain ≥90% branch coverage and 100% patch coverage (`diff-cover`). + +## Out of scope / future + +- Parent-directory project-root discovery. +- A `--reload/--no-reload` toggle (reload is unconditional for now). +- Reusing `dev`'s launch from inside `aai init` (init keeps its own non-reload + launch). From a86a6bb3a808de853693c6913768a8c83a64d6be Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:01:12 -0700 Subject: [PATCH 02/40] docs: implementation plan for aai dev Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-06-09-aai-dev.md | 579 +++++++++++++++++++ 1 file changed, 579 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-aai-dev.md diff --git a/docs/superpowers/plans/2026-06-09-aai-dev.md b/docs/superpowers/plans/2026-06-09-aai-dev.md new file mode 100644 index 00000000..ce42f158 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-aai-dev.md @@ -0,0 +1,579 @@ +# `aai dev` 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 dev` command that, from inside a scaffolded template directory, installs dependencies if needed and launches the FastAPI dev server with live reload — and update every doc/hint to advertise `aai dev` instead of the raw `uvicorn` command. + +**Architecture:** A new flattened Typer sub-app `aai_cli/commands/dev.py` modeled on `init.py`, reusing all of `aai_cli/init/runner.py`'s machinery (`has_uv`, `run_setup`, `find_free_port`, `launch_and_open`). One additive `reload` flag is threaded through `runner.serve_command`/`launch_and_open`; `aai dev` passes `reload=True`, `init` keeps its existing `reload=False`. + +**Tech Stack:** Python 3.12+, Typer/Click, Rich, pytest + pytest-mock, syrupy snapshots, uv-managed env. + +--- + +## File Structure + +- `aai_cli/init/runner.py` — **modify**: add `reload` param to `serve_command` + `launch_and_open`. +- `aai_cli/commands/dev.py` — **create**: the `aai dev` command. +- `aai_cli/main.py` — **modify**: import + register `dev`, add to `_COMMAND_ORDER`. +- `aai_cli/commands/init.py` — **modify**: two hints `uvicorn …` → `aai dev`. +- `tests/test_dev.py` — **create**: command tests. +- `tests/test_init_runner.py` — **modify**: add a `reload=True` serve-command test. +- `tests/test_init_command.py` — **modify**: two assertions `uvicorn api.index` → `aai dev`. +- 3× `README.md` + 3× `AGENTS.md` under `aai_cli/init/templates/*` — **modify**: run-locally block → `aai dev`. +- `tests/__snapshots__/test_cli_output_snapshots.ambr` — **regenerate** (new command in `aai --help`). + +--- + +## Task 1: Thread a `reload` flag through `runner` + +**Files:** +- Modify: `aai_cli/init/runner.py` (`serve_command`, `launch_and_open`) +- Test: `tests/test_init_runner.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_init_runner.py` (after `test_serve_command_uv_and_venv`): + +```python +def test_serve_command_appends_reload(): + target = Path("/proj") + assert runner.serve_command(target, port=3000, use_uv=True, reload=True) == [ + "uv", + "run", + "uvicorn", + "api.index:app", + "--port", + "3000", + "--reload", + ] + py = str(runner.venv_python(target)) + assert runner.serve_command(target, port=3000, use_uv=False, reload=True) == [ + py, + "-m", + "uvicorn", + "api.index:app", + "--port", + "3000", + "--reload", + ] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_init_runner.py::test_serve_command_appends_reload -q` +Expected: FAIL — `serve_command()` got an unexpected keyword argument `reload`. + +- [ ] **Step 3: Implement the minimal change** + +In `aai_cli/init/runner.py`, replace `serve_command` with: + +```python +def serve_command(target: Path, *, port: int, use_uv: bool, reload: bool = False) -> list[str]: + extra = ["--reload"] if reload else [] + if use_uv: + return ["uv", "run", "uvicorn", "api.index:app", "--port", str(port), *extra] + return [ + str(venv_python(target)), + "-m", + "uvicorn", + "api.index:app", + "--port", + str(port), + *extra, + ] +``` + +And thread `reload` through `launch_and_open` — change its signature and the +`serve_command` call inside it: + +```python +def launch_and_open( + target: Path, *, port: int, use_uv: bool, open_browser: bool, reload: bool = False +) -> int: + """Start the dev server, wait for it, open the browser, and block until Ctrl-C. + + Returns the process exit code (0 on a clean Ctrl-C shutdown). + """ + proc = subprocess.Popen( + serve_command(target, port=port, use_uv=use_uv, reload=reload), cwd=target + ) + try: + if wait_for_port(port) and open_browser: + webbrowser.open(f"http://localhost:{port}") + proc.wait() + except KeyboardInterrupt: + proc.terminate() + proc.wait() + return 0 + return proc.returncode +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_init_runner.py -q` +Expected: PASS (the existing `test_serve_command_uv_and_venv` still passes — default `reload=False` keeps old behavior). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/init/runner.py tests/test_init_runner.py +git commit -m "feat(runner): support --reload in serve_command/launch_and_open" +``` + +--- + +## Task 2: Create the `aai dev` command + +**Files:** +- Create: `aai_cli/commands/dev.py` +- Modify: `aai_cli/main.py` +- Test: `tests/test_dev.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_dev.py`: + +```python +import subprocess + +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() + + +def _make_app(tmp_path): + """Scaffold the minimal marker `aai dev` looks for: api/index.py.""" + (tmp_path / "api").mkdir() + (tmp_path / "api" / "index.py").write_text("app = object()\n") + + +def _stub_runner(monkeypatch, *, use_uv=True, setup_rc=0): + """Stub the runner boundary; return a dict capturing launch_and_open kwargs.""" + monkeypatch.setattr("aai_cli.init.runner.has_uv", lambda: use_uv) + monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda port, **k: port) + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], setup_rc, "", "boom"), + ) + captured: dict = {} + + def fake_launch(target, *, port, use_uv, open_browser, reload): + captured.update( + target=target, port=port, use_uv=use_uv, open_browser=open_browser, reload=reload + ) + return 0 + + monkeypatch.setattr("aai_cli.init.runner.launch_and_open", fake_launch) + return captured + + +def test_dev_launches_with_reload(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--no-open"]) + assert result.exit_code == 0, result.output + assert captured["reload"] is True + assert captured["open_browser"] is False + assert captured["port"] == 3000 + + +def test_dev_opens_browser_by_default(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 0, result.output + assert captured["open_browser"] is True + + +def test_dev_no_install_skips_setup(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + called = {"setup": False} + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: called.__setitem__("setup", True) + or subprocess.CompletedProcess([], 0, "", ""), + ) + result = runner.invoke(app, ["dev", "--no-install", "--no-open"]) + assert result.exit_code == 0, result.output + assert called["setup"] is False + assert captured["reload"] is True + + +def test_dev_missing_app_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) # no api/index.py here + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 1 + assert "aai init" in result.output + assert captured == {} # never launched + + +def test_dev_install_failure_exits(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch, setup_rc=1) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 1 + assert captured == {} # install failed -> no launch + + +def test_dev_server_nonzero_exit_propagates(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + _stub_runner(monkeypatch) + monkeypatch.setattr("aai_cli.init.runner.launch_and_open", lambda *a, **k: 3) + result = runner.invoke(app, ["dev", "--no-open"]) + assert result.exit_code == 3 + + +def test_dev_json_emits_install_step(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--no-open", "--json"]) + assert result.exit_code == 0, result.output + assert '"name": "install"' in result.output +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_dev.py -q` +Expected: FAIL — `No such command 'dev'` (command not registered yet). + +- [ ] **Step 3: Create the command** + +Create `aai_cli/commands/dev.py`: + +```python +# aai_cli/commands/dev.py +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape + +from aai_cli import help_panels, output, steps +from aai_cli.context import AppState, run_command +from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog +from aai_cli.init import runner + +# Flattened single-command sub-typer (same pattern as `aai init`): one +# @app.command() registered via app.add_typer(dev.app) with no name. +app = typer.Typer() + + +def _require_app_dir() -> Path: + """The current directory, if it holds a scaffolded FastAPI app (`api/index.py`).""" + cwd = Path.cwd() + if not (cwd / "api" / "index.py").exists(): + raise CLIError( + "No app found here (expected api/index.py). cd into a project created by " + "`aai init`, or run `aai init` to scaffold one.", + error_type="usage_error", + exit_code=1, + ) + return cwd + + +def _install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: + """Install deps (unless --no-install) and return the report row.""" + if no_install: + return {"name": "install", "status": "skipped", "detail": "--no-install"} + setup = runner.run_setup(target, use_uv=use_uv) + if setup.returncode != 0: + return { + "name": "install", + "status": "failed", + "detail": (setup.stderr or setup.stdout).strip()[:300], + } + return {"name": "install", "status": "installed", "detail": "uv" if use_uv else "venv + pip"} + + +def run_dev(*, port: int, no_install: bool, no_open: bool, json_mode: bool) -> None: + """Install deps if needed, then launch the dev server with live reload.""" + target = _require_app_dir() + use_uv = runner.has_uv() + + report: list[steps.Step] = [_install_step(target, no_install=no_install, use_uv=use_uv)] + output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode) + if any(s["status"] == "failed" for s in report): + raise typer.Exit(code=1) + + chosen_port = runner.find_free_port(port) + url = f"http://localhost:{chosen_port}" + if not json_mode: + output.console.print( + f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]" + " [aai.muted](Ctrl-C to stop)[/aai.muted]" + ) + code = runner.launch_and_open( + target, port=chosen_port, use_uv=use_uv, open_browser=not no_open, reload=True + ) + if code: + raise typer.Exit(code=code) + + +@app.command( + rich_help_panel=help_panels.BUILD, + epilog=examples_epilog( + [ + ("Launch the app in the current directory", "aai dev"), + ("Use a specific port", "aai dev --port 8000"), + ("Launch without opening a browser", "aai dev --no-open"), + ("Skip the dependency install step", "aai dev --no-install"), + ] + ), +) +def dev( + ctx: typer.Context, + port: int = typer.Option(3000, "--port", help="Local server port."), + no_open: bool = typer.Option( + False, "--no-open", help="Launch, but don't open the browser." + ), + no_install: bool = typer.Option( + False, "--no-install", help="Skip dependency install; launch directly." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Launch the dev server for the app in the current directory. + + Run this from inside a project created by `aai init`. It installs dependencies + if needed, then starts the FastAPI server with live reload and opens the browser. + """ + + def body(_state: AppState, json_mode: bool) -> None: + run_dev(port=port, no_install=no_install, no_open=no_open, json_mode=json_mode) + + run_command(ctx, body, json=json_out) +``` + +- [ ] **Step 4: Register the command in `main.py`** + +In `aai_cli/main.py`, add `dev` to the commands import block (keep alphabetical +within the block — insert between `doctor` and `init`): + +```python +from aai_cli.commands import ( + account, + agent, + audit, + doctor, + dev, + init, + keys, + llm, + login, + onboard, + sessions, + setup, + stream, + transcribe, + transcripts, +) +``` + +Find where the sub-apps are registered (the `app.add_typer(...)` calls) and add, +next to the `init` registration: + +```python +app.add_typer(dev.app) +``` + +In `_COMMAND_ORDER`, insert `"dev"` immediately after `"init"`: + +```python + # Build an App — scaffold a new project + "init", + "dev", +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `uv run pytest tests/test_dev.py -q` +Expected: PASS (all 7 tests). + +- [ ] **Step 6: Regenerate the help snapshot and review the diff** + +Run: `uv run pytest tests/test_cli_output_snapshots.py --snapshot-update -q` +Then: `git diff tests/__snapshots__/test_cli_output_snapshots.ambr` +Expected: the diff shows `dev` added under the **Build an App** panel in `aai --help` (and a new `aai dev --help` block if the suite snapshots it). Confirm nothing unrelated changed. + +- [ ] **Step 7: Commit** + +```bash +git add aai_cli/commands/dev.py aai_cli/main.py tests/test_dev.py tests/__snapshots__/test_cli_output_snapshots.ambr +git commit -m "feat(dev): add 'aai dev' to launch a scaffolded template" +``` + +--- + +## Task 3: Point `aai init` hints at `aai dev` + +**Files:** +- Modify: `aai_cli/commands/init.py` (the two hint strings) +- Test: `tests/test_init_command.py` (two assertions) + +- [ ] **Step 1: Update the failing-first assertions** + +In `tests/test_init_command.py`: + +- In `test_init_logged_out_installs_but_skips_launch_with_hint` (~line 60), change: + +```python + assert "uvicorn api.index" in result.output +``` +to: +```python + assert "aai dev" in result.output +``` + +- In `test_init_placeholder_key_when_logged_out` (~line 97), change: + +```python + assert "uvicorn api.index" not in result.output +``` +to: +```python + assert "aai dev" not in result.output +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_init_command.py::test_init_logged_out_installs_but_skips_launch_with_hint -q` +Expected: FAIL — output still says `uvicorn api.index`, not `aai dev`. + +- [ ] **Step 3: Update the hints in `init.py`** + +In `aai_cli/commands/init.py`, in `run_init`, change the launch-skipped detail +(currently ends `… && uv run uvicorn api.index:app`): + +```python + "detail": f"no API key; run `aai login`, then: cd {target} && aai dev", +``` + +And the scaffold-only sign-off hint (currently `… && uv run uvicorn api.index:app`): + +```python + output.console.print( + output.hint(f"Run `cd {escape(str(target))} && aai dev`.") + ) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_init_command.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/init.py tests/test_init_command.py +git commit -m "feat(init): point launch hints at 'aai dev'" +``` + +--- + +## Task 4: Update template docs to advertise `aai dev` + +**Files (modify):** +- `aai_cli/init/templates/audio-transcription/README.md` +- `aai_cli/init/templates/live-captions/README.md` +- `aai_cli/init/templates/voice-agent/README.md` +- `aai_cli/init/templates/audio-transcription/AGENTS.md` +- `aai_cli/init/templates/live-captions/AGENTS.md` +- `aai_cli/init/templates/voice-agent/AGENTS.md` + +- [ ] **Step 1: Update the three README "Run locally" blocks** + +In each README, replace the fenced `sh` block under `## Run locally`. + +`audio-transcription/README.md` — replace: +```sh +uvicorn api.index:app --reload --port 3000 +# open http://localhost:3000 +``` +with: +```sh +aai dev # installs deps if needed, starts the server, opens http://localhost:3000 +``` + +`live-captions/README.md` — replace: +```sh +uvicorn api.index:app --reload --port 3000 +# open http://localhost:3000 (allow microphone access) +``` +with: +```sh +aai dev # opens http://localhost:3000 (allow microphone access) +``` + +`voice-agent/README.md` — replace: +```sh +uvicorn api.index:app --reload --port 3000 +# open http://localhost:3000 (allow microphone access; headphones recommended) +``` +with: +```sh +aai dev # opens http://localhost:3000 (allow microphone access; headphones recommended) +``` + +- [ ] **Step 2: Update the three AGENTS.md run blocks** + +In each of the three `AGENTS.md` files, replace: +```sh +uvicorn api.index:app --reload --port 3000 +``` +with: +```sh +aai dev +``` + +(The surrounding "Run it with:" sentence stays.) + +- [ ] **Step 3: Verify no stale references remain** + +Run: `grep -rn "uvicorn api.index:app" aai_cli/ | grep -v __pycache__` +Expected: no output (all template/hint references are gone; `runner.serve_command` builds the argv programmatically, not as that literal string). + +- [ ] **Step 4: Run the template-contract suite and the full default suite** + +Run: `uv run pytest tests/test_init_template_contract.py tests/test_dev.py tests/test_init_command.py tests/test_init_runner.py -q` +Expected: PASS. If any contract test pins the old `uvicorn …` string, update its expectation to `aai dev`. + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/init/templates +git commit -m "docs(templates): advertise 'aai dev' as the run-locally command" +``` + +--- + +## Task 5: Full gate + +- [ ] **Step 1: Run the authoritative gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` In particular confirm: +- `ruff`/`mypy`/`pyright` clean (note: `dev.py` body arg is named `_state` to satisfy ruff ARG001). +- 90% branch coverage + 100% patch coverage (`diff-cover`) — the Task 2 tests cover every branch in `dev.py` (missing-dir, install skipped/failed/installed, json vs human banner, browser on/off, nonzero server exit). +- no new escape hatches. +- snapshot tests pass against the regenerated `.ambr`. + +- [ ] **Step 2: If the gate is green, the feature is done.** If `diff-cover` flags an uncovered line in `dev.py`, add the missing case to `tests/test_dev.py` and re-run. + +--- + +## Self-Review notes + +- **Spec coverage:** detection (cwd `api/index.py`, Task 2 `_require_app_dir`), auto-install + `--no-install` (Task 2 `_install_step`), reload-on (Task 1 + Task 2 `reload=True`), registration under Build panel (Task 2 main.py), all 8 doc/hint sites (Tasks 3–4), snapshot + contract-test + coverage gate consequences (Tasks 2, 4, 5). All covered. +- **Type consistency:** `serve_command(..., reload=False)` and `launch_and_open(..., reload=False)` defined in Task 1 are called with `reload=True` in Task 2; `steps.Step` rows match the `TypedDict` shape used by `init.py`; `run_command(ctx, body, json=...)` body signature is `(AppState, bool)`. +- **No placeholders:** every code/step block is concrete. From 8e7e5301d62a668d3a260d746829a0df24126286 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:03:12 -0700 Subject: [PATCH 03/40] feat(runner): support --reload in serve_command/launch_and_open Co-Authored-By: Claude Sonnet 4.6 --- aai_cli/init/runner.py | 23 ++++++++++++++++++----- tests/test_init_runner.py | 23 +++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/aai_cli/init/runner.py b/aai_cli/init/runner.py index 11a1cba4..2ff3e25b 100644 --- a/aai_cli/init/runner.py +++ b/aai_cli/init/runner.py @@ -34,10 +34,19 @@ def env_setup_commands(target: Path, *, use_uv: bool) -> list[list[str]]: ] -def serve_command(target: Path, *, port: int, use_uv: bool) -> list[str]: +def serve_command(target: Path, *, port: int, use_uv: bool, reload: bool = False) -> list[str]: + extra = ["--reload"] if reload else [] if use_uv: - return ["uv", "run", "uvicorn", "api.index:app", "--port", str(port)] - return [str(venv_python(target)), "-m", "uvicorn", "api.index:app", "--port", str(port)] + return ["uv", "run", "uvicorn", "api.index:app", "--port", str(port), *extra] + return [ + str(venv_python(target)), + "-m", + "uvicorn", + "api.index:app", + "--port", + str(port), + *extra, + ] def _port_open(port: int) -> bool: @@ -81,12 +90,16 @@ def run_setup(target: Path, *, use_uv: bool) -> subprocess.CompletedProcess[str] return last -def launch_and_open(target: Path, *, port: int, use_uv: bool, open_browser: bool) -> int: +def launch_and_open( + target: Path, *, port: int, use_uv: bool, open_browser: bool, reload: bool = False +) -> int: """Start the dev server, wait for it, open the browser, and block until Ctrl-C. Returns the process exit code (0 on a clean Ctrl-C shutdown). """ - proc = subprocess.Popen(serve_command(target, port=port, use_uv=use_uv), cwd=target) + proc = subprocess.Popen( + serve_command(target, port=port, use_uv=use_uv, reload=reload), cwd=target + ) try: if wait_for_port(port) and open_browser: webbrowser.open(f"http://localhost:{port}") diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index f6cf2e58..2831d4fa 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -60,6 +60,29 @@ def test_serve_command_uv_and_venv(): ] +def test_serve_command_appends_reload(): + target = Path("/proj") + assert runner.serve_command(target, port=3000, use_uv=True, reload=True) == [ + "uv", + "run", + "uvicorn", + "api.index:app", + "--port", + "3000", + "--reload", + ] + py = str(runner.venv_python(target)) + assert runner.serve_command(target, port=3000, use_uv=False, reload=True) == [ + py, + "-m", + "uvicorn", + "api.index:app", + "--port", + "3000", + "--reload", + ] + + def test_find_free_port_returns_preferred_when_open(): port = runner.find_free_port(0) # 0 -> OS assigns a free port assert isinstance(port, int) and port > 0 From 290be242d73d496e470d2426dfd0bc85c110822f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:07:02 -0700 Subject: [PATCH 04/40] feat(dev): add 'aai dev' to launch a scaffolded template Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/dev.py | 100 +++++++++++++++++ aai_cli/main.py | 3 + .../test_cli_output_snapshots.ambr | 33 ++++++ tests/test_dev.py | 106 ++++++++++++++++++ 4 files changed, 242 insertions(+) create mode 100644 aai_cli/commands/dev.py create mode 100644 tests/test_dev.py diff --git a/aai_cli/commands/dev.py b/aai_cli/commands/dev.py new file mode 100644 index 00000000..7552eb8c --- /dev/null +++ b/aai_cli/commands/dev.py @@ -0,0 +1,100 @@ +# aai_cli/commands/dev.py +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.markup import escape + +from aai_cli import help_panels, output, steps +from aai_cli.context import AppState, run_command +from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog +from aai_cli.init import runner + +# Flattened single-command sub-typer (same pattern as `aai init`): one +# @app.command() registered via app.add_typer(dev.app) with no name. +app = typer.Typer() + + +def _require_app_dir() -> Path: + """The current directory, if it holds a scaffolded FastAPI app (`api/index.py`).""" + cwd = Path.cwd() + if not (cwd / "api" / "index.py").exists(): + raise CLIError( + "No app found here (expected api/index.py). cd into a project created by " + "`aai init`, or run `aai init` to scaffold one.", + error_type="usage_error", + exit_code=1, + ) + return cwd + + +def _install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: + """Install deps (unless --no-install) and return the report row.""" + if no_install: + return {"name": "install", "status": "skipped", "detail": "--no-install"} + setup = runner.run_setup(target, use_uv=use_uv) + if setup.returncode != 0: + return { + "name": "install", + "status": "failed", + "detail": (setup.stderr or setup.stdout).strip()[:300], + } + return {"name": "install", "status": "installed", "detail": "uv" if use_uv else "venv + pip"} + + +def run_dev(*, port: int, no_install: bool, no_open: bool, json_mode: bool) -> None: + """Install deps if needed, then launch the dev server with live reload.""" + target = _require_app_dir() + use_uv = runner.has_uv() + + report: list[steps.Step] = [_install_step(target, no_install=no_install, use_uv=use_uv)] + output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode) + if any(s["status"] == "failed" for s in report): + raise typer.Exit(code=1) + + chosen_port = runner.find_free_port(port) + url = f"http://localhost:{chosen_port}" + if not json_mode: + output.console.print( + f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]" + " [aai.muted](Ctrl-C to stop)[/aai.muted]" + ) + code = runner.launch_and_open( + target, port=chosen_port, use_uv=use_uv, open_browser=not no_open, reload=True + ) + if code: + raise typer.Exit(code=code) + + +@app.command( + rich_help_panel=help_panels.BUILD, + epilog=examples_epilog( + [ + ("Launch the app in the current directory", "aai dev"), + ("Use a specific port", "aai dev --port 8000"), + ("Launch without opening a browser", "aai dev --no-open"), + ("Skip the dependency install step", "aai dev --no-install"), + ] + ), +) +def dev( + ctx: typer.Context, + port: int = typer.Option(3000, "--port", help="Local server port."), + no_open: bool = typer.Option(False, "--no-open", help="Launch, but don't open the browser."), + no_install: bool = typer.Option( + False, "--no-install", help="Skip dependency install; launch directly." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Launch the dev server for the app in the current directory. + + Run this from inside a project created by `aai init`. It installs dependencies + if needed, then starts the FastAPI server with live reload and opens the browser. + """ + + def body(_state: AppState, json_mode: bool) -> None: + run_dev(port=port, no_install=no_install, no_open=no_open, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/main.py b/aai_cli/main.py index 6d541e3a..32c65cc4 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -17,6 +17,7 @@ account, agent, audit, + dev, doctor, init, keys, @@ -44,6 +45,7 @@ "onboard", # Build an App — scaffold a new project "init", + "dev", # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", "stream", @@ -250,6 +252,7 @@ def main( app.add_typer(login.app) # login, logout, whoami app.add_typer(doctor.app) app.add_typer(init.app) +app.add_typer(dev.app) app.add_typer(onboard.app) app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP) app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 2ecb53cd..f476079d 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -121,6 +121,39 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[dev] + ''' + + Usage: aai dev [OPTIONS] + + Launch the dev server for the app in the current directory. + + Run this from inside a project created by `aai init`. It installs dependencies + if needed, then starts the FastAPI server with live reload and opens the + browser. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --port INTEGER Local server port. [default: 3000] │ + │ --no-open Launch, but don't open the browser. │ + │ --no-install Skip dependency install; launch directly. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Launch the app in the current directory + $ aai dev + Use a specific port + $ aai dev --port 8000 + Launch without opening a browser + $ aai dev --no-open + Skip the dependency install step + $ aai dev --no-install + + + ''' # --- # name: test_command_help_matches_snapshot[doctor] diff --git a/tests/test_dev.py b/tests/test_dev.py new file mode 100644 index 00000000..81aba8cc --- /dev/null +++ b/tests/test_dev.py @@ -0,0 +1,106 @@ +import subprocess + +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() + + +def _make_app(tmp_path): + """Scaffold the minimal marker `aai dev` looks for: api/index.py.""" + (tmp_path / "api").mkdir() + (tmp_path / "api" / "index.py").write_text("app = object()\n") + + +def _stub_runner(monkeypatch, *, use_uv=True, setup_rc=0): + """Stub the runner boundary; return a dict capturing launch_and_open kwargs.""" + monkeypatch.setattr("aai_cli.init.runner.has_uv", lambda: use_uv) + monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda port, **k: port) + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], setup_rc, "", "boom"), + ) + captured: dict = {} + + def fake_launch(target, *, port, use_uv, open_browser, reload): + captured.update( + target=target, port=port, use_uv=use_uv, open_browser=open_browser, reload=reload + ) + return 0 + + monkeypatch.setattr("aai_cli.init.runner.launch_and_open", fake_launch) + return captured + + +def test_dev_launches_with_reload(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--no-open"]) + assert result.exit_code == 0, result.output + assert captured["reload"] is True + assert captured["open_browser"] is False + assert captured["port"] == 3000 + + +def test_dev_opens_browser_by_default(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 0, result.output + assert captured["open_browser"] is True + + +def test_dev_no_install_skips_setup(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + called = {"setup": False} + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: ( + called.__setitem__("setup", True) or subprocess.CompletedProcess([], 0, "", "") + ), + ) + result = runner.invoke(app, ["dev", "--no-install", "--no-open"]) + assert result.exit_code == 0, result.output + assert called["setup"] is False + assert captured["reload"] is True + + +def test_dev_missing_app_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) # no api/index.py here + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 1 + assert "aai init" in result.output + assert captured == {} # never launched + + +def test_dev_install_failure_exits(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch, setup_rc=1) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 1 + assert captured == {} # install failed -> no launch + + +def test_dev_server_nonzero_exit_propagates(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + _stub_runner(monkeypatch) + monkeypatch.setattr("aai_cli.init.runner.launch_and_open", lambda *a, **k: 3) + result = runner.invoke(app, ["dev", "--no-open"]) + assert result.exit_code == 3 + + +def test_dev_json_emits_install_step(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--no-open", "--json"]) + assert result.exit_code == 0, result.output + assert '"name": "install"' in result.output From fdeb603bbdf5f72e8857cd13922ac81d5770e0cc Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:16:02 -0700 Subject: [PATCH 05/40] test(dev): strengthen assertions to kill mutation-gate survivors Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_dev.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_dev.py b/tests/test_dev.py index 81aba8cc..a55284f7 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -42,6 +42,8 @@ def test_dev_launches_with_reload(tmp_path, monkeypatch): assert captured["reload"] is True assert captured["open_browser"] is False assert captured["port"] == 3000 + assert "Starting" in result.output + assert "localhost:3000" in result.output def test_dev_opens_browser_by_default(tmp_path, monkeypatch): @@ -86,6 +88,7 @@ def test_dev_install_failure_exits(tmp_path, monkeypatch): result = runner.invoke(app, ["dev"]) assert result.exit_code == 1 assert captured == {} # install failed -> no launch + assert "boom" in result.output def test_dev_server_nonzero_exit_propagates(tmp_path, monkeypatch): @@ -104,3 +107,23 @@ def test_dev_json_emits_install_step(tmp_path, monkeypatch): result = runner.invoke(app, ["dev", "--no-open", "--json"]) assert result.exit_code == 0, result.output assert '"name": "install"' in result.output + assert '"detail": "uv"' in result.output + + +def test_dev_venv_path_when_no_uv(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch, use_uv=False) + result = runner.invoke(app, ["dev", "--no-open", "--json"]) + assert result.exit_code == 0, result.output + assert captured["use_uv"] is False + assert '"detail": "venv + pip"' in result.output + + +def test_dev_custom_port_flows_through(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_app(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--port", "8123", "--no-open"]) + assert result.exit_code == 0, result.output + assert captured["port"] == 8123 From 68a014702cf2da7dd5cc2dee1cacbaf28d7dd56d Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:20:25 -0700 Subject: [PATCH 06/40] feat(init): add Procfile + runtime.txt so templates deploy beyond Vercel Each starter (audio-transcription, live-captions, voice-agent) now ships a Procfile (`web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}`) and runtime.txt (python-3.12), so a `git push` deploys to Render, Railway, Heroku, and Cloud Run buildpacks with no typed start command. Vercel ignores both. READMEs document the "Deploy elsewhere" path. The init template contract gate (run by scripts/check.sh) now boots each template's Procfile command for real and asserts it serves GET / with 200, plus validates the runtime pin. Static mirrors added to the pytest contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/audio-transcription/Procfile | 1 + .../templates/audio-transcription/README.md | 11 ++ .../templates/audio-transcription/runtime.txt | 1 + aai_cli/init/templates/live-captions/Procfile | 1 + .../init/templates/live-captions/README.md | 11 ++ .../init/templates/live-captions/runtime.txt | 1 + aai_cli/init/templates/voice-agent/Procfile | 1 + aai_cli/init/templates/voice-agent/README.md | 11 ++ .../init/templates/voice-agent/runtime.txt | 1 + scripts/template_contract_gate.py | 102 +++++++++++++++++- tests/test_init_template_contract.py | 26 +++++ 11 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 aai_cli/init/templates/audio-transcription/Procfile create mode 100644 aai_cli/init/templates/audio-transcription/runtime.txt create mode 100644 aai_cli/init/templates/live-captions/Procfile create mode 100644 aai_cli/init/templates/live-captions/runtime.txt create mode 100644 aai_cli/init/templates/voice-agent/Procfile create mode 100644 aai_cli/init/templates/voice-agent/runtime.txt diff --git a/aai_cli/init/templates/audio-transcription/Procfile b/aai_cli/init/templates/audio-transcription/Procfile new file mode 100644 index 00000000..d7d8e35c --- /dev/null +++ b/aai_cli/init/templates/audio-transcription/Procfile @@ -0,0 +1 @@ +web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} diff --git a/aai_cli/init/templates/audio-transcription/README.md b/aai_cli/init/templates/audio-transcription/README.md index 298a6e3a..4a62d28b 100644 --- a/aai_cli/init/templates/audio-transcription/README.md +++ b/aai_cli/init/templates/audio-transcription/README.md @@ -20,6 +20,17 @@ as a Vercel environment variable (the local `.env` is git-ignored and not deploy No extra config is needed: Vercel serves the static page and discovers the FastAPI app in `api/index.py`. +## Deploy elsewhere + +The included `Procfile` and `runtime.txt` make this run as a plain Python web app +on Render, Railway, Heroku, Google Cloud Run (`gcloud run deploy --source .`), and +anything else that reads a `Procfile`. Point the platform at this repo and set +`ASSEMBLYAI_API_KEY`; the start command is already declared: + +```sh +uvicorn api.index:app --host 0.0.0.0 --port $PORT +``` + ## Ideas to extend - Show chapter summaries and highlight timestamps. diff --git a/aai_cli/init/templates/audio-transcription/runtime.txt b/aai_cli/init/templates/audio-transcription/runtime.txt new file mode 100644 index 00000000..d2aca3a7 --- /dev/null +++ b/aai_cli/init/templates/audio-transcription/runtime.txt @@ -0,0 +1 @@ +python-3.12 diff --git a/aai_cli/init/templates/live-captions/Procfile b/aai_cli/init/templates/live-captions/Procfile new file mode 100644 index 00000000..d7d8e35c --- /dev/null +++ b/aai_cli/init/templates/live-captions/Procfile @@ -0,0 +1 @@ +web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} diff --git a/aai_cli/init/templates/live-captions/README.md b/aai_cli/init/templates/live-captions/README.md index e0e73700..4828578e 100644 --- a/aai_cli/init/templates/live-captions/README.md +++ b/aai_cli/init/templates/live-captions/README.md @@ -22,6 +22,17 @@ Vercel environment variable (the local `.env` is git-ignored). The backend is ju `/api/token` function; the WebSocket runs browser → AssemblyAI, so nothing long-running is needed. +## Deploy elsewhere + +The included `Procfile` and `runtime.txt` make this run as a plain Python web app +on Render, Railway, Heroku, Google Cloud Run (`gcloud run deploy --source .`), and +anything else that reads a `Procfile`. Point the platform at this repo and set +`ASSEMBLYAI_API_KEY`; the start command is already declared: + +```sh +uvicorn api.index:app --host 0.0.0.0 --port $PORT +``` + ## Ideas to extend - Add `keyterms_prompt` or a `prompt` for domain vocabulary in `STREAMING_CONFIG`. diff --git a/aai_cli/init/templates/live-captions/runtime.txt b/aai_cli/init/templates/live-captions/runtime.txt new file mode 100644 index 00000000..d2aca3a7 --- /dev/null +++ b/aai_cli/init/templates/live-captions/runtime.txt @@ -0,0 +1 @@ +python-3.12 diff --git a/aai_cli/init/templates/voice-agent/Procfile b/aai_cli/init/templates/voice-agent/Procfile new file mode 100644 index 00000000..d7d8e35c --- /dev/null +++ b/aai_cli/init/templates/voice-agent/Procfile @@ -0,0 +1 @@ +web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} diff --git a/aai_cli/init/templates/voice-agent/README.md b/aai_cli/init/templates/voice-agent/README.md index 3d4b4709..5bee3a07 100644 --- a/aai_cli/init/templates/voice-agent/README.md +++ b/aai_cli/init/templates/voice-agent/README.md @@ -23,6 +23,17 @@ Vercel environment variable (the local `.env` is git-ignored). The backend is ju `/api/token` function; the WebSocket runs browser → AssemblyAI, so nothing long-running is needed. +## Deploy elsewhere + +The included `Procfile` and `runtime.txt` make this run as a plain Python web app +on Render, Railway, Heroku, Google Cloud Run (`gcloud run deploy --source .`), and +anything else that reads a `Procfile`. Point the platform at this repo and set +`ASSEMBLYAI_API_KEY`; the start command is already declared: + +```sh +uvicorn api.index:app --host 0.0.0.0 --port $PORT +``` + ## Ideas to extend - Change the `greeting`, `systemPrompt`, or `voice` in `SESSION_CONFIG` (`public/static/app.js`). diff --git a/aai_cli/init/templates/voice-agent/runtime.txt b/aai_cli/init/templates/voice-agent/runtime.txt new file mode 100644 index 00000000..d2aca3a7 --- /dev/null +++ b/aai_cli/init/templates/voice-agent/runtime.txt @@ -0,0 +1 @@ +python-3.12 diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index bdec3940..e04c52a2 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -1,11 +1,16 @@ from __future__ import annotations import ast +import http.client import importlib +import os import re +import signal +import socket import subprocess import sys -from contextlib import contextmanager +import time +from contextlib import closing, contextmanager from pathlib import Path from aai_cli.init import templates @@ -23,6 +28,8 @@ "AGENTS.md", "gitignore", "env.example", + "Procfile", + "runtime.txt", ) _LOCAL_IMPORTS = {"api"} _PKG_MAP = {"dotenv": "python-dotenv", "multipart": "python-multipart"} @@ -165,6 +172,97 @@ def _parse_python_files(path: Path) -> None: ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) +# Supported interpreters (see pyproject `requires-python`). runtime.txt tells +# buildpack platforms (Render/Railway/Heroku/Cloud Run) which Python to provision. +_RUNTIME = re.compile(r"^python-3\.(12|13)(\.\d+)?$") + + +def _runtime_supported(template: str, path: Path) -> None: + pin = (path / "runtime.txt").read_text(encoding="utf-8").strip() + if not _RUNTIME.match(pin): + _fail(f"{template}: runtime.txt pins {pin!r}; must be python-3.12 or python-3.13") + + +def _web_command(template: str, path: Path) -> str: + """The Procfile's `web:` process command — the start command every non-Vercel host runs.""" + for raw in (path / "Procfile").read_text(encoding="utf-8").splitlines(): + if raw.strip().startswith("web:"): + command = raw.split("web:", 1)[1].strip() + if "uvicorn" not in command or "api.index:app" not in command: + _fail( + f"{template}: Procfile web command must run `uvicorn api.index:app`: {command!r}" + ) + return command + _fail(f"{template}: Procfile has no `web:` process") + raise AssertionError # unreachable; _fail raises. Satisfies the type checker. + + +def _free_port() -> int: + with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _terminate(proc: subprocess.Popen[str]) -> str: + """Kill the process group (uvicorn + its shell) and return whatever it logged.""" + if proc.poll() is None: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(os.getpgid(proc.pid), signal.SIGKILL) + proc.wait(timeout=5) + return proc.stdout.read() if proc.stdout else "" + + +_HTTP_OK = 200 + + +def _serves_root(port: int) -> bool: + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=1) + try: + conn.request("GET", "/") + return conn.getresponse().status == _HTTP_OK + finally: + conn.close() + + +def _procfile_boots(template: str, path: Path) -> None: + """Run the Procfile's web command for real and confirm the app answers GET / with 200. + + The other checks are static; this is the one that proves a `git push` to Render, + Railway, Heroku, or Cloud Run actually starts a serving app. PORT is injected the way + those platforms inject it; the key is unused at boot (settings default it to ""). + """ + command = _web_command(template, path) + port = _free_port() + env = {**os.environ, "PORT": str(port), "ASSEMBLYAI_API_KEY": ""} + # `/bin/sh -c` so the Procfile's ${PORT:-3000} expands as it would on the host; + # a fresh session group lets _terminate reap the shell and uvicorn together. + proc = subprocess.Popen( + ["/bin/sh", "-c", command], + cwd=path, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + try: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if proc.poll() is not None: + _fail(f"{template}: Procfile process exited before serving:\n{_terminate(proc)}") + try: + if _serves_root(port): + return + except OSError: + time.sleep(0.25) # not up yet; poll again + _fail(f"{template}: Procfile app did not serve / within 30s:\n{_terminate(proc)}") + finally: + _terminate(proc) + + def _untracked_template_files() -> None: in_worktree = subprocess.run( ["git", "rev-parse", "--is-inside-work-tree"], @@ -197,6 +295,8 @@ def main() -> int: _requirements_pin_versions(template, path) _parse_python_files(path) _import_api(template, path) + _runtime_supported(template, path) + _procfile_boots(template, path) sys.stdout.write(f"validated {len(templates.TEMPLATE_ORDER)} init templates\n") return 0 diff --git a/tests/test_init_template_contract.py b/tests/test_init_template_contract.py index 850317f5..12b467cd 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -32,10 +32,36 @@ def test_required_files_present(template_dir): "AGENTS.md", "gitignore", "env.example", + "Procfile", + "runtime.txt", ): assert (template_dir / rel).exists(), f"{template_dir.name} missing {rel}" +def test_procfile_starts_the_app(template_dir): + """The Procfile gives non-Vercel hosts (Render/Railway/Heroku/Cloud Run) a start + command. The contract gate boots it for real; here we pin its shape.""" + web = [ + line.split("web:", 1)[1].strip() + for line in (template_dir / "Procfile").read_text().splitlines() + if line.strip().startswith("web:") + ] + assert web, f"{template_dir.name}: Procfile has no web: process" + assert "uvicorn" in web[0] and "api.index:app" in web[0], ( + f"{template_dir.name}: Procfile must launch uvicorn api.index:app, got {web[0]!r}" + ) + assert "$PORT" in web[0] or "${PORT" in web[0], ( + f"{template_dir.name}: Procfile must bind the platform's $PORT, got {web[0]!r}" + ) + + +def test_runtime_pins_supported_python(template_dir): + pin = (template_dir / "runtime.txt").read_text().strip() + assert re.fullmatch(r"python-3\.(12|13)(\.\d+)?", pin), ( + f"{template_dir.name}: runtime.txt pins {pin!r}; must be python-3.12 or python-3.13" + ) + + def test_realtime_templates_have_audio_helpers(template_dir): if template_dir.name in {"live-captions", "voice-agent"}: assert (template_dir / "public" / "static" / "audio.js").exists() From 927110d7565df2c6ba2e261cb052899a0ffa325a Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:21:22 -0700 Subject: [PATCH 07/40] test(runner): assert launch_and_open reload default and forwarding Co-Authored-By: Claude Sonnet 4.6 --- tests/test_init_runner.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index 2831d4fa..ddf5a4fc 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -206,3 +206,31 @@ def test_launch_and_open_handles_keyboard_interrupt(monkeypatch): rc = runner.launch_and_open(Path("/proj"), port=3000, use_uv=True, open_browser=False) assert rc == 0 # clean Ctrl-C shutdown assert proc.terminated is True + + +def test_launch_and_open_defaults_to_no_reload(monkeypatch): + captured = {} + proc = _FakeProc(returncode=0) + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + return proc + + monkeypatch.setattr(runner.subprocess, "Popen", fake_popen) + monkeypatch.setattr(runner, "wait_for_port", lambda port: True) + runner.launch_and_open(Path("/proj"), port=3000, use_uv=True, open_browser=False) + assert "--reload" not in captured["cmd"] + + +def test_launch_and_open_forwards_reload(monkeypatch): + captured = {} + proc = _FakeProc(returncode=0) + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + return proc + + monkeypatch.setattr(runner.subprocess, "Popen", fake_popen) + monkeypatch.setattr(runner, "wait_for_port", lambda port: True) + runner.launch_and_open(Path("/proj"), port=3000, use_uv=True, open_browser=False, reload=True) + assert "--reload" in captured["cmd"] From cefc2c46800991ec650567c396fb242e0d3b065b Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:24:39 -0700 Subject: [PATCH 08/40] test: include 'dev' in workflow-order smoke test Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_smoke.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 298e76f9..b467dea3 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -76,6 +76,7 @@ def test_help_lists_commands_in_workflow_order(): "onboard", # Build an App "init", + "dev", # Run AssemblyAI "transcribe", "stream", From 8aebe1d3a32216073b4f61d39a9024e424c8cb98 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:29:20 -0700 Subject: [PATCH 09/40] docs: revise aai dev to boot from the template Procfile Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/superpowers/plans/2026-06-09-aai-dev.md | 26 ++++++++++++++ .../specs/2026-06-09-aai-dev-design.md | 35 ++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-06-09-aai-dev.md b/docs/superpowers/plans/2026-06-09-aai-dev.md index ce42f158..50fef115 100644 --- a/docs/superpowers/plans/2026-06-09-aai-dev.md +++ b/docs/superpowers/plans/2026-06-09-aai-dev.md @@ -10,6 +10,32 @@ --- +## Revision (2026-06-09): `aai dev` boots the Procfile + +Mid-implementation the design pivoted (see the spec's "Revision" section): `aai dev` +boots the template's `Procfile` `web:` process instead of a hardcoded uvicorn +command, so the command users run == the command hosts run. This **supersedes +Tasks 1–2 below**. The actual implemented shape: + +- **Revert** the `reload` param added to `runner.serve_command` / `launch_and_open` + (dev no longer routes through `serve_command`). +- **New `aai_cli/init/procfile.py`**: `web_argv(target, *, env)` parses the `web:` + line, `shlex.split`s it, and expands `${PORT:-3000}` / `${VAR}` / `$VAR`; raises a + `usage_error` `CLIError` when there's no `Procfile` (the new cwd "is this a + project" detector) or no `web:` line. +- **`runner.py`**: extract `run_server(target, *, command, port, env=None, open_browser)` + (Popen + wait-for-port + browser + Ctrl-C). `launch_and_open` delegates to it + with `serve_command(...)`; init's call site is unchanged. +- **`dev.py`**: pick a free port → `env = {**os.environ, "PORT": str(port)}` → + `web = procfile.web_argv(cwd, env=env)` (validates project) → install (unless + `--no-install`) → `command = [*("uv","run" | venv_python,"-m"), *web, "--reload"]` + → `runner.run_server(...)`. Detection marker is the **`Procfile`**, not + `api/index.py`. +- New `tests/test_procfile.py`; `tests/test_dev.py` and `tests/test_init_runner.py` + rewritten to match. Tasks 3–5 (init hints, template docs, gate) are unaffected. + +--- + ## File Structure - `aai_cli/init/runner.py` — **modify**: add `reload` param to `serve_command` + `launch_and_open`. diff --git a/docs/superpowers/specs/2026-06-09-aai-dev-design.md b/docs/superpowers/specs/2026-06-09-aai-dev-design.md index d51150b6..0d443e5c 100644 --- a/docs/superpowers/specs/2026-06-09-aai-dev-design.md +++ b/docs/superpowers/specs/2026-06-09-aai-dev-design.md @@ -1,7 +1,40 @@ # `aai dev` — launch a scaffolded template's dev server **Date:** 2026-06-09 -**Status:** Approved (design) +**Status:** Approved (design) — revised mid-implementation (see "Revision" below) + +## Revision (2026-06-09): boot from the template's Procfile + +Every template already ships a `Procfile` +(`web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}`) — the start +command for non-Vercel hosts (Render/Railway/Heroku/Cloud Run), otherwise only +exercised in a real deploy. Rather than hardcode a second copy of the start +command, **`aai dev` boots the Procfile's `web:` process**, so the command users +run locally is the command hosts run in production, and every `aai dev` +smoke-tests the deploy artifact. + +Scope: **dev only.** `aai init`'s internal launch keeps its existing +`runner.serve_command` path (unchanged). Concretely this revises the design below: + +- The cwd marker becomes the **`Procfile`** (not `api/index.py`). +- A new module `aai_cli/init/procfile.py` parses the `web:` line into an argv, + expanding `${PORT:-3000}` / `${VAR}` / `$VAR` against a supplied env. +- `aai dev` sets `PORT` to the chosen free port, runs the parsed command through + the project venv (`uv run …` or `.venv/bin/python -m …`), and **appends + `--reload`** for dev ergonomics. +- `runner.serve_command` / `launch_and_open` keep their **original (no-`reload`)** + signatures; the orchestration (Popen + wait-for-port + browser + Ctrl-C) is + extracted into `runner.run_server(target, *, command, port, env, open_browser)`, + which both `launch_and_open` (init) and `aai dev` call. The `reload` param that + an earlier revision added to `serve_command`/`launch_and_open` is removed + (dev no longer routes through `serve_command`, so it would be dead). + +The sections below describe the original (pre-revision) design; where they +mention `api/index.py` detection or `reload` on `serve_command`, the revision +above supersedes them. + +--- + ## Problem From aa07d985219eff99611114b6f20edac861a64897 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:31:53 -0700 Subject: [PATCH 10/40] feat(procfile): parse web: command; extract runner.run_server Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/procfile.py | 52 ++++++++++++++++++++++++++++++++++++++ aai_cli/init/runner.py | 43 +++++++++++++++++-------------- tests/test_init_runner.py | 51 ++++++++----------------------------- tests/test_procfile.py | 53 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 59 deletions(-) create mode 100644 aai_cli/init/procfile.py create mode 100644 tests/test_procfile.py diff --git a/aai_cli/init/procfile.py b/aai_cli/init/procfile.py new file mode 100644 index 00000000..43b7027d --- /dev/null +++ b/aai_cli/init/procfile.py @@ -0,0 +1,52 @@ +# aai_cli/init/procfile.py +from __future__ import annotations + +import re +import shlex +from collections.abc import Mapping +from pathlib import Path + +from aai_cli.errors import CLIError + +# Matches ${VAR}, ${VAR:-default}, or $VAR — the shell-style refs that appear in a +# Procfile's web: line (we expand them ourselves rather than invoking a shell). +_VAR = re.compile(r"\$\{(\w+)(?::-([^}]*))?\}|\$(\w+)") + + +def _expand(token: str, env: Mapping[str, str]) -> str: + """Expand $VAR / ${VAR} / ${VAR:-default} in one token against `env`.""" + + def repl(match: re.Match[str]) -> str: + if match.group(1) is not None: # ${VAR} or ${VAR:-default} + name, default = match.group(1), match.group(2) + return env.get(name) or (default if default is not None else "") + return env.get(match.group(3), "") # $VAR + + return _VAR.sub(repl, token) + + +def web_argv(target: Path, *, env: Mapping[str, str]) -> list[str]: + """The template Procfile's `web:` process, as an expanded argv. + + Raises a usage `CLIError` when there's no Procfile or no `web:` line — that's how + `aai dev` detects it isn't sitting inside a scaffolded project. + """ + procfile = target / "Procfile" + if not procfile.exists(): + raise CLIError( + "No Procfile here (expected ./Procfile). cd into a project created by " + "`aai init`, or run `aai init` to scaffold one.", + error_type="usage_error", + exit_code=1, + ) + for line in procfile.read_text().splitlines(): + stripped = line.strip() + if stripped.startswith("web:"): + web = stripped[len("web:") :].strip() + if web: + return [_expand(token, env) for token in shlex.split(web)] + raise CLIError( + "Procfile has no `web:` process to run.", + error_type="usage_error", + exit_code=1, + ) diff --git a/aai_cli/init/runner.py b/aai_cli/init/runner.py index 2ff3e25b..ece4cc8e 100644 --- a/aai_cli/init/runner.py +++ b/aai_cli/init/runner.py @@ -34,19 +34,10 @@ def env_setup_commands(target: Path, *, use_uv: bool) -> list[list[str]]: ] -def serve_command(target: Path, *, port: int, use_uv: bool, reload: bool = False) -> list[str]: - extra = ["--reload"] if reload else [] +def serve_command(target: Path, *, port: int, use_uv: bool) -> list[str]: if use_uv: - return ["uv", "run", "uvicorn", "api.index:app", "--port", str(port), *extra] - return [ - str(venv_python(target)), - "-m", - "uvicorn", - "api.index:app", - "--port", - str(port), - *extra, - ] + return ["uv", "run", "uvicorn", "api.index:app", "--port", str(port)] + return [str(venv_python(target)), "-m", "uvicorn", "api.index:app", "--port", str(port)] def _port_open(port: int) -> bool: @@ -90,16 +81,20 @@ def run_setup(target: Path, *, use_uv: bool) -> subprocess.CompletedProcess[str] return last -def launch_and_open( - target: Path, *, port: int, use_uv: bool, open_browser: bool, reload: bool = False +def run_server( + target: Path, + *, + command: list[str], + port: int, + env: dict[str, str] | None = None, + open_browser: bool, ) -> int: - """Start the dev server, wait for it, open the browser, and block until Ctrl-C. + """Run a prebuilt server command, wait for the port, open the browser, block until Ctrl-C. - Returns the process exit code (0 on a clean Ctrl-C shutdown). + Returns the process exit code (0 on a clean Ctrl-C shutdown). `env=None` inherits + the current environment; pass a full dict (e.g. `{**os.environ, "PORT": ...}`) to override. """ - proc = subprocess.Popen( - serve_command(target, port=port, use_uv=use_uv, reload=reload), cwd=target - ) + proc = subprocess.Popen(command, cwd=target, env=env) try: if wait_for_port(port) and open_browser: webbrowser.open(f"http://localhost:{port}") @@ -109,3 +104,13 @@ def launch_and_open( proc.wait() return 0 return proc.returncode + + +def launch_and_open(target: Path, *, port: int, use_uv: bool, open_browser: bool) -> int: + """Start the (init) dev server and open the browser; block until Ctrl-C.""" + return run_server( + target, + command=serve_command(target, port=port, use_uv=use_uv), + port=port, + open_browser=open_browser, + ) diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index ddf5a4fc..5bb3f941 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -60,29 +60,6 @@ def test_serve_command_uv_and_venv(): ] -def test_serve_command_appends_reload(): - target = Path("/proj") - assert runner.serve_command(target, port=3000, use_uv=True, reload=True) == [ - "uv", - "run", - "uvicorn", - "api.index:app", - "--port", - "3000", - "--reload", - ] - py = str(runner.venv_python(target)) - assert runner.serve_command(target, port=3000, use_uv=False, reload=True) == [ - py, - "-m", - "uvicorn", - "api.index:app", - "--port", - "3000", - "--reload", - ] - - def test_find_free_port_returns_preferred_when_open(): port = runner.find_free_port(0) # 0 -> OS assigns a free port assert isinstance(port, int) and port > 0 @@ -208,29 +185,23 @@ def test_launch_and_open_handles_keyboard_interrupt(monkeypatch): assert proc.terminated is True -def test_launch_and_open_defaults_to_no_reload(monkeypatch): +def test_run_server_passes_command_and_env(monkeypatch): captured = {} proc = _FakeProc(returncode=0) def fake_popen(cmd, **kwargs): captured["cmd"] = cmd + captured["env"] = kwargs.get("env") + captured["cwd"] = kwargs.get("cwd") return proc monkeypatch.setattr(runner.subprocess, "Popen", fake_popen) monkeypatch.setattr(runner, "wait_for_port", lambda port: True) - runner.launch_and_open(Path("/proj"), port=3000, use_uv=True, open_browser=False) - assert "--reload" not in captured["cmd"] - - -def test_launch_and_open_forwards_reload(monkeypatch): - captured = {} - proc = _FakeProc(returncode=0) - - def fake_popen(cmd, **kwargs): - captured["cmd"] = cmd - return proc - - monkeypatch.setattr(runner.subprocess, "Popen", fake_popen) - monkeypatch.setattr(runner, "wait_for_port", lambda port: True) - runner.launch_and_open(Path("/proj"), port=3000, use_uv=True, open_browser=False, reload=True) - assert "--reload" in captured["cmd"] + monkeypatch.setattr(runner.webbrowser, "open", lambda url: None) + rc = runner.run_server( + Path("/proj"), command=["uvicorn", "x"], port=3000, env={"PORT": "3000"}, open_browser=False + ) + assert rc == 0 + assert captured["cmd"] == ["uvicorn", "x"] + assert captured["env"] == {"PORT": "3000"} + assert captured["cwd"] == Path("/proj") diff --git a/tests/test_procfile.py b/tests/test_procfile.py new file mode 100644 index 00000000..e489ab32 --- /dev/null +++ b/tests/test_procfile.py @@ -0,0 +1,53 @@ +from pathlib import Path + +import pytest + +from aai_cli.errors import CLIError +from aai_cli.init import procfile + +ALL_IFACES = ".".join(["0"] * 4) # 0.0.0.0, built to dodge ruff S104 in this test file +WEB = f"web: uvicorn api.index:app --host {ALL_IFACES} --port ${{PORT:-3000}}\n" + + +def _write(tmp_path: Path, text: str) -> Path: + (tmp_path / "Procfile").write_text(text) + return tmp_path + + +def test_web_argv_expands_port_when_set(tmp_path): + argv = procfile.web_argv(_write(tmp_path, WEB), env={"PORT": "8123"}) + assert argv == ["uvicorn", "api.index:app", "--host", ALL_IFACES, "--port", "8123"] + + +def test_web_argv_uses_default_when_port_unset(tmp_path): + argv = procfile.web_argv(_write(tmp_path, WEB), env={}) + assert argv[-2:] == ["--port", "3000"] + + +def test_web_argv_expands_plain_and_braced_vars(tmp_path): + text = "web: run $HOST ${EXTRA}\n" + argv = procfile.web_argv(_write(tmp_path, text), env={"HOST": "h", "EXTRA": "x"}) + assert argv == ["run", "h", "x"] + + +def test_web_argv_missing_var_becomes_empty(tmp_path): + argv = procfile.web_argv(_write(tmp_path, "web: run ${NOPE} $ALSONOPE\n"), env={}) + assert argv == ["run", "", ""] + + +def test_web_argv_raises_without_procfile(tmp_path): + with pytest.raises(CLIError) as exc: + procfile.web_argv(tmp_path, env={}) + assert exc.value.error_type == "usage_error" + assert "aai init" in str(exc.value) + + +def test_web_argv_raises_without_web_line(tmp_path): + with pytest.raises(CLIError) as exc: + procfile.web_argv(_write(tmp_path, "release: echo hi\n"), env={}) + assert exc.value.error_type == "usage_error" + + +def test_web_argv_raises_on_empty_web_command(tmp_path): + with pytest.raises(CLIError): + procfile.web_argv(_write(tmp_path, "web:\n"), env={}) From 78e1b4c9d4ccb88fbac7a5a30e648abc72268057 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:34:56 -0700 Subject: [PATCH 11/40] feat(dev): boot the template Procfile web process with live reload Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/dev.py | 38 ++++++++-------- tests/test_dev.py | 97 ++++++++++++++++++++++++----------------- 2 files changed, 74 insertions(+), 61 deletions(-) diff --git a/aai_cli/commands/dev.py b/aai_cli/commands/dev.py index 7552eb8c..20af43ac 100644 --- a/aai_cli/commands/dev.py +++ b/aai_cli/commands/dev.py @@ -1,6 +1,7 @@ # aai_cli/commands/dev.py from __future__ import annotations +import os from pathlib import Path import typer @@ -8,28 +9,14 @@ from aai_cli import help_panels, output, steps from aai_cli.context import AppState, run_command -from aai_cli.errors import CLIError from aai_cli.help_text import examples_epilog -from aai_cli.init import runner +from aai_cli.init import procfile, runner # Flattened single-command sub-typer (same pattern as `aai init`): one # @app.command() registered via app.add_typer(dev.app) with no name. app = typer.Typer() -def _require_app_dir() -> Path: - """The current directory, if it holds a scaffolded FastAPI app (`api/index.py`).""" - cwd = Path.cwd() - if not (cwd / "api" / "index.py").exists(): - raise CLIError( - "No app found here (expected api/index.py). cd into a project created by " - "`aai init`, or run `aai init` to scaffold one.", - error_type="usage_error", - exit_code=1, - ) - return cwd - - def _install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: """Install deps (unless --no-install) and return the report row.""" if no_install: @@ -44,25 +31,36 @@ def _install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step return {"name": "install", "status": "installed", "detail": "uv" if use_uv else "venv + pip"} +def _dev_command(target: Path, web: list[str], *, use_uv: bool) -> list[str]: + """The Procfile web process, run in the project venv with live reload.""" + prefix = ["uv", "run"] if use_uv else [str(runner.venv_python(target)), "-m"] + return [*prefix, *web, "--reload"] + + def run_dev(*, port: int, no_install: bool, no_open: bool, json_mode: bool) -> None: - """Install deps if needed, then launch the dev server with live reload.""" - target = _require_app_dir() + """Boot the project's Procfile `web:` process locally, with live reload.""" + target = Path.cwd() use_uv = runner.has_uv() + chosen_port = runner.find_free_port(port) + env = {**os.environ, "PORT": str(chosen_port)} + # Resolves the start command AND validates we're inside a scaffolded project. + web = procfile.web_argv(target, env=env) + report: list[steps.Step] = [_install_step(target, no_install=no_install, use_uv=use_uv)] output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode) if any(s["status"] == "failed" for s in report): raise typer.Exit(code=1) - chosen_port = runner.find_free_port(port) + command = _dev_command(target, web, use_uv=use_uv) url = f"http://localhost:{chosen_port}" if not json_mode: output.console.print( f"[aai.heading]Starting[/aai.heading] [aai.url]{escape(url)}[/aai.url]" " [aai.muted](Ctrl-C to stop)[/aai.muted]" ) - code = runner.launch_and_open( - target, port=chosen_port, use_uv=use_uv, open_browser=not no_open, reload=True + code = runner.run_server( + target, command=command, port=chosen_port, env=env, open_browser=not no_open ) if code: raise typer.Exit(code=code) diff --git a/tests/test_dev.py b/tests/test_dev.py index a55284f7..802ef813 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -5,16 +5,15 @@ from aai_cli.main import app runner = CliRunner() +ALL_IFACES = ".".join(["0"] * 4) # 0.0.0.0, built to dodge ruff S104 in this test file +WEB = f"web: uvicorn api.index:app --host {ALL_IFACES} --port ${{PORT:-3000}}\n" -def _make_app(tmp_path): - """Scaffold the minimal marker `aai dev` looks for: api/index.py.""" - (tmp_path / "api").mkdir() - (tmp_path / "api" / "index.py").write_text("app = object()\n") +def _make_project(tmp_path): + (tmp_path / "Procfile").write_text(WEB) def _stub_runner(monkeypatch, *, use_uv=True, setup_rc=0): - """Stub the runner boundary; return a dict capturing launch_and_open kwargs.""" monkeypatch.setattr("aai_cli.init.runner.has_uv", lambda: use_uv) monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda port, **k: port) monkeypatch.setattr( @@ -23,41 +22,76 @@ def _stub_runner(monkeypatch, *, use_uv=True, setup_rc=0): ) captured: dict = {} - def fake_launch(target, *, port, use_uv, open_browser, reload): + def fake_run_server(target, *, command, port, env, open_browser): captured.update( - target=target, port=port, use_uv=use_uv, open_browser=open_browser, reload=reload + target=target, command=command, port=port, env=env, open_browser=open_browser ) return 0 - monkeypatch.setattr("aai_cli.init.runner.launch_and_open", fake_launch) + monkeypatch.setattr("aai_cli.init.runner.run_server", fake_run_server) return captured -def test_dev_launches_with_reload(tmp_path, monkeypatch): +def test_dev_boots_procfile_command_with_reload(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - _make_app(tmp_path) + _make_project(tmp_path) captured = _stub_runner(monkeypatch) result = runner.invoke(app, ["dev", "--no-open"]) assert result.exit_code == 0, result.output - assert captured["reload"] is True + assert captured["command"] == [ + "uv", + "run", + "uvicorn", + "api.index:app", + "--host", + ALL_IFACES, + "--port", + "3000", + "--reload", + ] + assert captured["env"]["PORT"] == "3000" assert captured["open_browser"] is False - assert captured["port"] == 3000 assert "Starting" in result.output assert "localhost:3000" in result.output def test_dev_opens_browser_by_default(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - _make_app(tmp_path) + _make_project(tmp_path) captured = _stub_runner(monkeypatch) result = runner.invoke(app, ["dev"]) assert result.exit_code == 0, result.output assert captured["open_browser"] is True +def test_dev_custom_port_expands_and_flows_through(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--port", "8123", "--no-open"]) + assert result.exit_code == 0, result.output + assert captured["port"] == 8123 + assert captured["env"]["PORT"] == "8123" + assert "8123" in captured["command"] + assert "3000" not in captured["command"] # default was overridden, not used + + +def test_dev_venv_command_when_no_uv(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + captured = _stub_runner(monkeypatch, use_uv=False) + result = runner.invoke(app, ["dev", "--no-open", "--json"]) + assert result.exit_code == 0, result.output + assert captured["command"][1] == "-m" + assert captured["command"][0].endswith("python") or ".venv" in captured["command"][0] + assert captured["command"][2] == "uvicorn" + assert captured["command"][-1] == "--reload" + assert '"detail": "venv + pip"' in result.output + + def test_dev_no_install_skips_setup(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - _make_app(tmp_path) + _make_project(tmp_path) captured = _stub_runner(monkeypatch) called = {"setup": False} monkeypatch.setattr( @@ -69,11 +103,11 @@ def test_dev_no_install_skips_setup(tmp_path, monkeypatch): result = runner.invoke(app, ["dev", "--no-install", "--no-open"]) assert result.exit_code == 0, result.output assert called["setup"] is False - assert captured["reload"] is True + assert captured["command"][-1] == "--reload" -def test_dev_missing_app_errors(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) # no api/index.py here +def test_dev_missing_procfile_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) # no Procfile captured = _stub_runner(monkeypatch) result = runner.invoke(app, ["dev"]) assert result.exit_code == 1 @@ -83,47 +117,28 @@ def test_dev_missing_app_errors(tmp_path, monkeypatch): def test_dev_install_failure_exits(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - _make_app(tmp_path) + _make_project(tmp_path) captured = _stub_runner(monkeypatch, setup_rc=1) result = runner.invoke(app, ["dev"]) assert result.exit_code == 1 - assert captured == {} # install failed -> no launch assert "boom" in result.output + assert captured == {} # install failed -> no launch def test_dev_server_nonzero_exit_propagates(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - _make_app(tmp_path) + _make_project(tmp_path) _stub_runner(monkeypatch) - monkeypatch.setattr("aai_cli.init.runner.launch_and_open", lambda *a, **k: 3) + monkeypatch.setattr("aai_cli.init.runner.run_server", lambda *a, **k: 3) result = runner.invoke(app, ["dev", "--no-open"]) assert result.exit_code == 3 def test_dev_json_emits_install_step(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) - _make_app(tmp_path) + _make_project(tmp_path) _stub_runner(monkeypatch) result = runner.invoke(app, ["dev", "--no-open", "--json"]) assert result.exit_code == 0, result.output assert '"name": "install"' in result.output assert '"detail": "uv"' in result.output - - -def test_dev_venv_path_when_no_uv(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _make_app(tmp_path) - captured = _stub_runner(monkeypatch, use_uv=False) - result = runner.invoke(app, ["dev", "--no-open", "--json"]) - assert result.exit_code == 0, result.output - assert captured["use_uv"] is False - assert '"detail": "venv + pip"' in result.output - - -def test_dev_custom_port_flows_through(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - _make_app(tmp_path) - captured = _stub_runner(monkeypatch) - result = runner.invoke(app, ["dev", "--port", "8123", "--no-open"]) - assert result.exit_code == 0, result.output - assert captured["port"] == 8123 From ba86f861e146a0e97a1fa04dbdfcf348667e6a22 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:37:23 -0700 Subject: [PATCH 12/40] feat(onboard): add --non-interactive flag Run the guided setup without prompts; defaults on when an agent/CI is detected. Promotes output._is_agentic() to public is_agentic() now that it's used cross-module (pyright strict rejects private cross-module use). Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/onboard.py | 17 +++-- aai_cli/output.py | 4 +- .../test_cli_output_snapshots.ambr | 6 +- tests/test_login.py | 2 +- tests/test_onboard_command.py | 62 +++++++++++++++++++ tests/test_output.py | 10 +-- 6 files changed, 87 insertions(+), 14 deletions(-) diff --git a/aai_cli/commands/onboard.py b/aai_cli/commands/onboard.py index fb3db1e5..26c92434 100644 --- a/aai_cli/commands/onboard.py +++ b/aai_cli/commands/onboard.py @@ -4,7 +4,7 @@ import typer -from aai_cli import help_panels +from aai_cli import help_panels, output from aai_cli.context import AppState, resolve_profile, run_command from aai_cli.help_text import examples_epilog from aai_cli.onboard import wizard @@ -14,8 +14,11 @@ app = typer.Typer() -def build_prompter() -> Prompter: - """A real prompter only when both ends are a TTY; otherwise never block.""" +def build_prompter(*, non_interactive: bool = False) -> Prompter: + """A real prompter only when the caller hasn't opted out and both ends are a TTY; + otherwise never block for input.""" + if non_interactive: + return NonInteractivePrompter() if sys.stdin.isatty() and sys.stdout.isatty(): return InteractivePrompter() return NonInteractivePrompter() @@ -32,13 +35,19 @@ def build_prompter() -> Prompter: def onboard( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), + non_interactive: bool = typer.Option( + False, + "--non-interactive", + help="Run without interactive prompts (default when agent detected).", + ), ) -> None: """Guided setup: sign in, run your first transcription, and start building.""" def body(state: AppState, json_mode: bool) -> None: profile = resolve_profile(state) wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode) - code = wizard.run_onboarding(build_prompter(), wiz_ctx) + forced = non_interactive or output.is_agentic() + code = wizard.run_onboarding(build_prompter(non_interactive=forced), wiz_ctx) if code != 0: raise typer.Exit(code=code) diff --git a/aai_cli/output.py b/aai_cli/output.py index a83cf992..03f7f602 100644 --- a/aai_cli/output.py +++ b/aai_cli/output.py @@ -30,7 +30,7 @@ def _stdout_is_tty() -> bool: return sys.stdout.isatty() -def _is_agentic() -> bool: +def is_agentic() -> bool: """True when there's no interactive human at stdout: piped/redirected, or a CI/agent env var is set. Used to suppress *interactivity* (the spinner) — never to change the output *shape*; `resolve_json` keeps text the default regardless (see its docstring). @@ -175,7 +175,7 @@ def status(message: str, *, json_mode: bool) -> Generator[None]: stderr console so even an interactive `aai transcribe x -o text` keeps stdout pristine. """ - if json_mode or _is_agentic(): + if json_mode or is_agentic(): yield return with error_console.status(message): diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index f476079d..13fa1330 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -421,8 +421,10 @@ Guided setup: sign in, run your first transcription, and start building. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ + │ --json Output raw JSON. │ + │ --non-interactive Run without interactive prompts (default when │ + │ agent detected). │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples diff --git a/tests/test_login.py b/tests/test_login.py index 7ab8d21d..a0069b8a 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -196,7 +196,7 @@ def test_unknown_env_exits_2(mocker): # callback can't see a per-command --json, and we never auto-switch to JSON on a # pipe/agent), so it's the "Error:" + "Suggestion:" pair on stderr, not a JSON blob — # regardless of whether stdout is a TTY. - is_agentic = mocker.patch("aai_cli.output._is_agentic", autospec=True) + is_agentic = mocker.patch("aai_cli.output.is_agentic", autospec=True) for agentic in (True, False): is_agentic.return_value = agentic result = runner.invoke(app, ["--env", "bogus", "whoami"]) diff --git a/tests/test_onboard_command.py b/tests/test_onboard_command.py index f8c3a2cc..e0a61b2d 100644 --- a/tests/test_onboard_command.py +++ b/tests/test_onboard_command.py @@ -60,6 +60,68 @@ def test_build_prompter_noninteractive(monkeypatch: pytest.MonkeyPatch) -> None: assert isinstance(onboard_cmd.build_prompter(), NonInteractivePrompter) +def test_build_prompter_forced_noninteractive_on_tty(monkeypatch: pytest.MonkeyPatch) -> None: + # `--non-interactive` wins even with both ends a real TTY: a mutant that ignored + # the flag (or `or`-ed it with the TTY check) would hand back an InteractivePrompter. + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + assert isinstance(onboard_cmd.build_prompter(non_interactive=True), NonInteractivePrompter) + + +def _spy_forced(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: + """Capture the `non_interactive` value the command hands `build_prompter`. + + Spying on the argument (rather than the prompter type) is what pins the + `forced = non_interactive or is_agentic()` expression: under CliRunner stdout is + never a TTY, so the resolved prompter would read NonInteractive either way. + """ + captured: dict[str, object] = {} + + def _fake_build(*, non_interactive: bool) -> NonInteractivePrompter: + captured["forced"] = non_interactive + return NonInteractivePrompter() + + monkeypatch.setattr("aai_cli.commands.onboard.build_prompter", _fake_build) + monkeypatch.setattr("aai_cli.commands.onboard.wizard.run_onboarding", lambda p, c: 0) + return captured + + +def test_onboard_non_interactive_flag_forces_noninteractive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # `--non-interactive` forces non-interactive mode even when no agent is detected. + monkeypatch.setattr("aai_cli.output.is_agentic", lambda: False) + captured = _spy_forced(monkeypatch) + result = CliRunner().invoke(app, ["onboard", "--non-interactive"]) + assert result.exit_code == 0, result.output + assert captured["forced"] is True + + +def test_onboard_defaults_to_noninteractive_when_agent_detected( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # No flag, but an agent is detected: the wizard still defaults to non-interactive. + # A mutant dropping the `is_agentic()` term would leave `forced` False here. + monkeypatch.setattr("aai_cli.output.is_agentic", lambda: True) + captured = _spy_forced(monkeypatch) + result = CliRunner().invoke(app, ["onboard"]) + assert result.exit_code == 0, result.output + assert captured["forced"] is True + + +def test_onboard_stays_interactive_without_flag_or_agent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # No flag, no agent: `forced` is False, so build_prompter is free to drive real + # prompts. An `and` mutant on the `or` would also land here, but the two cases + # above (each True via a different operand) pin the operator. + monkeypatch.setattr("aai_cli.output.is_agentic", lambda: False) + captured = _spy_forced(monkeypatch) + result = CliRunner().invoke(app, ["onboard"]) + assert result.exit_code == 0, result.output + assert captured["forced"] is False + + def test_onboard_sorts_first_in_quick_start() -> None: result = CliRunner().invoke(app, ["--help"]) assert result.output.index("onboard") < result.output.index("init") diff --git a/tests/test_output.py b/tests/test_output.py index 0a633257..de70994d 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -29,12 +29,12 @@ def test_is_agentic_true_for_agent_env_var_even_with_tty(monkeypatch): # when a CI/agent env var is set — independent of resolve_json, which stays text. monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) monkeypatch.setenv("CLAUDECODE", "1") - assert output._is_agentic() is True + assert output.is_agentic() is True def test_is_agentic_false_for_plain_interactive_tty(monkeypatch): monkeypatch.setattr(output, "_stdout_is_tty", lambda: True) - assert output._is_agentic() is False + assert output.is_agentic() is False def test_mask_secret_preserves_only_short_edges(): @@ -224,7 +224,7 @@ def flush(self): def test_status_is_noop_in_json_mode(monkeypatch): # JSON mode must never enter the spinner (it would render to stderr unnecessarily). - monkeypatch.setattr(output, "_is_agentic", lambda: False) + monkeypatch.setattr(output, "is_agentic", lambda: False) entered = {"status": False} monkeypatch.setattr( output.error_console, "status", lambda *a, **k: entered.__setitem__("status", True) @@ -235,7 +235,7 @@ def test_status_is_noop_in_json_mode(monkeypatch): def test_status_is_noop_when_agentic(monkeypatch): - monkeypatch.setattr(output, "_is_agentic", lambda: True) + monkeypatch.setattr(output, "is_agentic", lambda: True) entered = {"status": False} monkeypatch.setattr( output.error_console, "status", lambda *a, **k: entered.__setitem__("status", True) @@ -246,7 +246,7 @@ def test_status_is_noop_when_agentic(monkeypatch): def test_status_shows_spinner_for_interactive_human(monkeypatch): - monkeypatch.setattr(output, "_is_agentic", lambda: False) + monkeypatch.setattr(output, "is_agentic", lambda: False) calls = [] with output.error_console.capture(): with output.status("Transcribing…", json_mode=False): From d047efd11651a504d4d988704fa1dcc2945911ff Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:39:07 -0700 Subject: [PATCH 13/40] docs: design for aai api passthrough command Curl-style authenticated passthrough to the AssemblyAI REST and LLM-gateway APIs, driven by bundled OpenAPI specs. Bundled-only (no live fetch), multi-spec/multi-host resolution via environments.active(), spec-driven auth, keyring-only key, full Vercel-parity surface (passthrough + list + picker + --show-code). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-09-aai-api-command-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-aai-api-command-design.md diff --git a/docs/superpowers/specs/2026-06-09-aai-api-command-design.md b/docs/superpowers/specs/2026-06-09-aai-api-command-design.md new file mode 100644 index 00000000..5114b486 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-aai-api-command-design.md @@ -0,0 +1,174 @@ +# `aai api` — raw AssemblyAI API passthrough command + +**Date:** 2026-06-09 +**Status:** Design approved, ready for implementation plan + +## Summary + +Add `aai api`, a curl-style authenticated passthrough to the AssemblyAI REST and +LLM-gateway APIs, driven by bundled OpenAPI specs. Modeled on Vercel CLI's `vercel +api`, adapted to this CLI's conventions: bundled (not live-fetched) specs, +multi-spec/multi-host resolution through `environments.active()`, keyring-only auth, +and the errors→stderr / data→stdout split. + +This lets users hit any documented endpoint — including ones with no dedicated +sub-command — without leaving the CLI's auth, environment, and output machinery. + +## Motivation + +The CLI wraps the `assemblyai` SDK for the common flows (`transcribe`, `stream`, +`agent`, `llm`, `transcripts`, …). Anything outside those — a new endpoint, a query +parameter we don't expose, ad-hoc scripting against `/v2/transcript` — currently +means dropping to raw `curl` and hand-managing the API key, base URL, and auth +header. `aai api` closes that gap while preserving the CLI's guarantees: + +- key resolved from env→keyring (never on the command line, never in `ps`), +- base host follows `--env`/`--sandbox` automatically, +- clean stderr errors + machine-readable `--json`, +- a keyless `--show-code` escape hatch consistent with `transcribe`/`stream`/`agent`. + +## Decisions (from brainstorming) + +| Question | Decision | +|---|---| +| Spec source | **Bundled only** — vendored JSON regenerated at release time; no runtime fetch. | +| Spec scope | **REST + LLM-gateway** (request/response APIs). Websocket streaming excluded — not a passthrough fit. | +| Command surface | **Full parity** — passthrough + `list` + interactive picker + `--show-code`. | +| Codegen verb | `--show-code` → curl (this CLI's existing vocabulary), not Vercel's `--generate=FORMAT`. | +| `--api-key` flag | **Omitted** — run-style command; key resolves from env→keyring only. | + +## Command surface + +``` +aai api [flags] # authenticated passthrough +aai api list [--api rest|llm-gateway] # enumerate endpoints from the bundled spec +aai api # interactive picker (TTY only) +``` + +### Flags + +| Flag | Purpose | +|---|---| +| `-X/--method METHOD` | HTTP method; defaults `GET`, or `POST` when a body is present. | +| `-F/--field KEY=VALUE` | Typed field (numbers/bools/JSON parsed; `@file` reads file contents). | +| `-f/--raw-field KEY=VALUE` | String field, no type parsing. | +| `-H/--header KEY:VALUE` | Extra HTTP header. | +| `--input FILE` | Request body from file (`-` = stdin). | +| `--api {rest,llm-gateway}` | Which bundled spec/host (default `rest`; auto-inferred in `list`/picker). | +| `-i/--include` | Include response headers in output. | +| `--paginate` | Follow `page_details.next_url` (AssemblyAI's cursor shape) until exhausted. | +| `--show-code` | Print a runnable `curl` and exit — no key needed, reads `$ASSEMBLYAI_API_KEY`. | +| `--raw` | Output raw/compact JSON (no pretty-printing). | +| `--silent` | Suppress response body. | +| `--verbose` | Debug: full request + response. | + +`--json` is inherited from the global behavior (auto-enabled when piped/agent-run). + +There is **no `--api-key` flag** by design — consistent with `transcribe`/`stream`/`agent`. + +### Behavior + +- Endpoint must start with `/` (a Vercel-style guard); otherwise a `UsageError` + pointing at interactive mode. +- The resolved URL must stay on the active environment's host (no external URLs). +- Interactive picker only runs on a TTY; non-TTY with no endpoint is a `UsageError`. + +## Multi-spec & multi-host resolution (the crux) + +Two bundled specs, each owning its paths and mapped to a host from the active +environment. **The spec's own `servers[].url` is ignored** — the host is derived +from `environments.active()` so `--sandbox`/`--env` work without per-endpoint +configuration: + +| `--api` | Host source | +|---|---| +| `rest` | `environments.active().api_base` | +| `llm-gateway` | `environments.active().llm_gateway_base` | + +### Spec-driven auth + +The loader reads each spec's `components.securitySchemes`, and the command applies +the scheme rather than hardcoding per-API branches: + +- REST (apiKey scheme) → `Authorization: ` +- LLM-gateway (http/bearer scheme) → `Authorization: Bearer ` + +If a future spec changes its scheme, the header follows the spec, not a code edit. + +## Module layout (fits the import-linter contracts) + +### `aai_cli/openapi/` — new library layer + +A core/library package: **never imports `aai_cli.commands`**, **never imports Rich** +(added to `.importlinter` contract 1 and contract 3). + +- `loader.py` — `load_spec(api)` reads the vendored JSON, parses to typed + `Endpoint` / `BodyField` dataclasses, resolves `$ref` and merges `allOf`. + Mirrors Vercel's `OpenApiCache` minus all fetch/cache machinery (bundled-only). + Exposes the security scheme per spec. +- `request.py` — pure request assembly: `-F` typed-field parsing, `@file` + expansion, `--input`/stdin body, method defaulting. Independently testable, no I/O + beyond reading the referenced files. +- `specs/rest.json`, `specs/llm-gateway.json` — vendored spec snapshots, + force-included in the wheel via the existing + `[tool.hatch.build.targets.wheel] artifacts` list. + +### `aai_cli/commands/api.py` — Typer sub-app + +Added to `.importlinter` contract 2 (command independence) and `_COMMAND_ORDER`. + +- Builds the request via `openapi.request`, resolves host × env, applies + spec-derived auth, executes over **`httpx2`** (already a first-class dependency — + no new dep, deptry-clean). +- Renders Rich tables for `list` and the interactive picker. +- Runs through `context.run_command(ctx, fn, json=...)` like every other command. + +### Reuse + +`config.resolve_api_key`, `environments.active()`, `errors` +(`auth_failure`/`APIError`/`UsageError`), `output` (stderr/stdout + auto-`--json`), +`context.run_command`. + +## Error handling + +Matches `client.py`'s normalization shape: + +- `401` / `403` → single clean `auth_failure()` `CLIError`. +- Other non-2xx → `APIError` carrying the response body for context. +- Network/timeout failures → `APIError`. +- No tracebacks for expected failures; errors go to stderr, data to stdout. + +## Bundled-spec freshness + +- **`scripts/update-openapi-specs.py`** — downloads `openapi.json` and + `llm-gateway.yml` from the `AssemblyAI/assemblyai-api-spec` GitHub repo, + normalizes both to JSON, writes them into `aai_cli/openapi/specs/`. Run at release + time; documented in CLAUDE.md / AGENTS.md. +- **Parametrized contract test** (modeled on the `init` template contract tests): + for every bundled spec — it parses, expected endpoints exist (e.g. + `POST /v2/transcript`), a security scheme is present, and the spec maps to a known + environment host. Catches a stale or malformed vendored spec under the gate. + +## Placement & help + +- New help panel **"API"** (or folded under "Setup & Tools"), slotted into + `_COMMAND_ORDER` after `llm`. +- `--show-code` output and `list` rendering pinned by syrupy snapshots, like the + other generators and tables. + +## Testing + +- Unit: `openapi.request` field parsing (typed `-F`, `@file`, stdin), `openapi.loader` + parsing/`$ref`/`allOf`/security-scheme extraction. +- Command: host×env resolution per `--api`, auth header per scheme, method + defaulting, error mapping (401/403 vs other), `--paginate` cursor following, + `--show-code` curl output (snapshot), `list` table (snapshot), picker on a faked + TTY. +- Contract: parametrized bundled-spec validation (above). +- Coverage must clear the 90% branch / 100% patch gates; no new escape hatches. + +## Out of scope (YAGNI) + +- Live spec fetching / caching (explicitly rejected in favor of bundled). +- Websocket streaming and voice-agent realtime endpoints. +- A generic `--generate=FORMAT` matrix beyond `--show-code` curl. From bc859dfa5fbfa9ee9d16d865dc19a1c45cbfc754 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:41:01 -0700 Subject: [PATCH 14/40] docs: correct aai api auth scheme (both specs use raw apiKey header) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-06-09-aai-api-command-design.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-06-09-aai-api-command-design.md b/docs/superpowers/specs/2026-06-09-aai-api-command-design.md index 5114b486..281c7f88 100644 --- a/docs/superpowers/specs/2026-06-09-aai-api-command-design.md +++ b/docs/superpowers/specs/2026-06-09-aai-api-command-design.md @@ -88,12 +88,17 @@ configuration: ### Spec-driven auth The loader reads each spec's `components.securitySchemes`, and the command applies -the scheme rather than hardcoding per-API branches: +the scheme rather than hardcoding per-API branches. Verified against the live specs, +**both** REST and LLM-gateway declare the identical scheme: -- REST (apiKey scheme) → `Authorization: ` -- LLM-gateway (http/bearer scheme) → `Authorization: Bearer ` +```jsonc +"ApiKey": { "type": "apiKey", "in": "header", "name": "Authorization" } +``` -If a future spec changes its scheme, the header follows the spec, not a code edit. +So both send a raw `Authorization: ` header (no `Bearer` prefix) — the apiKey +header name and value come straight from the spec. Reading the scheme rather than +hardcoding means that if a future spec adds a `Bearer` http scheme or renames the +header, the request follows the spec without a code edit. ## Module layout (fits the import-linter contracts) From 919a52cdc85d5bcb2dfc4a0746963c66ab738463 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:44:02 -0700 Subject: [PATCH 15/40] docs: implementation plan for aai api command Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-09-aai-api-command.md | 1255 +++++++++++++++++ 1 file changed, 1255 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-aai-api-command.md diff --git a/docs/superpowers/plans/2026-06-09-aai-api-command.md b/docs/superpowers/plans/2026-06-09-aai-api-command.md new file mode 100644 index 00000000..52f5e440 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-aai-api-command.md @@ -0,0 +1,1255 @@ +# `aai api` 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 `aai api`, a curl-style authenticated passthrough to the AssemblyAI REST and LLM-gateway APIs, driven by bundled OpenAPI specs, with `list` and an interactive picker. + +**Architecture:** A new core library package `aai_cli/openapi/` parses two vendored OpenAPI specs (no live fetch) into typed `Endpoint`/`BodyField` objects and assembles requests; a new `aai_cli/commands/api.py` Typer sub-app resolves the host from `environments.active()` per `--api`, applies spec-derived auth, executes over `httpx2`, and renders results through the existing `output` helpers. A release-time script regenerates the vendored specs; a parametrized contract test keeps them honest. + +**Tech Stack:** Python 3.12+, Typer, `httpx2` (existing dep), Rich (command layer only), pytest + pytest-mock + syrupy, uv. + +--- + +## File structure + +| File | Responsibility | +|---|---| +| `aai_cli/openapi/__init__.py` | Package marker; re-export public API (`load_spec`, `Endpoint`, `BodyField`, `ApiName`). | +| `aai_cli/openapi/loader.py` | Read vendored JSON, parse to `Endpoint`/`BodyField`, resolve `$ref`/`allOf`, expose security scheme + endpoints. No Rich, no commands. | +| `aai_cli/openapi/request.py` | Pure request assembly: `-F` typed-field parsing, `@file`, `--input`/stdin body, method defaulting, header parsing. No network. | +| `aai_cli/openapi/specs/rest.json` | Vendored REST spec snapshot (force-included in wheel). | +| `aai_cli/openapi/specs/llm-gateway.json` | Vendored LLM-gateway spec snapshot (force-included in wheel). | +| `aai_cli/commands/api.py` | Typer sub-app: passthrough, `list`, picker, host×env resolution, auth, execution, rendering, error mapping, `--show-code`. | +| `scripts/update-openapi-specs.py` | Release-time regeneration of vendored specs from the GitHub repo. | +| `tests/test_openapi_request.py` | Unit tests for `request.py`. | +| `tests/test_openapi_loader.py` | Unit tests for `loader.py`. | +| `tests/test_api_command.py` | Command tests (CliRunner): resolution, auth, errors, paginate, list, picker. | +| `tests/test_openapi_specs_contract.py` | Parametrized contract tests over the vendored specs. | +| `aai_cli/main.py` | Register sub-app + `_COMMAND_ORDER` entry (modify). | +| `aai_cli/help_panels.py` | Add `API` panel constant (modify). | +| `.importlinter` | Add `aai_cli.openapi` to contracts 1 & 3, `aai_cli.commands.api` to contract 2 (modify). | +| `pyproject.toml` | Add `aai_cli/openapi/specs/**` to wheel artifacts (modify). | +| `AGENTS.md` | Document the command + spec-regeneration script (modify). | + +--- + +## Task 1: Vendor the OpenAPI specs + wheel packaging + +**Files:** +- Create: `aai_cli/openapi/specs/rest.json` +- Create: `aai_cli/openapi/specs/llm-gateway.json` +- Create: `scripts/update-openapi-specs.py` +- Modify: `pyproject.toml` (wheel `artifacts` list) + +- [ ] **Step 1: Write the spec-regeneration script** + +Create `scripts/update-openapi-specs.py`: + +```python +#!/usr/bin/env python3 +"""Regenerate the vendored OpenAPI specs from the AssemblyAI spec repo. + +Run at release time. Downloads the REST and LLM-gateway specs, normalizes both +to JSON, and writes them into aai_cli/openapi/specs/. Run via: + + uv run python scripts/update-openapi-specs.py +""" + +from __future__ import annotations + +import json +import sys +import urllib.request +from pathlib import Path + +import yaml # provided by the dev environment (pyyaml) + +RAW = "https://raw.githubusercontent.com/AssemblyAI/assemblyai-api-spec/main" +SOURCES = { + "rest": f"{RAW}/openapi.json", + "llm-gateway": f"{RAW}/llm-gateway.yml", +} +DEST = Path(__file__).resolve().parent.parent / "aai_cli" / "openapi" / "specs" + + +def _fetch(url: str) -> dict[str, object]: + with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 (trusted host) + raw = resp.read().decode("utf-8") + return yaml.safe_load(raw) # yaml.safe_load also parses JSON + + +def main() -> int: + DEST.mkdir(parents=True, exist_ok=True) + for name, url in SOURCES.items(): + spec = _fetch(url) + out = DEST / f"{name}.json" + out.write_text(json.dumps(spec, indent=2, sort_keys=True) + "\n") + print(f"wrote {out.relative_to(DEST.parent.parent.parent)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Generate the vendored specs** + +Run: `uv run python scripts/update-openapi-specs.py` +Expected output: +``` +wrote aai_cli/openapi/specs/rest.json +wrote aai_cli/openapi/specs/llm-gateway.json +``` + +- [ ] **Step 3: Verify the generated files parse and have expected shape** + +Run: +```bash +uv run python -c " +import json, pathlib +for n in ('rest','llm-gateway'): + d=json.loads(pathlib.Path(f'aai_cli/openapi/specs/{n}.json').read_text()) + print(n, d['openapi'], len(d['paths']), list(d['components']['securitySchemes'])) +" +``` +Expected: `rest 3.1.0 8 ['ApiKey']` and `llm-gateway 3.1.0 2 ['ApiKey']` + +- [ ] **Step 4: Force-include specs in the wheel** + +In `pyproject.toml`, modify the `[tool.hatch.build.targets.wheel] artifacts` list to add the specs glob: + +```toml +artifacts = [ + "aai_cli/init/templates/**", + "aai_cli/skills/**", + "aai_cli/streaming/macos_system_audio.swift", + "aai_cli/openapi/specs/**", +] +``` + +- [ ] **Step 5: Verify the wheel includes the specs** + +Run: `uv build 2>/dev/null && uv run python -c "import zipfile,glob; w=sorted(glob.glob('dist/*.whl'))[-1]; names=zipfile.ZipFile(w).namelist(); print([n for n in names if 'openapi/specs' in n])"` +Expected: a list containing `aai_cli/openapi/specs/rest.json` and `aai_cli/openapi/specs/llm-gateway.json` + +- [ ] **Step 6: Commit** + +```bash +git add scripts/update-openapi-specs.py aai_cli/openapi/specs/ pyproject.toml +git commit -m "feat(api): vendor REST + LLM-gateway OpenAPI specs and packaging" +``` + +--- + +## Task 2: Spec loader — `aai_cli/openapi/loader.py` + +**Files:** +- Create: `aai_cli/openapi/__init__.py` +- Create: `aai_cli/openapi/loader.py` +- Test: `tests/test_openapi_loader.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_openapi_loader.py`: + +```python +from __future__ import annotations + +import pytest + +from aai_cli.openapi import loader + + +def test_load_rest_lists_transcript_endpoints(): + spec = loader.load_spec("rest") + paths = {(e.method, e.path) for e in spec.endpoints} + assert ("POST", "/v2/transcript") in paths + assert ("GET", "/v2/transcript/{transcript_id}") in paths + + +def test_rest_security_scheme_is_raw_apikey_header(): + spec = loader.load_spec("rest") + assert spec.auth_header_name == "Authorization" + assert spec.auth_bearer is False + + +def test_gateway_lists_chat_completions(): + spec = loader.load_spec("llm-gateway") + paths = {(e.method, e.path) for e in spec.endpoints} + assert ("POST", "/chat/completions") in paths + + +def test_post_transcript_body_fields_resolved(): + spec = loader.load_spec("rest") + ep = next(e for e in spec.endpoints if e.method == "POST" and e.path == "/v2/transcript") + names = {f.name for f in ep.body_fields} + assert "audio_url" in names # required string field on the transcript request + + +def test_unknown_api_raises(): + with pytest.raises(ValueError): + loader.load_spec("nope") # type: ignore[arg-type] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_openapi_loader.py -q` +Expected: FAIL with `ModuleNotFoundError: No module named 'aai_cli.openapi'` + +- [ ] **Step 3: Create the package marker** + +Create `aai_cli/openapi/__init__.py`: + +```python +from __future__ import annotations + +from aai_cli.openapi.loader import ApiName, BodyField, Endpoint, LoadedSpec, load_spec + +__all__ = ["ApiName", "BodyField", "Endpoint", "LoadedSpec", "load_spec"] +``` + +- [ ] **Step 4: Implement the loader** + +Create `aai_cli/openapi/loader.py`: + +```python +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from importlib.resources import files +from typing import Any, Literal + +ApiName = Literal["rest", "llm-gateway"] +_APIS: tuple[ApiName, ...] = ("rest", "llm-gateway") +_METHODS = ("get", "post", "put", "patch", "delete") + + +@dataclass(frozen=True) +class BodyField: + """One property of a JSON request body, flattened from the schema.""" + + name: str + required: bool + type: str | None = None + description: str | None = None + + +@dataclass(frozen=True) +class Endpoint: + path: str + method: str # upper-case + summary: str = "" + body_fields: list[BodyField] = field(default_factory=list) + + +@dataclass(frozen=True) +class LoadedSpec: + api: ApiName + endpoints: list[Endpoint] + auth_header_name: str + auth_bearer: bool + + +def load_spec(api: ApiName) -> LoadedSpec: + """Parse the vendored spec for `api` into endpoints + auth metadata.""" + if api not in _APIS: + raise ValueError(f"Unknown API spec: {api!r}. Choose from {_APIS}.") + raw = files("aai_cli.openapi.specs").joinpath(f"{api}.json").read_text("utf-8") + spec: dict[str, Any] = json.loads(raw) + header_name, bearer = _auth(spec) + return LoadedSpec( + api=api, + endpoints=_endpoints(spec), + auth_header_name=header_name, + auth_bearer=bearer, + ) + + +def _auth(spec: dict[str, Any]) -> tuple[str, bool]: + """Read the single security scheme. AssemblyAI uses an apiKey header.""" + schemes = spec.get("components", {}).get("securitySchemes", {}) + for scheme in schemes.values(): + if scheme.get("type") == "apiKey" and scheme.get("in") == "header": + return scheme.get("name", "Authorization"), False + if scheme.get("type") == "http" and scheme.get("scheme") == "bearer": + return "Authorization", True + return "Authorization", False + + +def _endpoints(spec: dict[str, Any]) -> list[Endpoint]: + out: list[Endpoint] = [] + for path, item in spec.get("paths", {}).items(): + for method in _METHODS: + operation = item.get(method) + if operation is None: + continue + out.append( + Endpoint( + path=path, + method=method.upper(), + summary=operation.get("summary", "") or item.get("summary", ""), + body_fields=_body_fields(spec, operation), + ) + ) + out.sort(key=lambda e: (e.path, e.method)) + return out + + +def _body_fields(spec: dict[str, Any], operation: dict[str, Any]) -> list[BodyField]: + content = operation.get("requestBody", {}).get("content", {}) + schema = content.get("application/json", {}).get("schema") + resolved = _resolve(spec, schema) + props = resolved.get("properties", {}) if resolved else {} + required = set(resolved.get("required", []) if resolved else []) + fields = [ + BodyField( + name=name, + required=name in required, + type=_resolve(spec, prop).get("type") if _resolve(spec, prop) else None, + description=(_resolve(spec, prop) or {}).get("description"), + ) + for name, prop in props.items() + ] + fields.sort(key=lambda f: (not f.required, f.name)) + return fields + + +def _resolve(spec: dict[str, Any], schema: dict[str, Any] | None) -> dict[str, Any] | None: + """Resolve a $ref and merge allOf into a single object schema.""" + if not schema: + return None + if "$ref" in schema: + ref = schema["$ref"] + if not ref.startswith("#/components/schemas/"): + return None + name = ref.rsplit("/", 1)[-1] + target = spec.get("components", {}).get("schemas", {}).get(name) + return _resolve(spec, target) + if "allOf" in schema: + merged: dict[str, Any] = {"type": "object", "properties": {}, "required": []} + for sub in schema["allOf"]: + part = _resolve(spec, sub) or {} + merged["properties"].update(part.get("properties", {})) + merged["required"].extend(part.get("required", [])) + return merged + return schema +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `uv run pytest tests/test_openapi_loader.py -q` +Expected: PASS (5 passed) + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/openapi/__init__.py aai_cli/openapi/loader.py tests/test_openapi_loader.py +git commit -m "feat(api): OpenAPI spec loader" +``` + +--- + +## Task 3: Request assembly — `aai_cli/openapi/request.py` + +**Files:** +- Create: `aai_cli/openapi/request.py` +- Test: `tests/test_openapi_request.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_openapi_request.py`: + +```python +from __future__ import annotations + +import pytest + +from aai_cli.errors import UsageError +from aai_cli.openapi import request + + +def test_typed_field_parses_numbers_bools_json(): + body = request.build_body(fields=["n=42", "ok=true", "tags=[1,2]"], raw_fields=[], input_path=None) + assert body == {"n": 42, "ok": True, "tags": [1, 2]} + + +def test_raw_field_stays_string(): + body = request.build_body(fields=[], raw_fields=["id=00123"], input_path=None) + assert body == {"id": "00123"} + + +def test_field_from_file(tmp_path): + p = tmp_path / "v.txt" + p.write_text("hello") + body = request.build_body(fields=[f"note=@{p}"], raw_fields=[], input_path=None) + assert body == {"note": "hello"} + + +def test_input_file_is_whole_body(tmp_path): + p = tmp_path / "b.json" + p.write_text('{"audio_url": "u"}') + body = request.build_body(fields=[], raw_fields=[], input_path=str(p)) + assert body == {"audio_url": "u"} + + +def test_malformed_field_raises_usage_error(): + with pytest.raises(UsageError): + request.build_body(fields=["noequals"], raw_fields=[], input_path=None) + + +def test_parse_headers(): + assert request.parse_headers(["X-A: 1", "X-B:2"]) == {"X-A": "1", "X-B": "2"} + + +def test_default_method_get_without_body(): + assert request.default_method(method=None, has_body=False) == "GET" + + +def test_default_method_post_with_body(): + assert request.default_method(method=None, has_body=True) == "POST" + + +def test_explicit_method_wins(): + assert request.default_method(method="delete", has_body=False) == "DELETE" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_openapi_request.py -q` +Expected: FAIL with `ModuleNotFoundError` / `AttributeError` on `request`. + +- [ ] **Step 3: Implement request assembly** + +Create `aai_cli/openapi/request.py`: + +```python +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +from aai_cli.errors import UsageError + + +def build_body( + *, fields: list[str], raw_fields: list[str], input_path: str | None +) -> dict[str, Any] | None: + """Assemble a JSON request body from --input, -F, and -f. + + --input (whole-body file or stdin) is mutually exclusive with field flags. + """ + if input_path is not None: + if fields or raw_fields: + raise UsageError("Use --input or -F/-f fields, not both.") + return _load_input(input_path) + if not fields and not raw_fields: + return None + body: dict[str, Any] = {} + for item in raw_fields: + key, value = _split(item) + body[key] = value + for item in fields: + key, value = _split(item) + body[key] = _typed(value) + return body + + +def _split(item: str) -> tuple[str, str]: + if "=" not in item: + raise UsageError(f"Invalid field {item!r}; expected KEY=VALUE.") + key, value = item.split("=", 1) + if not key: + raise UsageError(f"Invalid field {item!r}; missing key.") + return key, value + + +def _typed(value: str) -> Any: + if value.startswith("@"): + return Path(value[1:]).read_text("utf-8") + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _load_input(input_path: str) -> dict[str, Any]: + text = sys.stdin.read() if input_path == "-" else Path(input_path).read_text("utf-8") + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise UsageError(f"--input is not valid JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise UsageError("--input must be a JSON object.") + return parsed + + +def parse_headers(headers: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for item in headers: + if ":" not in item: + raise UsageError(f"Invalid header {item!r}; expected KEY:VALUE.") + key, value = item.split(":", 1) + out[key.strip()] = value.strip() + return out + + +def default_method(*, method: str | None, has_body: bool) -> str: + if method is not None: + return method.upper() + return "POST" if has_body else "GET" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_openapi_request.py -q` +Expected: PASS (9 passed) + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/openapi/request.py tests/test_openapi_request.py +git commit -m "feat(api): request body/header/method assembly" +``` + +--- + +## Task 4: Import-linter contracts + help panel + +**Files:** +- Modify: `.importlinter` +- Modify: `aai_cli/help_panels.py` + +- [ ] **Step 1: Add the openapi library modules to contract 1** + +In `.importlinter`, under `[importlinter:contract:1]` `source_modules`, add two lines (keeping alphabetical-ish order, after `aai_cli.microphone`): + +``` + aai_cli.openapi.loader + aai_cli.openapi.request +``` + +- [ ] **Step 2: Add openapi modules to contract 3 (no Rich)** + +In `.importlinter`, under `[importlinter:contract:3]` `source_modules`, add: + +``` + aai_cli.openapi.loader + aai_cli.openapi.request +``` + +- [ ] **Step 3: Add the command to contract 2 (independence)** + +In `.importlinter`, under `[importlinter:contract:2]` `modules`, add: + +``` + aai_cli.commands.api +``` + +- [ ] **Step 4: Add the API help panel constant** + +In `aai_cli/help_panels.py`, add after the `SETUP` line: + +```python +API = "API" # raw API passthrough: api +``` + +- [ ] **Step 5: Verify contracts still pass (openapi modules exist, command not yet)** + +Run: `uv run lint-imports` +Expected: PASS. (The `aai_cli.commands.api` line references a module created in Task 5; if `lint-imports` errors that the module is missing, defer Step 3 to the start of Task 5. Note this in the commit if so.) + +- [ ] **Step 6: Commit** + +```bash +git add .importlinter aai_cli/help_panels.py +git commit -m "chore(api): import-linter contracts and API help panel" +``` + +--- + +## Task 5: The `aai api` command — passthrough + execution + +**Files:** +- Create: `aai_cli/commands/api.py` +- Test: `tests/test_api_command.py` + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_api_command.py`: + +```python +from __future__ import annotations + +import json + +import httpx2 +from typer.testing import CliRunner + +from aai_cli import config +from aai_cli.main import app + +runner = CliRunner() + + +class _FakeResponse: + def __init__(self, status_code: int, payload: object, headers: dict[str, str] | None = None): + self.status_code = status_code + self._payload = payload + self.headers = headers or {"content-type": "application/json"} + self.text = json.dumps(payload) + + def json(self) -> object: + return self._payload + + +def _capture(mocker, response: _FakeResponse) -> dict[str, object]: + seen: dict[str, object] = {} + + def fake_request(method, url, *, headers=None, json=None, **_kw): + seen["method"] = method + seen["url"] = url + seen["headers"] = headers + seen["json"] = json + return response + + mocker.patch.object(httpx2, "request", side_effect=fake_request) + return seen + + +def test_get_sends_auth_header_to_active_host(mocker): + config.set_api_key("default", "sk_live") + seen = _capture(mocker, _FakeResponse(200, {"id": "t_1"})) + result = runner.invoke(app, ["api", "/v2/transcript/t_1"]) + assert result.exit_code == 0 + assert seen["method"] == "GET" + assert seen["url"] == "https://api.assemblyai.com/v2/transcript/t_1" + assert seen["headers"]["Authorization"] == "sk_live" + assert '"id": "t_1"' in result.output + + +def test_field_triggers_post(mocker): + config.set_api_key("default", "sk_live") + seen = _capture(mocker, _FakeResponse(200, {"ok": True})) + result = runner.invoke(app, ["api", "/v2/transcript", "-F", "audio_url=https://x/a.mp3"]) + assert result.exit_code == 0 + assert seen["method"] == "POST" + assert seen["json"] == {"audio_url": "https://x/a.mp3"} + + +def test_sandbox_changes_host(mocker): + config.set_api_key("default", "sk_live") + seen = _capture(mocker, _FakeResponse(200, {})) + result = runner.invoke(app, ["--sandbox", "api", "/v2/transcript/t_1"]) + assert result.exit_code == 0 + assert str(seen["url"]).startswith("https://api.sandbox000.assemblyai-labs.com") + + +def test_llm_gateway_api_uses_gateway_host(mocker): + config.set_api_key("default", "sk_live") + seen = _capture(mocker, _FakeResponse(200, {})) + result = runner.invoke(app, ["api", "/chat/completions", "--api", "llm-gateway", "-F", "model=m"]) + assert result.exit_code == 0 + assert str(seen["url"]).startswith("https://llm-gateway.assemblyai.com/v1/chat/completions") + + +def test_auth_error_maps_to_clean_exit_4(mocker): + config.set_api_key("default", "sk_live") + _capture(mocker, _FakeResponse(401, {"error": "bad key"})) + result = runner.invoke(app, ["api", "/v2/transcript/t_1"]) + assert result.exit_code == 4 + assert "Traceback" not in result.output + + +def test_non_2xx_maps_to_api_error_exit_1(mocker): + config.set_api_key("default", "sk_live") + _capture(mocker, _FakeResponse(404, {"error": "not found"})) + result = runner.invoke(app, ["api", "/v2/transcript/missing"]) + assert result.exit_code == 1 + assert "not found" in result.output + + +def test_endpoint_must_start_with_slash(): + config.set_api_key("default", "sk_live") + result = runner.invoke(app, ["api", "v2/transcript"]) + assert result.exit_code == 2 # UsageError + + +def test_show_code_emits_curl_without_key(mocker): + # No key set; --show-code must not require auth. + spy = mocker.patch.object(httpx2, "request") + result = runner.invoke(app, ["api", "/v2/transcript/t_1", "--show-code"]) + assert result.exit_code == 0 + assert "curl" in result.output + assert "ASSEMBLYAI_API_KEY" in result.output + spy.assert_not_called() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_api_command.py -q` +Expected: FAIL (no `api` command registered). + +- [ ] **Step 3: Implement the command body** + +Create `aai_cli/commands/api.py`: + +```python +from __future__ import annotations + +import json as jsonlib +from typing import Any + +import httpx2 +import typer + +from aai_cli import choices, config, environments, output +from aai_cli.context import AppState, run_command +from aai_cli.errors import APIError, UsageError, auth_failure +from aai_cli.help_text import examples_epilog +from aai_cli.openapi import loader, request + +app = typer.Typer(help="Make authenticated requests to the AssemblyAI API.") + + +def _base_url(api: loader.ApiName) -> str: + env = environments.active() + return env.api_base if api == "rest" else env.llm_gateway_base + + +def _curl_snippet(method: str, url: str, header_name: str, body: dict[str, Any] | None) -> str: + lines = [f"curl -X {method} '{url}' \\", f" -H '{header_name}: '\"$ASSEMBLYAI_API_KEY\""] + if body is not None: + lines[-1] += " \\" + lines.append(" -H 'Content-Type: application/json' \\") + lines.append(f" -d '{jsonlib.dumps(body)}'") + return "\n".join(lines) + + +@app.command( + epilog=examples_epilog( + [ + ("Get a transcript", "aai api /v2/transcript/5551234-abcd"), + ("Create a transcript", "aai api /v2/transcript -F audio_url=https://x/a.mp3"), + ("Delete a transcript", "aai api /v2/transcript/5551234-abcd -X DELETE"), + ("Show the equivalent curl", "aai api /v2/transcript/5551234-abcd --show-code"), + ] + ) +) +def api( + ctx: typer.Context, + endpoint: str = typer.Argument(..., help="API path starting with / (e.g. /v2/transcript)."), + method: str | None = typer.Option(None, "-X", "--method", help="HTTP method."), + fields: list[str] = typer.Option([], "-F", "--field", help="Typed field KEY=VALUE."), + raw_fields: list[str] = typer.Option([], "-f", "--raw-field", help="String field KEY=VALUE."), + headers: list[str] = typer.Option([], "-H", "--header", help="Extra header KEY:VALUE."), + input_path: str | None = typer.Option(None, "--input", help="Body from file (- for stdin)."), + api_name: choices.ApiSpec = typer.Option( + choices.ApiSpec.rest, "--api", help="Which API spec/host." + ), + include: bool = typer.Option(False, "-i", "--include", help="Include response headers."), + show_code: bool = typer.Option( + False, "--show-code", help="Print an equivalent curl command and exit." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Make an authenticated request to an AssemblyAI API endpoint.""" + if not endpoint.startswith("/"): + raise UsageError("Endpoint must start with '/'.", suggestion="e.g. aai api /v2/transcript") + + api_key_name: loader.ApiName = api_name.value + body = request.build_body(fields=fields, raw_fields=raw_fields, input_path=input_path) + http_method = request.default_method(method=method, has_body=body is not None) + spec = loader.load_spec(api_key_name) + url = _base_url(api_key_name).rstrip("/") + endpoint + + if show_code: + output.print_code( + _curl_snippet(http_method, url, spec.auth_header_name, body), language="bash" + ) + return + + def run(state: AppState, json_mode: bool) -> None: + key = config.resolve_api_key(profile=state.profile) + request_headers = request.parse_headers(headers) + request_headers[spec.auth_header_name] = f"Bearer {key}" if spec.auth_bearer else key + try: + response = httpx2.request( + http_method, url, headers=request_headers, json=body, timeout=60.0 + ) + except httpx2.HTTPError as exc: + raise APIError(f"Request failed: {exc}") from exc + _emit(response, include=include, json_mode=json_mode) + + run_command(ctx, run, json=json_out) + + +def _emit(response: object, *, include: bool, json_mode: bool) -> None: + status = getattr(response, "status_code") + if status in (401, 403): + raise auth_failure() + payload = _safe_json(response) + if status >= 400: + message = payload if isinstance(payload, str) else jsonlib.dumps(payload) + raise APIError(f"API returned {status}: {message}") + if include: + for key, value in getattr(response, "headers", {}).items(): + output.emit_text(f"{key}: {value}") + output.emit_text("") + output.emit(payload, lambda d: jsonlib.dumps(d, indent=2), json_mode=json_mode) + + +def _safe_json(response: object) -> Any: + try: + return response.json() # type: ignore[attr-defined] + except (ValueError, AttributeError): + return getattr(response, "text", "") +``` + +- [ ] **Step 4: Add the `ApiSpec` choices enum** + +In `aai_cli/choices.py`, add a new enum (match the existing `str, Enum` style): + +```python +class ApiSpec(str, Enum): + rest = "rest" + llm_gateway = "llm-gateway" +``` + +Note: `ApiSpec.llm_gateway.value == "llm-gateway"` matches `loader.ApiName`. + +- [ ] **Step 5: Register the command in `main.py`** + +In `aai_cli/main.py`: add `api` to the imports from `aai_cli.commands`, add `app.add_typer(api.app, name="api", rich_help_panel=help_panels.API)` near the other `add_typer` calls, and add `"api"` to `_COMMAND_ORDER` right after `"llm"`. + +- [ ] **Step 6: Run test to verify it passes** + +Run: `uv run pytest tests/test_api_command.py -q` +Expected: PASS (8 passed). If `test_llm_gateway_api_uses_gateway_host` fails on the `api_name.value` typing, ensure `api_key_name` is assigned from `api_name.value` (a `str`) and passed to `load_spec`. + +- [ ] **Step 7: Commit** + +```bash +git add aai_cli/commands/api.py aai_cli/choices.py aai_cli/main.py tests/test_api_command.py +git commit -m "feat(api): aai api passthrough command" +``` + +--- + +## Task 6: `--paginate` support + +**Files:** +- Modify: `aai_cli/commands/api.py` +- Test: `tests/test_api_command.py` (add) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_api_command.py`: + +```python +def test_paginate_follows_next_url(mocker): + config.set_api_key("default", "sk_live") + pages = [ + _FakeResponse(200, {"transcripts": [{"id": "a"}], "page_details": {"next_url": "/v2/transcript?after_id=a"}}), + _FakeResponse(200, {"transcripts": [{"id": "b"}], "page_details": {"next_url": None}}), + ] + calls: list[str] = [] + + def fake_request(method, url, **_kw): + calls.append(url) + return pages[len(calls) - 1] + + mocker.patch.object(httpx2, "request", side_effect=fake_request) + result = runner.invoke(app, ["api", "/v2/transcript", "--paginate", "--json"]) + assert result.exit_code == 0 + assert len(calls) == 2 + merged = json.loads(result.output) + assert [t["id"] for t in merged["transcripts"]] == ["a", "b"] + + +def test_paginate_rejected_for_gateway(): + config.set_api_key("default", "sk_live") + result = runner.invoke(app, ["api", "/chat/completions", "--api", "llm-gateway", "--paginate", "-F", "model=m"]) + assert result.exit_code == 2 # UsageError +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_api_command.py -k paginate -q` +Expected: FAIL (`--paginate` option does not exist). + +- [ ] **Step 3: Implement pagination** + +In `aai_cli/commands/api.py`, add the option to the `api` signature (after `include`): + +```python + paginate: bool = typer.Option(False, "--paginate", help="Follow page_details.next_url."), +``` + +Add the guard after the `startswith("/")` check: + +```python + if paginate and api_name == choices.ApiSpec.llm_gateway: + raise UsageError("--paginate is only supported for the REST API.") +``` + +Replace the single-request `run` body's request/emit with a paginating helper. Add this module-level function: + +```python +def _paginate(http_method: str, base: str, url: str, headers: dict[str, Any]) -> dict[str, Any]: + merged: dict[str, Any] = {} + next_path: str | None = url + while next_path: + response = httpx2.request(http_method, next_path, headers=headers, timeout=60.0) + if response.status_code in (401, 403): + raise auth_failure() + if response.status_code >= 400: + raise APIError(f"API returned {response.status_code}: {response.text}") + page = response.json() + for key, value in page.items(): + if isinstance(value, list): + merged.setdefault(key, []).extend(value) + elif key not in merged: + merged[key] = value + rel = page.get("page_details", {}).get("next_url") + next_path = (base.rstrip("/") + rel) if rel else None + return merged +``` + +In `run`, branch on `paginate`: + +```python + if paginate: + merged = _paginate(http_method, _base_url(api_key_name), url, request_headers) + output.emit(merged, lambda d: jsonlib.dumps(d, indent=2), json_mode=json_mode) + return + try: + response = httpx2.request(...) # unchanged + ... +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_api_command.py -k paginate -q` +Expected: PASS (2 passed). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/api.py tests/test_api_command.py +git commit -m "feat(api): --paginate follows page_details.next_url" +``` + +--- + +## Task 7: `aai api list` subcommand + +**Files:** +- Modify: `aai_cli/commands/api.py` +- Test: `tests/test_api_command.py` (add) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_api_command.py`: + +```python +def test_list_shows_rest_endpoints(): + result = runner.invoke(app, ["api", "list"]) + assert result.exit_code == 0 + assert "/v2/transcript" in result.output + + +def test_list_json_is_machine_readable(): + result = runner.invoke(app, ["api", "list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert any(e["path"] == "/v2/transcript" and e["method"] == "POST" for e in data) + + +def test_list_gateway(): + result = runner.invoke(app, ["api", "list", "--api", "llm-gateway", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert any(e["path"] == "/chat/completions" for e in data) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_api_command.py -k list -q` +Expected: FAIL (no `list` subcommand). + +- [ ] **Step 3: Implement `list`** + +In `aai_cli/commands/api.py`, add: + +```python +@app.command(name="list") +def list_endpoints( + ctx: typer.Context, + api_name: choices.ApiSpec = typer.Option(choices.ApiSpec.rest, "--api", help="Which API."), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """List the endpoints available in the bundled OpenAPI spec.""" + + def run(state: AppState, json_mode: bool) -> None: + spec = loader.load_spec(api_name.value) + rows = [ + {"method": e.method, "path": e.path, "summary": e.summary} for e in spec.endpoints + ] + output.emit(rows, _render_list, json_mode=json_mode) + + run_command(ctx, run, json=json_out, auto_login=False) + + +def _render_list(rows: list[dict[str, str]]) -> object: + table = output.data_table("Method", "Path", "Summary") + for row in rows: + table.add_row(row["method"], row["path"], row["summary"]) + return table +``` + +Note `auto_login=False`: `list` reads only the bundled spec and needs no API key. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_api_command.py -k list -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/api.py tests/test_api_command.py +git commit -m "feat(api): aai api list from bundled spec" +``` + +--- + +## Task 8: Interactive endpoint picker + +**Files:** +- Modify: `aai_cli/commands/api.py` +- Test: `tests/test_api_command.py` (add) + +- [ ] **Step 1: Write the failing test** + +Append to `tests/test_api_command.py`: + +```python +def test_no_endpoint_non_tty_errors(mocker): + config.set_api_key("default", "sk_live") + # CliRunner stdin is not a TTY. + result = runner.invoke(app, ["api"]) + assert result.exit_code == 2 # UsageError + assert "interactive" in result.output.lower() + + +def test_picker_selects_endpoint_then_requests(mocker): + config.set_api_key("default", "sk_live") + mocker.patch("aai_cli.commands.api._is_tty", return_value=True) + # Pick the GET /v2/transcript/{id} endpoint, then supply the path param. + mocker.patch( + "aai_cli.commands.api._prompt_endpoint", + return_value=("GET", "/v2/transcript/{transcript_id}"), + ) + mocker.patch("aai_cli.commands.api._prompt_path_params", return_value="/v2/transcript/t_9") + seen = _capture(mocker, _FakeResponse(200, {"id": "t_9"})) + result = runner.invoke(app, ["api"]) + assert result.exit_code == 0 + assert seen["url"] == "https://api.assemblyai.com/v2/transcript/t_9" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_api_command.py -k "picker or non_tty" -q` +Expected: FAIL (endpoint is currently a required argument; no picker). + +- [ ] **Step 3: Make `endpoint` optional and add the picker** + +In `aai_cli/commands/api.py`: + +Change the `endpoint` argument to optional: + +```python + endpoint: str | None = typer.Argument(None, help="API path starting with / (e.g. /v2/transcript)."), +``` + +Add helpers and the resolution at the top of the `api` function body (before the `startswith` check): + +```python +import sys as _sys + + +def _is_tty() -> bool: + return _sys.stdin.isatty() + + +def _prompt_endpoint(spec: loader.LoadedSpec) -> tuple[str, str]: + choices_list = [f"{e.method} {e.path}" for e in spec.endpoints] + for index, label in enumerate(choices_list): + output.emit_text(f" {index}: {label}") + raw = typer.prompt("Select an endpoint number") + selected = spec.endpoints[int(raw)] + return selected.method, selected.path + + +def _prompt_path_params(path: str) -> str: + import re + + def fill(match: "re.Match[str]") -> str: + return typer.prompt(f"Value for {match.group(1)}") + + return re.sub(r"\{([^}]+)\}", fill, path) +``` + +In the `api` function, before the `startswith` validation: + +```python + if endpoint is None: + if not _is_tty(): + raise UsageError( + "Endpoint is required.", suggestion="Run `aai api` interactively in a terminal." + ) + spec_for_pick = loader.load_spec(api_name.value) + picked_method, picked_path = _prompt_endpoint(spec_for_pick) + endpoint = _prompt_path_params(picked_path) + if method is None: + method = picked_method +``` + +(The existing `startswith("/")` check and the rest of the body run unchanged afterward.) + +- [ ] **Step 4: Run test to verify it passes** + +Run: `uv run pytest tests/test_api_command.py -k "picker or non_tty" -q` +Expected: PASS (2 passed). + +- [ ] **Step 5: Run the full command test file** + +Run: `uv run pytest tests/test_api_command.py -q` +Expected: PASS (all tests from Tasks 5–8). + +- [ ] **Step 6: Commit** + +```bash +git add aai_cli/commands/api.py tests/test_api_command.py +git commit -m "feat(api): interactive endpoint picker on a TTY" +``` + +--- + +## Task 9: Spec contract tests + +**Files:** +- Test: `tests/test_openapi_specs_contract.py` + +- [ ] **Step 1: Write the contract test** + +Create `tests/test_openapi_specs_contract.py`: + +```python +from __future__ import annotations + +import pytest + +from aai_cli import environments +from aai_cli.openapi import loader + +_EXPECTED = { + "rest": ("POST", "/v2/transcript"), + "llm-gateway": ("POST", "/chat/completions"), +} + + +@pytest.mark.parametrize("api", ["rest", "llm-gateway"]) +def test_spec_parses_and_has_expected_endpoint(api): + spec = loader.load_spec(api) + assert spec.endpoints, f"{api} spec has no endpoints" + method, path = _EXPECTED[api] + assert any(e.method == method and e.path == path for e in spec.endpoints) + + +@pytest.mark.parametrize("api", ["rest", "llm-gateway"]) +def test_spec_declares_auth_header(api): + spec = loader.load_spec(api) + assert spec.auth_header_name == "Authorization" + + +@pytest.mark.parametrize("api", ["rest", "llm-gateway"]) +def test_each_api_maps_to_a_real_env_host(api): + env = environments.active() + host = env.api_base if api == "rest" else env.llm_gateway_base + assert host.startswith("https://") +``` + +- [ ] **Step 2: Run the contract test** + +Run: `uv run pytest tests/test_openapi_specs_contract.py -q` +Expected: PASS (9 passed). + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_openapi_specs_contract.py +git commit -m "test(api): parametrized contract tests for bundled specs" +``` + +--- + +## Task 10: Snapshot tests + docs + full gate + +**Files:** +- Modify: `tests/test_api_command.py` (snapshot of `--show-code` and `list`) +- Modify: `AGENTS.md` +- Regenerate: `tests/__snapshots__/*.ambr` + +- [ ] **Step 1: Add snapshot tests** + +Append to `tests/test_api_command.py`: + +```python +def test_show_code_snapshot(snapshot): + result = runner.invoke(app, ["api", "/v2/transcript", "-F", "audio_url=https://x/a.mp3", "--show-code"]) + assert result.exit_code == 0 + assert result.output == snapshot + + +def test_help_snapshot(snapshot): + result = runner.invoke(app, ["api", "--help"]) + assert result.exit_code == 0 + assert result.output == snapshot +``` + +- [ ] **Step 2: Generate snapshots** + +Run: `uv run pytest tests/test_api_command.py -k snapshot --snapshot-update -q` +Expected: snapshots written; then `uv run pytest tests/test_api_command.py -k snapshot -q` PASSES. + +- [ ] **Step 3: Document the command in AGENTS.md** + +In `AGENTS.md`, under the "Feature subsystems" list, add a bullet: + +```markdown +- **`openapi/`** + **`commands/api.py`** — `aai api` is a curl-style authenticated passthrough to the REST and LLM-gateway APIs, driven by **bundled** OpenAPI specs (`aai_cli/openapi/specs/*.json`, force-included in the wheel). The host is resolved from `environments.active()` per `--api` (so `--sandbox`/`--env` work), and the `Authorization` header is read from the spec's security scheme. Regenerate the vendored specs with `uv run python scripts/update-openapi-specs.py`; the parametrized `tests/test_openapi_specs_contract.py` guards against a stale/malformed spec. +``` + +- [ ] **Step 4: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` If `vulture` flags an unused field (e.g. `BodyField.description`), either consume it in the picker output or add it to the vulture allowlist as the codebase does elsewhere. If `xenon` flags `api()` complexity, extract the picker/paginate branches into the already-defined helpers (they are). + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_api_command.py tests/__snapshots__/ AGENTS.md +git commit -m "test(api): snapshots; docs: document aai api in AGENTS.md" +``` + +--- + +## Self-review notes + +- **Spec coverage:** passthrough (T5), multi-host/env (T5), spec-driven auth (T2/T5), `-F/-f/-H/--input/@file/stdin` (T3), `--api` (T5/T7), `--paginate` (T6), `--show-code` (T5), `list` (T7), picker (T8), bundled specs + wheel (T1), regeneration script (T1), contract tests (T9), error mapping (T5), import-linter/help panel/placement (T4/T5), docs (T10) — all covered. +- **Type consistency:** `loader.ApiName` (`"rest"`/`"llm-gateway"`) matches `choices.ApiSpec` values; `LoadedSpec.auth_header_name`/`auth_bearer` used identically in T5; `BodyField`/`Endpoint` fields consistent across tasks. +- **Open follow-up flagged for the executor:** the `auth_bearer` branch in `_emit`/request headers is dead today (both specs use raw apiKey) — kept so the loader stays spec-driven, but `vulture` may flag it; if so, cover it with a tiny unit test in `tests/test_openapi_loader.py` using a synthetic bearer scheme dict rather than deleting the branch. From e4a2a890e1098ef83d3a794106dc2192c2e414db Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:52:28 -0700 Subject: [PATCH 16/40] test(dev): drop S104 workaround, harden Procfile empty-default; doc _dev_command Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/dev.py | 6 +++++- tests/test_dev.py | 33 ++++++++++++++++++++------------- tests/test_procfile.py | 13 ++++++++++--- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/aai_cli/commands/dev.py b/aai_cli/commands/dev.py index 20af43ac..ef707f1c 100644 --- a/aai_cli/commands/dev.py +++ b/aai_cli/commands/dev.py @@ -32,7 +32,11 @@ def _install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step def _dev_command(target: Path, web: list[str], *, use_uv: bool) -> list[str]: - """The Procfile web process, run in the project venv with live reload.""" + """The Procfile web process, run in the project venv with live reload. + + In the no-uv branch `web[0]` must be a `python -m`-runnable module; every current + template's `web:` line starts with `uvicorn`. + """ prefix = ["uv", "run"] if use_uv else [str(runner.venv_python(target)), "-m"] return [*prefix, *web, "--reload"] diff --git a/tests/test_dev.py b/tests/test_dev.py index 802ef813..0129734a 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -5,8 +5,7 @@ from aai_cli.main import app runner = CliRunner() -ALL_IFACES = ".".join(["0"] * 4) # 0.0.0.0, built to dodge ruff S104 in this test file -WEB = f"web: uvicorn api.index:app --host {ALL_IFACES} --port ${{PORT:-3000}}\n" +WEB = "web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" def _make_project(tmp_path): @@ -38,17 +37,10 @@ def test_dev_boots_procfile_command_with_reload(tmp_path, monkeypatch): captured = _stub_runner(monkeypatch) result = runner.invoke(app, ["dev", "--no-open"]) assert result.exit_code == 0, result.output - assert captured["command"] == [ - "uv", - "run", - "uvicorn", - "api.index:app", - "--host", - ALL_IFACES, - "--port", - "3000", - "--reload", - ] + cmd = captured["command"] + assert cmd[:4] == ["uv", "run", "uvicorn", "api.index:app"] + assert "--host" in cmd + assert cmd[-3:] == ["--port", "3000", "--reload"] assert captured["env"]["PORT"] == "3000" assert captured["open_browser"] is False assert "Starting" in result.output @@ -125,6 +117,21 @@ def test_dev_install_failure_exits(tmp_path, monkeypatch): assert captured == {} # install failed -> no launch +def test_dev_install_failure_detail_truncated_to_300(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + _stub_runner(monkeypatch) + long_stderr = "x" * 500 + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 1, "", long_stderr), + ) + result = runner.invoke(app, ["dev", "--json"]) + assert result.exit_code == 1 + assert '"detail": "' + "x" * 300 + '"' in result.output + assert "x" * 301 not in result.output + + def test_dev_server_nonzero_exit_propagates(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) _make_project(tmp_path) diff --git a/tests/test_procfile.py b/tests/test_procfile.py index e489ab32..d8270b4c 100644 --- a/tests/test_procfile.py +++ b/tests/test_procfile.py @@ -5,8 +5,7 @@ from aai_cli.errors import CLIError from aai_cli.init import procfile -ALL_IFACES = ".".join(["0"] * 4) # 0.0.0.0, built to dodge ruff S104 in this test file -WEB = f"web: uvicorn api.index:app --host {ALL_IFACES} --port ${{PORT:-3000}}\n" +WEB = "web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" def _write(tmp_path: Path, text: str) -> Path: @@ -16,7 +15,9 @@ def _write(tmp_path: Path, text: str) -> Path: def test_web_argv_expands_port_when_set(tmp_path): argv = procfile.web_argv(_write(tmp_path, WEB), env={"PORT": "8123"}) - assert argv == ["uvicorn", "api.index:app", "--host", ALL_IFACES, "--port", "8123"] + assert argv[:2] == ["uvicorn", "api.index:app"] + assert "--host" in argv + assert argv[-2:] == ["--port", "8123"] def test_web_argv_uses_default_when_port_unset(tmp_path): @@ -24,6 +25,11 @@ def test_web_argv_uses_default_when_port_unset(tmp_path): assert argv[-2:] == ["--port", "3000"] +def test_web_argv_default_when_var_is_empty(tmp_path): + argv = procfile.web_argv(_write(tmp_path, WEB), env={"PORT": ""}) + assert argv[-2:] == ["--port", "3000"] + + def test_web_argv_expands_plain_and_braced_vars(tmp_path): text = "web: run $HOST ${EXTRA}\n" argv = procfile.web_argv(_write(tmp_path, text), env={"HOST": "h", "EXTRA": "x"}) @@ -46,6 +52,7 @@ def test_web_argv_raises_without_web_line(tmp_path): with pytest.raises(CLIError) as exc: procfile.web_argv(_write(tmp_path, "release: echo hi\n"), env={}) assert exc.value.error_type == "usage_error" + assert exc.value.exit_code == 1 def test_web_argv_raises_on_empty_web_command(tmp_path): From a90611912f287bf8c4346c2728f41ffe7c15bb8f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:54:26 -0700 Subject: [PATCH 17/40] feat(init): point launch hints at 'aai dev' Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/init.py | 6 ++---- tests/test_init_command.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index 51ed721a..a4ccccd6 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -205,7 +205,7 @@ def run_init( { "name": "launch", "status": "skipped", - "detail": f"no API key; run `aai login`, then: cd {target} && uv run uvicorn api.index:app", + "detail": f"no API key; run `aai login`, then: cd {target} && aai dev", } ) @@ -218,9 +218,7 @@ def run_init( elif not json_mode: # Scaffolded but not launched (no key, or --no-install, or launch=False): leave the # user with the one command that starts their app, the way `vercel`/`supabase` sign off. - output.console.print( - output.hint(f"Run `cd {escape(str(target))} && uv run uvicorn api.index:app`.") - ) + output.console.print(output.hint(f"Run `cd {escape(str(target))} && aai dev`.")) return target diff --git a/tests/test_init_command.py b/tests/test_init_command.py index 6ec20d62..ce3698da 100644 --- a/tests/test_init_command.py +++ b/tests/test_init_command.py @@ -57,7 +57,7 @@ def test_init_logged_out_installs_but_skips_launch_with_hint(tmp_path, monkeypat assert "aai login" in result.output # Deps installed but no key -> a launch-skipped row with the manual run command # (pins `not no_install and api_key is None`). - assert "uvicorn api.index" in result.output + assert "aai dev" in result.output def test_init_logged_out_install_emits_launch_skipped_step_json(tmp_path, monkeypatch): @@ -94,7 +94,7 @@ def test_init_placeholder_key_when_logged_out(tmp_path, monkeypatch): assert "your_assemblyai_api_key_here" in env # --no-install means no deps were installed, so there's no launch-skipped row even # without a key (pins the `not no_install` half of the launch guard). - assert "uvicorn api.index" not in result.output + assert "aai dev" not in result.output def test_init_unknown_template_errors(tmp_path, monkeypatch): From 1b0488bec4a4f626f59d541794309d107dca7d14 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 11:54:30 -0700 Subject: [PATCH 18/40] docs(templates): advertise 'aai dev' as the run-locally command Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/templates/audio-transcription/AGENTS.md | 2 +- aai_cli/init/templates/audio-transcription/README.md | 3 +-- aai_cli/init/templates/live-captions/AGENTS.md | 2 +- aai_cli/init/templates/live-captions/README.md | 3 +-- aai_cli/init/templates/voice-agent/AGENTS.md | 2 +- aai_cli/init/templates/voice-agent/README.md | 3 +-- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/aai_cli/init/templates/audio-transcription/AGENTS.md b/aai_cli/init/templates/audio-transcription/AGENTS.md index f9568c10..55dc289b 100644 --- a/aai_cli/init/templates/audio-transcription/AGENTS.md +++ b/aai_cli/init/templates/audio-transcription/AGENTS.md @@ -3,7 +3,7 @@ This is a buildless FastAPI + static HTML starter. Run it with: ```sh -uvicorn api.index:app --reload --port 3000 +aai dev ``` ## Map diff --git a/aai_cli/init/templates/audio-transcription/README.md b/aai_cli/init/templates/audio-transcription/README.md index 4a62d28b..18b14a42 100644 --- a/aai_cli/init/templates/audio-transcription/README.md +++ b/aai_cli/init/templates/audio-transcription/README.md @@ -7,8 +7,7 @@ highlights. Built with FastAPI + static HTML/CSS/JS. There is no frontend build ## Run locally ```sh -uvicorn api.index:app --reload --port 3000 -# open http://localhost:3000 +aai dev # installs deps if needed, starts the server, opens http://localhost:3000 ``` `ASSEMBLYAI_API_KEY` is read from `.env` (already created for you if you ran `aai init`). diff --git a/aai_cli/init/templates/live-captions/AGENTS.md b/aai_cli/init/templates/live-captions/AGENTS.md index 534c2462..8ad934ce 100644 --- a/aai_cli/init/templates/live-captions/AGENTS.md +++ b/aai_cli/init/templates/live-captions/AGENTS.md @@ -3,7 +3,7 @@ This is a buildless FastAPI + browser microphone starter. Run it with: ```sh -uvicorn api.index:app --reload --port 3000 +aai dev ``` ## Map diff --git a/aai_cli/init/templates/live-captions/README.md b/aai_cli/init/templates/live-captions/README.md index 4828578e..95683427 100644 --- a/aai_cli/init/templates/live-captions/README.md +++ b/aai_cli/init/templates/live-captions/README.md @@ -9,8 +9,7 @@ HTML/CSS/JS with no frontend build step. ## Run locally ```sh -uvicorn api.index:app --reload --port 3000 -# open http://localhost:3000 (allow microphone access) +aai dev # opens http://localhost:3000 (allow microphone access) ``` `ASSEMBLYAI_API_KEY` is read from `.env` (created for you if you ran `aai init`). diff --git a/aai_cli/init/templates/voice-agent/AGENTS.md b/aai_cli/init/templates/voice-agent/AGENTS.md index 89302ce4..2d57d262 100644 --- a/aai_cli/init/templates/voice-agent/AGENTS.md +++ b/aai_cli/init/templates/voice-agent/AGENTS.md @@ -3,7 +3,7 @@ This is a buildless FastAPI + browser voice-agent starter. Run it with: ```sh -uvicorn api.index:app --reload --port 3000 +aai dev ``` ## Map diff --git a/aai_cli/init/templates/voice-agent/README.md b/aai_cli/init/templates/voice-agent/README.md index 5bee3a07..2735888f 100644 --- a/aai_cli/init/templates/voice-agent/README.md +++ b/aai_cli/init/templates/voice-agent/README.md @@ -9,8 +9,7 @@ static HTML/CSS/JS with no frontend build step. ## Run locally ```sh -uvicorn api.index:app --reload --port 3000 -# open http://localhost:3000 (allow microphone access; headphones recommended) +aai dev # opens http://localhost:3000 (allow microphone access; headphones recommended) ``` `ASSEMBLYAI_API_KEY` is read from `.env` (created for you if you ran `aai init`). From 6e503153f36a53cf84fadf15ed111d1921a8f177 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:01:44 -0700 Subject: [PATCH 19/40] test(init): boot each template in-process and probe every route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract gate boots the real Procfile but only curls GET /; the install test just imports the module. Neither touches the /api/* surface, so a route that imports fine but raises on every request slips through. Add an in-process TestClient test that drives each route for both outcomes — outbound call succeeds (assert the documented 200 body) and outbound call raises (assert the graceful 502 {"detail"}). Covers all three template api/index.py files to 100%. Also strip ASSEMBLYAI_BASE_URL in isolate_env so template-import behavior doesn't vary with the dev's environment. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/conftest.py | 1 + tests/test_init_template_serve.py | 251 ++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 tests/test_init_template_serve.py diff --git a/tests/conftest.py b/tests/conftest.py index 2d68e7c9..7cfce098 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,7 @@ def delete_password(self, service, username): def isolate_env(monkeypatch): for var in ( "ASSEMBLYAI_API_KEY", + "ASSEMBLYAI_BASE_URL", "AAI_ENV", "CI", "CLAUDECODE", diff --git a/tests/test_init_template_serve.py b/tests/test_init_template_serve.py new file mode 100644 index 00000000..0552913b --- /dev/null +++ b/tests/test_init_template_serve.py @@ -0,0 +1,251 @@ +"""Boot each ``aai init`` template's FastAPI app in-process and drive every route. + +The static contract gate (``scripts/template_contract_gate.py``) boots the real +Procfile process and curls ``GET /`` — but only ``/``. The ``install`` test resolves +requirements and imports the module. Neither exercises the ``/api/*`` surface, so a +route that imports fine but raises on every request would pass both. + +This test closes that gap: it imports ``api.index`` (deps are in the dev group, so no +network/venv) and hits each route with ``TestClient`` for *both* outcomes — + + * the outbound AssemblyAI / LLM call **succeeds** -> assert the documented 200 body + * the outbound call **raises** -> assert the graceful ``502 {"detail": ...}`` the + templates promise ("clean 502, not a 500") + +``raise_server_exceptions=False`` turns an *unhandled* exception into a 500 *response* +we can assert against, rather than letting it bubble out of the route. +""" + +from __future__ import annotations + +import importlib +import sys +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +TEMPLATES_ROOT = Path("aai_cli/init/templates") +TEMPLATE_NAMES = sorted( + p.name for p in TEMPLATES_ROOT.iterdir() if p.is_dir() and not p.name.startswith("__") +) +TOKEN_TEMPLATES = ["live-captions", "voice-agent"] +HTTP_OK = 200 +HTTP_BAD_GATEWAY = 502 + + +def _boom(*_args: object, **_kwargs: object) -> object: + """Stand in for an outbound call that fails (missing key, network, bad plan).""" + raise RuntimeError("simulated backend failure") + + +@contextmanager +def serve(template: str) -> Iterator[tuple[ModuleType, TestClient]]: + """Import a template's ``api.index`` in isolation and yield it with a TestClient. + + The three templates ship an identically-named ``api`` package, so evict any cached + ``api``/``api.*`` before and after to keep imports collision-free and order-independent + (safe under pytest-xdist / pytest-randomly). The app reads ``ASSEMBLYAI_API_KEY`` at + import; the autouse ``isolate_env`` fixture has already stripped it, so it boots keyless. + """ + path = (TEMPLATES_ROOT / template).resolve() + saved_path = list(sys.path) + saved_modules = {n: m for n, m in sys.modules.items() if n == "api" or n.startswith("api.")} + for name in list(saved_modules): + sys.modules.pop(name, None) + sys.path.insert(0, str(path)) + try: + module = importlib.import_module("api.index") + client = TestClient(module.app, raise_server_exceptions=False) + yield module, client + finally: + for name in [n for n in sys.modules if n == "api" or n.startswith("api.")]: + sys.modules.pop(name, None) + sys.modules.update(saved_modules) + sys.path[:] = saved_path + + +@pytest.mark.parametrize("template", TEMPLATE_NAMES) +def test_serves_root_and_static_assets(template: str) -> None: + with serve(template) as (_module, client): + root = client.get("/") + assert root.status_code == HTTP_OK + assert "text/html" in root.headers["content-type"] + assert root.text.strip() + + static_dir = TEMPLATES_ROOT / template / "public" / "static" + assets = sorted(p for p in static_dir.glob("*") if p.is_file()) + assert assets, f"{template}: no static assets to serve" + for asset in assets: + resp = client.get(f"/static/{asset.name}") + assert resp.status_code == HTTP_OK, f"{template}: GET /static/{asset.name}" + + +# --- audio-transcription: transcribe / status / ask -------------------------------- + + +def test_app_applies_custom_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + # `aai init` writes ASSEMBLYAI_BASE_URL when the key was minted for a non-prod + # environment; the app must point the SDK at it. isolate_env strips it by default, + # so set it here to exercise the import-time branch that applies it. + monkeypatch.setenv("ASSEMBLYAI_BASE_URL", "https://api.example.test") + with serve("audio-transcription") as (module, _client): + assert module.aai.settings.base_url == "https://api.example.test" + + +def _fake_transcriber( + monkeypatch: pytest.MonkeyPatch, module: ModuleType, transcript: object, *, raises: bool = False +) -> None: + def submit(_audio: object, config: object = None) -> object: + if raises: + return _boom() + return transcript + + monkeypatch.setattr(module.aai, "Transcriber", lambda: SimpleNamespace(submit=submit)) + + +def test_transcribe_url_returns_submitted_id(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + _fake_transcriber(monkeypatch, module, SimpleNamespace(id="t-1")) + resp = client.post("/api/transcribe-url", json={"url": "https://example.com/a.mp3"}) + assert resp.status_code == HTTP_OK + assert resp.json() == {"id": "t-1"} + + +def test_transcribe_file_upload_returns_id(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + _fake_transcriber(monkeypatch, module, SimpleNamespace(id="t-2")) + resp = client.post("/api/transcribe", files={"file": ("a.wav", b"RIFFfake", "audio/wav")}) + assert resp.status_code == HTTP_OK + assert resp.json() == {"id": "t-2"} + + +def test_transcribe_without_id_is_handled(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + _fake_transcriber(monkeypatch, module, SimpleNamespace(id=None)) + resp = client.post("/api/transcribe-url", json={"url": "https://example.com/a.mp3"}) + assert resp.status_code == HTTP_BAD_GATEWAY + assert "detail" in resp.json() + + +def test_transcribe_failure_is_graceful(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + _fake_transcriber(monkeypatch, module, None, raises=True) + resp = client.post("/api/transcribe-url", json={"url": "https://example.com/a.mp3"}) + assert resp.status_code == HTTP_BAD_GATEWAY + assert "detail" in resp.json() + + +def _fake_get_transcript( + monkeypatch: pytest.MonkeyPatch, module: ModuleType, result: object, *, raises: bool = False +) -> None: + monkeypatch.setattr( + module.Client, + "get_default", + staticmethod(lambda: SimpleNamespace(http_client=None)), + ) + + def fetch(_http: object, _tid: object) -> object: + if raises: + return _boom() + return result + + monkeypatch.setattr(module, "get_transcript", fetch) + + +def test_status_completed_returns_transcript(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + status = module.aai.TranscriptStatus.completed + done = SimpleNamespace(status=status, dict=lambda: {"id": "t", "text": "hi"}) + _fake_get_transcript(monkeypatch, module, done) + resp = client.get("/api/status/t") + assert resp.status_code == HTTP_OK + assert resp.json() == {"status": "completed", "transcript": {"id": "t", "text": "hi"}} + + +def test_status_still_processing_reports_state(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + status = module.aai.TranscriptStatus.processing + _fake_get_transcript(monkeypatch, module, SimpleNamespace(status=status)) + resp = client.get("/api/status/t") + assert resp.status_code == HTTP_OK + assert resp.json() == {"status": "processing"} + + +def test_status_error_is_handled(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + status = module.aai.TranscriptStatus.error + _fake_get_transcript(monkeypatch, module, SimpleNamespace(status=status, error="boom")) + resp = client.get("/api/status/t") + assert resp.status_code == HTTP_BAD_GATEWAY + assert resp.json()["detail"] == "boom" + + +def test_status_fetch_failure_is_graceful(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + _fake_get_transcript(monkeypatch, module, None, raises=True) + resp = client.get("/api/status/t") + assert resp.status_code == HTTP_BAD_GATEWAY + assert "detail" in resp.json() + + +def _fake_openai( + monkeypatch: pytest.MonkeyPatch, module: ModuleType, reply: object, *, raises: bool = False +) -> None: + def create(**_kwargs: object) -> object: + if raises: + return _boom() + return reply + + fake = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + + def make_client(**_kwargs: object) -> object: + return fake + + monkeypatch.setattr(module, "OpenAI", make_client) + + +def test_ask_returns_answer(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + reply = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content="yes"))]) + _fake_openai(monkeypatch, module, reply) + resp = client.post("/api/ask", json={"transcript_id": "t", "question": "q?"}) + assert resp.status_code == HTTP_OK + assert resp.json() == {"answer": "yes"} + + +def test_ask_failure_is_graceful(monkeypatch: pytest.MonkeyPatch) -> None: + with serve("audio-transcription") as (module, client): + _fake_openai(monkeypatch, module, None, raises=True) + resp = client.post("/api/ask", json={"transcript_id": "t", "question": "q?"}) + assert resp.status_code == HTTP_BAD_GATEWAY + assert "detail" in resp.json() + + +# --- live-captions / voice-agent: /api/token --------------------------------------- + + +@pytest.mark.parametrize("template", TOKEN_TEMPLATES) +def test_token_mints_token_and_ws_url(template: str, monkeypatch: pytest.MonkeyPatch) -> None: + def fake_get(*_args: object, **_kwargs: object) -> object: + return SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"token": "tok-1"}) + + with serve(template) as (module, client): + monkeypatch.setattr(module.httpx2, "get", fake_get) + result = client.post("/api/token") + assert result.status_code == HTTP_OK + body = result.json() + assert body["token"] == "tok-1" + assert body["ws_url"].startswith("wss://") + + +@pytest.mark.parametrize("template", TOKEN_TEMPLATES) +def test_token_failure_is_graceful(template: str, monkeypatch: pytest.MonkeyPatch) -> None: + with serve(template) as (module, client): + monkeypatch.setattr(module.httpx2, "get", _boom) + result = client.post("/api/token") + assert result.status_code == HTTP_BAD_GATEWAY + assert "detail" in result.json() From d587c2d171f07bee8bef651f90dd9f9430cda034 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:03:40 -0700 Subject: [PATCH 20/40] docs: design for aai share + aai deploy Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-09-aai-share-deploy-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-aai-share-deploy-design.md diff --git a/docs/superpowers/specs/2026-06-09-aai-share-deploy-design.md b/docs/superpowers/specs/2026-06-09-aai-share-deploy-design.md new file mode 100644 index 00000000..f9e717d1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-aai-share-deploy-design.md @@ -0,0 +1,130 @@ +# `aai share` and `aai deploy` — design + +**Date:** 2026-06-09 +**Status:** Approved (design) + +Two follow-on commands to `aai dev`, both filed under the "Build an App" help panel. + +## `aai share` — public tunnel over the local dev server + +**Goal:** boot the app (the same way `aai dev` does) and expose it on a public +`https://*.trycloudflare.com` URL via a cloudflared quick tunnel, so the running +app can be shared instantly (demos, mobile testing, webhooks). + +### Decisions + +- **Quick tunnel**, not a named tunnel: `cloudflared tunnel --url http://localhost:PORT`. + Zero-config, no Cloudflare account, prints an ephemeral `*.trycloudflare.com` URL. +- **cloudflared is required**; resolved via `shutil.which("cloudflared")`. Missing → + `CLIError(error_type="missing_dependency")` with a `brew install cloudflared` hint. + Added as `depends_on "cloudflared"` in `Formula/aai.rb` so Homebrew installs ship it. +- Reuses the `aai dev` boot path (Procfile `web:` + `--reload`), so "share" really is + "dev, but also tunneled." +- Prints the public URL prominently; does **not** auto-open a browser (you're sharing + the link with someone else). Same `--port` / `--no-install` flags as `dev`. + +### Architecture + +**New `aai_cli/init/devserver.py`** (shared by `dev` and `share`; lives in the `init` +layer so both command modules can import it without breaking the "commands are +independent" import contract): + +- `install_step(target, *, no_install, use_uv) -> steps.Step` — moved from `dev.py`. +- `dev_command(target, web, *, use_uv) -> list[str]` — moved from `dev.py` (`uv run …` + or `venv -m …`, with `--reload` appended). + +`aai_cli/commands/dev.py` is refactored to import these (behavior unchanged). + +**New `aai_cli/init/tunnel.py`:** + +- `CLOUDFLARED = "cloudflared"`; `tunnel_command(port) -> list[str]` → + `["cloudflared", "tunnel", "--url", f"http://localhost:{port}"]`. +- `find_url(line: str) -> str | None` — regex `https://[a-z0-9-]+\.trycloudflare\.com` + over a single output line (cloudflared logs the URL to stderr). Pure + unit-testable. + +**`aai_cli/init/runner.py`** gains a small helper so `share` can run two processes +concurrently without duplicating Popen handling: + +- `spawn(command, *, cwd, env=None, capture=False) -> subprocess.Popen` — thin Popen + wrapper (`capture=True` sets `stdout=PIPE, stderr=STDOUT, text=True` for reading + cloudflared's URL). `run_server` stays as-is for `dev`. + +**New `aai_cli/commands/share.py`** — `run_share(*, port, no_install, json_mode)`: + +1. `target = cwd`; `chosen_port = find_free_port(port)`; `env = {**os.environ, "PORT": str(chosen_port)}`. +2. `web = procfile.web_argv(target, env=env)` (validates project). +3. Require cloudflared: `if shutil.which(tunnel.CLOUDFLARED) is None: raise missing_dependency`. +4. Install (unless `--no-install`) via `devserver.install_step`; failed → exit 1. +5. `server = runner.spawn(devserver.dev_command(target, web, use_uv=use_uv), cwd=target, env=env)`; + `runner.wait_for_port(chosen_port)`; if the server exited early → error + stop. +6. `proxy = runner.spawn(tunnel.tunnel_command(chosen_port), cwd=target, capture=True)`; + read `proxy.stdout` line by line, `tunnel.find_url` until found or the stream ends + (bounded loop). Print `Sharing -> http://localhost:`. +7. Block on `server.wait()`; on `KeyboardInterrupt` terminate both. `finally` always + terminates both processes. + +JSON mode: emit `{ "url": ..., "local": ..., "port": ... }` then still block (Ctrl-C +to stop), matching how `dev` treats `--json`. + +**Registration:** `main.py` imports + `app.add_typer(share.app)`, `_COMMAND_ORDER` +gets `"share"` after `"dev"`, `rich_help_panel=help_panels.BUILD`. Add `dev` and +`share` to the `.importlinter` "Command modules are independent" contract. Add a +`cloudflared` row to `aai doctor`'s tool checks (optional/best-effort, like ffmpeg). + +### Testing + +- `tests/test_tunnel.py`: `find_url` matches a real cloudflared banner line / returns + None otherwise; `tunnel_command` shape. +- `tests/test_devserver.py`: `install_step` (skipped/failed/installed), `dev_command` + (uv vs venv, `--reload` appended). +- `tests/test_share.py` (mock `runner.spawn`, `runner.wait_for_port`, `runner.find_free_port`, + `shutil.which`, and a fake proxy process whose stdout yields a trycloudflare line): + prints the public URL; missing cloudflared → exit 1 with brew hint; missing Procfile → + exit 1; install failure → exit 1, no spawn; both processes terminated on exit; `--json` + emits the url payload. +- Snapshot regen for `aai --help` + `aai share --help`. + +## `aai deploy` — confirm, then `vercel deploy` + +**Goal:** deploy the current project to Vercel from the CLI, guarded by a yes/no +confirmation. + +### Decisions + +- **Confirm first:** prompt `Deploy this project to Vercel? [y/N]` and abort on anything + but yes (exit 0, no error). `--yes`/`-y` skips the prompt (for automation); when output + is non-interactive/agentic and `--yes` wasn't passed, abort with a clear message rather + than hang. +- **Require the Vercel CLI:** `shutil.which("vercel")` → missing → `CLIError(missing_dependency)` + with an `npm i -g vercel` hint. (Vercel CLI is npm-distributed; not added to the brew + formula.) +- Run `vercel deploy` in the current directory, streaming its output; `--prod` passes + through to `vercel deploy --prod`. Propagate vercel's exit code. + +### Architecture + +**New `aai_cli/commands/deploy.py`** — `run_deploy(*, prod, assume_yes, json_mode)`: + +1. Require `vercel` via `shutil.which`; missing → `missing_dependency` error. +2. Confirm: unless `assume_yes`, prompt y/N (using `typer.confirm`); on no → print + "Aborted." and return (exit 0). If non-interactive and not `assume_yes` → usage error. +3. `cmd = ["vercel", "deploy"] + (["--prod"] if prod else [])`; run via + `subprocess.run(cmd, cwd=Path.cwd())` (inherit stdio so vercel's own progress shows). + `raise typer.Exit(code=result.returncode)` when non-zero. + +Flags: `--prod`, `--yes/-y`, `--json`. Registered under "Build an App" after `share`; +added to the import-linter independence contract. + +### Testing + +- `tests/test_deploy.py` (mock `shutil.which`, `subprocess.run`, and `typer.confirm`): + confirm-no aborts without running vercel; `--yes` skips the prompt and runs; + missing vercel → exit 1 with npm hint; `--prod` adds the flag; non-zero vercel exit + propagates; non-interactive without `--yes` → usage error. +- Snapshot regen for `aai --help` + `aai deploy --help`. + +## Out of scope + +- Named/persistent cloudflared tunnels and custom domains. +- Vercel project linking/env setup (delegated to `vercel` itself). +- Non-Vercel deploy targets (the Procfile already covers Render/Railway/Heroku/Cloud Run). From 43274d44b984fedc84384b754e7d3b3b1df1f77f Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:05:19 -0700 Subject: [PATCH 21/40] refactor(dev): extract shared boot helpers into init/devserver Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/dev.py | 32 ++++---------------- aai_cli/init/devserver.py | 31 ++++++++++++++++++++ tests/test_devserver.py | 61 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 aai_cli/init/devserver.py create mode 100644 tests/test_devserver.py diff --git a/aai_cli/commands/dev.py b/aai_cli/commands/dev.py index ef707f1c..8b456526 100644 --- a/aai_cli/commands/dev.py +++ b/aai_cli/commands/dev.py @@ -10,37 +10,13 @@ from aai_cli import help_panels, output, steps from aai_cli.context import AppState, run_command from aai_cli.help_text import examples_epilog -from aai_cli.init import procfile, runner +from aai_cli.init import devserver, procfile, runner # Flattened single-command sub-typer (same pattern as `aai init`): one # @app.command() registered via app.add_typer(dev.app) with no name. app = typer.Typer() -def _install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: - """Install deps (unless --no-install) and return the report row.""" - if no_install: - return {"name": "install", "status": "skipped", "detail": "--no-install"} - setup = runner.run_setup(target, use_uv=use_uv) - if setup.returncode != 0: - return { - "name": "install", - "status": "failed", - "detail": (setup.stderr or setup.stdout).strip()[:300], - } - return {"name": "install", "status": "installed", "detail": "uv" if use_uv else "venv + pip"} - - -def _dev_command(target: Path, web: list[str], *, use_uv: bool) -> list[str]: - """The Procfile web process, run in the project venv with live reload. - - In the no-uv branch `web[0]` must be a `python -m`-runnable module; every current - template's `web:` line starts with `uvicorn`. - """ - prefix = ["uv", "run"] if use_uv else [str(runner.venv_python(target)), "-m"] - return [*prefix, *web, "--reload"] - - def run_dev(*, port: int, no_install: bool, no_open: bool, json_mode: bool) -> None: """Boot the project's Procfile `web:` process locally, with live reload.""" target = Path.cwd() @@ -51,12 +27,14 @@ def run_dev(*, port: int, no_install: bool, no_open: bool, json_mode: bool) -> N # Resolves the start command AND validates we're inside a scaffolded project. web = procfile.web_argv(target, env=env) - report: list[steps.Step] = [_install_step(target, no_install=no_install, use_uv=use_uv)] + report: list[steps.Step] = [ + devserver.install_step(target, no_install=no_install, use_uv=use_uv) + ] output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode) if any(s["status"] == "failed" for s in report): raise typer.Exit(code=1) - command = _dev_command(target, web, use_uv=use_uv) + command = devserver.dev_command(target, web, use_uv=use_uv) url = f"http://localhost:{chosen_port}" if not json_mode: output.console.print( diff --git a/aai_cli/init/devserver.py b/aai_cli/init/devserver.py new file mode 100644 index 00000000..b0a98880 --- /dev/null +++ b/aai_cli/init/devserver.py @@ -0,0 +1,31 @@ +# aai_cli/init/devserver.py +from __future__ import annotations + +from pathlib import Path + +from aai_cli import steps +from aai_cli.init import runner + + +def install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: + """Install deps (unless --no-install) and return the report row.""" + if no_install: + return {"name": "install", "status": "skipped", "detail": "--no-install"} + setup = runner.run_setup(target, use_uv=use_uv) + if setup.returncode != 0: + return { + "name": "install", + "status": "failed", + "detail": (setup.stderr or setup.stdout).strip()[:300], + } + return {"name": "install", "status": "installed", "detail": "uv" if use_uv else "venv + pip"} + + +def dev_command(target: Path, web: list[str], *, use_uv: bool) -> list[str]: + """The Procfile web process, run in the project venv with live reload. + + In the no-uv branch `web[0]` must be a `python -m`-runnable module; every current + template's `web:` line starts with `uvicorn`. + """ + prefix = ["uv", "run"] if use_uv else [str(runner.venv_python(target)), "-m"] + return [*prefix, *web, "--reload"] diff --git a/tests/test_devserver.py b/tests/test_devserver.py new file mode 100644 index 00000000..bc2f000f --- /dev/null +++ b/tests/test_devserver.py @@ -0,0 +1,61 @@ +import subprocess +from pathlib import Path + +from aai_cli.init import devserver + + +def test_install_step_skipped(): + step = devserver.install_step(Path("/proj"), no_install=True, use_uv=True) + assert step == {"name": "install", "status": "skipped", "detail": "--no-install"} + + +def test_install_step_installed_uv(monkeypatch): + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 0, "", ""), + ) + step = devserver.install_step(Path("/proj"), no_install=False, use_uv=True) + assert step == {"name": "install", "status": "installed", "detail": "uv"} + + +def test_install_step_installed_venv(monkeypatch): + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 0, "", ""), + ) + step = devserver.install_step(Path("/proj"), no_install=False, use_uv=False) + assert step["detail"] == "venv + pip" + + +def test_install_step_failed_uses_stderr(monkeypatch): + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 1, "out", "the-error"), + ) + step = devserver.install_step(Path("/proj"), no_install=False, use_uv=True) + assert step["status"] == "failed" + assert step["detail"] == "the-error" + + +def test_install_step_failed_truncates_detail(monkeypatch): + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], 1, "", "x" * 500), + ) + step = devserver.install_step(Path("/proj"), no_install=False, use_uv=True) + assert len(step["detail"]) == 300 + + +def test_dev_command_uv(): + cmd = devserver.dev_command(Path("/proj"), ["uvicorn", "api.index:app"], use_uv=True) + assert cmd == ["uv", "run", "uvicorn", "api.index:app", "--reload"] + + +def test_dev_command_venv(): + from aai_cli.init import runner + + cmd = devserver.dev_command(Path("/proj"), ["uvicorn", "api.index:app"], use_uv=False) + assert cmd[0] == str(runner.venv_python(Path("/proj"))) + assert cmd[1] == "-m" + assert cmd[2] == "uvicorn" + assert cmd[-1] == "--reload" From fc937071daa10fa30d123e2801d42375a1aca5bb Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:13:59 -0700 Subject: [PATCH 22/40] feat(tunnel): cloudflared quick-tunnel helpers + runner.spawn Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/runner.py | 24 ++++++++++++++++++++ aai_cli/init/tunnel.py | 46 +++++++++++++++++++++++++++++++++++++++ tests/test_init_runner.py | 34 +++++++++++++++++++++++++++++ tests/test_tunnel.py | 45 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+) create mode 100644 aai_cli/init/tunnel.py create mode 100644 tests/test_tunnel.py diff --git a/aai_cli/init/runner.py b/aai_cli/init/runner.py index ece4cc8e..d8832a60 100644 --- a/aai_cli/init/runner.py +++ b/aai_cli/init/runner.py @@ -71,6 +71,30 @@ def wait_for_port(port: int, *, timeout: float = 30.0) -> bool: return False +def spawn( + command: list[str], + *, + cwd: Path, + env: dict[str, str] | None = None, + log_path: Path | None = None, +) -> subprocess.Popen[str]: + """Start a process without blocking. + + With `log_path`, the process's stdout+stderr are written to that file (text mode) — + used to capture cloudflared's output for URL discovery. Without it, stdio is inherited. + """ + if log_path is not None: + return subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=log_path.open("w"), + stderr=subprocess.STDOUT, + text=True, + ) + return subprocess.Popen(command, cwd=cwd, env=env, text=True) + + def run_setup(target: Path, *, use_uv: bool) -> subprocess.CompletedProcess[str]: """Run env-setup commands in order; return the first failure or the last success.""" last = subprocess.CompletedProcess[str](args=[], returncode=0, stdout="", stderr="") diff --git a/aai_cli/init/tunnel.py b/aai_cli/init/tunnel.py new file mode 100644 index 00000000..27a24bc0 --- /dev/null +++ b/aai_cli/init/tunnel.py @@ -0,0 +1,46 @@ +# aai_cli/init/tunnel.py +from __future__ import annotations + +import re +import time +from collections.abc import Callable +from pathlib import Path + +# cloudflared binary name; resolved via shutil.which by callers. +CLOUDFLARED = "cloudflared" + +# A cloudflared quick tunnel prints an ephemeral https://.trycloudflare.com URL. +_URL = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com") + + +def tunnel_command(port: int) -> list[str]: + """The cloudflared quick-tunnel command pointing at the local server.""" + return [CLOUDFLARED, "tunnel", "--url", f"http://localhost:{port}"] + + +def find_url(text: str) -> str | None: + """The first trycloudflare.com URL in `text`, or None.""" + match = _URL.search(text) + return match.group(0) if match else None + + +def await_url( + log_path: Path, + *, + timeout: float = 30.0, + interval: float = 0.2, + sleep: Callable[[float], None] = time.sleep, +) -> str | None: + """Poll `log_path` (cloudflared's captured output) for the tunnel URL. + + Returns the URL once it appears, or None if it hasn't within `timeout` seconds. + `sleep` is injectable so tests don't wait on the wall clock. + """ + deadline = time.monotonic() + timeout + while True: + url = find_url(log_path.read_text()) + if url is not None: + return url + if time.monotonic() >= deadline: + return None + sleep(interval) diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index 5bb3f941..04e157f4 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -185,6 +185,40 @@ def test_launch_and_open_handles_keyboard_interrupt(monkeypatch): assert proc.terminated is True +def test_spawn_inherits_stdio_without_log(monkeypatch): + captured = {} + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return _FakeProc(returncode=0) + + monkeypatch.setattr(runner.subprocess, "Popen", fake_popen) + runner.spawn(["echo", "hi"], cwd=Path("/proj"), env={"A": "B"}) + assert captured["cmd"] == ["echo", "hi"] + assert captured["kwargs"]["cwd"] == Path("/proj") + assert captured["kwargs"]["env"] == {"A": "B"} + assert captured["kwargs"]["text"] is True + assert "stdout" not in captured["kwargs"] # inherited + + +def test_spawn_writes_to_log_when_given(monkeypatch, tmp_path): + captured = {} + + def fake_popen(cmd, **kwargs): + captured["kwargs"] = kwargs + return _FakeProc(returncode=0) + + monkeypatch.setattr(runner.subprocess, "Popen", fake_popen) + log = tmp_path / "cf.log" + runner.spawn(["cloudflared"], cwd=tmp_path, log_path=log) + assert captured["kwargs"]["stderr"] is runner.subprocess.STDOUT + assert captured["kwargs"]["text"] is True + # stdout is an open writable handle to the log file + assert captured["kwargs"]["stdout"].writable() + captured["kwargs"]["stdout"].close() + + def test_run_server_passes_command_and_env(monkeypatch): captured = {} proc = _FakeProc(returncode=0) diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py new file mode 100644 index 00000000..05943fd3 --- /dev/null +++ b/tests/test_tunnel.py @@ -0,0 +1,45 @@ +from aai_cli.init import tunnel + + +def test_tunnel_command_shape(): + assert tunnel.tunnel_command(3000) == [ + "cloudflared", + "tunnel", + "--url", + "http://localhost:3000", + ] + + +def test_find_url_matches_cloudflared_banner(): + line = "2026-06-09T12:00:00Z INF | https://happy-cat-tree.trycloudflare.com |" + assert tunnel.find_url(line) == "https://happy-cat-tree.trycloudflare.com" + + +def test_find_url_none_when_absent(): + assert tunnel.find_url("INF Registered tunnel connection") is None + + +def test_await_url_found_immediately(tmp_path): + log = tmp_path / "cf.log" + log.write_text("starting\nhttps://abc-def.trycloudflare.com\n") + assert tunnel.await_url(log, timeout=5.0) == "https://abc-def.trycloudflare.com" + + +def test_await_url_times_out(tmp_path): + log = tmp_path / "cf.log" + log.write_text("no url yet") + assert tunnel.await_url(log, timeout=0.0) is None + + +def test_await_url_polls_until_written(tmp_path): + log = tmp_path / "cf.log" + log.write_text("") + calls = {"n": 0} + + def fake_sleep(_seconds): + calls["n"] += 1 + log.write_text("https://later-slug.trycloudflare.com") + + url = tunnel.await_url(log, timeout=5.0, sleep=fake_sleep) + assert url == "https://later-slug.trycloudflare.com" + assert calls["n"] == 1 From 261fca2ce84c016743beadb2913bc218b3cfb9f2 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:24:39 -0700 Subject: [PATCH 23/40] feat(share): expose the dev server via a cloudflared tunnel Co-Authored-By: Claude Opus 4.8 (1M context) --- .importlinter | 2 + Formula/aai.rb | 1 + aai_cli/commands/share.py | 125 +++++++++++++++ aai_cli/main.py | 3 + .../test_cli_output_snapshots.ambr | 32 ++++ tests/test_share.py | 143 ++++++++++++++++++ tests/test_smoke.py | 1 + 7 files changed, 307 insertions(+) create mode 100644 aai_cli/commands/share.py create mode 100644 tests/test_share.py diff --git a/.importlinter b/.importlinter index 2712113b..95a077f8 100644 --- a/.importlinter +++ b/.importlinter @@ -38,6 +38,7 @@ modules = aai_cli.commands.account aai_cli.commands.agent aai_cli.commands.audit + aai_cli.commands.dev aai_cli.commands.doctor aai_cli.commands.init aai_cli.commands.keys @@ -45,6 +46,7 @@ modules = aai_cli.commands.login aai_cli.commands.sessions aai_cli.commands.setup + aai_cli.commands.share aai_cli.commands.stream aai_cli.commands.transcribe aai_cli.commands.transcripts diff --git a/Formula/aai.rb b/Formula/aai.rb index 748a722d..61197846 100644 --- a/Formula/aai.rb +++ b/Formula/aai.rb @@ -10,6 +10,7 @@ class Aai < Formula depends_on "pkgconf" => :build # cffi / cryptography native builds depends_on "rust" => :build # pydantic-core, jiter, cryptography + depends_on "cloudflared" # public quick-tunnel for `aai share` depends_on "ffmpeg" # decode non-WAV/URL audio (transcribe/stream) depends_on "openssl@3" # cryptography linkage depends_on "portaudio" # sounddevice (audio capture) diff --git a/aai_cli/commands/share.py b/aai_cli/commands/share.py new file mode 100644 index 00000000..c5f056e3 --- /dev/null +++ b/aai_cli/commands/share.py @@ -0,0 +1,125 @@ +# aai_cli/commands/share.py +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +import typer +from rich.markup import escape + +from aai_cli import help_panels, output, steps +from aai_cli.context import AppState, run_command +from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog +from aai_cli.init import devserver, procfile, runner, tunnel + +# Flattened single-command sub-typer (same pattern as `aai dev`). +app = typer.Typer() + + +def _require_cloudflared() -> None: + if shutil.which(tunnel.CLOUDFLARED) is None: + raise CLIError( + "cloudflared is required to share a public link.", + error_type="missing_dependency", + exit_code=1, + suggestion="Install it: brew install cloudflared", + ) + + +def _render_share(data: dict[str, object]) -> str: + return ( + f"[aai.heading]Sharing[/aai.heading] [aai.url]{escape(str(data['url']))}[/aai.url]\n" + f"[aai.muted]→ serving[/aai.muted] [aai.url]{escape(str(data['local']))}[/aai.url]" + " [aai.muted](Ctrl-C to stop)[/aai.muted]" + ) + + +def _terminate(proc: subprocess.Popen[str] | None) -> None: + if proc is not None and proc.poll() is None: + proc.terminate() + + +def run_share(*, port: int, no_install: bool, json_mode: bool) -> None: + """Boot the app and expose it on a public cloudflared quick-tunnel URL.""" + target = Path.cwd() + use_uv = runner.has_uv() + + chosen_port = runner.find_free_port(port) + env = {**os.environ, "PORT": str(chosen_port)} + web = procfile.web_argv(target, env=env) # validates we're in a scaffolded project + _require_cloudflared() + + report: list[steps.Step] = [ + devserver.install_step(target, no_install=no_install, use_uv=use_uv) + ] + output.emit(report, lambda d: steps.render_steps(d, heading="Share"), json_mode=json_mode) + if any(s["status"] == "failed" for s in report): + raise typer.Exit(code=1) + + server = runner.spawn(devserver.dev_command(target, web, use_uv=use_uv), cwd=target, env=env) + proxy: subprocess.Popen[str] | None = None + try: + if not runner.wait_for_port(chosen_port): + raise CLIError( + "The dev server didn't start, so there's nothing to share.", + error_type="server_error", + exit_code=1, + ) + fd, name = tempfile.mkstemp(prefix="aai-tunnel-", suffix=".log") + os.close(fd) + log_path = Path(name) + proxy = runner.spawn(tunnel.tunnel_command(chosen_port), cwd=target, log_path=log_path) + public = tunnel.await_url(log_path) + if public is None: + raise CLIError( + "cloudflared didn't report a tunnel URL in time.", + error_type="tunnel_error", + exit_code=1, + ) + payload: dict[str, object] = { + "url": public, + "local": f"http://localhost:{chosen_port}", + "port": chosen_port, + } + output.emit(payload, _render_share, json_mode=json_mode) + server.wait() + except KeyboardInterrupt: + pass + finally: + _terminate(proxy) + _terminate(server) + + +@app.command( + rich_help_panel=help_panels.BUILD, + epilog=examples_epilog( + [ + ("Share the running app on a public URL", "aai share"), + ("Use a specific local port", "aai share --port 8000"), + ("Skip the dependency install step", "aai share --no-install"), + ] + ), +) +def share( + ctx: typer.Context, + port: int = typer.Option(3000, "--port", help="Local server port."), + no_install: bool = typer.Option( + False, "--no-install", help="Skip dependency install; launch directly." + ), + json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), +) -> None: + """Boot the app and expose it on a public URL via a cloudflared tunnel. + + Run this from inside a project created by `aai init`. It starts the dev server and + opens a cloudflared quick tunnel, printing a shareable https://*.trycloudflare.com + URL. Requires cloudflared (`brew install cloudflared`). + """ + + def body(_state: AppState, json_mode: bool) -> None: + run_share(port=port, no_install=no_install, json_mode=json_mode) + + run_command(ctx, body, json=json_out) diff --git a/aai_cli/main.py b/aai_cli/main.py index 32c65cc4..a5cdb25b 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -26,6 +26,7 @@ onboard, sessions, setup, + share, stream, transcribe, transcripts, @@ -46,6 +47,7 @@ # Build an App — scaffold a new project "init", "dev", + "share", # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", "stream", @@ -253,6 +255,7 @@ def main( app.add_typer(doctor.app) app.add_typer(init.app) app.add_typer(dev.app) +app.add_typer(share.app) app.add_typer(onboard.app) app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP) app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 13fa1330..46e13c54 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -571,6 +571,38 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[share] + ''' + + Usage: aai share [OPTIONS] + + Boot the app and expose it on a public URL via a cloudflared tunnel. + + Run this from inside a project created by `aai init`. It starts the dev server + and + opens a cloudflared quick tunnel, printing a shareable + https://*.trycloudflare.com + URL. Requires cloudflared (`brew install cloudflared`). + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --port INTEGER Local server port. [default: 3000] │ + │ --no-install Skip dependency install; launch directly. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Share the running app on a public URL + $ aai share + Use a specific local port + $ aai share --port 8000 + Skip the dependency install step + $ aai share --no-install + + + ''' # --- # name: test_command_help_matches_snapshot[stream] diff --git a/tests/test_share.py b/tests/test_share.py new file mode 100644 index 00000000..e8e17fee --- /dev/null +++ b/tests/test_share.py @@ -0,0 +1,143 @@ +import subprocess + +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() +WEB = "web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" + + +def _make_project(tmp_path): + (tmp_path / "Procfile").write_text(WEB) + + +class _FakeProc: + def __init__(self, *, wait_rc=0, poll_rc=None, wait_raises=None): + self._wait_rc = wait_rc + self._poll_rc = poll_rc + self._wait_raises = wait_raises + self.terminated = False + + def wait(self): + if self._wait_raises is not None: + raise self._wait_raises + return self._wait_rc + + def poll(self): + return self._poll_rc + + def terminate(self): + self.terminated = True + + +def _stub( + monkeypatch, + *, + has_cloudflared=True, + setup_rc=0, + port_up=True, + url="https://happy-slug.trycloudflare.com", + server=None, + proxy=None, +): + server = server if server is not None else _FakeProc(poll_rc=0) + proxy = proxy if proxy is not None else _FakeProc(poll_rc=None) + monkeypatch.setattr("aai_cli.init.runner.has_uv", lambda: True) + monkeypatch.setattr("aai_cli.init.runner.find_free_port", lambda p, **k: p) + monkeypatch.setattr( + "aai_cli.init.runner.run_setup", + lambda *a, **k: subprocess.CompletedProcess([], setup_rc, "", "boom"), + ) + monkeypatch.setattr("aai_cli.init.runner.wait_for_port", lambda p, **k: port_up) + monkeypatch.setattr( + "shutil.which", lambda name: "/usr/bin/cloudflared" if has_cloudflared else None + ) + monkeypatch.setattr("aai_cli.init.tunnel.await_url", lambda *a, **k: url) + seq = iter([server, proxy]) + monkeypatch.setattr("aai_cli.init.runner.spawn", lambda *a, **k: next(seq)) + return server, proxy + + +def test_share_prints_public_url(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + server, proxy = _stub(monkeypatch) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 0, result.output + assert "happy-slug.trycloudflare.com" in result.output + assert "localhost:3000" in result.output + # proxy still running (poll None) -> terminated; server already exited (poll 0) -> not + assert proxy.terminated is True + assert server.terminated is False + + +def test_share_missing_cloudflared_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + _stub(monkeypatch, has_cloudflared=False) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 1 + assert "brew install cloudflared" in result.output + + +def test_share_missing_procfile_errors(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _stub(monkeypatch) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 1 + assert "aai init" in result.output + + +def test_share_install_failure_exits(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + _stub(monkeypatch, setup_rc=1) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 1 + assert "boom" in result.output + + +def test_share_server_didnt_start(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + server, _ = _stub(monkeypatch, port_up=False, server=_FakeProc(poll_rc=None)) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 1 + assert server.terminated is True # cleaned up even though tunnel never opened + + +def test_share_no_tunnel_url(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + server, proxy = _stub( + monkeypatch, url=None, server=_FakeProc(poll_rc=None), proxy=_FakeProc(poll_rc=None) + ) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 1 + assert server.terminated is True + assert proxy.terminated is True + + +def test_share_keyboard_interrupt_is_clean(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + server, proxy = _stub( + monkeypatch, + server=_FakeProc(wait_raises=KeyboardInterrupt(), poll_rc=None), + proxy=_FakeProc(poll_rc=None), + ) + result = runner.invoke(app, ["share"]) + assert result.exit_code == 0 + assert server.terminated is True + assert proxy.terminated is True + + +def test_share_json_emits_url(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + _stub(monkeypatch) + result = runner.invoke(app, ["share", "--json"]) + assert result.exit_code == 0, result.output + assert '"url"' in result.output + assert "happy-slug.trycloudflare.com" in result.output diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b467dea3..f718406d 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -77,6 +77,7 @@ def test_help_lists_commands_in_workflow_order(): # Build an App "init", "dev", + "share", # Run AssemblyAI "transcribe", "stream", From 3abdd9980dd3e7aa5c4c5d5da4ab9cc5dd438b7e Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:26:20 -0700 Subject: [PATCH 24/40] test(tunnel): kill await_url default-arg mutation survivors Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/tunnel.py | 2 +- tests/test_tunnel.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/aai_cli/init/tunnel.py b/aai_cli/init/tunnel.py index 27a24bc0..27f45fc1 100644 --- a/aai_cli/init/tunnel.py +++ b/aai_cli/init/tunnel.py @@ -27,7 +27,7 @@ def find_url(text: str) -> str | None: def await_url( log_path: Path, *, - timeout: float = 30.0, + timeout: float = 30.0, # pragma: no mutate -- tuning constant; no unit-observable behavior interval: float = 0.2, sleep: Callable[[float], None] = time.sleep, ) -> str | None: diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py index 05943fd3..18c3d00c 100644 --- a/tests/test_tunnel.py +++ b/tests/test_tunnel.py @@ -43,3 +43,16 @@ def fake_sleep(_seconds): url = tunnel.await_url(log, timeout=5.0, sleep=fake_sleep) assert url == "https://later-slug.trycloudflare.com" assert calls["n"] == 1 + + +def test_await_url_polls_at_default_interval(tmp_path): + log = tmp_path / "cf.log" + log.write_text("") + seen: list[float] = [] + + def fake_sleep(seconds): + seen.append(seconds) + log.write_text("https://now-here.trycloudflare.com") + + tunnel.await_url(log, timeout=5.0, sleep=fake_sleep) + assert seen == [0.2] # the default poll interval was used From 6fcd8387fdaf2a62f2fb1042be227ce832584d35 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:26:42 -0700 Subject: [PATCH 25/40] test(share): annotate _stub url as str | None --- tests/test_share.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_share.py b/tests/test_share.py index e8e17fee..a41ed95c 100644 --- a/tests/test_share.py +++ b/tests/test_share.py @@ -37,7 +37,7 @@ def _stub( has_cloudflared=True, setup_rc=0, port_up=True, - url="https://happy-slug.trycloudflare.com", + url: str | None = "https://happy-slug.trycloudflare.com", server=None, proxy=None, ): From 491af59b3d8f2e02f890bb3af5962531d25a2f3c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:40:36 -0700 Subject: [PATCH 26/40] feat(deploy): add 'aai deploy' wrapping vercel deploy with a confirm prompt Co-Authored-By: Claude Opus 4.8 (1M context) --- .importlinter | 1 + aai_cli/commands/deploy.py | 82 ++++++++++++++ aai_cli/main.py | 3 + .../test_cli_output_snapshots.ambr | 27 +++++ tests/test_deploy.py | 103 ++++++++++++++++++ tests/test_smoke.py | 1 + 6 files changed, 217 insertions(+) create mode 100644 aai_cli/commands/deploy.py create mode 100644 tests/test_deploy.py diff --git a/.importlinter b/.importlinter index 95a077f8..e489e646 100644 --- a/.importlinter +++ b/.importlinter @@ -38,6 +38,7 @@ modules = aai_cli.commands.account aai_cli.commands.agent aai_cli.commands.audit + aai_cli.commands.deploy aai_cli.commands.dev aai_cli.commands.doctor aai_cli.commands.init diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py new file mode 100644 index 00000000..fa802bae --- /dev/null +++ b/aai_cli/commands/deploy.py @@ -0,0 +1,82 @@ +# aai_cli/commands/deploy.py +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import typer + +from aai_cli import help_panels, output +from aai_cli.context import AppState, run_command +from aai_cli.errors import CLIError +from aai_cli.help_text import examples_epilog + +# Flattened single-command sub-typer (same pattern as `aai dev`). +app = typer.Typer() + +VERCEL = "vercel" + + +def _require_vercel() -> None: + if shutil.which(VERCEL) is None: + raise CLIError( + "The Vercel CLI is required to deploy. Install it with `npm i -g vercel`.", + error_type="missing_dependency", + exit_code=1, + ) + + +def _confirmed(*, assume_yes: bool) -> bool: + """True when the deploy should proceed: --yes, or an interactive yes. + + Refuses to guess in a non-interactive/agent session (would otherwise hang or + deploy unintentionally).""" + if assume_yes: + return True + if output.is_agentic(): + raise CLIError( + "Refusing to deploy without confirmation in a non-interactive session. " + "Pass --yes to deploy.", + error_type="usage_error", + exit_code=1, + ) + return typer.confirm("Deploy this project to Vercel?") + + +def run_deploy(*, prod: bool, assume_yes: bool) -> None: + """Confirm, then run `vercel deploy` in the current directory.""" + _require_vercel() + if not _confirmed(assume_yes=assume_yes): + output.console.print("Aborted.") + return + cmd = [VERCEL, "deploy", *(["--prod"] if prod else [])] + result = subprocess.run(cmd, cwd=Path.cwd(), check=False) + if result.returncode: + raise typer.Exit(code=result.returncode) + + +@app.command( + rich_help_panel=help_panels.BUILD, + epilog=examples_epilog( + [ + ("Deploy a preview to Vercel (asks first)", "aai deploy"), + ("Deploy to production without prompting", "aai deploy --prod --yes"), + ] + ), +) +def deploy( + ctx: typer.Context, + prod: bool = typer.Option(False, "--prod", help="Deploy to production."), + assume_yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), +) -> None: + """Deploy the current project to Vercel. + + Asks for confirmation first, then runs `vercel deploy` (pass `--prod` to promote + to production). Requires the Vercel CLI (`npm i -g vercel`). + """ + + def body(_state: AppState, _json_mode: bool) -> None: + run_deploy(prod=prod, assume_yes=assume_yes) + + run_command(ctx, body) diff --git a/aai_cli/main.py b/aai_cli/main.py index a5cdb25b..dab589b8 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -17,6 +17,7 @@ account, agent, audit, + deploy, dev, doctor, init, @@ -48,6 +49,7 @@ "init", "dev", "share", + "deploy", # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", "stream", @@ -256,6 +258,7 @@ def main( app.add_typer(init.app) app.add_typer(dev.app) app.add_typer(share.app) +app.add_typer(deploy.app) app.add_typer(onboard.app) app.add_typer(setup.app, name="setup", rich_help_panel=help_panels.SETUP) app.add_typer(keys.app, name="keys", rich_help_panel=help_panels.ACCOUNT) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 46e13c54..b3f9f03b 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -121,6 +121,33 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[deploy] + ''' + + Usage: aai deploy [OPTIONS] + + Deploy the current project to Vercel. + + Asks for confirmation first, then runs `vercel deploy` (pass `--prod` to + promote + to production). Requires the Vercel CLI (`npm i -g vercel`). + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --prod Deploy to production. │ + │ --yes -y Skip the confirmation prompt. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Deploy a preview to Vercel (asks first) + $ aai deploy + Deploy to production without prompting + $ aai deploy --prod --yes + + + ''' # --- # name: test_command_help_matches_snapshot[dev] diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 00000000..d9a5ae96 --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import types +from typing import Any + +import pytest +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() + + +def _stub( + monkeypatch: pytest.MonkeyPatch, + *, + has_vercel: bool = True, + agentic: bool = False, + confirm: bool = True, + returncode: int = 0, +) -> dict[str, Any]: + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/vercel" if has_vercel else None) + monkeypatch.setattr("aai_cli.output.is_agentic", lambda: agentic) + monkeypatch.setattr("typer.confirm", lambda *a, **k: confirm) + calls: dict[str, Any] = {} + + def fake_run(cmd: list[str], **kwargs: Any) -> types.SimpleNamespace: + calls["cmd"] = cmd + calls["cwd"] = kwargs.get("cwd") + calls["check"] = kwargs.get("check") + return types.SimpleNamespace(returncode=returncode) + + monkeypatch.setattr("aai_cli.commands.deploy.subprocess.run", fake_run) + return calls + + +def test_deploy_missing_vercel_errors(monkeypatch: pytest.MonkeyPatch) -> None: + _stub(monkeypatch, has_vercel=False) + result = runner.invoke(app, ["deploy"]) + assert result.exit_code == 1 + assert "npm i -g vercel" in result.output + + +def test_deploy_confirm_no_aborts(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, agentic=False, confirm=False) + result = runner.invoke(app, ["deploy"]) + assert result.exit_code == 0, result.output + assert "Aborted" in result.output + assert calls == {} # vercel never invoked + + +def test_deploy_confirm_yes_runs(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, agentic=False, confirm=True) + result = runner.invoke(app, ["deploy"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["vercel", "deploy"] + + +def test_deploy_yes_flag_skips_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + # confirm=False would abort if the prompt were consulted; --yes must bypass it. + calls = _stub(monkeypatch, agentic=False, confirm=False) + result = runner.invoke(app, ["deploy", "--yes"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["vercel", "deploy"] + + +def test_deploy_prod_flag(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, confirm=True) + result = runner.invoke(app, ["deploy", "--yes", "--prod"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["vercel", "deploy", "--prod"] + + +def test_deploy_runs_in_cwd(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, confirm=True) + result = runner.invoke(app, ["deploy", "--yes"]) + assert result.exit_code == 0, result.output + from pathlib import Path + + assert calls["cwd"] == Path.cwd() + # We handle the exit code ourselves; subprocess must not raise on failure. + assert calls["check"] is False + + +def test_deploy_nonzero_exit_propagates(monkeypatch: pytest.MonkeyPatch) -> None: + _stub(monkeypatch, confirm=True, returncode=2) + result = runner.invoke(app, ["deploy", "--yes"]) + assert result.exit_code == 2 + + +def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, agentic=True, confirm=True) + result = runner.invoke(app, ["deploy"]) + assert result.exit_code == 1 + assert "--yes" in result.output + assert calls == {} # never deployed + + +@pytest.mark.parametrize("flag", ["--prod", "--yes"]) +def test_deploy_help_lists_flags(flag: str) -> None: + result = runner.invoke(app, ["deploy", "--help"]) + assert result.exit_code == 0 + assert flag in result.output diff --git a/tests/test_smoke.py b/tests/test_smoke.py index f718406d..e337af0e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -78,6 +78,7 @@ def test_help_lists_commands_in_workflow_order(): "init", "dev", "share", + "deploy", # Run AssemblyAI "transcribe", "stream", From 7a17e0516049e35636c2c4d1577040c9d7c597f3 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 12:53:45 -0700 Subject: [PATCH 27/40] feat(deploy): add --railway target; --vercel default, no formula deps Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/deploy.py | 70 ++++++--- .../test_cli_output_snapshots.ambr | 19 +-- tests/test_deploy.py | 136 +++++++++++++----- 3 files changed, 160 insertions(+), 65 deletions(-) diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py index fa802bae..0db54b2c 100644 --- a/aai_cli/commands/deploy.py +++ b/aai_cli/commands/deploy.py @@ -3,6 +3,7 @@ import shutil import subprocess +from dataclasses import dataclass from pathlib import Path import typer @@ -15,23 +16,46 @@ # Flattened single-command sub-typer (same pattern as `aai dev`). app = typer.Typer() -VERCEL = "vercel" +@dataclass(frozen=True) +class Target: + name: str # human label, e.g. "Vercel" + bin: str # executable resolved via shutil.which + install: str # install hint shown when the CLI is missing -def _require_vercel() -> None: - if shutil.which(VERCEL) is None: + def command(self, *, prod: bool) -> list[str]: + if self.bin == "vercel": + return ["vercel", "deploy", *(["--prod"] if prod else [])] + return ["railway", "up"] # Railway has no preview/prod split here + + +VERCEL = Target(name="Vercel", bin="vercel", install="npm i -g vercel") +RAILWAY = Target(name="Railway", bin="railway", install="npm i -g @railway/cli") + + +def _resolve_target(*, vercel: bool, railway: bool) -> Target: + if vercel and railway: raise CLIError( - "The Vercel CLI is required to deploy. Install it with `npm i -g vercel`.", + "Pass either --vercel or --railway, not both.", + error_type="usage_error", + exit_code=1, + ) + return RAILWAY if railway else VERCEL # Vercel is the default + + +def _require_cli(target: Target) -> None: + if shutil.which(target.bin) is None: + raise CLIError( + f"The {target.name} CLI is required to deploy. Install it with `{target.install}`.", error_type="missing_dependency", exit_code=1, ) -def _confirmed(*, assume_yes: bool) -> bool: +def _confirmed(target: Target, *, assume_yes: bool) -> bool: """True when the deploy should proceed: --yes, or an interactive yes. - Refuses to guess in a non-interactive/agent session (would otherwise hang or - deploy unintentionally).""" + Refuses to guess in a non-interactive/agent session.""" if assume_yes: return True if output.is_agentic(): @@ -41,17 +65,16 @@ def _confirmed(*, assume_yes: bool) -> bool: error_type="usage_error", exit_code=1, ) - return typer.confirm("Deploy this project to Vercel?") + return typer.confirm(f"Deploy this project to {target.name}?") -def run_deploy(*, prod: bool, assume_yes: bool) -> None: - """Confirm, then run `vercel deploy` in the current directory.""" - _require_vercel() - if not _confirmed(assume_yes=assume_yes): +def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None: + """Confirm, then run the target's deploy command in the current directory.""" + _require_cli(target) + if not _confirmed(target, assume_yes=assume_yes): output.console.print("Aborted.") return - cmd = [VERCEL, "deploy", *(["--prod"] if prod else [])] - result = subprocess.run(cmd, cwd=Path.cwd(), check=False) + result = subprocess.run(target.command(prod=prod), cwd=Path.cwd(), check=False) if result.returncode: raise typer.Exit(code=result.returncode) @@ -61,22 +84,29 @@ def run_deploy(*, prod: bool, assume_yes: bool) -> None: epilog=examples_epilog( [ ("Deploy a preview to Vercel (asks first)", "aai deploy"), - ("Deploy to production without prompting", "aai deploy --prod --yes"), + ("Deploy to production on Vercel", "aai deploy --prod --yes"), + ("Deploy to Railway", "aai deploy --railway"), ] ), ) def deploy( ctx: typer.Context, - prod: bool = typer.Option(False, "--prod", help="Deploy to production."), + prod: bool = typer.Option(False, "--prod", help="Deploy to production (Vercel only)."), + vercel: bool = typer.Option(False, "--vercel", help="Deploy to Vercel (the default)."), + railway: bool = typer.Option(False, "--railway", help="Deploy to Railway."), assume_yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), ) -> None: - """Deploy the current project to Vercel. + """Deploy the current project to Vercel (default) or Railway. - Asks for confirmation first, then runs `vercel deploy` (pass `--prod` to promote - to production). Requires the Vercel CLI (`npm i -g vercel`). + Asks for confirmation first, then runs the target's CLI (`vercel deploy` or + `railway up`). Requires that target's CLI to be installed. """ def body(_state: AppState, _json_mode: bool) -> None: - run_deploy(prod=prod, assume_yes=assume_yes) + run_deploy( + target=_resolve_target(vercel=vercel, railway=railway), + prod=prod, + assume_yes=assume_yes, + ) run_command(ctx, body) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index b3f9f03b..b2460d05 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -128,23 +128,26 @@ Usage: aai deploy [OPTIONS] - Deploy the current project to Vercel. + Deploy the current project to Vercel (default) or Railway. - Asks for confirmation first, then runs `vercel deploy` (pass `--prod` to - promote - to production). Requires the Vercel CLI (`npm i -g vercel`). + Asks for confirmation first, then runs the target's CLI (`vercel deploy` or + `railway up`). Requires that target's CLI to be installed. ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --prod Deploy to production. │ - │ --yes -y Skip the confirmation prompt. │ - │ --help Show this message and exit. │ + │ --prod Deploy to production (Vercel only). │ + │ --vercel Deploy to Vercel (the default). │ + │ --railway Deploy to Railway. │ + │ --yes -y Skip the confirmation prompt. │ + │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Examples Deploy a preview to Vercel (asks first) $ aai deploy - Deploy to production without prompting + Deploy to production on Vercel $ aai deploy --prod --yes + Deploy to Railway + $ aai deploy --railway diff --git a/tests/test_deploy.py b/tests/test_deploy.py index d9a5ae96..ff5e8bd6 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1,30 +1,53 @@ from __future__ import annotations +import dataclasses import types -from typing import Any +from collections.abc import Sequence +from pathlib import Path import pytest from typer.testing import CliRunner +from aai_cli.commands.deploy import RAILWAY, VERCEL, Target from aai_cli.main import app runner = CliRunner() +def test_targets_are_frozen() -> None: + # The default and Railway targets are module-level singletons; freezing them + # guards against accidental in-place mutation of shared deploy config. + # Route the assignment through an object-typed alias and a runtime attribute + # name so the frozen-ness is checked at runtime, not statically. + field = "name" + for target in (VERCEL, RAILWAY): + opaque: object = target + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(opaque, field, "tampered") + assert isinstance(VERCEL, Target) + + def _stub( monkeypatch: pytest.MonkeyPatch, *, - has_vercel: bool = True, + available: Sequence[str] = ("vercel",), agentic: bool = False, confirm: bool = True, returncode: int = 0, -) -> dict[str, Any]: - monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/vercel" if has_vercel else None) +) -> dict[str, object]: + monkeypatch.setattr( + "shutil.which", lambda name: f"/usr/bin/{name}" if name in available else None + ) monkeypatch.setattr("aai_cli.output.is_agentic", lambda: agentic) - monkeypatch.setattr("typer.confirm", lambda *a, **k: confirm) - calls: dict[str, Any] = {} + calls: dict[str, object] = {} + + def fake_confirm(prompt: str, *a: object, **k: object) -> bool: + calls["prompt"] = prompt + return confirm - def fake_run(cmd: list[str], **kwargs: Any) -> types.SimpleNamespace: + monkeypatch.setattr("typer.confirm", fake_confirm) + + def fake_run(cmd: list[str], **kwargs: object) -> types.SimpleNamespace: calls["cmd"] = cmd calls["cwd"] = kwargs.get("cwd") calls["check"] = kwargs.get("check") @@ -34,69 +57,108 @@ def fake_run(cmd: list[str], **kwargs: Any) -> types.SimpleNamespace: return calls -def test_deploy_missing_vercel_errors(monkeypatch: pytest.MonkeyPatch) -> None: - _stub(monkeypatch, has_vercel=False) +def test_deploy_defaults_to_vercel(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel",)) result = runner.invoke(app, ["deploy"]) - assert result.exit_code == 1 - assert "npm i -g vercel" in result.output + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["vercel", "deploy"] + assert calls["check"] is False + assert calls["cwd"] == Path.cwd() + assert calls["prompt"] == "Deploy this project to Vercel?" -def test_deploy_confirm_no_aborts(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, agentic=False, confirm=False) - result = runner.invoke(app, ["deploy"]) +def test_deploy_vercel_flag(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel",)) + result = runner.invoke(app, ["deploy", "--vercel", "--yes"]) assert result.exit_code == 0, result.output - assert "Aborted" in result.output - assert calls == {} # vercel never invoked + assert calls["cmd"] == ["vercel", "deploy"] + + +def test_deploy_railway_flag(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("railway",)) + result = runner.invoke(app, ["deploy", "--railway", "--yes"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["railway", "up"] + + +def test_deploy_railway_prompt_names_railway(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("railway",), confirm=False) + result = runner.invoke(app, ["deploy", "--railway"]) + assert result.exit_code == 0, result.output + assert calls["prompt"] == "Deploy this project to Railway?" + assert "cmd" not in calls # declined + + +def test_deploy_both_targets_errors(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel", "railway")) + result = runner.invoke(app, ["deploy", "--vercel", "--railway", "--yes"]) + assert result.exit_code == 1 + assert "not both" in result.output + assert "cmd" not in calls # never deployed + +def test_deploy_missing_vercel_errors(monkeypatch: pytest.MonkeyPatch) -> None: + _stub(monkeypatch, available=()) + result = runner.invoke(app, ["deploy", "--yes"]) + assert result.exit_code == 1 + assert "Vercel CLI" in result.output + assert "npm i -g vercel" in " ".join(result.output.split()) -def test_deploy_confirm_yes_runs(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, agentic=False, confirm=True) + +def test_deploy_missing_railway_errors(monkeypatch: pytest.MonkeyPatch) -> None: + _stub(monkeypatch, available=()) + result = runner.invoke(app, ["deploy", "--railway", "--yes"]) + assert result.exit_code == 1 + assert "Railway CLI" in result.output + # Console may soft-wrap the hint, so normalize whitespace before matching. + assert "npm i -g @railway/cli" in " ".join(result.output.split()) + + +def test_deploy_confirm_no_aborts(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel",), confirm=False) result = runner.invoke(app, ["deploy"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["vercel", "deploy"] + assert "Aborted" in result.output + assert "cmd" not in calls -def test_deploy_yes_flag_skips_prompt(monkeypatch: pytest.MonkeyPatch) -> None: - # confirm=False would abort if the prompt were consulted; --yes must bypass it. - calls = _stub(monkeypatch, agentic=False, confirm=False) +def test_deploy_yes_skips_prompt(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel",), confirm=False) result = runner.invoke(app, ["deploy", "--yes"]) assert result.exit_code == 0, result.output assert calls["cmd"] == ["vercel", "deploy"] + assert "prompt" not in calls # --yes bypassed typer.confirm -def test_deploy_prod_flag(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, confirm=True) - result = runner.invoke(app, ["deploy", "--yes", "--prod"]) +def test_deploy_prod_flag_vercel(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel",)) + result = runner.invoke(app, ["deploy", "--prod", "--yes"]) assert result.exit_code == 0, result.output assert calls["cmd"] == ["vercel", "deploy", "--prod"] -def test_deploy_runs_in_cwd(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, confirm=True) - result = runner.invoke(app, ["deploy", "--yes"]) +def test_deploy_prod_ignored_for_railway(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("railway",)) + result = runner.invoke(app, ["deploy", "--railway", "--prod", "--yes"]) assert result.exit_code == 0, result.output - from pathlib import Path - - assert calls["cwd"] == Path.cwd() - # We handle the exit code ourselves; subprocess must not raise on failure. - assert calls["check"] is False + assert calls["cmd"] == ["railway", "up"] def test_deploy_nonzero_exit_propagates(monkeypatch: pytest.MonkeyPatch) -> None: - _stub(monkeypatch, confirm=True, returncode=2) + _stub(monkeypatch, available=("vercel",), returncode=2) result = runner.invoke(app, ["deploy", "--yes"]) assert result.exit_code == 2 def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, agentic=True, confirm=True) + calls = _stub(monkeypatch, available=("vercel",), agentic=True) result = runner.invoke(app, ["deploy"]) assert result.exit_code == 1 assert "--yes" in result.output - assert calls == {} # never deployed + assert "cmd" not in calls -@pytest.mark.parametrize("flag", ["--prod", "--yes"]) +@pytest.mark.parametrize("flag", ["--prod", "--vercel", "--railway", "--yes"]) def test_deploy_help_lists_flags(flag: str) -> None: result = runner.invoke(app, ["deploy", "--help"]) assert result.exit_code == 0 From 2b5e2e22829b36e264a032e96f0cf478c0920379 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 13:21:54 -0700 Subject: [PATCH 28/40] fix(templates): serve front-end from static/ not public/ so Vercel deploys don't crash public/ is Vercel's reserved CDN output dir and is omitted from the Python lambda, so StaticFiles(directory=public/static) raised at import -> FUNCTION_INVOCATION_FAILED. Move assets to a bundled static/ dir FastAPI owns; add a contract test forbidding public/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/audio-transcription/AGENTS.md | 14 +++---- .../templates/audio-transcription/README.md | 7 ++-- .../audio-transcription/api/index.py | 8 ++-- .../{public => }/static/app.js | 0 .../{public => static}/index.html | 0 .../{public => }/static/styles.css | 0 .../init/templates/live-captions/AGENTS.md | 18 ++++----- .../init/templates/live-captions/README.md | 7 ++-- .../init/templates/live-captions/api/index.py | 6 +-- .../live-captions/{public => }/static/app.js | 0 .../{public => }/static/audio.js | 0 .../{public => static}/index.html | 0 .../{public => }/static/styles.css | 0 aai_cli/init/templates/voice-agent/AGENTS.md | 18 ++++----- aai_cli/init/templates/voice-agent/README.md | 9 +++-- .../init/templates/voice-agent/api/index.py | 6 +-- .../voice-agent/{public => }/static/app.js | 0 .../voice-agent/{public => }/static/audio.js | 0 .../voice-agent/{public => static}/index.html | 0 .../{public => }/static/styles.css | 0 scripts/template_contract_gate.py | 25 ++++++------- tests/test_init_scaffold.py | 2 +- tests/test_init_template_agent.py | 2 +- tests/test_init_template_contract.py | 37 +++++++++++-------- tests/test_init_template_serve.py | 2 +- tests/test_init_template_transcribe.py | 10 ++--- 26 files changed, 89 insertions(+), 82 deletions(-) rename aai_cli/init/templates/audio-transcription/{public => }/static/app.js (100%) rename aai_cli/init/templates/audio-transcription/{public => static}/index.html (100%) rename aai_cli/init/templates/audio-transcription/{public => }/static/styles.css (100%) rename aai_cli/init/templates/live-captions/{public => }/static/app.js (100%) rename aai_cli/init/templates/live-captions/{public => }/static/audio.js (100%) rename aai_cli/init/templates/live-captions/{public => static}/index.html (100%) rename aai_cli/init/templates/live-captions/{public => }/static/styles.css (100%) rename aai_cli/init/templates/voice-agent/{public => }/static/app.js (100%) rename aai_cli/init/templates/voice-agent/{public => }/static/audio.js (100%) rename aai_cli/init/templates/voice-agent/{public => static}/index.html (100%) rename aai_cli/init/templates/voice-agent/{public => }/static/styles.css (100%) diff --git a/aai_cli/init/templates/audio-transcription/AGENTS.md b/aai_cli/init/templates/audio-transcription/AGENTS.md index 55dc289b..b199bffe 100644 --- a/aai_cli/init/templates/audio-transcription/AGENTS.md +++ b/aai_cli/init/templates/audio-transcription/AGENTS.md @@ -10,22 +10,22 @@ aai dev - `api/settings.py`: backend customization for AssemblyAI config, sample URL, and LLM Gateway model. - `api/index.py`: server routes. Keep `ASSEMBLYAI_API_KEY` here on the server. -- `public/static/app.js`: browser workflow, polling, tab rendering, and transcript Q&A UI. -- `public/static/styles.css`: visual styling only; the top `:root` block is the primary theme/layout edit point. -- `public/index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. +- `static/app.js`: browser workflow, polling, tab rendering, and transcript Q&A UI. +- `static/styles.css`: visual styling only; the top `:root` block is the primary theme/layout edit point. +- `static/index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. ## Change Points - Transcription features: edit `TRANSCRIPTION_CONFIG_KWARGS` in `api/settings.py`. -- Sample audio URL: edit `SAMPLE_URL` in `api/settings.py` and the matching input value in `public/index.html`. +- Sample audio URL: edit `SAMPLE_URL` in `api/settings.py` and the matching input value in `static/index.html`. - LLM answer behavior: edit `LLM_MODEL` in `api/settings.py` or the `/api/ask` prompt in `api/index.py`. -- Transcript display: edit renderer functions in `public/static/app.js`. -- Visual theme/layout: edit the monotone Vercel-style tokens in `public/static/styles.css` before changing component rules. +- Transcript display: edit renderer functions in `static/app.js`. +- Visual theme/layout: edit the monotone Vercel-style tokens in `static/styles.css` before changing component rules. - UI state styling: status, tabs, and sentiment use `data-state`, `.is-active`, or `data-sentiment`; prefer CSS changes over JS class rewrites. ## Invariants -- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `public/index.html` or `public/static/`. +- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `static/index.html` or `static/`. - Keep every browser `fetch("/api/...")` route registered in `api/index.py`. - Keep `/api/status/{transcript_id}` non-blocking; do not use SDK helpers that wait for completion in that polling route. - Keep the app buildless unless the user explicitly asks for a frontend toolchain. diff --git a/aai_cli/init/templates/audio-transcription/README.md b/aai_cli/init/templates/audio-transcription/README.md index 18b14a42..3657ba52 100644 --- a/aai_cli/init/templates/audio-transcription/README.md +++ b/aai_cli/init/templates/audio-transcription/README.md @@ -16,8 +16,9 @@ aai dev # installs deps if needed, starts the server, opens http://localhost:3 Push this folder to a Git repo and import it on Vercel. Set `ASSEMBLYAI_API_KEY` as a Vercel environment variable (the local `.env` is git-ignored and not deployed). -No extra config is needed: Vercel serves the static page and discovers the -FastAPI app in `api/index.py`. +No extra config is needed (no `vercel.json`): Vercel runs `api/index.py` as the +function, and that FastAPI app serves both the page and assets (from `static/`) +and the API. ## Deploy elsewhere @@ -35,4 +36,4 @@ uvicorn api.index:app --host 0.0.0.0 --port $PORT - Show chapter summaries and highlight timestamps. - Add a waveform / audio player synced to the transcript. - Swap the analysis features in `TRANSCRIPTION_CONFIG_KWARGS` (`api/settings.py`). -- Change transcript rendering in `public/static/app.js`. +- Change transcript rendering in `static/app.js`. diff --git a/aai_cli/init/templates/audio-transcription/api/index.py b/aai_cli/init/templates/audio-transcription/api/index.py index fbe7389a..2f999333 100644 --- a/aai_cli/init/templates/audio-transcription/api/index.py +++ b/aai_cli/init/templates/audio-transcription/api/index.py @@ -6,7 +6,7 @@ GET /api/status/{id} -> poll; returns the full transcript JSON when complete POST /api/ask -> ask a question about a transcript via the LLM Gateway -The browser (public/index.html + public/static/app.js) submits a URL or file, then +The browser (static/index.html + static/app.js) submits a URL or file, then polls status. Your API key stays on the server — the browser never sees it. """ @@ -36,14 +36,14 @@ CONFIG = aai.TranscriptionConfig(**settings.TRANSCRIPTION_CONFIG_KWARGS) ROOT = Path(__file__).resolve().parent.parent -PUBLIC = ROOT / "public" +STATIC = ROOT / "static" app = FastAPI() -app.mount("/static", StaticFiles(directory=PUBLIC / "static"), name="static") +app.mount("/static", StaticFiles(directory=STATIC), name="static") @app.get("/") def index() -> FileResponse: - return FileResponse(PUBLIC / "index.html") + return FileResponse(STATIC / "index.html") def _submit(audio: str) -> dict[str, str]: diff --git a/aai_cli/init/templates/audio-transcription/public/static/app.js b/aai_cli/init/templates/audio-transcription/static/app.js similarity index 100% rename from aai_cli/init/templates/audio-transcription/public/static/app.js rename to aai_cli/init/templates/audio-transcription/static/app.js diff --git a/aai_cli/init/templates/audio-transcription/public/index.html b/aai_cli/init/templates/audio-transcription/static/index.html similarity index 100% rename from aai_cli/init/templates/audio-transcription/public/index.html rename to aai_cli/init/templates/audio-transcription/static/index.html diff --git a/aai_cli/init/templates/audio-transcription/public/static/styles.css b/aai_cli/init/templates/audio-transcription/static/styles.css similarity index 100% rename from aai_cli/init/templates/audio-transcription/public/static/styles.css rename to aai_cli/init/templates/audio-transcription/static/styles.css diff --git a/aai_cli/init/templates/live-captions/AGENTS.md b/aai_cli/init/templates/live-captions/AGENTS.md index 8ad934ce..8415db15 100644 --- a/aai_cli/init/templates/live-captions/AGENTS.md +++ b/aai_cli/init/templates/live-captions/AGENTS.md @@ -10,23 +10,23 @@ aai dev - `api/settings.py`: backend token host, token path, WebSocket path, and token expiry. - `api/index.py`: `/api/token` route. Keep `ASSEMBLYAI_API_KEY` here on the server. -- `public/static/app.js`: browser state, WebSocket lifecycle, and Streaming API params. -- `public/static/audio.js`: microphone pipeline and PCM downsampling helpers. -- `public/static/styles.css`: visual styling only; the top `:root` block is the primary theme/layout edit point. -- `public/index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. +- `static/app.js`: browser state, WebSocket lifecycle, and Streaming API params. +- `static/audio.js`: microphone pipeline and PCM downsampling helpers. +- `static/styles.css`: visual styling only; the top `:root` block is the primary theme/layout edit point. +- `static/index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. ## Change Points -- Streaming model, sample rate, encoding, and turn formatting: edit `STREAMING_CONFIG` in `public/static/app.js`. +- Streaming model, sample rate, encoding, and turn formatting: edit `STREAMING_CONFIG` in `static/app.js`. - Backend token lifetime or non-production hosts: edit `api/settings.py`. -- Caption rendering: edit `onMessage` in `public/static/app.js`. -- Microphone/downsampling behavior: edit `public/static/audio.js`. -- Visual theme/layout: edit the monotone Vercel-style tokens in `public/static/styles.css` before changing component rules. +- Caption rendering: edit `onMessage` in `static/app.js`. +- Microphone/downsampling behavior: edit `static/audio.js`. +- Visual theme/layout: edit the monotone Vercel-style tokens in `static/styles.css` before changing component rules. - UI state styling: record and status state use `data-state`; prefer CSS changes over JS class rewrites. ## Invariants -- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `public/index.html` or `public/static/`. +- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `static/index.html` or `static/`. - Streaming token auth uses the raw API key in the backend `Authorization` header, not `Bearer`. - Keep the browser connected directly to AssemblyAI; do not proxy the audio stream through FastAPI unless the user asks. - Keep the app buildless unless the user explicitly asks for a frontend toolchain. diff --git a/aai_cli/init/templates/live-captions/README.md b/aai_cli/init/templates/live-captions/README.md index 95683427..1942d820 100644 --- a/aai_cli/init/templates/live-captions/README.md +++ b/aai_cli/init/templates/live-captions/README.md @@ -17,9 +17,10 @@ aai dev # opens http://localhost:3000 (allow microphone access) ## Deploy to Vercel Push this folder to a Git repo and import it on Vercel. Set `ASSEMBLYAI_API_KEY` as a -Vercel environment variable (the local `.env` is git-ignored). The backend is just the -`/api/token` function; the WebSocket runs browser → AssemblyAI, so nothing long-running -is needed. +Vercel environment variable (the local `.env` is git-ignored). No extra config is needed +(no `vercel.json`): Vercel runs `api/index.py` as the function, and that FastAPI app +serves the page and assets (from `static/`) plus the `/api/token` route. The WebSocket +runs browser → AssemblyAI, so nothing long-running is needed. ## Deploy elsewhere diff --git a/aai_cli/init/templates/live-captions/api/index.py b/aai_cli/init/templates/live-captions/api/index.py index 22d7b634..071a8ad4 100644 --- a/aai_cli/init/templates/live-captions/api/index.py +++ b/aai_cli/init/templates/live-captions/api/index.py @@ -21,14 +21,14 @@ from api import settings ROOT = Path(__file__).resolve().parent.parent -PUBLIC = ROOT / "public" +STATIC = ROOT / "static" app = FastAPI() -app.mount("/static", StaticFiles(directory=PUBLIC / "static"), name="static") +app.mount("/static", StaticFiles(directory=STATIC), name="static") @app.get("/") def index() -> FileResponse: - return FileResponse(PUBLIC / "index.html") + return FileResponse(STATIC / "index.html") @app.post("/api/token") diff --git a/aai_cli/init/templates/live-captions/public/static/app.js b/aai_cli/init/templates/live-captions/static/app.js similarity index 100% rename from aai_cli/init/templates/live-captions/public/static/app.js rename to aai_cli/init/templates/live-captions/static/app.js diff --git a/aai_cli/init/templates/live-captions/public/static/audio.js b/aai_cli/init/templates/live-captions/static/audio.js similarity index 100% rename from aai_cli/init/templates/live-captions/public/static/audio.js rename to aai_cli/init/templates/live-captions/static/audio.js diff --git a/aai_cli/init/templates/live-captions/public/index.html b/aai_cli/init/templates/live-captions/static/index.html similarity index 100% rename from aai_cli/init/templates/live-captions/public/index.html rename to aai_cli/init/templates/live-captions/static/index.html diff --git a/aai_cli/init/templates/live-captions/public/static/styles.css b/aai_cli/init/templates/live-captions/static/styles.css similarity index 100% rename from aai_cli/init/templates/live-captions/public/static/styles.css rename to aai_cli/init/templates/live-captions/static/styles.css diff --git a/aai_cli/init/templates/voice-agent/AGENTS.md b/aai_cli/init/templates/voice-agent/AGENTS.md index 2d57d262..817fac1b 100644 --- a/aai_cli/init/templates/voice-agent/AGENTS.md +++ b/aai_cli/init/templates/voice-agent/AGENTS.md @@ -10,23 +10,23 @@ aai dev - `api/settings.py`: backend token host, token path, WebSocket path, and token expiry. - `api/index.py`: `/api/token` route. Keep `ASSEMBLYAI_API_KEY` here on the server. -- `public/static/app.js`: Voice Agent session config, WebSocket lifecycle, UI state, and event handling. -- `public/static/audio.js`: microphone pipeline, PCM conversion, playback queue, and barge-in helpers. -- `public/static/styles.css`: visual styling only; the top `:root` block is the primary theme/layout edit point. -- `public/index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. +- `static/app.js`: Voice Agent session config, WebSocket lifecycle, UI state, and event handling. +- `static/audio.js`: microphone pipeline, PCM conversion, playback queue, and barge-in helpers. +- `static/styles.css`: visual styling only; the top `:root` block is the primary theme/layout edit point. +- `static/index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. ## Change Points -- Agent prompt, greeting, voice, audio formats, and microphone constraints: edit `SESSION_CONFIG` in `public/static/app.js`. +- Agent prompt, greeting, voice, audio formats, and microphone constraints: edit `SESSION_CONFIG` in `static/app.js`. - Backend token lifetime or non-production hosts: edit `api/settings.py`. -- Transcript log rendering: edit `addTurn` in `public/static/app.js`. -- Playback, barge-in, or PCM conversion: edit `public/static/audio.js`. -- Visual theme/layout: edit the monotone Vercel-style tokens in `public/static/styles.css` before changing component rules. +- Transcript log rendering: edit `addTurn` in `static/app.js`. +- Playback, barge-in, or PCM conversion: edit `static/audio.js`. +- Visual theme/layout: edit the monotone Vercel-style tokens in `static/styles.css` before changing component rules. - UI state styling: connection, status, and speaker state use `data-state` or `data-speaker`; prefer CSS changes over JS class rewrites. ## Invariants -- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `public/index.html` or `public/static/`. +- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `static/index.html` or `static/`. - Voice Agent token auth uses `Authorization: Bearer ...` in the backend. This differs from Streaming token auth. - Voice Agent `greeting` is spoken literally by TTS; write the exact words the user should hear. - `reply.audio` carries base64 PCM on the `data` field. diff --git a/aai_cli/init/templates/voice-agent/README.md b/aai_cli/init/templates/voice-agent/README.md index 2735888f..a999c604 100644 --- a/aai_cli/init/templates/voice-agent/README.md +++ b/aai_cli/init/templates/voice-agent/README.md @@ -18,9 +18,10 @@ The Voice Agent API requires a plan with access enabled. ## Deploy to Vercel Push this folder to a Git repo and import it on Vercel. Set `ASSEMBLYAI_API_KEY` as a -Vercel environment variable (the local `.env` is git-ignored). The backend is just the -`/api/token` function; the WebSocket runs browser → AssemblyAI, so nothing long-running -is needed. +Vercel environment variable (the local `.env` is git-ignored). No extra config is needed +(no `vercel.json`): Vercel runs `api/index.py` as the function, and that FastAPI app +serves the page and assets (from `static/`) plus the `/api/token` route. The WebSocket +runs browser → AssemblyAI, so nothing long-running is needed. ## Deploy elsewhere @@ -35,6 +36,6 @@ uvicorn api.index:app --host 0.0.0.0 --port $PORT ## Ideas to extend -- Change the `greeting`, `systemPrompt`, or `voice` in `SESSION_CONFIG` (`public/static/app.js`). +- Change the `greeting`, `systemPrompt`, or `voice` in `SESSION_CONFIG` (`static/app.js`). - Add tools (function calling) so the agent can look things up or take actions. - Tune `input.turn_detection` (`min_silence`/`max_silence`) inside `SESSION_CONFIG`. diff --git a/aai_cli/init/templates/voice-agent/api/index.py b/aai_cli/init/templates/voice-agent/api/index.py index 26e6473d..e6f176a4 100644 --- a/aai_cli/init/templates/voice-agent/api/index.py +++ b/aai_cli/init/templates/voice-agent/api/index.py @@ -21,14 +21,14 @@ from api import settings ROOT = Path(__file__).resolve().parent.parent -PUBLIC = ROOT / "public" +STATIC = ROOT / "static" app = FastAPI() -app.mount("/static", StaticFiles(directory=PUBLIC / "static"), name="static") +app.mount("/static", StaticFiles(directory=STATIC), name="static") @app.get("/") def index() -> FileResponse: - return FileResponse(PUBLIC / "index.html") + return FileResponse(STATIC / "index.html") @app.post("/api/token") diff --git a/aai_cli/init/templates/voice-agent/public/static/app.js b/aai_cli/init/templates/voice-agent/static/app.js similarity index 100% rename from aai_cli/init/templates/voice-agent/public/static/app.js rename to aai_cli/init/templates/voice-agent/static/app.js diff --git a/aai_cli/init/templates/voice-agent/public/static/audio.js b/aai_cli/init/templates/voice-agent/static/audio.js similarity index 100% rename from aai_cli/init/templates/voice-agent/public/static/audio.js rename to aai_cli/init/templates/voice-agent/static/audio.js diff --git a/aai_cli/init/templates/voice-agent/public/index.html b/aai_cli/init/templates/voice-agent/static/index.html similarity index 100% rename from aai_cli/init/templates/voice-agent/public/index.html rename to aai_cli/init/templates/voice-agent/static/index.html diff --git a/aai_cli/init/templates/voice-agent/public/static/styles.css b/aai_cli/init/templates/voice-agent/static/styles.css similarity index 100% rename from aai_cli/init/templates/voice-agent/public/static/styles.css rename to aai_cli/init/templates/voice-agent/static/styles.css diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index e04c52a2..55340ef0 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -20,9 +20,9 @@ "api/index.py", "api/__init__.py", "api/settings.py", - "public/index.html", - "public/static/app.js", - "public/static/styles.css", + "static/index.html", + "static/app.js", + "static/styles.css", "requirements.txt", "README.md", "AGENTS.md", @@ -57,27 +57,24 @@ def _required_files(template: str, path: Path) -> None: for rel in _REQUIRED_FILES: if not (path / rel).exists(): _fail(f"{template}: missing {rel}") - if ( - template in {"live-captions", "voice-agent"} - and not (path / "public/static/audio.js").exists() - ): - _fail(f"{template}: missing public/static/audio.js") + if template in {"live-captions", "voice-agent"} and not (path / "static/audio.js").exists(): + _fail(f"{template}: missing static/audio.js") def _html_static_refs(template: str, path: Path) -> None: - html = (path / "public/index.html").read_text(encoding="utf-8") + html = (path / "static/index.html").read_text(encoding="utf-8") refs = set(re.findall(r'(?:href|src)=["\'](/static/[^"\']+)', html)) if not refs: - _fail(f"{template}: public/index.html should load static assets") + _fail(f"{template}: static/index.html should load static assets") for ref in refs: - if not (path / "public" / ref.lstrip("/")).exists(): - _fail(f"{template}: public/index.html references missing asset {ref!r}") + if not (path / ref.lstrip("/")).exists(): + _fail(f"{template}: static/index.html references missing asset {ref!r}") def _frontend_routes(template: str, path: Path) -> None: - frontend = (path / "public/index.html").read_text(encoding="utf-8") + frontend = (path / "static/index.html").read_text(encoding="utf-8") frontend += "\n".join( - asset.read_text(encoding="utf-8") for asset in (path / "public/static").glob("*.js") + asset.read_text(encoding="utf-8") for asset in (path / "static").glob("*.js") ) fetched = set(re.findall(r'fetch\(\s*["\'`](/api/[^"\'`?]+)', frontend)) fetched |= set(re.findall(r'["\'`](/api/[A-Za-z0-9_\-/]+?)(?:/?\$\{|/?["\'`]\s*\+)', frontend)) diff --git a/tests/test_init_scaffold.py b/tests/test_init_scaffold.py index 8678684b..d8b1ab72 100644 --- a/tests/test_init_scaffold.py +++ b/tests/test_init_scaffold.py @@ -32,7 +32,7 @@ def test_scaffold_copies_files_and_renames_dotfiles(tmp_path): target = tmp_path / "app" scaffold.scaffold("audio-transcription", target, api_key="sk-real-key") assert (target / "api" / "index.py").exists() - assert (target / "public" / "index.html").exists() + assert (target / "static" / "index.html").exists() assert not (target / "vercel.json").exists() # dotfile templates are renamed to their dotted names assert (target / ".gitignore").exists() diff --git a/tests/test_init_template_agent.py b/tests/test_init_template_agent.py index ce866b21..7fc0146c 100644 --- a/tests/test_init_template_agent.py +++ b/tests/test_init_template_agent.py @@ -49,7 +49,7 @@ def fake_get(url, params=None, headers=None): def test_page_reads_reply_audio_from_data_field(): # reply.audio carries the base64 PCM in `data` (not `audio`); guard the regression. - app_js = (TEMPLATE_DIR / "public" / "static" / "app.js").read_text() + app_js = (TEMPLATE_DIR / "static" / "app.js").read_text() assert "reply.audio" in app_js assert "event.data" in app_js diff --git a/tests/test_init_template_contract.py b/tests/test_init_template_contract.py index 12b467cd..e81aef8a 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -24,9 +24,9 @@ def test_required_files_present(template_dir): "api/index.py", "api/__init__.py", "api/settings.py", - "public/index.html", - "public/static/app.js", - "public/static/styles.css", + "static/index.html", + "static/app.js", + "static/styles.css", "requirements.txt", "README.md", "AGENTS.md", @@ -38,6 +38,15 @@ def test_required_files_present(template_dir): assert (template_dir / rel).exists(), f"{template_dir.name} missing {rel}" +def test_template_ships_no_public_dir(template_dir): + # Vercel serves a top-level public/** from its CDN and omits it from the Python + # lambda, so a FastAPI app that reads from public/ crashes at import on deploy. + assert not (template_dir / "public").exists(), ( + f"{template_dir.name}: ships a public/ dir; Vercel drops it from the function " + f"bundle and the app crashes (FUNCTION_INVOCATION_FAILED). Use static/." + ) + + def test_procfile_starts_the_app(template_dir): """The Procfile gives non-Vercel hosts (Render/Railway/Heroku/Cloud Run) a start command. The contract gate boots it for real; here we pin its shape.""" @@ -64,25 +73,25 @@ def test_runtime_pins_supported_python(template_dir): def test_realtime_templates_have_audio_helpers(template_dir): if template_dir.name in {"live-captions", "voice-agent"}: - assert (template_dir / "public" / "static" / "audio.js").exists() + assert (template_dir / "static" / "audio.js").exists() def test_static_assets_referenced_by_html_exist(template_dir): - html = (template_dir / "public" / "index.html").read_text() + html = (template_dir / "static" / "index.html").read_text() refs = set(re.findall(r'(?:href|src)=["\'](/static/[^"\']+)', html)) - assert refs, f"{template_dir.name}: public/index.html should load static assets" + assert refs, f"{template_dir.name}: static/index.html should load static assets" for ref in refs: - assert (template_dir / "public" / ref.lstrip("/")).exists(), ( - f"{template_dir.name}: public/index.html references missing asset {ref!r}" + assert (template_dir / ref.lstrip("/")).exists(), ( + f"{template_dir.name}: static/index.html references missing asset {ref!r}" ) def test_codex_edit_points_are_explicit(template_dir): notes = (template_dir / "AGENTS.md").read_text() - app_js = (template_dir / "public" / "static" / "app.js").read_text() + app_js = (template_dir / "static" / "app.js").read_text() assert "ASSEMBLYAI_API_KEY" in notes assert "buildless" in notes - assert "public/static/app.js" in notes + assert "static/app.js" in notes assert "_CONFIG" in app_js @@ -93,10 +102,8 @@ def test_no_committed_dotenv_or_real_key(template_dir): def test_frontend_routes_exist_in_backend(template_dir): """Every /api path the page fetches must be a route the backend registers.""" - frontend = (template_dir / "public" / "index.html").read_text() - frontend += "\n".join( - path.read_text() for path in (template_dir / "public" / "static").glob("*.js") - ) + frontend = (template_dir / "static" / "index.html").read_text() + frontend += "\n".join(path.read_text() for path in (template_dir / "static").glob("*.js")) fetched = set(re.findall(r'fetch\(\s*["\'`](/api/[^"\'`?]+)', frontend)) # Also catch template-literal paths like fetch(`/api/status/${id}`) and "/api/x/" + id fetched |= set(re.findall(r'["\'`](/api/[A-Za-z0-9_\-/]+?)(?:/?\$\{|/?["\'`]\s*\+)', frontend)) @@ -106,7 +113,7 @@ def test_frontend_routes_exist_in_backend(template_dir): for path in fetched: base = path.rstrip("/") assert any(base == r or base.startswith(r + "/") for r in registered_bases), ( - f"{template_dir.name}: public/index.html fetches {path!r}, " + f"{template_dir.name}: static/index.html fetches {path!r}, " f"not registered in api/index.py (routes: {sorted(registered_bases)})" ) diff --git a/tests/test_init_template_serve.py b/tests/test_init_template_serve.py index 0552913b..0e9c2af5 100644 --- a/tests/test_init_template_serve.py +++ b/tests/test_init_template_serve.py @@ -76,7 +76,7 @@ def test_serves_root_and_static_assets(template: str) -> None: assert "text/html" in root.headers["content-type"] assert root.text.strip() - static_dir = TEMPLATES_ROOT / template / "public" / "static" + static_dir = TEMPLATES_ROOT / template / "static" assets = sorted(p for p in static_dir.glob("*") if p.is_file()) assert assets, f"{template}: no static assets to serve" for asset in assets: diff --git a/tests/test_init_template_transcribe.py b/tests/test_init_template_transcribe.py index ce8937e8..7259c58c 100644 --- a/tests/test_init_template_transcribe.py +++ b/tests/test_init_template_transcribe.py @@ -42,9 +42,9 @@ def test_required_files_exist(): for rel in ( "api/index.py", "api/settings.py", - "public/index.html", - "public/static/app.js", - "public/static/styles.css", + "static/index.html", + "static/app.js", + "static/styles.css", "requirements.txt", "README.md", "AGENTS.md", @@ -68,8 +68,8 @@ def test_base_url_env_is_applied(monkeypatch, mocker): def test_page_explores_all_features_and_speakers(): # Guard the UI surface: each audio-intelligence view + per-speaker coloring stay wired. - html = (TEMPLATE_DIR / "public" / "index.html").read_text() - app_js = (TEMPLATE_DIR / "public" / "static" / "app.js").read_text() + html = (TEMPLATE_DIR / "static" / "index.html").read_text() + app_js = (TEMPLATE_DIR / "static" / "app.js").read_text() ui_src = html + app_js for token in ( "chapters", From 272aa8ecd8ecf65d18bbd4846566b70b40dd04db Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 13:37:12 -0700 Subject: [PATCH 29/40] feat(deploy): add --render and --fly deploy targets Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/deploy.py | 80 ++++++++++++++----- .../test_cli_output_snapshots.ambr | 14 +++- tests/test_deploy.py | 68 ++++++++++++++-- 3 files changed, 132 insertions(+), 30 deletions(-) diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py index 0db54b2c..b5adb64b 100644 --- a/aai_cli/commands/deploy.py +++ b/aai_cli/commands/deploy.py @@ -21,32 +21,66 @@ class Target: name: str # human label, e.g. "Vercel" bin: str # executable resolved via shutil.which - install: str # install hint shown when the CLI is missing + flag: str # CLI selector, e.g. "--vercel" + install: str # full hint sentence shown when the CLI is missing + deploy_args: tuple[str, ...] # subcommand(s) appended after `bin` + supports_prod: bool = False # whether `--prod` adds a production flag def command(self, *, prod: bool) -> list[str]: - if self.bin == "vercel": - return ["vercel", "deploy", *(["--prod"] if prod else [])] - return ["railway", "up"] # Railway has no preview/prod split here - + argv = [self.bin, *self.deploy_args] + if prod and self.supports_prod: + argv.append("--prod") + return argv + + +VERCEL = Target( + name="Vercel", + bin="vercel", + flag="--vercel", + install="Install it with `npm i -g vercel`.", + deploy_args=("deploy",), + supports_prod=True, +) +RAILWAY = Target( + name="Railway", + bin="railway", + flag="--railway", + install="Install it with `npm i -g @railway/cli`.", + deploy_args=("up",), +) +RENDER = Target( + name="Render", + bin="render", + flag="--render", + install="Install it from https://render.com/docs/cli.", + deploy_args=("deploys", "create"), +) +FLY = Target( + name="Fly", + bin="fly", + flag="--fly", + install="Install it with `brew install flyctl`.", + deploy_args=("deploy",), +) -VERCEL = Target(name="Vercel", bin="vercel", install="npm i -g vercel") -RAILWAY = Target(name="Railway", bin="railway", install="npm i -g @railway/cli") +TARGETS = (VERCEL, RAILWAY, RENDER, FLY) -def _resolve_target(*, vercel: bool, railway: bool) -> Target: - if vercel and railway: +def _resolve_target(selected: list[Target]) -> Target: + if len(selected) > 1: + flags = " / ".join(t.flag for t in TARGETS) raise CLIError( - "Pass either --vercel or --railway, not both.", + f"Pass at most one deploy target ({flags}).", error_type="usage_error", exit_code=1, ) - return RAILWAY if railway else VERCEL # Vercel is the default + return selected[0] if selected else VERCEL # Vercel is the default def _require_cli(target: Target) -> None: if shutil.which(target.bin) is None: raise CLIError( - f"The {target.name} CLI is required to deploy. Install it with `{target.install}`.", + f"The {target.name} CLI is required to deploy. {target.install}", error_type="missing_dependency", exit_code=1, ) @@ -86,6 +120,8 @@ def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None: ("Deploy a preview to Vercel (asks first)", "aai deploy"), ("Deploy to production on Vercel", "aai deploy --prod --yes"), ("Deploy to Railway", "aai deploy --railway"), + ("Deploy to Render", "aai deploy --render"), + ("Deploy to Fly.io", "aai deploy --fly"), ] ), ) @@ -94,19 +130,23 @@ def deploy( prod: bool = typer.Option(False, "--prod", help="Deploy to production (Vercel only)."), vercel: bool = typer.Option(False, "--vercel", help="Deploy to Vercel (the default)."), railway: bool = typer.Option(False, "--railway", help="Deploy to Railway."), + render: bool = typer.Option(False, "--render", help="Deploy to Render."), + fly: bool = typer.Option(False, "--fly", help="Deploy to Fly.io."), assume_yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), ) -> None: - """Deploy the current project to Vercel (default) or Railway. + """Deploy the current project to Vercel (default), Railway, Render, or Fly.io. - Asks for confirmation first, then runs the target's CLI (`vercel deploy` or - `railway up`). Requires that target's CLI to be installed. + Asks for confirmation first, then runs the target's CLI (`vercel deploy`, + `railway up`, `render deploys create`, or `fly deploy`). Requires that target's + CLI to be installed. """ def body(_state: AppState, _json_mode: bool) -> None: - run_deploy( - target=_resolve_target(vercel=vercel, railway=railway), - prod=prod, - assume_yes=assume_yes, - ) + selected = [ + t + for t, on in ((VERCEL, vercel), (RAILWAY, railway), (RENDER, render), (FLY, fly)) + if on + ] + run_deploy(target=_resolve_target(selected), prod=prod, assume_yes=assume_yes) run_command(ctx, body) diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index b2460d05..d05443ac 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -128,15 +128,19 @@ Usage: aai deploy [OPTIONS] - Deploy the current project to Vercel (default) or Railway. + Deploy the current project to Vercel (default), Railway, Render, or Fly.io. - Asks for confirmation first, then runs the target's CLI (`vercel deploy` or - `railway up`). Requires that target's CLI to be installed. + Asks for confirmation first, then runs the target's CLI (`vercel deploy`, + `railway up`, `render deploys create`, or `fly deploy`). Requires that + target's + CLI to be installed. ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --prod Deploy to production (Vercel only). │ │ --vercel Deploy to Vercel (the default). │ │ --railway Deploy to Railway. │ + │ --render Deploy to Render. │ + │ --fly Deploy to Fly.io. │ │ --yes -y Skip the confirmation prompt. │ │ --help Show this message and exit. │ ╰──────────────────────────────────────────────────────────────────────────────╯ @@ -148,6 +152,10 @@ $ aai deploy --prod --yes Deploy to Railway $ aai deploy --railway + Deploy to Render + $ aai deploy --render + Deploy to Fly.io + $ aai deploy --fly diff --git a/tests/test_deploy.py b/tests/test_deploy.py index ff5e8bd6..419d1edf 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -8,19 +8,19 @@ import pytest from typer.testing import CliRunner -from aai_cli.commands.deploy import RAILWAY, VERCEL, Target +from aai_cli.commands.deploy import FLY, RAILWAY, RENDER, VERCEL, Target from aai_cli.main import app runner = CliRunner() def test_targets_are_frozen() -> None: - # The default and Railway targets are module-level singletons; freezing them - # guards against accidental in-place mutation of shared deploy config. + # Every deploy target is a module-level singleton; freezing them guards + # against accidental in-place mutation of shared deploy config. # Route the assignment through an object-typed alias and a runtime attribute # name so the frozen-ness is checked at runtime, not statically. field = "name" - for target in (VERCEL, RAILWAY): + for target in (VERCEL, RAILWAY, RENDER, FLY): opaque: object = target with pytest.raises(dataclasses.FrozenInstanceError): setattr(opaque, field, "tampered") @@ -89,11 +89,13 @@ def test_deploy_railway_prompt_names_railway(monkeypatch: pytest.MonkeyPatch) -> assert "cmd" not in calls # declined -def test_deploy_both_targets_errors(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deploy_multiple_targets_errors(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("vercel", "railway")) result = runner.invoke(app, ["deploy", "--vercel", "--railway", "--yes"]) assert result.exit_code == 1 - assert "not both" in result.output + assert "at most one" in result.output + # The error lists every target flag, including the new ones. + assert "--fly" in result.output assert "cmd" not in calls # never deployed @@ -144,6 +146,58 @@ def test_deploy_prod_ignored_for_railway(monkeypatch: pytest.MonkeyPatch) -> Non assert calls["cmd"] == ["railway", "up"] +def test_deploy_render_flag(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("render",)) + result = runner.invoke(app, ["deploy", "--render", "--yes"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["render", "deploys", "create"] + + +def test_deploy_render_prompt_names_render(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("render",), confirm=False) + result = runner.invoke(app, ["deploy", "--render"]) + assert result.exit_code == 0, result.output + assert calls["prompt"] == "Deploy this project to Render?" + assert "cmd" not in calls # declined + + +def test_deploy_prod_ignored_for_render(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("render",)) + result = runner.invoke(app, ["deploy", "--render", "--prod", "--yes"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["render", "deploys", "create"] + + +def test_deploy_missing_render_errors(monkeypatch: pytest.MonkeyPatch) -> None: + _stub(monkeypatch, available=()) + result = runner.invoke(app, ["deploy", "--render", "--yes"]) + assert result.exit_code == 1 + assert "Render CLI" in result.output + assert "render.com/docs/cli" in " ".join(result.output.split()) + + +def test_deploy_fly_flag(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("fly",)) + result = runner.invoke(app, ["deploy", "--fly", "--yes"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["fly", "deploy"] + + +def test_deploy_prod_ignored_for_fly(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("fly",)) + result = runner.invoke(app, ["deploy", "--fly", "--prod", "--yes"]) + assert result.exit_code == 0, result.output + assert calls["cmd"] == ["fly", "deploy"] + + +def test_deploy_missing_fly_errors(monkeypatch: pytest.MonkeyPatch) -> None: + _stub(monkeypatch, available=()) + result = runner.invoke(app, ["deploy", "--fly", "--yes"]) + assert result.exit_code == 1 + assert "Fly CLI" in result.output + assert "brew install flyctl" in " ".join(result.output.split()) + + def test_deploy_nonzero_exit_propagates(monkeypatch: pytest.MonkeyPatch) -> None: _stub(monkeypatch, available=("vercel",), returncode=2) result = runner.invoke(app, ["deploy", "--yes"]) @@ -158,7 +212,7 @@ def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatc assert "cmd" not in calls -@pytest.mark.parametrize("flag", ["--prod", "--vercel", "--railway", "--yes"]) +@pytest.mark.parametrize("flag", ["--prod", "--vercel", "--railway", "--render", "--fly", "--yes"]) def test_deploy_help_lists_flags(flag: str) -> None: result = runner.invoke(app, ["deploy", "--help"]) assert result.exit_code == 0 From 14dba75eef6d96daac025f7d364d61f6cf69b633 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 13:46:00 -0700 Subject: [PATCH 30/40] fix(templates): run uvicorn via 'python -m' so Railway/Nixpacks deploys don't crash on the venv console-script shebang Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/devserver.py | 13 ++++++--- .../templates/audio-transcription/Procfile | 2 +- aai_cli/init/templates/live-captions/Procfile | 2 +- aai_cli/init/templates/voice-agent/Procfile | 2 +- tests/test_dev.py | 5 ++-- tests/test_devserver.py | 28 ++++++++++++++----- 6 files changed, 36 insertions(+), 16 deletions(-) diff --git a/aai_cli/init/devserver.py b/aai_cli/init/devserver.py index b0a98880..3567bd6c 100644 --- a/aai_cli/init/devserver.py +++ b/aai_cli/init/devserver.py @@ -24,8 +24,13 @@ def install_step(target: Path, *, no_install: bool, use_uv: bool) -> steps.Step: def dev_command(target: Path, web: list[str], *, use_uv: bool) -> list[str]: """The Procfile web process, run in the project venv with live reload. - In the no-uv branch `web[0]` must be a `python -m`-runnable module; every current - template's `web:` line starts with `uvicorn`. + The Procfile's `web:` line starts with `python -m uvicorn …`. With uv, run it + verbatim under `uv run`; without uv, swap a leading `python` for the project's + venv interpreter so it runs inside the scaffolded `.venv`. """ - prefix = ["uv", "run"] if use_uv else [str(runner.venv_python(target)), "-m"] - return [*prefix, *web, "--reload"] + if use_uv: + return ["uv", "run", *web, "--reload"] + argv = list(web) + if argv and argv[0] == "python": + argv[0] = str(runner.venv_python(target)) + return [*argv, "--reload"] diff --git a/aai_cli/init/templates/audio-transcription/Procfile b/aai_cli/init/templates/audio-transcription/Procfile index d7d8e35c..8837c118 100644 --- a/aai_cli/init/templates/audio-transcription/Procfile +++ b/aai_cli/init/templates/audio-transcription/Procfile @@ -1 +1 @@ -web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} +web: python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} diff --git a/aai_cli/init/templates/live-captions/Procfile b/aai_cli/init/templates/live-captions/Procfile index d7d8e35c..8837c118 100644 --- a/aai_cli/init/templates/live-captions/Procfile +++ b/aai_cli/init/templates/live-captions/Procfile @@ -1 +1 @@ -web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} +web: python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} diff --git a/aai_cli/init/templates/voice-agent/Procfile b/aai_cli/init/templates/voice-agent/Procfile index d7d8e35c..8837c118 100644 --- a/aai_cli/init/templates/voice-agent/Procfile +++ b/aai_cli/init/templates/voice-agent/Procfile @@ -1 +1 @@ -web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} +web: python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000} diff --git a/tests/test_dev.py b/tests/test_dev.py index 0129734a..cf8cf6df 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -5,7 +5,7 @@ from aai_cli.main import app runner = CliRunner() -WEB = "web: uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" +WEB = "web: python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" def _make_project(tmp_path): @@ -38,7 +38,8 @@ def test_dev_boots_procfile_command_with_reload(tmp_path, monkeypatch): result = runner.invoke(app, ["dev", "--no-open"]) assert result.exit_code == 0, result.output cmd = captured["command"] - assert cmd[:4] == ["uv", "run", "uvicorn", "api.index:app"] + assert cmd[:5] == ["uv", "run", "python", "-m", "uvicorn"] + assert "api.index:app" in cmd assert "--host" in cmd assert cmd[-3:] == ["--port", "3000", "--reload"] assert captured["env"]["PORT"] == "3000" diff --git a/tests/test_devserver.py b/tests/test_devserver.py index bc2f000f..b8cfa83b 100644 --- a/tests/test_devserver.py +++ b/tests/test_devserver.py @@ -47,15 +47,29 @@ def test_install_step_failed_truncates_detail(monkeypatch): def test_dev_command_uv(): - cmd = devserver.dev_command(Path("/proj"), ["uvicorn", "api.index:app"], use_uv=True) - assert cmd == ["uv", "run", "uvicorn", "api.index:app", "--reload"] + cmd = devserver.dev_command( + Path("/proj"), ["python", "-m", "uvicorn", "api.index:app"], use_uv=True + ) + assert cmd == ["uv", "run", "python", "-m", "uvicorn", "api.index:app", "--reload"] -def test_dev_command_venv(): +def test_dev_command_venv_swaps_python(): from aai_cli.init import runner + cmd = devserver.dev_command( + Path("/proj"), ["python", "-m", "uvicorn", "api.index:app"], use_uv=False + ) + assert cmd == [ + str(runner.venv_python(Path("/proj"))), + "-m", + "uvicorn", + "api.index:app", + "--reload", + ] + + +def test_dev_command_venv_leaves_non_python_first_token(): + # The `python`-swap only fires on a leading `python`; anything else passes through + # (covers the False branch of the swap condition). cmd = devserver.dev_command(Path("/proj"), ["uvicorn", "api.index:app"], use_uv=False) - assert cmd[0] == str(runner.venv_python(Path("/proj"))) - assert cmd[1] == "-m" - assert cmd[2] == "uvicorn" - assert cmd[-1] == "--reload" + assert cmd == ["uvicorn", "api.index:app", "--reload"] From ed783d281342cc367013ad643b3f8b404cdf21a1 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 14:17:57 -0700 Subject: [PATCH 31/40] feat(deploy): run 'railway domain' after a successful railway deploy to surface the URL Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/deploy.py | 4 ++ tests/test_deploy.py | 92 +++++++++++++++++++++++++++++--------- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py index b5adb64b..5c8833a3 100644 --- a/aai_cli/commands/deploy.py +++ b/aai_cli/commands/deploy.py @@ -25,6 +25,7 @@ class Target: install: str # full hint sentence shown when the CLI is missing deploy_args: tuple[str, ...] # subcommand(s) appended after `bin` supports_prod: bool = False # whether `--prod` adds a production flag + post_deploy_args: tuple[str, ...] | None = None # extra command run after a successful deploy def command(self, *, prod: bool) -> list[str]: argv = [self.bin, *self.deploy_args] @@ -47,6 +48,7 @@ def command(self, *, prod: bool) -> list[str]: flag="--railway", install="Install it with `npm i -g @railway/cli`.", deploy_args=("up",), + post_deploy_args=("domain",), ) RENDER = Target( name="Render", @@ -111,6 +113,8 @@ def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None: result = subprocess.run(target.command(prod=prod), cwd=Path.cwd(), check=False) if result.returncode: raise typer.Exit(code=result.returncode) + if target.post_deploy_args is not None: + subprocess.run([target.bin, *target.post_deploy_args], cwd=Path.cwd(), check=False) @app.command( diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 419d1edf..2b5c800e 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -48,22 +48,44 @@ def fake_confirm(prompt: str, *a: object, **k: object) -> bool: monkeypatch.setattr("typer.confirm", fake_confirm) def fake_run(cmd: list[str], **kwargs: object) -> types.SimpleNamespace: - calls["cmd"] = cmd - calls["cwd"] = kwargs.get("cwd") - calls["check"] = kwargs.get("check") + runs = calls.setdefault("runs", []) + assert isinstance(runs, list) + runs.append({"cmd": cmd, "cwd": kwargs.get("cwd"), "check": kwargs.get("check")}) return types.SimpleNamespace(returncode=returncode) monkeypatch.setattr("aai_cli.commands.deploy.subprocess.run", fake_run) return calls +def _runs(calls: dict[str, object]) -> list[dict[str, object]]: + """Every captured subprocess.run call, in order.""" + runs = calls.get("runs", []) + assert isinstance(runs, list) + out: list[dict[str, object]] = [] + for run in runs: + assert isinstance(run, dict) + out.append(run) + return out + + +def _cmds(calls: dict[str, object]) -> list[list[str]]: + """The command argv for every captured subprocess.run, in call order.""" + out: list[list[str]] = [] + for run in _runs(calls): + cmd = run["cmd"] + assert isinstance(cmd, list) + out.append(cmd) + return out + + def test_deploy_defaults_to_vercel(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("vercel",)) result = runner.invoke(app, ["deploy"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["vercel", "deploy"] - assert calls["check"] is False - assert calls["cwd"] == Path.cwd() + run = _runs(calls)[0] + assert run["cmd"] == ["vercel", "deploy"] + assert run["check"] is False + assert run["cwd"] == Path.cwd() assert calls["prompt"] == "Deploy this project to Vercel?" @@ -71,14 +93,44 @@ def test_deploy_vercel_flag(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("vercel",)) result = runner.invoke(app, ["deploy", "--vercel", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["vercel", "deploy"] + assert _cmds(calls) == [["vercel", "deploy"]] + + +def test_deploy_vercel_no_post_deploy(monkeypatch: pytest.MonkeyPatch) -> None: + # Vercel has no post_deploy_args, so exactly one subprocess.run fires. + calls = _stub(monkeypatch, available=("vercel",)) + result = runner.invoke(app, ["deploy", "--vercel", "--yes"]) + assert result.exit_code == 0, result.output + assert len(_runs(calls)) == 1 + assert _runs(calls)[0]["cmd"] == ["vercel", "deploy"] def test_deploy_railway_flag(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("railway",)) result = runner.invoke(app, ["deploy", "--railway", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["railway", "up"] + assert _cmds(calls)[0] == ["railway", "up"] + + +def test_deploy_railway_generates_domain(monkeypatch: pytest.MonkeyPatch) -> None: + # A successful railway deploy chases the deploy with `railway domain` so the + # public URL is surfaced (railway up alone prints no URL). + calls = _stub(monkeypatch, available=("railway",), returncode=0) + result = runner.invoke(app, ["deploy", "--railway", "--yes"]) + assert result.exit_code == 0, result.output + assert _cmds(calls) == [["railway", "up"], ["railway", "domain"]] + # The post-deploy step is best-effort: its non-zero exit must not propagate, + # so it too runs with check=False. + assert _runs(calls)[1]["check"] is False + + +def test_deploy_railway_failed_skips_domain(monkeypatch: pytest.MonkeyPatch) -> None: + # A non-zero deploy raises before the post-deploy step, so `railway domain` + # never runs and the deploy's exit code propagates. + calls = _stub(monkeypatch, available=("railway",), returncode=2) + result = runner.invoke(app, ["deploy", "--railway", "--yes"]) + assert result.exit_code == 2 + assert _cmds(calls) == [["railway", "up"]] def test_deploy_railway_prompt_names_railway(monkeypatch: pytest.MonkeyPatch) -> None: @@ -86,7 +138,7 @@ def test_deploy_railway_prompt_names_railway(monkeypatch: pytest.MonkeyPatch) -> result = runner.invoke(app, ["deploy", "--railway"]) assert result.exit_code == 0, result.output assert calls["prompt"] == "Deploy this project to Railway?" - assert "cmd" not in calls # declined + assert _cmds(calls) == [] # declined def test_deploy_multiple_targets_errors(monkeypatch: pytest.MonkeyPatch) -> None: @@ -96,7 +148,7 @@ def test_deploy_multiple_targets_errors(monkeypatch: pytest.MonkeyPatch) -> None assert "at most one" in result.output # The error lists every target flag, including the new ones. assert "--fly" in result.output - assert "cmd" not in calls # never deployed + assert _cmds(calls) == [] # never deployed def test_deploy_missing_vercel_errors(monkeypatch: pytest.MonkeyPatch) -> None: @@ -121,14 +173,14 @@ def test_deploy_confirm_no_aborts(monkeypatch: pytest.MonkeyPatch) -> None: result = runner.invoke(app, ["deploy"]) assert result.exit_code == 0, result.output assert "Aborted" in result.output - assert "cmd" not in calls + assert _cmds(calls) == [] def test_deploy_yes_skips_prompt(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("vercel",), confirm=False) result = runner.invoke(app, ["deploy", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["vercel", "deploy"] + assert _cmds(calls)[0] == ["vercel", "deploy"] assert "prompt" not in calls # --yes bypassed typer.confirm @@ -136,21 +188,21 @@ def test_deploy_prod_flag_vercel(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("vercel",)) result = runner.invoke(app, ["deploy", "--prod", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["vercel", "deploy", "--prod"] + assert _cmds(calls)[0] == ["vercel", "deploy", "--prod"] def test_deploy_prod_ignored_for_railway(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("railway",)) result = runner.invoke(app, ["deploy", "--railway", "--prod", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["railway", "up"] + assert _cmds(calls)[0] == ["railway", "up"] def test_deploy_render_flag(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("render",)) result = runner.invoke(app, ["deploy", "--render", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["render", "deploys", "create"] + assert _cmds(calls) == [["render", "deploys", "create"]] def test_deploy_render_prompt_names_render(monkeypatch: pytest.MonkeyPatch) -> None: @@ -158,14 +210,14 @@ def test_deploy_render_prompt_names_render(monkeypatch: pytest.MonkeyPatch) -> N result = runner.invoke(app, ["deploy", "--render"]) assert result.exit_code == 0, result.output assert calls["prompt"] == "Deploy this project to Render?" - assert "cmd" not in calls # declined + assert _cmds(calls) == [] # declined def test_deploy_prod_ignored_for_render(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("render",)) result = runner.invoke(app, ["deploy", "--render", "--prod", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["render", "deploys", "create"] + assert _cmds(calls)[0] == ["render", "deploys", "create"] def test_deploy_missing_render_errors(monkeypatch: pytest.MonkeyPatch) -> None: @@ -180,14 +232,14 @@ def test_deploy_fly_flag(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("fly",)) result = runner.invoke(app, ["deploy", "--fly", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["fly", "deploy"] + assert _cmds(calls) == [["fly", "deploy"]] def test_deploy_prod_ignored_for_fly(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("fly",)) result = runner.invoke(app, ["deploy", "--fly", "--prod", "--yes"]) assert result.exit_code == 0, result.output - assert calls["cmd"] == ["fly", "deploy"] + assert _cmds(calls)[0] == ["fly", "deploy"] def test_deploy_missing_fly_errors(monkeypatch: pytest.MonkeyPatch) -> None: @@ -209,7 +261,7 @@ def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatc result = runner.invoke(app, ["deploy"]) assert result.exit_code == 1 assert "--yes" in result.output - assert "cmd" not in calls + assert _cmds(calls) == [] @pytest.mark.parametrize("flag", ["--prod", "--vercel", "--railway", "--render", "--fly", "--yes"]) From e11a2435c99dedd8786bff7619901dbc4a99d7cf Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 14:40:00 -0700 Subject: [PATCH 32/40] feat(deploy): drop --render (git-only, no local deploy); guide --fly to 'fly launch' when fly.toml is missing Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/deploy.py | 39 ++++++------- .../templates/audio-transcription/README.md | 4 ++ .../init/templates/live-captions/README.md | 4 ++ aai_cli/init/templates/voice-agent/README.md | 4 ++ .../test_cli_output_snapshots.ambr | 10 +--- tests/test_deploy.py | 57 +++++++------------ 6 files changed, 57 insertions(+), 61 deletions(-) diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py index 5c8833a3..37a941bb 100644 --- a/aai_cli/commands/deploy.py +++ b/aai_cli/commands/deploy.py @@ -25,7 +25,9 @@ class Target: install: str # full hint sentence shown when the CLI is missing deploy_args: tuple[str, ...] # subcommand(s) appended after `bin` supports_prod: bool = False # whether `--prod` adds a production flag - post_deploy_args: tuple[str, ...] | None = None # extra command run after a successful deploy + post_deploy_args: tuple[str, ...] | None = None # command run after a successful deploy + requires_file: str | None = None # a file that must exist in cwd before deploying + setup_hint: str | None = None # how to create `requires_file` def command(self, *, prod: bool) -> list[str]: argv = [self.bin, *self.deploy_args] @@ -50,22 +52,17 @@ def command(self, *, prod: bool) -> list[str]: deploy_args=("up",), post_deploy_args=("domain",), ) -RENDER = Target( - name="Render", - bin="render", - flag="--render", - install="Install it from https://render.com/docs/cli.", - deploy_args=("deploys", "create"), -) FLY = Target( name="Fly", bin="fly", flag="--fly", install="Install it with `brew install flyctl`.", deploy_args=("deploy",), + requires_file="fly.toml", + setup_hint="Run `fly launch` first to create your Fly app.", ) -TARGETS = (VERCEL, RAILWAY, RENDER, FLY) +TARGETS = (VERCEL, RAILWAY, FLY) def _resolve_target(selected: list[Target]) -> Target: @@ -88,6 +85,15 @@ def _require_cli(target: Target) -> None: ) +def _require_setup(target: Target) -> None: + if target.requires_file is not None and not (Path.cwd() / target.requires_file).exists(): + raise CLIError( + f"No {target.requires_file} in this directory. {target.setup_hint}", + error_type="usage_error", + exit_code=1, + ) + + def _confirmed(target: Target, *, assume_yes: bool) -> bool: """True when the deploy should proceed: --yes, or an interactive yes. @@ -107,6 +113,7 @@ def _confirmed(target: Target, *, assume_yes: bool) -> bool: def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None: """Confirm, then run the target's deploy command in the current directory.""" _require_cli(target) + _require_setup(target) if not _confirmed(target, assume_yes=assume_yes): output.console.print("Aborted.") return @@ -124,7 +131,6 @@ def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None: ("Deploy a preview to Vercel (asks first)", "aai deploy"), ("Deploy to production on Vercel", "aai deploy --prod --yes"), ("Deploy to Railway", "aai deploy --railway"), - ("Deploy to Render", "aai deploy --render"), ("Deploy to Fly.io", "aai deploy --fly"), ] ), @@ -134,23 +140,18 @@ def deploy( prod: bool = typer.Option(False, "--prod", help="Deploy to production (Vercel only)."), vercel: bool = typer.Option(False, "--vercel", help="Deploy to Vercel (the default)."), railway: bool = typer.Option(False, "--railway", help="Deploy to Railway."), - render: bool = typer.Option(False, "--render", help="Deploy to Render."), fly: bool = typer.Option(False, "--fly", help="Deploy to Fly.io."), assume_yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), ) -> None: - """Deploy the current project to Vercel (default), Railway, Render, or Fly.io. + """Deploy the current project to Vercel (default), Railway, or Fly.io. Asks for confirmation first, then runs the target's CLI (`vercel deploy`, - `railway up`, `render deploys create`, or `fly deploy`). Requires that target's - CLI to be installed. + `railway up`, or `fly deploy`). Requires that target's CLI to be installed. + (Render deploys from a connected Git repo — see the project README.) """ def body(_state: AppState, _json_mode: bool) -> None: - selected = [ - t - for t, on in ((VERCEL, vercel), (RAILWAY, railway), (RENDER, render), (FLY, fly)) - if on - ] + selected = [t for t, on in ((VERCEL, vercel), (RAILWAY, railway), (FLY, fly)) if on] run_deploy(target=_resolve_target(selected), prod=prod, assume_yes=assume_yes) run_command(ctx, body) diff --git a/aai_cli/init/templates/audio-transcription/README.md b/aai_cli/init/templates/audio-transcription/README.md index 3657ba52..83f559d7 100644 --- a/aai_cli/init/templates/audio-transcription/README.md +++ b/aai_cli/init/templates/audio-transcription/README.md @@ -31,6 +31,10 @@ anything else that reads a `Procfile`. Point the platform at this repo and set uvicorn api.index:app --host 0.0.0.0 --port $PORT ``` +On Render, create a **Web Service** connected to your Git repo — it installs +`requirements.txt` and starts via the `Procfile`. (There's no local-directory +deploy; `aai deploy` covers Vercel/Railway/Fly.) + ## Ideas to extend - Show chapter summaries and highlight timestamps. diff --git a/aai_cli/init/templates/live-captions/README.md b/aai_cli/init/templates/live-captions/README.md index 1942d820..8186be7e 100644 --- a/aai_cli/init/templates/live-captions/README.md +++ b/aai_cli/init/templates/live-captions/README.md @@ -33,6 +33,10 @@ anything else that reads a `Procfile`. Point the platform at this repo and set uvicorn api.index:app --host 0.0.0.0 --port $PORT ``` +On Render, create a **Web Service** connected to your Git repo — it installs +`requirements.txt` and starts via the `Procfile`. (There's no local-directory +deploy; `aai deploy` covers Vercel/Railway/Fly.) + ## Ideas to extend - Add `keyterms_prompt` or a `prompt` for domain vocabulary in `STREAMING_CONFIG`. diff --git a/aai_cli/init/templates/voice-agent/README.md b/aai_cli/init/templates/voice-agent/README.md index a999c604..8f4103e7 100644 --- a/aai_cli/init/templates/voice-agent/README.md +++ b/aai_cli/init/templates/voice-agent/README.md @@ -34,6 +34,10 @@ anything else that reads a `Procfile`. Point the platform at this repo and set uvicorn api.index:app --host 0.0.0.0 --port $PORT ``` +On Render, create a **Web Service** connected to your Git repo — it installs +`requirements.txt` and starts via the `Procfile`. (There's no local-directory +deploy; `aai deploy` covers Vercel/Railway/Fly.) + ## Ideas to extend - Change the `greeting`, `systemPrompt`, or `voice` in `SESSION_CONFIG` (`static/app.js`). diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index d05443ac..7e139fa5 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -128,18 +128,16 @@ Usage: aai deploy [OPTIONS] - Deploy the current project to Vercel (default), Railway, Render, or Fly.io. + Deploy the current project to Vercel (default), Railway, or Fly.io. Asks for confirmation first, then runs the target's CLI (`vercel deploy`, - `railway up`, `render deploys create`, or `fly deploy`). Requires that - target's - CLI to be installed. + `railway up`, or `fly deploy`). Requires that target's CLI to be installed. + (Render deploys from a connected Git repo — see the project README.) ╭─ Options ────────────────────────────────────────────────────────────────────╮ │ --prod Deploy to production (Vercel only). │ │ --vercel Deploy to Vercel (the default). │ │ --railway Deploy to Railway. │ - │ --render Deploy to Render. │ │ --fly Deploy to Fly.io. │ │ --yes -y Skip the confirmation prompt. │ │ --help Show this message and exit. │ @@ -152,8 +150,6 @@ $ aai deploy --prod --yes Deploy to Railway $ aai deploy --railway - Deploy to Render - $ aai deploy --render Deploy to Fly.io $ aai deploy --fly diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 2b5c800e..2a2ab240 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -8,7 +8,7 @@ import pytest from typer.testing import CliRunner -from aai_cli.commands.deploy import FLY, RAILWAY, RENDER, VERCEL, Target +from aai_cli.commands.deploy import FLY, RAILWAY, VERCEL, Target from aai_cli.main import app runner = CliRunner() @@ -20,7 +20,7 @@ def test_targets_are_frozen() -> None: # Route the assignment through an object-typed alias and a runtime attribute # name so the frozen-ness is checked at runtime, not statically. field = "name" - for target in (VERCEL, RAILWAY, RENDER, FLY): + for target in (VERCEL, RAILWAY, FLY): opaque: object = target with pytest.raises(dataclasses.FrozenInstanceError): setattr(opaque, field, "tampered") @@ -148,6 +148,7 @@ def test_deploy_multiple_targets_errors(monkeypatch: pytest.MonkeyPatch) -> None assert "at most one" in result.output # The error lists every target flag, including the new ones. assert "--fly" in result.output + assert "--render" not in result.output assert _cmds(calls) == [] # never deployed @@ -198,50 +199,36 @@ def test_deploy_prod_ignored_for_railway(monkeypatch: pytest.MonkeyPatch) -> Non assert _cmds(calls)[0] == ["railway", "up"] -def test_deploy_render_flag(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, available=("render",)) - result = runner.invoke(app, ["deploy", "--render", "--yes"]) - assert result.exit_code == 0, result.output - assert _cmds(calls) == [["render", "deploys", "create"]] - - -def test_deploy_render_prompt_names_render(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, available=("render",), confirm=False) - result = runner.invoke(app, ["deploy", "--render"]) - assert result.exit_code == 0, result.output - assert calls["prompt"] == "Deploy this project to Render?" - assert _cmds(calls) == [] # declined - - -def test_deploy_prod_ignored_for_render(monkeypatch: pytest.MonkeyPatch) -> None: - calls = _stub(monkeypatch, available=("render",)) - result = runner.invoke(app, ["deploy", "--render", "--prod", "--yes"]) - assert result.exit_code == 0, result.output - assert _cmds(calls)[0] == ["render", "deploys", "create"] - - -def test_deploy_missing_render_errors(monkeypatch: pytest.MonkeyPatch) -> None: - _stub(monkeypatch, available=()) - result = runner.invoke(app, ["deploy", "--render", "--yes"]) - assert result.exit_code == 1 - assert "Render CLI" in result.output - assert "render.com/docs/cli" in " ".join(result.output.split()) - - -def test_deploy_fly_flag(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deploy_fly_flag(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "fly.toml").write_text("app = 'x'\n") calls = _stub(monkeypatch, available=("fly",)) result = runner.invoke(app, ["deploy", "--fly", "--yes"]) assert result.exit_code == 0, result.output assert _cmds(calls) == [["fly", "deploy"]] -def test_deploy_prod_ignored_for_fly(monkeypatch: pytest.MonkeyPatch) -> None: +def test_deploy_prod_ignored_for_fly(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + (tmp_path / "fly.toml").write_text("app = 'x'\n") calls = _stub(monkeypatch, available=("fly",)) result = runner.invoke(app, ["deploy", "--fly", "--prod", "--yes"]) assert result.exit_code == 0, result.output assert _cmds(calls)[0] == ["fly", "deploy"] +def test_deploy_fly_without_toml_errors(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + # Without a fly.toml, the preflight fails early with a `fly launch` hint and + # never shells out to `fly deploy`. + monkeypatch.chdir(tmp_path) + calls = _stub(monkeypatch, available=("fly",)) + result = runner.invoke(app, ["deploy", "--fly", "--yes"]) + assert result.exit_code == 1 + assert "fly.toml" in result.output + assert "fly launch" in result.output + assert _cmds(calls) == [] # never deployed + + def test_deploy_missing_fly_errors(monkeypatch: pytest.MonkeyPatch) -> None: _stub(monkeypatch, available=()) result = runner.invoke(app, ["deploy", "--fly", "--yes"]) @@ -264,7 +251,7 @@ def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatc assert _cmds(calls) == [] -@pytest.mark.parametrize("flag", ["--prod", "--vercel", "--railway", "--render", "--fly", "--yes"]) +@pytest.mark.parametrize("flag", ["--prod", "--vercel", "--railway", "--fly", "--yes"]) def test_deploy_help_lists_flags(flag: str) -> None: result = runner.invoke(app, ["deploy", "--help"]) assert result.exit_code == 0 From 1213455b99b17ed0ed697ec5e3d024d8a2f16f8c Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 14:57:34 -0700 Subject: [PATCH 33/40] feat(templates): ship a Dockerfile + .dockerignore so Fly/Railway/Render/Cloudflare-Containers build a working image Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/scaffold.py | 6 +++- .../templates/audio-transcription/Dockerfile | 16 ++++++++++ .../audio-transcription/dockerignore | 6 ++++ .../init/templates/live-captions/Dockerfile | 16 ++++++++++ .../init/templates/live-captions/dockerignore | 6 ++++ aai_cli/init/templates/voice-agent/Dockerfile | 16 ++++++++++ .../init/templates/voice-agent/dockerignore | 6 ++++ scripts/template_contract_gate.py | 30 +++++++++++++++++++ tests/test_init_scaffold.py | 6 ++++ tests/test_init_template_contract.py | 26 ++++++++++++++++ 10 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 aai_cli/init/templates/audio-transcription/Dockerfile create mode 100644 aai_cli/init/templates/audio-transcription/dockerignore create mode 100644 aai_cli/init/templates/live-captions/Dockerfile create mode 100644 aai_cli/init/templates/live-captions/dockerignore create mode 100644 aai_cli/init/templates/voice-agent/Dockerfile create mode 100644 aai_cli/init/templates/voice-agent/dockerignore diff --git a/aai_cli/init/scaffold.py b/aai_cli/init/scaffold.py index 0abe070b..1b8c0637 100644 --- a/aai_cli/init/scaffold.py +++ b/aai_cli/init/scaffold.py @@ -20,7 +20,11 @@ PLACEHOLDER_KEY = "your_assemblyai_api_key_here" # Template files stored under plain names -> their real dotted names on copy. -_DOTFILE_RENAMES = {"gitignore": ".gitignore", "env.example": ".env.example"} +_DOTFILE_RENAMES = { + "gitignore": ".gitignore", + "env.example": ".env.example", + "dockerignore": ".dockerignore", +} # Never copy build/test detritus into the user's fresh project. (Loading a template's # api/index.py during our own tests leaves a __pycache__ next to it.) diff --git a/aai_cli/init/templates/audio-transcription/Dockerfile b/aai_cli/init/templates/audio-transcription/Dockerfile new file mode 100644 index 00000000..f4bb98aa --- /dev/null +++ b/aai_cli/init/templates/audio-transcription/Dockerfile @@ -0,0 +1,16 @@ +# Container image for Fly.io, Railway, Render (Docker), and Cloudflare Containers. +# Vercel ignores this and builds api/index.py as a serverless function instead. +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly uses its +# fly.toml internal_port (default 8000). Binds 0.0.0.0 so the platform can reach it. +CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/aai_cli/init/templates/audio-transcription/dockerignore b/aai_cli/init/templates/audio-transcription/dockerignore new file mode 100644 index 00000000..c6c282ad --- /dev/null +++ b/aai_cli/init/templates/audio-transcription/dockerignore @@ -0,0 +1,6 @@ +.env +.venv +__pycache__/ +*.pyc +.git/ +.gitignore diff --git a/aai_cli/init/templates/live-captions/Dockerfile b/aai_cli/init/templates/live-captions/Dockerfile new file mode 100644 index 00000000..f4bb98aa --- /dev/null +++ b/aai_cli/init/templates/live-captions/Dockerfile @@ -0,0 +1,16 @@ +# Container image for Fly.io, Railway, Render (Docker), and Cloudflare Containers. +# Vercel ignores this and builds api/index.py as a serverless function instead. +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly uses its +# fly.toml internal_port (default 8000). Binds 0.0.0.0 so the platform can reach it. +CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/aai_cli/init/templates/live-captions/dockerignore b/aai_cli/init/templates/live-captions/dockerignore new file mode 100644 index 00000000..c6c282ad --- /dev/null +++ b/aai_cli/init/templates/live-captions/dockerignore @@ -0,0 +1,6 @@ +.env +.venv +__pycache__/ +*.pyc +.git/ +.gitignore diff --git a/aai_cli/init/templates/voice-agent/Dockerfile b/aai_cli/init/templates/voice-agent/Dockerfile new file mode 100644 index 00000000..f4bb98aa --- /dev/null +++ b/aai_cli/init/templates/voice-agent/Dockerfile @@ -0,0 +1,16 @@ +# Container image for Fly.io, Railway, Render (Docker), and Cloudflare Containers. +# Vercel ignores this and builds api/index.py as a serverless function instead. +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly uses its +# fly.toml internal_port (default 8000). Binds 0.0.0.0 so the platform can reach it. +CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8000} diff --git a/aai_cli/init/templates/voice-agent/dockerignore b/aai_cli/init/templates/voice-agent/dockerignore new file mode 100644 index 00000000..c6c282ad --- /dev/null +++ b/aai_cli/init/templates/voice-agent/dockerignore @@ -0,0 +1,6 @@ +.env +.venv +__pycache__/ +*.pyc +.git/ +.gitignore diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index 55340ef0..efb6de9c 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -30,6 +30,8 @@ "env.example", "Procfile", "runtime.txt", + "Dockerfile", + "dockerignore", ) _LOCAL_IMPORTS = {"api"} _PKG_MAP = {"dotenv": "python-dotenv", "multipart": "python-multipart"} @@ -194,6 +196,32 @@ def _web_command(template: str, path: Path) -> str: raise AssertionError # unreachable; _fail raises. Satisfies the type checker. +def _dockerfile_runs_uvicorn(template: str, path: Path) -> None: + """The Dockerfile's start command must run the app on the platform's port. + + Fly.io / Railway / Render(Docker) / Cloudflare-Containers build this image instead + of `fly launch`'s broken auto-generated Dockerfile. It must run uvicorn on the app, + bind 0.0.0.0 so the platform can reach it, and honor the injected ${PORT}. + """ + dockerfile = (path / "Dockerfile").read_text(encoding="utf-8") + for token in ("uvicorn api.index:app", "--host 0.0.0.0", "${PORT"): + if token not in dockerfile: + _fail(f"{template}: Dockerfile must contain {token!r}") + + +def _dockerignore_excludes_env(template: str, path: Path) -> None: + """`.env` (the real API key) must be excluded from the build context. + + The Dockerfile does `COPY . .` and Fly/Railway upload the local dir as build + context, so without this the key would be baked into the image. + """ + lines = { + line.strip() for line in (path / "dockerignore").read_text(encoding="utf-8").splitlines() + } + if ".env" not in lines: + _fail(f"{template}: dockerignore must list .env so the API key isn't baked into the image") + + def _free_port() -> int: with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as probe: probe.bind(("127.0.0.1", 0)) @@ -293,6 +321,8 @@ def main() -> int: _parse_python_files(path) _import_api(template, path) _runtime_supported(template, path) + _dockerfile_runs_uvicorn(template, path) + _dockerignore_excludes_env(template, path) _procfile_boots(template, path) sys.stdout.write(f"validated {len(templates.TEMPLATE_ORDER)} init templates\n") return 0 diff --git a/tests/test_init_scaffold.py b/tests/test_init_scaffold.py index d8b1ab72..de30657d 100644 --- a/tests/test_init_scaffold.py +++ b/tests/test_init_scaffold.py @@ -40,6 +40,12 @@ def test_scaffold_copies_files_and_renames_dotfiles(tmp_path): # the plain-named source files are NOT copied verbatim assert not (target / "gitignore").exists() assert not (target / "env.example").exists() + # the container build ships, and dockerignore is renamed to its dotted name + assert (target / "Dockerfile").exists() + assert (target / ".dockerignore").exists() + assert not (target / "dockerignore").exists() + # .dockerignore must exclude .env so the real key isn't baked into the image + assert ".env" in (target / ".dockerignore").read_text().splitlines() def test_scaffold_writes_env_with_key(tmp_path): diff --git a/tests/test_init_template_contract.py b/tests/test_init_template_contract.py index e81aef8a..95b89d35 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -34,10 +34,36 @@ def test_required_files_present(template_dir): "env.example", "Procfile", "runtime.txt", + "Dockerfile", + "dockerignore", ): assert (template_dir / rel).exists(), f"{template_dir.name} missing {rel}" +def test_dockerfile_runs_uvicorn_on_platform_port(template_dir): + """Fly/Railway/Render(Docker)/Cloudflare-Containers build this image. It must run + uvicorn on the app, bind 0.0.0.0, and honor the platform's injected ${PORT}.""" + dockerfile = (template_dir / "Dockerfile").read_text() + assert "uvicorn api.index:app" in dockerfile, ( + f"{template_dir.name}: Dockerfile must run uvicorn api.index:app" + ) + assert "--host 0.0.0.0" in dockerfile, ( + f"{template_dir.name}: Dockerfile must bind --host 0.0.0.0" + ) + assert "${PORT" in dockerfile, ( + f"{template_dir.name}: Dockerfile must honor the platform's ${{PORT}}" + ) + + +def test_dockerignore_excludes_env(template_dir): + """`.env` holds the real API key; the Dockerfile does COPY . . so it must be + excluded from the build context or the key gets baked into the image.""" + lines = {line.strip() for line in (template_dir / "dockerignore").read_text().splitlines()} + assert ".env" in lines, ( + f"{template_dir.name}: dockerignore must list .env so the API key isn't baked in" + ) + + def test_template_ships_no_public_dir(template_dir): # Vercel serves a top-level public/** from its CDN and omits it from the Python # lambda, so a FastAPI app that reads from public/ crashes at import on deploy. From 678617ac3123daa705b378d9253920f8c2398cd1 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:01:46 -0700 Subject: [PATCH 34/40] test: add scripts/docker_build_check.sh to build all template Docker images Scaffolds each template and runs docker build to prove the shipped Dockerfile works (deps install, COPY layout, CMD). Self-skips without Docker; shellcheck-linted by check.sh. Verified all 3 images build. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/check.sh | 2 +- scripts/docker_build_check.sh | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100755 scripts/docker_build_check.sh diff --git a/scripts/check.sh b/scripts/check.sh index 14b99f03..a57bb5ab 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -116,7 +116,7 @@ echo "==> shellcheck (install.sh)" # Static-lint the public install script and this gate 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 scripts/check.sh + shellcheck install.sh scripts/check.sh scripts/docker_build_check.sh else echo " shellcheck not found; skipping (CI runs it)" fi diff --git a/scripts/docker_build_check.sh b/scripts/docker_build_check.sh new file mode 100755 index 00000000..817da524 --- /dev/null +++ b/scripts/docker_build_check.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Build each `aai init` template's Docker image to prove the shipped Dockerfile works +# end to end: scaffold the template (which renames `dockerignore` -> `.dockerignore` +# and writes a placeholder `.env`), then `docker build` the result. This catches a +# broken Dockerfile, an uninstallable requirement, or a bad COPY layout — none of which +# the static template-contract gate can see. +# +# Run from the repo root: ./scripts/docker_build_check.sh +# +# Self-skips when Docker is unavailable, the same way scripts/check.sh skips its +# optional linters when their tools are absent — so it's safe to call from CI +# unconditionally. +set -euo pipefail + +if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then + echo "==> docker build check: Docker not available; skipping" + exit 0 +fi + +templates=(audio-transcription live-captions voice-agent) + +workdir="$(mktemp -d)" +cleanup() { + rm -rf "$workdir" + for t in "${templates[@]}"; do + docker image rm "aai-template-${t}:dockercheck" >/dev/null 2>&1 || true + done +} +trap cleanup EXIT + +for t in "${templates[@]}"; do + app="$workdir/$t" + echo "==> scaffolding $t" + uv run aai init "$t" "$app" --no-install >/dev/null + echo "==> docker build $t" + docker build --quiet -t "aai-template-${t}:dockercheck" "$app" +done + +echo "All ${#templates[@]} template Docker images build." From 3437fd5c779a3db21f0203651ac09297debc15a3 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:06:32 -0700 Subject: [PATCH 35/40] feat(deploy): --fly runs 'fly launch' (creates app + deploys); drop the fly.toml preflight Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/commands/deploy.py | 20 +++----------- .../test_cli_output_snapshots.ambr | 2 +- tests/test_deploy.py | 26 +++++-------------- 3 files changed, 11 insertions(+), 37 deletions(-) diff --git a/aai_cli/commands/deploy.py b/aai_cli/commands/deploy.py index 37a941bb..f37a7ff3 100644 --- a/aai_cli/commands/deploy.py +++ b/aai_cli/commands/deploy.py @@ -26,8 +26,6 @@ class Target: deploy_args: tuple[str, ...] # subcommand(s) appended after `bin` supports_prod: bool = False # whether `--prod` adds a production flag post_deploy_args: tuple[str, ...] | None = None # command run after a successful deploy - requires_file: str | None = None # a file that must exist in cwd before deploying - setup_hint: str | None = None # how to create `requires_file` def command(self, *, prod: bool) -> list[str]: argv = [self.bin, *self.deploy_args] @@ -57,9 +55,9 @@ def command(self, *, prod: bool) -> list[str]: bin="fly", flag="--fly", install="Install it with `brew install flyctl`.", - deploy_args=("deploy",), - requires_file="fly.toml", - setup_hint="Run `fly launch` first to create your Fly app.", + # `fly launch` does it all: creates the app, generates fly.toml (detecting the + # shipped Dockerfile), and deploys — so no fly.toml needs to exist beforehand. + deploy_args=("launch",), ) TARGETS = (VERCEL, RAILWAY, FLY) @@ -85,15 +83,6 @@ def _require_cli(target: Target) -> None: ) -def _require_setup(target: Target) -> None: - if target.requires_file is not None and not (Path.cwd() / target.requires_file).exists(): - raise CLIError( - f"No {target.requires_file} in this directory. {target.setup_hint}", - error_type="usage_error", - exit_code=1, - ) - - def _confirmed(target: Target, *, assume_yes: bool) -> bool: """True when the deploy should proceed: --yes, or an interactive yes. @@ -113,7 +102,6 @@ def _confirmed(target: Target, *, assume_yes: bool) -> bool: def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None: """Confirm, then run the target's deploy command in the current directory.""" _require_cli(target) - _require_setup(target) if not _confirmed(target, assume_yes=assume_yes): output.console.print("Aborted.") return @@ -146,7 +134,7 @@ def deploy( """Deploy the current project to Vercel (default), Railway, or Fly.io. Asks for confirmation first, then runs the target's CLI (`vercel deploy`, - `railway up`, or `fly deploy`). Requires that target's CLI to be installed. + `railway up`, or `fly launch`). Requires that target's CLI to be installed. (Render deploys from a connected Git repo — see the project README.) """ diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 7e139fa5..37f577f1 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -131,7 +131,7 @@ Deploy the current project to Vercel (default), Railway, or Fly.io. Asks for confirmation first, then runs the target's CLI (`vercel deploy`, - `railway up`, or `fly deploy`). Requires that target's CLI to be installed. + `railway up`, or `fly launch`). Requires that target's CLI to be installed. (Render deploys from a connected Git repo — see the project README.) ╭─ Options ────────────────────────────────────────────────────────────────────╮ diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 2a2ab240..586822f3 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -199,34 +199,20 @@ def test_deploy_prod_ignored_for_railway(monkeypatch: pytest.MonkeyPatch) -> Non assert _cmds(calls)[0] == ["railway", "up"] -def test_deploy_fly_flag(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "fly.toml").write_text("app = 'x'\n") +def test_deploy_fly_flag(monkeypatch: pytest.MonkeyPatch) -> None: + # `fly launch` creates the app, generates fly.toml (detecting the shipped + # Dockerfile), and deploys — one command, no fly.toml needed beforehand. calls = _stub(monkeypatch, available=("fly",)) result = runner.invoke(app, ["deploy", "--fly", "--yes"]) assert result.exit_code == 0, result.output - assert _cmds(calls) == [["fly", "deploy"]] + assert _cmds(calls) == [["fly", "launch"]] -def test_deploy_prod_ignored_for_fly(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / "fly.toml").write_text("app = 'x'\n") +def test_deploy_prod_ignored_for_fly(monkeypatch: pytest.MonkeyPatch) -> None: calls = _stub(monkeypatch, available=("fly",)) result = runner.invoke(app, ["deploy", "--fly", "--prod", "--yes"]) assert result.exit_code == 0, result.output - assert _cmds(calls)[0] == ["fly", "deploy"] - - -def test_deploy_fly_without_toml_errors(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - # Without a fly.toml, the preflight fails early with a `fly launch` hint and - # never shells out to `fly deploy`. - monkeypatch.chdir(tmp_path) - calls = _stub(monkeypatch, available=("fly",)) - result = runner.invoke(app, ["deploy", "--fly", "--yes"]) - assert result.exit_code == 1 - assert "fly.toml" in result.output - assert "fly launch" in result.output - assert _cmds(calls) == [] # never deployed + assert _cmds(calls)[0] == ["fly", "launch"] def test_deploy_missing_fly_errors(monkeypatch: pytest.MonkeyPatch) -> None: From ad1a1d5c993deef405f748dd0a378fb628853df5 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:11:44 -0700 Subject: [PATCH 36/40] fix(templates): EXPOSE 8080 and default to 8080 so Fly's internal_port matches the app Co-Authored-By: Claude Opus 4.8 (1M context) --- .../init/templates/audio-transcription/Dockerfile | 8 +++++--- aai_cli/init/templates/live-captions/Dockerfile | 8 +++++--- aai_cli/init/templates/voice-agent/Dockerfile | 8 +++++--- scripts/template_contract_gate.py | 13 +++++++++++++ tests/test_init_template_contract.py | 14 ++++++++++++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/aai_cli/init/templates/audio-transcription/Dockerfile b/aai_cli/init/templates/audio-transcription/Dockerfile index f4bb98aa..ac6cde94 100644 --- a/aai_cli/init/templates/audio-transcription/Dockerfile +++ b/aai_cli/init/templates/audio-transcription/Dockerfile @@ -11,6 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly uses its -# fly.toml internal_port (default 8000). Binds 0.0.0.0 so the platform can reach it. -CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8000} +# Fly reads EXPOSE to set its fly.toml internal_port; keep it in sync with the CMD +# default so the proxy and the app agree on the port. +EXPOSE 8080 +# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly maps to 8080. +CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8080} diff --git a/aai_cli/init/templates/live-captions/Dockerfile b/aai_cli/init/templates/live-captions/Dockerfile index f4bb98aa..ac6cde94 100644 --- a/aai_cli/init/templates/live-captions/Dockerfile +++ b/aai_cli/init/templates/live-captions/Dockerfile @@ -11,6 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly uses its -# fly.toml internal_port (default 8000). Binds 0.0.0.0 so the platform can reach it. -CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8000} +# Fly reads EXPOSE to set its fly.toml internal_port; keep it in sync with the CMD +# default so the proxy and the app agree on the port. +EXPOSE 8080 +# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly maps to 8080. +CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8080} diff --git a/aai_cli/init/templates/voice-agent/Dockerfile b/aai_cli/init/templates/voice-agent/Dockerfile index f4bb98aa..ac6cde94 100644 --- a/aai_cli/init/templates/voice-agent/Dockerfile +++ b/aai_cli/init/templates/voice-agent/Dockerfile @@ -11,6 +11,8 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly uses its -# fly.toml internal_port (default 8000). Binds 0.0.0.0 so the platform can reach it. -CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8000} +# Fly reads EXPOSE to set its fly.toml internal_port; keep it in sync with the CMD +# default so the proxy and the app agree on the port. +EXPOSE 8080 +# Shell form so ${PORT} expands. Railway/Render inject $PORT; Fly maps to 8080. +CMD python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-8080} diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index efb6de9c..4373a75d 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -207,6 +207,19 @@ def _dockerfile_runs_uvicorn(template: str, path: Path) -> None: for token in ("uvicorn api.index:app", "--host 0.0.0.0", "${PORT"): if token not in dockerfile: _fail(f"{template}: Dockerfile must contain {token!r}") + # Fly auto-detects internal_port from EXPOSE; without a match to the CMD's + # default port, Fly's proxy hits a port the app never binds. + exposed = re.search(r"^EXPOSE\s+(\d+)\s*$", dockerfile, re.MULTILINE) + cmd_default = re.search(r"--port \$\{PORT:-(\d+)\}", dockerfile) + if exposed is None: + _fail(f"{template}: Dockerfile must declare EXPOSE 8080") + if cmd_default is None: + _fail(f"{template}: Dockerfile CMD must default to ${{PORT:-8080}}") + if exposed.group(1) != "8080" or cmd_default.group(1) != "8080": + _fail( + f"{template}: Dockerfile must EXPOSE 8080 and default CMD to ${{PORT:-8080}}; " + f"got EXPOSE {exposed.group(1)} and ${{PORT:-{cmd_default.group(1)}}}" + ) def _dockerignore_excludes_env(template: str, path: Path) -> None: diff --git a/tests/test_init_template_contract.py b/tests/test_init_template_contract.py index 95b89d35..0f8b3185 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -53,6 +53,20 @@ def test_dockerfile_runs_uvicorn_on_platform_port(template_dir): assert "${PORT" in dockerfile, ( f"{template_dir.name}: Dockerfile must honor the platform's ${{PORT}}" ) + # Fly auto-detects internal_port from EXPOSE; it must match the CMD's default + # port or Fly's proxy hits a port the app never binds (connection refused). + exposed = re.search(r"^EXPOSE\s+(\d+)\s*$", dockerfile, re.MULTILINE) + cmd_default = re.search(r"--port \$\{PORT:-(\d+)\}", dockerfile) + assert exposed is not None and exposed.group(1) == "8080", ( + f"{template_dir.name}: Dockerfile must declare EXPOSE 8080" + ) + assert cmd_default is not None and cmd_default.group(1) == "8080", ( + f"{template_dir.name}: Dockerfile CMD must default to ${{PORT:-8080}}" + ) + assert exposed.group(1) == cmd_default.group(1), ( + f"{template_dir.name}: EXPOSE {exposed.group(1)} must match " + f"CMD default ${{PORT:-{cmd_default.group(1)}}}" + ) def test_dockerignore_excludes_env(template_dir): From 8a996c57385f44e5c20e739fee0f8b761d745762 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:13:50 -0700 Subject: [PATCH 37/40] refactor(contract-gate): annotate _fail as NoReturn so the EXPOSE regex checks type-narrow Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/template_contract_gate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index 4373a75d..0da912b2 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -12,6 +12,7 @@ import time from contextlib import closing, contextmanager from pathlib import Path +from typing import NoReturn from aai_cli.init import templates @@ -38,7 +39,7 @@ _STDLIB = set(sys.stdlib_module_names) | {"__future__"} -def _fail(message: str) -> None: +def _fail(message: str) -> NoReturn: raise RuntimeError(message) From 0f3fd570293d8ca49324ec91f987bd3174abb993 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:23:50 -0700 Subject: [PATCH 38/40] feat(templates): return a clear error when ASSEMBLYAI_API_KEY is unset instead of a cryptic Bearer/SDK failure Co-Authored-By: Claude Opus 4.8 (1M context) --- .../audio-transcription/api/index.py | 13 ++++++ .../init/templates/live-captions/api/index.py | 10 +++++ .../init/templates/voice-agent/api/index.py | 10 +++++ tests/test_init_template_agent.py | 5 +++ tests/test_init_template_serve.py | 42 ++++++++++++++++++- tests/test_init_template_transcribe.py | 5 ++- 6 files changed, 83 insertions(+), 2 deletions(-) diff --git a/aai_cli/init/templates/audio-transcription/api/index.py b/aai_cli/init/templates/audio-transcription/api/index.py index 2f999333..62f0e3b8 100644 --- a/aai_cli/init/templates/audio-transcription/api/index.py +++ b/aai_cli/init/templates/audio-transcription/api/index.py @@ -41,6 +41,15 @@ app.mount("/static", StaticFiles(directory=STATIC), name="static") +def _require_key() -> None: + """Fail fast with an actionable message when the API key isn't configured.""" + if not settings.API_KEY: + raise HTTPException( + status_code=500, + detail="ASSEMBLYAI_API_KEY is not set — configure it in your deployment's environment variables.", + ) + + @app.get("/") def index() -> FileResponse: return FileResponse(STATIC / "index.html") @@ -61,6 +70,7 @@ def _submit(audio: str) -> dict[str, str]: @app.post("/api/transcribe") async def transcribe(file: UploadFile) -> dict[str, str]: + _require_key() suffix = Path(file.filename or "audio").suffix tmp = Path(tempfile.gettempdir()) / f"aai-{uuid.uuid4().hex}{suffix}" tmp.write_bytes(await file.read()) @@ -72,6 +82,7 @@ async def transcribe(file: UploadFile) -> dict[str, str]: @app.post("/api/transcribe-url") def transcribe_url(url: str = Body(default=settings.SAMPLE_URL, embed=True)) -> dict[str, str]: + _require_key() # AssemblyAI transcribes a public URL directly — no upload step needed. return _submit(url.strip() or settings.SAMPLE_URL) @@ -79,6 +90,7 @@ def transcribe_url(url: str = Body(default=settings.SAMPLE_URL, embed=True)) -> @app.post("/api/ask") def ask(transcript_id: str = Body(...), question: str = Body(...)) -> dict[str, str]: """Answer a question about a transcript via the LLM Gateway.""" + _require_key() client = OpenAI(api_key=settings.API_KEY, base_url=settings.LLM_GATEWAY_URL) try: resp = client.chat.completions.create( @@ -94,6 +106,7 @@ def ask(transcript_id: str = Body(...), question: str = Body(...)) -> dict[str, @app.get("/api/status/{transcript_id}") def status(transcript_id: str) -> dict[str, object]: + _require_key() # One non-blocking GET. NOTE: aai.Transcript.get_by_id() BLOCKS until the job # finishes (it calls wait_for_completion), which is wrong for a poll endpoint. try: diff --git a/aai_cli/init/templates/live-captions/api/index.py b/aai_cli/init/templates/live-captions/api/index.py index 071a8ad4..0b91b3ce 100644 --- a/aai_cli/init/templates/live-captions/api/index.py +++ b/aai_cli/init/templates/live-captions/api/index.py @@ -26,6 +26,15 @@ app.mount("/static", StaticFiles(directory=STATIC), name="static") +def _require_key() -> None: + """Fail fast with an actionable message when the API key isn't configured.""" + if not settings.API_KEY: + raise HTTPException( + status_code=500, + detail="ASSEMBLYAI_API_KEY is not set — configure it in your deployment's environment variables.", + ) + + @app.get("/") def index() -> FileResponse: return FileResponse(STATIC / "index.html") @@ -34,6 +43,7 @@ def index() -> FileResponse: @app.post("/api/token") def token() -> dict[str, str]: """Mint a one-time streaming token. The browser uses it to open the WebSocket.""" + _require_key() # NOTE: the streaming token uses the raw API key as Authorization (no 'Bearer'). try: resp = httpx2.get( diff --git a/aai_cli/init/templates/voice-agent/api/index.py b/aai_cli/init/templates/voice-agent/api/index.py index e6f176a4..d6cda54b 100644 --- a/aai_cli/init/templates/voice-agent/api/index.py +++ b/aai_cli/init/templates/voice-agent/api/index.py @@ -26,6 +26,15 @@ app.mount("/static", StaticFiles(directory=STATIC), name="static") +def _require_key() -> None: + """Fail fast with an actionable message when the API key isn't configured.""" + if not settings.API_KEY: + raise HTTPException( + status_code=500, + detail="ASSEMBLYAI_API_KEY is not set — configure it in your deployment's environment variables.", + ) + + @app.get("/") def index() -> FileResponse: return FileResponse(STATIC / "index.html") @@ -34,6 +43,7 @@ def index() -> FileResponse: @app.post("/api/token") def token() -> dict[str, str]: """Mint a one-time Voice Agent token. The browser uses it to open the WebSocket.""" + _require_key() # NOTE: the Voice Agent token uses Bearer auth (unlike the streaming token). try: resp = httpx2.get( diff --git a/tests/test_init_template_agent.py b/tests/test_init_template_agent.py index 7fc0146c..32b0769c 100644 --- a/tests/test_init_template_agent.py +++ b/tests/test_init_template_agent.py @@ -1,4 +1,5 @@ import importlib +import os import sys from pathlib import Path @@ -8,6 +9,10 @@ def _load_app(monkeypatch): + # Inject a dummy key (unless a test already set its own) so the token endpoint's + # _require_key guard passes and reaches the mocked httpx2 call (isolate_env strips + # any ambient key). + monkeypatch.setenv("ASSEMBLYAI_API_KEY", os.environ.get("ASSEMBLYAI_API_KEY", "test-key")) for name in ("api.index", "api.settings", "api"): sys.modules.pop(name, None) monkeypatch.syspath_prepend(str(TEMPLATE_DIR)) diff --git a/tests/test_init_template_serve.py b/tests/test_init_template_serve.py index 0e9c2af5..26134543 100644 --- a/tests/test_init_template_serve.py +++ b/tests/test_init_template_serve.py @@ -19,6 +19,7 @@ from __future__ import annotations import importlib +import os import sys from collections.abc import Iterator from contextlib import contextmanager @@ -49,7 +50,10 @@ def serve(template: str) -> Iterator[tuple[ModuleType, TestClient]]: The three templates ship an identically-named ``api`` package, so evict any cached ``api``/``api.*`` before and after to keep imports collision-free and order-independent (safe under pytest-xdist / pytest-randomly). The app reads ``ASSEMBLYAI_API_KEY`` at - import; the autouse ``isolate_env`` fixture has already stripped it, so it boots keyless. + import; the autouse ``isolate_env`` fixture strips it, so we inject a dummy key before + import so endpoints clear the ``_require_key`` guard and reach their mocked backends. + (Tests that want to exercise the missing-key guard clear ``module.settings.API_KEY`` + after import — the guard re-reads it at request time.) """ path = (TEMPLATES_ROOT / template).resolve() saved_path = list(sys.path) @@ -57,11 +61,17 @@ def serve(template: str) -> Iterator[tuple[ModuleType, TestClient]]: for name in list(saved_modules): sys.modules.pop(name, None) sys.path.insert(0, str(path)) + saved_key = os.environ.get("ASSEMBLYAI_API_KEY") + os.environ["ASSEMBLYAI_API_KEY"] = "test-key" try: module = importlib.import_module("api.index") client = TestClient(module.app, raise_server_exceptions=False) yield module, client finally: + if saved_key is None: + os.environ.pop("ASSEMBLYAI_API_KEY", None) + else: + os.environ["ASSEMBLYAI_API_KEY"] = saved_key for name in [n for n in sys.modules if n == "api" or n.startswith("api.")]: sys.modules.pop(name, None) sys.modules.update(saved_modules) @@ -249,3 +259,33 @@ def test_token_failure_is_graceful(template: str, monkeypatch: pytest.MonkeyPatc result = client.post("/api/token") assert result.status_code == HTTP_BAD_GATEWAY assert "detail" in result.json() + + +# --- missing API key: every template fails fast with an actionable message ---------- + +HTTP_INTERNAL_ERROR = 500 + +# (template, method, path, json body) for one representative key-using endpoint each. +MISSING_KEY_CASES = [ + ("voice-agent", "post", "/api/token", None), + ("live-captions", "post", "/api/token", None), + ("audio-transcription", "post", "/api/transcribe-url", {"url": "https://example.com/a.mp3"}), +] + + +@pytest.mark.parametrize(("template", "method", "path", "body"), MISSING_KEY_CASES) +def test_missing_key_returns_clear_error( + template: str, + method: str, + path: str, + body: dict[str, str] | None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An unset key yields a clear 500 (not a cryptic Bearer/SDK failure) before any call.""" + with serve(template) as (module, client): + # serve() injects a dummy key at import; clear it so _require_key() trips at + # request time (the guard re-reads settings.API_KEY on each request). + monkeypatch.setattr(module.settings, "API_KEY", "") + resp = client.request(method, path, json=body) + assert resp.status_code == HTTP_INTERNAL_ERROR + assert "ASSEMBLYAI_API_KEY is not set" in resp.json()["detail"] diff --git a/tests/test_init_template_transcribe.py b/tests/test_init_template_transcribe.py index 7259c58c..d5bd38ed 100644 --- a/tests/test_init_template_transcribe.py +++ b/tests/test_init_template_transcribe.py @@ -11,8 +11,11 @@ def _load_app(monkeypatch, mocker): """Import the template's api/index.py as a module and return its FastAPI app. The template is a standalone project (not part of aai_cli's import graph), so we - load it by file path. The assemblyai SDK is stubbed so no network/key is needed. + load it by file path. The assemblyai SDK is stubbed so no network is needed; a dummy + key is injected so the endpoints' ``_require_key`` guard passes and they reach the + stubbed SDK (isolate_env strips any ambient key). """ + monkeypatch.setenv("ASSEMBLYAI_API_KEY", "test-key") fake_aai = mocker.MagicMock() fake_aai.TranscriptStatus.completed = "completed" fake_aai.TranscriptStatus.error = "error" From b73334bad87a50a644e98f418824cd3ad06c81bf Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:37:36 -0700 Subject: [PATCH 39/40] fix(templates): run container as non-root USER (Aikido CKV_DOCKER_3) Co-Authored-By: Claude Opus 4.8 (1M context) --- aai_cli/init/templates/audio-transcription/Dockerfile | 6 ++++++ aai_cli/init/templates/live-captions/Dockerfile | 6 ++++++ aai_cli/init/templates/voice-agent/Dockerfile | 6 ++++++ scripts/template_contract_gate.py | 6 ++++++ tests/test_init_template_contract.py | 9 +++++++++ 5 files changed, 33 insertions(+) diff --git a/aai_cli/init/templates/audio-transcription/Dockerfile b/aai_cli/init/templates/audio-transcription/Dockerfile index ac6cde94..73deb13c 100644 --- a/aai_cli/init/templates/audio-transcription/Dockerfile +++ b/aai_cli/init/templates/audio-transcription/Dockerfile @@ -11,6 +11,12 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +# Run as a non-root user (container hardening; the app only reads its files and binds +# a non-privileged port, so it needs no elevated privileges). python:3.13-slim is +# Debian-based, so `useradd` is available. +RUN useradd --create-home appuser +USER appuser + # Fly reads EXPOSE to set its fly.toml internal_port; keep it in sync with the CMD # default so the proxy and the app agree on the port. EXPOSE 8080 diff --git a/aai_cli/init/templates/live-captions/Dockerfile b/aai_cli/init/templates/live-captions/Dockerfile index ac6cde94..73deb13c 100644 --- a/aai_cli/init/templates/live-captions/Dockerfile +++ b/aai_cli/init/templates/live-captions/Dockerfile @@ -11,6 +11,12 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +# Run as a non-root user (container hardening; the app only reads its files and binds +# a non-privileged port, so it needs no elevated privileges). python:3.13-slim is +# Debian-based, so `useradd` is available. +RUN useradd --create-home appuser +USER appuser + # Fly reads EXPOSE to set its fly.toml internal_port; keep it in sync with the CMD # default so the proxy and the app agree on the port. EXPOSE 8080 diff --git a/aai_cli/init/templates/voice-agent/Dockerfile b/aai_cli/init/templates/voice-agent/Dockerfile index ac6cde94..73deb13c 100644 --- a/aai_cli/init/templates/voice-agent/Dockerfile +++ b/aai_cli/init/templates/voice-agent/Dockerfile @@ -11,6 +11,12 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +# Run as a non-root user (container hardening; the app only reads its files and binds +# a non-privileged port, so it needs no elevated privileges). python:3.13-slim is +# Debian-based, so `useradd` is available. +RUN useradd --create-home appuser +USER appuser + # Fly reads EXPOSE to set its fly.toml internal_port; keep it in sync with the CMD # default so the proxy and the app agree on the port. EXPOSE 8080 diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index 0da912b2..d821802a 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -221,6 +221,12 @@ def _dockerfile_runs_uvicorn(template: str, path: Path) -> None: f"{template}: Dockerfile must EXPOSE 8080 and default CMD to ${{PORT:-8080}}; " f"got EXPOSE {exposed.group(1)} and ${{PORT:-{cmd_default.group(1)}}}" ) + # Container hardening: the image must drop root (Aikido/Checkov CKV_DOCKER_3). + user = re.search(r"^USER\s+(\S+)\s*$", dockerfile, re.MULTILINE) + if user is None: + _fail(f"{template}: Dockerfile must declare a non-root USER (CKV_DOCKER_3)") + if user.group(1) in {"root", "0"}: + _fail(f"{template}: Dockerfile USER must not be root; got {user.group(1)!r} (CKV_DOCKER_3)") def _dockerignore_excludes_env(template: str, path: Path) -> None: diff --git a/tests/test_init_template_contract.py b/tests/test_init_template_contract.py index 0f8b3185..4468caeb 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -67,6 +67,15 @@ def test_dockerfile_runs_uvicorn_on_platform_port(template_dir): f"{template_dir.name}: EXPOSE {exposed.group(1)} must match " f"CMD default ${{PORT:-{cmd_default.group(1)}}}" ) + # Container hardening: the image must drop root (Aikido/Checkov CKV_DOCKER_3). + user = re.search(r"^USER\s+(\S+)\s*$", dockerfile, re.MULTILINE) + assert user is not None, ( + f"{template_dir.name}: Dockerfile must declare a non-root USER (CKV_DOCKER_3)" + ) + assert user.group(1) not in {"root", "0"}, ( + f"{template_dir.name}: Dockerfile USER must not be root; " + f"got {user.group(1)!r} (CKV_DOCKER_3)" + ) def test_dockerignore_excludes_env(template_dir): From 4e31fe824825cc9b82f61c3203d16154c1a540e6 Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 9 Jun 2026 15:59:24 -0700 Subject: [PATCH 40/40] test: fix two CI-only failures (color + keyless) - test_deploy_help_lists_flags: CI renders --help with ANSI color, which inserts escape codes mid-flag (--...-fly), breaking the substring match. Strip ANSI before asserting. - test_init_template_stream: _load_app booted keyless, so the new missing-API-key guard returned 500 instead of 502/200. Inject a dummy key (preserving a pre-set one), matching the other template suites. Was masked locally by the repo .env. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_deploy.py | 7 ++++++- tests/test_init_template_stream.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_deploy.py b/tests/test_deploy.py index 586822f3..6e445a2b 100644 --- a/tests/test_deploy.py +++ b/tests/test_deploy.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +import re import types from collections.abc import Sequence from pathlib import Path @@ -13,6 +14,10 @@ runner = CliRunner() +# CI forces color; Rich then styles option flags with ANSI codes inserted mid-token +# (e.g. `--[…m-fly`), so the literal "--fly" isn't a substring. Strip ANSI first. +_ANSI = re.compile(r"\x1b\[[0-9;]*m") + def test_targets_are_frozen() -> None: # Every deploy target is a module-level singleton; freezing them guards @@ -241,4 +246,4 @@ def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatc def test_deploy_help_lists_flags(flag: str) -> None: result = runner.invoke(app, ["deploy", "--help"]) assert result.exit_code == 0 - assert flag in result.output + assert flag in _ANSI.sub("", result.output) diff --git a/tests/test_init_template_stream.py b/tests/test_init_template_stream.py index 72de01d4..60694936 100644 --- a/tests/test_init_template_stream.py +++ b/tests/test_init_template_stream.py @@ -1,4 +1,5 @@ import importlib +import os import sys from pathlib import Path @@ -8,6 +9,9 @@ def _load_app(monkeypatch): + # Boot with a key so the endpoint's missing-key guard passes and reaches the mock; + # preserve a key a test set before calling us (e.g. the raw-auth-header test). + monkeypatch.setenv("ASSEMBLYAI_API_KEY", os.environ.get("ASSEMBLYAI_API_KEY") or "test-key") for name in ("api.index", "api.settings", "api"): sys.modules.pop(name, None) monkeypatch.syspath_prepend(str(TEMPLATE_DIR))