diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0d3e10d7b..4a31db808 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -478,6 +478,15 @@ def cmd_chat(args): print() print(" Run: hermes setup") print() + + from hermes_cli.setup import is_interactive_stdin, print_noninteractive_setup_guidance + + if not is_interactive_stdin(): + print_noninteractive_setup_guidance( + "No interactive TTY detected for the first-run setup prompt." + ) + sys.exit(1) + try: reply = input("Run setup now? [Y/n] ").strip().lower() except (EOFError, KeyboardInterrupt): diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 4f1a1c247..5fd2950c9 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -176,6 +176,36 @@ def print_error(text: str): print(color(f"✗ {text}", Colors.RED)) +def is_interactive_stdin() -> bool: + """Return True when stdin looks like a usable interactive TTY.""" + stdin = getattr(sys, "stdin", None) + if stdin is None: + return False + try: + return bool(stdin.isatty()) + except Exception: + return False + + +def print_noninteractive_setup_guidance(reason: str | None = None) -> None: + """Print guidance for headless/non-interactive setup flows.""" + print() + print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD)) + print() + if reason: + print_info(reason) + print_info("The interactive wizard cannot be used here.") + print() + print_info("Configure Hermes using environment variables or config commands:") + print_info(" hermes config set model.provider custom") + print_info(" hermes config set model.base_url http://localhost:8080/v1") + print_info(" hermes config set model.default your-model-name") + print() + print_info("Or set OPENROUTER_API_KEY / OPENAI_API_KEY in your environment.") + print_info("Run 'hermes setup' in an interactive terminal to use the full wizard.") + print() + + def prompt(question: str, default: str = None, password: bool = False) -> str: """Prompt for input with optional default.""" if default: @@ -2338,6 +2368,17 @@ def run_setup_wizard(args): config = load_config() hermes_home = get_hermes_home() + # Detect non-interactive environments (headless SSH, Docker, CI/CD) + non_interactive = getattr(args, 'non_interactive', False) + if not non_interactive and not is_interactive_stdin(): + non_interactive = True + + if non_interactive: + print_noninteractive_setup_guidance( + "Running in a non-interactive environment (no TTY detected)." + ) + return + # Check if a specific section was requested section = getattr(args, "section", None) if section: diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py new file mode 100644 index 000000000..4e76c013d --- /dev/null +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -0,0 +1,94 @@ +"""Tests for non-interactive setup and first-run headless behavior.""" + +from argparse import Namespace +from unittest.mock import patch + +import pytest + + +def _make_setup_args(**overrides): + return Namespace( + non_interactive=overrides.get("non_interactive", False), + section=overrides.get("section", None), + reset=overrides.get("reset", False), + ) + + +def _make_chat_args(**overrides): + return Namespace( + continue_last=overrides.get("continue_last", None), + resume=overrides.get("resume", None), + model=overrides.get("model", None), + provider=overrides.get("provider", None), + toolsets=overrides.get("toolsets", None), + verbose=overrides.get("verbose", False), + query=overrides.get("query", None), + worktree=overrides.get("worktree", False), + yolo=overrides.get("yolo", False), + pass_session_id=overrides.get("pass_session_id", False), + quiet=overrides.get("quiet", False), + checkpoints=overrides.get("checkpoints", False), + ) + + +class TestNonInteractiveSetup: + """Verify setup paths exit cleanly in headless/non-interactive environments.""" + + def test_non_interactive_flag_skips_wizard(self, capsys): + """--non-interactive should print guidance and not enter the wizard.""" + from hermes_cli.setup import run_setup_wizard + + args = _make_setup_args(non_interactive=True) + + with ( + patch("hermes_cli.setup.ensure_hermes_home"), + patch("hermes_cli.setup.load_config", return_value={}), + patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), + patch("hermes_cli.auth.get_active_provider", side_effect=AssertionError("wizard continued")), + patch("builtins.input", side_effect=AssertionError("input should not be called")), + ): + run_setup_wizard(args) + + out = capsys.readouterr().out + assert "hermes config set model.provider custom" in out + + def test_no_tty_skips_wizard(self, capsys): + """When stdin has no TTY, the setup wizard should print guidance and return.""" + from hermes_cli.setup import run_setup_wizard + + args = _make_setup_args(non_interactive=False) + + with ( + patch("hermes_cli.setup.ensure_hermes_home"), + patch("hermes_cli.setup.load_config", return_value={}), + patch("hermes_cli.setup.get_hermes_home", return_value="/tmp/.hermes"), + patch("hermes_cli.auth.get_active_provider", side_effect=AssertionError("wizard continued")), + patch("sys.stdin") as mock_stdin, + patch("builtins.input", side_effect=AssertionError("input should not be called")), + ): + mock_stdin.isatty.return_value = False + run_setup_wizard(args) + + out = capsys.readouterr().out + assert "hermes config set model.provider custom" in out + + def test_chat_first_run_headless_skips_setup_prompt(self, capsys): + """Bare `hermes` should not prompt for input when no provider exists and stdin is headless.""" + from hermes_cli.main import cmd_chat + + args = _make_chat_args() + + with ( + patch("hermes_cli.main._has_any_provider_configured", return_value=False), + patch("hermes_cli.main.cmd_setup") as mock_setup, + patch("sys.stdin") as mock_stdin, + patch("builtins.input", side_effect=AssertionError("input should not be called")), + ): + mock_stdin.isatty.return_value = False + with pytest.raises(SystemExit) as exc: + cmd_chat(args) + + assert exc.value.code == 1 + mock_setup.assert_not_called() + out = capsys.readouterr().out + assert "hermes config set model.provider custom" in out diff --git a/tests/hermes_cli/test_setup_openclaw_migration.py b/tests/hermes_cli/test_setup_openclaw_migration.py index 344079aa6..be5d61bab 100644 --- a/tests/hermes_cli/test_setup_openclaw_migration.py +++ b/tests/hermes_cli/test_setup_openclaw_migration.py @@ -180,6 +180,7 @@ class TestSetupWizardOpenclawIntegration: patch.object(setup_mod, "load_config", return_value={}), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object(setup_mod, "get_env_value", return_value=""), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch("hermes_cli.auth.get_active_provider", return_value=None), # User presses Enter to start patch("builtins.input", return_value=""), @@ -214,6 +215,7 @@ class TestSetupWizardOpenclawIntegration: patch.object(setup_mod, "load_config", side_effect=tracking_load_config), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object(setup_mod, "get_env_value", return_value=""), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch("hermes_cli.auth.get_active_provider", return_value=None), patch("builtins.input", return_value=""), patch.object(setup_mod, "_offer_openclaw_migration", return_value=True), @@ -244,6 +246,7 @@ class TestSetupWizardOpenclawIntegration: ), patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), patch.object(setup_mod, "get_env_value", return_value=""), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), patch("hermes_cli.auth.get_active_provider", return_value=None), patch("builtins.input", return_value=""), patch.object(setup_mod, "_offer_openclaw_migration", return_value=True),