Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8372,6 +8372,8 @@ def process_command(self, command: str) -> bool:
print(f"Plugin system error: {e}")
elif canonical == "rollback":
self._handle_rollback_command(cmd_original)
elif canonical == "diff":
self._handle_diff_command(cmd_original)
elif canonical == "snapshot":
self._handle_snapshot_command(cmd_original)
elif canonical == "stop":
Expand Down
3 changes: 3 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8734,6 +8734,9 @@ async def _do_undo():
if canonical == "rollback":
return await self._handle_rollback_command(event)

if canonical == "diff":
return await self._handle_diff_command(event)

if canonical == "background":
return await self._handle_background_command(event)

Expand Down
58 changes: 58 additions & 0 deletions gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2263,6 +2263,64 @@ async def _handle_rollback_command(self, event: MessageEvent) -> str:
)
return t("gateway.rollback.restore_failed", error=result["error"])

async def _handle_diff_command(self, event: MessageEvent) -> str:
"""Handle /diff - show everything Hermes has changed in this directory.

Cumulative diff from the earliest retained checkpoint (the pre-edit
baseline) to the current working tree. ``/diff --stat`` shows just the
summary. Complements ``/rollback diff <N>`` (single-checkpoint preview).
"""
from gateway.run import _hermes_home
from tools.checkpoint_manager import CheckpointManager

# Read checkpoint config from config.yaml (mirrors _handle_rollback_command).
cp_cfg = {}
try:
import yaml as _y
_cfg_path = _hermes_home / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_data = _y.safe_load(_f) or {}
cp_cfg = _data.get("checkpoints", {})
if isinstance(cp_cfg, bool):
cp_cfg = {"enabled": cp_cfg}
except Exception:
pass

if not cp_cfg.get("enabled", False):
return t("gateway.diff.not_enabled")

mgr = CheckpointManager(
enabled=True,
max_snapshots=cp_cfg.get("max_snapshots", 50),
max_total_size_mb=cp_cfg.get("max_total_size_mb", 500),
max_file_size_mb=cp_cfg.get("max_file_size_mb", 10),
)

cwd = os.getenv("TERMINAL_CWD", str(Path.home()))
stat_only = event.get_command_args().strip().lower() in {"--stat", "stat"}

result = mgr.session_diff(cwd)
if not result.get("success"):
return t("gateway.diff.failed", error=result.get("error", "Could not generate diff"))

stat = result.get("stat", "")
diff = result.get("diff", "")
if result.get("empty") or (not stat and not diff):
return t("gateway.diff.no_changes")

out: list[str] = []
if stat:
out.append(stat)
if not stat_only and diff:
diff_lines = diff.splitlines()
if len(diff_lines) > 60:
diff = "\n".join(diff_lines[:60]) + (
f"\n... ({len(diff_lines) - 60} more lines - use /diff --stat for a summary)"
)
out.append(f"```diff\n{diff}\n```")
return "\n\n".join(out)

async def _handle_background_command(self, event: MessageEvent) -> str:
"""Handle /background <prompt> — run a prompt in a separate background session.

Expand Down
52 changes: 52 additions & 0 deletions hermes_cli/cli_commands_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,58 @@ def _handle_rollback_command(self, command: str):
else:
print(f" ❌ {result['error']}")

def _handle_diff_command(self, command: str):
"""Handle /diff - show everything Hermes has changed in this directory.

Unlike ``/rollback diff <N>`` (which previews changes since one chosen
checkpoint), ``/diff`` shows the cumulative diff from the earliest
retained checkpoint - the pre-edit baseline - to the current working
tree, answering "what has Hermes changed here?" in one view.

Syntax:
/diff - full cumulative diff
/diff --stat - summary (changed files + insertions/deletions)
"""
if not hasattr(self, 'agent') or not self.agent:
print(" No active agent session.")
return

mgr = self.agent._checkpoint_mgr
if not mgr.enabled:
print(" Checkpoints are not enabled.")
print(" Enable with: hermes --checkpoints")
print(" Or in config.yaml: checkpoints: { enabled: true }")
return

cwd = os.getenv("TERMINAL_CWD", os.getcwd())
parts = command.split()
stat_only = any(a.lower() in {"--stat", "stat"} for a in parts[1:])

result = mgr.session_diff(cwd)
if not result.get("success"):
print(f" {result.get('error', 'Could not generate diff')}")
return

stat = result.get("stat", "")
diff = result.get("diff", "")
if result.get("empty") or (not stat and not diff):
print(" No changes - Hermes hasn't edited any files here yet.")
return

if stat:
print(f"\n{stat}")
if stat_only:
return
if diff:
# Limit diff output to avoid flooding the terminal (mirrors
# /rollback diff). Full diff is always available via git.
diff_lines = diff.splitlines()
if len(diff_lines) > 80:
print("\n".join(diff_lines[:80]))
print(f"\n ... ({len(diff_lines) - 80} more lines - run /diff --stat for a summary)")
else:
print(f"\n{diff}")

def _handle_snapshot_command(self, command: str):
"""Handle /snapshot — lightweight state snapshots for Hermes config/state.

Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ class CommandDef:
args_hint="[number]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]"),
CommandDef("diff", "Show everything Hermes has changed here (cumulative git diff)", "Session",
args_hint="[--stat]"),
CommandDef("stop", "Kill all running background processes", "Session"),
CommandDef("approve", "Approve a pending dangerous command", "Session",
gateway_only=True, args_hint="[session|always]"),
Expand Down
5 changes: 5 additions & 0 deletions locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,11 @@ gateway:
restored: "✅ Restored to checkpoint {hash}: {reason}\nA pre-rollback snapshot was saved automatically."
restore_failed: "❌ {error}"

diff:
not_enabled: "Checkpoints are not enabled, so there's nothing to diff.\nEnable in config.yaml:\n```\ncheckpoints:\n enabled: true\n```"
no_changes: "No changes - Hermes hasn't edited any files here yet."
failed: "{error}"

set_home:
save_failed: "Failed to save home channel: {error}"
success: "✅ Home channel set to **{name}** (ID: {chat_id}).\nCron jobs and cross-platform messages will be delivered here."
Expand Down
1 change: 1 addition & 0 deletions skills/autonomous-ai-agents/hermes-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ The registry of record is `hermes_cli/commands.py` — every consumer
/compress Manually compress context
/stop Kill background processes
/rollback [N] Restore filesystem checkpoint
/diff [--stat] Show cumulative diff of everything changed this session
/snapshot [sub] Create or restore state snapshots of Hermes config/state (CLI)
/background <prompt> Run prompt in background
/queue <prompt> Queue for next turn
Expand Down
107 changes: 107 additions & 0 deletions tests/gateway/test_diff_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""End-to-end tests for the gateway ``/diff`` command.

Exercises the real handler against a real checkpoint store and git, proving
the messaging surface returns the cumulative working-tree diff (and degrades
to friendly messages when checkpoints are off or nothing has changed).
"""

import shutil

import pytest

import gateway.run as gateway_run
import tools.checkpoint_manager as cpm
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource

pytestmark = pytest.mark.skipif(
shutil.which("git") is None, reason="git required for checkpoint diffs"
)


def _runner():
runner = object.__new__(gateway_run.GatewayRunner)
runner.session_store = None
runner.config = None
return runner


def _event(text: str) -> MessageEvent:
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="user-1",
chat_id="chat-1",
user_name="tester",
chat_type="dm",
)
return MessageEvent(text=text, source=source)


def _enable_checkpoints(tmp_path, monkeypatch, enabled=True):
home = tmp_path / "home"
home.mkdir()
(home / "config.yaml").write_text(
f"checkpoints:\n enabled: {str(enabled).lower()}\n", encoding="utf-8"
)
monkeypatch.setattr(gateway_run, "_hermes_home", home, raising=False)
monkeypatch.setattr(cpm, "CHECKPOINT_BASE", tmp_path / "checkpoints")


@pytest.mark.asyncio
async def test_diff_reports_cumulative_changes(tmp_path, monkeypatch):
_enable_checkpoints(tmp_path, monkeypatch)
project = tmp_path / "project"
project.mkdir()
(project / "main.py").write_text("print('hello')\n", encoding="utf-8")
monkeypatch.setenv("TERMINAL_CWD", str(project))

# Baseline checkpoint (pre-edit) then an edit, so a diff exists.
mgr = cpm.CheckpointManager(enabled=True, max_snapshots=50)
assert mgr.ensure_checkpoint(str(project), "baseline") is True
(project / "main.py").write_text("print('changed')\n", encoding="utf-8")

result = await _runner()._handle_diff_command(_event("/diff"))

assert "-print('hello')" in result
assert "+print('changed')" in result


@pytest.mark.asyncio
async def test_diff_stat_only_omits_body(tmp_path, monkeypatch):
_enable_checkpoints(tmp_path, monkeypatch)
project = tmp_path / "project"
project.mkdir()
(project / "main.py").write_text("a = 1\n", encoding="utf-8")
monkeypatch.setenv("TERMINAL_CWD", str(project))

mgr = cpm.CheckpointManager(enabled=True, max_snapshots=50)
mgr.ensure_checkpoint(str(project), "baseline")
(project / "main.py").write_text("a = 2\n", encoding="utf-8")

result = await _runner()._handle_diff_command(_event("/diff --stat"))

assert "main.py" in result
assert "+a = 2" not in result # body suppressed


@pytest.mark.asyncio
async def test_diff_no_changes_message(tmp_path, monkeypatch):
_enable_checkpoints(tmp_path, monkeypatch)
project = tmp_path / "project"
project.mkdir()
monkeypatch.setenv("TERMINAL_CWD", str(project))

result = await _runner()._handle_diff_command(_event("/diff"))

assert "No changes" in result


@pytest.mark.asyncio
async def test_diff_disabled_message(tmp_path, monkeypatch):
_enable_checkpoints(tmp_path, monkeypatch, enabled=False)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))

result = await _runner()._handle_diff_command(_event("/diff"))

assert "not enabled" in result.lower()
88 changes: 88 additions & 0 deletions tests/hermes_cli/test_diff_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Tests for the CLI ``/diff`` command handler.

``/diff`` shows the cumulative diff of everything Hermes changed in the
working directory (earliest retained checkpoint to working tree), the
session-wide counterpart to ``/rollback diff <N>``. These assert the handler
renders the manager's ``session_diff`` result, honours ``--stat``, and
degrades gracefully when checkpoints are off / empty / no agent.
"""

import contextlib
import io

from hermes_cli.cli_commands_mixin import CLICommandsMixin


class _Mgr:
def __init__(self, result, enabled=True):
self.enabled = enabled
self._result = result
self.calls = []

def session_diff(self, cwd):
self.calls.append(cwd)
return self._result


class _Agent:
def __init__(self, mgr):
self._checkpoint_mgr = mgr


class _Stub(CLICommandsMixin):
def __init__(self, agent=None):
self.agent = agent


def _run(stub, command):
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
stub._handle_diff_command(command)
return buf.getvalue()


def test_diff_prints_stat_and_diff():
mgr = _Mgr({
"success": True,
"stat": " main.py | 2 +-",
"diff": "--- a/main.py\n+++ b/main.py\n-print('hello')\n+print('v3')\n",
})
out = _run(_Stub(_Agent(mgr)), "/diff")
assert " main.py | 2 +-" in out
assert "+print('v3')" in out
assert mgr.calls # session_diff was consulted


def test_diff_stat_only_suppresses_body():
mgr = _Mgr({
"success": True,
"stat": " main.py | 2 +-",
"diff": "+print('v3')\n",
})
out = _run(_Stub(_Agent(mgr)), "/diff --stat")
assert " main.py | 2 +-" in out
assert "+print('v3')" not in out


def test_diff_empty_reports_no_changes():
mgr = _Mgr({"success": True, "stat": "", "diff": "", "empty": True})
out = _run(_Stub(_Agent(mgr)), "/diff")
assert "No changes" in out


def test_diff_disabled_explains_how_to_enable():
mgr = _Mgr({"success": True, "stat": "", "diff": ""}, enabled=False)
out = _run(_Stub(_Agent(mgr)), "/diff")
assert "not enabled" in out.lower()
assert not mgr.calls # short-circuits before touching the store


def test_diff_without_agent_is_graceful():
out = _run(_Stub(agent=None), "/diff")
assert "No active agent session" in out


def test_diff_failure_surfaces_error():
mgr = _Mgr({"success": False, "error": "boom"})
out = _run(_Stub(_Agent(mgr)), "/diff")
assert "boom" in out
Loading