diff --git a/docs/usage/cli.mdx b/docs/usage/cli.mdx index 684d6746..2fc492e9 100644 --- a/docs/usage/cli.mdx +++ b/docs/usage/cli.mdx @@ -57,7 +57,9 @@ strix (--target | --target-list ) [options] - Run in headless mode without TUI. Ideal for CI/CD. + Run in headless mode without TUI. Ideal for CI/CD. When no terminal is + attached (CI, `nohup`, pipes) and a target is given, Strix runs headless + automatically and says so on one line. diff --git a/strix/interface/interactive.py b/strix/interface/interactive.py index 3c228d03..d704cd45 100644 --- a/strix/interface/interactive.py +++ b/strix/interface/interactive.py @@ -17,12 +17,17 @@ class InteractiveSetupUnavailableError(RuntimeError): """Raised when the interactive TUI cannot be launched.""" +class InteractiveInterfaceExitedError(RuntimeError): + """Raised when the TUI process dies after it was up (no terminal, killed).""" + + async def run_tui(args: argparse.Namespace) -> None: """Run the Bubble Tea TUI.""" from strix.interface.tui.runtime import ( GoTuiPreActivationError, run_go_tui, ) + from strix.interface.tui.sidecar import TuiProcessExitedError try: await run_go_tui(args) @@ -30,9 +35,12 @@ async def run_tui(args: argparse.Namespace) -> None: raise InteractiveSetupUnavailableError( f"The interactive interface could not start: {exc}" ) from exc + except TuiProcessExitedError as exc: + raise InteractiveInterfaceExitedError(str(exc)) from exc __all__ = [ + "InteractiveInterfaceExitedError", "InteractiveSetupUnavailableError", "run_tui", ] diff --git a/strix/interface/main.py b/strix/interface/main.py index 5b23d257..118e7924 100644 --- a/strix/interface/main.py +++ b/strix/interface/main.py @@ -7,6 +7,7 @@ import argparse import asyncio import contextlib import logging +import os import sys from pathlib import Path from typing import Any @@ -30,6 +31,7 @@ from strix.interface.environment import ( validate_environment, ) from strix.interface.interactive import ( + InteractiveInterfaceExitedError, InteractiveSetupUnavailableError, run_tui, ) @@ -345,6 +347,39 @@ def _print_cli_error(message: str) -> None: ) +HEADLESS_HINT = "If Strix runs without a terminal (CI, nohup, pipes), pass -n to run headless." + + +def _terminal_attached() -> bool: + """Whether the TUI can take over the terminal: a tty on both ends, not dumb.""" + if os.environ.get("TERM", "").strip().lower() == "dumb": + return False + return all(hasattr(stream, "isatty") and stream.isatty() for stream in (sys.stdin, sys.stdout)) + + +def _fall_back_to_headless(args: argparse.Namespace) -> None: + """Run headless when there is no terminal for the TUI to attach to. + + CI jobs, ``nohup``, pipes and cron have no tty; the Go TUI exits as soon + as it tries to take over the screen. With a target the scan can still run + as if ``-n`` was given. Without one the start screen is the only way to + enter a target, so stop with the fix instead. A bare ``--resume`` is left + to the picker, which already explains itself without a terminal. + """ + if args.non_interactive or args.resume_picker or _terminal_attached(): + return + if args.needs_setup: + report_error("no_terminal_for_setup") + _print_error_panel( + "NO TERMINAL ATTACHED", + "The interactive interface needs a terminal and no target was given.\n" + "Pass -t -n to run headless.", + ) + sys.exit(1) + args.non_interactive = True + Console().print("No terminal attached, running headless (same as -n).", style="dim") + + def _pick_run_to_resume(args: argparse.Namespace) -> None: """A bare --resume: let the user pick a run, then load it like --resume .""" from strix.interface.resume_picker import PickerUnavailableError, pick_run @@ -443,6 +478,7 @@ def main() -> None: start_import_warmup() args = parse_arguments() + _fall_back_to_headless(args) start_background_check() if not args.non_interactive and prompt_update_if_available(Console()): @@ -481,6 +517,11 @@ def main() -> None: report_error("interactive_setup_unavailable", exc) _print_error_panel("INTERACTIVE SETUP UNAVAILABLE", str(exc)) sys.exit(1) + except InteractiveInterfaceExitedError as exc: + exit_reason = "error" + report_error("interactive_interface_exited", exc) + _print_error_panel("INTERACTIVE INTERFACE STOPPED", f"{exc}.\n{HEADLESS_HINT}") + sys.exit(1) except KeyboardInterrupt: exit_reason = "interrupted" except Exception as exc: diff --git a/strix/interface/tui/sidecar.py b/strix/interface/tui/sidecar.py index d08d04ac..c0d0f986 100644 --- a/strix/interface/tui/sidecar.py +++ b/strix/interface/tui/sidecar.py @@ -177,9 +177,17 @@ async def _launch_windows_tui_process( return windows_process, connection +class TuiProcessExitedError(RuntimeError): + """The Go TUI process ended with a non-zero status.""" + + def __init__(self, return_code: int) -> None: + super().__init__(f"Bubble Tea TUI exited with status {return_code}") + self.return_code = return_code + + def check_return_code(return_code: int) -> None: if return_code != 0: - raise RuntimeError(f"Bubble Tea TUI exited with status {return_code}") + raise TuiProcessExitedError(return_code) def package_version() -> str: diff --git a/tests/test_main_launch.py b/tests/test_main_launch.py index 4965cd42..8a278ac7 100644 --- a/tests/test_main_launch.py +++ b/tests/test_main_launch.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import asyncio import importlib import sys from types import SimpleNamespace @@ -11,24 +12,41 @@ import pytest cli_main: Any = importlib.import_module("strix.interface.main") report_state_module: Any = importlib.import_module("strix.report.state") +interactive: Any = importlib.import_module("strix.interface.interactive") +tui_runtime: Any = importlib.import_module("strix.interface.tui.runtime") +tui_sidecar: Any = importlib.import_module("strix.interface.tui.sidecar") def _launch( - monkeypatch: pytest.MonkeyPatch, *, needs_setup: bool, resume_picker: bool = False + monkeypatch: pytest.MonkeyPatch, + *, + needs_setup: bool, + resume_picker: bool = False, + terminal: bool = True, + args: argparse.Namespace | None = None, ) -> list[str]: calls: list[str] = [] - args = argparse.Namespace( - non_interactive=False, - needs_setup=needs_setup, - resume_picker=resume_picker, - run_name=None, - fail_on=None, - ) + if args is None: + args = argparse.Namespace( + non_interactive=False, + needs_setup=needs_setup, + resume_picker=resume_picker, + run_name=None, + fail_on=None, + ) async def run_tui(_args: argparse.Namespace) -> None: calls.append("tui") + async def run_cli(_args: argparse.Namespace) -> None: + calls.append("cli") + monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) + monkeypatch.setattr(cli_main, "_terminal_attached", lambda: terminal) + monkeypatch.setattr("strix.interface.cli.run_cli", run_cli) + monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None) + monkeypatch.setattr(cli_main, "_print_error_panel", lambda title, _msg: calls.append(title)) + monkeypatch.setattr(cli_main, "display_completion_message", lambda *_args: None) monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None) monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None) monkeypatch.setattr(cli_main, "parse_arguments", lambda: args) @@ -103,6 +121,7 @@ def test_tui_startup_failure_marks_the_prepared_run_failed(monkeypatch: pytest.M raise cli_main.InteractiveSetupUnavailableError("no sidecar") monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) + monkeypatch.setattr(cli_main, "_terminal_attached", lambda: True) monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None) monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None) monkeypatch.setattr(cli_main, "parse_arguments", lambda: args) @@ -125,3 +144,138 @@ def test_tui_startup_failure_marks_the_prepared_run_failed(monkeypatch: pytest.M assert exit_info.value.code == 1 assert calls == ["bootstrap", "panel", "cleanup:failed"] + + +def test_no_terminal_with_a_target_runs_headless_with_a_notice( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + args = argparse.Namespace( + non_interactive=False, needs_setup=False, resume_picker=False, run_name=None, fail_on=None + ) + + calls = _launch(monkeypatch, needs_setup=False, terminal=False, args=args) + + assert calls == ["bootstrap", "cli"] + assert args.non_interactive is True + assert "No terminal attached, running headless (same as -n)." in capsys.readouterr().out + + +def test_no_terminal_without_a_target_stops_with_the_headless_hint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + panels: list[tuple[str, str]] = [] + args = argparse.Namespace( + non_interactive=False, needs_setup=True, resume_picker=False, run_name=None, fail_on=None + ) + monkeypatch.setattr(cli_main, "_terminal_attached", lambda: False) + monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + cli_main, "_print_error_panel", lambda title, msg: panels.append((title, msg)) + ) + + with pytest.raises(SystemExit) as exit_info: + cli_main._fall_back_to_headless(args) + + assert exit_info.value.code == 1 + assert panels == [ + ( + "NO TERMINAL ATTACHED", + "The interactive interface needs a terminal and no target was given.\n" + "Pass -t -n to run headless.", + ) + ] + assert args.non_interactive is False + + +def test_no_terminal_leaves_a_bare_resume_to_the_picker(monkeypatch: pytest.MonkeyPatch) -> None: + args = argparse.Namespace( + non_interactive=False, needs_setup=False, resume_picker=True, run_name=None, fail_on=None + ) + monkeypatch.setattr(cli_main, "_terminal_attached", lambda: False) + + cli_main._fall_back_to_headless(args) + + assert args.non_interactive is False + + +def test_terminal_attached_needs_a_tty_on_both_ends_and_a_real_term( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def stream(*, tty: bool) -> SimpleNamespace: + return SimpleNamespace(isatty=lambda: tty) + + monkeypatch.delenv("TERM", raising=False) + monkeypatch.setattr(sys, "stdin", stream(tty=True)) + monkeypatch.setattr(sys, "stdout", stream(tty=True)) + assert cli_main._terminal_attached() is True + + monkeypatch.setattr(sys, "stdout", stream(tty=False)) + assert cli_main._terminal_attached() is False + + monkeypatch.setattr(sys, "stdout", stream(tty=True)) + monkeypatch.setattr(sys, "stdin", stream(tty=False)) + assert cli_main._terminal_attached() is False + + monkeypatch.setattr(sys, "stdin", stream(tty=True)) + monkeypatch.setenv("TERM", "dumb") + assert cli_main._terminal_attached() is False + + +def test_tui_process_dying_after_startup_prints_a_panel_instead_of_a_traceback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + panels: list[tuple[str, str]] = [] + report_state = SimpleNamespace(cleanup=lambda status: calls.append(f"cleanup:{status}")) + args = argparse.Namespace( + non_interactive=False, needs_setup=False, resume_picker=False, run_name="run", fail_on=None + ) + + async def run_tui(_args: argparse.Namespace) -> None: + raise cli_main.InteractiveInterfaceExitedError("Bubble Tea TUI exited with status 1") + + monkeypatch.setattr(sys, "argv", ["strix", "--target", "https://example.com"]) + monkeypatch.setattr(cli_main, "_terminal_attached", lambda: True) + monkeypatch.setattr(cli_main, "setup_console_logging", lambda: None) + monkeypatch.setattr(cli_main, "start_import_warmup", lambda: None) + monkeypatch.setattr(cli_main, "parse_arguments", lambda: args) + monkeypatch.setattr(cli_main, "start_background_check", lambda: None) + monkeypatch.setattr(cli_main, "prompt_update_if_available", lambda _console: False) + monkeypatch.setattr(cli_main, "check_docker_installed", lambda: None) + monkeypatch.setattr(cli_main, "pull_docker_image", lambda: None) + monkeypatch.setattr(cli_main, "validate_environment", lambda: None) + monkeypatch.setattr(cli_main, "wait_for_import_warmup", lambda: None) + monkeypatch.setattr(cli_main, "_bootstrap_scan", lambda _args: calls.append("bootstrap")) + monkeypatch.setattr(cli_main, "run_tui", run_tui) + monkeypatch.setattr(cli_main, "report_error", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + cli_main, "_print_error_panel", lambda title, msg: panels.append((title, msg)) + ) + monkeypatch.setattr(cli_main.posthog, "end", lambda *_args, **_kwargs: None) + monkeypatch.setattr(cli_main.scarf, "end", lambda *_args, **_kwargs: None) + monkeypatch.setattr(report_state_module, "get_global_report_state", lambda: report_state) + + with pytest.raises(SystemExit) as exit_info: + cli_main.main() + + assert exit_info.value.code == 1 + assert calls == ["bootstrap", "cleanup:failed"] + assert panels == [ + ( + "INTERACTIVE INTERFACE STOPPED", + "Bubble Tea TUI exited with status 1.\n" + "If Strix runs without a terminal (CI, nohup, pipes), pass -n to run headless.", + ) + ] + + +def test_run_tui_maps_a_dead_sidecar_to_the_interface_exited_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def run_go_tui(_args: argparse.Namespace) -> None: + tui_sidecar.check_return_code(1) + + monkeypatch.setattr(tui_runtime, "run_go_tui", run_go_tui) + + with pytest.raises(interactive.InteractiveInterfaceExitedError, match="status 1"): + asyncio.run(interactive.run_tui(argparse.Namespace()))