feat(cli): add /update slash command to CLI and TUI (#23854)

* feat: add /update slash command to CLI and TUI

* test(cli): add Python tests for /update slash command

Co-authored-by: Cursor <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: austinpickett <260188+austinpickett@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-05-18 20:10:46 -04:00 committed by GitHub
parent 378bca1d2f
commit 2ef501e1f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 289 additions and 3 deletions

69
cli.py
View file

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

View file

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

View file

@ -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)

View file

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

View file

@ -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"

View file

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

View file

@ -4382,7 +4382,6 @@ _TUI_HIDDEN: frozenset[str] = frozenset(
{
"sethome",
"set-home",
"update",
"commands",
"approve",
"deny",

View file

@ -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(),

View file

@ -277,6 +277,7 @@ export interface SlashHandlerContext {
session: {
closeSession: (targetSid?: null | string) => Promise<unknown>
die: () => void
dieWithCode: (code: number) => void
guardBusySessionSwitch: (what?: string) => boolean
newSession: (msg?: string, title?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void

View file

@ -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]',

View file

@ -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,