diff --git a/.importlinter b/.importlinter index 2712113b..e489e646 100644 --- a/.importlinter +++ b/.importlinter @@ -38,6 +38,8 @@ 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 aai_cli.commands.keys @@ -45,6 +47,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/deploy.py b/aai_cli/commands/deploy.py new file mode 100644 index 00000000..f37a7ff3 --- /dev/null +++ b/aai_cli/commands/deploy.py @@ -0,0 +1,145 @@ +# aai_cli/commands/deploy.py +from __future__ import annotations + +import shutil +import subprocess +from dataclasses import dataclass +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() + + +@dataclass(frozen=True) +class Target: + name: str # human label, e.g. "Vercel" + bin: str # executable resolved via shutil.which + 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 + post_deploy_args: tuple[str, ...] | None = None # command run after a successful deploy + + def command(self, *, prod: bool) -> list[str]: + 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",), + post_deploy_args=("domain",), +) +FLY = Target( + name="Fly", + bin="fly", + flag="--fly", + install="Install it with `brew install flyctl`.", + # `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) + + +def _resolve_target(selected: list[Target]) -> Target: + if len(selected) > 1: + flags = " / ".join(t.flag for t in TARGETS) + raise CLIError( + f"Pass at most one deploy target ({flags}).", + error_type="usage_error", + exit_code=1, + ) + 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. {target.install}", + error_type="missing_dependency", + exit_code=1, + ) + + +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.""" + 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(f"Deploy this project to {target.name}?") + + +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 + 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( + rich_help_panel=help_panels.BUILD, + epilog=examples_epilog( + [ + ("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 Fly.io", "aai deploy --fly"), + ] + ), +) +def deploy( + ctx: typer.Context, + 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."), + 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, or Fly.io. + + Asks for confirmation first, then runs the target's CLI (`vercel deploy`, + `railway up`, or `fly launch`). 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), (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/commands/dev.py b/aai_cli/commands/dev.py new file mode 100644 index 00000000..8b456526 --- /dev/null +++ b/aai_cli/commands/dev.py @@ -0,0 +1,80 @@ +# aai_cli/commands/dev.py +from __future__ import annotations + +import os +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.help_text import examples_epilog +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 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() + 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] = [ + 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 = devserver.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.run_server( + target, command=command, port=chosen_port, env=env, open_browser=not no_open + ) + 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/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/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/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/init/devserver.py b/aai_cli/init/devserver.py new file mode 100644 index 00000000..3567bd6c --- /dev/null +++ b/aai_cli/init/devserver.py @@ -0,0 +1,36 @@ +# 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. + + 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`. + """ + 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/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 11a1cba4..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="") @@ -81,12 +105,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) -> int: - """Start the dev server, wait for it, open the browser, and block until Ctrl-C. +def run_server( + target: Path, + *, + command: list[str], + port: int, + env: dict[str, str] | None = None, + open_browser: bool, +) -> int: + """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), 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}") @@ -96,3 +128,13 @@ def launch_and_open(target: Path, *, port: int, use_uv: bool, open_browser: bool 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/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/AGENTS.md b/aai_cli/init/templates/audio-transcription/AGENTS.md index f9568c10..b199bffe 100644 --- a/aai_cli/init/templates/audio-transcription/AGENTS.md +++ b/aai_cli/init/templates/audio-transcription/AGENTS.md @@ -3,29 +3,29 @@ This is a buildless FastAPI + static HTML starter. Run it with: ```sh -uvicorn api.index:app --reload --port 3000 +aai dev ``` ## Map - `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/Dockerfile b/aai_cli/init/templates/audio-transcription/Dockerfile new file mode 100644 index 00000000..73deb13c --- /dev/null +++ b/aai_cli/init/templates/audio-transcription/Dockerfile @@ -0,0 +1,24 @@ +# 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 . . + +# 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 +# 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/audio-transcription/Procfile b/aai_cli/init/templates/audio-transcription/Procfile new file mode 100644 index 00000000..8837c118 --- /dev/null +++ b/aai_cli/init/templates/audio-transcription/Procfile @@ -0,0 +1 @@ +web: python -m 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..83f559d7 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`). @@ -17,12 +16,28 @@ uvicorn api.index:app --reload --port 3000 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 + +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 +``` + +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. - 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..62f0e3b8 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,23 @@ 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") + + +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(PUBLIC / "index.html") + return FileResponse(STATIC / "index.html") def _submit(audio: str) -> dict[str, str]: @@ -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/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/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/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 534c2462..8415db15 100644 --- a/aai_cli/init/templates/live-captions/AGENTS.md +++ b/aai_cli/init/templates/live-captions/AGENTS.md @@ -3,30 +3,30 @@ This is a buildless FastAPI + browser microphone starter. Run it with: ```sh -uvicorn api.index:app --reload --port 3000 +aai dev ``` ## Map - `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/Dockerfile b/aai_cli/init/templates/live-captions/Dockerfile new file mode 100644 index 00000000..73deb13c --- /dev/null +++ b/aai_cli/init/templates/live-captions/Dockerfile @@ -0,0 +1,24 @@ +# 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 . . + +# 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 +# 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/Procfile b/aai_cli/init/templates/live-captions/Procfile new file mode 100644 index 00000000..8837c118 --- /dev/null +++ b/aai_cli/init/templates/live-captions/Procfile @@ -0,0 +1 @@ +web: python -m 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..8186be7e 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`). @@ -18,9 +17,25 @@ uvicorn api.index:app --reload --port 3000 ## 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 + +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 +``` + +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 diff --git a/aai_cli/init/templates/live-captions/api/index.py b/aai_cli/init/templates/live-captions/api/index.py index 22d7b634..0b91b3ce 100644 --- a/aai_cli/init/templates/live-captions/api/index.py +++ b/aai_cli/init/templates/live-captions/api/index.py @@ -21,19 +21,29 @@ 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") + + +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(PUBLIC / "index.html") + return FileResponse(STATIC / "index.html") @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/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/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/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 89302ce4..817fac1b 100644 --- a/aai_cli/init/templates/voice-agent/AGENTS.md +++ b/aai_cli/init/templates/voice-agent/AGENTS.md @@ -3,30 +3,30 @@ This is a buildless FastAPI + browser voice-agent starter. Run it with: ```sh -uvicorn api.index:app --reload --port 3000 +aai dev ``` ## Map - `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/Dockerfile b/aai_cli/init/templates/voice-agent/Dockerfile new file mode 100644 index 00000000..73deb13c --- /dev/null +++ b/aai_cli/init/templates/voice-agent/Dockerfile @@ -0,0 +1,24 @@ +# 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 . . + +# 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 +# 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/Procfile b/aai_cli/init/templates/voice-agent/Procfile new file mode 100644 index 00000000..8837c118 --- /dev/null +++ b/aai_cli/init/templates/voice-agent/Procfile @@ -0,0 +1 @@ +web: python -m 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..8f4103e7 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`). @@ -19,12 +18,28 @@ 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 + +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 +``` + +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` (`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..d6cda54b 100644 --- a/aai_cli/init/templates/voice-agent/api/index.py +++ b/aai_cli/init/templates/voice-agent/api/index.py @@ -21,19 +21,29 @@ 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") + + +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(PUBLIC / "index.html") + return FileResponse(STATIC / "index.html") @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/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/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/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/aai_cli/init/tunnel.py b/aai_cli/init/tunnel.py new file mode 100644 index 00000000..27f45fc1 --- /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, # pragma: no mutate -- tuning constant; no unit-observable behavior + 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/aai_cli/main.py b/aai_cli/main.py index 6d541e3a..dab589b8 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -17,6 +17,8 @@ account, agent, audit, + deploy, + dev, doctor, init, keys, @@ -25,6 +27,7 @@ onboard, sessions, setup, + share, stream, transcribe, transcripts, @@ -44,6 +47,9 @@ "onboard", # Build an App — scaffold a new project "init", + "dev", + "share", + "deploy", # Run AssemblyAI — use AssemblyAI directly from the terminal "transcribe", "stream", @@ -250,6 +256,9 @@ 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(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/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/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. 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..50fef115 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-aai-dev.md @@ -0,0 +1,605 @@ +# `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. + +--- + +## 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`. +- `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. 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..281c7f88 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-aai-api-command-design.md @@ -0,0 +1,179 @@ +# `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. Verified against the live specs, +**both** REST and LLM-gateway declare the identical scheme: + +```jsonc +"ApiKey": { "type": "apiKey", "in": "header", "name": "Authorization" } +``` + +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) + +### `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. 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..0d443e5c --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-aai-dev-design.md @@ -0,0 +1,194 @@ +# `aai dev` — launch a scaffolded template's dev server + +**Date:** 2026-06-09 +**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 + +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). 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). 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." diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index bdec3940..d821802a 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -1,12 +1,18 @@ 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 typing import NoReturn from aai_cli.init import templates @@ -15,21 +21,25 @@ "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", "gitignore", "env.example", + "Procfile", + "runtime.txt", + "Dockerfile", + "dockerignore", ) _LOCAL_IMPORTS = {"api"} _PKG_MAP = {"dotenv": "python-dotenv", "multipart": "python-multipart"} _STDLIB = set(sys.stdlib_module_names) | {"__future__"} -def _fail(message: str) -> None: +def _fail(message: str) -> NoReturn: raise RuntimeError(message) @@ -50,27 +60,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)) @@ -165,6 +172,142 @@ 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 _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}") + # 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)}}}" + ) + # 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: + """`.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)) + 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 +340,10 @@ def main() -> int: _requirements_pin_versions(template, path) _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/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 2ecb53cd..37f577f1 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -121,6 +121,73 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[deploy] + ''' + + Usage: aai deploy [OPTIONS] + + 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 launch`). 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. │ + │ --fly Deploy to Fly.io. │ + │ --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 on Vercel + $ aai deploy --prod --yes + Deploy to Railway + $ aai deploy --railway + Deploy to Fly.io + $ aai deploy --fly + + + + ''' +# --- +# 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] @@ -388,8 +455,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 @@ -536,6 +605,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/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_deploy.py b/tests/test_deploy.py new file mode 100644 index 00000000..6e445a2b --- /dev/null +++ b/tests/test_deploy.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import dataclasses +import re +import types +from collections.abc import Sequence +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from aai_cli.commands.deploy import FLY, RAILWAY, VERCEL, Target +from aai_cli.main import app + +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 + # 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, FLY): + opaque: object = target + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(opaque, field, "tampered") + assert isinstance(VERCEL, Target) + + +def _stub( + monkeypatch: pytest.MonkeyPatch, + *, + available: Sequence[str] = ("vercel",), + agentic: bool = False, + confirm: bool = True, + returncode: int = 0, +) -> 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) + calls: dict[str, object] = {} + + def fake_confirm(prompt: str, *a: object, **k: object) -> bool: + calls["prompt"] = prompt + return confirm + + monkeypatch.setattr("typer.confirm", fake_confirm) + + def fake_run(cmd: list[str], **kwargs: object) -> types.SimpleNamespace: + 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 + 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?" + + +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 _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 _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: + 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 _cmds(calls) == [] # declined + + +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 "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 + + +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_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 "Aborted" in result.output + 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 _cmds(calls)[0] == ["vercel", "deploy"] + assert "prompt" not in calls # --yes bypassed typer.confirm + + +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 _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 _cmds(calls)[0] == ["railway", "up"] + + +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", "launch"]] + + +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", "launch"] + + +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"]) + assert result.exit_code == 2 + + +def test_deploy_noninteractive_without_yes_errors(monkeypatch: pytest.MonkeyPatch) -> None: + calls = _stub(monkeypatch, available=("vercel",), agentic=True) + result = runner.invoke(app, ["deploy"]) + assert result.exit_code == 1 + assert "--yes" in result.output + assert _cmds(calls) == [] + + +@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 + assert flag in _ANSI.sub("", result.output) diff --git a/tests/test_dev.py b/tests/test_dev.py new file mode 100644 index 00000000..cf8cf6df --- /dev/null +++ b/tests/test_dev.py @@ -0,0 +1,152 @@ +import subprocess + +from typer.testing import CliRunner + +from aai_cli.main import app + +runner = CliRunner() +WEB = "web: python -m uvicorn api.index:app --host 0.0.0.0 --port ${PORT:-3000}\n" + + +def _make_project(tmp_path): + (tmp_path / "Procfile").write_text(WEB) + + +def _stub_runner(monkeypatch, *, use_uv=True, setup_rc=0): + 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_run_server(target, *, command, port, env, open_browser): + captured.update( + target=target, command=command, port=port, env=env, open_browser=open_browser + ) + return 0 + + monkeypatch.setattr("aai_cli.init.runner.run_server", fake_run_server) + return captured + + +def test_dev_boots_procfile_command_with_reload(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + captured = _stub_runner(monkeypatch) + result = runner.invoke(app, ["dev", "--no-open"]) + assert result.exit_code == 0, result.output + cmd = captured["command"] + 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" + assert captured["open_browser"] is False + 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_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_project(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["command"][-1] == "--reload" + + +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 + assert "aai init" in result.output + assert captured == {} # never launched + + +def test_dev_install_failure_exits(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + _make_project(tmp_path) + captured = _stub_runner(monkeypatch, setup_rc=1) + result = runner.invoke(app, ["dev"]) + assert result.exit_code == 1 + assert "boom" in result.output + 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) + _stub_runner(monkeypatch) + 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_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 diff --git a/tests/test_devserver.py b/tests/test_devserver.py new file mode 100644 index 00000000..b8cfa83b --- /dev/null +++ b/tests/test_devserver.py @@ -0,0 +1,75 @@ +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"), ["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_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 == ["uvicorn", "api.index:app", "--reload"] 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): diff --git a/tests/test_init_runner.py b/tests/test_init_runner.py index f6cf2e58..04e157f4 100644 --- a/tests/test_init_runner.py +++ b/tests/test_init_runner.py @@ -183,3 +183,59 @@ 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_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) + + 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) + 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_init_scaffold.py b/tests/test_init_scaffold.py index 8678684b..de30657d 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() @@ -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_agent.py b/tests/test_init_template_agent.py index ce866b21..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)) @@ -49,7 +54,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 850317f5..4468caeb 100644 --- a/tests/test_init_template_contract.py +++ b/tests/test_init_template_contract.py @@ -24,39 +24,123 @@ 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", "gitignore", "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}}" + ) + # 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)}}}" + ) + # 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): + """`.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. + 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.""" + 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() + 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 @@ -67,10 +151,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)) @@ -80,7 +162,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 new file mode 100644 index 00000000..26134543 --- /dev/null +++ b/tests/test_init_template_serve.py @@ -0,0 +1,291 @@ +"""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 os +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 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) + 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)) + 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) + 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 / "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() + + +# --- 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_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)) diff --git a/tests/test_init_template_transcribe.py b/tests/test_init_template_transcribe.py index ce8937e8..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" @@ -42,9 +45,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 +71,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", 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): diff --git a/tests/test_procfile.py b/tests/test_procfile.py new file mode 100644 index 00000000..d8270b4c --- /dev/null +++ b/tests/test_procfile.py @@ -0,0 +1,60 @@ +from pathlib import Path + +import pytest + +from aai_cli.errors import CLIError +from aai_cli.init import procfile + +WEB = "web: uvicorn api.index:app --host 0.0.0.0 --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[: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): + argv = procfile.web_argv(_write(tmp_path, WEB), env={}) + 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"}) + 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" + assert exc.value.exit_code == 1 + + +def test_web_argv_raises_on_empty_web_command(tmp_path): + with pytest.raises(CLIError): + procfile.web_argv(_write(tmp_path, "web:\n"), env={}) diff --git a/tests/test_share.py b/tests/test_share.py new file mode 100644 index 00000000..a41ed95c --- /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: str | None = "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 298e76f9..e337af0e 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -76,6 +76,9 @@ def test_help_lists_commands_in_workflow_order(): "onboard", # Build an App "init", + "dev", + "share", + "deploy", # Run AssemblyAI "transcribe", "stream", diff --git a/tests/test_tunnel.py b/tests/test_tunnel.py new file mode 100644 index 00000000..18c3d00c --- /dev/null +++ b/tests/test_tunnel.py @@ -0,0 +1,58 @@ +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 + + +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