Setup wizard hangs in non-interactive/headless environments
Cluster 905 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #1274 on 14 Mar 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #1274. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #1274. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes address the Hermes setup wizard hanging when run in non-interactive SSH or headless environments. The work involves detecting non-TTY conditions and handling headless setup flows to prevent the hang.
Open issues0
Open PRs0
Fixes on record
-
#1274 fix: handle headless setup flows end-to-end
files:
hermes_cli/main.py,hermes_cli/setup.py,tests/hermes_cli/test_setup_noninteractive.py,tests/hermes_cli/test_setup_openclaw_migration.pyShow the diff
-
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): -
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: -
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 -
tests/hermes_cli/test_setup_openclaw_migration.py@@ -180,6 +180,7 @@ def test_migration_offered_during_first_time_setup(self, tmp_path): 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 @@ def tracking_load_config(): 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 @@ def test_reloaded_config_flows_into_remaining_setup_sections(self, tmp_path): ), 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),
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#920 fix: detect non-interactive TTY in setup wizard to prevent hang · merged via another change →
#1274,#905
Closed issues · 1
- Closed#905hermes setup wizard hangs on non-interactive SSH / headless environments
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #1274
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 14 Mar 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.