diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0d3e10d7bf..4a31db808e 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 6eb2ce0a78..5fd2950c99 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: @@ -2340,24 +2370,13 @@ def run_setup_wizard(args): # Detect non-interactive environments (headless SSH, Docker, CI/CD) non_interactive = getattr(args, 'non_interactive', False) - if not non_interactive and not sys.stdin.isatty(): + if not non_interactive and not is_interactive_stdin(): non_interactive = True if non_interactive: - print() - print(color("⚕ Hermes Setup — Non-interactive mode", Colors.CYAN, Colors.BOLD)) - print() - print_info("Running in a non-interactive environment (no TTY detected).") - 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() + print_noninteractive_setup_guidance( + "Running in a non-interactive environment (no TTY detected)." + ) return # Check if a specific section was requested diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index 724337bfd3..4e76c013d2 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -1,43 +1,94 @@ -"""Tests for non-interactive setup wizard behavior.""" +"""Tests for non-interactive setup and first-run headless behavior.""" + +from argparse import Namespace +from unittest.mock import patch + import pytest -from unittest.mock import patch, MagicMock -def _make_args(**kwargs): - args = MagicMock() - args.non_interactive = kwargs.get("non_interactive", False) - args.section = kwargs.get("section", None) - args.reset = kwargs.get("reset", False) - return args +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 wizard exits cleanly in non-interactive environments.""" + """Verify setup paths exit cleanly in headless/non-interactive environments.""" def test_non_interactive_flag_skips_wizard(self, capsys): - """--non-interactive flag should print help and return without hanging.""" + """--non-interactive should print guidance and not enter the wizard.""" from hermes_cli.setup import run_setup_wizard - args = _make_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"): + 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" in out + assert "hermes config set model.provider custom" in out def test_no_tty_skips_wizard(self, capsys): - """When stdin has no TTY, wizard should exit with helpful message.""" + """When stdin has no TTY, the setup wizard should print guidance and return.""" from hermes_cli.setup import run_setup_wizard - args = _make_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("sys.stdin") as mock_stdin: + 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" in 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 344079aa6a..be5d61bab9 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),