From 7e0f6136110b6ff4e49b78c96cc8b2d8db4acff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 15:48:37 +0000 Subject: [PATCH 1/5] Simplify dictate language parsing and hoist help command rank Reuse core split_csv in dictate's _languages instead of re-inlining the CSV split-and-strip, and lift the help command-rank dict to a module constant so _OrderedGroup.list_commands stops rebuilding it on every --help/completion render. --- aai_cli/commands/dictate/_exec.py | 5 ++--- aai_cli/main.py | 8 ++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/aai_cli/commands/dictate/_exec.py b/aai_cli/commands/dictate/_exec.py index c00d3515..d2a07d77 100644 --- a/aai_cli/commands/dictate/_exec.py +++ b/aai_cli/commands/dictate/_exec.py @@ -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 @@ -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 diff --git a/aai_cli/main.py b/aai_cli/main.py index 84025547..991e87aa 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -33,6 +33,10 @@ # Names not listed (the hidden _update-check) fall to the end, sorted alphabetically. _COMMAND_ORDER = command_registry.command_order(_REGISTERED_COMMAND_MODULES) +# Rank lookup derived once from the static order; list_commands runs per `--help` +# and per completion, so there's no reason to rebuild this on every call. +_COMMAND_RANK = {name: i for i, name in enumerate(_COMMAND_ORDER)} + class _OrderedGroup(TyperGroup): """Lists commands in `_COMMAND_ORDER` rather than registration order. @@ -42,9 +46,9 @@ class _OrderedGroup(TyperGroup): """ def list_commands(self, ctx: ClickContext) -> list[str]: - rank = {name: i for i, name in enumerate(_COMMAND_ORDER)} return sorted( - super().list_commands(ctx), key=lambda name: (rank.get(name, len(rank)), name) + super().list_commands(ctx), + key=lambda name: (_COMMAND_RANK.get(name, len(_COMMAND_RANK)), name), ) def parse_args(self, ctx: ClickContext, args: list[str]) -> list[str]: From aca9d9b1ab833c81d274653ab91f190c334d2fc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:49:54 +0000 Subject: [PATCH 2/5] Collapse the per-command options/run lambda adapter into run_with_options 13 flag-heavy commands each wrapped their run_(opts, state, *, json_mode) body in a byte-identical 'lambda state, json_mode: run_(opts, state, json_mode=json_mode)' to fit run_command's (state, json_mode) callable. Add a typed context.run_with_options(ctx, run_, opts, json=...) adapter and route all 13 through it, so the seam lives in one place. opts/state are positional-only on the runner Protocol so a state-ignoring body can still name it _state. --- aai_cli/AGENTS.md | 7 +++++-- aai_cli/app/context.py | 30 ++++++++++++++++++++++++++- aai_cli/commands/agent/__init__.py | 8 ++----- aai_cli/commands/caption/__init__.py | 8 ++----- aai_cli/commands/clip/__init__.py | 8 ++----- aai_cli/commands/deploy/__init__.py | 8 ++----- aai_cli/commands/dev/__init__.py | 8 ++----- aai_cli/commands/dictate/__init__.py | 8 ++----- aai_cli/commands/dub/__init__.py | 8 ++----- aai_cli/commands/evaluate/__init__.py | 8 ++----- aai_cli/commands/llm/__init__.py | 8 ++----- aai_cli/commands/share/__init__.py | 8 ++----- aai_cli/commands/speak/__init__.py | 8 ++----- aai_cli/commands/stream/__init__.py | 8 ++----- aai_cli/commands/transcribe.py | 8 ++----- 15 files changed, 60 insertions(+), 81 deletions(-) diff --git a/aai_cli/AGENTS.md b/aai_cli/AGENTS.md index c2bc1b7e..2f77e96b 100644 --- a/aai_cli/AGENTS.md +++ b/aai_cli/AGENTS.md @@ -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 `Options` dataclass and hands it -to a module-level `run_(opts, state, *, json_mode)` through a thin lambda -adapter in `run_command(ctx, ..., json=...)`. The run commands follow it — +to a module-level `run_(opts, state, *, json_mode)` via +`context.run_with_options(ctx, run_, opts, json=...)` — the typed adapter +that wraps the `run_` 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`, diff --git a/aai_cli/app/context.py b/aai_cli/app/context.py index eb082ee6..9b8cb6dc 100644 --- a/aai_cli/app/context.py +++ b/aai_cli/app/context.py @@ -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 @@ -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_(opts, state, *, json_mode)`` to the ``(state, json_mode)`` body + run_command expects, replacing the identical ``lambda state, json_mode: run_( + 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) diff --git a/aai_cli/commands/agent/__init__.py b/aai_cli/commands/agent/__init__.py index 6a1a84f9..f535b54c 100644 --- a/aai_cli/commands/agent/__init__.py +++ b/aai_cli/commands/agent/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/caption/__init__.py b/aai_cli/commands/caption/__init__.py index a73dbf9c..286baf6d 100644 --- a/aai_cli/commands/caption/__init__.py +++ b/aai_cli/commands/caption/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/clip/__init__.py b/aai_cli/commands/clip/__init__.py index d8f7303e..3a74299b 100644 --- a/aai_cli/commands/clip/__init__.py +++ b/aai_cli/commands/clip/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/deploy/__init__.py b/aai_cli/commands/deploy/__init__.py index efcb6b2e..f42bce47 100644 --- a/aai_cli/commands/deploy/__init__.py +++ b/aai_cli/commands/deploy/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/dev/__init__.py b/aai_cli/commands/dev/__init__.py index 2c52fb78..11800386 100644 --- a/aai_cli/commands/dev/__init__.py +++ b/aai_cli/commands/dev/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/dictate/__init__.py b/aai_cli/commands/dictate/__init__.py index a298423d..24e5d7d7 100644 --- a/aai_cli/commands/dictate/__init__.py +++ b/aai_cli/commands/dictate/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/dub/__init__.py b/aai_cli/commands/dub/__init__.py index d9b0c6be..6939316a 100644 --- a/aai_cli/commands/dub/__init__.py +++ b/aai_cli/commands/dub/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/evaluate/__init__.py b/aai_cli/commands/evaluate/__init__.py index a16838c1..e860e944 100644 --- a/aai_cli/commands/evaluate/__init__.py +++ b/aai_cli/commands/evaluate/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/llm/__init__.py b/aai_cli/commands/llm/__init__.py index 84d32e34..8c1d5f15 100644 --- a/aai_cli/commands/llm/__init__.py +++ b/aai_cli/commands/llm/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/share/__init__.py b/aai_cli/commands/share/__init__.py index cc7f9db2..1a8b9fde 100644 --- a/aai_cli/commands/share/__init__.py +++ b/aai_cli/commands/share/__init__.py @@ -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 @@ -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) diff --git a/aai_cli/commands/speak/__init__.py b/aai_cli/commands/speak/__init__.py index 85b99030..58ef16e2 100644 --- a/aai_cli/commands/speak/__init__.py +++ b/aai_cli/commands/speak/__init__.py @@ -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.speak import _exec as speak_exec from aai_cli.commands.speak._exec import DEFAULT_LANGUAGE from aai_cli.ui.help_text import examples_epilog @@ -87,8 +87,4 @@ def speak( sample_rate=sample_rate, out=out, ) - run_command( - ctx, - lambda state, json_mode: speak_exec.run_speak(opts, state, json_mode=json_mode), - json=json_out, - ) + run_with_options(ctx, speak_exec.run_speak, opts, json=json_out) diff --git a/aai_cli/commands/stream/__init__.py b/aai_cli/commands/stream/__init__.py index 531643ab..79b88ab1 100644 --- a/aai_cli/commands/stream/__init__.py +++ b/aai_cli/commands/stream/__init__.py @@ -7,7 +7,7 @@ from assemblyai.streaming.v3 import Encoding, NoiseSuppressionModel, SpeechModel 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.stream import _exec as stream_exec from aai_cli.core import choices, llm from aai_cli.ui.help_text import examples_epilog @@ -338,8 +338,4 @@ def stream( output_field=output_field, show_code=show_code, ) - run_command( - ctx, - lambda state, json_mode: stream_exec.run_stream(opts, state, json_mode=json_mode), - json=json_out, - ) + run_with_options(ctx, stream_exec.run_stream, opts, json=json_out) diff --git a/aai_cli/commands/transcribe.py b/aai_cli/commands/transcribe.py index b9fd3565..20633476 100644 --- a/aai_cli/commands/transcribe.py +++ b/aai_cli/commands/transcribe.py @@ -6,7 +6,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.app.transcribe import run as transcribe_exec from aai_cli.core import choices, llm from aai_cli.ui.help_text import examples_epilog @@ -417,11 +417,7 @@ def transcribe( out=out, show_code=show_code, ) - run_command( - ctx, - lambda state, json_mode: transcribe_exec.run_transcribe(opts, state, json_mode=json_mode), - json=json_out, - ) + run_with_options(ctx, transcribe_exec.run_transcribe, opts, json=json_out) # `assembly t` — a one-letter alias for the CLI's highest-frequency command (the From 3414c52bdec26b1524db2e6c211785d24d015afe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 16:58:03 +0000 Subject: [PATCH 3/5] Merge stream/agent output-mode validate+resolve into resolve_output_modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stream and agent both ran validate_output_flags(...) immediately followed by output.stream_output_modes(...) on the next line — the only two callers of validate_output_flags. Pair them in streaming.session.resolve_output_modes so the modes can't be resolved without first rejecting a contradictory -o/--json combination, and both commands call one helper. --- aai_cli/commands/agent/_exec.py | 5 ++--- aai_cli/commands/stream/_exec.py | 5 ++--- aai_cli/streaming/session.py | 13 +++++++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/aai_cli/commands/agent/_exec.py b/aai_cli/commands/agent/_exec.py index 87556ef9..00395438 100644 --- a/aai_cli/commands/agent/_exec.py +++ b/aai_cli/commands/agent/_exec.py @@ -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 @@ -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}.", diff --git a/aai_cli/commands/stream/_exec.py b/aai_cli/commands/stream/_exec.py index 9f007a77..91d1993e 100644 --- a/aai_cli/commands/stream/_exec.py +++ b/aai_cli/commands/stream/_exec.py @@ -26,7 +26,7 @@ from aai_cli.streaming.session import ( SourceOptions, StreamSession, - validate_output_flags, + resolve_output_modes, validate_sources, ) from aai_cli.streaming.sources import TARGET_RATE, FileSource, StdinSource @@ -207,8 +207,7 @@ def _dispatch(session: StreamSession, opts: SourceOptions) -> None: def run_stream(opts: StreamOptions, state: AppState, *, json_mode: bool) -> None: """Execute one `assembly stream` invocation 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) sources = opts.source_options() base_flags = opts.base_flags() diff --git a/aai_cli/streaming/session.py b/aai_cli/streaming/session.py index f15db27a..64601c8c 100644 --- a/aai_cli/streaming/session.py +++ b/aai_cli/streaming/session.py @@ -65,6 +65,19 @@ def validate_output_flags(*, json_mode: bool, output_field: choices.TextOrJson | ) +def resolve_output_modes( + output_field: choices.TextOrJson | None, *, json_mode: bool +) -> tuple[bool, bool]: + """Validate the -o/--json combination, then fold it into (text_mode, json_mode). + + The two steps always run together for the realtime commands (`stream`, `agent`), + so pairing them here keeps a caller from resolving the modes without first + rejecting a contradictory pair. + """ + validate_output_flags(json_mode=json_mode, output_field=output_field) + return output.stream_output_modes(output_field, json_mode=json_mode) + + def validate_sources(opts: SourceOptions, *, has_llm: bool, text_mode: bool) -> None: """Reject flag combinations that can't be honored, before any audio is opened.""" mutually_exclusive( From bec8d031fcd578f51256d8cafd441e12ef7a56dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:07:44 +0000 Subject: [PATCH 4/5] Delegate init's install report row to the shared devserver.install_step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/init_exec._install_step hand-built the same skipped/failed/installed report rows that init/devserver.install_step (used by dev/share) already produces. Reuse it and keep only the init-specific launch decision. run_init raises Exit(1) on any failed step before reading will_launch, so the failed path no longer needs to special-case the flag — which also drops a # pragma: no mutate. --- aai_cli/app/init_exec.py | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/aai_cli/app/init_exec.py b/aai_cli/app/init_exec.py index 7c43aa40..decd6cce 100644 --- a/aai_cli/app/init_exec.py +++ b/aai_cli/app/init_exec.py @@ -22,7 +22,7 @@ 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.init import devserver, keys, runner, scaffold, templates from aai_cli.ui import output, steps DEFAULT_PORT = 3000 @@ -109,32 +109,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: From 33c8c540fe9c8242ebb19c2835c369f9c52b7b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 17:26:56 +0000 Subject: [PATCH 5/5] Add errors.missing_dependency factory for the 4 dependency-probe sites ffmpeg (mediafile), cloudflared (tunnel), the deploy CLIs (deploy), and the interactive picker's questionary (init_exec) each hand-constructed CLIError(error_type="missing_dependency", exit_code=1, ...). Route all four through a errors.missing_dependency() factory (mirrors auth_failure/ mutually_exclusive) so the error-type string can't drift across them. --- aai_cli/app/init_exec.py | 6 ++---- aai_cli/app/mediafile.py | 5 ++--- aai_cli/commands/deploy/_exec.py | 8 +++----- aai_cli/core/errors.py | 10 ++++++++++ aai_cli/init/tunnel.py | 6 ++---- 5 files changed, 19 insertions(+), 16 deletions(-) diff --git a/aai_cli/app/init_exec.py b/aai_cli/app/init_exec.py index decd6cce..bf1602ef 100644 --- a/aai_cli/app/init_exec.py +++ b/aai_cli/app/init_exec.py @@ -21,7 +21,7 @@ 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.core.errors import UsageError, missing_dependency from aai_cli.init import devserver, keys, runner, scaffold, templates from aai_cli.ui import output, steps @@ -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( diff --git a/aai_cli/app/mediafile.py b/aai_cli/app/mediafile.py index b285cef8..5c81fd10 100644 --- a/aai_cli/app/mediafile.py +++ b/aai_cli/app/mediafile.py @@ -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 @@ -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 diff --git a/aai_cli/commands/deploy/_exec.py b/aai_cli/commands/deploy/_exec.py index 84b57c1c..c19505ae 100644 --- a/aai_cli/commands/deploy/_exec.py +++ b/aai_cli/commands/deploy/_exec.py @@ -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 @@ -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)}" ) diff --git a/aai_cli/core/errors.py b/aai_cli/core/errors.py index 1e393728..98d86f4a 100644 --- a/aai_cli/core/errors.py +++ b/aai_cli/core/errors.py @@ -175,3 +175,13 @@ def auth_failure() -> NotAuthenticated: return NotAuthenticated( REJECTED_KEY_MESSAGE, suggestion=REJECTED_KEY_SUGGESTION, rejected_key=True ) + + +def missing_dependency(message: str, *, suggestion: str | None = None) -> CLIError: + """A required external tool or optional package isn't on PATH/importable (exit 1). + + Centralizes the ``missing_dependency`` error type shared by the dependency probes + (ffmpeg, cloudflared, the deploy CLIs, the interactive picker's questionary) so the + type string can't drift across them. + """ + return CLIError(message, error_type="missing_dependency", suggestion=suggestion) diff --git a/aai_cli/init/tunnel.py b/aai_cli/init/tunnel.py index 121350f7..ba7e0d51 100644 --- a/aai_cli/init/tunnel.py +++ b/aai_cli/init/tunnel.py @@ -11,7 +11,7 @@ from pathlib import Path from aai_cli.core import config -from aai_cli.core.errors import CLIError +from aai_cli.core.errors import missing_dependency from aai_cli.init import runner # cloudflared binary name; resolved via shutil.which by callers. @@ -37,10 +37,8 @@ def install_hint() -> str: def require_cloudflared(purpose: str) -> None: """Raise a clean missing-dependency error when cloudflared isn't on PATH.""" if shutil.which(CLOUDFLARED) is None: - raise CLIError( + raise missing_dependency( f"cloudflared is required to {purpose}.", - error_type="missing_dependency", - exit_code=1, suggestion=install_hint(), )