diff --git a/aai_cli/commands/speak.py b/aai_cli/commands/speak.py index a0abcef6..d4a9a3e5 100644 --- a/aai_cli/commands/speak.py +++ b/aai_cli/commands/speak.py @@ -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', ), ] ), @@ -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: @@ -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) diff --git a/aai_cli/main.py b/aai_cli/main.py index bc67198b..a2821555 100644 --- a/aai_cli/main.py +++ b/aai_cli/main.py @@ -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 @@ -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) @@ -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) diff --git a/aai_cli/transcribe_batch.py b/aai_cli/transcribe_batch.py index 358f4085..b88117ef 100644 --- a/aai_cli/transcribe_batch.py +++ b/aai_cli/transcribe_batch.py @@ -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(): diff --git a/tests/__snapshots__/test_cli_output_snapshots.ambr b/tests/__snapshots__/test_cli_output_snapshots.ambr index 7404c450..f1499568 100644 --- a/tests/__snapshots__/test_cli_output_snapshots.ambr +++ b/tests/__snapshots__/test_cli_output_snapshots.ambr @@ -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. │ @@ -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 diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a4faff8a..edaf54d4 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -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 diff --git a/tests/test_speak.py b/tests/test_speak.py index 742fc22d..7aaeb9e1 100644 --- a/tests/test_speak.py +++ b/tests/test_speak.py @@ -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): diff --git a/tests/test_transcribe_batch_sources.py b/tests/test_transcribe_batch_sources.py index d3c1f0fc..736f2114 100644 --- a/tests/test_transcribe_batch_sources.py +++ b/tests/test_transcribe_batch_sources.py @@ -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