diff --git a/aai_cli/app/context.py b/aai_cli/app/context.py index 9b8cb6dc..6508c538 100644 --- a/aai_cli/app/context.py +++ b/aai_cli/app/context.py @@ -1,6 +1,5 @@ from __future__ import annotations -import sys from collections.abc import Callable from dataclasses import dataclass from typing import NoReturn, Protocol @@ -8,7 +7,7 @@ import keyring.errors import typer -from aai_cli.core import config, debuglog, env, environments, telemetry +from aai_cli.core import config, debuglog, env, environments, stdio, telemetry from aai_cli.core.environments import Environment from aai_cli.core.errors import APIError, CLIError, NotAuthenticated from aai_cli.ui import output, update_check @@ -143,7 +142,7 @@ def _fail(err: CLIError, *, json_mode: bool) -> NoReturn: def _interactive_session() -> bool: """True only when a human can complete a browser login: stdin and stderr are both real TTYs and no agent/CI context is detected (`output.is_agentic`).""" - return sys.stdin.isatty() and sys.stderr.isatty() and not output.is_agentic() + return stdio.stdin_is_tty() and stdio.stderr_is_tty() and not output.is_agentic() def _should_auto_login(err: NotAuthenticated) -> bool: diff --git a/aai_cli/commands/login.py b/aai_cli/commands/login.py index 21fca3b6..e5e61f33 100644 --- a/aai_cli/commands/login.py +++ b/aai_cli/commands/login.py @@ -1,14 +1,12 @@ from __future__ import annotations -import sys - import typer from rich.markup import escape from rich.table import Table from aai_cli import command_registry, help_panels, options from aai_cli.app.context import AppState, persist_browser_login, run_command -from aai_cli.core import client, config, environments +from aai_cli.core import client, config, environments, stdio from aai_cli.core.errors import STDIN_KEY_RECIPE, APIError, CLIError, UsageError, mutually_exclusive from aai_cli.ui import output from aai_cli.ui.help_text import examples_epilog @@ -28,13 +26,15 @@ def _read_stdin_key() -> str: Stdin-only on purpose (the Codex-CLI pattern): a key passed as an argv value lands in shell history and ``ps`` output; a piped key does not. """ - if sys.stdin.isatty(): + if not stdio.stdin_is_piped(): raise UsageError( "--with-api-key reads the key from stdin, but stdin is a terminal.", suggestion=f"Pipe the key in: {STDIN_KEY_RECIPE}", ) - key = sys.stdin.read().strip() - if not key: + # stdin is piped, so a None here means an empty/blank pipe (not a terminal) — + # distinct from the terminal case above, so the recipe hint stays accurate. + key = stdio.piped_stdin_text() + if key is None: raise UsageError( "--with-api-key found no key on stdin.", suggestion=( @@ -42,7 +42,7 @@ def _read_stdin_key() -> str: "(check that the variable you piped is set)." ), ) - return key + return key.strip() @app.command( diff --git a/aai_cli/core/stdio.py b/aai_cli/core/stdio.py index 48268f01..05db2ef3 100644 --- a/aai_cli/core/stdio.py +++ b/aai_cli/core/stdio.py @@ -24,13 +24,31 @@ def silence_stdout() -> None: os.close(devnull_fd) +def stdin_is_tty() -> bool: + """True when stdin is an interactive terminal. The single raw-``isatty`` chokepoint + for stdin, so higher layers compose this rather than re-reaching for ``sys.stdin``.""" + return sys.stdin.isatty() + + +def stdout_is_tty() -> bool: + """True when stdout is an interactive terminal. The single raw-``isatty`` chokepoint + for stdout (e.g. `output.is_agentic`/`print_code` compose it).""" + return sys.stdout.isatty() + + +def stderr_is_tty() -> bool: + """True when stderr is an interactive terminal. The single raw-``isatty`` chokepoint + for stderr (the browser-login interactivity probe composes it).""" + return sys.stderr.isatty() + + def interactive_stdio() -> bool: """True only when stdin and stdout are both real TTYs — i.e. a human can answer a prompt and see it. The shared "may we prompt here?" predicate for the bare-`assembly` setup offer, the onboarding prompter, and the `assembly init` template picker, so the three can't drift on what counts as interactive. """ - return sys.stdin.isatty() and sys.stdout.isatty() + return stdin_is_tty() and stdout_is_tty() def stdin_is_piped() -> bool: diff --git a/aai_cli/ui/output.py b/aai_cli/ui/output.py index 2c3d68cd..2c67e37b 100644 --- a/aai_cli/ui/output.py +++ b/aai_cli/ui/output.py @@ -12,7 +12,7 @@ from rich.text import Text from aai_cli import __version__ -from aai_cli.core import choices, env, jsonshape +from aai_cli.core import choices, env, jsonshape, stdio from aai_cli.ui import theme if TYPE_CHECKING: @@ -27,7 +27,7 @@ def _stdout_is_tty() -> bool: - return sys.stdout.isatty() + return stdio.stdout_is_tty() def is_agentic() -> bool: diff --git a/tests/test_output.py b/tests/test_output.py index 641045e8..0b014702 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -5,6 +5,23 @@ from aai_cli.ui import output +class _StdoutProbe: + def __init__(self, tty): + self._tty = tty + + def isatty(self): + return self._tty + + +def test_stdout_is_tty_seam_reflects_real_stdout(monkeypatch): + # The seam every other test patches delegates to the stdio chokepoint; exercise + # the real body so the delegation is covered and a negated isatty mutant dies. + monkeypatch.setattr("sys.stdout", _StdoutProbe(True)) + assert output._stdout_is_tty() is True + monkeypatch.setattr("sys.stdout", _StdoutProbe(False)) + assert output._stdout_is_tty() is False + + def test_resolve_json_true_only_when_explicit(): # JSON is opt-in: the flag is the single source of truth. assert output.resolve_json(explicit=True) is True diff --git a/tests/test_stdio.py b/tests/test_stdio.py index e1285f7f..9b0e2fc0 100644 --- a/tests/test_stdio.py +++ b/tests/test_stdio.py @@ -30,6 +30,39 @@ def test_stdin_is_piped(monkeypatch): assert stdio.stdin_is_piped() is False +def test_stdin_is_tty(monkeypatch): + monkeypatch.setattr("sys.stdin", _Tty("")) + assert stdio.stdin_is_tty() is True + monkeypatch.setattr("sys.stdin", _Pipe("")) + assert stdio.stdin_is_tty() is False + + +def test_stdout_is_tty(monkeypatch): + monkeypatch.setattr("sys.stdout", _Tty("")) + assert stdio.stdout_is_tty() is True + monkeypatch.setattr("sys.stdout", _Pipe("")) + assert stdio.stdout_is_tty() is False + + +def test_stderr_is_tty(monkeypatch): + monkeypatch.setattr("sys.stderr", _Tty("")) + assert stdio.stderr_is_tty() is True + monkeypatch.setattr("sys.stderr", _Pipe("")) + assert stdio.stderr_is_tty() is False + + +def test_interactive_stdio_requires_both_stdin_and_stdout_tty(monkeypatch): + # Both must be terminals; either one piped flips it false (kills the and->or mutant). + monkeypatch.setattr("sys.stdin", _Tty("")) + monkeypatch.setattr("sys.stdout", _Tty("")) + assert stdio.interactive_stdio() is True + monkeypatch.setattr("sys.stdout", _Pipe("")) + assert stdio.interactive_stdio() is False + monkeypatch.setattr("sys.stdin", _Pipe("")) + monkeypatch.setattr("sys.stdout", _Tty("")) + assert stdio.interactive_stdio() is False + + def test_piped_stdin_text_returns_none_on_tty(monkeypatch): monkeypatch.setattr("sys.stdin", _Tty("ignored\n")) assert stdio.piped_stdin_text() is None