Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
95ba58f
docs: design spec for aai init scaffolder
alexkroman-assembly Jun 4, 2026
48c286d
docs: implementation plan for aai init core + transcribe template
alexkroman-assembly Jun 4, 2026
b5419eb
feat(init): add questionary dep + init package skeleton
alexkroman-assembly Jun 4, 2026
c3b6d2e
feat(init): template registry
alexkroman-assembly Jun 4, 2026
f01dcc0
feat(init): optional API-key resolution (no raise when absent)
alexkroman-assembly Jun 4, 2026
a0a42a8
feat(init): transcribe template (FastAPI submit+poll, Vercel-ready)
alexkroman-assembly Jun 4, 2026
2355f63
docs(plan): fix transcribe poll to non-blocking SDK call + add contra…
alexkroman-assembly Jun 4, 2026
f782013
fix(init): transcribe poll uses non-blocking SDK get_transcript
alexkroman-assembly Jun 4, 2026
c9490a2
docs(plan): drop unused pytest import from transcribe test
alexkroman-assembly Jun 4, 2026
047e547
docs(plan): scaffold skips __pycache__/.pyc + test for it
alexkroman-assembly Jun 4, 2026
f21e787
docs(plan): scaffold resolves templates via aai_cli.init package + TY…
alexkroman-assembly Jun 4, 2026
683a8f8
feat(init): scaffold (copy template tree + write .env)
alexkroman-assembly Jun 4, 2026
8d9448f
docs(plan): drop unused Path import from scaffold test
alexkroman-assembly Jun 4, 2026
c8bdda7
docs(plan): runner run_setup avoids bare assert (ruff S101)
alexkroman-assembly Jun 4, 2026
c11c130
feat(init): runner command builders + port/launch helpers
alexkroman-assembly Jun 4, 2026
889554d
feat(init): step-status rendering helper
alexkroman-assembly Jun 4, 2026
413d4ae
docs(plan): init uses proven flat @app.command() registration (like t…
alexkroman-assembly Jun 4, 2026
694ea36
feat(init): wire init command (picker, scaffold, install, launch) + r…
alexkroman-assembly Jun 4, 2026
7728284
refactor(init): use scaffold submodule directly; drop unused __init__…
alexkroman-assembly Jun 4, 2026
1f25a8c
docs(plan)+chore: align Task 8 with impl (CLIError exit 1, minimal __…
alexkroman-assembly Jun 4, 2026
ca2c9c5
test(init): parametrized template contract tests (keep examples working)
alexkroman-assembly Jun 4, 2026
1e69b0e
docs: document aai init in README; ruff-format contract test
alexkroman-assembly Jun 4, 2026
7b98d54
fix(init): only register shipped templates; harden picker/runner/launch
alexkroman-assembly Jun 4, 2026
c35e878
docs(login): drop 'Stytch' from the help text
alexkroman-assembly Jun 4, 2026
af68fe0
feat(init): transcribe template — URL/sample default + cleaner UI; re…
alexkroman-assembly Jun 4, 2026
119b005
fix(init): pin scaffolded app to the key's AssemblyAI environment
alexkroman-assembly Jun 4, 2026
82d005e
feat(init): transcribe UI — explore chapters/sentiment/entities/highl…
alexkroman-assembly Jun 5, 2026
47355b9
feat(init): transcribe template — ask the transcript via LLM Gateway
alexkroman-assembly Jun 5, 2026
15238c8
fix(init): address code-review findings (error handling, dedup, robus…
alexkroman-assembly Jun 5, 2026
6318eab
feat(init): add stream (live captions) + agent (voice) templates
alexkroman-assembly Jun 5, 2026
b1e2e9f
feat(init): Vercel-style 'AssemblyAI CLI <version>' banner on run
alexkroman-assembly Jun 5, 2026
5d5f95b
chore: remove specs/ design+plan docs from the repo
alexkroman-assembly Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ docs/

# Local scratch scripts (often contain live keys)
transcribe/
# But do track the packaged template
!aai_cli/init/templates/transcribe/

# Wrong tool for this project (hatchling + uv); never commit a poetry lock
poetry.lock

# Brainstorming visual-companion scratch
.superpowers/

# Playwright MCP scratch
.playwright-mcp/
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ aai login # store your API key (browser-assisted)
aai transcribe --sample # transcribe the hosted wildfires.mp3 sample
```

## Scaffold a starter app

```sh
aai init # pick a template, scaffold it, install deps, open the browser
aai init transcribe myapp # non-interactive: template + directory
```

`aai init` copies a small, self-contained FastAPI + HTML project you can run locally
and deploy to Vercel as-is. Your key is written to a git-ignored `.env` (and is never
sent to the browser). Use `--no-install` to scaffold only.

## API key & security

`aai` resolves your key in this order:
Expand Down
31 changes: 8 additions & 23 deletions aai_cli/commands/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@
import shutil
import subprocess
from pathlib import Path
from typing import TypedDict

import typer
from rich.markup import escape

from aai_cli import output, theme
from aai_cli import output
from aai_cli.context import AppState, run_command
from aai_cli.errors import UsageError
from aai_cli.steps import Step, render_steps

app = typer.Typer(
help="Wire up Claude Code for AssemblyAI (docs MCP + skill).",
Expand All @@ -22,14 +21,7 @@
MCP_URL = "https://mcp.assemblyai.com/docs"
SKILL_REPO = "AssemblyAI/assemblyai-skill"
_VALID_SCOPES = ("user", "project", "local")


class Step(TypedDict):
"""One line of setup output: a named step, its status, and a human detail."""

name: str
status: str
detail: str
_STEPS_HEADING = "AssemblyAI coding-agent setup:"


def _run(cmd: list[str], *, timeout: float = 120) -> subprocess.CompletedProcess:
Expand Down Expand Up @@ -191,15 +183,8 @@ def _remove_skill() -> Step:
return {"name": "skill", "status": "removed", "detail": str(_skill_dir())}


def _render_steps(data: dict[str, list[Step]]) -> str:
lines = []
for s in data["steps"]:
style = theme.status_style(s["status"])
lines.append(
f" {escape(s['name'])}: "
f"[{style}]{escape(s['status'])}[/{style}] — {escape(s['detail'])}"
)
return "[aai.heading]AssemblyAI coding-agent setup:[/aai.heading]\n" + "\n".join(lines)
def _render(data: dict[str, list[Step]]) -> str:
return render_steps(data["steps"], heading=_STEPS_HEADING)


@app.command()
Expand All @@ -224,7 +209,7 @@ def body(_state: AppState, json_mode: bool) -> None:
f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}."
)
steps = [_install_mcp(scope, force), _install_skill(force)]
output.emit({"steps": steps}, _render_steps, json_mode=json_mode)
output.emit({"steps": steps}, _render, json_mode=json_mode)
if any(s["status"] == "failed" for s in steps):
raise typer.Exit(code=1)

Expand All @@ -240,7 +225,7 @@ def status(

def body(_state: AppState, json_mode: bool) -> None:
steps = [_mcp_status(), _skill_status()]
output.emit({"steps": steps}, _render_steps, json_mode=json_mode)
output.emit({"steps": steps}, _render, json_mode=json_mode)

run_command(ctx, body, json=json_out)

Expand All @@ -266,7 +251,7 @@ def body(_state: AppState, json_mode: bool) -> None:
f"Invalid --scope '{scope}'. Choose one of: {', '.join(_VALID_SCOPES)}."
)
steps = [_remove_mcp(scope), _remove_skill()]
output.emit({"steps": steps}, _render_steps, json_mode=json_mode)
output.emit({"steps": steps}, _render, json_mode=json_mode)
if any(s["status"] == "failed" for s in steps):
raise typer.Exit(code=1)

Expand Down
190 changes: 190 additions & 0 deletions aai_cli/commands/init.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# aai_cli/commands/init.py
from __future__ import annotations

import sys
from pathlib import Path

import typer
from rich.markup import escape

from aai_cli import __version__, environments, output, steps
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError
from aai_cli.init import keys, runner, scaffold, templates

# Single-command sub-typer flattened to `aai init` (the exact pattern `aai transcribe`
# uses): one @app.command() named `init`, registered via app.add_typer(init.app) with
# no name. Bare `aai init` runs the command with template=None -> the interactive picker.
app = typer.Typer()


def _pick_template() -> str:
"""Interactive picker; raises a usage error when there's no TTY to prompt on."""
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise CLIError(
"No template given and not running interactively. "
f"Pass one of: {', '.join(templates.TEMPLATE_ORDER)}.",
error_type="usage_error",
exit_code=1,
)
try:
import questionary
except ImportError as exc: # a broken/stale install missing the declared dep
raise CLIError(
"The interactive picker needs 'questionary'. Reinstall the CLI "
"(e.g. `uv tool install --reinstall aai-cli`), or pass a template "
f"directly: {', '.join(templates.TEMPLATE_ORDER)}.",
error_type="missing_dependency",
exit_code=1,
) from exc

choice = questionary.select(
"Pick a template",
choices=[
questionary.Choice(title=templates.title_for(t), value=t)
for t in templates.TEMPLATE_ORDER
],
).ask()
if choice is None: # user pressed Ctrl-C
raise typer.Exit(code=130)
return str(choice)


def _resolve_dir(directory: str | None, template: str, *, here: bool) -> Path:
if here:
return Path.cwd()
if directory:
return Path(directory)
return Path.cwd() / f"{template}-app"


@app.command()
def init(
ctx: typer.Context,
template: str = typer.Argument(None, help="Template to scaffold (omit to pick interactively)."),
directory: str = typer.Argument(None, help="Target directory (default: <template>-app)."),
no_install: bool = typer.Option(
False, "--no-install", help="Scaffold only; don't install or launch."
),
no_open: bool = typer.Option(
False, "--no-open", help="Install + launch, but don't open the browser."
),
force: bool = typer.Option(False, "--force", help="Overwrite a non-empty target directory."),
here: bool = typer.Option(False, "--here", help="Scaffold into the current directory."),
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."""

def body(state: AppState, json_mode: bool) -> None:
if not json_mode:
# Vercel-style banner at the top of the run.
output.console.print(
f"[aai.heading]AssemblyAI CLI[/aai.heading] [aai.muted]{__version__}[/aai.muted]"
)
chosen = template
if chosen is None:
chosen = _pick_template()
if not templates.is_template(chosen):
raise CLIError(
f"Unknown template {chosen!r}. Choose one of: "
f"{', '.join(templates.TEMPLATE_ORDER)}.",
error_type="usage_error",
exit_code=1,
)

if here and directory:
raise CLIError(
"Pass either a DIRECTORY or --here, not both.",
error_type="usage_error",
exit_code=1,
)
target = _resolve_dir(directory, chosen, here=here)
if scaffold.target_conflict(target) and not force:
raise CLIError(
f"{target} already exists and is not empty. "
f"Use --force to overwrite or pick another directory.",
error_type="usage_error",
exit_code=1,
)

api_key = keys.resolve_optional_api_key(profile=state.profile)
# Pin the app to the active environment's hosts so a sandbox key (minted by
# `aai login` against a non-prod env) isn't rejected by the production defaults.
env = environments.active()
env_vars = {
"ASSEMBLYAI_BASE_URL": env.api_base,
"ASSEMBLYAI_LLM_GATEWAY_URL": env.llm_gateway_base,
"ASSEMBLYAI_STREAMING_HOST": env.streaming_host,
# Voice Agent host mirrors the streaming host's naming across environments.
"ASSEMBLYAI_AGENTS_HOST": env.streaming_host.replace("streaming", "agents", 1),
}
scaffold.scaffold(chosen, target, api_key=api_key, env_vars=env_vars)

report: list[steps.Step] = [
{"name": "scaffold", "status": "created", "detail": str(target)}
]
if api_key is None:
report.append(
{
"name": "key",
"status": "skipped",
"detail": "no API key found; wrote a placeholder to .env (run `aai login`)",
}
)

use_uv = runner.has_uv()
will_launch = not no_install and api_key is not None
if no_install:
report.append({"name": "install", "status": "skipped", "detail": "--no-install"})
else:
setup = runner.run_setup(target, use_uv=use_uv)
if setup.returncode != 0:
report.append(
{
"name": "install",
"status": "failed",
"detail": (setup.stderr or setup.stdout).strip()[:300],
}
)
will_launch = False
else:
report.append(
{
"name": "install",
"status": "installed",
"detail": "uv" if use_uv else "venv + pip",
}
)

# Deps are installed but there's no key, so the server can't start — say so
# rather than exiting silently.
if not no_install and api_key is None:
report.append(
{
"name": "launch",
"status": "skipped",
"detail": f"no API key; run `aai login`, then: cd {target} && uv run uvicorn api.index:app",
}
)

output.emit(
report, lambda d: steps.render_steps(d, heading="aai init:"), json_mode=json_mode
)
if any(s["status"] == "failed" for s in report):
raise typer.Exit(code=1)

if will_launch:
chosen_port = runner.find_free_port(port)
url = f"http://localhost:{chosen_port}"
if not json_mode:
output.console.print(
f"[aai.heading]Starting[/aai.heading] {escape(url)} (Ctrl-C to stop)"
)
code = runner.launch_and_open(
target, port=chosen_port, use_uv=use_uv, open_browser=not no_open
)
if code:
raise typer.Exit(code=code)

run_command(ctx, body, json=json_out)
2 changes: 1 addition & 1 deletion aai_cli/commands/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def login(
api_key: str = typer.Option(None, "--api-key", help="Provide key non-interactively."),
json_out: bool = typer.Option(False, "--json", help="Output raw JSON."),
) -> None:
"""Authenticate via your browser (Stytch); stores a CLI API key."""
"""Authenticate via your browser; stores a CLI API key."""

def body(state: AppState, json_mode: bool) -> None:
profile = resolve_profile(state)
Expand Down
5 changes: 5 additions & 0 deletions aai_cli/init/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# aai_cli/init/__init__.py
# Submodules (templates, keys, scaffold, runner, steps) are imported directly by
# consumers (e.g. `from aai_cli.init import scaffold`); re-exporting their members
# here would shadow the same-named submodule (notably `scaffold`), so we don't.
from __future__ import annotations
16 changes: 16 additions & 0 deletions aai_cli/init/keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from __future__ import annotations

from aai_cli import config
from aai_cli.errors import NotAuthenticated


def resolve_optional_api_key(*, profile: str | None) -> str | None:
"""The CLI's key chain (env -> keyring), but None instead of raising when absent.

`aai init` scaffolds even without a key (writing a placeholder), so it must not
fail the way run commands do.
"""
try:
return config.resolve_api_key(profile=profile)
except NotAuthenticated:
return None
Loading
Loading