# NousResearch/hermes-agent · Setup wizard hangs in non-interactive/headless environments

## Open now

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

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #1274. Verify it addresses your case.
- **Recommended:** read PR #1274 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-1274`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #905: hermes setup wizard hangs on non-interactive SSH / headless environments

## Prior & competing PRs

- **#1274: fix: handle headless setup flows end-to-end**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/main.py`, `hermes_cli/setup.py`, `tests/hermes_cli/test_setup_noninteractive.py`, `tests/hermes_cli/test_setup_openclaw_migration.py`
  - fix hunks:
  - `hermes_cli/main.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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=""),
    ```

    ```diff
    @@ -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),
    ```

    ```diff
    @@ -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),
    ```
- **#920: fix: detect non-interactive TTY in setup wizard to prevent hang**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#1274`, `#905`

## Fix status

- Merged to main in PR #1274 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-03-14`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `hermes_cli`
