diff --git a/cli.py b/cli.py index 714fd96ad51..cda78e89f98 100644 --- a/cli.py +++ b/cli.py @@ -2119,6 +2119,11 @@ class HermesCLI: self._pending_input = queue.Queue() self._interrupt_queue = queue.Queue() self._should_exit = False + # /exit --delete: when True, the current session's SQLite history and + # on-disk transcripts are deleted during shutdown. Set by + # process_command() when the user runs /exit --delete or /quit --delete. + # Ported from google-gemini/gemini-cli#19332. + self._delete_session_on_exit = False self._last_ctrl_c_time = 0 self._clarify_state = None self._clarify_freetext = False @@ -6083,6 +6088,16 @@ class HermesCLI: canonical = _cmd_def.name if _cmd_def else _base_word if canonical in ("quit", "exit", "q"): + # Parse --delete flag: /exit --delete also removes the current + # session's transcripts + SQLite history. Ported from + # google-gemini/gemini-cli#19332. + _rest = cmd_original.split(None, 1) + _args = (_rest[1] if len(_rest) > 1 else "").strip().lower() + if _args in ("--delete", "-d"): + self._delete_session_on_exit = True + elif _args: + _cprint(f" {_DIM}✗ Unknown argument: {_escape(_args)}. Use /exit --delete to also remove session history.{_RST}") + return True return False elif canonical == "help": self.show_help() @@ -11174,6 +11189,19 @@ class HermesCLI: self._session_db.end_session(self.agent.session_id, "cli_close") except (Exception, KeyboardInterrupt) as e: logger.debug("Could not close session in DB: %s", e) + # /exit --delete: also remove the current session's transcripts + # and SQLite history. Ported from google-gemini/gemini-cli#19332. + if getattr(self, '_delete_session_on_exit', False): + try: + from hermes_constants import get_hermes_home as _ghh + _sessions_dir = _ghh() / "sessions" + _sid = self.agent.session_id + if self._session_db.delete_session(_sid, sessions_dir=_sessions_dir): + _cprint(f" {_DIM}✓ Session {_escape(_sid)} deleted{_RST}") + else: + _cprint(f" {_DIM}✗ Session {_escape(_sid)} not found for deletion{_RST}") + except (Exception, KeyboardInterrupt) as e: + logger.debug("Could not delete session on exit: %s", e) # Plugin hook: on_session_end — safety net for interrupted exits. # run_conversation() already fires this per-turn on normal completion, # so only fire here if the agent was mid-turn (_agent_running) when diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 7e3e14c5409..34d1725eefc 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -183,8 +183,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"), # Exit - CommandDef("quit", "Exit the CLI", "Exit", - cli_only=True, aliases=("exit",)), + CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit", + cli_only=True, aliases=("exit",), args_hint="[--delete]"), ] diff --git a/tests/cli/test_exit_delete_session.py b/tests/cli/test_exit_delete_session.py new file mode 100644 index 00000000000..dd4fe8d5aa1 --- /dev/null +++ b/tests/cli/test_exit_delete_session.py @@ -0,0 +1,119 @@ +"""Tests for `/exit --delete` and `/quit --delete` session deletion. + +Ports the behavior from google-gemini/gemini-cli#19332: running `/exit` or +`/quit` with the `--delete` flag arms a one-shot `_delete_session_on_exit` +flag that the CLI shutdown path uses to remove the current session from +SQLite + on-disk transcripts before exit. +""" + +from unittest.mock import MagicMock + + +def _make_cli(): + """Bare HermesCLI suitable for process_command() tests. + + Uses ``__new__`` to skip the heavy __init__; only sets the attributes + the /exit branch touches. + """ + from cli import HermesCLI + cli = HermesCLI.__new__(HermesCLI) + cli.config = {} + cli.console = MagicMock() + cli.agent = None + cli.conversation_history = [] + cli.session_id = "test-session" + cli._delete_session_on_exit = False + return cli + + +class TestExitDeleteFlag: + def test_plain_exit_does_not_arm_delete(self): + cli = _make_cli() + result = cli.process_command("/exit") + assert result is False + assert cli._delete_session_on_exit is False + + def test_plain_quit_does_not_arm_delete(self): + cli = _make_cli() + result = cli.process_command("/quit") + assert result is False + assert cli._delete_session_on_exit is False + + def test_exit_delete_arms_flag(self): + cli = _make_cli() + result = cli.process_command("/exit --delete") + assert result is False + assert cli._delete_session_on_exit is True + + def test_quit_delete_arms_flag(self): + cli = _make_cli() + result = cli.process_command("/quit --delete") + assert result is False + assert cli._delete_session_on_exit is True + + def test_exit_delete_short_form(self): + """`-d` is a convenience alias for `--delete`.""" + cli = _make_cli() + result = cli.process_command("/exit -d") + assert result is False + assert cli._delete_session_on_exit is True + + def test_quit_alias_q_is_not_quit(self): + """`/q` is the alias for `/queue`, not `/quit`. This test documents + that /q --delete does NOT arm session deletion — it would dispatch + to /queue instead.""" + cli = _make_cli() + cli._pending_input = __import__("queue").Queue() + # /q with no args shows a usage error and keeps the CLI running. + result = cli.process_command("/q") + assert result is not False # queue command doesn't exit + assert cli._delete_session_on_exit is False + + def test_delete_flag_is_case_insensitive(self): + cli = _make_cli() + result = cli.process_command("/exit --DELETE") + assert result is False + assert cli._delete_session_on_exit is True + + def test_delete_flag_trims_whitespace(self): + cli = _make_cli() + result = cli.process_command("/exit --delete ") + assert result is False + assert cli._delete_session_on_exit is True + + def test_unknown_exit_argument_does_not_exit(self): + """Unrecognised args should NOT exit the CLI — they surface an + error message and stay in the session. This prevents accidental + session destruction from typos like `/exit -delete`.""" + cli = _make_cli() + result = cli.process_command("/exit --delte") + # process_command returns True = keep running + assert result is True + assert cli._delete_session_on_exit is False + + def test_unknown_exit_argument_prints_help(self): + cli = _make_cli() + # _cprint goes through module-level print, so capture via console. + # We can't patch _cprint directly without import juggling; the + # previous assertion already proves the unknown-arg branch is + # reached (result True + flag False). + result = cli.process_command("/exit garbage") + assert result is True + assert cli._delete_session_on_exit is False + + +class TestCommandRegistry: + def test_quit_command_advertises_delete_flag(self): + """The CommandDef args_hint should surface `--delete` in /help and + CLI autocomplete.""" + from hermes_cli.commands import resolve_command + cmd = resolve_command("quit") + assert cmd is not None + assert cmd.args_hint == "[--delete]" + + def test_exit_alias_resolves_to_quit_with_hint(self): + from hermes_cli.commands import resolve_command + cmd = resolve_command("exit") + assert cmd is not None + assert cmd.name == "quit" + assert cmd.args_hint == "[--delete]" diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index d88705eec50..1a9cd8fcb6d 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -87,7 +87,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | Command | Description | |---------|-------------| -| `/quit` | Exit the CLI (also: `/exit`). See note on `/q` under `/queue` above. | +| `/quit` | Exit the CLI (also: `/exit`). See note on `/q` under `/queue` above. Pass `--delete` (or `-d`) — e.g. `/exit --delete` — to also permanently remove the current session's SQLite history and on-disk transcripts before exiting. Useful for privacy-sensitive or one-off tasks. | ### Dynamic CLI slash commands