From 2ef501e1f5a9530435b7db5e334f00747b1e7b8f Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Mon, 18 May 2026 20:10:46 -0400 Subject: [PATCH] feat(cli): add /update slash command to CLI and TUI (#23854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add /update slash command to CLI and TUI * test(cli): add Python tests for /update slash command Co-authored-by: Cursor * fix(cli): address Copilot review for /update slash command Route classic CLI /update through prompt_toolkit modal confirmation and defer relaunch to the main-thread cleanup path after app.exit(). Tighten Y/n semantics, add Python wrapper and catalog coverage tests, and assert /update stays visible in the TUI command catalog. Co-authored-by: Cursor * fix(cli): address review feedback on /update command - Replace raw input() with _prompt_text_input_modal in _handle_update_command to avoid EOF/hang/keystroke-leak races with prompt_toolkit's stdin ownership - Fix confirmation logic: only proceed on recognized affirmative aliases (y/yes/1/ok); cancel on everything else including empty string, typos, and unrecognized input — matches all other [Y/n] prompts in the codebase - Route relaunch through main-thread shutdown path: set _pending_relaunch and return False from process_command so process_loop triggers app.exit(); run() then calls relaunch() after prompt_toolkit has restored terminal modes and after cleanup — safe on both POSIX (execvp) and Windows (subprocess+exit) - Fix misleading docstring in test_update_command.py: the Vitest only covers the TypeScript slash handler that emits code 42, not the Python wrapper branch that acts on it - Rewrite tests to use SimpleNamespace pattern (like test_destructive_slash_confirm) so _prompt_text_input_modal can be stubbed directly - Add Python test for _launch_tui exit-code-42 → relaunch branch in main.py Agent-Logs-Url: https://github.com/NousResearch/hermes-agent/sessions/f6da68cf-e7b1-4b7a-aed6-3d4b0f523bdb Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com> * fix(cli): polish test fixtures for /update command - Remove unused _prompt_text_input from SimpleNamespace stub - Use pytest.fail sentinel in managed-install guard test to catch unexpected modal invocations Agent-Logs-Url: https://github.com/NousResearch/hermes-agent/sessions/f6da68cf-e7b1-4b7a-aed6-3d4b0f523bdb Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com> * chore: re-trigger CI after Copilot review fixes Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com> --- cli.py | 69 ++++++++ hermes_cli/commands.py | 3 +- hermes_cli/main.py | 12 ++ tests/cli/test_update_command.py | 150 ++++++++++++++++++ tests/hermes_cli/test_tui_resume_flow.py | 18 +++ tests/test_tui_gateway_server.py | 3 + tui_gateway/server.py | 1 - .../src/__tests__/createSlashHandler.test.ts | 17 ++ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/slash/commands/core.ts | 11 ++ ui-tui/src/app/useMainApp.ts | 7 + 11 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 tests/cli/test_update_command.py diff --git a/cli.py b/cli.py index 423b96a73d6..528c49cca2e 100644 --- a/cli.py +++ b/cli.py @@ -2830,6 +2830,11 @@ class HermesCLI: # process_command() when the user runs /exit --delete or /quit --delete. # Ported from google-gemini/gemini-cli#19332. self._delete_session_on_exit = False + # /update: when set, run() executes relaunch() after prompt_toolkit + # has fully exited and cleaned up terminal modes. Set by + # _handle_update_command() so the relaunch happens on the main thread, + # not the background process_loop thread. + self._pending_relaunch: list[str] | None = None self._last_ctrl_c_time = 0 self._clarify_state = None self._clarify_freetext = False @@ -7948,6 +7953,9 @@ class HermesCLI: self._handle_copy_command(cmd_original) elif canonical == "debug": self._handle_debug_command() + elif canonical == "update": + if self._handle_update_command(): + return False elif canonical == "paste": self._handle_paste_command() elif canonical == "image": @@ -9230,6 +9238,58 @@ class HermesCLI: args = SimpleNamespace(lines=200, expire=7, local=False) run_debug_share(args) + def _handle_update_command(self) -> bool: + """Handle /update — update Hermes Agent to the latest version. + + In the classic CLI this exits the session and relaunches as + ``hermes update`` so the user sees update output directly and gets + the new version on next launch. + + Returns ``True`` when the update was confirmed (caller should trigger + app exit so the relaunch is deferred to the main thread after + prompt_toolkit cleans up terminal modes). Returns ``False`` / falsy + when cancelled. + """ + from hermes_cli.config import is_managed, format_managed_message + + if is_managed(): + print(f" ✗ {format_managed_message('update Hermes Agent')}") + return False + + # Use the prompt_toolkit-native modal so the confirmation panel + # renders properly above the composer and avoids raw input() races + # with the prompt_toolkit event loop (same pattern as + # _confirm_destructive_slash). + choices = [ + ("once", "Update Now", "exit the current session and update Hermes Agent"), + ("cancel", "Cancel", "keep the current session"), + ] + raw = self._prompt_text_input_modal( + title="⚕ Update Hermes Agent", + detail="This will exit the current session and run `hermes update`.", + choices=choices, + ) + if raw is None: + print(" 🟡 /update cancelled.") + return False + choice = self._normalize_slash_confirm_choice(raw, choices) + if choice != "once": + print(" 🟡 /update cancelled.") + return False + + print() + print(" ⚕ Launching update...") + print() + + # Store the relaunch args so run() can exec them from the main thread + # after prompt_toolkit exits and restores terminal modes. Calling + # relaunch() directly here (from the process_loop daemon thread) would + # skip terminal cleanup on POSIX (execvp replaces the process mid-TUI) + # and only exit the worker thread on Windows (subprocess.run + + # sys.exit inside a non-main thread does not exit the process). + self._pending_relaunch = ["update"] + return True + def _show_usage(self): """Show rate limits (if available) and session token usage.""" if not self.agent: @@ -13937,6 +13997,15 @@ class HermesCLI: _run_cleanup() self._print_exit_summary() + # Deferred relaunch: /update sets _pending_relaunch so the exec + # happens here — after prompt_toolkit has exited and fully restored + # terminal modes — rather than from the background process_loop + # thread (which would skip terminal cleanup on POSIX and only exit + # the worker thread on Windows). + if getattr(self, '_pending_relaunch', None): + from hermes_cli.relaunch import relaunch + relaunch(self._pending_relaunch, preserve_inherited=False) + # ============================================================================ # Main Entry Point diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1e42fb9421e..9cff75e4579 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -207,8 +207,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True), CommandDef("image", "Attach a local image file for your next prompt", "Info", cli_only=True, args_hint=""), - CommandDef("update", "Update Hermes Agent to the latest version", "Info", - gateway_only=True), + CommandDef("update", "Update Hermes Agent to the latest version", "Info"), CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"), # Exit diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 871ad681f53..370e5eafc12 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1302,6 +1302,18 @@ def _launch_tui( except Exception: pass + # Exit code 42 = TUI requested an update. Relaunch as `hermes update` so + # the user sees update output directly and gets the new version. + # preserve_inherited=False ensures --tui and other flags are NOT carried + # into the update subcommand. + if code == 42: + from hermes_cli.relaunch import relaunch + + print() + print("⚕ Launching update...") + print() + relaunch(["update"], preserve_inherited=False) + sys.exit(code) diff --git a/tests/cli/test_update_command.py b/tests/cli/test_update_command.py new file mode 100644 index 00000000000..392c11d1b26 --- /dev/null +++ b/tests/cli/test_update_command.py @@ -0,0 +1,150 @@ +"""Tests for the /update slash command in the classic CLI and TUI launcher. + +Verifies that ``HermesCLI._handle_update_command`` correctly: +- Refuses to run under a managed install (Homebrew, Docker, etc.) +- Sets ``_pending_relaunch`` and returns ``True`` on confirmation +- Cancels cleanly on a "no"-shaped answer or unrecognized input +- Cancels cleanly when ``_prompt_text_input_modal`` returns None (timeout / + modal dismissed) + +Also verifies that ``hermes_cli.main._launch_tui`` correctly handles exit +code 42 (the TUI's signal to trigger an update) by calling +``relaunch(["update"], preserve_inherited=False)`` from the Python wrapper +side. The companion Vitest (``ui-tui/src/__tests__/createSlashHandler.test.ts``) +covers the TypeScript slash-handler that *emits* code 42; this file covers +the Python wrapper branch that *acts on* it. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from cli import HermesCLI + + +def _bound(fn, instance): + """Bind an unbound method to a stand-in instance.""" + return fn.__get__(instance, type(instance)) + + +def _make_self(modal_response): + """Build a minimal stand-in 'self' for ``_handle_update_command``. + + Uses the same SimpleNamespace pattern as ``test_destructive_slash_confirm`` + so we don't need a full ``HermesCLI`` construction. + ``_prompt_text_input_modal`` is stubbed to return *modal_response* + directly so tests can drive the entire confirmation branch without + touching stdin or prompt_toolkit internals. + """ + self_ = SimpleNamespace( + _app=None, + _pending_relaunch=None, + _prompt_text_input_modal=lambda **_kw: modal_response, + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_ + ) + return self_ + + +def _call(self_): + """Invoke the real ``_handle_update_command`` on the stub.""" + return HermesCLI._handle_update_command(self_) + + +# --------------------------------------------------------------------------- +# Managed-install guard +# --------------------------------------------------------------------------- + + +def test_managed_install_refuses_and_does_not_set_pending_relaunch(capsys): + """Under a managed install (brew/docker), /update prints a hint and + returns without setting ``_pending_relaunch``.""" + self_ = SimpleNamespace( + _app=None, + _pending_relaunch=None, + # Use pytest.fail so any unexpected modal invocation surfaces as a failure. + _prompt_text_input_modal=lambda **_kw: pytest.fail("Modal should not be called"), + ) + self_._normalize_slash_confirm_choice = _bound( + HermesCLI._normalize_slash_confirm_choice, self_ + ) + with ( + patch("hermes_cli.config.is_managed", return_value=True), + patch( + "hermes_cli.config.format_managed_message", + return_value="Use `brew upgrade hermes-agent` to update.", + ), + ): + result = _call(self_) + + out = capsys.readouterr().out + assert "brew upgrade hermes-agent" in out + assert self_._pending_relaunch is None + assert not result + + +# --------------------------------------------------------------------------- +# Confirmation proceeds only on recognised affirmative responses +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("answer", ["y", "Y", "yes", "YES", "1", "ok"]) +def test_affirmative_answer_sets_pending_relaunch_and_returns_true(answer, capsys): + """Recognised affirmative answers ("y", "yes", "1", "ok") set + ``_pending_relaunch = ["update"]`` and return ``True`` so the caller + (process_command) can trigger the main-thread app-exit path.""" + self_ = _make_self(modal_response=answer) + with patch("hermes_cli.config.is_managed", return_value=False): + result = _call(self_) + + assert self_._pending_relaunch == ["update"] + assert result is True + assert "Launching update" in capsys.readouterr().out + + +# --------------------------------------------------------------------------- +# Cancellation paths — _pending_relaunch must stay None +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("answer", ["n", "N", "no", "NO", " no "]) +def test_negative_answer_cancels(answer, capsys): + """Any "no"-shaped answer cancels without setting ``_pending_relaunch``.""" + self_ = _make_self(modal_response=answer) + with patch("hermes_cli.config.is_managed", return_value=False): + result = _call(self_) + + assert self_._pending_relaunch is None + assert not result + assert "Launching update" not in capsys.readouterr().out + + +def test_none_response_cancels(capsys): + """``None`` from the modal (timeout or dismiss) cancels cleanly.""" + self_ = _make_self(modal_response=None) + with patch("hermes_cli.config.is_managed", return_value=False): + result = _call(self_) + + assert self_._pending_relaunch is None + assert not result + + +@pytest.mark.parametrize("answer", ["nope", "cancel", "sure", "2", "3", "abort", ""]) +def test_unrecognized_or_cancel_input_cancels(answer, capsys): + """Unrecognised input and explicit "cancel" do not proceed. + + Previously the implementation treated any non-"n/no" answer as approval, + which meant typos like "nope" or "cancel" would launch the update. + Now only confirmed affirmative aliases ("y", "yes", "1", "ok") proceed; + everything else (including empty string, "cancel", typos) cancels. + """ + self_ = _make_self(modal_response=answer) + with patch("hermes_cli.config.is_managed", return_value=False): + result = _call(self_) + + assert self_._pending_relaunch is None + assert not result diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 25e478ccd2c..bfdea103ae3 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -523,6 +523,24 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod): assert env["NODE_ENV"] == "production" +def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod): + from unittest.mock import patch + + monkeypatch.setattr( + main_mod, + "_make_tui_argv", + lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")), + ) + monkeypatch.setattr(main_mod.subprocess, "call", lambda *args, **kwargs: 42) + + with patch("hermes_cli.relaunch.relaunch") as mock_relaunch: + with pytest.raises(SystemExit) as exc: + main_mod._launch_tui() + + assert exc.value.code == 42 + mock_relaunch.assert_called_once_with(["update"], preserve_inherited=False) + + def test_make_tui_argv_dev_prebuilds_hermes_ink(monkeypatch, main_mod, tmp_path): tui_dir = tmp_path / "ui-tui" tsx = tui_dir / "node_modules" / ".bin" / "tsx" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 24a34e75c17..43d0643c779 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2193,6 +2193,9 @@ def test_commands_catalog_filters_gateway_only_commands_and_keeps_status_visible assert "/deny" not in pairs assert "/sethome" not in pairs + assert "/update" in pairs + assert canon["/update"] == "/update" + assert "/topic" not in canon assert "/approve" not in canon assert "/deny" not in canon diff --git a/tui_gateway/server.py b/tui_gateway/server.py index de2888a6de7..a00b44320da 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4382,7 +4382,6 @@ _TUI_HIDDEN: frozenset[str] = frozenset( { "sethome", "set-home", - "update", "commands", "approve", "deny", diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 30263205c0d..8c1cee8bfd9 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -34,6 +34,21 @@ describe('createSlashHandler', () => { expect(ctx.gateway.gw.request).not.toHaveBeenCalled() }) + it('handles /update locally and exits with code 42 via dieWithCode', () => { + vi.useFakeTimers() + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/update')).toBe(true) + expect(ctx.gateway.gw.request).not.toHaveBeenCalled() + expect(ctx.transcript.sys).toHaveBeenCalledWith('exiting TUI to run update...') + + // Advance past the 100ms setTimeout + vi.advanceTimersByTime(150) + expect(ctx.session.dieWithCode).toHaveBeenCalledWith(42) + + vi.useRealTimers() + }) + it('routes /status to live session.status instead of slash worker', async () => { patchUiState({ sid: 'sid-abc' }) const rpc = vi.fn(() => Promise.resolve({ output: 'Hermes TUI Status' })) @@ -730,6 +745,7 @@ const buildComposer = () => ({ const buildGateway = () => ({ gw: { getLogTail: vi.fn(() => ''), + kill: vi.fn(), request: vi.fn(() => Promise.resolve({})) }, rpc: vi.fn(() => Promise.resolve({})) @@ -746,6 +762,7 @@ const buildLocal = () => ({ const buildSession = () => ({ closeSession: vi.fn(() => Promise.resolve(null)), die: vi.fn(), + dieWithCode: vi.fn(), guardBusySessionSwitch: vi.fn(() => false), newSession: vi.fn(), resetVisibleHistory: vi.fn(), diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9b9ceb6830e..b5ad2c0f3d3 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -277,6 +277,7 @@ export interface SlashHandlerContext { session: { closeSession: (targetSid?: null | string) => Promise die: () => void + dieWithCode: (code: number) => void guardBusySessionSwitch: (what?: string) => boolean newSession: (msg?: string, title?: string) => void resetVisibleHistory: (info?: null | SessionInfo) => void diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index c40307dc468..85f46028f55 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -92,6 +92,17 @@ export const coreCommands: SlashCommand[] = [ run: (_arg, ctx) => ctx.session.die() }, + { + help: 'update Hermes Agent to the latest version (exits TUI)', + name: 'update', + run: (_arg, ctx) => { + ctx.transcript.sys('exiting TUI to run update...') + // Exit code 42 signals the Python wrapper to exec `hermes update`. + // Use dieWithCode for proper cleanup (gateway kill + Ink unmount). + setTimeout(() => ctx.session.dieWithCode(42), 100) + } + }, + { aliases: ['scroll'], help: 'toggle mouse/wheel tracking [on|off|toggle]', diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 648cc1b69a0..7990b302ae3 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -377,6 +377,12 @@ export function useMainApp(gw: GatewayClient) { process.exit(0) }, [exit, gw]) + const dieWithCode = useCallback((code: number) => { + gw.kill() + exit() + process.exit(code) + }, [exit, gw]) + const session = useSessionLifecycle({ colsRef, composerActions, @@ -643,6 +649,7 @@ export function useMainApp(gw: GatewayClient) { session: { closeSession: session.closeSession, die, + dieWithCode, guardBusySessionSwitch: session.guardBusySessionSwitch, newSession: session.newSession, resetVisibleHistory: session.resetVisibleHistory,