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
18 changes: 4 additions & 14 deletions aai_cli/app/init_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,9 @@ class InitOptions:
def _pick_template() -> str:
"""Interactive picker; raises a usage error when there's no TTY to prompt on."""
if not stdio.interactive_stdio():
raise CLIError(
raise UsageError(
"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
Expand Down Expand Up @@ -86,10 +84,8 @@ def _resolve_template(template: str | None) -> str:
"""Resolve the template name: the picker when omitted, else validate the arg."""
chosen = template if template is not None else _pick_template()
if not templates.is_template(chosen):
raise CLIError(
raise UsageError(
f"Unknown template {chosen!r}. Choose one of: {', '.join(templates.TEMPLATE_ORDER)}.",
error_type="usage_error",
exit_code=1,
)
return chosen

Expand Down Expand Up @@ -165,22 +161,16 @@ def _resolve_target(
"""Resolve the target directory, rejecting --here+DIRECTORY, an existing file, or
a non-empty conflict. Returns the target and whether --force is overlaying it."""
if here and directory:
raise CLIError(
"Pass either a DIRECTORY or --here, not both.",
error_type="usage_error",
exit_code=1,
)
raise UsageError("Pass either a DIRECTORY or --here, not both.")
target = _resolve_dir(directory, chosen, here=here)
if target.exists() and not target.is_dir():
raise UsageError(f"{target} exists and is not a directory.")
_reject_file_ancestor(target)
conflict = scaffold.target_conflict(target)
if conflict and not force:
raise CLIError(
raise UsageError(
f"{target} already exists and is not empty. "
f"Use --force to overwrite or pick another directory.",
error_type="usage_error",
exit_code=1,
)
return target, conflict

Expand Down
4 changes: 4 additions & 0 deletions aai_cli/app/transcribe/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,10 @@ def run_transcribe(opts: TranscribeOptions, state: AppState, *, json_mode: bool)
transcribe_validate.validate_out_path(opts.out)
transcribe_validate.validate_json_with_output(opts.output_field, json_mode=json_mode)
client.validate_chars_per_caption(opts.chars_per_caption, opts.output_field)
# --download-sections only slices a downloadable-URL fetch; for a local file,
# stdin, remote bucket, or directory batch it would be dropped silently — reject
# it up front like `clip`/`dub` rather than billing a full-file transcription.
youtube.validate_sections_flag(opts.source, list(opts.download_sections or []))

merged = config_builder.merge_transcribe_config(
flags=flags, overrides=opts.config_kv, config_file=opts.config_file
Expand Down
2 changes: 1 addition & 1 deletion aai_cli/commands/dev/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
)
def dev(
ctx: typer.Context,
port: int = typer.Option(3000, "--port", help="Local server port"),
port: int = typer.Option(3000, "--port", help="Local server port", min=0, max=65535),
host: str = typer.Option(
devserver.LOCAL_HOST,
"--host",
Expand Down
4 changes: 3 additions & 1 deletion aai_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ def init(
),
),
here: bool = typer.Option(False, "--here", help="Scaffold into the current directory"),
port: int = typer.Option(init_exec.DEFAULT_PORT, "--port", help="Local server port"),
port: int = typer.Option(
init_exec.DEFAULT_PORT, "--port", help="Local server port", min=0, max=65535
),
json_out: bool = options.json_option(),
) -> None:
"""Scaffold a new app from a template and launch it
Expand Down
2 changes: 1 addition & 1 deletion aai_cli/commands/share/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
)
def share(
ctx: typer.Context,
port: int = typer.Option(3000, "--port", help="Local server port"),
port: int = typer.Option(3000, "--port", help="Local server port", min=0, max=65535),
no_install: bool = typer.Option(
False, "--no-install", help="Skip dependency install; launch directly"
),
Expand Down
4 changes: 4 additions & 0 deletions aai_cli/commands/stream/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,14 @@ def stream(
None,
"--min-turn-silence",
help="Min silence to end a turn (ms)",
min=1,
rich_help_panel=help_panels.OPT_TURNS,
),
max_turn_silence: int | None = typer.Option(
None,
"--max-turn-silence",
help="Max silence before ending a turn (ms)",
min=1,
rich_help_panel=help_panels.OPT_TURNS,
),
vad_threshold: float | None = typer.Option(
Expand Down Expand Up @@ -190,6 +192,7 @@ def stream(
None,
"--inactivity-timeout",
help="Auto-close after N seconds idle",
min=1,
rich_help_panel=help_panels.OPT_FEATURES,
),
# guardrails
Expand Down Expand Up @@ -255,6 +258,7 @@ def stream(
llm.DEFAULT_MAX_TOKENS,
"--max-tokens",
help="Max tokens",
min=1,
rich_help_panel=help_panels.OPT_LLM,
),
# escape hatch
Expand Down
2 changes: 1 addition & 1 deletion aai_cli/core/youtube.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ def validate_video_flag(source: str, *, video: bool) -> None:
)


def validate_sections_flag(source: str, sections: list[str]) -> None:
def validate_sections_flag(source: str | None, sections: list[str]) -> None:
"""Reject ``--download-sections`` for a source that isn't a downloadable URL.

The specs select which parts of a media-page download yt-dlp fetches; a local
Expand Down
53 changes: 32 additions & 21 deletions tests/__snapshots__/test_snapshots_help_build.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,18 @@
browser.

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --port INTEGER Local server port [default: 3000] │
│ --host TEXT Interface to bind. Loopback by default; pass │
│ 0.0.0.0 to expose on your network. │
│ [default: 127.0.0.1] │
│ --no-open Launch, but don't open the browser │
│ --no-install Skip dependency install; launch directly │
│ --json -j Output raw JSON │
│ --help Show this message and exit. │
│ --port INTEGER RANGE Local server port │
│ [0<=x<=65535] [default: 3000] │
│ --host TEXT Interface to bind. Loopback │
│ by default; pass 0.0.0.0 to │
│ expose on your network. │
│ [default: 127.0.0.1] │
│ --no-open Launch, but don't open the │
│ browser │
│ --no-install Skip dependency install; │
│ launch directly │
│ --json -j Output raw JSON │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯

Examples
Expand Down Expand Up @@ -90,15 +94,20 @@
│ directory [DIRECTORY] Target directory (default: <template>) │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --no-install Scaffold only; don't install or launch │
│ --no-open Install + launch, but don't open the browser │
│ --force Overwrite a non-empty target directory │
│ (overlays the template; files not in the │
│ template are kept) │
│ --here Scaffold into the current directory │
│ --port INTEGER Local server port [default: 3000] │
│ --json -j Output raw JSON │
│ --help Show this message and exit. │
│ --no-install Scaffold only; don't │
│ install or launch │
│ --no-open Install + launch, but don't │
│ open the browser │
│ --force Overwrite a non-empty │
│ target directory (overlays │
│ the template; files not in │
│ the template are kept) │
│ --here Scaffold into the current │
│ directory │
│ --port INTEGER RANGE Local server port │
│ [0<=x<=65535] [default: 3000] │
│ --json -j Output raw JSON │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯

Examples
Expand Down Expand Up @@ -155,10 +164,12 @@
downloads/).

╭─ Options ────────────────────────────────────────────────────────────────────╮
│ --port INTEGER Local server port [default: 3000] │
│ --no-install Skip dependency install; launch directly │
│ --json -j Output raw JSON │
│ --help Show this message and exit. │
│ --port INTEGER RANGE Local server port │
│ [0<=x<=65535] [default: 3000] │
│ --no-install Skip dependency install; │
│ launch directly │
│ --json -j Output raw JSON │
│ --help Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────╯

Examples
Expand Down
8 changes: 4 additions & 4 deletions tests/__snapshots__/test_snapshots_help_run.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,10 @@
│ [0.0<=x<=1.0 confidence │
│ ] (0-1) │
│ --min-turn-silence INTEGER Min silence │
to end a turn │
RANGE [x>=1] to end a turn │
│ (ms) │
│ --max-turn-silence INTEGER Max silence │
before ending │
RANGE [x>=1] before ending │
│ a turn (ms) │
│ --vad-threshold FLOAT RANGE Voice │
│ [0.0<=x<=1.0 activity │
Expand All @@ -647,7 +647,7 @@
│ ] suppression model) │
│ --voice-focus-threshold FLOAT RANGE Voice-focus threshold │
│ [0.0<=x<=1.0] (0-1) │
│ --inactivity-timeout INTEGER Auto-close after N │
│ --inactivity-timeout INTEGER RANGE [x>=1] Auto-close after N │
│ seconds idle │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Guardrails ─────────────────────────────────────────────────────────────────╮
Expand Down Expand Up @@ -675,7 +675,7 @@
│ --model TEXT LLM Gateway model │
│ [default: │
│ claude-haiku-4-5-20251001] │
│ --max-tokens INTEGER Max tokens [default: 1000] │
│ --max-tokens INTEGER RANGE [x>=1] Max tokens [default: 1000] │
╰──────────────────────────────────────────────────────────────────────────────╯
╭─ Advanced ───────────────────────────────────────────────────────────────────╮
│ --config KEY=VALUE Set any StreamingParameters field as │
Expand Down
19 changes: 19 additions & 0 deletions tests/test_dev.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ def test_dev_custom_port_expands_and_flows_through(tmp_path, monkeypatch):
assert "3000" not in captured["command"] # default was overridden, not used


def test_dev_port_out_of_range_is_rejected(tmp_path, monkeypatch):
# A bad --port used to reach socket.connect_ex and surface as an internal "report a
# bug" error; Typer now rejects it up front with a usage error (2). Pins max=65535.
monkeypatch.chdir(tmp_path)
_make_project(tmp_path)
result = runner.invoke(app, ["dev", "--no-open", "--port", "65536"])
assert result.exit_code == 2


def test_dev_port_zero_is_accepted(tmp_path, monkeypatch):
# Port 0 ("OS-assign a free port") must stay valid (pins min=0, not 1).
monkeypatch.chdir(tmp_path)
_make_project(tmp_path)
captured = _stub_runner(monkeypatch)
result = runner.invoke(app, ["dev", "--no-open", "--port", "0"])
assert result.exit_code == 0, result.output
assert captured["port"] == 0


def test_dev_venv_command_when_no_uv(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
_make_project(tmp_path)
Expand Down
33 changes: 25 additions & 8 deletions tests/test_init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ def test_init_scaffold_only_creates_project(tmp_path, monkeypatch):

def test_init_rejects_dir_and_here_together(tmp_path, monkeypatch):
# DIRECTORY and --here are mutually exclusive; passing both is a usage error
# exiting 1 (pins that exit_code on the conflict).
# exiting 2 (pins that exit_code on the conflict).
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", TEMPLATE, "somedir", "--here", "--no-install"])
assert result.exit_code == 1
assert result.exit_code == 2
assert "not both" in result.output


Expand Down Expand Up @@ -110,14 +110,14 @@ def test_init_placeholder_key_when_logged_out(tmp_path, monkeypatch):
def test_init_unknown_template_errors(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", "nope", "myapp", "--no-install"])
assert result.exit_code == 1
assert result.exit_code == 2


def test_init_refuses_nonempty_dir_without_force(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install"]).exit_code == 0
result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install"])
assert result.exit_code == 1
assert result.exit_code == 2


def test_init_no_template_non_interactive_errors(tmp_path, monkeypatch):
Expand All @@ -128,6 +128,23 @@ def test_init_no_template_non_interactive_errors(tmp_path, monkeypatch):
assert TEMPLATE in result.output # lists the available templates


def test_init_port_out_of_range_is_rejected_before_scaffolding(tmp_path, monkeypatch):
# A bad --port used to surface only at launch (after a wasted scaffold+install) as an
# internal "report a bug" error. Typer now rejects it up front with a usage error (2),
# before any directory is created. Pins the max=65535 bound.
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install", "--port", "65536"])
assert result.exit_code == 2
assert not (tmp_path / "myapp").exists() # rejected before scaffolding


def test_init_port_zero_is_accepted(tmp_path, monkeypatch):
# Port 0 means "let the OS assign a free port" — it must stay valid (pins min=0, not 1).
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install", "--port", "0"])
assert result.exit_code == 0, result.output


def test_init_default_dir_is_template_slug(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", TEMPLATE, "--no-install"])
Expand Down Expand Up @@ -155,7 +172,7 @@ def test_init_banner_skipped_on_error_only_runs(tmp_path, monkeypatch):
# template) stays undecorated like the sibling commands, and stdout stays empty.
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", "nope", "x", "--no-install"])
assert result.exit_code == 1
assert result.exit_code == 2
assert "AssemblyAI CLI" not in result.stderr
assert result.stdout == ""

Expand All @@ -165,7 +182,7 @@ def test_init_banner_skipped_on_target_conflict_error(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
assert runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install"]).exit_code == 0
result = runner.invoke(app, ["init", TEMPLATE, "myapp", "--no-install"])
assert result.exit_code == 1
assert result.exit_code == 2
assert "AssemblyAI CLI" not in result.stderr


Expand Down Expand Up @@ -231,7 +248,7 @@ def test_init_unregistered_template_errors_cleanly(tmp_path, monkeypatch):
# picking it must give a clean error, not a FileNotFoundError.
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, ["init", "llm", "x", "--no-install"])
assert result.exit_code == 1
assert result.exit_code == 2
assert "llm" in result.output


Expand Down Expand Up @@ -289,7 +306,7 @@ def test_pick_template_errors_when_either_stream_not_a_tty(monkeypatch, stdin_tt
with pytest.raises(CLIError) as exc:
init_exec._pick_template()
assert exc.value.error_type == "usage_error"
assert exc.value.exit_code == 1
assert exc.value.exit_code == 2


def test_active_env_vars_agents_host_replaces_only_first_streaming(monkeypatch):
Expand Down
Loading
Loading