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
5 changes: 2 additions & 3 deletions aai_cli/app/context.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
from __future__ import annotations

import sys
from collections.abc import Callable
from dataclasses import dataclass
from typing import NoReturn, Protocol

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
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 7 additions & 7 deletions aai_cli/commands/login.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -28,21 +26,23 @@ 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=(
f"Pipe a non-empty key: {STDIN_KEY_RECIPE} "
"(check that the variable you piped is set)."
),
)
return key
return key.strip()


@app.command(
Expand Down
20 changes: 19 additions & 1 deletion aai_cli/core/stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions aai_cli/ui/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -27,7 +27,7 @@


def _stdout_is_tty() -> bool:
return sys.stdout.isatty()
return stdio.stdout_is_tty()


def is_agentic() -> bool:
Expand Down
17 changes: 17 additions & 0 deletions tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading