diff --git a/.gitignore b/.gitignore index caa963c5..18d796db 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index cb099db2..65513843 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/aai_cli/commands/claude.py b/aai_cli/commands/claude.py index ef8ccb09..dd21c315 100644 --- a/aai_cli/commands/claude.py +++ b/aai_cli/commands/claude.py @@ -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).", @@ -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: @@ -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() @@ -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) @@ -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) @@ -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) diff --git a/aai_cli/commands/init.py b/aai_cli/commands/init.py new file mode 100644 index 00000000..b983a971 --- /dev/null +++ b/aai_cli/commands/init.py @@ -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: