Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions aai_cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,11 @@ import one command module from another.

**Options/run split for flag-heavy commands** (gh-CLI style): the Typer
function only parses argv into a frozen `<Cmd>Options` dataclass and hands it
to a module-level `run_<cmd>(opts, state, *, json_mode)` through a thin lambda
adapter in `run_command(ctx, ..., json=...)`. The run commands follow it —
to a module-level `run_<cmd>(opts, state, *, json_mode)` via
`context.run_with_options(ctx, run_<cmd>, opts, json=...)` — the typed adapter
that wraps the `run_<cmd>` body in the `(state, json_mode)` callable
`run_command` expects, so no command repeats the `lambda state, json_mode: …`
boilerplate. The run commands follow it —
`commands/stream/_exec.py` (the reference implementation), `app/transcribe/run.py`
(in the `app/` layer — shared with onboarding), `commands/agent/_exec.py`,
`commands/speak/_exec.py`, `commands/llm/_exec.py`, `commands/clip/_exec.py`,
Expand Down
30 changes: 29 additions & 1 deletion aai_cli/app/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import NoReturn
from typing import NoReturn, Protocol

import keyring.errors
import typer
Expand Down Expand Up @@ -256,3 +256,31 @@ def run_command(
# `from exc`, unlike _fail's `from None`: the original exception is a bug
# worth keeping on the chain for anyone re-raising with tracebacks enabled.
raise typer.Exit(code=internal.exit_code) from exc


class _OptionsRunner[OptsT](Protocol):
"""The run-body half of an options/run-split command: it acts on already-parsed
``opts`` instead of parsing argv. ``run_with_options`` adapts it to run_command."""

def __call__(self, opts: OptsT, state: AppState, /, *, json_mode: bool) -> None:
"""Run the command from its parsed options.

``opts``/``state`` are positional-only so a body free to ignore state can
still name it ``_state`` without breaking structural compatibility.
"""


def run_with_options[OptsT](
ctx: typer.Context,
run_fn: _OptionsRunner[OptsT],
opts: OptsT,
*,
json: bool,
) -> None:
"""run_command for an options/run-split command (see aai_cli/AGENTS.md).

Adapts ``run_<cmd>(opts, state, *, json_mode)`` to the ``(state, json_mode)`` body
run_command expects, replacing the identical ``lambda state, json_mode: run_<cmd>(
opts, state, json_mode=json_mode)`` adapter that every flag-heavy command repeats.
"""
run_command(ctx, lambda state, json_mode: run_fn(opts, state, json_mode=json_mode), json=json)
37 changes: 9 additions & 28 deletions aai_cli/app/init_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
from aai_cli import __version__
from aai_cli.app.context import AppState
from aai_cli.core import environments, stdio
from aai_cli.core.errors import CLIError, UsageError
from aai_cli.init import keys, runner, scaffold, templates
from aai_cli.core.errors import UsageError, missing_dependency
from aai_cli.init import devserver, keys, runner, scaffold, templates
from aai_cli.ui import output, steps

DEFAULT_PORT = 3000
Expand Down Expand Up @@ -52,12 +52,10 @@ def _pick_template() -> str:
try:
import questionary
except ImportError as exc: # a broken/stale install missing the declared dep
raise CLIError(
raise missing_dependency(
"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(
Expand Down Expand Up @@ -109,32 +107,15 @@ def _active_env_vars() -> dict[str, str]:
def _install_step(
target: Path, *, no_install: bool, api_key: str | None, use_uv: bool
) -> tuple[list[steps.Step], bool]:
"""Run (or skip) dependency install, returning the report rows and whether to launch.
"""Run (or skip) dependency install, returning the report row and whether to launch.

Launch only happens when deps are installed and there's a key; an install failure
flips `will_launch` off so the caller exits non-zero instead of starting a server.
The row is built by the shared `devserver.install_step` (the same form `dev`/`share`
report), so only the launch decision is init-specific: launch when deps install and
a key is present. On a failed install the flag is moot — run_init raises Exit(1) on
any failed step before it consults will_launch.
"""
will_launch = not no_install and api_key is not None
if no_install:
return [{"name": "install", "status": "skipped", "detail": "--no-install"}], will_launch
setup = runner.run_setup(target, use_uv=use_uv)
if setup.returncode != 0:
row: steps.Step = {
"name": "install",
"status": "failed",
"detail": (setup.stderr or setup.stdout).strip()[:300],
}
# The False (don't-launch) is an equivalent mutant: run_init raises Exit(1) on
# any failed step before it ever consults will_launch, so the value is unused
# on this branch.
return [row], False # pragma: no mutate
return [
{
"name": "install",
"status": "installed",
"detail": "uv" if use_uv else "venv + pip",
}
], will_launch
return [devserver.install_step(target, no_install=no_install, use_uv=use_uv)], will_launch


def _reject_file_ancestor(target: Path) -> None:
Expand Down
5 changes: 2 additions & 3 deletions aai_cli/app/mediafile.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import assemblyai as aai

from aai_cli.core import client, youtube
from aai_cli.core.errors import APIError, CLIError, UsageError
from aai_cli.core.errors import APIError, CLIError, UsageError, missing_dependency
from aai_cli.ui import output


Expand Down Expand Up @@ -140,9 +140,8 @@ def require_ffmpeg(purpose: str) -> str:
"""The ffmpeg executable; checked before any (billed) transcription work."""
path = shutil.which("ffmpeg")
if path is None:
raise CLIError(
raise missing_dependency(
f"ffmpeg is required to {purpose}, but it isn't on PATH.",
error_type="missing_dependency",
suggestion="Install it (brew install ffmpeg / apt install ffmpeg) and re-run.",
)
return path
Expand Down
8 changes: 2 additions & 6 deletions aai_cli/commands/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from aai_cli import command_registry, help_panels, options
from aai_cli.agent.session import DEFAULT_GREETING, DEFAULT_PROMPT
from aai_cli.agent.voices import DEFAULT_VOICE, VOICES, complete_voice, format_voice_list
from aai_cli.app.context import AppState, run_command
from aai_cli.app.context import AppState, run_command, run_with_options
from aai_cli.commands.agent import _exec as agent_exec
from aai_cli.core import choices
from aai_cli.ui import output
Expand Down Expand Up @@ -110,8 +110,4 @@ def agent(
output_field=output_field,
show_code=show_code,
)
run_command(
ctx,
lambda state, json_mode: agent_exec.run_agent(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, agent_exec.run_agent, opts, json=json_out)
5 changes: 2 additions & 3 deletions aai_cli/commands/agent/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from aai_cli.app.context import AppState
from aai_cli.core import choices, client
from aai_cli.core.errors import CLIError, UsageError
from aai_cli.streaming.session import validate_output_flags
from aai_cli.streaming.session import resolve_output_modes
from aai_cli.streaming.sources import FileSource
from aai_cli.ui import output

Expand Down Expand Up @@ -107,8 +107,7 @@ def _print_show_code(opts: AgentOptions, system_prompt_text: str) -> None:

def run_agent(opts: AgentOptions, state: AppState, *, json_mode: bool) -> None:
"""Execute one `assembly agent` conversation from already-parsed flags."""
validate_output_flags(json_mode=json_mode, output_field=opts.output_field)
text_mode, json_mode = output.stream_output_modes(opts.output_field, json_mode=json_mode)
text_mode, json_mode = resolve_output_modes(opts.output_field, json_mode=json_mode)
if opts.voice not in VOICE_NAMES:
raise UsageError(
f"Unknown voice {opts.voice!r}.",
Expand Down
8 changes: 2 additions & 6 deletions aai_cli/commands/caption/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.caption import _exec as caption_exec
from aai_cli.ui.help_text import examples_epilog

Expand Down Expand Up @@ -85,8 +85,4 @@ def caption(
font_size=font_size,
out=out,
)
run_command(
ctx,
lambda state, json_mode: caption_exec.run_caption(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, caption_exec.run_caption, opts, json=json_out)
8 changes: 2 additions & 6 deletions aai_cli/commands/clip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.clip import _exec as clip_exec
from aai_cli.core import llm
from aai_cli.ui.help_text import examples_epilog
Expand Down Expand Up @@ -149,8 +149,4 @@ def clip(
out_dir=out_dir,
video=video,
)
run_command(
ctx,
lambda state, json_mode: clip_exec.run_clip(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, clip_exec.run_clip, opts, json=json_out)
8 changes: 2 additions & 6 deletions aai_cli/commands/deploy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.deploy import _exec as deploy_exec
from aai_cli.ui.help_text import examples_epilog

Expand Down Expand Up @@ -46,8 +46,4 @@ def deploy(
opts = deploy_exec.DeployOptions(
prod=prod, vercel=vercel, railway=railway, fly=fly, assume_yes=assume_yes
)
run_command(
ctx,
lambda state, json_mode: deploy_exec.run_deploy(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, deploy_exec.run_deploy, opts, json=json_out)
8 changes: 3 additions & 5 deletions aai_cli/commands/deploy/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import typer

from aai_cli.app.context import AppState
from aai_cli.core.errors import CLIError, UsageError
from aai_cli.core.errors import UsageError, missing_dependency
from aai_cli.init import procfile
from aai_cli.ui import output

Expand Down Expand Up @@ -103,10 +103,8 @@ def _install_hint(target: Target) -> str:

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


Expand Down
8 changes: 2 additions & 6 deletions aai_cli/commands/dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.dev import _exec as dev_exec
from aai_cli.init import devserver
from aai_cli.ui.help_text import examples_epilog
Expand Down Expand Up @@ -50,8 +50,4 @@ def dev(
if needed, then starts the FastAPI server with live reload and opens the browser.
"""
opts = dev_exec.DevOptions(port=port, host=host, no_install=no_install, no_open=no_open)
run_command(
ctx,
lambda state, json_mode: dev_exec.run_dev(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, dev_exec.run_dev, opts, json=json_out)
8 changes: 2 additions & 6 deletions aai_cli/commands/dictate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.dictate import _exec as dictate_exec
from aai_cli.core.sync_stt import MAX_AUDIO_SECONDS
from aai_cli.ui.help_text import examples_epilog
Expand Down Expand Up @@ -74,8 +74,4 @@ def dictate(
once=once,
max_seconds=max_seconds,
)
run_command(
ctx,
lambda state, json_mode: dictate_exec.run_dictate(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, dictate_exec.run_dictate, opts, json=json_out)
5 changes: 2 additions & 3 deletions aai_cli/commands/dictate/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from aai_cli.app.context import AppState
from aai_cli.core import sync_stt
from aai_cli.core.config_builder import split_csv
from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys
from aai_cli.core.microphone import MicrophoneSource
from aai_cli.ui import output
Expand Down Expand Up @@ -53,9 +54,7 @@ def _note(message: str, *, json_mode: bool, quiet: bool) -> None:
def _languages(language: str | None) -> str | list[str] | None:
"""Fold --language into the config shape: one ISO code as a string, a
comma-separated list (code-switching audio) as a list, blank as unset."""
if language is None:
return None
codes = [code.strip() for code in language.split(",") if code.strip()]
codes = split_csv(language)
if not codes:
return None
return codes[0] if len(codes) == 1 else codes
Expand Down
8 changes: 2 additions & 6 deletions aai_cli/commands/dub/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.dub import _exec as dub_exec
from aai_cli.core import llm
from aai_cli.ui.help_text import examples_epilog
Expand Down Expand Up @@ -140,8 +140,4 @@ def dub(
video=video,
download_sections=download_sections,
)
run_command(
ctx,
lambda state, json_mode: dub_exec.run_dub(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, dub_exec.run_dub, opts, json=json_out)
8 changes: 2 additions & 6 deletions aai_cli/commands/evaluate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.evaluate import _exec as evaluate_exec
from aai_cli.commands.evaluate._exec import EvalSpeechModel
from aai_cli.ui.help_text import examples_epilog
Expand Down Expand Up @@ -111,8 +111,4 @@ def evaluate(
language_code=language_code,
concurrency=concurrency,
)
run_command(
ctx,
lambda state, json_mode: evaluate_exec.run_evaluate(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, evaluate_exec.run_evaluate, opts, json=json_out)
8 changes: 2 additions & 6 deletions aai_cli/commands/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_command, run_with_options
from aai_cli.commands.llm import _exec as llm_exec
from aai_cli.core import choices
from aai_cli.core import llm as gateway
Expand Down Expand Up @@ -111,8 +111,4 @@ def llm(
max_tokens=max_tokens,
config_kv=tuple(config_kv or ()),
)
run_command(
ctx,
lambda state, json_mode: llm_exec.run_llm(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, llm_exec.run_llm, opts, json=json_out)
8 changes: 2 additions & 6 deletions aai_cli/commands/share/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import typer

from aai_cli import command_registry, help_panels, options
from aai_cli.app.context import run_command
from aai_cli.app.context import run_with_options
from aai_cli.commands.share import _exec as share_exec
from aai_cli.ui.help_text import examples_epilog

Expand Down Expand Up @@ -43,8 +43,4 @@ def share(
https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/).
"""
opts = share_exec.ShareOptions(port=port, no_install=no_install)
run_command(
ctx,
lambda state, json_mode: share_exec.run_share(opts, state, json_mode=json_mode),
json=json_out,
)
run_with_options(ctx, share_exec.run_share, opts, json=json_out)
Loading
Loading