NousResearch/hermes-agent

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

0open issues
0open PRs
3items grouped
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

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #1274 fix: handle headless setup flows end-to-end

    merged to main

    files: hermes_cli/main.py, hermes_cli/setup.py, tests/hermes_cli/test_setup_noninteractive.py, tests/hermes_cli/test_setup_openclaw_migration.py

    Show 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.00 means 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 Lab

At 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.

For your agent

The same facts, typed.