diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0c15a16..3668c513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,10 +29,10 @@ jobs: - name: System deps (PortAudio + ffmpeg) run: sudo apt-get update && sudo apt-get install -y libportaudio2 ffmpeg - # check.sh lints Markdown via the markdownlint CLI (a Node tool); pin to the - # version used locally. The runner ships Node, so a global npm install suffices. - - name: markdownlint CLI - run: npm install -g markdownlint-cli@0.45.0 + # check.sh lints Markdown and template JS/CSS via Node CLIs; pin to the + # versions used locally. The runner ships Node, so a global npm install suffices. + - name: Node lint CLIs + run: npm install -g markdownlint-cli@0.45.0 prettier@3.8.3 # check.sh runs every tool through `uv run` / `uv build` for a locked, # reproducible env, so only uv must be on PATH (installed from PyPI to match diff --git a/.importlinter b/.importlinter index 08d091d1..3545018b 100644 --- a/.importlinter +++ b/.importlinter @@ -38,7 +38,6 @@ modules = aai_cli.commands.account aai_cli.commands.agent aai_cli.commands.audit - aai_cli.commands.claude aai_cli.commands.doctor aai_cli.commands.init aai_cli.commands.keys @@ -46,6 +45,7 @@ modules = aai_cli.commands.login aai_cli.commands.samples aai_cli.commands.sessions + aai_cli.commands.setup aai_cli.commands.stream aai_cli.commands.transcribe aai_cli.commands.transcripts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8ecdf76d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +This file provides guidance to coding agents (Claude Code, Codex, Cursor, and +others) when working with code in this repository. `CLAUDE.md` is a symlink to +this file, so Claude Code reads the same instructions. + +## Development commands + +This project uses [uv](https://docs.astral.sh/uv/). **Run every Python tool through `uv run`** so it uses the locked environment (`pyproject.toml` + `uv.lock`), not whatever is on `PATH`: + +```sh +uv sync --extra dev # create/refresh the venv with dev dependencies +uv run aai --help # run the CLI from the locked environment +./scripts/check.sh # the full gate CI runs: ruff + mypy + markdownlint + prettier + shellcheck + pytest(+coverage) + build/twine +``` + +Individual tools (all via `uv run`): + +```sh +uv run ruff check . # lint +uv run ruff format . # format (line-length 100) +uv run mypy # files = ["aai_cli", "tests"] from pyproject; strict (disallow_untyped_defs on src) +prettier --check "aai_cli/init/templates/**/*.{js,css}" # JS/CSS template formatting +uv run pytest -q # default unit suite +uv run pytest tests/test_transcribe.py -q # a single file +uv run pytest tests/test_transcribe.py::test_name -q # a single test +``` + +### Test markers + +The default suite **excludes** two slow/credentialed marker sets (see `scripts/check.sh` and `pyproject.toml`): + +```sh +uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY, else skips +uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx +``` + +`check.sh` runs `-m "not e2e and not install_script"` with a **90% branch-coverage gate** (`--cov-fail-under=90`). New code generally needs tests to clear that gate. + +## Naming & packaging gotchas + +- The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `aai` (`[project.scripts] aai = "aai_cli.main:run"`). +- `aai init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`). +- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.10–3.13. + +## Architecture + +A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app, and controls `aai --help` ordering via `_COMMAND_ORDER` + a custom `_OrderedGroup`. `run()` is the entry point and swallows `BrokenPipeError` (closed downstream pipe → exit 0). + +### Command layer + +Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `transcripts`, `agent`, `llm`, `login`, `doctor`, `samples`, `init`, `claude`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures. + +### Cross-cutting state (resolution order matters) + +- **`context.py`** — `AppState` (profile, env) is attached to the Typer context in the root `@app.callback()`. `run_command` is the standard command wrapper. +- **`config.py`** — profiles persisted in `config.toml` (via `platformdirs`); the **API key lives only in the OS keyring** (`KEYRING_SERVICE = "assemblyai-cli"`), never in a dotfile. Key resolution order: `--api-key` flag (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. **Run commands deliberately expose no `--api-key` flag** so keys can't leak into `ps`/shell history. +- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is currently **`sandbox000`** (flip to `production` once the prod Stytch value is real). The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it. +- **`client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape. +- **`errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` (auto-enabled when piped/agent-run) switches to machine-readable output. + +### Feature subsystems + +- **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. +- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). +- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). +- **`auth/`** — browser-assisted `aai login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. +- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`. +- **`commands/claude.py`** — `aai claude install/status/remove` shells out to `claude mcp add` (the `assemblyai-docs` MCP) and `npx skills add` (the AssemblyAI skill). Missing `claude`/`npx` is reported and skipped, not an error. + +## Conventions + +- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). +- Ruff lint set: `E,F,I,UP,B,BLE,C4,SIM,RET,PTH,ARG,S,RUF`. `S603/S607` are ignored project-wide because the CLI intentionally shells out to `claude`/`npx` with controlled args. `B008` is ignored (Typer uses `typer.Option/Argument` calls as defaults). +- mypy is strict on `aai_cli` (`disallow_untyped_defs`); tests are type-checked but exempt from return annotations. +- Errors → stderr, data → stdout. Preserve this split; it's what makes the CLI pipeline-safe. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 5a9d3558..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,73 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Development commands - -This project uses [uv](https://docs.astral.sh/uv/). **Run every Python tool through `uv run`** so it uses the locked environment (`pyproject.toml` + `uv.lock`), not whatever is on `PATH`: - -```sh -uv sync --extra dev # create/refresh the venv with dev dependencies -uv run aai --help # run the CLI from the locked environment -./scripts/check.sh # the full gate CI runs: ruff + mypy + markdownlint + shellcheck + pytest(+coverage) + build/twine -``` - -Individual tools (all via `uv run`): - -```sh -uv run ruff check . # lint -uv run ruff format . # format (line-length 100) -uv run mypy # files = ["aai_cli", "tests"] from pyproject; strict (disallow_untyped_defs on src) -uv run pytest -q # default unit suite -uv run pytest tests/test_transcribe.py -q # a single file -uv run pytest tests/test_transcribe.py::test_name -q # a single test -``` - -### Test markers - -The default suite **excludes** two slow/credentialed marker sets (see `scripts/check.sh` and `pyproject.toml`): - -```sh -uv run pytest -m e2e # real-API end-to-end; needs ASSEMBLYAI_API_KEY, else skips -uv run pytest -m install_script # builds a wheel and runs install.sh for real; needs network + uv/pipx -``` - -`check.sh` runs `-m "not e2e and not install_script"` with a **90% branch-coverage gate** (`--cov-fail-under=90`). New code generally needs tests to clear that gate. - -## Naming & packaging gotchas - -- The **package/module** is `aai_cli`; the **distribution** name is `aai-cli`; the **console command** is `aai` (`[project.scripts] aai = "aai_cli.main:run"`). -- `aai init` templates live in `aai_cli/init/templates/` and are **committed**, including renamed dotfiles (`gitignore` → `.gitignore`, `env.example`). The wheel force-includes them via `[tool.hatch.build.targets.wheel] artifacts`, excluding `__pycache__/*.pyc`. Editing templates needs care — see the parametrized contract tests (`tests/test_init_template_*.py`). -- `audioop` left the stdlib in 3.13; `audioop-lts` backfills it (conditional dependency). Supported Pythons: 3.10–3.13. - -## Architecture - -A Typer CLI. `aai_cli/main.py` builds the `app`, registers each command sub-app, and controls `aai --help` ordering via `_COMMAND_ORDER` + a custom `_OrderedGroup`. `run()` is the entry point and swallows `BrokenPipeError` (closed downstream pipe → exit 0). - -### Command layer - -Each file in `aai_cli/commands/` is a Typer sub-app (`transcribe`, `stream`, `transcripts`, `agent`, `llm`, `login`, `doctor`, `samples`, `init`, `claude`). Command bodies run through `context.run_command(ctx, fn, json=...)`, which maps any `CLIError` to clean stderr output + the error's exit code. Commands never print tracebacks for expected failures. - -### Cross-cutting state (resolution order matters) - -- **`context.py`** — `AppState` (profile, env) is attached to the Typer context in the root `@app.callback()`. `run_command` is the standard command wrapper. -- **`config.py`** — profiles persisted in `config.toml` (via `platformdirs`); the **API key lives only in the OS keyring** (`KEYRING_SERVICE = "assemblyai-cli"`), never in a dotfile. Key resolution order: `--api-key` flag (validation paths only) → `ASSEMBLYAI_API_KEY` env → keyring. **Run commands deliberately expose no `--api-key` flag** so keys can't leak into `ps`/shell history. -- **`environments.py`** — a frozen `Environment` (api_base, streaming_host, llm_gateway_base, ams_base, stytch_*). `DEFAULT_ENV` is currently **`sandbox000`** (flip to `production` once prod AMS/Stytch values are real). The active environment is a process-global set once at startup; precedence: `--env` → `AAI_ENV` → profile's stored env → default. A credential is only valid against the environment that minted it. -- **`client.py`** — thin wrappers over the `assemblyai` SDK (`transcribe`, `list_transcripts`, `stream_audio`, etc.). It normalizes SDK exceptions: auth failures become a single clean `auth_failure()` `CLIError`; everything else becomes `APIError`. New SDK calls should follow this try/except shape. -- **`errors.py`** — the `CLIError` hierarchy (each with `error_type` + `exit_code`). `output.py` emits errors to **stderr**; stdout stays clean for pipelines. `--json` (auto-enabled when piped/agent-run) switches to machine-readable output. - -### Feature subsystems - -- **`streaming/`** + `client.stream_audio` — v3 realtime API. Event callbacks run on the SDK reader thread and guard against `BrokenPipeError` (`stdio.silence_stdout()`) so a closed pipe never dumps a thread traceback. -- **`agent/`** — full-duplex voice agent (mic in, TTS out via `voices.py`). -- **`code_gen/`** — backs `--show-code` on `transcribe`/`stream`/`agent`: builds a ready-to-run Python SDK script from exactly the flags passed (no API key needed; generated code reads `ASSEMBLYAI_API_KEY`). -- **`auth/`** — browser-assisted `aai login` via AMS + **Stytch B2B OAuth discovery** (`discovery.py`, `flow.py`, `loopback.py`, `ams.py`). Not Stytch Connected Apps. -- **`init/`** — scaffolds a self-contained FastAPI + HTML starter (`audio-transcription`/`live-captions`/`voice-agent` templates), optionally installs deps and opens the browser; writes the key to a git-ignored `.env`. -- **`commands/claude.py`** — `aai claude install/status/remove` shells out to `claude mcp add` (the `assemblyai-docs` MCP) and `npx skills add` (the AssemblyAI skill). Missing `claude`/`npx` is reported and skipped, not an error. - -## Conventions - -- `from __future__ import annotations` at the top of every module; modern typing (`X | None`). -- Ruff lint set: `E,F,I,UP,B,BLE,C4,SIM,RET,PTH,ARG,S,RUF`. `S603/S607` are ignored project-wide because the CLI intentionally shells out to `claude`/`npx` with controlled args. `B008` is ignored (Typer uses `typer.Option/Argument` calls as defaults). -- mypy is strict on `aai_cli` (`disallow_untyped_defs`); tests are type-checked but exempt from return annotations. -- Errors → stderr, data → stdout. Preserve this split; it's what makes the CLI pipeline-safe. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 5f7bb44a..5357eef4 100644 --- a/README.md +++ b/README.md @@ -45,17 +45,20 @@ aai login # store your API key (browser-assisted) aai transcribe --sample # transcribe the hosted wildfires.mp3 sample ``` -## Scaffold A Starter App +## Build An App -Copy a small, self-contained FastAPI + HTML project you can run locally and deploy to Vercel as-is: +`aai init` is how you **build a new app** — it copies a small, self-contained FastAPI + HTML project you can run locally and deploy to Vercel as-is. This is the starting point whenever you want to *create* something, including a voice agent app: ```sh aai init # pick a template, scaffold, install deps, open the browser aai init audio-transcription myapp # non-interactive: template + directory +aai init voice-agent my-agent # build a voice agent app (full FastAPI + browser starter) ``` Your key is written to a git-ignored `.env` (never sent to the browser). Use `--no-install` to scaffold only. +> **Building a voice agent? Use `aai init voice-agent`, not `aai agent`.** `aai agent` only *runs* a live mic conversation in the terminal and writes no code; `aai init` creates the actual app. + ## Commands | Command | What it does | @@ -65,9 +68,9 @@ Your key is written to a git-ignored `.env` (never sent to the browser). Use `-- | `aai transcribe ` | Transcribe a file, URL, or YouTube URL (`--sample`, `--llm`, `--show-code`). | | `aai transcripts list` / `get ` | Browse and fetch past transcripts. | | `aai stream [file]` | Real-time transcription from a file or the microphone. | -| `aai agent` | Live two-way voice conversation with a voice agent. | +| `aai agent` | *Run* a live two-way voice conversation (to **build** a voice agent app, use `aai init voice-agent`). | | `aai llm ` | Prompt the LLM Gateway (`--transcript-id`, or `--follow` for a live stream). | -| `aai claude install` | Wire Claude Code up to AssemblyAI's docs + skill. | +| `aai setup install` | Set up your coding agent for AssemblyAI (docs MCP + skills). | | `aai samples create ` | Scaffold a runnable starter script. | | `aai keys` / `balance` / `usage` / `limits` / `sessions` / `audit` | Account self-service (browser login). | @@ -128,7 +131,7 @@ aai stream -o text | aai llm -f --system "You are a meeting scribe" "summarize a ## Voice Agent -Have a live, two-way voice conversation — full-duplex, so you can interrupt mid-sentence (barge-in). **Use headphones**, otherwise the agent hears itself: +Have a live, two-way voice conversation — full-duplex, so you can interrupt mid-sentence (barge-in). **Use headphones**, otherwise the agent hears itself. (To **build** a voice agent *app*, use `aai init voice-agent` instead — this command just runs a conversation in the terminal.) ```sh aai agent # talk; the agent talks back. Ctrl-C to stop. @@ -208,15 +211,15 @@ AMS sessions are short-lived — if a command reports it needs a browser login, ## AI Coding Agents -Wire Claude Code up to AssemblyAI's live docs (MCP server) and the AssemblyAI skill so your agent writes current, correct integration code: +Set your coding agent up for AssemblyAI — the live docs (MCP server), the AssemblyAI skill, and the bundled `aai-cli` skill — so your agent writes current, correct integration code: ```sh -aai claude install # installs the docs MCP server + skill (user scope) -aai claude status # show what's wired up -aai claude remove # unwind both +aai setup install # docs MCP + assemblyai skill + bundled aai-cli skill (user scope) +aai setup status # show what's set up +aai setup remove # unwind all three ``` -`install` shells out to `claude mcp add` and `npx skills add`. Pass `--scope project` to scope the MCP server to the current project. A missing `claude` or `npx` is reported and skipped, not treated as an error. +`install` shells out to `claude mcp add` for the MCP and `npx skills add` for the `assemblyai` skill; the `aai-cli` skill ships inside the package and is copied in directly (no network). Pass `--scope project` to scope the MCP server to the current project. A missing `claude` or `npx` is reported and skipped, not treated as an error. ## Reference diff --git a/aai_cli/commands/agent.py b/aai_cli/commands/agent.py index 6dc81e6a..cfb73950 100644 --- a/aai_cli/commands/agent.py +++ b/aai_cli/commands/agent.py @@ -67,6 +67,9 @@ def agent( Pass an audio file/URL (or --sample) to speak a recorded clip to the agent instead of the microphone; the session then ends after the agent's reply. + + This only runs a conversation in the terminal — it writes no code. To build + a voice agent app, run 'aai init voice-agent' instead. """ if list_voices: diff --git a/aai_cli/commands/doctor.py b/aai_cli/commands/doctor.py index 4f5bd8a1..8caa8602 100644 --- a/aai_cli/commands/doctor.py +++ b/aai_cli/commands/doctor.py @@ -177,14 +177,14 @@ def _check_audio() -> Check: def _check_coding_agent() -> Check: - affects = ["aai claude install"] + affects = ["aai setup install"] missing = [tool for tool in ("claude", "npx") if shutil.which(tool) is None] if not missing: return { "name": "coding-agent", "status": "ok", "affects": [], - "detail": "claude and npx found.", + "detail": "claude and npx found; run 'aai setup install' to wire up the docs MCP + skills.", "fix": None, } return { @@ -192,7 +192,10 @@ def _check_coding_agent() -> Check: "status": "warn", "affects": affects, "detail": f"not found: {', '.join(missing)}.", - "fix": "Install Claude Code (https://claude.com/claude-code) and Node.js to wire up docs.", + "fix": ( + "Install Claude Code (https://claude.com/claude-code) and Node.js, " + "then run 'aai setup install'." + ), } diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py index b1f0c726..e075dc38 100644 --- a/aai_cli/commands/init.py +++ b/aai_cli/commands/init.py @@ -89,7 +89,12 @@ def init( port: int = typer.Option(3000, "--port", help="Local server port."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Pick a template, scaffold it, install deps, launch the server, open the browser.""" + """Build a new app: pick a template, scaffold it, install deps, launch it, open the browser. + + This is the starting point for creating an app — including a voice agent app + ('aai init voice-agent'). The 'aai agent' command only runs a live mic + conversation and writes no code. + """ def body(state: AppState, json_mode: bool) -> None: if not json_mode: diff --git a/aai_cli/commands/claude.py b/aai_cli/commands/setup.py similarity index 69% rename from aai_cli/commands/claude.py rename to aai_cli/commands/setup.py index 354e30a8..d3465e80 100644 --- a/aai_cli/commands/claude.py +++ b/aai_cli/commands/setup.py @@ -4,6 +4,7 @@ import shutil import subprocess from pathlib import Path +from typing import TYPE_CHECKING import typer @@ -13,8 +14,13 @@ from aai_cli.help_text import examples_epilog from aai_cli.steps import Step, render_steps +if TYPE_CHECKING: + # Annotation only (PEP 563 string), so no runtime import. Import from + # importlib.abc — that is the protocol `resources.files()` is typed to return. + from importlib.abc import Traversable + app = typer.Typer( - help="Wire up Claude Code for AssemblyAI (docs MCP + skill).", + help="Set up your coding agent for AssemblyAI (docs MCP + skills).", no_args_is_help=True, ) @@ -52,6 +58,17 @@ def _proc_detail(proc: subprocess.CompletedProcess[str]) -> str: return (proc.stderr or proc.stdout).strip() +def _skills_root() -> Path: + # Honor CLAUDE_CONFIG_DIR so install/status/remove agree with the agent's actual + # config root rather than assuming ~/.claude. + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") + root = Path(config_dir) if config_dir else Path.home() / ".claude" + return root / "skills" + + +# --- docs MCP (registered via the `claude` CLI) ------------------------------ + + def _mcp_present() -> bool: return _run(["claude", "mcp", "get", MCP_NAME]).returncode == 0 @@ -85,11 +102,46 @@ def _install_mcp(scope: str, force: bool) -> Step: return {"name": "mcp", "status": "installed", "detail": f"{MCP_NAME} @ {scope} scope"} +def _mcp_status() -> Step: + if shutil.which("claude") is None: + return {"name": "mcp", "status": "unknown", "detail": "Claude Code not found"} + present = _mcp_present() + return { + "name": "mcp", + "status": "installed" if present else "not_installed", + "detail": MCP_NAME, + } + + +def _remove_mcp(scope: str | None) -> Step: + if shutil.which("claude") is None: + return {"name": "mcp", "status": "skipped", "detail": "Claude Code not found"} + if not _mcp_present(): + return {"name": "mcp", "status": "not_installed", "detail": MCP_NAME} + cmd = ["claude", "mcp", "remove", MCP_NAME] + if scope is not None: + cmd += ["--scope", scope] + proc = _run(cmd) + if proc.returncode != 0: + return {"name": "mcp", "status": "failed", "detail": _proc_detail(proc)} + return {"name": "mcp", "status": "removed", "detail": MCP_NAME} + + +# --- assemblyai skill (downloaded from its own repo via the `skills` CLI) ----- + _SKILL_ADD = ["npx", "-y", "skills", "add", SKILL_REPO, "--global", "--yes"] _SKILL_REMOVE = ["npx", "-y", "skills", "remove", "assemblyai", "--global"] _SKILL_ADD_HINT = f"npx skills add {SKILL_REPO} --global" +def _skill_dir() -> Path: + return _skills_root() / "assemblyai" + + +def _skill_installed() -> bool: + return (_skill_dir() / "SKILL.md").exists() + + def _install_skill(force: bool) -> Step: if shutil.which("npx") is None: return { @@ -126,29 +178,6 @@ def _install_skill(force: bool) -> Step: return {"name": "skill", "status": "installed", "detail": str(_skill_dir())} -def _skill_dir() -> Path: - # Honor CLAUDE_CONFIG_DIR so install/status/remove agree with Claude Code's - # actual config root rather than assuming ~/.claude. - config_dir = os.environ.get("CLAUDE_CONFIG_DIR") - root = Path(config_dir) if config_dir else Path.home() / ".claude" - return root / "skills" / "assemblyai" - - -def _skill_installed() -> bool: - return (_skill_dir() / "SKILL.md").exists() - - -def _mcp_status() -> Step: - if shutil.which("claude") is None: - return {"name": "mcp", "status": "unknown", "detail": "Claude Code not found"} - present = _mcp_present() - return { - "name": "mcp", - "status": "installed" if present else "not_installed", - "detail": MCP_NAME, - } - - def _skill_status() -> Step: return { "name": "skill", @@ -157,20 +186,6 @@ def _skill_status() -> Step: } -def _remove_mcp(scope: str | None) -> Step: - if shutil.which("claude") is None: - return {"name": "mcp", "status": "skipped", "detail": "Claude Code not found"} - if not _mcp_present(): - return {"name": "mcp", "status": "not_installed", "detail": MCP_NAME} - cmd = ["claude", "mcp", "remove", MCP_NAME] - if scope is not None: - cmd += ["--scope", scope] - proc = _run(cmd) - if proc.returncode != 0: - return {"name": "mcp", "status": "failed", "detail": _proc_detail(proc)} - return {"name": "mcp", "status": "removed", "detail": MCP_NAME} - - def _remove_skill() -> Step: if not _skill_installed(): return {"name": "skill", "status": "not_installed", "detail": str(_skill_dir())} @@ -189,6 +204,88 @@ def _remove_skill() -> Step: return {"name": "skill", "status": "removed", "detail": str(_skill_dir())} +# --- aai-cli skill (bundled in this package, copied into the agent) ----------- + +_CLI_SKILL_NAME = "aai-cli" + + +def _cli_skill_dir() -> Path: + return _skills_root() / _CLI_SKILL_NAME + + +def _cli_skill_installed() -> bool: + return (_cli_skill_dir() / "SKILL.md").exists() + + +def _bundled_cli_skill() -> Traversable: + # Ships inside the wheel (force-included via [tool.hatch.build.targets.wheel] + # artifacts). skills/ has no __init__.py, so navigate from the aai_cli package. + from importlib import resources + + return resources.files("aai_cli") / "skills" / _CLI_SKILL_NAME + + +def _copy_tree(node: Traversable, dest: Path) -> None: + dest.mkdir(parents=True, exist_ok=True) + for child in node.iterdir(): + if child.name == "__pycache__" or child.name.endswith(".pyc"): + continue + out = dest / child.name + if child.is_dir(): + _copy_tree(child, out) + else: + out.write_bytes(child.read_bytes()) + + +def _install_cli_skill(force: bool) -> Step: + # Bundled in the package, so no network/npx — just copy it into the agent's + # skills dir. Idempotent: skip the copy when already present and not --force. + dest = _cli_skill_dir() + if _cli_skill_installed() and not force: + return {"name": "aai-cli skill", "status": "already", "detail": f"aai-cli skill at {dest}"} + src = _bundled_cli_skill() + if not src.is_dir(): + return { + "name": "aai-cli skill", + "status": "failed", + "detail": f"bundled aai-cli skill missing at {src} — this is a packaging bug.", + } + if dest.exists(): + shutil.rmtree(dest) + _copy_tree(src, dest) + if not _cli_skill_installed(): + return { + "name": "aai-cli skill", + "status": "failed", + "detail": f"copied the bundled skill but {dest / 'SKILL.md'} is missing.", + } + return {"name": "aai-cli skill", "status": "installed", "detail": str(dest)} + + +def _cli_skill_status() -> Step: + return { + "name": "aai-cli skill", + "status": "installed" if _cli_skill_installed() else "not_installed", + "detail": str(_cli_skill_dir()), + } + + +def _remove_cli_skill() -> Step: + # We copied a real directory in (not a symlink into a store), so removal is a + # plain rmtree of the destination. + dest = _cli_skill_dir() + if not _cli_skill_installed(): + return {"name": "aai-cli skill", "status": "not_installed", "detail": str(dest)} + shutil.rmtree(dest, ignore_errors=True) + if _cli_skill_installed(): + return { + "name": "aai-cli skill", + "status": "failed", + "detail": "skill still present after removal", + } + return {"name": "aai-cli skill", "status": "removed", "detail": str(dest)} + + def _render(data: dict[str, list[Step]]) -> str: return render_steps(data["steps"], heading=_STEPS_HEADING) @@ -196,8 +293,8 @@ def _render(data: dict[str, list[Step]]) -> str: @app.command( epilog=examples_epilog( [ - ("Wire AssemblyAI docs + skill into Claude Code", "aai claude install"), - ("Install for the current project only", "aai claude install --scope project"), + ("Set up your coding agent for AssemblyAI", "aai setup install"), + ("Install for the current project only", "aai setup install --scope project"), ] ) ) @@ -214,14 +311,14 @@ def install( force: bool = typer.Option(False, "--force", help="Reinstall even if already present."), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Install the AssemblyAI docs MCP server and skill into Claude Code.""" + """Install the AssemblyAI docs MCP server and skills into your coding agent.""" def body(_state: AppState, json_mode: bool) -> None: if scope not in _VALID_SCOPES: raise UsageError( f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." ) - steps = [_install_mcp(scope, force), _install_skill(force)] + steps = [_install_mcp(scope, force), _install_skill(force), _install_cli_skill(force)] output.emit({"steps": steps}, _render, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): raise typer.Exit(code=1) @@ -232,7 +329,7 @@ def body(_state: AppState, json_mode: bool) -> None: @app.command( epilog=examples_epilog( [ - ("Show whether Claude Code is wired up", "aai claude status"), + ("Show what's set up", "aai setup status"), ] ) ) @@ -240,10 +337,10 @@ def status( ctx: typer.Context, json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Show whether the AssemblyAI MCP server and skill are wired into Claude Code.""" + """Show whether the AssemblyAI MCP server and skills are set up in your coding agent.""" def body(_state: AppState, json_mode: bool) -> None: - steps = [_mcp_status(), _skill_status()] + steps = [_mcp_status(), _skill_status(), _cli_skill_status()] output.emit({"steps": steps}, _render, json_mode=json_mode) run_command(ctx, body, json=json_out) @@ -252,7 +349,7 @@ def body(_state: AppState, json_mode: bool) -> None: @app.command( epilog=examples_epilog( [ - ("Remove the AssemblyAI MCP server and skill", "aai claude remove"), + ("Remove the AssemblyAI MCP server and skills", "aai setup remove"), ] ) ) @@ -268,14 +365,14 @@ def remove( ), json_out: bool = typer.Option(False, "--json", help="Output raw JSON."), ) -> None: - """Remove the AssemblyAI MCP server and skill from Claude Code.""" + """Remove the AssemblyAI MCP server and skills from your coding agent.""" def body(_state: AppState, json_mode: bool) -> None: if scope is not None and scope not in _VALID_SCOPES: raise UsageError( f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}." ) - steps = [_remove_mcp(scope), _remove_skill()] + steps = [_remove_mcp(scope), _remove_skill(), _remove_cli_skill()] output.emit({"steps": steps}, _render, json_mode=json_mode) if any(s["status"] == "failed" for s in steps): raise typer.Exit(code=1) diff --git a/aai_cli/environments.py b/aai_cli/environments.py index 72aa0ddf..3739edc9 100644 --- a/aai_cli/environments.py +++ b/aai_cli/environments.py @@ -32,10 +32,10 @@ class Environment: streaming_host="streaming.assemblyai.com", agents_host="agents.assemblyai.com", llm_gateway_base="https://llm-gateway.assemblyai.com/v1", - # NOTE: production AMS + Stytch are not provisioned yet — the values below are - # placeholders (see the REPLACE_ME token), which is why DEFAULT_ENV stays - # "sandbox000". Tracked under spec P2/O4. - ams_base="https://ams.assemblyai.com", + # NOTE: production Stytch is not provisioned yet (see the REPLACE_ME + # token), which is why DEFAULT_ENV stays "sandbox000". Tracked under + # spec O4. + ams_base="https://ams.internal.assemblyai-labs.com", stytch_domain="https://api.stytch.com", stytch_public_token="public-token-live-REPLACE_ME", # noqa: S106 - public token, safe to ship signup_url="https://www.assemblyai.com/dashboard", @@ -54,7 +54,7 @@ class Environment: } # Shipped default when nothing selects an environment. Flip to "production" at -# release once the prod AMS/Stytch values above are real. +# release once the production Stytch value above is real. DEFAULT_ENV = "sandbox000" # The environment in effect for this process, set once at CLI startup (like diff --git a/aai_cli/init/templates/audio-transcription/AGENTS.md b/aai_cli/init/templates/audio-transcription/AGENTS.md index da68c6b4..f9568c10 100644 --- a/aai_cli/init/templates/audio-transcription/AGENTS.md +++ b/aai_cli/init/templates/audio-transcription/AGENTS.md @@ -10,22 +10,22 @@ uvicorn api.index:app --reload --port 3000 - `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. -- `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. -- `index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. +- `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. ## 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 `index.html`. +- Sample audio URL: edit `SAMPLE_URL` in `api/settings.py` and the matching input value in `public/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 `static/app.js`. -- Visual theme/layout: edit the monotone Vercel-style tokens in `static/styles.css` before changing component rules. +- 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. - 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 `index.html` or `static/`. +- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `public/index.html` or `public/static/`. - Keep every browser `fetch("/api/...")` route registered in `api/index.py`. - Keep `/api/status/{transcript_id}` non-blocking; do not use SDK helpers that wait for completion in that polling route. - Keep the app buildless unless the user explicitly asks for a frontend toolchain. diff --git a/aai_cli/init/templates/audio-transcription/README.md b/aai_cli/init/templates/audio-transcription/README.md index b85e90bd..298a6e3a 100644 --- a/aai_cli/init/templates/audio-transcription/README.md +++ b/aai_cli/init/templates/audio-transcription/README.md @@ -17,11 +17,12 @@ 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 — `vercel.json` routes the page and the `/api` function. +No extra config is needed: Vercel serves the static page and discovers the +FastAPI app in `api/index.py`. ## 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 `static/app.js`. +- Change transcript rendering in `public/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 a932e053..fbe7389a 100644 --- a/aai_cli/init/templates/audio-transcription/api/index.py +++ b/aai_cli/init/templates/audio-transcription/api/index.py @@ -6,7 +6,8 @@ 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 (index.html + static/app.js) submits a URL or file, then polls status. +The browser (public/index.html + public/static/app.js) submits a URL or file, then +polls status. Your API key stays on the server — the browser never sees it. """ @@ -35,13 +36,14 @@ CONFIG = aai.TranscriptionConfig(**settings.TRANSCRIPTION_CONFIG_KWARGS) ROOT = Path(__file__).resolve().parent.parent +PUBLIC = ROOT / "public" app = FastAPI() -app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") +app.mount("/static", StaticFiles(directory=PUBLIC / "static"), name="static") @app.get("/") def index() -> FileResponse: - return FileResponse(ROOT / "index.html") + return FileResponse(PUBLIC / "index.html") def _submit(audio: str) -> dict[str, str]: diff --git a/aai_cli/init/templates/audio-transcription/index.html b/aai_cli/init/templates/audio-transcription/public/index.html similarity index 100% rename from aai_cli/init/templates/audio-transcription/index.html rename to aai_cli/init/templates/audio-transcription/public/index.html diff --git a/aai_cli/init/templates/audio-transcription/static/app.js b/aai_cli/init/templates/audio-transcription/public/static/app.js similarity index 64% rename from aai_cli/init/templates/audio-transcription/static/app.js rename to aai_cli/init/templates/audio-transcription/public/static/app.js index e42c8e73..858aad46 100644 --- a/aai_cli/init/templates/audio-transcription/static/app.js +++ b/aai_cli/init/templates/audio-transcription/public/static/app.js @@ -1,7 +1,14 @@ const APP_CONFIG = { sampleUrl: "https://assembly.ai/wildfires.mp3", pollIntervalMs: 2000, - speakerPalette: ["#171717", "#525252", "#737373", "#262626", "#404040", "#a3a3a3"], + speakerPalette: [ + "#171717", + "#525252", + "#737373", + "#262626", + "#404040", + "#a3a3a3", + ], }; const els = { @@ -39,7 +46,7 @@ async function transcribeUrl(url) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url }), - }) + }), ); } @@ -75,7 +82,10 @@ async function poll(id) { const data = await res.json(); if (data.status !== "completed") { - window.setTimeout(() => poll(id).catch((error) => fail(String(error))), APP_CONFIG.pollIntervalMs); + window.setTimeout( + () => poll(id).catch((error) => fail(String(error))), + APP_CONFIG.pollIntervalMs, + ); return; } @@ -97,7 +107,9 @@ async function ask(question) { body: JSON.stringify({ transcript_id: currentId, question }), }); const data = await res.json(); - els.answer.textContent = res.ok ? data.answer : "Error: " + (data.detail || res.statusText); + els.answer.textContent = res.ok + ? data.answer + : "Error: " + (data.detail || res.statusText); } catch (error) { els.answer.textContent = "Error: " + (error.message || String(error)); } finally { @@ -107,7 +119,9 @@ async function ask(question) { function speakerColor(speaker) { return (speakerSeen[speaker] ??= - APP_CONFIG.speakerPalette[Object.keys(speakerSeen).length % APP_CONFIG.speakerPalette.length]); + APP_CONFIG.speakerPalette[ + Object.keys(speakerSeen).length % APP_CONFIG.speakerPalette.length + ]); } function explore(transcript) { @@ -116,17 +130,29 @@ function explore(transcript) { ]; if (transcript.chapters?.length) { - views.push({ label: `Chapters - ${transcript.chapters.length}`, render: () => renderChapters(transcript.chapters) }); + views.push({ + label: `Chapters - ${transcript.chapters.length}`, + render: () => renderChapters(transcript.chapters), + }); } if (transcript.sentiment_analysis_results?.length) { - views.push({ label: "Sentiment", render: () => renderSentiment(transcript.sentiment_analysis_results) }); + views.push({ + label: "Sentiment", + render: () => renderSentiment(transcript.sentiment_analysis_results), + }); } if (transcript.entities?.length) { - views.push({ label: `Entities - ${transcript.entities.length}`, render: () => renderEntities(transcript.entities) }); + views.push({ + label: `Entities - ${transcript.entities.length}`, + render: () => renderEntities(transcript.entities), + }); } const highlights = transcript.auto_highlights_result?.results || []; if (highlights.length) { - views.push({ label: "Highlights", render: () => renderHighlights(highlights) }); + views.push({ + label: "Highlights", + render: () => renderHighlights(highlights), + }); } renderTabs(views); @@ -140,7 +166,8 @@ function renderTabs(views) { button.textContent = view.label; button.addEventListener("click", () => { els.view.replaceChildren(view.render()); - for (const child of els.tabs.children) child.classList.toggle("is-active", child === button); + for (const child of els.tabs.children) + child.classList.toggle("is-active", child === button); }); els.tabs.appendChild(button); if (index === 0) button.click(); @@ -156,25 +183,41 @@ function renderTranscript(transcript) { } function renderSentiment(results) { - return fragment(results.map((item) => { - const pill = element("span", { className: "sentiment-pill" }, item.sentiment || ""); - if (["POSITIVE", "NEGATIVE", "NEUTRAL"].includes(item.sentiment)) { - pill.dataset.sentiment = item.sentiment.toLowerCase(); - } - return turnNode(item.speaker || "?", item.text, pill); - })); + return fragment( + results.map((item) => { + const pill = element( + "span", + { className: "sentiment-pill" }, + item.sentiment || "", + ); + if (["POSITIVE", "NEGATIVE", "NEUTRAL"].includes(item.sentiment)) { + pill.dataset.sentiment = item.sentiment.toLowerCase(); + } + return turnNode(item.speaker || "?", item.text, pill); + }), + ); } function renderChapters(chapters) { - return fragment(chapters.map((chapter) => { - const node = element("article", { className: "chapter-card" }); - node.append( - element("h4", {}, chapter.headline || chapter.gist || "Chapter"), - element("span", { className: "timestamp" }, `${fmt(chapter.start)} - ${fmt(chapter.end)}`), - element("p", { className: "chapter-summary" }, chapter.summary || chapter.gist || "") - ); - return node; - })); + return fragment( + chapters.map((chapter) => { + const node = element("article", { className: "chapter-card" }); + node.append( + element("h4", {}, chapter.headline || chapter.gist || "Chapter"), + element( + "span", + { className: "timestamp" }, + `${fmt(chapter.start)} - ${fmt(chapter.end)}`, + ), + element( + "p", + { className: "chapter-summary" }, + chapter.summary || chapter.gist || "", + ), + ); + return node; + }), + ); } function renderEntities(entities) { @@ -182,19 +225,39 @@ function renderEntities(entities) { for (const entity of entities) { (groups[entity.entity_type] ??= []).push(entity.text); } - return fragment(Object.entries(groups).map(([type, items]) => { - const group = element("section", { className: "entity-group" }, element("div", { className: "entity-label" }, type)); - for (const text of new Set(items)) { - group.appendChild(element("span", { className: "entity-tag" }, text)); - } - return group; - })); + return fragment( + Object.entries(groups).map(([type, items]) => { + const group = element( + "section", + { className: "entity-group" }, + element("div", { className: "entity-label" }, type), + ); + for (const text of new Set(items)) { + group.appendChild(element("span", { className: "entity-tag" }, text)); + } + return group; + }), + ); } function renderHighlights(results) { - return fragment([...results].sort((a, b) => b.rank - a.rank).map((highlight) => - element("div", { className: "highlight-row" }, element("span", { className: "highlight-count" }, `${highlight.count}x`), " ", highlight.text) - )); + return fragment( + [...results] + .sort((a, b) => b.rank - a.rank) + .map((highlight) => + element( + "div", + { className: "highlight-row" }, + element( + "span", + { className: "highlight-count" }, + `${highlight.count}x`, + ), + " ", + highlight.text, + ), + ), + ); } function turnNode(speaker, text, extra = null) { @@ -202,7 +265,11 @@ function turnNode(speaker, text, extra = null) { const node = element("article", { className: "transcript-turn" }); node.style.borderLeftColor = color; - const name = element("span", { className: "speaker-label" }, `Speaker ${speaker}`); + const name = element( + "span", + { className: "speaker-label" }, + `Speaker ${speaker}`, + ); name.style.color = color; node.append(name); if (extra) node.append(extra); @@ -232,7 +299,9 @@ function fail(message) { function element(tag, options = {}, ...children) { const node = document.createElement(tag); if (options.className) node.className = options.className; - node.append(...children.filter((child) => child !== null && child !== undefined)); + node.append( + ...children.filter((child) => child !== null && child !== undefined), + ); return node; } diff --git a/aai_cli/init/templates/audio-transcription/static/styles.css b/aai_cli/init/templates/audio-transcription/public/static/styles.css similarity index 97% rename from aai_cli/init/templates/audio-transcription/static/styles.css rename to aai_cli/init/templates/audio-transcription/public/static/styles.css index b5b29b4c..67cbf5fe 100644 --- a/aai_cli/init/templates/audio-transcription/static/styles.css +++ b/aai_cli/init/templates/audio-transcription/public/static/styles.css @@ -1,6 +1,7 @@ /* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */ :root { - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --background: oklch(0.985 0 0); --foreground: oklch(0.12 0 0); --card: oklch(1 0 0); @@ -17,7 +18,8 @@ --input: oklch(0.9 0 0); --ring: oklch(0.5 0 0); --shadow-panel: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03); - --shadow-focus: 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); + --shadow-focus: + 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); --color-bg: var(--background); --color-surface: var(--card); --color-text: var(--foreground); @@ -44,7 +46,9 @@ } /* BASE */ -* { box-sizing: border-box; } +* { + box-sizing: border-box; +} body { min-height: 100vh; diff --git a/aai_cli/init/templates/audio-transcription/vercel.json b/aai_cli/init/templates/audio-transcription/vercel.json deleted file mode 100644 index 7f1e3652..00000000 --- a/aai_cli/init/templates/audio-transcription/vercel.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "rewrites": [ - { "source": "/api/(.*)", "destination": "/api/index" }, - { "source": "/(.*)", "destination": "/index.html" } - ] -} diff --git a/aai_cli/init/templates/live-captions/AGENTS.md b/aai_cli/init/templates/live-captions/AGENTS.md index 038971b4..534c2462 100644 --- a/aai_cli/init/templates/live-captions/AGENTS.md +++ b/aai_cli/init/templates/live-captions/AGENTS.md @@ -10,23 +10,23 @@ uvicorn api.index:app --reload --port 3000 - `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. -- `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. -- `index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. +- `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. ## Change Points -- Streaming model, sample rate, encoding, and turn formatting: edit `STREAMING_CONFIG` in `static/app.js`. +- Streaming model, sample rate, encoding, and turn formatting: edit `STREAMING_CONFIG` in `public/static/app.js`. - Backend token lifetime or non-production hosts: edit `api/settings.py`. -- 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. +- 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. - 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 `index.html` or `static/`. +- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `public/index.html` or `public/static/`. - Streaming token auth uses the raw API key in the backend `Authorization` header, not `Bearer`. - Keep the browser connected directly to AssemblyAI; do not proxy the audio stream through FastAPI unless the user asks. - Keep the app buildless unless the user explicitly asks for a frontend toolchain. diff --git a/aai_cli/init/templates/live-captions/README.md b/aai_cli/init/templates/live-captions/README.md index fec5718f..e0e73700 100644 --- a/aai_cli/init/templates/live-captions/README.md +++ b/aai_cli/init/templates/live-captions/README.md @@ -20,7 +20,7 @@ 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). The backend is just the `/api/token` function; the WebSocket runs browser → AssemblyAI, so nothing long-running -is needed. `vercel.json` routes the page and the function. +is needed. ## 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 a78ad020..6da4180a 100644 --- a/aai_cli/init/templates/live-captions/api/index.py +++ b/aai_cli/init/templates/live-captions/api/index.py @@ -19,13 +19,14 @@ from api import settings ROOT = Path(__file__).resolve().parent.parent +PUBLIC = ROOT / "public" app = FastAPI() -app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") +app.mount("/static", StaticFiles(directory=PUBLIC / "static"), name="static") @app.get("/") def index() -> FileResponse: - return FileResponse(ROOT / "index.html") + return FileResponse(PUBLIC / "index.html") @app.post("/api/token") diff --git a/aai_cli/init/templates/live-captions/index.html b/aai_cli/init/templates/live-captions/public/index.html similarity index 100% rename from aai_cli/init/templates/live-captions/index.html rename to aai_cli/init/templates/live-captions/public/index.html diff --git a/aai_cli/init/templates/live-captions/static/app.js b/aai_cli/init/templates/live-captions/public/static/app.js similarity index 86% rename from aai_cli/init/templates/live-captions/static/app.js rename to aai_cli/init/templates/live-captions/public/static/app.js index 7138f8c4..c51a6f1e 100644 --- a/aai_cli/init/templates/live-captions/static/app.js +++ b/aai_cli/init/templates/live-captions/public/static/app.js @@ -15,7 +15,9 @@ let ws = null; let audioPipeline = null; let recording = false; -recBtn.addEventListener("click", () => (recording ? stop() : start().catch(fail))); +recBtn.addEventListener("click", () => + recording ? stop() : start().catch(fail), +); function setStatus(message, state) { statusEl.textContent = message; @@ -50,7 +52,13 @@ async function start() { setStatus("● Live", "live"); audioPipeline.connect((frame, sampleRate) => { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(AudioHelpers.downsampleToPCM(frame, sampleRate, STREAMING_CONFIG.sampleRate)); + ws.send( + AudioHelpers.downsampleToPCM( + frame, + sampleRate, + STREAMING_CONFIG.sampleRate, + ), + ); } }); }; @@ -76,7 +84,8 @@ function stop() { recBtn.textContent = "● Record"; recBtn.dataset.state = "idle"; setStatus("Stopped", "idle"); - if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "Terminate" })); + if (ws && ws.readyState === WebSocket.OPEN) + ws.send(JSON.stringify({ type: "Terminate" })); if (audioPipeline) audioPipeline.close(); ws = null; audioPipeline = null; diff --git a/aai_cli/init/templates/live-captions/static/audio.js b/aai_cli/init/templates/live-captions/public/static/audio.js similarity index 100% rename from aai_cli/init/templates/live-captions/static/audio.js rename to aai_cli/init/templates/live-captions/public/static/audio.js diff --git a/aai_cli/init/templates/live-captions/static/styles.css b/aai_cli/init/templates/live-captions/public/static/styles.css similarity index 94% rename from aai_cli/init/templates/live-captions/static/styles.css rename to aai_cli/init/templates/live-captions/public/static/styles.css index d7707f65..7fe51e1e 100644 --- a/aai_cli/init/templates/live-captions/static/styles.css +++ b/aai_cli/init/templates/live-captions/public/static/styles.css @@ -1,6 +1,7 @@ /* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */ :root { - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --background: oklch(0.985 0 0); --foreground: oklch(0.12 0 0); --card: oklch(1 0 0); @@ -12,7 +13,8 @@ --border: oklch(0.9 0 0); --ring: oklch(0.5 0 0); --shadow-panel: 0 1px 3px oklch(0 0 0 / 0.05), 0 1px 1px oklch(0 0 0 / 0.03); - --shadow-focus: 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); + --shadow-focus: + 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); --color-bg: var(--background); --color-surface: var(--card); --color-text: var(--foreground); @@ -34,7 +36,9 @@ } /* BASE */ -* { box-sizing: border-box; } +* { + box-sizing: border-box; +} body { min-height: 100vh; diff --git a/aai_cli/init/templates/live-captions/vercel.json b/aai_cli/init/templates/live-captions/vercel.json deleted file mode 100644 index 7f1e3652..00000000 --- a/aai_cli/init/templates/live-captions/vercel.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "rewrites": [ - { "source": "/api/(.*)", "destination": "/api/index" }, - { "source": "/(.*)", "destination": "/index.html" } - ] -} diff --git a/aai_cli/init/templates/voice-agent/AGENTS.md b/aai_cli/init/templates/voice-agent/AGENTS.md index 6719c1c7..89302ce4 100644 --- a/aai_cli/init/templates/voice-agent/AGENTS.md +++ b/aai_cli/init/templates/voice-agent/AGENTS.md @@ -10,23 +10,23 @@ uvicorn api.index:app --reload --port 3000 - `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. -- `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. -- `index.html`: page structure and static asset links. IDs are JavaScript hooks; classes are styling hooks. +- `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. ## Change Points -- Agent prompt, greeting, voice, audio formats, and microphone constraints: edit `SESSION_CONFIG` in `static/app.js`. +- Agent prompt, greeting, voice, audio formats, and microphone constraints: edit `SESSION_CONFIG` in `public/static/app.js`. - Backend token lifetime or non-production hosts: edit `api/settings.py`. -- 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. +- 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. - 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 `index.html` or `static/`. +- Never expose `ASSEMBLYAI_API_KEY` or any server secret in `public/index.html` or `public/static/`. - Voice Agent token auth uses `Authorization: Bearer ...` in the backend. This differs from Streaming token auth. - Voice Agent `greeting` is spoken literally by TTS; write the exact words the user should hear. - `reply.audio` carries base64 PCM on the `data` field. diff --git a/aai_cli/init/templates/voice-agent/README.md b/aai_cli/init/templates/voice-agent/README.md index fe1a477d..3d4b4709 100644 --- a/aai_cli/init/templates/voice-agent/README.md +++ b/aai_cli/init/templates/voice-agent/README.md @@ -25,6 +25,6 @@ is needed. ## Ideas to extend -- Change the `greeting`, `systemPrompt`, or `voice` in `SESSION_CONFIG` (`static/app.js`). +- Change the `greeting`, `systemPrompt`, or `voice` in `SESSION_CONFIG` (`public/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 948845cc..5144773e 100644 --- a/aai_cli/init/templates/voice-agent/api/index.py +++ b/aai_cli/init/templates/voice-agent/api/index.py @@ -19,13 +19,14 @@ from api import settings ROOT = Path(__file__).resolve().parent.parent +PUBLIC = ROOT / "public" app = FastAPI() -app.mount("/static", StaticFiles(directory=ROOT / "static"), name="static") +app.mount("/static", StaticFiles(directory=PUBLIC / "static"), name="static") @app.get("/") def index() -> FileResponse: - return FileResponse(ROOT / "index.html") + return FileResponse(PUBLIC / "index.html") @app.post("/api/token") diff --git a/aai_cli/init/templates/voice-agent/index.html b/aai_cli/init/templates/voice-agent/public/index.html similarity index 100% rename from aai_cli/init/templates/voice-agent/index.html rename to aai_cli/init/templates/voice-agent/public/index.html diff --git a/aai_cli/init/templates/voice-agent/static/app.js b/aai_cli/init/templates/voice-agent/public/static/app.js similarity index 81% rename from aai_cli/init/templates/voice-agent/static/app.js rename to aai_cli/init/templates/voice-agent/public/static/app.js index 044886e5..55eb9be9 100644 --- a/aai_cli/init/templates/voice-agent/static/app.js +++ b/aai_cli/init/templates/voice-agent/public/static/app.js @@ -1,5 +1,6 @@ const SESSION_CONFIG = { - systemPrompt: "You are a friendly, concise voice assistant. Keep replies short and conversational.", + systemPrompt: + "You are a friendly, concise voice assistant. Keep replies short and conversational.", greeting: "Hi! I'm your AssemblyAI voice agent. What can I help you with?", input: { format: { encoding: "audio/pcm" } }, output: { voice: "ivy", format: { encoding: "audio/pcm" } }, @@ -18,7 +19,9 @@ let micPipeline = null; let player = null; let connected = false; -connBtn.addEventListener("click", () => (connected ? hangup() : connect().catch(fail))); +connBtn.addEventListener("click", () => + connected ? hangup() : connect().catch(fail), +); function setStatus(message, state) { statusEl.textContent = message; @@ -33,7 +36,9 @@ async function connect() { ws = new WebSocket(`${ws_url}?token=${encodeURIComponent(token)}`); ws.onopen = () => { - ws.send(JSON.stringify({ type: "session.update", session: buildSessionConfig() })); + ws.send( + JSON.stringify({ type: "session.update", session: buildSessionConfig() }), + ); startMic().catch(fail); }; ws.onmessage = (event) => onEvent(JSON.parse(event.data)); @@ -53,16 +58,29 @@ function buildSessionConfig() { } async function startMic() { - const stream = await navigator.mediaDevices.getUserMedia(SESSION_CONFIG.microphone); + const stream = await navigator.mediaDevices.getUserMedia( + SESSION_CONFIG.microphone, + ); micPipeline = AudioHelpers.createMicrophonePipeline(stream, { bufferSize: SESSION_CONFIG.processorBufferSize, }); - player = AudioHelpers.createPcmPlayer({ sampleRate: SESSION_CONFIG.outputSampleRate }); + player = AudioHelpers.createPcmPlayer({ + sampleRate: SESSION_CONFIG.outputSampleRate, + }); await player.resume(); await micPipeline.start((frame, sampleRate) => { if (!ws || ws.readyState !== WebSocket.OPEN) return; - const pcm = AudioHelpers.downsampleToPCM(frame, sampleRate, SESSION_CONFIG.inputSampleRate); - ws.send(JSON.stringify({ type: "input.audio", audio: AudioHelpers.bytesToB64(pcm) })); + const pcm = AudioHelpers.downsampleToPCM( + frame, + sampleRate, + SESSION_CONFIG.inputSampleRate, + ); + ws.send( + JSON.stringify({ + type: "input.audio", + audio: AudioHelpers.bytesToB64(pcm), + }), + ); }); connected = true; diff --git a/aai_cli/init/templates/voice-agent/static/audio.js b/aai_cli/init/templates/voice-agent/public/static/audio.js similarity index 97% rename from aai_cli/init/templates/voice-agent/static/audio.js rename to aai_cli/init/templates/voice-agent/public/static/audio.js index 744ffc25..bda694c9 100644 --- a/aai_cli/init/templates/voice-agent/static/audio.js +++ b/aai_cli/init/templates/voice-agent/public/static/audio.js @@ -81,7 +81,8 @@ function downsampleToPCM(input, inputRate, outputRate) { function bytesToB64(buffer) { let binary = ""; const bytes = new Uint8Array(buffer); - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); return btoa(binary); } diff --git a/aai_cli/init/templates/voice-agent/static/styles.css b/aai_cli/init/templates/voice-agent/public/static/styles.css similarity index 94% rename from aai_cli/init/templates/voice-agent/static/styles.css rename to aai_cli/init/templates/voice-agent/public/static/styles.css index 75d997d9..c3223409 100644 --- a/aai_cli/init/templates/voice-agent/static/styles.css +++ b/aai_cli/init/templates/voice-agent/public/static/styles.css @@ -1,6 +1,7 @@ /* THEME TOKENS: edit this block first for monotone colors, spacing, radii, and width. */ :root { - --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; --background: oklch(0.985 0 0); --foreground: oklch(0.12 0 0); --card: oklch(1 0 0); @@ -10,7 +11,8 @@ --secondary-foreground: oklch(0.38 0 0); --muted-foreground: oklch(0.58 0 0); --border: oklch(0.9 0 0); - --shadow-focus: 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); + --shadow-focus: + 0 0 0 1px oklch(0 0 0 / 0.08), 0 2px 8px -2px oklch(0 0 0 / 0.08); --color-bg: var(--background); --color-surface: var(--card); --color-text: var(--foreground); @@ -34,7 +36,9 @@ } /* BASE */ -* { box-sizing: border-box; } +* { + box-sizing: border-box; +} body { min-height: 100vh; diff --git a/aai_cli/init/templates/voice-agent/vercel.json b/aai_cli/init/templates/voice-agent/vercel.json deleted file mode 100644 index 7f1e3652..00000000 --- a/aai_cli/init/templates/voice-agent/vercel.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "rewrites": [ - { "source": "/api/(.*)", "destination": "/api/index" }, - { "source": "/(.*)", "destination": "/index.html" } - ] -} diff --git a/aai_cli/main.py b/aai_cli/main.py index 04dfc34c..cc367828 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -16,7 +16,6 @@ account, agent, audit, - claude, doctor, init, keys, @@ -24,6 +23,7 @@ login, samples, sessions, + setup, stream, transcribe, transcripts, @@ -42,7 +42,7 @@ # Setup & Tools — get set up & maintain; `version` last "samples", "doctor", - "claude", + "setup", "version", # Transcription & AI — the verbs you run "transcribe", @@ -134,7 +134,7 @@ def main( app.add_typer(doctor.app) app.add_typer(samples.app, name="samples", rich_help_panel=help_panels.SETUP) app.add_typer(init.app) -app.add_typer(claude.app, name="claude", rich_help_panel=help_panels.SETUP) +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/skills/aai-cli/SKILL.md b/aai_cli/skills/aai-cli/SKILL.md new file mode 100644 index 00000000..b66e59e2 --- /dev/null +++ b/aai_cli/skills/aai-cli/SKILL.md @@ -0,0 +1,98 @@ +--- +name: aai-cli +description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold starter apps and SDK samples (init/samples); diagnose setup (doctor); and set up your coding agent's AssemblyAI docs MCP + skills (setup). Use whenever an agent is invoking the `aai` command. +--- + +# AssemblyAI CLI (`aai`) + +`aai` runs AssemblyAI from the terminal: transcription, real-time streaming, +voice agents, the LLM Gateway, history, and account management. + +**`aai --help` is the source of truth for flags.** This skill covers +the command map and the non-obvious operational rules; check `--help` before +guessing a flag. + +## Critical: auth & environment + +**Authentication.** A command needs a key resolved in this order: + +1. `ASSEMBLYAI_API_KEY` environment variable +2. The OS keyring (populated by `aai login`) + +Get authenticated with either `aai login` (browser sign-in; stores a key in the +keyring) or by exporting `ASSEMBLYAI_API_KEY`. **Run commands deliberately have +no `--api-key` flag** — that is on purpose, so keys never land in `ps` output or +shell history. Do not look for one. + +**Environment binding.** The backend environment is selected by `--env` +(or `AAI_ENV`, or the profile's stored env). `--sandbox` is shorthand for +`--env sandbox000`. The default environment is currently `sandbox000`. +**A credential is only valid against the environment that minted it** — a +sandbox key fails against production and vice-versa. If a freshly-working key +suddenly returns auth errors, check you are on the same `--env` you logged in +under. + +**Profiles.** `--profile ` selects a named credential set. Global flags +(`--profile`, `--env`, `--sandbox`) go *before* the subcommand: +`aai --sandbox transcribe call.mp3`. + +## Output contract (read this before parsing output) + +- **Data goes to stdout; errors and progress go to stderr.** Piping stdout is + always safe. +- **`--json` is auto-enabled when output is piped or the CLI detects an agent + run**, so you usually get machine-readable JSON on stdout for free. Pass + `--json` explicitly to force it. Many commands also accept `-o/--output` to + print a single field (e.g. `-o text`). +- Expected failures print a clean message to stderr and exit non-zero — never a + traceback. Exit code reflects the error type. + +## Quick start + +```bash +aai login # browser sign-in (or: export ASSEMBLYAI_API_KEY=...) +aai doctor # verify the environment is ready +aai transcribe call.mp3 # transcribe a file +aai transcribe call.mp3 -o text # just the text, pipeline-friendly +aai stream # live transcription from the mic +aai init # scaffold a starter app +``` + +## Building an app vs running a command + +If the task is to **build/create an app or project** (a transcription app, live +captions, or a **voice agent app**), that is `aai init` — a scaffolder that +writes a full starter project (pick the `voice-agent` template for an agent +app). The verbs `aai transcribe`, `aai stream`, and **`aai agent`** are *run* +commands: they perform a one-off action in the terminal (e.g. `aai agent` holds +a live mic conversation) and produce **no code**. When someone says "build an +agent," reach for `aai init voice-agent`, not `aai agent`. + +## Decision tree + +- **Build/scaffold an app (transcription, live captions, or a voice agent app)** + → `aai init` — see `references/setup.md` +- **Transcribe a file/URL/YouTube, stream live audio, run a live voice agent, or + query the LLM Gateway** → `references/transcription.md` +- **Browse past transcripts or streaming sessions** → `references/history.md` +- **Sign in/out, identity, balance, usage, rate limits, API keys, audit log** → + `references/account.md` +- **Scaffold apps/samples (`init`, `samples`), diagnose setup (`doctor`), set up + your coding agent's MCP + skills (`setup`), version** → `references/setup.md` + +## Anti-patterns + +- **Passing `--api-key` to a run command.** It does not exist. Use `aai login` + or `ASSEMBLYAI_API_KEY`. +- **Mixing a credential with the wrong `--env`.** A `sandbox000` key won't work + against production. Log in and run under the same environment. +- **Running before authenticating.** No key → auth failure. Run `aai doctor` to + see exactly what's missing. +- **Assuming `pip install assemblyai-cli` works.** That PyPI name is squatted by + an unrelated third party. Use the project's official install path, not that + name. +- **Parsing human output.** Pipe stdout (auto-JSON) or pass `--json` / `-o text` + rather than scraping the pretty-printed tables. +- **Forgetting `--show-code`.** `transcribe`, `stream`, and `agent` accept + `--show-code` to print a ready-to-run Python SDK script for exactly the flags + you passed — no API call made. Great for "how would I do this in code?". diff --git a/aai_cli/skills/aai-cli/references/account.md b/aai_cli/skills/aai-cli/references/account.md new file mode 100644 index 00000000..5b138665 --- /dev/null +++ b/aai_cli/skills/aai-cli/references/account.md @@ -0,0 +1,172 @@ +# Account + +Commands for authentication and read-only account queries. Auth rules and +environment precedence live in `SKILL.md`; the notes below are per-command. +All commands accept `--json`. + +## `aai login` — authenticate + +Opens a browser OAuth flow and stores the resulting CLI API key in the OS +keyring, bound to the active `--env`; pass `--api-key` to authenticate +non-interactively (CI). + +Key options: + +- `--api-key TEXT` — provide a key directly without opening a browser. +- `--json` — machine-readable confirmation output. + +Examples: + +```bash +aai login +aai login --api-key sk_... +``` + +## `aai logout` — clear stored credentials + +Removes stored credentials for the active profile from the OS keyring. + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai logout +``` + +## `aai whoami` — show active profile + +Reports the active profile name and whether its stored key is currently usable +(i.e. validates against the active environment). + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai whoami +``` + +## `aai balance` — account balance + +Read-only query that shows your remaining account balance. + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai balance +``` + +## `aai usage` — usage over a date range + +Read-only query that shows API usage; defaults to the last 30 days. + +Key options: + +- `--start YYYY-MM-DD` — start date (default: 30 days ago). +- `--end YYYY-MM-DD` — end date (default: today). +- `--window day|month` — bucket size for the report. +- `--all` — include zero-usage windows. +- `--json` — machine-readable output. + +Examples: + +```bash +aai usage +aai usage --start 2026-05-01 --end 2026-06-01 +``` + +## `aai limits` — rate limits + +Read-only query that shows your account's rate limits per service. + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai limits +``` + +## `aai keys` — manage API keys + +Sub-app for listing, creating, and renaming AssemblyAI API keys; keys are shown +masked in list output. + +### `aai keys list` + +List all API keys across your projects (values masked). + +Key options: + +- `--json` — machine-readable output, useful for scripting. + +Examples: + +```bash +aai keys list +aai keys list --json +``` + +### `aai keys create` + +Create a new API key; the full key value is printed once — copy it immediately. + +Key options: + +- `--name TEXT` — label for the new key (required). +- `--project INTEGER` — project id to create the key in (defaults to your + first project). +- `--json` — machine-readable output. + +Examples: + +```bash +aai keys create --name ci-pipeline +aai keys create --name prod --project 7 +``` + +### `aai keys rename TOKEN_ID NEW_NAME` + +Rename (relabel) an existing API key; use `aai keys list` to find the key id. + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai keys rename 123 "prod" +``` + +## `aai audit` — audit log + +Read-only query that lists recent audit-log entries for the account; login +events are omitted by default. + +Key options: + +- `--limit N` — how many entries to show (default 20). +- `--action TEXT` — filter by raw action name (e.g. `token.create`). +- `--resource TEXT` — filter by raw resource type. +- `--include-logins` — include successful login events. +- `--json` — machine-readable output. + +Examples: + +```bash +aai audit --limit 20 +aai audit --include-logins +aai audit --action token.create +``` diff --git a/aai_cli/skills/aai-cli/references/history.md b/aai_cli/skills/aai-cli/references/history.md new file mode 100644 index 00000000..7af1135e --- /dev/null +++ b/aai_cli/skills/aai-cli/references/history.md @@ -0,0 +1,83 @@ +# History + +Two sub-apps for browsing past work. All output supports `--json` (auto-enabled +when piped) so an agent can parse listings and retrieve records by id. To run a +prompt over a past transcript's text, use `aai llm --transcript-id ID` (see +`transcription.md`). + +## `aai transcripts` — browse and fetch past transcripts + +Sub-app with two subcommands: `list` and `get`. + +### `aai transcripts list` + +List the most recent batch transcription jobs for the active account. + +Key options: + +- `--limit N` — how many transcripts to return (default 10). +- `--json` — machine-readable output; pipe to `jq` to extract ids. + +Examples: + +```bash +aai transcripts list +aai transcripts list --limit 50 +aai transcripts list --json | jq '.[].id' +``` + +### `aai transcripts get TRANSCRIPT_ID` + +Fetch a past transcript by id and print its text. + +Key options: + +- `-o/--output text|id|status|utterances|srt|json` — print one field; omit for + the default human view. +- `--json` — full raw JSON. + +Examples: + +```bash +aai transcripts get 5551234-abcd +aai transcripts get 5551234-abcd --json +aai transcripts get 5551234-abcd -o text +``` + +## `aai sessions` — browse past real-time streaming sessions + +Sub-app for the v3 real-time API session history, with two subcommands: `list` +and `get`. + +### `aai sessions list` + +List the most recent streaming sessions for the active account. + +Key options: + +- `--limit N` — how many sessions to return (default 10). +- `--status created|completed|error` — filter by session status. +- `--json` — machine-readable output. + +Examples: + +```bash +aai sessions list +aai sessions list --status completed +aai sessions list --limit 25 --json +``` + +### `aai sessions get SESSION_ID` + +Show details for a single streaming session by id. + +Key options: + +- `--json` — raw JSON output. + +Examples: + +```bash +aai sessions get +aai sessions get --json +``` diff --git a/aai_cli/skills/aai-cli/references/setup.md b/aai_cli/skills/aai-cli/references/setup.md new file mode 100644 index 00000000..0bc13697 --- /dev/null +++ b/aai_cli/skills/aai-cli/references/setup.md @@ -0,0 +1,154 @@ +# Setup & Tools + +Commands for scaffolding projects, validating the environment, and setting up +your coding agent (docs MCP + skills) for AssemblyAI. + +## `aai init [TEMPLATE] [DIRECTORY]` — scaffold a starter app + +**This is how you build a new app.** When the goal is to create an +application or project — including a voice-agent app — start here, not with the +`aai agent` / `aai transcribe` / `aai stream` run commands (those just *run* a +one-off action in the terminal and produce no code). + +Picks a template, scaffolds it into a directory, optionally installs +dependencies, starts the local server, and opens the browser. Available +templates: `audio-transcription`, `live-captions`, `voice-agent`. The API key +is written to a git-ignored `.env` file in the scaffolded directory. + +To build a **voice agent app**, use `aai init voice-agent` (a full +FastAPI + browser starter) — `aai agent` only runs a live mic conversation and +writes no code. + +Key options: + +- `--no-install` — scaffold only; skip install and launch. +- `--no-open` — install and launch but don't open the browser. +- `--force` — overwrite a non-empty target directory. +- `--here` — scaffold into the current directory instead of a new subdirectory. +- `--port INTEGER` — local server port (default 3000). +- `--json` — machine-readable output. + +Examples: + +```bash +aai init +aai init audio-transcription my-app +aai init audio-transcription --here +``` + +## `aai samples` — scaffold runnable starter scripts + +Sub-app for listing and scaffolding single-file Python starter scripts that read +`ASSEMBLYAI_API_KEY` from the environment. + +### `aai samples list` + +List the available sample script names. + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai samples list +``` + +### `aai samples create NAME` + +Scaffold a named starter script into the current directory. + +Key options: + +- `--force` — overwrite an existing file. +- `--json` — machine-readable output. + +Examples: + +```bash +aai samples create transcribe +aai samples create transcribe --force +``` + +## `aai doctor` — environment health check + +Verifies that your environment is ready to use AssemblyAI (checks credentials, +network reachability, and runtime dependencies). + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai doctor +``` + +## `aai setup` — set up your coding agent for AssemblyAI + +Sub-app that wires three things into your coding agent: the `assemblyai-docs` +MCP server (via `claude mcp add`), the `assemblyai` skill (downloaded with +`npx skills add`), and the `aai-cli` skill (this skill — bundled in the pip +package and copied in directly, no network needed). Missing `claude` or `npx` +is reported and skipped, not treated as an error; the bundled `aai-cli` skill +installs regardless. + +### `aai setup install` + +Install the docs MCP server and both skills into your coding agent. + +Key options: + +- `--scope user|project|local` — config scope to register the MCP under + (default `user`); presence is detected across all scopes. +- `--force` — reinstall even if already present. +- `--json` — machine-readable output. + +Examples: + +```bash +aai setup install +aai setup install --scope project +``` + +### `aai setup status` + +Show whether the MCP server and both skills are currently set up. + +Key options: + +- `--json` — machine-readable output. + +Examples: + +```bash +aai setup status +``` + +### `aai setup remove` + +Remove the MCP server and both skills from your coding agent. + +Key options: + +- `--scope user|project|local` — remove the MCP only from this scope (default: + remove from whichever scope it is found in). +- `--json` — machine-readable output. + +Examples: + +```bash +aai setup remove +``` + +## `aai version` — show CLI version + +Prints the installed `aai` version string. + +Examples: + +```bash +aai version +``` diff --git a/aai_cli/skills/aai-cli/references/transcription.md b/aai_cli/skills/aai-cli/references/transcription.md new file mode 100644 index 00000000..03d11468 --- /dev/null +++ b/aai_cli/skills/aai-cli/references/transcription.md @@ -0,0 +1,113 @@ +# Transcription & AI + +Four commands. All accept `--json` (auto-enabled when piped) and `-o/--output` +to print a single field. `transcribe`, `stream`, and `agent` accept +`--show-code` to print equivalent Python SDK code without calling the API. + +## `aai transcribe [SOURCE]` — file / URL / YouTube + +`SOURCE` is a local file path, public URL, or YouTube URL (downloaded first). +Use `--sample` for the hosted `wildfires.mp3`. Analysis results (summary, +chapters, sentiment, …) render automatically in human mode. + +High-value flags (run `aai transcribe --help` for the full set): + +- Model/language: `--speech-model` (best, nano, slam-1, universal), + `--language-code en_us`, `--language-detection`. +- Diarization: `--speaker-labels`, `--speakers-expected N`, `--multichannel`. +- PII: `--redact-pii`, `--redact-pii-policy person_name,...`, + `--redact-pii-sub hash|entity_name`, `--redact-pii-audio`. +- Audio intelligence: `--summarization`, `--auto-chapters`, + `--sentiment-analysis`, `--entity-detection`, `--auto-highlights`, + `--topic-detection`, `--content-safety`. +- Escape hatch to any SDK field: `--config KEY=VALUE` (repeatable) and + `--config-file config.json`. +- Post-process: `--llm "PROMPT"` (repeatable; chains over the transcript via LLM + Gateway), `--translate-to es` (repeatable). +- Output: `-o text|id|status|utterances|srt|json`, `--json`, `--show-code`. + +Examples: + +```bash +aai transcribe call.mp3 +aai transcribe --sample +aai transcribe call.mp3 --speaker-labels --speakers-expected 2 --redact-pii +aai transcribe call.mp3 -o text +aai transcribe call.mp3 --show-code +``` + +## `aai stream [SOURCE]` — live real-time transcription + +Omit `SOURCE` to use the microphone; pass a file/URL/YouTube to stream that, or +`--sample`. macOS can capture system audio with `--system-audio` (mic + system) +or `--system-audio-only`. + +High-value flags (run `aai stream --help` for the full set): + +- Capture: `--device N`, `--sample-rate HZ`, `--encoding pcm_s16le|pcm_mulaw`. +- Model/turns: `--speech-model` (default `u3-rt-pro`), `--format-turns`, + `--include-partial-turns`, `--end-of-turn-confidence`, `--min-turn-silence`, + `--max-turn-silence`, `--vad-threshold`. +- Features: `--speaker-labels`, `--max-speakers`, `--keyterms-prompt`, + `--redact-pii`, `--voice-focus near_field|far_field`, `--domain medical`. +- Live LLM: `--llm "PROMPT"` (refreshes the answer on every finalized turn). +- Output: `-o text|json`, `--json` (newline-delimited JSON events), + `--show-code`. + +Examples: + +```bash +aai stream +aai stream --system-audio +aai stream --sample +aai stream --llm "summarize action items" +aai stream -o text # finalized turns as plain lines, pipe-friendly +``` + +## `aai agent [SOURCE]` — full-duplex voice agent + +Two-way voice conversation (mic in, TTS out). Pass a file/URL or `--sample` to +speak a recorded clip instead of the mic; the session then ends after the reply. + +> **`aai agent` only *runs* a live conversation in the terminal — it does not +> create any code or project.** If the goal is to *build* a voice-agent app, +> use `aai init` with the `voice-agent` template (see `setup.md`), not this +> command. + +High-value flags: + +- `--voice ivy` (see `--list-voices`), `--system-prompt "..."` or + `--system-prompt-file path`, `--greeting "..."`, `--device N`. +- Output: `-o text|json`, `--json`, `--show-code`. + +Examples: + +```bash +aai agent +aai agent --voice james --greeting "Hi there" +aai agent --list-voices +aai agent --show-code +``` + +## `aai llm [PROMPT]` — LLM Gateway + +Send a prompt to the LLM Gateway. With `--transcript-id ID` the transcript's +text is injected server-side so you can ask questions about a past +transcription. Reads stdin when piped. + +High-value flags: + +- `--model` (default `claude-haiku-4-5-20251001`, see `--list-models`), + `--transcript-id ID`, `--system "..."`, `--max-tokens N`. +- `-f/--follow`: re-run the prompt over a transcript growing on stdin, + refreshing the answer in place on every finalized turn. +- Output: `-o text|json`, `--json`. + +Examples: + +```bash +aai llm "summarize" --transcript-id 5551234-abcd +echo "meeting notes" | aai llm "turn into action items" +aai stream -o text | aai llm -f "summarize action items as I talk" +aai llm --list-models +``` diff --git a/aai_cli/steps.py b/aai_cli/steps.py index 0b516221..12b1c905 100644 --- a/aai_cli/steps.py +++ b/aai_cli/steps.py @@ -18,7 +18,7 @@ class Step(TypedDict): def render_steps(items: list[Step], *, heading: str) -> str: """Render steps as a themed heading followed by one status-styled line each. - Shared by the multi-step commands (`aai init`, `aai claude`); each passes its + Shared by the multi-step commands (`aai init`, `aai setup`); each passes its own heading. """ lines: list[str] = [] diff --git a/docs/superpowers/plans/2026-06-05-aai-cli-skill.md b/docs/superpowers/plans/2026-06-05-aai-cli-skill.md new file mode 100644 index 00000000..004758c7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-aai-cli-skill.md @@ -0,0 +1,608 @@ +# aai-cli skill 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:** Ship a model-invocable `aai-cli` skill (mirroring `vercel/skills/vercel-cli`) that teaches an agent how to drive the installed `aai` CLI safely, and wire it into `aai claude install`/`status`/`remove`. + +**Architecture:** A new repo-root `skills/aai-cli/` directory with a lean `SKILL.md` (decision tree + the auth/env/output gotchas + anti-patterns) routing to four `references/*.md` files grouped to match the CLI's own `aai --help` sections. Distribution mirrors the existing `assemblyai` skill step in `aai_cli/commands/claude.py`: a third `npx skills add` step, detected on disk under `~/.claude/skills/aai-cli/`. + +**Tech Stack:** Markdown (skill content), Python/Typer (`aai_cli/commands/claude.py`), pytest (coverage gate 90%), ruff/mypy/markdownlint via `./scripts/check.sh`. + +**Spec:** `docs/superpowers/specs/2026-06-05-aai-cli-skill-design.md` + +--- + +## File Structure + +- **Create** `skills/aai-cli/SKILL.md` — model-invocable keystone: intro, auth & env rules, output contract, quick start, decision tree, anti-patterns. +- **Create** `skills/aai-cli/references/transcription.md` — `transcribe`, `stream`, `agent`, `llm`. +- **Create** `skills/aai-cli/references/history.md` — `transcripts`, `sessions`. +- **Create** `skills/aai-cli/references/account.md` — `login`, `logout`, `whoami`, `balance`, `usage`, `limits`, `keys`, `audit`. +- **Create** `skills/aai-cli/references/setup.md` — `init`, `samples`, `doctor`, `claude`, `version`. +- **Modify** `aai_cli/commands/claude.py` — add the `aai-cli` skill install/status/remove step. +- **Modify** `tests/test_claude.py` (or the existing claude-command test file) — assert three steps and cover the new step's branches. + +### Important: capturing `--help` cleanly + +In this dev environment, `uv run aai --help` prints build/dependency noise that is **not** part of the help. Ignore these lines when transcribing content into the skill: + +``` + Building aai-cli @ file:///... + Built aai-cli @ file:///... +Uninstalled 1 package in 1ms +Installed 1 package in 1ms +Fetching malware lists from https://malware-list.aikido.dev (default) +``` + +Only the `Usage: ...` block, `Options`, and `Examples` are real help content. + +--- + +## Task 1: Create `skills/aai-cli/SKILL.md` + +**Files:** +- Create: `skills/aai-cli/SKILL.md` + +- [ ] **Step 1: Write the file with exactly this content** + +````markdown +--- +name: aai-cli +description: Use the AssemblyAI CLI (`aai`) from the command line — transcribe audio/video files, URLs, and YouTube links; stream live real-time transcription from a mic/file/system audio; run full-duplex voice agents; query the LLM Gateway over transcripts; browse transcript and streaming-session history; sign in and manage account balance, usage, rate limits, API keys, and audit logs; scaffold starter apps and SDK samples (init/samples); diagnose setup (doctor); and wire up Claude Code (claude). Use whenever an agent is invoking the `aai` command. +--- + +# AssemblyAI CLI (`aai`) + +`aai` runs AssemblyAI from the terminal: transcription, real-time streaming, +voice agents, the LLM Gateway, history, and account management. + +**`aai --help` is the source of truth for flags.** This skill covers +the command map and the non-obvious operational rules; check `--help` before +guessing a flag. + +## Critical: auth & environment + +**Authentication.** A command needs a key resolved in this order: + +1. `ASSEMBLYAI_API_KEY` environment variable +2. The OS keyring (populated by `aai login`) + +Get authenticated with either `aai login` (browser sign-in; stores a key in the +keyring) or by exporting `ASSEMBLYAI_API_KEY`. **Run commands deliberately have +no `--api-key` flag** — that is on purpose, so keys never land in `ps` output or +shell history. Do not look for one. + +**Environment binding.** The backend environment is selected by `--env` +(or `AAI_ENV`, or the profile's stored env). `--sandbox` is shorthand for +`--env sandbox000`. The default environment is currently `sandbox000`. +**A credential is only valid against the environment that minted it** — a +sandbox key fails against production and vice-versa. If a freshly-working key +suddenly returns auth errors, check you are on the same `--env` you logged in +under. + +**Profiles.** `--profile ` selects a named credential set. Global flags +(`--profile`, `--env`, `--sandbox`) go *before* the subcommand: +`aai --sandbox transcribe call.mp3`. + +## Output contract (read this before parsing output) + +- **Data goes to stdout; errors and progress go to stderr.** Piping stdout is + always safe. +- **`--json` is auto-enabled when output is piped or the CLI detects an agent + run**, so you usually get machine-readable JSON on stdout for free. Pass + `--json` explicitly to force it. Many commands also accept `-o/--output` to + print a single field (e.g. `-o text`). +- Expected failures print a clean message to stderr and exit non-zero — never a + traceback. Exit code reflects the error type. + +## Quick start + +```bash +aai login # browser sign-in (or: export ASSEMBLYAI_API_KEY=...) +aai doctor # verify the environment is ready +aai transcribe call.mp3 # transcribe a file +aai transcribe call.mp3 -o text # just the text, pipeline-friendly +aai stream # live transcription from the mic +aai init # scaffold a starter app +``` + +## Decision tree + +- **Transcribe a file/URL/YouTube, stream live audio, run a voice agent, or + query the LLM Gateway** → `references/transcription.md` +- **Browse past transcripts or streaming sessions** → `references/history.md` +- **Sign in/out, identity, balance, usage, rate limits, API keys, audit log** → + `references/account.md` +- **Scaffold apps/samples (`init`, `samples`), diagnose setup (`doctor`), wire + up Claude Code (`claude`), version** → `references/setup.md` + +## Anti-patterns + +- **Passing `--api-key` to a run command.** It does not exist. Use `aai login` + or `ASSEMBLYAI_API_KEY`. +- **Mixing a credential with the wrong `--env`.** A `sandbox000` key won't work + against production. Log in and run under the same environment. +- **Running before authenticating.** No key → auth failure. Run `aai doctor` to + see exactly what's missing. +- **Assuming `pip install assemblyai-cli` works.** That PyPI name is squatted by + an unrelated third party. Use the project's official install path, not that + name. +- **Parsing human output.** Pipe stdout (auto-JSON) or pass `--json` / `-o text` + rather than scraping the pretty-printed tables. +- **Forgetting `--show-code`.** `transcribe`, `stream`, and `agent` accept + `--show-code` to print a ready-to-run Python SDK script for exactly the flags + you passed — no API call made. Great for "how would I do this in code?". +```` + +- [ ] **Step 2: Lint the file** + +Run: `uv run --with-requirements /dev/null markdownlint skills/aai-cli/SKILL.md` — or simply rely on the project's markdownlint via `./scripts/check.sh` in Task 6. If markdownlint is installed locally: + +Run: `markdownlint skills/aai-cli/SKILL.md` +Expected: no output (clean), or fix any reported line-length/heading issues. + +- [ ] **Step 3: Commit** + +```bash +git add skills/aai-cli/SKILL.md +git commit -m "Add aai-cli skill SKILL.md" +``` + +--- + +## Task 2: Create `references/transcription.md` (the exemplar) + +This file is written **in full** below — it is the worked example whose +structure Tasks 3 covers for the other groups. Content is verified against +`aai transcribe|stream|agent|llm --help`. + +**Files:** +- Create: `skills/aai-cli/references/transcription.md` + +- [ ] **Step 1: Write the file with exactly this content** + +````markdown +# Transcription & AI + +Four commands. All accept `--json` (auto-enabled when piped) and `-o/--output` +to print a single field. `transcribe`, `stream`, and `agent` accept +`--show-code` to print equivalent Python SDK code without calling the API. + +## `aai transcribe [SOURCE]` — file / URL / YouTube + +`SOURCE` is a local file path, public URL, or YouTube URL (downloaded first). +Use `--sample` for the hosted `wildfires.mp3`. Analysis results (summary, +chapters, sentiment, …) render automatically in human mode. + +High-value flags (run `aai transcribe --help` for the full set): + +- Model/language: `--speech-model` (best, nano, slam-1, universal), + `--language-code en_us`, `--language-detection`. +- Diarization: `--speaker-labels`, `--speakers-expected N`, `--multichannel`. +- PII: `--redact-pii`, `--redact-pii-policy person_name,...`, + `--redact-pii-sub hash|entity_name`, `--redact-pii-audio`. +- Audio intelligence: `--summarization`, `--auto-chapters`, + `--sentiment-analysis`, `--entity-detection`, `--auto-highlights`, + `--topic-detection`, `--content-safety`. +- Escape hatch to any SDK field: `--config KEY=VALUE` (repeatable) and + `--config-file config.json`. +- Post-process: `--llm "PROMPT"` (repeatable; chains over the transcript via LLM + Gateway), `--translate-to es` (repeatable). +- Output: `-o text|id|status|utterances|srt|json`, `--json`, `--show-code`. + +Examples: + +```bash +aai transcribe call.mp3 +aai transcribe --sample +aai transcribe call.mp3 --speaker-labels --speakers-expected 2 --redact-pii +aai transcribe call.mp3 -o text +aai transcribe call.mp3 --show-code +``` + +## `aai stream [SOURCE]` — live real-time transcription + +Omit `SOURCE` to use the microphone; pass a file/URL/YouTube to stream that, or +`--sample`. macOS can capture system audio with `--system-audio` (mic + system) +or `--system-audio-only`. + +High-value flags (run `aai stream --help` for the full set): + +- Capture: `--device N`, `--sample-rate HZ`, `--encoding pcm_s16le|pcm_mulaw`. +- Model/turns: `--speech-model` (default `u3-rt-pro`), `--format-turns`, + `--include-partial-turns`, `--end-of-turn-confidence`, `--min-turn-silence`, + `--max-turn-silence`, `--vad-threshold`. +- Features: `--speaker-labels`, `--max-speakers`, `--keyterms-prompt`, + `--redact-pii`, `--voice-focus near_field|far_field`, `--domain medical`. +- Live LLM: `--llm "PROMPT"` (refreshes the answer on every finalized turn). +- Output: `-o text|json`, `--json` (newline-delimited JSON events), + `--show-code`. + +Examples: + +```bash +aai stream +aai stream --system-audio +aai stream --sample +aai stream --llm "summarize action items" +aai stream -o text # finalized turns as plain lines, pipe-friendly +``` + +## `aai agent [SOURCE]` — full-duplex voice agent + +Two-way voice conversation (mic in, TTS out). Pass a file/URL or `--sample` to +speak a recorded clip instead of the mic; the session then ends after the reply. + +High-value flags: + +- `--voice ivy` (see `--list-voices`), `--system-prompt "..."` or + `--system-prompt-file path`, `--greeting "..."`, `--device N`. +- Output: `-o text|json`, `--json`, `--show-code`. + +Examples: + +```bash +aai agent +aai agent --voice james --greeting "Hi there" +aai agent --list-voices +aai agent --show-code +``` + +## `aai llm [PROMPT]` — LLM Gateway + +Send a prompt to the LLM Gateway. With `--transcript-id ID` the transcript's +text is injected server-side so you can ask questions about a past +transcription. Reads stdin when piped. + +High-value flags: + +- `--model` (default `claude-haiku-4-5-20251001`, see `--list-models`), + `--transcript-id ID`, `--system "..."`, `--max-tokens N`. +- `-f/--follow`: re-run the prompt over a transcript growing on stdin, + refreshing the answer in place on every finalized turn. +- Output: `-o text|json`, `--json`. + +Examples: + +```bash +aai llm "summarize" --transcript-id 5551234-abcd +echo "meeting notes" | aai llm "turn into action items" +aai stream -o text | aai llm -f "summarize action items as I talk" +aai llm --list-models +``` +```` + +- [ ] **Step 2: Commit** + +```bash +git add skills/aai-cli/references/transcription.md +git commit -m "Add aai-cli transcription reference" +``` + +--- + +## Task 3: Create `history.md`, `account.md`, `setup.md` + +These three reference files follow the **same structure as `transcription.md`** +(Task 2): for each command — a `##` heading with its usage line, one sentence of +purpose, a short bullet list of the highest-value flags, and a fenced +`Examples` block copied from the command's own `--help` Examples section. + +**Procedure for each command below:** run `aai --help`, ignore the +build/aikido noise (see "Important" at the top of this plan), and transcribe its +purpose + top flags + the `Examples` block. + +**Files:** +- Create: `skills/aai-cli/references/history.md` +- Create: `skills/aai-cli/references/account.md` +- Create: `skills/aai-cli/references/setup.md` + +- [ ] **Step 1: Gather help for the History group** + +Run: `uv run aai transcripts --help && uv run aai sessions --help` +(Note: `transcripts` and `sessions` are sub-apps — also check their subcommands, +e.g. `aai transcripts --help` lists `list`/`get`/etc.; run `--help` on each.) + +- [ ] **Step 2: Write `references/history.md`** + +Header `# History`, then a `##` section per command (`transcripts`, `sessions` +and their subcommands). Each section: usage line, one-sentence purpose, top +flags as bullets, and an `Examples` fenced block from `--help`. Emphasize that +output is JSON-friendly (pipe stdout / `--json`) so an agent can parse listings +and fetch a transcript by id, and cross-link: "fetch a transcript's text for a +prompt with `aai llm --transcript-id` (see `transcription.md`)." + +- [ ] **Step 3: Gather help for the Account group** + +Run each: `uv run aai login --help`, `... logout --help`, `... whoami --help`, +`... balance --help`, `... usage --help`, `... limits --help`, +`... keys --help` (a sub-app — also its subcommands), `... audit --help`. + +- [ ] **Step 4: Write `references/account.md`** + +Header `# Account`, then a `##` section per command. Cross-reference the +SKILL.md auth/env rules rather than repeating them: note that `login` stores the +key in the keyring bound to the active `--env`, `whoami` reports whether the +active profile's key is usable, and `keys` manages AssemblyAI API keys (list/ +create/rename). Include each command's `Examples` block. + +- [ ] **Step 5: Gather help for the Setup group** + +Run each: `uv run aai init --help`, `... samples --help`, `... doctor --help`, +`... claude --help` (and `aai claude install|status|remove --help`), +`... version --help`. + +- [ ] **Step 6: Write `references/setup.md`** + +Header `# Setup & Tools`, then a `##` section per command. For `init` note the +templates (`audio-transcription` / `live-captions` / `voice-agent`) and that it +writes the key to a git-ignored `.env`; for `claude` note it wires the +`assemblyai-docs` MCP + the `assemblyai` skill **and this `aai-cli` skill** (see +Task 4) into Claude Code. Include each command's `Examples` block. + +- [ ] **Step 7: Commit** + +```bash +git add skills/aai-cli/references/history.md skills/aai-cli/references/account.md skills/aai-cli/references/setup.md +git commit -m "Add aai-cli history, account, and setup references" +``` + +--- + +## Task 4: Wire the skill into `aai claude install`/`status`/`remove` + +Mirror the existing `_install_skill` / `_skill_status` / `_remove_skill` +functions in `aai_cli/commands/claude.py` for a second skill named `aai-cli` +sourced from **this** repo. + +**Files:** +- Modify: `aai_cli/commands/claude.py` +- Test: `tests/` (the file that tests `claude.py` — find it in Task 5) + +- [ ] **Step 1: VERIFY the exact `skills` invocation (do not guess)** + +The existing skill is a dedicated repo (`AssemblyAI/assemblyai-skill` → skill +`assemblyai`). This skill lives in a **subdirectory** (`skills/aai-cli/`) of +`AssemblyAI/cli`. Confirm how the `skills` CLI targets it: + +Run: `npx -y skills add --help` and `npx -y skills --help` + +Determine which form installs **only** the `aai-cli` skill into +`~/.claude/skills/aai-cli/` (candidates, in order of likelihood): +`npx skills add AssemblyAI/cli aai-cli`, `npx skills add AssemblyAI/cli`, +or a path-qualified form. Verify by running it and checking that +`~/.claude/skills/aai-cli/SKILL.md` appears (and that the repo's `.claude/skills` +dev skills are NOT pulled in). Use the confirmed command in Step 2. + +**Fallback:** if no `skills` form can target the subdirectory skill, stop and +switch to spec Approach B (bundle the skill in the wheel via +`[tool.hatch.build.targets.wheel] artifacts` and copy it into +`~/.claude/skills/aai-cli` on install). Note the deviation in the commit. + +- [ ] **Step 2: Add the constants and helpers** + +Near the existing `_SKILL_ADD` block in `aai_cli/commands/claude.py`, add (using +the command form confirmed in Step 1 — shown here with the most likely form): + +```python +CLI_SKILL_REPO = "AssemblyAI/cli" +_CLI_SKILL_NAME = "aai-cli" +_CLI_SKILL_ADD = ["npx", "-y", "skills", "add", CLI_SKILL_REPO, _CLI_SKILL_NAME, "--global", "--yes"] +_CLI_SKILL_REMOVE = ["npx", "-y", "skills", "remove", _CLI_SKILL_NAME, "--global"] +_CLI_SKILL_ADD_HINT = f"npx skills add {CLI_SKILL_REPO} {_CLI_SKILL_NAME} --global" + + +def _cli_skill_dir() -> Path: + config_dir = os.environ.get("CLAUDE_CONFIG_DIR") + root = Path(config_dir) if config_dir else Path.home() / ".claude" + return root / "skills" / _CLI_SKILL_NAME + + +def _cli_skill_installed() -> bool: + return (_cli_skill_dir() / "SKILL.md").exists() + + +def _install_cli_skill(force: bool) -> Step: + if shutil.which("npx") is None: + return { + "name": "aai-cli skill", + "status": "skipped", + "detail": f"Node.js/npx not found. Install Node.js, then run: {_CLI_SKILL_ADD_HINT}", + } + if _cli_skill_installed() and not force: + return { + "name": "aai-cli skill", + "status": "already", + "detail": f"aai-cli skill at {_cli_skill_dir()}", + } + proc = _run(_CLI_SKILL_ADD, timeout=300) + if proc.returncode != 0: + return {"name": "aai-cli skill", "status": "failed", "detail": _proc_detail(proc)} + if not _cli_skill_installed(): + return { + "name": "aai-cli skill", + "status": "failed", + "detail": ( + f"'{' '.join(_CLI_SKILL_ADD[3:])}' reported success but no skill was found at " + f"{_cli_skill_dir()}. Install it manually: {_CLI_SKILL_ADD_HINT}" + ), + } + return {"name": "aai-cli skill", "status": "installed", "detail": str(_cli_skill_dir())} + + +def _cli_skill_status() -> Step: + return { + "name": "aai-cli skill", + "status": "installed" if _cli_skill_installed() else "not_installed", + "detail": str(_cli_skill_dir()), + } + + +def _remove_cli_skill() -> Step: + if not _cli_skill_installed(): + return {"name": "aai-cli skill", "status": "not_installed", "detail": str(_cli_skill_dir())} + if shutil.which("npx") is None: + return { + "name": "aai-cli skill", + "status": "skipped", + "detail": f"Node.js/npx not found. Remove manually: {' '.join(_CLI_SKILL_REMOVE)}", + } + proc = _run(_CLI_SKILL_REMOVE, timeout=120) + if proc.returncode != 0 or _cli_skill_installed(): + detail = _proc_detail(proc) or "skill still present after removal" + return {"name": "aai-cli skill", "status": "failed", "detail": detail} + return {"name": "aai-cli skill", "status": "removed", "detail": str(_cli_skill_dir())} +``` + +Also rename the existing `assemblyai` skill step's `"name"` from `"skill"` to a +clearer label if desired, but **keep it `"skill"`** to avoid churning existing +tests — only the new step uses `"aai-cli skill"`. + +- [ ] **Step 3: Add the new step to the three command bodies** + +In `install`'s `body`, change the steps list to include the new step: + +```python +steps = [_install_mcp(scope, force), _install_skill(force), _install_cli_skill(force)] +``` + +In `status`'s `body`: + +```python +steps = [_mcp_status(), _skill_status(), _cli_skill_status()] +``` + +In `remove`'s `body`: + +```python +steps = [_remove_mcp(scope), _remove_skill(), _remove_cli_skill()] +``` + +- [ ] **Step 4: Type-check and lint** + +Run: `uv run mypy && uv run ruff check aai_cli/commands/claude.py` +Expected: clean. (Watch for the ruff PostToolUse autofix stripping a +now-unused import; re-add if needed.) + +- [ ] **Step 5: Commit** + +```bash +git add aai_cli/commands/claude.py +git commit -m "Install the aai-cli skill alongside the docs MCP and assemblyai skill" +``` + +--- + +## Task 5: Tests for the new install step + +**Files:** +- Test: locate with `grep -rl "_install_skill\|claude install\|MCP_NAME\|assemblyai-docs" tests/` + +- [ ] **Step 1: Find and read the existing claude tests** + +Run: `grep -rl "assemblyai-docs\|_install_skill\|claude" tests/` +Read the file. Note how it stubs `shutil.which`, `_run`, and `_skill_installed` +(likely via monkeypatch) and how it asserts on the steps list / rendered output. + +- [ ] **Step 2: Write failing tests for the three branches of the new step** + +Mirror the existing `assemblyai` skill tests. Add tests asserting: + +```python +def test_install_includes_aai_cli_skill(monkeypatch, ...): + # npx present, skill not yet installed, _run returns success, dir then exists + # -> steps include {"name": "aai-cli skill", "status": "installed", ...} + ... + +def test_install_aai_cli_skill_skipped_without_npx(monkeypatch, ...): + # shutil.which("npx") -> None + # -> {"name": "aai-cli skill", "status": "skipped", ...} + ... + +def test_status_reports_aai_cli_skill(monkeypatch, ...): + # _cli_skill_installed() patched True/False + # -> status step present with installed / not_installed + ... + +def test_remove_aai_cli_skill(monkeypatch, ...): + # installed then removed; assert {"name": "aai-cli skill", "status": "removed"} + ... +``` + +Match the existing tests' exact monkeypatch targets (e.g. +`aai_cli.commands.claude._run`, `...shutil.which`, `..._cli_skill_installed`). +Update any existing test that asserts the **number** of steps (was 2, now 3) or +asserts the full steps list. + +- [ ] **Step 3: Run the new tests to verify they fail (before Step 2 of Task 4 if done out of order) / pass now** + +Run: `uv run pytest tests/.py -q` +Expected: PASS (Task 4 already added the implementation). If you wrote tests +first, they fail with the expected missing-step assertion, then pass after Task 4. + +- [ ] **Step 4: Commit** + +```bash +git add tests/.py +git commit -m "Test the aai-cli skill install/status/remove step" +``` + +--- + +## Task 6: Full gate + content verification + +**Files:** none (verification only) + +- [ ] **Step 1: Verify every skill example against real `--help`** + +For each command referenced in the skill, confirm the flags exist: + +Run: `uv run aai transcribe --help`, `... stream --help`, `... agent --help`, +`... llm --help`, and the History/Account/Setup commands. Cross-check each flag +named in the reference files against its `--help`. Fix any drift. + +- [ ] **Step 2: Run the full gate** + +Run: `./scripts/check.sh` +Expected: ends with `All checks passed.` This runs ruff, ruff format --check, +mypy, **markdownlint** (which now lints `skills/aai-cli/**.md` — fix any +line-length/heading/list issues), shellcheck, pytest with the **90% +branch-coverage gate**, and build + `twine check --strict`. + +If markdownlint flags the new markdown, fix the files and re-run until green. + +- [ ] **Step 3: Manual smoke test of the install wiring** + +Run: `uv run aai claude status --json` +Expected: JSON listing three steps including `"aai-cli skill"`. (Install/remove +need network + npx; run `aai claude install` manually if you want a live check, +then `aai claude remove`.) + +- [ ] **Step 4: Run the project review skill** + +Run `/review-changes` on the diff (it runs the `code-review` skill; this diff +touches `aai_cli/commands/claude.py` which shells out via `subprocess`, so the +`security-review` + `security-reviewer` agent paths apply). Address findings. + +- [ ] **Step 5: Final commit if review produced changes** + +```bash +git add -A && git commit -m "Address review feedback for aai-cli skill" +``` + +--- + +## Self-review notes + +- **Spec coverage:** SKILL.md (Task 1) ✓; references mirroring CLI `--help` + groups (Tasks 2–3) ✓; auth/env/output/anti-patterns content (Task 1) ✓; + `claude install`/`status`/`remove` wiring (Task 4) ✓; tests for 90% gate + (Task 5) ✓; markdownlint over `skills/` + example verification + check.sh + (Task 6) ✓; distribution-readiness risk + `npx` subdir verification + (Task 4 Step 1) ✓. +- **`disable-model-invocation` intentionally omitted** from SKILL.md frontmatter + so the skill is model-invocable (unlike the repo's dev skills) — this is the + whole point and matches the spec. +- **Distribution-readiness risk** (repo must be public for external `npx skills + add`) is a maintainer flag from the spec, surfaced in Task 4 Step 1's fallback + rather than solved here. diff --git a/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md b/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md index 868098b2..cbe50c8a 100644 --- a/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md +++ b/docs/superpowers/specs/2026-06-04-stytch-oauth-cli-auth-design.md @@ -177,7 +177,8 @@ CLI; surface a clear message and stop). - `STYTCH_OAUTH_PROVIDER = "google"` (enabled + verified on the project) - `LOOPBACK_REDIRECT = "http://127.0.0.1:8585/callback"` (fixed port; exact-match validation — Stytch rejects unregistered ports/paths. Single registered URL.) -- `AMS_BASE_URL` (`https://ams.sandbox000.assemblyai-labs.com`; prod URL per P2) +- `AMS_BASE_URL` (`https://ams.sandbox000.assemblyai-labs.com`; production: + `https://ams.internal.assemblyai-labs.com`) - `CLI_TOKEN_NAME = "AssemblyAI CLI"` Hardcode sandbox defaults; allow override via env (e.g. `AAI_AUTH_*`) so swapping @@ -232,10 +233,9 @@ Mirror existing test patterns in `tests/` (see `test_login*`-style if present). → 401 unauth; `/v2/auth/discover` processes via Stytch). Settle via fcastillo or the first real end-to-end login. Note: a first-time user with **0 orgs** hits the `/v2/auth/organization` create branch — the CLI must handle it. -- **P2 — Blessed public, stable AMS URL.** A public sandbox is already reachable - (`ams.sandbox000.assemblyai-labs.com`); a production public URL (out of - `experimental/`) is needed for release. *(Downgraded: the "internal-only, - unreachable" risk is resolved — a public ingress demonstrably exists.)* +- **P2 — RESOLVED.** Production AMS endpoint: + `https://ams.internal.assemblyai-labs.com`. A public sandbox remains reachable + at `ams.sandbox000.assemblyai-labs.com`. *(Former P3 removed: AMS already brokers the Stytch exchange via `/v2/auth/discover` + `/v2/auth/exchange`; no new endpoint is required.)* diff --git a/docs/superpowers/specs/2026-06-05-aai-cli-skill-design.md b/docs/superpowers/specs/2026-06-05-aai-cli-skill-design.md new file mode 100644 index 00000000..a7c772a4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-aai-cli-skill-design.md @@ -0,0 +1,143 @@ +# aai-cli skill — design + +## Problem + +When an agent (Claude Code or otherwise) drives the installed `aai` CLI, it has +no grounding in the CLI's command surface or — more importantly — its +non-obvious operational rules: the auth/env credential binding, the +no-`--api-key`-on-run-commands rule, the stdout/stderr split, and the +`--json`-when-piped behavior. It guesses, and the guesses leak keys into argv, +mix sandbox credentials with production, or parse human-formatted output. + +Vercel ships `skills/vercel-cli` for exactly this reason: a model-invocable +skill that teaches an agent how to use the `vercel` CLI safely. This design +adds the equivalent for `aai`. + +This skill is distinct from the existing **`assemblyai`** skill (installed from +`AssemblyAI/assemblyai-skill` via `aai claude install`), which covers the +**APIs/SDKs**. The new skill covers the **`aai` CLI** specifically. They +coexist. + +## Goals + +- A model-invocable `aai-cli` skill that auto-triggers when an agent is using + the `aai` CLI, teaching it the command surface and the operational gotchas. +- Mirror Vercel's layout: lean `SKILL.md` with a decision tree + anti-patterns, + routing to `references/*.md`. +- Wire the skill into `aai claude install` / `status` / `remove`, alongside the + existing docs MCP and `assemblyai` skill. + +## Non-goals (YAGNI) + +- No `command/` subdirectory (Vercel has one; we don't need it). +- No new CLI command and no changes to the CLI's behavior. +- No auto-generated reference content — content is hand-written and verified + against `aai --help`. + +## Placement & structure + +New directory at the repo root, mirroring `vercel/vercel`'s `skills/vercel-cli`: + +``` +skills/aai-cli/ + SKILL.md + references/ + transcription.md # transcribe, stream, agent, llm + history.md # transcripts, sessions + account.md # login, logout, whoami, balance, usage, limits, keys, audit + setup.md # init, samples, doctor, claude, version +``` + +The reference split **mirrors the CLI's own `aai --help` groups** (Quick +Start / Setup & Tools / Transcription & AI / History / Account), so the skill's +organization stays self-consistent with the tool it documents. `transcribe` and +`stream` carry the most flags and get the deepest treatment. + +### `SKILL.md` + +Frontmatter — **model-invocable** (no `disable-model-invocation`): + +```yaml +--- +name: aai-cli +description: +--- +``` + +Body sections, in order: + +1. **Intro** — what `aai` is; "run `aai --help`; it is the source of + truth for flags." +2. **Critical: auth & environment** (highest-leverage section): + - Auth: `aai login` (browser) **or** the `ASSEMBLYAI_API_KEY` env var. Key + resolution order: env → OS keyring. **Run commands deliberately expose no + `--api-key` flag** so keys can't leak into `ps`/shell history. + - Environment binding: `--env` / `AAI_ENV` / `--sandbox`. **A credential is + only valid against the environment that minted it** (a sandbox key fails + against production and vice-versa). Default env is currently `sandbox000`. + - Profiles: `--profile`. +3. **Output contract for agents** — data → stdout, errors → stderr; `--json` is + **auto-enabled when piped or agent-run**, so parse stdout as JSON. +4. **Quick start** — a short copy-paste block (login → transcribe → init). +5. **Decision tree** — routes to the four `references/*.md` files. +6. **Anti-patterns**: + - Passing `--api-key` to a run command — it doesn't exist; use `aai login` + or `ASSEMBLYAI_API_KEY`. + - Using a credential against the wrong environment (sandbox vs production). + - Running a command before `aai login` / setting a key → auth failure; run + `aai doctor` to diagnose setup. + - Assuming `pip install assemblyai-cli` works — the PyPI name is squatted by + a third party; use the project's official install path. + - Forgetting that `--show-code` on `transcribe` / `stream` / `agent` emits a + ready-to-run SDK script (no API call needed). + +All command and flag examples MUST be verified against real `aai --help` +output before the skill is considered done. + +## Distribution wiring (`aai_cli/commands/claude.py`) + +Add a third step to `install`, `status`, and `remove`, mirroring the existing +`_install_skill` / `_skill_status` / `_remove_skill` shape exactly: + +- Install via `npx skills add ... --global` for **this** repo + (`AssemblyAI/cli`), idempotent with a `--force` path, gracefully **skipped** + (not failed) when `npx` is missing — identical to the existing skill step. +- Detect installation at `~/.claude/skills/aai-cli/SKILL.md`, honoring + `CLAUDE_CONFIG_DIR` (reuse the `_skill_dir` root logic). +- `status` reports its `installed` / `not_installed` state; `remove` uses + `npx skills remove aai-cli --global`. + +**Open implementation detail (must verify, not guess):** the existing skill is +its own dedicated repo (`AssemblyAI/assemblyai-skill`), so `npx skills add +AssemblyAI/assemblyai-skill` maps repo → one skill. This repo hosts the skill +under `skills/aai-cli/`, a *subdirectory*. The implementer MUST confirm the +exact `skills` CLI invocation that resolves the `aai-cli` skill out of this repo +(candidates: `npx skills add AssemblyAI/cli`, `npx skills add AssemblyAI/cli +aai-cli`, or a path-qualified form) and confirm the resulting on-disk path, +adjusting the detection logic to match. If the `skills` tool cannot target a +subdirectory skill, fall back to bundling + copy (design Approach B) and note +the deviation. + +## Verification + +- `./scripts/check.sh` is green, including: + - **markdownlint** over the new `skills/` markdown (the root `skills/` tree is + not part of the generated-`docs/` lint exclusion, so it is linted — the new + files must pass). + - **pytest with the 90% branch-coverage gate** — extend the `claude.py` tests + so `install` / `status` / `remove` now assert **three** steps (MCP + + `assemblyai` skill + `aai-cli` skill), covering the `npx`-missing skip path + and the `--force` / already-installed branches for the new step. +- Every command/flag example in the skill matches real `aai --help`. +- Security guarantees untouched: no API key on disk or argv; env↔credential + binding unchanged (this work only adds docs + an install step). + +## Distribution-readiness risk (flag, do not solve here) + +`npx skills add AssemblyAI/cli` requires `github.com/AssemblyAI/cli` to be +publicly reachable. If the repo is private, external users' installs fail. Flag +this to the maintainer as a release-readiness item; it is out of scope for the +skill implementation itself. diff --git a/pyproject.toml b/pyproject.toml index 43cd3e2c..90d9fe1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,11 @@ packages = ["aai_cli"] # Force-include the committed template files (incl. renamed dotfiles); the `**` glob # would otherwise also sweep in __pycache__/*.pyc left by importing a template during # tests, so exclude those from the wheel. -artifacts = ["aai_cli/init/templates/**", "aai_cli/streaming/macos_system_audio.swift"] +artifacts = [ + "aai_cli/init/templates/**", + "aai_cli/skills/**", + "aai_cli/streaming/macos_system_audio.swift", +] exclude = ["**/__pycache__", "**/*.pyc"] [tool.pytest.ini_options] diff --git a/scripts/check.sh b/scripts/check.sh index bd5607e2..eb696d56 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -92,6 +92,9 @@ fi echo "==> markdownlint (docs/ is generated, so excluded)" markdownlint "**/*.md" --ignore docs --ignore node_modules --ignore .pytest_cache +echo "==> prettier (init template JS/CSS)" +prettier --check "aai_cli/init/templates/**/*.{js,css}" + 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. diff --git a/scripts/template_contract_gate.py b/scripts/template_contract_gate.py index 0a709e23..61062c6d 100644 --- a/scripts/template_contract_gate.py +++ b/scripts/template_contract_gate.py @@ -2,7 +2,6 @@ import ast import importlib -import json import re import subprocess import sys @@ -16,10 +15,9 @@ "api/index.py", "api/__init__.py", "api/settings.py", - "index.html", - "static/app.js", - "static/styles.css", - "vercel.json", + "public/index.html", + "public/static/app.js", + "public/static/styles.css", "requirements.txt", "README.md", "AGENTS.md", @@ -52,24 +50,27 @@ 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 / "static/audio.js").exists(): - _fail(f"{template}: missing static/audio.js") + if ( + template in {"live-captions", "voice-agent"} + and not (path / "public/static/audio.js").exists() + ): + _fail(f"{template}: missing public/static/audio.js") def _html_static_refs(template: str, path: Path) -> None: - html = (path / "index.html").read_text(encoding="utf-8") + html = (path / "public/index.html").read_text(encoding="utf-8") refs = set(re.findall(r'(?:href|src)=["\'](/static/[^"\']+)', html)) if not refs: - _fail(f"{template}: index.html should load static assets") + _fail(f"{template}: public/index.html should load static assets") for ref in refs: - if not (path / ref.lstrip("/")).exists(): - _fail(f"{template}: index.html references missing asset {ref!r}") + if not (path / "public" / ref.lstrip("/")).exists(): + _fail(f"{template}: public/index.html references missing asset {ref!r}") def _frontend_routes(template: str, path: Path) -> None: - frontend = (path / "index.html").read_text(encoding="utf-8") + frontend = (path / "public/index.html").read_text(encoding="utf-8") frontend += "\n".join( - asset.read_text(encoding="utf-8") for asset in (path / "static").glob("*.js") + asset.read_text(encoding="utf-8") for asset in (path / "public/static").glob("*.js") ) fetched = set(re.findall(r'fetch\(\s*["\'`](/api/[^"\'`?]+)', frontend)) fetched |= set(re.findall(r'["\'`](/api/[A-Za-z0-9_\-/]+?)(?:/?\$\{|/?["\'`]\s*\+)', frontend)) @@ -138,13 +139,9 @@ def _import_api(template: str, path: Path) -> None: _fail(f"{template}: FastAPI app does not register /") -def _parse_json_files(template: str, path: Path) -> None: +def _parse_python_files(path: Path) -> None: for source in (path / "api").glob("*.py"): ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) - try: - json.loads((path / "vercel.json").read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - _fail(f"{template}: vercel.json is invalid JSON: {exc}") def _untracked_template_files() -> None: @@ -176,7 +173,7 @@ def main() -> int: _html_static_refs(template, path) _frontend_routes(template, path) _requirements_cover_imports(template, path) - _parse_json_files(template, path) + _parse_python_files(path) _import_api(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 5a49985a..9d9f1139 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -9,6 +9,9 @@ Pass an audio file/URL (or --sample) to speak a recorded clip to the agent instead of the microphone; the session then ends after the agent's reply. + This only runs a conversation in the terminal — it writes no code. To build + a voice agent app, run 'aai init voice-agent' instead. + ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ source [SOURCE] Audio file path or URL to speak to the agent. Omit │ │ to use the microphone. │ @@ -101,75 +104,6 @@ - ''' -# --- -# name: test_command_help_matches_snapshot[claude_install] - ''' - - Usage: aai claude install [OPTIONS] - - Install the AssemblyAI docs MCP server and skill into Claude Code. - - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --scope TEXT Config scope to register the MCP under: user, project, │ - │ or local. Presence is detected across all scopes. │ - │ [default: user] │ - │ --force Reinstall even if already present. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - Examples - Wire AssemblyAI docs + skill into Claude Code - $ aai claude install - Install for the current project only - $ aai claude install --scope project - - - - ''' -# --- -# name: test_command_help_matches_snapshot[claude_remove] - ''' - - Usage: aai claude remove [OPTIONS] - - Remove the AssemblyAI MCP server and skill from Claude Code. - - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --scope TEXT Only remove the MCP from this scope (user, project, or │ - │ local). Default: remove from whichever scope it exists │ - │ in. │ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - Examples - Remove the AssemblyAI MCP server and skill - $ aai claude remove - - - - ''' -# --- -# name: test_command_help_matches_snapshot[claude_status] - ''' - - Usage: aai claude status [OPTIONS] - - Show whether the AssemblyAI MCP server and skill are wired into Claude Code. - - ╭─ Options ────────────────────────────────────────────────────────────────────╮ - │ --json Output raw JSON. │ - │ --help Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────╯ - - Examples - Show whether Claude Code is wired up - $ aai claude status - - - ''' # --- # name: test_command_help_matches_snapshot[doctor] @@ -197,8 +131,12 @@ Usage: aai init [OPTIONS] [TEMPLATE] [DIRECTORY] - Pick a template, scaffold it, install deps, launch the server, open the - browser. + Build a new app: pick a template, scaffold it, install deps, launch it, open + the browser. + + This is the starting point for creating an app — including a voice agent app + ('aai init voice-agent'). The 'aai agent' command only runs a live mic + conversation and writes no code. ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ template [TEMPLATE] Template to scaffold (omit to pick │ @@ -500,6 +438,76 @@ + ''' +# --- +# name: test_command_help_matches_snapshot[setup_install] + ''' + + Usage: aai setup install [OPTIONS] + + Install the AssemblyAI docs MCP server and skills into your coding agent. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --scope TEXT Config scope to register the MCP under: user, project, │ + │ or local. Presence is detected across all scopes. │ + │ [default: user] │ + │ --force Reinstall even if already present. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Set up your coding agent for AssemblyAI + $ aai setup install + Install for the current project only + $ aai setup install --scope project + + + + ''' +# --- +# name: test_command_help_matches_snapshot[setup_remove] + ''' + + Usage: aai setup remove [OPTIONS] + + Remove the AssemblyAI MCP server and skills from your coding agent. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --scope TEXT Only remove the MCP from this scope (user, project, or │ + │ local). Default: remove from whichever scope it exists │ + │ in. │ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Remove the AssemblyAI MCP server and skills + $ aai setup remove + + + + ''' +# --- +# name: test_command_help_matches_snapshot[setup_status] + ''' + + Usage: aai setup status [OPTIONS] + + Show whether the AssemblyAI MCP server and skills are set up in your coding + agent. + + ╭─ Options ────────────────────────────────────────────────────────────────────╮ + │ --json Output raw JSON. │ + │ --help Show this message and exit. │ + ╰──────────────────────────────────────────────────────────────────────────────╯ + + Examples + Show what's set up + $ aai setup status + + + ''' # --- # name: test_command_help_matches_snapshot[stream] diff --git a/tests/test_claude.py b/tests/test_claude.py deleted file mode 100644 index c0d9708d..00000000 --- a/tests/test_claude.py +++ /dev/null @@ -1,431 +0,0 @@ -import json -import shutil -import subprocess -from pathlib import Path - -import pytest -from typer.testing import CliRunner - -from aai_cli.main import app - -runner = CliRunner() - - -@pytest.fixture(autouse=True) -def _isolate_home(tmp_path, monkeypatch): - """Keep skill writes/reads inside a temp HOME so tests never touch ~/.claude.""" - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False) - - -def _skill_path() -> Path: - return Path.home() / ".claude" / "skills" / "assemblyai" - - -class FakeRun: - """Records subprocess calls and returns canned CompletedProcess results. - - `returncodes` maps a command prefix tuple (the first N argv tokens) to a - return code; the longest matching prefix wins, default 0. To mimic the real - `skills` CLI, a successful `npx … add` materializes the skill under HOME - (so `_install_skill`'s filesystem check passes) and `npx … remove` deletes - it — toggle with `creates_skill` / `removes_skill`. - """ - - def __init__(self, returncodes=None, *, creates_skill=True, removes_skill=True): - self.calls = [] - self.returncodes = returncodes or {} - self.creates_skill = creates_skill - self.removes_skill = removes_skill - - def __call__(self, cmd, *args, **kwargs): - self.calls.append(cmd) - rc = 0 - best = -1 - for prefix, code in self.returncodes.items(): - n = len(prefix) - if tuple(cmd[:n]) == prefix and n > best: - rc, best = code, n - if rc == 0 and cmd[:1] == ["npx"]: - if "add" in cmd and self.creates_skill: - _skill_path().mkdir(parents=True, exist_ok=True) - (_skill_path() / "SKILL.md").write_text("# AssemblyAI") - elif "remove" in cmd and self.removes_skill: - shutil.rmtree(_skill_path(), ignore_errors=True) - return subprocess.CompletedProcess(args=cmd, returncode=rc, stdout="", stderr="boom") - - -def _all_tools_present(monkeypatch): - monkeypatch.setattr( - "aai_cli.commands.claude.shutil.which", - lambda tool: f"/usr/bin/{tool}", - ) - - -def test_install_happy_path_runs_both_steps(monkeypatch): - _all_tools_present(monkeypatch) - # MCP not yet present -> `mcp get` returns non-zero. - fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 0 - - payload = json.loads(result.output) - statuses = {s["name"]: s["status"] for s in payload["steps"]} - assert statuses == {"mcp": "installed", "skill": "installed"} - - assert [ - "claude", - "mcp", - "add", - "--transport", - "http", - "--scope", - "user", - "assemblyai-docs", - "https://mcp.assemblyai.com/docs", - ] in fake.calls - assert [ - "npx", - "-y", - "skills", - "add", - "AssemblyAI/assemblyai-skill", - "--global", - "--yes", - ] in fake.calls - - -def test_install_skill_failed_when_npx_succeeds_but_nothing_installed(monkeypatch): - # Regression: `install` must verify the skill landed, not trust npx's exit - # code — otherwise install says "installed" while status says "not_installed". - _all_tools_present(monkeypatch) - fake = FakeRun({("claude", "mcp", "get"): 1}, creates_skill=False) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 1 # skill step failed - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses["skill"] == "failed" - - # And status agrees: still not installed. - status_result = runner.invoke(app, ["claude", "status"]) - skill = {s["name"]: s["status"] for s in json.loads(status_result.output)["steps"]}["skill"] - assert skill == "not_installed" - - -def test_install_detaches_stdin_and_sets_timeout(monkeypatch): - """Regression: subprocess children must not inherit stdin, or an interactive - prompt (npx, claude) hangs the CLI forever. Each call must pass a timeout too.""" - _all_tools_present(monkeypatch) - seen = [] - - def record(cmd, *args, **kwargs): - seen.append(kwargs) - return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="") - - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", record) - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code in (0, 1) - assert seen, "expected subprocess.run to be called" - for kwargs in seen: - assert kwargs.get("stdin") is subprocess.DEVNULL - assert kwargs.get("timeout") - - -def test_install_scope_passthrough(monkeypatch): - _all_tools_present(monkeypatch) - fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install", "--scope", "project"]) - assert result.exit_code == 0 - assert [ - "claude", - "mcp", - "add", - "--transport", - "http", - "--scope", - "project", - "assemblyai-docs", - "https://mcp.assemblyai.com/docs", - ] in fake.calls - - -def test_install_invalid_scope_exits_2(monkeypatch): - _all_tools_present(monkeypatch) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", FakeRun()) - result = runner.invoke(app, ["claude", "install", "--scope", "bogus"]) - assert result.exit_code == 2 - - -def test_install_idempotent_when_mcp_present(monkeypatch): - _all_tools_present(monkeypatch) - # `mcp get` returns 0 -> already registered. - fake = FakeRun({("claude", "mcp", "get"): 0}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 0 - payload = json.loads(result.output) - statuses = {s["name"]: s["status"] for s in payload["steps"]} - assert statuses["mcp"] == "already" - # No `mcp add` should have run. - assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) - - -def test_install_skill_idempotent_when_present(monkeypatch): - # Regression: a repeat install must report the skill as `already` (like MCP), - # not re-run `npx skills add` and claim `installed` every time. - _all_tools_present(monkeypatch) - skill = _skill_path() - skill.mkdir(parents=True) - (skill / "SKILL.md").write_text("# AssemblyAI") - fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses["skill"] == "already" - # No `npx … add` should have run — the skill was already present. - assert not any(c[0] == "npx" and "add" in c for c in fake.calls) - - -def test_install_force_reinstalls_skill(monkeypatch): - # --force must re-run `npx skills add` even when the skill is already present. - _all_tools_present(monkeypatch) - skill = _skill_path() - skill.mkdir(parents=True) - (skill / "SKILL.md").write_text("# AssemblyAI") - fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install", "--force"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses["skill"] == "installed" - assert [ - "npx", - "-y", - "skills", - "add", - "AssemblyAI/assemblyai-skill", - "--global", - "--yes", - ] in fake.calls - - -def test_install_force_removes_then_adds(monkeypatch): - _all_tools_present(monkeypatch) - fake = FakeRun({("claude", "mcp", "get"): 0}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install", "--force"]) - assert result.exit_code == 0 - assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls - assert any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) - - -def test_install_skips_mcp_when_claude_missing(monkeypatch): - monkeypatch.setattr( - "aai_cli.commands.claude.shutil.which", - lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", - ) - fake = FakeRun() - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 0 # skip is not a failure - payload = json.loads(result.output) - statuses = {s["name"]: s["status"] for s in payload["steps"]} - assert statuses["mcp"] == "skipped" - assert statuses["skill"] == "installed" - assert not any(c[0] == "claude" for c in fake.calls) - - -def test_install_skips_skill_when_npx_missing(monkeypatch): - monkeypatch.setattr( - "aai_cli.commands.claude.shutil.which", - lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", - ) - fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 0 - payload = json.loads(result.output) - statuses = {s["name"]: s["status"] for s in payload["steps"]} - assert statuses["skill"] == "skipped" - assert statuses["mcp"] == "installed" - assert not any(c[0] == "npx" for c in fake.calls) - - -def test_install_failure_exits_nonzero(monkeypatch): - _all_tools_present(monkeypatch) - # mcp not present, but `mcp add` fails. - fake = FakeRun({("claude", "mcp", "get"): 1, ("claude", "mcp", "add"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install"]) - assert result.exit_code == 1 - payload = json.loads(result.output) - statuses = {s["name"]: s["status"] for s in payload["steps"]} - assert statuses["mcp"] == "failed" - - -def test_install_force_remove_failure_reports_failed(monkeypatch): - _all_tools_present(monkeypatch) - # present, but the forced remove fails - fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install", "--force"]) - assert result.exit_code == 1 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses["mcp"] == "failed" - assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) - - -def test_status_reports_both_installed(monkeypatch, tmp_path): - _all_tools_present(monkeypatch) - monkeypatch.setenv("HOME", str(tmp_path)) - skill = tmp_path / ".claude" / "skills" / "assemblyai" - skill.mkdir(parents=True) - (skill / "SKILL.md").write_text("# AssemblyAI") - # `mcp get` returns 0 -> present. - monkeypatch.setattr( - "aai_cli.commands.claude.subprocess.run", - FakeRun({("claude", "mcp", "get"): 0}), - ) - - result = runner.invoke(app, ["claude", "status"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses == {"mcp": "installed", "skill": "installed"} - - -def test_status_reports_not_installed(monkeypatch, tmp_path): - _all_tools_present(monkeypatch) - monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir created - monkeypatch.setattr( - "aai_cli.commands.claude.subprocess.run", - FakeRun({("claude", "mcp", "get"): 1}), - ) - - result = runner.invoke(app, ["claude", "status"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses == {"mcp": "not_installed", "skill": "not_installed"} - - -def test_status_mcp_unknown_when_claude_missing(monkeypatch, tmp_path): - monkeypatch.setattr( - "aai_cli.commands.claude.shutil.which", - lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", - ) - monkeypatch.setenv("HOME", str(tmp_path)) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", FakeRun()) - - result = runner.invoke(app, ["claude", "status"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses["mcp"] == "unknown" - - -def test_remove_unwinds_both(monkeypatch, tmp_path): - _all_tools_present(monkeypatch) - monkeypatch.setenv("HOME", str(tmp_path)) - skill = tmp_path / ".claude" / "skills" / "assemblyai" - skill.mkdir(parents=True) - (skill / "SKILL.md").write_text("# AssemblyAI") - fake = FakeRun({("claude", "mcp", "get"): 0}) # present -> removable - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "remove"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses == {"mcp": "removed", "skill": "removed"} - assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls - assert ["npx", "-y", "skills", "remove", "assemblyai", "--global"] in fake.calls - assert not skill.exists() - - -def test_remove_when_absent_is_not_an_error(monkeypatch, tmp_path): - _all_tools_present(monkeypatch) - monkeypatch.setenv("HOME", str(tmp_path)) # no skill dir - fake = FakeRun({("claude", "mcp", "get"): 1}) # absent - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "remove"]) - assert result.exit_code == 0 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses == {"mcp": "not_installed", "skill": "not_installed"} - assert not any(c[:3] == ["claude", "mcp", "remove"] for c in fake.calls) - - -def test_remove_skill_failure_reports_failed(monkeypatch, tmp_path): - _all_tools_present(monkeypatch) - skill = _skill_path() - skill.mkdir(parents=True) - (skill / "SKILL.md").write_text("# AssemblyAI") - # MCP absent (so only the skill step can fail) and `npx skills remove` runs but - # leaves the skill in place -> install/remove must report it as failed, not removed. - monkeypatch.setattr( - "aai_cli.commands.claude.subprocess.run", - FakeRun({("claude", "mcp", "get"): 1}, removes_skill=False), - ) - - result = runner.invoke(app, ["claude", "remove"]) - assert result.exit_code == 1 - statuses = {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} - assert statuses["skill"] == "failed" - - -def test_install_scope_local_passthrough(monkeypatch): - _all_tools_present(monkeypatch) - fake = FakeRun({("claude", "mcp", "get"): 1}) - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "install", "--scope", "local"]) - assert result.exit_code == 0 - assert [ - "claude", - "mcp", - "add", - "--transport", - "http", - "--scope", - "local", - "assemblyai-docs", - "https://mcp.assemblyai.com/docs", - ] in fake.calls - - -def test_remove_scope_passthrough(monkeypatch, tmp_path): - _all_tools_present(monkeypatch) - monkeypatch.setenv("HOME", str(tmp_path)) - fake = FakeRun({("claude", "mcp", "get"): 0}) # present - monkeypatch.setattr("aai_cli.commands.claude.subprocess.run", fake) - - result = runner.invoke(app, ["claude", "remove", "--scope", "project"]) - assert result.exit_code == 0 - assert ["claude", "mcp", "remove", "assemblyai-docs", "--scope", "project"] in fake.calls - - -def test_claude_help_lists_all_subcommands(): - result = runner.invoke(app, ["claude", "--help"]) - assert result.exit_code == 0 - assert "install" in result.output - assert "status" in result.output - assert "remove" in result.output - - -def test_claude_no_subcommand_lists_commands(): - # Bare `aai claude` should show its commands instead of "Missing command". - result = runner.invoke(app, ["claude"]) - assert "install" in result.output - assert "status" in result.output - assert "remove" in result.output diff --git a/tests/test_environments.py b/tests/test_environments.py index 817ab193..ee7bfe6f 100644 --- a/tests/test_environments.py +++ b/tests/test_environments.py @@ -12,6 +12,11 @@ def test_get_returns_named_environment(): assert env.llm_gateway_base == "https://llm-gateway.sandbox000.assemblyai-labs.com/v1" +def test_production_uses_production_ams_endpoint(): + env = environments.get("production") + assert env.ams_base == "https://ams.internal.assemblyai-labs.com" + + def test_get_unknown_raises_cli_error(): with pytest.raises(CLIError) as exc: environments.get("nope") diff --git a/tests/test_init_scaffold.py b/tests/test_init_scaffold.py index abf9cdec..ee1b9ef4 100644 --- a/tests/test_init_scaffold.py +++ b/tests/test_init_scaffold.py @@ -8,8 +8,8 @@ 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 / "index.html").exists() - assert (target / "vercel.json").exists() + assert (target / "public" / "index.html").exists() + assert not (target / "vercel.json").exists() # dotfile templates are renamed to their dotted names assert (target / ".gitignore").exists() assert (target / ".env.example").exists() diff --git a/tests/test_init_template_agent.py b/tests/test_init_template_agent.py index 5b26dbec..def68cb0 100644 --- a/tests/test_init_template_agent.py +++ b/tests/test_init_template_agent.py @@ -50,7 +50,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 / "static" / "app.js").read_text() + app_js = (TEMPLATE_DIR / "public" / "static" / "app.js").read_text() assert "reply.audio" in app_js assert "event.data" in app_js @@ -64,3 +64,10 @@ def boom(*a, **k): monkeypatch.setattr(mod.httpx2, "get", boom) resp = TestClient(mod.app).post("/api/token") assert resp.status_code == 502 + + +def test_index_route_serves_page(monkeypatch): + mod = _load_app(monkeypatch) + resp = TestClient(mod.app).get("/") + assert resp.status_code == 200 + assert " Path: + return Path.home() / ".claude" / "skills" / "assemblyai" + + +def _cli_skill_path() -> Path: + return Path.home() / ".claude" / "skills" / "aai-cli" + + +class FakeRun: + """Records subprocess calls and returns canned CompletedProcess results. + + `returncodes` maps a command prefix tuple (the first N argv tokens) to a + return code; the longest matching prefix wins, default 0. To mimic the real + `skills` CLI, a successful `npx … add` materializes the assemblyai skill under + HOME (so `_install_skill`'s filesystem check passes) and `npx … remove` + deletes it — toggle with `creates_skill` / `removes_skill`. The aai-cli skill + is bundled and copied directly (no subprocess), so it never goes through here. + """ + + def __init__(self, returncodes=None, *, creates_skill=True, removes_skill=True): + self.calls = [] + self.returncodes = returncodes or {} + self.creates_skill = creates_skill + self.removes_skill = removes_skill + + def __call__(self, cmd, *args, **kwargs): + self.calls.append(cmd) + rc = 0 + best = -1 + for prefix, code in self.returncodes.items(): + n = len(prefix) + if tuple(cmd[:n]) == prefix and n > best: + rc, best = code, n + if rc == 0 and cmd[:1] == ["npx"]: + if "add" in cmd and self.creates_skill: + _skill_path().mkdir(parents=True, exist_ok=True) + (_skill_path() / "SKILL.md").write_text("# AssemblyAI") + elif "remove" in cmd and self.removes_skill: + shutil.rmtree(_skill_path(), ignore_errors=True) + return subprocess.CompletedProcess(args=cmd, returncode=rc, stdout="", stderr="boom") + + +def _all_tools_present(monkeypatch): + monkeypatch.setattr( + "aai_cli.commands.setup.shutil.which", + lambda tool: f"/usr/bin/{tool}", + ) + + +def _statuses(result): + return {s["name"]: s["status"] for s in json.loads(result.output)["steps"]} + + +# --- install: all three steps ------------------------------------------------ + + +def test_install_happy_path_runs_all_steps(monkeypatch): + _all_tools_present(monkeypatch) + # MCP not yet present -> `mcp get` returns non-zero. + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 0 + + statuses = _statuses(result) + assert statuses == {"mcp": "installed", "skill": "installed", "aai-cli skill": "installed"} + + assert [ + "claude", + "mcp", + "add", + "--transport", + "http", + "--scope", + "user", + "assemblyai-docs", + "https://mcp.assemblyai.com/docs", + ] in fake.calls + assert [ + "npx", + "-y", + "skills", + "add", + "AssemblyAI/assemblyai-skill", + "--global", + "--yes", + ] in fake.calls + # The bundled aai-cli skill was copied into HOME (no subprocess involved). + assert (_cli_skill_path() / "SKILL.md").exists() + + +def test_install_skill_failed_when_npx_succeeds_but_nothing_installed(monkeypatch): + # Regression: `install` must verify the skill landed, not trust npx's exit + # code — otherwise install says "installed" while status says "not_installed". + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}, creates_skill=False) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 1 # skill step failed + assert _statuses(result)["skill"] == "failed" + + # And status agrees: still not installed. + status_result = runner.invoke(app, ["setup", "status"]) + assert _statuses(status_result)["skill"] == "not_installed" + + +def test_install_detaches_stdin_and_sets_timeout(monkeypatch): + """Regression: subprocess children must not inherit stdin, or an interactive + prompt (npx, claude) hangs the CLI forever. Each call must pass a timeout too.""" + _all_tools_present(monkeypatch) + seen = [] + + def record(cmd, *args, **kwargs): + seen.append(kwargs) + return subprocess.CompletedProcess(args=cmd, returncode=1, stdout="", stderr="") + + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", record) + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code in (0, 1) + assert seen, "expected subprocess.run to be called" + for kwargs in seen: + assert kwargs.get("stdin") is subprocess.DEVNULL + assert kwargs.get("timeout") + + +def test_install_scope_passthrough(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install", "--scope", "project"]) + assert result.exit_code == 0 + assert [ + "claude", + "mcp", + "add", + "--transport", + "http", + "--scope", + "project", + "assemblyai-docs", + "https://mcp.assemblyai.com/docs", + ] in fake.calls + + +def test_install_scope_local_passthrough(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install", "--scope", "local"]) + assert result.exit_code == 0 + assert [ + "claude", + "mcp", + "add", + "--transport", + "http", + "--scope", + "local", + "assemblyai-docs", + "https://mcp.assemblyai.com/docs", + ] in fake.calls + + +def test_install_invalid_scope_exits_2(monkeypatch): + _all_tools_present(monkeypatch) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", FakeRun()) + result = runner.invoke(app, ["setup", "install", "--scope", "bogus"]) + assert result.exit_code == 2 + + +def test_install_idempotent_when_mcp_present(monkeypatch): + _all_tools_present(monkeypatch) + # `mcp get` returns 0 -> already registered. + fake = FakeRun({("claude", "mcp", "get"): 0}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 0 + assert _statuses(result)["mcp"] == "already" + # No `mcp add` should have run. + assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_failure_exits_nonzero(monkeypatch): + _all_tools_present(monkeypatch) + # mcp not present, but `mcp add` fails. + fake = FakeRun({("claude", "mcp", "get"): 1, ("claude", "mcp", "add"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 1 + assert _statuses(result)["mcp"] == "failed" + + +def test_install_force_remove_failure_reports_failed(monkeypatch): + _all_tools_present(monkeypatch) + # present, but the forced remove fails + fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install", "--force"]) + assert result.exit_code == 1 + assert _statuses(result)["mcp"] == "failed" + assert not any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_force_removes_then_adds(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 0}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install", "--force"]) + assert result.exit_code == 0 + assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls + assert any(c[:3] == ["claude", "mcp", "add"] for c in fake.calls) + + +def test_install_skips_mcp_when_claude_missing(monkeypatch): + monkeypatch.setattr( + "aai_cli.commands.setup.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + fake = FakeRun() + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 0 # skip is not a failure + statuses = _statuses(result) + assert statuses["mcp"] == "skipped" + assert statuses["skill"] == "installed" + # The bundled aai-cli skill installs regardless of claude/npx. + assert statuses["aai-cli skill"] == "installed" + assert not any(c[0] == "claude" for c in fake.calls) + + +# --- assemblyai skill (npx-based) -------------------------------------------- + + +def test_install_skill_idempotent_when_present(monkeypatch): + # Regression: a repeat install must report the skill as `already` (like MCP), + # not re-run `npx skills add` and claim `installed` every time. + _all_tools_present(monkeypatch) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 0 + assert _statuses(result)["skill"] == "already" + # No `npx … add` should have run — the skill was already present. + assert not any(c[0] == "npx" and "add" in c for c in fake.calls) + + +def test_install_force_reinstalls_skill(monkeypatch): + # --force must re-run `npx skills add` even when the skill is already present. + _all_tools_present(monkeypatch) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install", "--force"]) + assert result.exit_code == 0 + assert _statuses(result)["skill"] == "installed" + assert [ + "npx", + "-y", + "skills", + "add", + "AssemblyAI/assemblyai-skill", + "--global", + "--yes", + ] in fake.calls + + +def test_install_skips_skill_when_npx_missing(monkeypatch): + monkeypatch.setattr( + "aai_cli.commands.setup.shutil.which", + lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", + ) + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 0 + statuses = _statuses(result) + assert statuses["skill"] == "skipped" + assert statuses["mcp"] == "installed" + # aai-cli skill copies in regardless (no npx needed). + assert statuses["aai-cli skill"] == "installed" + assert not any(c[0] == "npx" for c in fake.calls) + + +def test_remove_skill_failure_reports_failed(monkeypatch): + _all_tools_present(monkeypatch) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + # MCP absent (so only the skill step can fail) and `npx skills remove` runs but + # leaves the skill in place -> remove must report it as failed, not removed. + monkeypatch.setattr( + "aai_cli.commands.setup.subprocess.run", + FakeRun({("claude", "mcp", "get"): 1}, removes_skill=False), + ) + + result = runner.invoke(app, ["setup", "remove"]) + assert result.exit_code == 1 + assert _statuses(result)["skill"] == "failed" + + +def test_remove_skill_skipped_when_npx_missing(monkeypatch): + # The assemblyai skill is present but npx is gone -> we can't drive `skills + # remove`, so report skipped (not failed). + monkeypatch.setattr( + "aai_cli.commands.setup.shutil.which", + lambda tool: None if tool == "npx" else f"/usr/bin/{tool}", + ) + skill = _skill_path() + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text("# AssemblyAI") + monkeypatch.setattr( + "aai_cli.commands.setup.subprocess.run", + FakeRun({("claude", "mcp", "get"): 1}), + ) + + result = runner.invoke(app, ["setup", "remove"]) + assert result.exit_code == 0 + assert _statuses(result)["skill"] == "skipped" + + +# --- aai-cli skill (bundled, copied) ----------------------------------------- + + +def test_install_aai_cli_skill_idempotent_when_present(monkeypatch): + _all_tools_present(monkeypatch) + cli_skill = _cli_skill_path() + cli_skill.mkdir(parents=True) + (cli_skill / "SKILL.md").write_text("# old") + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install"]) + assert result.exit_code == 0 + assert _statuses(result)["aai-cli skill"] == "already" + # Not overwritten without --force. + assert (cli_skill / "SKILL.md").read_text() == "# old" + + +def test_install_aai_cli_skill_force_reinstalls(monkeypatch): + _all_tools_present(monkeypatch) + cli_skill = _cli_skill_path() + cli_skill.mkdir(parents=True) + (cli_skill / "SKILL.md").write_text("# old") + fake = FakeRun({("claude", "mcp", "get"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "install", "--force"]) + assert result.exit_code == 0 + assert _statuses(result)["aai-cli skill"] == "installed" + # Overwritten with the bundled copy (references/ exist; placeholder gone). + assert (cli_skill / "references").is_dir() + assert "# old" not in (cli_skill / "SKILL.md").read_text() + + +# --- status ------------------------------------------------------------------ + + +def test_status_reports_all_installed(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + for name in ("assemblyai", "aai-cli"): + d = tmp_path / ".claude" / "skills" / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text("# x") + # `mcp get` returns 0 -> present. + monkeypatch.setattr( + "aai_cli.commands.setup.subprocess.run", + FakeRun({("claude", "mcp", "get"): 0}), + ) + + result = runner.invoke(app, ["setup", "status"]) + assert result.exit_code == 0 + assert _statuses(result) == { + "mcp": "installed", + "skill": "installed", + "aai-cli skill": "installed", + } + + +def test_status_reports_not_installed(monkeypatch): + _all_tools_present(monkeypatch) # no skill dirs created + monkeypatch.setattr( + "aai_cli.commands.setup.subprocess.run", + FakeRun({("claude", "mcp", "get"): 1}), + ) + + result = runner.invoke(app, ["setup", "status"]) + assert result.exit_code == 0 + assert _statuses(result) == { + "mcp": "not_installed", + "skill": "not_installed", + "aai-cli skill": "not_installed", + } + + +def test_status_mcp_unknown_when_claude_missing(monkeypatch): + monkeypatch.setattr( + "aai_cli.commands.setup.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", FakeRun()) + + result = runner.invoke(app, ["setup", "status"]) + assert result.exit_code == 0 + assert _statuses(result)["mcp"] == "unknown" + + +# --- remove ------------------------------------------------------------------ + + +def test_remove_unwinds_all(monkeypatch, tmp_path): + _all_tools_present(monkeypatch) + for name in ("assemblyai", "aai-cli"): + d = tmp_path / ".claude" / "skills" / name + d.mkdir(parents=True) + (d / "SKILL.md").write_text("# x") + fake = FakeRun({("claude", "mcp", "get"): 0}) # present -> removable + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "remove"]) + assert result.exit_code == 0 + assert _statuses(result) == {"mcp": "removed", "skill": "removed", "aai-cli skill": "removed"} + assert ["claude", "mcp", "remove", "assemblyai-docs"] in fake.calls + assert ["npx", "-y", "skills", "remove", "assemblyai", "--global"] in fake.calls + assert not _skill_path().exists() + assert not _cli_skill_path().exists() + + +def test_remove_when_absent_is_not_an_error(monkeypatch): + _all_tools_present(monkeypatch) # no skill dirs + fake = FakeRun({("claude", "mcp", "get"): 1}) # absent + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "remove"]) + assert result.exit_code == 0 + assert _statuses(result) == { + "mcp": "not_installed", + "skill": "not_installed", + "aai-cli skill": "not_installed", + } + assert not any(c[:3] == ["claude", "mcp", "remove"] for c in fake.calls) + + +def test_remove_scope_passthrough(monkeypatch): + _all_tools_present(monkeypatch) + fake = FakeRun({("claude", "mcp", "get"): 0}) # present + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "remove", "--scope", "project"]) + assert result.exit_code == 0 + assert ["claude", "mcp", "remove", "assemblyai-docs", "--scope", "project"] in fake.calls + + +def test_remove_invalid_scope_exits_2(monkeypatch): + _all_tools_present(monkeypatch) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", FakeRun()) + result = runner.invoke(app, ["setup", "remove", "--scope", "bogus"]) + assert result.exit_code == 2 + + +def test_remove_skips_mcp_when_claude_missing(monkeypatch): + monkeypatch.setattr( + "aai_cli.commands.setup.shutil.which", + lambda tool: None if tool == "claude" else f"/usr/bin/{tool}", + ) + fake = FakeRun() + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "remove"]) + assert result.exit_code == 0 + assert _statuses(result)["mcp"] == "skipped" + assert not any(c[0] == "claude" for c in fake.calls) + + +def test_remove_mcp_failure_reports_failed(monkeypatch): + _all_tools_present(monkeypatch) + # present, but `mcp remove` fails -> the mcp step is failed and exit is non-zero. + fake = FakeRun({("claude", "mcp", "get"): 0, ("claude", "mcp", "remove"): 1}) + monkeypatch.setattr("aai_cli.commands.setup.subprocess.run", fake) + + result = runner.invoke(app, ["setup", "remove"]) + assert result.exit_code == 1 + assert _statuses(result)["mcp"] == "failed" + + +def test_copy_tree_skips_pycache_and_pyc(tmp_path): + # _copy_tree must not copy compiled-Python detritus into the agent's skills dir. + from aai_cli.commands import setup + + src = tmp_path / "src" + (src / "references").mkdir(parents=True) + (src / "SKILL.md").write_text("# skill") + (src / "references" / "a.md").write_text("a") + (src / "stale.pyc").write_text("junk") + (src / "__pycache__").mkdir() + (src / "__pycache__" / "x.cpython-312.pyc").write_text("junk") + + dest = tmp_path / "dest" + setup._copy_tree(src, dest) + + assert (dest / "SKILL.md").read_text() == "# skill" + assert (dest / "references" / "a.md").read_text() == "a" + assert not (dest / "stale.pyc").exists() + assert not (dest / "__pycache__").exists() + + +# --- help -------------------------------------------------------------------- + + +def test_setup_help_lists_all_subcommands(): + result = runner.invoke(app, ["setup", "--help"]) + assert result.exit_code == 0 + assert "install" in result.output + assert "status" in result.output + assert "remove" in result.output + + +def test_setup_no_subcommand_lists_commands(): + # Bare `aai setup` should show its commands instead of "Missing command". + result = runner.invoke(app, ["setup"]) + assert "install" in result.output + assert "status" in result.output + assert "remove" in result.output + + +# --- aai-cli skill: defensive failure branches -------------------------------- + + +def test_install_cli_skill_fails_when_bundle_missing(monkeypatch, tmp_path): + from aai_cli.commands import setup + + monkeypatch.setattr(setup, "_bundled_cli_skill", lambda: tmp_path / "nonexistent") + step = setup._install_cli_skill(force=False) + assert step["status"] == "failed" + assert "packaging bug" in step["detail"] + + +def test_install_cli_skill_fails_when_copy_lacks_skill_md(monkeypatch, tmp_path): + from aai_cli.commands import setup + + empty = tmp_path / "emptybundle" + empty.mkdir() + monkeypatch.setattr(setup, "_bundled_cli_skill", lambda: empty) + step = setup._install_cli_skill(force=False) + assert step["status"] == "failed" + assert "SKILL.md" in step["detail"] + + +def test_remove_cli_skill_fails_when_rmtree_noops(monkeypatch): + from aai_cli.commands import setup + + dest = _cli_skill_path() + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("# x") + monkeypatch.setattr(setup.shutil, "rmtree", lambda *a, **k: None) + step = setup._remove_cli_skill() + assert step["status"] == "failed" + assert "still present" in step["detail"] diff --git a/tests/test_claude_render.py b/tests/test_setup_render.py similarity index 95% rename from tests/test_claude_render.py rename to tests/test_setup_render.py index 695770b4..073b945e 100644 --- a/tests/test_claude_render.py +++ b/tests/test_setup_render.py @@ -1,7 +1,7 @@ import io from aai_cli import theme -from aai_cli.commands.claude import _render +from aai_cli.commands.setup import _render from aai_cli.steps import Step diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a56de456..30634fd0 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -47,7 +47,7 @@ def test_help_lists_commands_in_workflow_order(): # Setup & Tools "samples", "doctor", - "claude", + "setup", "version", # Transcription & AI "transcribe",