From 8e684269815c6e20de7474b8ddc046a341a445fc Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 24 May 2026 15:28:15 -0700 Subject: [PATCH] fix(cli): add inline --yes/now skip for destructive slash commands (#30768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #30768 reports that on native Windows PowerShell the destructive-slash confirmation modal renders but never registers keypresses, leaving the user unable to confirm or cancel /reset, /new, /clear, or /undo. The modal works on macOS, Linux, and WSL; PR #23907 (merged May 11) replaced the daemon-thread input() pattern with a prompt_toolkit-native keybinding modal but the win32 input pipeline apparently doesn't dispatch keys to the filter-conditioned handlers. The modal investigation is ongoing. This change ships the immediate escape hatch: append `now`, `--yes`, or `-y` to any destructive slash command to bypass the modal and run the action immediately. Works on every platform without touching the broken Windows code path. /reset now -> reset, no modal /new --yes my-session -> new session titled "my-session", no modal /clear -y -> clear, no modal /undo -y -> undo, no modal The default behavior (modal prompts when approvals.destructive_slash_confirm is True) is unchanged for users who don't pass a skip token. Implementation: - New classmethod HermesCLI._split_destructive_skip(text) -> (remainder, skip) parses a destructive-slash command string, strips the leading "/cmd" word and any recognized skip tokens (case-insensitive exact match, not substring), and reports whether a skip was requested. - HermesCLI._confirm_destructive_slash gains an optional cmd_original= arg. When the arg contains a skip token, it returns "once" immediately — before the gate check and before any modal rendering. - The /clear, /new, /undo handlers in process_command pass cmd_original through. /new additionally uses _split_destructive_skip to strip skip tokens from the remaining text before deriving the session title, so "/new now My Session" yields title="My Session" (not "now My Session"). Tests: - 7 new unit tests in tests/cli/test_destructive_slash_confirm.py covering the helper (recognized tokens, command-word stripping, case-insensitive exact match, None/empty input) and the modal bypass (now and --yes both skip; no-skip-token still consults the modal). - 3 new integration tests in tests/cli/test_destructive_slash_inline_skip_e2e.py driving HermesCLI.process_command end-to-end and asserting (a) new_session is invoked, (b) the modal is never reached, (c) the skip token does not leak into the session title, and (d) the no-skip-token path still reaches the modal as a sanity check that we haven't accidentally short-circuited the normal flow. All 31 tests across the destructive-slash test surface pass. Docs: - website/docs/reference/slash-commands.md documents the new flags both in the destructive-commands table and the dedicated approval section, with a link back to issue #30768 explaining why the escape hatch exists. --- cli.py | 69 +++++++++- tests/cli/test_destructive_slash_confirm.py | 120 ++++++++++++++++ .../test_destructive_slash_inline_skip_e2e.py | 129 ++++++++++++++++++ website/docs/reference/slash-commands.md | 4 +- 4 files changed, 318 insertions(+), 4 deletions(-) create mode 100644 tests/cli/test_destructive_slash_inline_skip_e2e.py diff --git a/cli.py b/cli.py index c61d08d254a..92be2c487cf 100644 --- a/cli.py +++ b/cli.py @@ -8115,6 +8115,7 @@ class HermesCLI: "clear", "This clears the screen and starts a new session.\n" "The current conversation history will be discarded.", + cmd_original=cmd_original, ) is None: return self.new_session(silent=True) @@ -8239,12 +8240,16 @@ class HermesCLI: if not self._handle_handoff_command(cmd_original): return False elif canonical == "new": - parts = cmd_original.split(maxsplit=1) - title = parts[1].strip() if len(parts) > 1 else None + # Strip inline-skip tokens (now/--yes/-y) before deriving the title + # so "/new now My Session" yields title="My Session" instead of + # title="now My Session". See _split_destructive_skip. + _new_args, _ = self._split_destructive_skip(cmd_original) + title = _new_args.strip() or None if self._confirm_destructive_slash( "new", "This starts a fresh session.\n" "The current conversation history will be discarded.", + cmd_original=cmd_original, ) is None: return self.new_session(title=title) @@ -8271,6 +8276,7 @@ class HermesCLI: if self._confirm_destructive_slash( "undo", "This removes the last user/assistant exchange from history.", + cmd_original=cmd_original, ) is None: return self.undo_last() @@ -9922,7 +9928,49 @@ class HermesCLI: if _reload_thread.is_alive(): print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.") - def _confirm_destructive_slash(self, command: str, detail: str) -> Optional[str]: + # Inline-skip tokens that bypass the destructive-slash confirmation modal. + # Matches the escape-hatch pattern users on broken modal platforms + # (currently native Windows PowerShell — issue #30768) need to self-serve + # without having to flip approvals.destructive_slash_confirm in config. + _DESTRUCTIVE_SKIP_TOKENS = frozenset({"now", "--yes", "-y"}) + + @classmethod + def _split_destructive_skip(cls, cmd_text: Optional[str]) -> tuple[str, bool]: + """Split inline-skip tokens out of a destructive slash command. + + Returns ``(remainder, skip)`` where ``remainder`` is the original + text with the command word and any recognized skip tokens removed, + and ``skip`` is True iff at least one skip token was found. + + Examples: + "/reset now" -> ("", True) + "/reset --yes My title" -> ("My title", True) + "/new My title" -> ("My title", False) + "/clear" -> ("", False) + """ + if not cmd_text: + return "", False + tokens = cmd_text.strip().split() + if not tokens: + return "", False + # Drop leading "/cmd" word — callers pass the full command text. + if tokens[0].startswith("/"): + tokens = tokens[1:] + skip = False + kept: list[str] = [] + for tok in tokens: + if tok.lower() in cls._DESTRUCTIVE_SKIP_TOKENS: + skip = True + continue + kept.append(tok) + return " ".join(kept), skip + + def _confirm_destructive_slash( + self, + command: str, + detail: str, + cmd_original: Optional[str] = None, + ) -> Optional[str]: """Prompt the user to confirm a destructive session slash command. Used by ``/clear``, ``/new``/``/reset``, and ``/undo`` before they @@ -9938,9 +9986,24 @@ class HermesCLI: gate is off the function returns ``"once"`` immediately without prompting. + Inline-skip: if ``cmd_original`` contains ``now``, ``--yes``, or + ``-y`` as an argument (e.g. ``/reset now``, ``/new --yes My title``), + the modal is bypassed and ``"once"`` is returned immediately. This is + an escape hatch for platforms where the prompt_toolkit modal hangs + (issue #30768 — native Windows PowerShell). Callers are responsible + for stripping the skip tokens from any remaining argument parsing + (see :meth:`_split_destructive_skip`). + Returns ``"once"``, ``"always"``, or ``None`` (cancelled). Callers proceed with the destructive action when the result is non-None. """ + # Inline-skip escape hatch — works regardless of platform/modal state. + # See class-level _DESTRUCTIVE_SKIP_TOKENS for the accepted tokens. + if cmd_original: + _, _skip = self._split_destructive_skip(cmd_original) + if _skip: + return "once" + # Gate check — respects prior "Always Approve" clicks. try: cfg = load_cli_config() diff --git a/tests/cli/test_destructive_slash_confirm.py b/tests/cli/test_destructive_slash_confirm.py index 1b2fc8c0b1f..88103ac8dcd 100644 --- a/tests/cli/test_destructive_slash_confirm.py +++ b/tests/cli/test_destructive_slash_confirm.py @@ -209,3 +209,123 @@ def test_slash_confirm_display_fragments_include_choice_mapping(): assert "[2] Always Approve" in rendered assert "[3] Cancel" in rendered assert "Type 1/2/3" in rendered + + +# --------------------------------------------------------------------------- +# Inline-skip escape hatch (issue #30768) +# +# Users on platforms where the prompt_toolkit modal doesn't dispatch keys +# (currently native Windows PowerShell) need a way to bypass the confirmation +# without flipping the config gate. ``/reset now``, ``/new --yes``, ``/clear +# -y`` all skip the modal and return "once" immediately. +# --------------------------------------------------------------------------- + + +def test_split_destructive_skip_recognized_tokens(): + """``now``, ``--yes``, and ``-y`` are recognized as skip tokens.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip("/reset now") == ("", True) + assert HermesCLI._split_destructive_skip("/clear --yes") == ("", True) + assert HermesCLI._split_destructive_skip("/undo -y") == ("", True) + + +def test_split_destructive_skip_strips_command_word(): + """Leading ``/cmd`` token is stripped; remaining args survive.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip("/new My title") == ("My title", False) + assert HermesCLI._split_destructive_skip("/new --yes My title") == ("My title", True) + + +def test_split_destructive_skip_case_insensitive(): + """Token matching is case-insensitive but not a substring match.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip("/new NOW") == ("", True) + # Substring match must NOT trigger — "Now-Title" is a literal title token. + assert HermesCLI._split_destructive_skip("/new Now-Title") == ("Now-Title", False) + + +def test_split_destructive_skip_handles_empty_and_none(): + """Defensive against missing/empty input.""" + from cli import HermesCLI + + assert HermesCLI._split_destructive_skip(None) == ("", False) + assert HermesCLI._split_destructive_skip("") == ("", False) + assert HermesCLI._split_destructive_skip(" ") == ("", False) + + +def test_confirm_destructive_slash_now_skips_modal(): + """``/reset now`` skips the modal even when the gate is on.""" + from cli import HermesCLI + + # Build a prompt stub that fails the test if invoked — proving the modal + # was never reached. + def _explode(**_kw): + raise AssertionError("modal must not be invoked when inline-skip present") + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=_explode, + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_, + ) + self_._split_destructive_skip = HermesCLI._split_destructive_skip # classmethod + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + result = _bound(HermesCLI._confirm_destructive_slash, self_)( + "new", "detail", cmd_original="/reset now", + ) + + assert result == "once" + + +def test_confirm_destructive_slash_yes_flag_skips_modal(): + """``--yes`` flag is equivalent to ``now``.""" + from cli import HermesCLI + + def _explode(**_kw): + raise AssertionError("modal must not be invoked when --yes present") + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=_explode, + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_, + ) + self_._split_destructive_skip = HermesCLI._split_destructive_skip + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + result = _bound(HermesCLI._confirm_destructive_slash, self_)( + "new", "detail", cmd_original="/new --yes My Session", + ) + + assert result == "once" + + +def test_confirm_destructive_slash_no_skip_token_still_prompts(): + """Without a skip token the gate-on path still consults the modal.""" + from cli import HermesCLI + + self_ = _make_self(prompt_response="3") # cancel + self_._split_destructive_skip = HermesCLI._split_destructive_skip + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + result = _bound(HermesCLI._confirm_destructive_slash, self_)( + "new", "detail", cmd_original="/new My Session", + ) + + # Prompt was reached and returned cancel → None. + assert result is None diff --git a/tests/cli/test_destructive_slash_inline_skip_e2e.py b/tests/cli/test_destructive_slash_inline_skip_e2e.py new file mode 100644 index 00000000000..3ed434ab47a --- /dev/null +++ b/tests/cli/test_destructive_slash_inline_skip_e2e.py @@ -0,0 +1,129 @@ +"""End-to-end integration test for the destructive-slash inline-skip path. + +Drives ``HermesCLI.process_command("/reset now")`` against a minimal stand-in +and verifies: + +1. ``new_session`` was invoked (the command actually ran) +2. ``_prompt_text_input_modal`` was NOT invoked (modal bypassed) +3. The skip token did not leak into the session title + +This is the regression test for issue #30768 — the inline-skip escape hatch +must work without ever touching the modal, on every platform. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + + +def _make_cli_stub(): + """Build a minimal HermesCLI-shaped object that can run ``process_command`` + for the destructive-slash branches without spinning up a real TUI.""" + from cli import HermesCLI + + new_session_calls = [] + + def _capture_new_session(self_, title=None, silent=False): + new_session_calls.append({"title": title, "silent": silent}) + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=lambda **_kw: (_ for _ in ()).throw( + AssertionError("modal must not be invoked when inline-skip token present") + ), + new_session=lambda **kw: _capture_new_session(self_, **kw), + # Stub out side-effects the destructive-slash branches reach for. + console=SimpleNamespace(clear=lambda: None), + compact=False, + model="stub-model", + session_id="stub-session", + enabled_toolsets=[], + _pending_title=None, + _session_db=None, + ) + # Bind the methods we need under test. + self_._split_destructive_skip = HermesCLI._split_destructive_skip + self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__( + self_, type(self_) + ) + self_.process_command = HermesCLI.process_command.__get__(self_, type(self_)) + return self_, new_session_calls + + +def test_reset_now_invokes_new_session_without_modal(): + """``/reset now`` runs ``new_session`` and never touches the modal.""" + self_, calls = _make_cli_stub() + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + self_.process_command("/reset now") + + assert calls, "new_session was never invoked" + # The /new branch passes title=None when there's no non-skip remainder. + assert calls[0]["title"] is None + + +def test_new_yes_with_title_preserves_title(): + """``/new --yes My Session`` runs ``new_session(title='My Session')``.""" + self_, calls = _make_cli_stub() + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + self_.process_command("/new --yes My Session") + + assert calls, "new_session was never invoked" + assert calls[0]["title"] == "My Session" + + +def test_new_without_skip_token_still_consults_modal(): + """``/new My Session`` (no skip token) must reach the modal. + + Sanity check that we haven't accidentally short-circuited the normal path. + """ + from cli import HermesCLI + + new_session_calls = [] + modal_calls = [] + + def _capture_new_session(self_, title=None, silent=False): + new_session_calls.append({"title": title, "silent": silent}) + + def _record_modal(**kw): + modal_calls.append(kw) + # Simulate user cancelling so new_session is not called. + return "3" + + self_ = SimpleNamespace( + _app=None, + _prompt_text_input_modal=_record_modal, + new_session=lambda **kw: _capture_new_session(self_, **kw), + console=SimpleNamespace(clear=lambda: None), + compact=False, + model="stub-model", + session_id="stub-session", + enabled_toolsets=[], + _pending_title=None, + _session_db=None, + ) + self_._split_destructive_skip = HermesCLI._split_destructive_skip + self_._normalize_slash_confirm_choice = HermesCLI._normalize_slash_confirm_choice.__get__( + self_, type(self_) + ) + self_._confirm_destructive_slash = HermesCLI._confirm_destructive_slash.__get__( + self_, type(self_) + ) + self_.process_command = HermesCLI.process_command.__get__(self_, type(self_)) + + with patch( + "cli.load_cli_config", + return_value={"approvals": {"destructive_slash_confirm": True}}, + ): + self_.process_command("/new My Session") + + assert modal_calls, "modal must be reached when no skip token is present" + assert not new_session_calls, "user cancelled — new_session must not run" diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 3239aa43117..cda04232635 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -36,7 +36,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| -| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. | +| `/new [name]` (alias: `/reset`) | Start a new session (fresh session ID + history). Optional `[name]` sets the initial session title — e.g. `/new my-experiment` opens a fresh session already titled `my-experiment` so it's easy to find later with `/resume` or `/sessions`. Append `now`, `--yes`, or `-y` to skip the confirmation modal — e.g. `/reset now`, `/new --yes my-experiment`. | | `/clear` | Clear screen and start a new session | | `/history` | Show conversation history | | `/save` | Save the current conversation | @@ -252,4 +252,6 @@ The CLI prompts before running slash commands that throw away unsaved session st For each of these the CLI opens a three-choice modal: **Approve Once** (proceed this time), **Always Approve** (proceed and persist `approvals.destructive_slash_confirm: false` so future destructive commands run without prompting), or **Cancel**. +**Inline skip:** append `now`, `--yes`, or `-y` to bypass the modal for a single invocation — e.g. `/reset now`, `/new --yes my-session`, `/clear -y`, `/undo -y`. Useful when the modal doesn't render correctly on your terminal (see [issue #30768](https://github.com/NousResearch/hermes-agent/issues/30768) for native Windows PowerShell) or when scripting against the CLI. + Set `approvals.destructive_slash_confirm: false` in `~/.hermes/config.yaml` to disable the prompts globally; set it back to `true` to re-enable. See [Security — Destructive slash command confirmation](../user-guide/security.md#dangerous-command-approval) for context.