From 56c54d08b64ff95b2fba9848864e682c4e91f0fd Mon Sep 17 00:00:00 2001 From: yyy Date: Sat, 9 May 2026 14:55:43 +0800 Subject: [PATCH] fix(cli): guard interactive chat without tty --- hermes_cli/main.py | 12 +++++ tests/hermes_cli/test_setup_noninteractive.py | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f728159da32b4..934b514606b13 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1404,6 +1404,18 @@ def cmd_chat(args): print("You can run 'hermes setup' at any time to configure.") sys.exit(1) + # Interactive prompt_toolkit chat requires a real terminal for stdin. + # In headless/non-PTY invocations (regular file/pipe stdin), kqueue on macOS + # can raise OSError(EINVAL) from loop.add_reader(fd=0). Fail early with a + # clear message, while still allowing `hermes chat -q ...` / `--image ...` + # automation paths to run without a TTY. + if ( + not use_tui + and not getattr(args, "query", None) + and not getattr(args, "image", None) + ): + _require_tty("chat") + # Start update check in background (runs while other init happens) try: from hermes_cli.banner import prefetch_update_check diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index 68f6bd5a20308..2a60ecd22e999 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -29,6 +29,8 @@ def _make_chat_args(**overrides): pass_session_id=overrides.get("pass_session_id", False), quiet=overrides.get("quiet", False), checkpoints=overrides.get("checkpoints", False), + image=overrides.get("image", None), + tui=overrides.get("tui", False), ) @@ -144,6 +146,48 @@ def test_chat_first_run_headless_skips_setup_prompt(self, capsys): out = capsys.readouterr().out assert "hermes config set model.provider custom" in out + def test_chat_headless_interactive_exits_before_prompt_toolkit(self, capsys): + """Bare `hermes` with configured provider needs a TTY instead of crashing in prompt_toolkit.""" + from hermes_cli.main import cmd_chat + + args = _make_chat_args() + + with ( + patch("hermes_cli.main._has_any_provider_configured", return_value=True), + patch("hermes_cli.main._resolve_session_by_name_or_id", return_value=None), + patch("cli.main", side_effect=AssertionError("prompt_toolkit should not start")), + patch("sys.stdin") as mock_stdin, + ): + mock_stdin.isatty.return_value = False + with pytest.raises(SystemExit) as exc: + cmd_chat(args) + + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "hermes chat" in err + assert "requires an interactive terminal" in err + + def test_chat_headless_single_query_still_allowed(self): + """`hermes chat -q` is the supported non-interactive automation path.""" + from hermes_cli.main import cmd_chat + + args = _make_chat_args(query="hello") + captured = {} + + def fake_cli_main(**kwargs): + captured.update(kwargs) + + with ( + patch("hermes_cli.main._has_any_provider_configured", return_value=True), + patch("tools.skills_sync.sync_skills"), + patch("cli.main", side_effect=fake_cli_main), + patch("sys.stdin") as mock_stdin, + ): + mock_stdin.isatty.return_value = False + cmd_chat(args) + + assert captured["query"] == "hello" + def test_main_accepts_tts_setup_section(self, monkeypatch): """`hermes setup tts` should parse and dispatch like other setup sections.""" from hermes_cli import main as main_mod