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
15 changes: 8 additions & 7 deletions aai_cli/commands/speak.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,22 +169,22 @@ def _speak_dialogue(
rich_help_panel=help_panels.TRANSCRIPTION,
epilog=examples_epilog(
[
("Speak text aloud (sandbox only)", 'assembly speak "Hello there, friend." --sandbox'),
("Speak text aloud (sandbox only)", 'assembly --sandbox speak "Hello there, friend."'),
(
"Pick a voice and language",
'assembly speak "Bonjour" --voice jane --language French --sandbox',
'assembly --sandbox speak "Bonjour" --voice jane --language French',
),
(
"Speak a diarized transcript, one voice per speaker",
"assembly transcribe meeting.mp3 --speaker-labels | assembly speak --sandbox",
"assembly transcribe meeting.mp3 --speaker-labels | assembly --sandbox speak",
),
(
"Override a speaker's voice",
"… | assembly speak --voice A=vera --voice B=paul --sandbox",
"… | assembly --sandbox speak --voice A=vera --voice B=paul",
),
(
"Save to a WAV instead of playing",
'assembly speak "Hello" --out /tmp/hello.wav --sandbox',
'assembly --sandbox speak "Hello" --out /tmp/hello.wav',
),
]
),
Expand All @@ -211,7 +211,8 @@ def speak(
Plays the audio through your speakers by default, or writes a WAV with --out.
Speaker-labeled input (from 'assembly transcribe --speaker-labels') is detected
automatically: the labels are stripped and each speaker gets a different
voice. This feature only exists in the sandbox today — run it with --sandbox.
voice. This feature only exists in the sandbox today — run it as
'assembly --sandbox speak' (--sandbox goes before the subcommand).
"""

def body(state: AppState, json_mode: bool) -> None:
Expand All @@ -220,7 +221,7 @@ def body(state: AppState, json_mode: bool) -> None:
"assembly speak is only available in the sandbox.",
error_type="unsupported_environment",
exit_code=2,
suggestion="Re-run with --sandbox (or --env sandbox000).",
suggestion="Re-run as 'assembly --sandbox speak …'.",
)
spoken = _read_text(text)
api_key = config.resolve_api_key(profile=state.profile)
Expand Down
22 changes: 17 additions & 5 deletions aai_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,17 @@ def _command_line_requests_json(raw_args: list[str]) -> bool:
return False


def _sandbox_conflict_warning(sandbox: bool, env: str | None) -> str | None:
"""A warning when ``--sandbox`` and a contradictory ``--env`` are both passed.

Credentials are environment-bound, so the conflict must not be resolved silently:
``--env`` wins, and the warning names the loser so the user can drop a flag.
"""
if sandbox and env is not None and env != "sandbox000":
return f"--sandbox ignored: --env {env} takes precedence."
return None


def _offer_or_help(ctx: typer.Context, state: AppState) -> None:
"""No subcommand given: offer guided setup to a credential-less, interactive user;
otherwise print help. Never prompts in a non-interactive session, and never on
Expand Down Expand Up @@ -286,6 +297,7 @@ def main(
is_eager=True, # pragma: no mutate
),
) -> None:
conflict_warning = _sandbox_conflict_warning(sandbox, env)
if sandbox and env is None:
env = "sandbox000"
state = AppState(profile=profile, env=env, quiet=quiet)
Expand All @@ -300,11 +312,11 @@ def main(
except CLIError as err:
output.emit_error(err, json_mode=json_mode)
raise typer.Exit(code=err.exit_code) from None
warning = env_override_warning(state)
if warning and not quiet:
# Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets a
# machine-readable hint instead of an unexplained downstream auth failure.
output.emit_warning(warning, json_mode=json_mode)
for warning in (conflict_warning, env_override_warning(state)):
if warning and not quiet:
# Surfaced in JSON mode too (as {"warning": …}), so a `--json` pipeline gets
# a machine-readable hint instead of an unexplained downstream auth failure.
output.emit_warning(warning, json_mode=json_mode)
if ctx.invoked_subcommand is None:
_offer_or_help(ctx, state)

Expand Down
6 changes: 5 additions & 1 deletion aai_cli/transcribe_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ def expand_sources(source: str | None, *, from_stdin: bool, sample: bool) -> lis
"""
if from_stdin:
return _stdin_sources(source, sample=sample)
if source is None or sample or source == "-" or source.startswith(_URL_PREFIXES):
# `not source` (rather than `is None`) also catches the empty string — e.g. an
# unset shell variable in `assembly transcribe "$FILE"`. `Path("")` is `Path(".")`,
# so it would otherwise fall into the directory branch and batch-transcribe the
# whole working directory; instead it stays single-source and fails validation.
if not source or sample or source == "-" or source.startswith(_URL_PREFIXES):
return None
path = Path(source)
if path.is_dir():
Expand Down
13 changes: 7 additions & 6 deletions tests/__snapshots__/test_cli_output_snapshots.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -766,7 +766,8 @@
Speaker-labeled input (from 'assembly transcribe --speaker-labels') is
detected
automatically: the labels are stripped and each speaker gets a different
voice. This feature only exists in the sandbox today — run it with --sandbox.
voice. This feature only exists in the sandbox today — run it as
'assembly --sandbox speak' (--sandbox goes before the subcommand).

╭─ Arguments ──────────────────────────────────────────────────────────────────╮
│ text [TEXT] Text to speak. Omit to read from stdin. │
Expand All @@ -786,15 +787,15 @@

Examples
Speak text aloud (sandbox only)
$ assembly speak "Hello there, friend." --sandbox
$ assembly --sandbox speak "Hello there, friend."
Pick a voice and language
$ assembly speak "Bonjour" --voice jane --language French --sandbox
$ assembly --sandbox speak "Bonjour" --voice jane --language French
Speak a diarized transcript, one voice per speaker
$ assembly transcribe meeting.mp3 --speaker-labels | assembly speak --sandbox
$ assembly transcribe meeting.mp3 --speaker-labels | assembly --sandbox speak
Override a speaker's voice
$ … | assembly speak --voice A=vera --voice B=paul --sandbox
$ … | assembly --sandbox speak --voice A=vera --voice B=paul
Save to a WAV instead of playing
$ assembly speak "Hello" --out /tmp/hello.wav --sandbox
$ assembly --sandbox speak "Hello" --out /tmp/hello.wav



Expand Down
34 changes: 34 additions & 0 deletions tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,40 @@ def test_env_override_warning_is_structured_in_json_mode(monkeypatch, mocker):
assert "may be rejected" in json.loads(warning_line)["warning"]


def test_sandbox_flag_conflicting_env_warns():
# Credentials are environment-bound, so `--sandbox --env production` must not pick
# one silently: --env wins, with a warning. Agreeing flags and --quiet stay silent.
noisy = runner.invoke(app, ["--sandbox", "--env", "production"])
assert "--sandbox ignored: --env production takes precedence." in noisy.output
assert '{"warning"' not in noisy.output # human mode renders text, not JSON
quiet = runner.invoke(app, ["--quiet", "--sandbox", "--env", "production"])
assert "--sandbox ignored" not in quiet.output
agreeing = runner.invoke(app, ["--sandbox", "--env", "sandbox000"])
assert "--sandbox ignored" not in agreeing.output
env_only = runner.invoke(app, ["--env", "production"])
assert "--sandbox ignored" not in env_only.output


def test_sandbox_conflict_warning_is_structured_in_json_mode(mocker):
# Same {"warning": …} contract as the env-override warning: a --json pipeline gets
# a machine-readable hint instead of a decorated human line.
import json

from aai_cli import config

config.set_api_key("default", "sk_live")
mocker.patch(
"aai_cli.commands.transcripts.client.list_transcripts", autospec=True, return_value=[]
)
result = runner.invoke(
app, ["--sandbox", "--env", "production", "transcripts", "list", "--json"]
)
warning_line = next(
line for line in result.output.splitlines() if line.strip().startswith('{"warning"')
)
assert "--sandbox ignored" in json.loads(warning_line)["warning"]


def test_shell_completion_is_available(monkeypatch):
# add_completion=True ships `--show-completion` (and --install-completion), the
# discoverability affordance gh/kubectl/docker users reach for. Typer detects the
Expand Down
4 changes: 3 additions & 1 deletion tests/test_speak.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def test_production_env_is_rejected_with_sandbox_hint():
result = runner.invoke(app, ["speak", "Hello"]) # default = production
assert result.exit_code == 2
assert "only available in the sandbox" in result.output
assert "--sandbox" in result.output
# The full command, not just the flag: --sandbox is root-only, so a naive
# `assembly speak … --sandbox` retry would fail with "No such option".
assert "assembly --sandbox speak" in result.output


def test_plays_audio_by_default(monkeypatch, fake_synthesize):
Expand Down
15 changes: 14 additions & 1 deletion tests/test_transcribe_batch_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,24 @@ def test_from_stdin_rejects_sample():
assert "--from-stdin reads sources from stdin" in result.output


@pytest.mark.parametrize("source", ["-", "https://example.com/a.mp3", None])
@pytest.mark.parametrize("source", ["-", "https://example.com/a.mp3", None, ""])
def test_non_batch_sources_return_none(source):
assert transcribe_batch.expand_sources(source, from_stdin=False, sample=False) is None


def test_empty_source_is_rejected_not_treated_as_cwd(tmp_path, mocker, monkeypatch):
# Path("") == Path("."), so an empty source (e.g. an unset shell variable in
# `assembly transcribe "$FILE"`) used to batch-transcribe the whole working
# directory; it must fail like a missing source instead.
_auth()
(tmp_path / "a.mp3").write_bytes(b"a")
seen = _patch_transcribe(mocker, monkeypatch)
result = runner.invoke(app, ["transcribe", ""])
assert result.exit_code == 2
assert "Provide an audio path or URL." in result.output
assert seen == [] # nothing in the cwd was picked up, let alone transcribed


def test_sample_returns_none_even_without_source():
assert transcribe_batch.expand_sources(None, from_stdin=False, sample=True) is None

Expand Down
Loading