Merge pull request #1274 from NousResearch/hermes/hermes-de3d4e49-pr920
fix: handle headless setup flows end-to-end
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
94
tests/hermes_cli/test_setup_noninteractive.py
Normal file
94
tests/hermes_cli/test_setup_noninteractive.py
Normal file
@@ -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
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user