Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
27e0b26
docs: spec for aai dev command
alexkroman-assembly Jun 9, 2026
a86a6bb
docs: implementation plan for aai dev
alexkroman-assembly Jun 9, 2026
8e7e530
feat(runner): support --reload in serve_command/launch_and_open
alexkroman-assembly Jun 9, 2026
290be24
feat(dev): add 'aai dev' to launch a scaffolded template
alexkroman-assembly Jun 9, 2026
fdeb603
test(dev): strengthen assertions to kill mutation-gate survivors
alexkroman-assembly Jun 9, 2026
68a0147
feat(init): add Procfile + runtime.txt so templates deploy beyond Vercel
alexkroman-assembly Jun 9, 2026
927110d
test(runner): assert launch_and_open reload default and forwarding
alexkroman-assembly Jun 9, 2026
cefc2c4
test: include 'dev' in workflow-order smoke test
alexkroman-assembly Jun 9, 2026
8aebe1d
docs: revise aai dev to boot from the template Procfile
alexkroman-assembly Jun 9, 2026
aa07d98
feat(procfile): parse web: command; extract runner.run_server
alexkroman-assembly Jun 9, 2026
78e1b4c
feat(dev): boot the template Procfile web process with live reload
alexkroman-assembly Jun 9, 2026
ba86f86
feat(onboard): add --non-interactive flag
alexkroman-assembly Jun 9, 2026
d047efd
docs: design for aai api passthrough command
alexkroman-assembly Jun 9, 2026
bc859df
docs: correct aai api auth scheme (both specs use raw apiKey header)
alexkroman-assembly Jun 9, 2026
919a52c
docs: implementation plan for aai api command
alexkroman-assembly Jun 9, 2026
e4a2a89
test(dev): drop S104 workaround, harden Procfile empty-default; doc _…
alexkroman-assembly Jun 9, 2026
a906119
feat(init): point launch hints at 'aai dev'
alexkroman-assembly Jun 9, 2026
1b0488b
docs(templates): advertise 'aai dev' as the run-locally command
alexkroman-assembly Jun 9, 2026
6e50315
test(init): boot each template in-process and probe every route
alexkroman-assembly Jun 9, 2026
d587c2d
docs: design for aai share + aai deploy
alexkroman-assembly Jun 9, 2026
43274d4
refactor(dev): extract shared boot helpers into init/devserver
alexkroman-assembly Jun 9, 2026
fc93707
feat(tunnel): cloudflared quick-tunnel helpers + runner.spawn
alexkroman-assembly Jun 9, 2026
261fca2
feat(share): expose the dev server via a cloudflared tunnel
alexkroman-assembly Jun 9, 2026
3abdd99
test(tunnel): kill await_url default-arg mutation survivors
alexkroman-assembly Jun 9, 2026
6fcd838
test(share): annotate _stub url as str | None
alexkroman-assembly Jun 9, 2026
491af59
feat(deploy): add 'aai deploy' wrapping vercel deploy with a confirm …
alexkroman-assembly Jun 9, 2026
7a17e05
feat(deploy): add --railway target; --vercel default, no formula deps
alexkroman-assembly Jun 9, 2026
2b5e2e2
fix(templates): serve front-end from static/ not public/ so Vercel de…
alexkroman-assembly Jun 9, 2026
272aa8e
feat(deploy): add --render and --fly deploy targets
alexkroman-assembly Jun 9, 2026
14dba75
fix(templates): run uvicorn via 'python -m' so Railway/Nixpacks deplo…
alexkroman-assembly Jun 9, 2026
ed783d2
feat(deploy): run 'railway domain' after a successful railway deploy …
alexkroman-assembly Jun 9, 2026
e11a243
feat(deploy): drop --render (git-only, no local deploy); guide --fly …
alexkroman-assembly Jun 9, 2026
1213455
feat(templates): ship a Dockerfile + .dockerignore so Fly/Railway/Ren…
alexkroman-assembly Jun 9, 2026
678617a
test: add scripts/docker_build_check.sh to build all template Docker …
alexkroman-assembly Jun 9, 2026
3437fd5
feat(deploy): --fly runs 'fly launch' (creates app + deploys); drop t…
alexkroman-assembly Jun 9, 2026
ad1a1d5
fix(templates): EXPOSE 8080 and default to 8080 so Fly's internal_por…
alexkroman-assembly Jun 9, 2026
8a996c5
refactor(contract-gate): annotate _fail as NoReturn so the EXPOSE reg…
alexkroman-assembly Jun 9, 2026
0f3fd57
feat(templates): return a clear error when ASSEMBLYAI_API_KEY is unse…
alexkroman-assembly Jun 9, 2026
db9206d
Merge branch 'main' into feat/aai-dev
alexkroman Jun 9, 2026
b73334b
fix(templates): run container as non-root USER (Aikido CKV_DOCKER_3)
alexkroman-assembly Jun 9, 2026
4e31fe8
test: fix two CI-only failures (color + keyless)
alexkroman-assembly Jun 9, 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
3 changes: 3 additions & 0 deletions .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,16 @@ modules =
aai_cli.commands.account
aai_cli.commands.agent
aai_cli.commands.audit
aai_cli.commands.deploy
aai_cli.commands.dev
aai_cli.commands.doctor
aai_cli.commands.init
aai_cli.commands.keys
aai_cli.commands.llm
aai_cli.commands.login
aai_cli.commands.sessions
aai_cli.commands.setup
aai_cli.commands.share
aai_cli.commands.stream
aai_cli.commands.transcribe
aai_cli.commands.transcripts
Expand Down
1 change: 1 addition & 0 deletions Formula/aai.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Aai < Formula

depends_on "pkgconf" => :build # cffi / cryptography native builds
depends_on "rust" => :build # pydantic-core, jiter, cryptography
depends_on "cloudflared" # public quick-tunnel for `aai share`
depends_on "ffmpeg" # decode non-WAV/URL audio (transcribe/stream)
depends_on "openssl@3" # cryptography linkage
depends_on "portaudio" # sounddevice (audio capture)
Expand Down
145 changes: 145 additions & 0 deletions aai_cli/commands/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# aai_cli/commands/deploy.py
from __future__ import annotations

import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path

import typer

from aai_cli import help_panels, output
from aai_cli.context import AppState, run_command
from aai_cli.errors import CLIError
from aai_cli.help_text import examples_epilog

# Flattened single-command sub-typer (same pattern as `aai dev`).
app = typer.Typer()


@dataclass(frozen=True)
class Target:
name: str # human label, e.g. "Vercel"
bin: str # executable resolved via shutil.which
flag: str # CLI selector, e.g. "--vercel"
install: str # full hint sentence shown when the CLI is missing
deploy_args: tuple[str, ...] # subcommand(s) appended after `bin`
supports_prod: bool = False # whether `--prod` adds a production flag
post_deploy_args: tuple[str, ...] | None = None # command run after a successful deploy

def command(self, *, prod: bool) -> list[str]:
argv = [self.bin, *self.deploy_args]
if prod and self.supports_prod:
argv.append("--prod")
return argv


VERCEL = Target(
name="Vercel",
bin="vercel",
flag="--vercel",
install="Install it with `npm i -g vercel`.",
deploy_args=("deploy",),
supports_prod=True,
)
RAILWAY = Target(
name="Railway",
bin="railway",
flag="--railway",
install="Install it with `npm i -g @railway/cli`.",
deploy_args=("up",),
post_deploy_args=("domain",),
)
FLY = Target(
name="Fly",
bin="fly",
flag="--fly",
install="Install it with `brew install flyctl`.",
# `fly launch` does it all: creates the app, generates fly.toml (detecting the
# shipped Dockerfile), and deploys — so no fly.toml needs to exist beforehand.
deploy_args=("launch",),
)

TARGETS = (VERCEL, RAILWAY, FLY)


def _resolve_target(selected: list[Target]) -> Target:
if len(selected) > 1:
flags = " / ".join(t.flag for t in TARGETS)
raise CLIError(
f"Pass at most one deploy target ({flags}).",
error_type="usage_error",
exit_code=1,
)
return selected[0] if selected else VERCEL # Vercel is the default


def _require_cli(target: Target) -> None:
if shutil.which(target.bin) is None:
raise CLIError(
f"The {target.name} CLI is required to deploy. {target.install}",
error_type="missing_dependency",
exit_code=1,
)


def _confirmed(target: Target, *, assume_yes: bool) -> bool:
"""True when the deploy should proceed: --yes, or an interactive yes.

Refuses to guess in a non-interactive/agent session."""
if assume_yes:
return True
if output.is_agentic():
raise CLIError(
"Refusing to deploy without confirmation in a non-interactive session. "
"Pass --yes to deploy.",
error_type="usage_error",
exit_code=1,
)
return typer.confirm(f"Deploy this project to {target.name}?")


def run_deploy(*, target: Target, prod: bool, assume_yes: bool) -> None:
"""Confirm, then run the target's deploy command in the current directory."""
_require_cli(target)
if not _confirmed(target, assume_yes=assume_yes):
output.console.print("Aborted.")
return
result = subprocess.run(target.command(prod=prod), cwd=Path.cwd(), check=False)
if result.returncode:
raise typer.Exit(code=result.returncode)
if target.post_deploy_args is not None:
subprocess.run([target.bin, *target.post_deploy_args], cwd=Path.cwd(), check=False)


@app.command(
rich_help_panel=help_panels.BUILD,
epilog=examples_epilog(
[
("Deploy a preview to Vercel (asks first)", "aai deploy"),
("Deploy to production on Vercel", "aai deploy --prod --yes"),
("Deploy to Railway", "aai deploy --railway"),
("Deploy to Fly.io", "aai deploy --fly"),
]
),
)
def deploy(
ctx: typer.Context,
prod: bool = typer.Option(False, "--prod", help="Deploy to production (Vercel only)."),
vercel: bool = typer.Option(False, "--vercel", help="Deploy to Vercel (the default)."),
railway: bool = typer.Option(False, "--railway", help="Deploy to Railway."),
fly: bool = typer.Option(False, "--fly", help="Deploy to Fly.io."),
assume_yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."),
) -> None:
"""Deploy the current project to Vercel (default), Railway, or Fly.io.

Asks for confirmation first, then runs the target's CLI (`vercel deploy`,
`railway up`, or `fly launch`). Requires that target's CLI to be installed.
(Render deploys from a connected Git repo — see the project README.)
"""

def body(_state: AppState, _json_mode: bool) -> None:
selected = [t for t, on in ((VERCEL, vercel), (RAILWAY, railway), (FLY, fly)) if on]
run_deploy(target=_resolve_target(selected), prod=prod, assume_yes=assume_yes)

run_command(ctx, body)
80 changes: 80 additions & 0 deletions aai_cli/commands/dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# aai_cli/commands/dev.py
from __future__ import annotations

import os
from pathlib import Path

import typer
from rich.markup import escape

from aai_cli import help_panels, output, steps
from aai_cli.context import AppState, run_command
from aai_cli.help_text import examples_epilog
from aai_cli.init import devserver, procfile, runner

# Flattened single-command sub-typer (same pattern as `aai init`): one
# @app.command() registered via app.add_typer(dev.app) with no name.
app = typer.Typer()


def run_dev(*, port: int, no_install: bool, no_open: bool, json_mode: bool) -> None:
"""Boot the project's Procfile `web:` process locally, with live reload."""
target = Path.cwd()
use_uv = runner.has_uv()

chosen_port = runner.find_free_port(port)
env = {**os.environ, "PORT": str(chosen_port)}
# Resolves the start command AND validates we're inside a scaffolded project.
web = procfile.web_argv(target, env=env)

report: list[steps.Step] = [
devserver.install_step(target, no_install=no_install, use_uv=use_uv)
]
output.emit(report, lambda d: steps.render_steps(d, heading="Dev"), json_mode=json_mode)
if any(s["status"] == "failed" for s in report):
raise typer.Exit(code=1)

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


@app.command(
rich_help_panel=help_panels.BUILD,
epilog=examples_epilog(
[
("Launch the app in the current directory", "aai dev"),
("Use a specific port", "aai dev --port 8000"),
("Launch without opening a browser", "aai dev --no-open"),
("Skip the dependency install step", "aai dev --no-install"),
]
),
)
def dev(
ctx: typer.Context,
port: int = typer.Option(3000, "--port", help="Local server port."),
no_open: bool = typer.Option(False, "--no-open", help="Launch, but don't open the browser."),
no_install: bool = typer.Option(
False, "--no-install", help="Skip dependency install; launch directly."
),
json_out: bool = typer.Option(False, "--json", help="Output raw JSON."),
) -> None:
"""Launch the dev server for the app in the current directory.

Run this from inside a project created by `aai init`. It installs dependencies
if needed, then starts the FastAPI server with live reload and opens the browser.
"""

def body(_state: AppState, json_mode: bool) -> None:
run_dev(port=port, no_install=no_install, no_open=no_open, json_mode=json_mode)

run_command(ctx, body, json=json_out)
6 changes: 2 additions & 4 deletions aai_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def run_init(
{
"name": "launch",
"status": "skipped",
"detail": f"no API key; run `aai login`, then: cd {target} && uv run uvicorn api.index:app",
"detail": f"no API key; run `aai login`, then: cd {target} && aai dev",
}
)

Expand All @@ -218,9 +218,7 @@ def run_init(
elif not json_mode:
# Scaffolded but not launched (no key, or --no-install, or launch=False): leave the
# user with the one command that starts their app, the way `vercel`/`supabase` sign off.
output.console.print(
output.hint(f"Run `cd {escape(str(target))} && uv run uvicorn api.index:app`.")
)
output.console.print(output.hint(f"Run `cd {escape(str(target))} && aai dev`."))
return target


Expand Down
17 changes: 13 additions & 4 deletions aai_cli/commands/onboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import typer

from aai_cli import help_panels
from aai_cli import help_panels, output
from aai_cli.context import AppState, resolve_profile, run_command
from aai_cli.help_text import examples_epilog
from aai_cli.onboard import wizard
Expand All @@ -14,8 +14,11 @@
app = typer.Typer()


def build_prompter() -> Prompter:
"""A real prompter only when both ends are a TTY; otherwise never block."""
def build_prompter(*, non_interactive: bool = False) -> Prompter:
"""A real prompter only when the caller hasn't opted out and both ends are a TTY;
otherwise never block for input."""
if non_interactive:
return NonInteractivePrompter()
if sys.stdin.isatty() and sys.stdout.isatty():
return InteractivePrompter()
return NonInteractivePrompter()
Expand All @@ -32,13 +35,19 @@ def build_prompter() -> Prompter:
def onboard(
ctx: typer.Context,
json_out: bool = typer.Option(False, "--json", help="Output raw JSON."),
non_interactive: bool = typer.Option(
False,
"--non-interactive",
help="Run without interactive prompts (default when agent detected).",
),
) -> None:
"""Guided setup: sign in, run your first transcription, and start building."""

def body(state: AppState, json_mode: bool) -> None:
profile = resolve_profile(state)
wiz_ctx = WizardContext(state=state, profile=profile, json_mode=json_mode)
code = wizard.run_onboarding(build_prompter(), wiz_ctx)
forced = non_interactive or output.is_agentic()
code = wizard.run_onboarding(build_prompter(non_interactive=forced), wiz_ctx)
if code != 0:
raise typer.Exit(code=code)

Expand Down
Loading
Loading