feat(cli): ! shell mode — run a command without spending a model turn

This commit is contained in:
teknium1
2026-07-26 14:34:14 -07:00
committed by Teknium
parent 5686ea43d4
commit 9704ed86c1
4 changed files with 638 additions and 1 deletions

94
cli.py
View File

@@ -7028,6 +7028,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
app = getattr(self, "_app", None)
# `!<command>` shell mode, checked before slash dispatch — matches the
# Enter path in the input loop so an editor-saved bang command runs
# locally instead of being sent to the agent.
try:
if self.handle_bang_shell(text):
self._reset_input_buffer(buffer)
if app is not None:
app.invalidate()
return
except Exception as exc:
_cprint(f" {_DIM}Shell command failed: {exc}{_RST}")
self._reset_input_buffer(buffer)
if app is not None:
app.invalidate()
return
# Slash commands: dispatch directly, same as the Enter handler's
# _looks_like_slash_command branch.
if _looks_like_slash_command(text):
@@ -9588,6 +9604,64 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
"""Print through the active command-safe console."""
self._output_console().print(*args, **kwargs)
def handle_bang_shell(self, text: str) -> bool:
"""Run a ``!<command>`` submission. Returns True when it was handled.
Dispatched from the input loop BEFORE slash-command routing and before
anything is queued for the agent, so a bang command never becomes a
turn: no user message, no assistant message, no tool result touches
``self.conversation_history``. That is what makes ``!`` free — zero
tokens, and role alternation / prompt caching are untouched by
construction. The invariant is covered by
tests/cli/test_bang_shell_mode.py.
Returns False when the text is not a bang command or when bang mode is
disabled for this context (gateway/cron), letting the caller fall
through to normal routing.
"""
from hermes_cli.bang_shell import (
USAGE_HINT,
bang_shell_enabled,
check_bang_approval,
is_bang_command,
parse_bang_command,
resolve_bang_cwd,
run_bang_command,
)
if not is_bang_command(text):
return False
if not bang_shell_enabled():
# Gateway / cron / API contexts: no composer, no human at a
# keyboard, and those users already have their own shells. Let the
# text route normally rather than becoming remote execution.
return False
command = parse_bang_command(text)
if not command:
# Bare `!` — show what the feature does instead of running an
# empty shell or sending "!" to the model.
self._console_print(f"[dim]{USAGE_HINT}[/]")
return True
approval = check_bang_approval(command)
if not approval.get("approved"):
message = approval.get("message") or (
f"Command denied: {approval.get('description', 'flagged as dangerous')}"
)
self._console_print(f"[bold red]{_escape(str(message))}[/]")
return True
cwd = resolve_bang_cwd(getattr(self, "session_id", None))
exit_code = run_bang_command(
command,
cwd=cwd,
writer=lambda line: self._console_print(_rich_text_from_ansi(line)),
)
if exit_code:
self._console_print(f"[dim]! exited {exit_code}[/]")
return True
@staticmethod
def _resolve_personality_prompt(value) -> str:
"""Accept string or dict personality value; return system prompt string."""
@@ -15131,7 +15205,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
event.app.invalidate()
# Bundle text + images as a tuple when images are present
payload = (text, images) if images else text
if self._agent_running and not (text and _looks_like_slash_command(text)):
# A bang command is treated like a slash command while the
# agent is busy: it must never be routed into steer/redirect
# (which would inject `!git status` into the model's context as
# a prompt). It queues and runs locally once the loop drains.
_is_local_dispatch = bool(text) and (
_looks_like_slash_command(text) or text.strip().startswith("!")
)
if self._agent_running and not _is_local_dispatch:
_effective_mode = self.busy_input_mode
redirected = False
if _effective_mode == "steer":
@@ -17132,6 +17213,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
):
continue
# `!<command>` shell mode — run it here and loop back to
# idle. Checked BEFORE slash routing and before the chat
# path so nothing enters conversation history and no model
# turn is spent. See handle_bang_shell().
if (
not _file_drop
and isinstance(user_input, str)
and self.handle_bang_shell(user_input)
):
continue
if not _file_drop and isinstance(user_input, str) and _looks_like_slash_command(user_input):
_cprint(f"\n⚙️ {user_input}")
try:

212
hermes_cli/bang_shell.py Normal file
View File

@@ -0,0 +1,212 @@
"""``!<command>`` shell mode for the interactive CLI.
Typing ``!git status`` at the composer runs the command directly in the
session's working directory. The model is never invoked: no user message, no
assistant message, no tool result enters the conversation history, so a bang
command costs zero tokens and cannot perturb role alternation or the prompt
cache.
A user-typed command still goes through the SAME dangerous-pattern approval
gate the terminal tool uses (``tools.approval.check_all_command_guards``),
reached here through ``tools.terminal_tool._check_all_guards`` so the CLI
approval callback and Docker host-access handling behave identically.
CLI-only by design: gateway/API/cron sessions have their own shells and no
composer, so :func:`bang_shell_enabled` gates the feature off there.
"""
from __future__ import annotations
import os
import subprocess
from typing import Optional
USAGE_HINT = "Usage: !<command> — run a shell command without spending a model turn (e.g. !git status)"
# Bang commands are interactive convenience, not agent work. Keep the ceiling
# well under the terminal tool's foreground cap: a user watching output can
# Ctrl+C, and an accidental `!sleep 999` should not wedge the composer.
DEFAULT_TIMEOUT = 120
def is_bang_command(text: Optional[str]) -> bool:
"""Return True when *text* is a ``!`` shell-mode submission.
Only a leading ``!`` (after surrounding whitespace) counts. A line that
merely *contains* ``!`` mid-text (``fix the bug!``, ``echo hi!``) is an
ordinary prompt and must reach the agent untouched.
"""
if not isinstance(text, str):
return False
return text.strip().startswith("!")
def parse_bang_command(text: str) -> str:
"""Return the shell command inside a bang submission (``""`` when bare).
``!ls`` → ``ls``; ``! ls -la`` → ``ls -la``; ``!!`` → ``!`` (a literal
second bang is part of the command, e.g. history expansion the user's
shell will handle); ``!`` alone → ``""``.
"""
if not isinstance(text, str):
return ""
stripped = text.strip()
if not stripped.startswith("!"):
return ""
return stripped[1:].strip()
def bang_shell_enabled() -> bool:
"""True only for interactive local CLI sessions.
Gateway, API, and cron sessions never reach the composer and their users
already have a shell; running arbitrary commands for them would be a
remote-execution surface with no approving human at the keyboard.
"""
try:
from utils import env_var_enabled
except Exception: # pragma: no cover - utils is always importable in-tree
def env_var_enabled(name, default=""): # type: ignore[misc]
return str(os.getenv(name, default)).strip().lower() in {"1", "true", "yes", "on"}
if env_var_enabled("HERMES_GATEWAY_SESSION"):
return False
if env_var_enabled("HERMES_CRON_SESSION"):
return False
if (os.getenv("HERMES_SESSION_PLATFORM") or "").strip():
return False
return True
def resolve_bang_cwd(session_key: Optional[str] = None) -> Optional[str]:
"""Return the directory a bang command should run in.
Mirrors the terminal tool's resolution order so ``!pwd`` matches where the
agent's own commands land: the session's recorded ``cd`` state first
(``terminal_tool.get_session_cwd``, updated after every agent command),
then the configured ``TERMINAL_CWD``/backend default. ``None`` means "let
the subprocess inherit the process cwd".
"""
try:
from tools.terminal_tool import _get_env_config, get_session_cwd
recorded = get_session_cwd(session_key)
if recorded:
return recorded
configured = (_get_env_config() or {}).get("cwd")
if configured:
return configured
except Exception:
pass
return None
def check_bang_approval(command: str) -> dict:
"""Run *command* through the terminal tool's approval gate.
Reuses ``tools.terminal_tool._check_all_guards`` — the exact function
``terminal_tool()`` calls before executing anything — so the hardline
blocklist, user deny rules, tirith findings, and the interactive
dangerous-command prompt all apply to user-typed bang commands too. A
command the agent would need approval for still needs approval when the
user types it; ``!`` is a latency/cost shortcut, not a security bypass.
Returns the gate's decision dict (``{"approved": bool, "message": ...}``).
Falls back to *approved* only when the gate itself cannot be imported,
which would mean a broken install rather than a policy decision.
"""
try:
from tools.terminal_tool import _check_all_guards
except Exception:
return {"approved": True, "message": None}
# env_type mirrors the terminal tool: bang commands always run locally in
# the CLI process, never inside a remote/sandbox backend.
return _check_all_guards(command, "local", has_host_access=False)
def _bang_env() -> dict:
"""Environment for a bang command, with Hermes-managed secrets filtered.
The CLI process holds every configured provider API key in ``os.environ``.
A bang command is user-typed, but it can still be a third-party script, so
reuse the same sanitizer ``quick_commands`` and the local terminal backend
use rather than handing the whole keyring to an arbitrary subprocess.
"""
try:
from tools.environments.local import _sanitize_subprocess_env
return _sanitize_subprocess_env(os.environ.copy())
except Exception:
return os.environ.copy()
def run_bang_command(
command: str,
*,
cwd: Optional[str] = None,
timeout: int = DEFAULT_TIMEOUT,
writer=None,
) -> int:
"""Execute *command* and stream its output, returning the exit code.
stdout and stderr are merged and written through *writer* (defaults to
``print``) as they arrive, so long-running commands show progress instead
of buffering to the end. Nothing is returned to a caller for insertion
into conversation history — the output exists only on the user's terminal.
"""
emit = writer or (lambda line: print(line, end="" if line.endswith("\n") else "\n"))
run_cwd = cwd if (cwd and os.path.isdir(os.path.expanduser(cwd))) else None
if run_cwd:
run_cwd = os.path.expanduser(run_cwd)
try:
from hermes_cli._subprocess_compat import windows_hide_flags
creationflags = windows_hide_flags()
except Exception:
creationflags = 0
try:
# shell=True is intentional and matches quick_commands: this is a
# command the human typed into their own composer, not model output.
proc = subprocess.Popen(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
cwd=run_cwd,
env=_bang_env(),
creationflags=creationflags,
)
except Exception as exc:
emit(f"!: failed to run command: {exc}")
return 127
try:
if proc.stdout is not None:
for line in proc.stdout:
emit(line.rstrip("\n"))
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
emit(f"!: command timed out after {timeout}s")
return 124
except KeyboardInterrupt:
# Ctrl+C interrupts the command, not the Hermes session.
proc.kill()
emit("!: interrupted")
return 130
finally:
try:
if proc.stdout is not None:
proc.stdout.close()
except Exception:
pass
return int(proc.returncode or 0)

View File

@@ -0,0 +1,313 @@
"""Tests for `!<command>` shell mode in the interactive CLI.
Covers bang detection/parsing, that the terminal tool's approval gate is
invoked for a dangerous command, that non-zero exit codes surface, and the
load-bearing invariant: a bang command leaves conversation_history
byte-identical because it never becomes a turn.
"""
import copy
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from hermes_cli.bang_shell import (
USAGE_HINT,
bang_shell_enabled,
is_bang_command,
parse_bang_command,
run_bang_command,
)
# ── detection / parsing ────────────────────────────────────────────────────
class TestBangDetection:
@pytest.mark.parametrize("text", [
"!ls",
"!git status",
" !ls -la", # leading whitespace still counts
"! spaced out", # `!` followed by spaces
"!!", # double bang
"!", # bare bang
])
def test_leading_bang_is_bang(self, text):
assert is_bang_command(text) is True
@pytest.mark.parametrize("text", [
"fix the bug!", # trailing `!` in prose
"echo hi! please", # mid-text `!`
"run this: !ls", # `!` not at the start
"/help",
"hello world",
"",
" ",
None,
123,
])
def test_non_leading_bang_is_not_bang(self, text):
assert is_bang_command(text) is False
@pytest.mark.parametrize("text,expected", [
("!ls", "ls"),
("!git status", "git status"),
(" !ls -la ", "ls -la"),
("! echo hi", "echo hi"),
("!!", "!"), # second bang belongs to the command
("!!ls", "!ls"),
("!", ""), # bare bang → no command
("! ", ""),
("not a bang", ""),
])
def test_parse_strips_exactly_one_bang(self, text, expected):
assert parse_bang_command(text) == expected
class TestBangContextGating:
"""Bang mode is CLI-only — gateway/cron users have their own shells."""
def test_enabled_in_plain_cli(self, monkeypatch):
for var in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
"HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(var, raising=False)
assert bang_shell_enabled() is True
@pytest.mark.parametrize("var,value", [
("HERMES_GATEWAY_SESSION", "1"),
("HERMES_CRON_SESSION", "true"),
("HERMES_SESSION_PLATFORM", "discord"),
])
def test_disabled_in_non_cli_contexts(self, monkeypatch, var, value):
for v in ("HERMES_GATEWAY_SESSION", "HERMES_CRON_SESSION",
"HERMES_SESSION_PLATFORM"):
monkeypatch.delenv(v, raising=False)
monkeypatch.setenv(var, value)
assert bang_shell_enabled() is False
# ── execution ──────────────────────────────────────────────────────────────
class TestBangExecution:
def test_output_is_streamed_to_writer(self):
lines = []
code = run_bang_command("echo bang-one; echo bang-two", writer=lines.append)
assert code == 0
assert "bang-one" in lines
assert "bang-two" in lines
def test_stderr_is_merged_into_output(self):
lines = []
run_bang_command("echo to-stderr >&2", writer=lines.append)
assert "to-stderr" in lines
def test_nonzero_exit_code_is_returned(self):
lines = []
code = run_bang_command("exit 42", writer=lines.append)
assert code == 42
def test_runs_in_requested_cwd(self, tmp_path):
lines = []
code = run_bang_command("pwd", cwd=str(tmp_path), writer=lines.append)
assert code == 0
# macOS resolves /tmp through /private, so compare realpaths.
assert os.path.realpath(lines[-1].strip()) == os.path.realpath(str(tmp_path))
def test_missing_cwd_falls_back_without_crashing(self, tmp_path):
lines = []
code = run_bang_command(
"echo ok", cwd=str(tmp_path / "does-not-exist"), writer=lines.append
)
assert code == 0
assert "ok" in lines
# ── CLI handler: approval gate, usage hint, exit codes ─────────────────────
def _make_cli(history=None):
"""Build a HermesCLI shell with only what handle_bang_shell touches."""
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli.config = {}
cli.console = MagicMock()
cli.agent = None
cli.session_id = "test-session"
cli.conversation_history = [] if history is None else history
cli._app = None
return cli
def _printed(cli):
"""All text the CLI printed, flattened to plain strings."""
out = []
for call in cli.console.print.call_args_list:
if not call.args:
continue
arg = call.args[0]
out.append(getattr(arg, "plain", None) or str(arg))
return out
class TestBangHandlerDispatch:
def test_non_bang_text_is_not_handled(self):
cli = _make_cli()
assert cli.handle_bang_shell("please fix this bug!") is False
assert cli.handle_bang_shell("/help") is False
def test_bare_bang_prints_usage_and_runs_nothing(self):
cli = _make_cli()
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!") is True
runner.assert_not_called()
assert any(USAGE_HINT in line for line in _printed(cli))
def test_command_output_is_printed(self):
cli = _make_cli()
assert cli.handle_bang_shell("!echo hello-bang") is True
assert any("hello-bang" in line for line in _printed(cli))
def test_nonzero_exit_is_surfaced_to_the_user(self):
cli = _make_cli()
assert cli.handle_bang_shell("!exit 3") is True
assert any("exited 3" in line for line in _printed(cli))
def test_zero_exit_prints_no_exit_line(self):
cli = _make_cli()
cli.handle_bang_shell("!true")
assert not any("exited" in line for line in _printed(cli))
def test_disabled_context_falls_through(self, monkeypatch):
"""Gateway sessions must not execute bang commands."""
cli = _make_cli()
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!echo nope") is False
runner.assert_not_called()
class TestBangApprovalGate:
"""A user-typed command still goes through the terminal tool's gate."""
def test_approval_gate_is_invoked_for_a_dangerous_command(self):
cli = _make_cli()
gate = MagicMock(return_value={"approved": True, "message": None})
with patch("tools.terminal_tool._check_all_guards", gate), \
patch("hermes_cli.bang_shell.run_bang_command", return_value=0):
cli.handle_bang_shell("!rm -rf ./build")
gate.assert_called_once()
assert gate.call_args.args[0] == "rm -rf ./build"
def test_gate_is_invoked_for_every_command_not_just_dangerous_ones(self):
cli = _make_cli()
gate = MagicMock(return_value={"approved": True, "message": None})
with patch("tools.terminal_tool._check_all_guards", gate), \
patch("hermes_cli.bang_shell.run_bang_command", return_value=0):
cli.handle_bang_shell("!ls")
gate.assert_called_once()
def test_denied_command_is_not_executed(self):
cli = _make_cli()
gate = MagicMock(return_value={
"approved": False,
"message": "Command denied: recursive delete",
})
with patch("tools.terminal_tool._check_all_guards", gate), \
patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!rm -rf /important") is True
runner.assert_not_called()
assert any("denied" in line.lower() for line in _printed(cli))
def test_real_gate_blocks_a_hardline_command(self):
"""End-to-end through the real approval module — no execution."""
cli = _make_cli()
with patch("hermes_cli.bang_shell.run_bang_command") as runner:
assert cli.handle_bang_shell("!rm -rf /") is True
runner.assert_not_called()
# ── THE load-bearing invariant ─────────────────────────────────────────────
_SEED_HISTORY = [
{"role": "system", "content": "You are Hermes."},
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there."},
{"role": "user", "content": "list the files"},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "terminal", "arguments": '{"command": "ls"}'},
}],
},
{"role": "tool", "tool_call_id": "call_1", "content": "a.py b.py"},
]
class TestBangLeavesHistoryByteIdentical:
"""Nothing about a bang command may enter conversation history.
This is what makes `!` free (zero tokens, prompt cache untouched) and
unable to break role alternation. Compared as serialized JSON so an added,
removed, or mutated message anywhere in the list fails the assertion.
"""
@pytest.mark.parametrize("submission", [
"!echo history-check", # succeeds
"!exit 7", # non-zero exit
"!", # bare bang / usage hint
"!definitely-not-a-real-binary-xyz", # command not found
])
def test_history_is_byte_identical_before_and_after(self, submission):
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
before = json.dumps(cli.conversation_history, sort_keys=True)
cli.handle_bang_shell(submission)
after = json.dumps(cli.conversation_history, sort_keys=True)
assert after == before, (
f"bang command {submission!r} mutated conversation history"
)
assert len(cli.conversation_history) == len(_SEED_HISTORY)
def test_history_unchanged_when_command_is_denied(self):
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
before = json.dumps(cli.conversation_history, sort_keys=True)
gate = MagicMock(return_value={"approved": False, "message": "nope"})
with patch("tools.terminal_tool._check_all_guards", gate):
cli.handle_bang_shell("!rm -rf /important")
assert json.dumps(cli.conversation_history, sort_keys=True) == before
def test_agent_is_never_invoked(self):
"""No model turn: the agent object is not touched at all."""
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
agent = MagicMock()
cli.agent = agent
cli.handle_bang_shell("!echo no-model-turn")
# No chat/steer/run/redirect call of any kind.
assert agent.mock_calls == []
assert json.dumps(cli.conversation_history, sort_keys=True) == json.dumps(
_SEED_HISTORY, sort_keys=True
)
def test_role_alternation_is_preserved_across_many_bangs(self):
cli = _make_cli(history=copy.deepcopy(_SEED_HISTORY))
before = json.dumps(cli.conversation_history, sort_keys=True)
for _ in range(5):
cli.handle_bang_shell("!echo repeated")
assert json.dumps(cli.conversation_history, sort_keys=True) == before
roles = [m["role"] for m in cli.conversation_history]
assert roles == ["system", "user", "assistant", "user", "assistant", "tool"]

View File

@@ -112,9 +112,29 @@ When resuming a previous session (`hermes -c` or `hermes --resume <id>`), a "Pre
| `Ctrl+D` | Exit |
| `Ctrl+Z` | Suspend Hermes to background (Unix only). Run `fg` in the shell to resume. |
| `Tab` | Accept auto-suggestion (ghost text) or autocomplete slash commands |
| `!<command>` | **Shell mode** — run a shell command yourself without spending a model turn (e.g. `!git status`, `!pytest -x`). See below. |
**Multiline paste preview.** When you paste a multi-line block, the CLI echoes a compact single-line preview (`[pasted: 47 lines, 1,842 chars — press Enter to send]`) instead of dumping the whole payload into the scrollback. The full content is still what gets sent; this is just display polish.
### `!` Shell Mode
Start a line with `!` to run it as a shell command instead of sending it to the agent:
```
> !git status
> !ls -la
> !pytest -x tests/cli
```
- **Zero cost.** The model is never invoked — no API call, no tokens, no latency.
- **Nothing enters the conversation.** The command and its output are not added to history, so your context stays clean and the prompt cache is untouched.
- **Runs where the agent's `terminal` tool runs.** Uses the session working directory, so `!pwd` matches what the agent would see.
- **Approvals still apply.** A dangerous command (`rm -rf`, writes to `~/.hermes/config.yaml`, etc.) goes through the same approval prompt the agent's `terminal` tool uses. `!` is a cost/latency shortcut, not a security bypass.
- **Non-zero exits are shown.** A failing command prints `! exited <code>` after its output.
- `!` on its own prints a one-line usage reminder.
Shell mode is CLI-only. Gateway platforms (Discord, Telegram, Slack) and cron runs ignore it — those users already have their own shells.
**Markdown stripping in final responses.** The CLI strips the most verbose markdown fences and `**bold**` / `*italic*` wrappers from *final* agent replies so they render as readable terminal prose rather than raw source. Code blocks and lists are preserved. This does not affect gateway platforms or tool results — they keep their markdown for native rendering.
## Slash Commands