refactor: centralize slash command registry (#1603)

* refactor: centralize slash command registry

Replace 7+ scattered command definition sites with a single
CommandDef registry in hermes_cli/commands.py. All downstream
consumers now derive from this registry:

- CLI process_command() resolves aliases via resolve_command()
- Gateway _known_commands uses GATEWAY_KNOWN_COMMANDS frozenset
- Gateway help text generated by gateway_help_lines()
- Telegram BotCommands generated by telegram_bot_commands()
- Slack subcommand map generated by slack_subcommand_map()

Adding a command or alias is now a one-line change to
COMMAND_REGISTRY instead of touching 6+ files.

Bugfixes included:
- Telegram now registers /rollback, /background (were missing)
- Slack now has /voice, /update, /reload-mcp (were missing)
- Gateway duplicate 'reasoning' dispatch (dead code) removed
- Gateway help text can no longer drift from CLI help

Backwards-compatible: COMMANDS and COMMANDS_BY_CATEGORY dicts are
rebuilt from the registry, so existing imports work unchanged.

* docs: update developer docs for centralized command registry

Update AGENTS.md with full 'Slash Command Registry' and 'Adding a
Slash Command' sections covering CommandDef fields, registry helpers,
and the one-line alias workflow.

Also update:
- CONTRIBUTING.md: commands.py description
- website/docs/reference/slash-commands.md: reference central registry
- docs/plans/centralize-command-registry.md: mark COMPLETED
- plans/checkpoint-rollback.md: reference new pattern
- hermes-agent-dev skill: architecture table

* chore: remove stale plan docs
This commit is contained in:
Teknium 2026-03-16 23:21:03 -07:00 committed by GitHub
parent b798062501
commit 46176c8029
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 571 additions and 802 deletions

View file

@ -129,14 +129,50 @@ Messages follow OpenAI format: `{"role": "system/user/assistant/tool", ...}`. Re
- **KawaiiSpinner** (`agent/display.py`) — animated faces during API calls, `┊` activity feed for tool results
- `load_cli_config()` in cli.py merges hardcoded defaults + user config YAML
- **Skin engine** (`hermes_cli/skin_engine.py`) — data-driven CLI theming; initialized from `display.skin` config key at startup; skins customize banner colors, spinner faces/verbs/wings, tool prefix, response box, branding text
- `process_command()` is a method on `HermesCLI` (not in commands.py)
- `process_command()` is a method on `HermesCLI` — dispatches on canonical command name resolved via `resolve_command()` from the central registry
- Skill slash commands: `agent/skill_commands.py` scans `~/.hermes/skills/`, injects as **user message** (not system prompt) to preserve prompt caching
### Adding CLI Commands
### Slash Command Registry (`hermes_cli/commands.py`)
1. Add to `COMMANDS` dict in `hermes_cli/commands.py`
2. Add handler in `HermesCLI.process_command()` in `cli.py`
3. For persistent settings, use `save_config_value()` in `cli.py`
All slash commands are defined in a central `COMMAND_REGISTRY` list of `CommandDef` objects. Every downstream consumer derives from this registry automatically:
- **CLI**`process_command()` resolves aliases via `resolve_command()`, dispatches on canonical name
- **Gateway**`GATEWAY_KNOWN_COMMANDS` frozenset for hook emission, `resolve_command()` for dispatch
- **Gateway help**`gateway_help_lines()` generates `/help` output
- **Telegram**`telegram_bot_commands()` generates the BotCommand menu
- **Slack**`slack_subcommand_map()` generates `/hermes` subcommand routing
- **Autocomplete**`COMMANDS` flat dict feeds `SlashCommandCompleter`
- **CLI help**`COMMANDS_BY_CATEGORY` dict feeds `show_help()`
### Adding a Slash Command
1. Add a `CommandDef` entry to `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
```python
CommandDef("mycommand", "Description of what it does", "Session",
aliases=("mc",), args_hint="[arg]"),
```
2. Add handler in `HermesCLI.process_command()` in `cli.py`:
```python
elif canonical == "mycommand":
self._handle_mycommand(cmd_original)
```
3. If the command is available in the gateway, add a handler in `gateway/run.py`:
```python
if canonical == "mycommand":
return await self._handle_mycommand(event)
```
4. For persistent settings, use `save_config_value()` in `cli.py`
**CommandDef fields:**
- `name` — canonical name without slash (e.g. `"background"`)
- `description` — human-readable description
- `category` — one of `"Session"`, `"Configuration"`, `"Tools & Skills"`, `"Info"`, `"Exit"`
- `aliases` — tuple of alternative names (e.g. `("bg",)`)
- `args_hint` — argument placeholder shown in help (e.g. `"<prompt>"`, `"[name]"`)
- `cli_only` — only available in the interactive CLI
- `gateway_only` — only available in messaging platforms
**Adding an alias** requires only adding it to the `aliases` tuple on the existing `CommandDef`. No other file changes needed — dispatch, help text, Telegram menu, Slack mapping, and autocomplete all update automatically.
---

View file

@ -136,7 +136,7 @@ hermes-agent/
│ ├── auth.py # Provider resolution, OAuth, Nous Portal
│ ├── models.py # OpenRouter model selection lists
│ ├── banner.py # Welcome banner, ASCII art
│ ├── commands.py # Slash command definitions + autocomplete
│ ├── commands.py # Central slash command registry (CommandDef), autocomplete, gateway helpers
│ ├── callbacks.py # Interactive callbacks (clarify, sudo, approval)
│ ├── doctor.py # Diagnostics
│ ├── skills_hub.py # Skills Hub CLI + /skills slash command

75
cli.py
View file

@ -3267,17 +3267,24 @@ class HermesCLI:
cmd_lower = command.lower().strip()
cmd_original = command.strip()
if cmd_lower in ("/quit", "/exit", "/q"):
# Resolve aliases via central registry so adding an alias is a one-line
# change in hermes_cli/commands.py instead of touching every dispatch site.
from hermes_cli.commands import resolve_command as _resolve_cmd
_base_word = cmd_lower.split()[0].lstrip("/")
_cmd_def = _resolve_cmd(_base_word)
canonical = _cmd_def.name if _cmd_def else _base_word
if canonical in ("quit", "exit", "q"):
return False
elif cmd_lower == "/help":
elif canonical == "help":
self.show_help()
elif cmd_lower == "/tools":
elif canonical == "tools":
self.show_tools()
elif cmd_lower == "/toolsets":
elif canonical == "toolsets":
self.show_toolsets()
elif cmd_lower == "/config":
elif canonical == "config":
self.show_config()
elif cmd_lower == "/clear":
elif canonical == "clear":
self.new_session(silent=True)
# Clear terminal screen. Inside the TUI, Rich's console.clear()
# goes through patch_stdout's StdoutProxy which swallows the
@ -3318,9 +3325,9 @@ class HermesCLI:
else:
self.show_banner()
print(" ✨ (◕‿◕)✨ Fresh start! Screen cleared and conversation reset.\n")
elif cmd_lower == "/history":
elif canonical == "history":
self.show_history()
elif cmd_lower.startswith("/title"):
elif canonical == "title":
parts = cmd_original.split(maxsplit=1)
if len(parts) > 1:
raw_title = parts[1].strip()
@ -3391,9 +3398,9 @@ class HermesCLI:
_cprint(f" No title set. Usage: /title <your session title>")
else:
_cprint(" Session database not available.")
elif cmd_lower in ("/reset", "/new"):
elif canonical == "new":
self.new_session()
elif cmd_lower.startswith("/model"):
elif canonical == "model":
# Use original case so model names like "Anthropic/Claude-Opus-4" are preserved
parts = cmd_original.split(maxsplit=1)
if len(parts) > 1:
@ -3480,50 +3487,50 @@ class HermesCLI:
print(" Note: Model will revert on restart. Use a verified model to save to config.")
else:
self._show_model_and_providers()
elif cmd_lower == "/provider":
elif canonical == "provider":
self._show_model_and_providers()
elif cmd_lower.startswith("/prompt"):
elif canonical == "prompt":
# Use original case so prompt text isn't lowercased
self._handle_prompt_command(cmd_original)
elif cmd_lower.startswith("/personality"):
elif canonical == "personality":
# Use original case (handler lowercases the personality name itself)
self._handle_personality_command(cmd_original)
elif cmd_lower == "/plan" or cmd_lower.startswith("/plan "):
elif canonical == "plan":
self._handle_plan_command(cmd_original)
elif cmd_lower == "/retry":
elif canonical == "retry":
retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'):
# Re-queue the message so process_loop sends it to the agent
self._pending_input.put(retry_msg)
elif cmd_lower == "/undo":
elif canonical == "undo":
self.undo_last()
elif cmd_lower == "/save":
elif canonical == "save":
self.save_conversation()
elif cmd_lower.startswith("/cron"):
elif canonical == "cron":
self._handle_cron_command(cmd_original)
elif cmd_lower.startswith("/skills"):
elif canonical == "skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._handle_skills_command(cmd_original)
elif cmd_lower == "/platforms" or cmd_lower == "/gateway":
elif canonical == "platforms":
self._show_gateway_status()
elif cmd_lower == "/verbose":
elif canonical == "verbose":
self._toggle_verbose()
elif cmd_lower.startswith("/reasoning"):
elif canonical == "reasoning":
self._handle_reasoning_command(cmd_original)
elif cmd_lower == "/compress":
elif canonical == "compress":
self._manual_compress()
elif cmd_lower == "/usage":
elif canonical == "usage":
self._show_usage()
elif cmd_lower.startswith("/insights"):
elif canonical == "insights":
self._show_insights(cmd_original)
elif cmd_lower == "/paste":
elif canonical == "paste":
self._handle_paste_command()
elif cmd_lower == "/reload-mcp":
elif canonical == "reload-mcp":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_mcp()
elif cmd_lower.startswith("/browser"):
elif _base_word == "browser":
self._handle_browser_command(cmd_original)
elif cmd_lower == "/plugins":
elif canonical == "plugins":
try:
from hermes_cli.plugins import get_plugin_manager
mgr = get_plugin_manager()
@ -3544,15 +3551,15 @@ class HermesCLI:
print(f" {status} {p['name']}{version}{detail}{error}")
except Exception as e:
print(f"Plugin system error: {e}")
elif cmd_lower.startswith("/rollback"):
elif canonical == "rollback":
self._handle_rollback_command(cmd_original)
elif cmd_lower == "/stop":
elif canonical == "stop":
self._handle_stop_command()
elif cmd_lower.startswith("/background") or cmd_lower.startswith("/bg"):
elif canonical == "background":
self._handle_background_command(cmd_original)
elif cmd_lower.startswith("/skin"):
elif canonical == "skin":
self._handle_skin_command(cmd_original)
elif cmd_lower.startswith("/voice"):
elif canonical == "voice":
self._handle_voice_command(cmd_original)
else:
# Check for user-defined quick commands (bypass agent loop, no LLM call)

View file

@ -1,350 +0,0 @@
# Plan: Centralize Slash Command Registry
## Problem
Slash command definitions are scattered across 7+ locations with significant drift:
| Location | What it defines | Commands |
|----------|----------------|----------|
| `hermes_cli/commands.py` | COMMANDS_BY_CATEGORY dict | 34 commands |
| `cli.py` process_command() | if/elif dispatch chain | ~30 branches |
| `gateway/run.py` _known_commands | Hook emission set | 25 entries |
| `gateway/run.py` _handle_message() | if dispatch chain | ~22 branches |
| `gateway/run.py` _handle_help_command() | Hardcoded help text list | 22 lines |
| `gateway/platforms/telegram.py` | BotCommand registration | 20 commands |
| `gateway/platforms/discord.py` | @tree.command decorators | 22 commands |
| `gateway/platforms/slack.py` | subcommand_map dict | 20 mappings |
**Known drift:**
- Telegram missing: `/rollback`, `/background`, `/bg`, `/plan`, `/set-home`
- Slack missing: `/sethome`, `/set-home`, `/update`, `/voice`, `/reload-mcp`, `/plan`
- Gateway help text missing: `/bg` alias mention
- Gateway `_known_commands` has duplicate `"reasoning"` entry
- Gateway dispatch has dead code: second `"reasoning"` check (line 1384) never executes
- Adding one alias (`/bg`) required touching 6 files + 1 test file
## Goal
Single source of truth for "what commands exist, what are their aliases, and
what platforms support them." Adding a command or alias should require exactly
one definition change + the handler implementation.
## Design
### 1. CommandDef dataclass (hermes_cli/commands.py)
```python
from dataclasses import dataclass, field
@dataclass(frozen=True)
class CommandDef:
name: str # canonical name without slash: "background"
description: str # human-readable description
category: str # "Session", "Configuration", "Tools & Skills", "Info", "Exit"
aliases: tuple[str, ...] = () # alternative names: ("bg",)
args_hint: str = "" # argument placeholder: "<prompt>", "[name]", "[level|show|hide]"
gateway: bool = True # available in gateway (Telegram/Discord/Slack/etc.)
cli_only: bool = False # only available in CLI (e.g., /clear, /paste, /skin)
gateway_only: bool = False # only available in gateway (e.g., /status, /sethome, /update)
```
### 2. COMMAND_REGISTRY list (hermes_cli/commands.py)
Replace COMMANDS_BY_CATEGORY with a flat list of CommandDef objects:
```python
COMMAND_REGISTRY: list[CommandDef] = [
# Session
CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",)),
CommandDef("clear", "Clear screen and start a new session", "Session", cli_only=True),
CommandDef("history", "Show conversation history", "Session", cli_only=True),
CommandDef("save", "Save the current conversation", "Session", cli_only=True),
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
CommandDef("title", "Set a title for the current session", "Session", args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"),
CommandDef("stop", "Kill all running background processes", "Session"),
CommandDef("background", "Run a prompt in the background", "Session", aliases=("bg",), args_hint="<prompt>"),
CommandDef("status", "Show session info", "Session", gateway_only=True),
CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)),
CommandDef("resume", "Resume a previously-named session", "Session", args_hint="[name]"),
# Configuration
CommandDef("config", "Show current configuration", "Configuration", cli_only=True),
CommandDef("model", "Show or change the current model", "Configuration", args_hint="[name]"),
CommandDef("provider", "Show available providers and current provider", "Configuration"),
CommandDef("prompt", "View/set custom system prompt", "Configuration", cli_only=True, args_hint="[text]"),
CommandDef("personality", "Set a predefined personality", "Configuration", args_hint="[name]"),
CommandDef("verbose", "Cycle tool progress display: off → new → all → verbose", "Configuration", cli_only=True),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]"),
CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]"),
# Tools & Skills
CommandDef("tools", "List available tools", "Tools & Skills", cli_only=True),
CommandDef("toolsets", "List available toolsets", "Tools & Skills", cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills", "Tools & Skills", cli_only=True),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]"),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", aliases=("reload_mcp",)),
CommandDef("plugins", "List installed plugins and their status", "Tools & Skills", cli_only=True),
# Info
CommandDef("help", "Show available commands", "Info"),
CommandDef("usage", "Show token usage for the current session", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info", args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info", cli_only=True, aliases=("gateway",)),
CommandDef("paste", "Check clipboard for an image and attach it", "Info", cli_only=True),
CommandDef("update", "Update Hermes Agent to the latest version", "Info", gateway_only=True),
# Exit
CommandDef("quit", "Exit the CLI", "Exit", cli_only=True, aliases=("exit", "q")),
]
```
### 3. Derived data structures (hermes_cli/commands.py)
Build all downstream dicts/sets from the registry automatically:
```python
# --- derived lookups (rebuilt on import, all consumers use these) ---
# name_or_alias -> CommandDef (used by dispatch to resolve aliases)
_COMMAND_LOOKUP: dict[str, CommandDef] = {}
for _cmd in COMMAND_REGISTRY:
_COMMAND_LOOKUP[_cmd.name] = _cmd
for _alias in _cmd.aliases:
_COMMAND_LOOKUP[_alias] = _cmd
def resolve_command(name: str) -> CommandDef | None:
"""Resolve a command name or alias to its CommandDef."""
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
# Backwards-compat: flat COMMANDS dict (slash-prefixed key -> description)
COMMANDS: dict[str, str] = {}
for _cmd in COMMAND_REGISTRY:
desc = _cmd.description
if _cmd.args_hint:
desc = f"{desc} (usage: /{_cmd.name} {_cmd.args_hint})"
COMMANDS[f"/{_cmd.name}"] = desc
for _alias in _cmd.aliases:
alias_desc = f"{desc} (alias for /{_cmd.name})" if _alias not in ("reset",) else desc
COMMANDS[f"/{_alias}"] = alias_desc
# Backwards-compat: COMMANDS_BY_CATEGORY
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
for _cmd in COMMAND_REGISTRY:
cat = COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {})
cat[f"/{_cmd.name}"] = COMMANDS[f"/{_cmd.name}"]
for _alias in _cmd.aliases:
cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
# Gateway known commands set (for hook emission)
GATEWAY_KNOWN_COMMANDS: set[str] = set()
for _cmd in COMMAND_REGISTRY:
if not _cmd.cli_only:
GATEWAY_KNOWN_COMMANDS.add(_cmd.name)
GATEWAY_KNOWN_COMMANDS.update(_cmd.aliases)
# Gateway help lines (for _handle_help_command)
def gateway_help_lines() -> list[str]:
"""Generate gateway help text from the registry."""
lines = []
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
continue
args = f" {cmd.args_hint}" if cmd.args_hint else ""
alias_note = ""
if cmd.aliases:
alias_strs = ", ".join(f"`/{a}`" for a in cmd.aliases)
alias_note = f" (alias: {alias_strs})"
lines.append(f"`/{cmd.name}{args}` — {cmd.description}{alias_note}")
return lines
# Telegram BotCommand list
def telegram_bot_commands() -> list[tuple[str, str]]:
"""Return (command_name, description) pairs for Telegram's setMyCommands."""
result = []
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
continue
# Telegram doesn't support hyphens in command names
tg_name = cmd.name.replace("-", "_")
result.append((tg_name, cmd.description))
return result
# Slack subcommand map
def slack_subcommand_map() -> dict[str, str]:
"""Return subcommand -> /command mapping for Slack's /hermes handler."""
mapping = {}
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
continue
mapping[cmd.name] = f"/{cmd.name}"
for alias in cmd.aliases:
mapping[alias] = f"/{alias}"
return mapping
```
### 4. Consumer changes
#### cli.py — process_command()
The dispatch chain stays as-is (if/elif is fine for ~30 commands), but alias
resolution moves to the top:
```python
def process_command(self, command: str) -> bool:
cmd_original = command.strip()
cmd_lower = cmd_original.lower()
base = cmd_lower.split()[0].lstrip("/")
# Resolve alias to canonical name
cmd_def = resolve_command(base)
if cmd_def:
canonical = cmd_def.name
else:
canonical = base
# Dispatch on canonical name
if canonical in ("quit", "exit", "q"):
...
elif canonical == "help":
...
elif canonical == "background": # no more "or startswith /bg"
...
```
This eliminates every `or cmd_lower.startswith("/bg")` style alias check.
#### gateway/run.py — _handle_message()
```python
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command
# Replace hardcoded _known_commands set
if command and command in GATEWAY_KNOWN_COMMANDS:
await self.hooks.emit(f"command:{command}", {...})
# Resolve aliases before dispatch
cmd_def = resolve_command(command)
canonical = cmd_def.name if cmd_def else command
if canonical in ("new",):
return await self._handle_reset_command(event)
elif canonical == "background":
return await self._handle_background_command(event)
...
```
#### gateway/run.py — _handle_help_command()
```python
from hermes_cli.commands import gateway_help_lines
async def _handle_help_command(self, event):
lines = gateway_help_lines()
# ... append skill commands, format, return
```
Delete the hardcoded 22-line list entirely.
#### gateway/platforms/telegram.py — set_my_commands()
```python
from hermes_cli.commands import telegram_bot_commands
async def set_my_commands(self):
commands = [BotCommand(name, desc) for name, desc in telegram_bot_commands()]
await self._bot.set_my_commands(commands)
```
Delete the hardcoded 20-entry list.
#### gateway/platforms/slack.py — _handle_slash_command()
```python
from hermes_cli.commands import slack_subcommand_map
async def _handle_slash_command(self, command: dict):
...
subcommand_map = slack_subcommand_map()
...
```
Delete the hardcoded dict.
#### gateway/platforms/discord.py — _register_slash_commands()
Discord is the **exception**. Its `@tree.command()` decorators need typed
parameters, custom descriptions, and platform-specific interaction handling
(defer, ephemeral, followups). These can't be generated from a simple registry.
**Approach:** Keep the decorator registrations, but validate at startup that
every registered Discord command has a matching entry in COMMAND_REGISTRY
(except platform-specific ones like `/ask` and `/thread`). Add a test for this.
```python
# In _register_slash_commands(), after all decorators:
_DISCORD_ONLY_COMMANDS = {"ask", "thread"}
registered = {cmd.name for cmd in tree.get_commands()}
registry_names = {c.name for c in COMMAND_REGISTRY if not c.cli_only}
# Warn about Discord commands not in registry (excluding Discord-only)
for name in registered - registry_names - _DISCORD_ONLY_COMMANDS:
logger.warning("Discord command /%s not in central registry", name)
```
## Files Changed
| File | Change |
|------|--------|
| `hermes_cli/commands.py` | Add `CommandDef`, `COMMAND_REGISTRY`, derived structures, helper functions |
| `cli.py` | Add alias resolution at top of `process_command()`, remove per-command alias checks |
| `gateway/run.py` | Import `GATEWAY_KNOWN_COMMANDS` + `resolve_command` + `gateway_help_lines`, delete hardcoded sets/lists |
| `gateway/platforms/telegram.py` | Import `telegram_bot_commands()`, delete hardcoded BotCommand list |
| `gateway/platforms/slack.py` | Import `slack_subcommand_map()`, delete hardcoded dict |
| `gateway/platforms/discord.py` | Add startup validation against registry |
| `tests/hermes_cli/test_commands.py` | Update to test registry, derived structures, helper functions |
| `tests/gateway/test_background_command.py` | Simplify — no more source-code-inspection tests |
## Bugfixes included for free
1. **Telegram missing commands**: `/rollback`, `/background`, `/bg` automatically added
2. **Slack missing commands**: `/voice`, `/update`, `/reload-mcp` automatically added
3. **Gateway duplicate "reasoning"**: Eliminated (generated from registry)
4. **Gateway dead code**: Second `"reasoning"` dispatch branch removed
5. **Help text drift**: Gateway help now generated from same source as CLI help
## What stays the same
- CLI dispatch remains an if/elif chain (readable, fast, explicit)
- Gateway dispatch remains an if chain
- Discord slash command decorators stay platform-specific
- Handler function signatures and locations don't change
- Quick commands and skill commands remain separate (config-driven / dynamic)
## Migration / backwards compat
- `COMMANDS` flat dict and `COMMANDS_BY_CATEGORY` dict are rebuilt from the
registry, so any code importing them continues to work unchanged
- `SlashCommandCompleter` continues to read from `COMMANDS` dict
- No config changes, no user-facing behavior changes
## Risks
- **Import ordering**: `gateway/run.py` importing from `hermes_cli/commands.py`
verify no circular import. Currently `gateway/run.py` doesn't import from
`hermes_cli/` at all. Need to confirm this works or move the registry to a
shared location (e.g., `commands_registry.py` at the top level).
- **Telegram command name sanitization**: Telegram doesn't allow hyphens in
command names. The `telegram_bot_commands()` helper handles this with
`.replace("-", "_")`, but the gateway dispatch must still accept both forms.
Currently handled via the `("reload-mcp", "reload_mcp")` alias.
## Estimated scope
- ~200 lines of new code in `commands.py` (dataclass + registry + helpers)
- ~100 lines deleted across gateway/run.py, telegram.py, slack.py (hardcoded lists)
- ~50 lines changed in cli.py (alias resolution refactor)
- ~80 lines of new/updated tests
- Net: roughly even LOC, dramatically less maintenance surface

View file

@ -789,24 +789,11 @@ class SlackAdapter(BasePlatformAdapter):
user_id = command.get("user_id", "")
channel_id = command.get("channel_id", "")
# Map subcommands to gateway commands
subcommand_map = {
"new": "/reset", "reset": "/reset",
"status": "/status", "stop": "/stop",
"help": "/help",
"model": "/model", "personality": "/personality",
"retry": "/retry", "undo": "/undo",
"compact": "/compress", "compress": "/compress",
"resume": "/resume",
"background": "/background",
"bg": "/bg",
"usage": "/usage",
"insights": "/insights",
"title": "/title",
"reasoning": "/reasoning",
"provider": "/provider",
"rollback": "/rollback",
}
# Map subcommands to gateway commands — derived from central registry.
# Also keep "compact" as a Slack-specific alias for /compress.
from hermes_cli.commands import slack_subcommand_map
subcommand_map = slack_subcommand_map()
subcommand_map["compact"] = "/compress"
first_word = text.split()[0] if text else ""
if first_word in subcommand_map:
# Preserve arguments after the subcommand

View file

@ -240,29 +240,13 @@ class TelegramAdapter(BasePlatformAdapter):
)
# Register bot commands so Telegram shows a hint menu when users type /
# List is derived from the central COMMAND_REGISTRY — adding a new
# gateway command there automatically adds it to the Telegram menu.
try:
from telegram import BotCommand
from hermes_cli.commands import telegram_bot_commands
await self._bot.set_my_commands([
BotCommand("new", "Start a new conversation"),
BotCommand("reset", "Reset conversation history"),
BotCommand("model", "Show or change the model"),
BotCommand("reasoning", "Show or change reasoning effort"),
BotCommand("personality", "Set a personality"),
BotCommand("retry", "Retry your last message"),
BotCommand("undo", "Remove the last exchange"),
BotCommand("status", "Show session info"),
BotCommand("stop", "Stop the running agent"),
BotCommand("sethome", "Set this chat as the home channel"),
BotCommand("compress", "Compress conversation context"),
BotCommand("title", "Set or show the session title"),
BotCommand("resume", "Resume a previously-named session"),
BotCommand("usage", "Show token usage for this session"),
BotCommand("provider", "Show available providers"),
BotCommand("insights", "Show usage insights and analytics"),
BotCommand("update", "Update Hermes to the latest version"),
BotCommand("reload_mcp", "Reload MCP servers from config"),
BotCommand("voice", "Toggle voice reply mode"),
BotCommand("help", "Show available commands"),
BotCommand(name, desc) for name, desc in telegram_bot_commands()
])
except Exception as e:
logger.warning(

View file

@ -1285,13 +1285,11 @@ class GatewayRunner:
# Check for commands
command = event.get_command()
# Emit command:* hook for any recognized slash command
_known_commands = {"new", "reset", "help", "status", "stop", "model", "reasoning",
"personality", "plan", "retry", "undo", "sethome", "set-home",
"compress", "usage", "insights", "reload-mcp", "reload_mcp",
"update", "title", "resume", "provider", "rollback",
"background", "bg", "reasoning", "voice"}
if command and command in _known_commands:
# Emit command:* hook for any recognized slash command.
# GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY
# in hermes_cli/commands.py — no hardcoded set to maintain here.
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd
if command and command in GATEWAY_KNOWN_COMMANDS:
await self.hooks.emit(f"command:{command}", {
"platform": source.platform.value if source.platform else "",
"user_id": source.user_id,
@ -1299,31 +1297,35 @@ class GatewayRunner:
"args": event.get_command_args().strip(),
})
if command in ["new", "reset"]:
# Resolve aliases to canonical name so dispatch only checks canonicals.
_cmd_def = _resolve_cmd(command) if command else None
canonical = _cmd_def.name if _cmd_def else command
if canonical == "new":
return await self._handle_reset_command(event)
if command == "help":
if canonical == "help":
return await self._handle_help_command(event)
if command == "status":
if canonical == "status":
return await self._handle_status_command(event)
if command == "stop":
if canonical == "stop":
return await self._handle_stop_command(event)
if command == "model":
if canonical == "model":
return await self._handle_model_command(event)
if command == "reasoning":
if canonical == "reasoning":
return await self._handle_reasoning_command(event)
if command == "provider":
if canonical == "provider":
return await self._handle_provider_command(event)
if command == "personality":
if canonical == "personality":
return await self._handle_personality_command(event)
if command == "plan":
if canonical == "plan":
try:
from agent.skill_commands import build_plan_path, build_skill_invocation_message
@ -1340,51 +1342,48 @@ class GatewayRunner:
)
if not event.text:
return "Failed to load the bundled /plan skill."
command = None
canonical = None
except Exception as e:
logger.exception("Failed to prepare /plan command")
return f"Failed to enter plan mode: {e}"
if command == "retry":
if canonical == "retry":
return await self._handle_retry_command(event)
if command == "undo":
if canonical == "undo":
return await self._handle_undo_command(event)
if command in ["sethome", "set-home"]:
if canonical == "sethome":
return await self._handle_set_home_command(event)
if command == "compress":
if canonical == "compress":
return await self._handle_compress_command(event)
if command == "usage":
if canonical == "usage":
return await self._handle_usage_command(event)
if command == "insights":
if canonical == "insights":
return await self._handle_insights_command(event)
if command in ("reload-mcp", "reload_mcp"):
if canonical == "reload-mcp":
return await self._handle_reload_mcp_command(event)
if command == "update":
if canonical == "update":
return await self._handle_update_command(event)
if command == "title":
if canonical == "title":
return await self._handle_title_command(event)
if command == "resume":
if canonical == "resume":
return await self._handle_resume_command(event)
if command == "rollback":
if canonical == "rollback":
return await self._handle_rollback_command(event)
if command == "background":
if canonical == "background":
return await self._handle_background_command(event)
if command == "reasoning":
return await self._handle_reasoning_command(event)
if command == "voice":
if canonical == "voice":
return await self._handle_voice_command(event)
# User-defined quick commands (bypass agent loop, no LLM call)
@ -2093,30 +2092,10 @@ class GatewayRunner:
async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands."""
from hermes_cli.commands import gateway_help_lines
lines = [
"📖 **Hermes Commands**\n",
"`/new` — Start a new conversation",
"`/reset` — Reset conversation history",
"`/status` — Show session info",
"`/stop` — Interrupt the running agent",
"`/model [provider:model]` — Show/change model (or switch provider)",
"`/provider` — Show available providers and auth status",
"`/personality [name]` — Set a personality",
"`/retry` — Retry your last message",
"`/undo` — Remove the last exchange",
"`/sethome` — Set this chat as the home channel",
"`/compress` — Compress conversation context",
"`/title [name]` — Set or show the session title",
"`/resume [name]` — Resume a previously-named session",
"`/usage` — Show token usage for this session",
"`/insights [days]` — Show usage insights and analytics",
"`/reasoning [level|show|hide]` — Set reasoning effort or toggle display",
"`/rollback [number]` — List or restore filesystem checkpoints",
"`/background <prompt>` — Run a prompt in a separate background session",
"`/voice [on|off|tts|status]` — Toggle voice reply mode",
"`/reload-mcp` — Reload MCP servers from config",
"`/update` — Update Hermes Agent to the latest version",
"`/help` — Show this message",
*gateway_help_lines(),
]
try:
from agent.skill_commands import get_skill_commands

View file

@ -1,74 +1,240 @@
"""Slash command definitions and autocomplete for the Hermes CLI.
Contains the shared built-in ``COMMANDS`` dict and ``SlashCommandCompleter``.
The completer can optionally include dynamic skill slash commands supplied by the
interactive CLI.
Central registry for all slash commands. Every consumer -- CLI help, gateway
dispatch, Telegram BotCommands, Slack subcommand mapping, autocomplete --
derives its data from ``COMMAND_REGISTRY``.
To add a command: add a ``CommandDef`` entry to ``COMMAND_REGISTRY``.
To add an alias: set ``aliases=("short",)`` on the existing ``CommandDef``.
"""
from __future__ import annotations
import os
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from prompt_toolkit.completion import Completer, Completion
# Commands organized by category for better help display
COMMANDS_BY_CATEGORY = {
"Session": {
"/new": "Start a new session (fresh session ID + history)",
"/reset": "Start a new session (alias for /new)",
"/clear": "Clear screen and start a new session",
"/history": "Show conversation history",
"/save": "Save the current conversation",
"/retry": "Retry the last message (resend to agent)",
"/undo": "Remove the last user/assistant exchange",
"/title": "Set a title for the current session (usage: /title My Session Name)",
"/compress": "Manually compress conversation context (flush memories + summarize)",
"/rollback": "List or restore filesystem checkpoints (usage: /rollback [number])",
"/stop": "Kill all running background processes",
"/background": "Run a prompt in the background (usage: /background <prompt>)",
"/bg": "Run a prompt in the background (alias for /background)",
},
"Configuration": {
"/config": "Show current configuration",
"/model": "Show or change the current model",
"/provider": "Show available providers and current provider",
"/prompt": "View/set custom system prompt",
"/personality": "Set a predefined personality",
"/verbose": "Cycle tool progress display: off → new → all → verbose",
"/reasoning": "Manage reasoning effort and display (usage: /reasoning [level|show|hide])",
"/skin": "Show or change the display skin/theme",
"/voice": "Toggle voice mode (Ctrl+B to record). Usage: /voice [on|off|tts|status]",
},
"Tools & Skills": {
"/tools": "List available tools",
"/toolsets": "List available toolsets",
"/skills": "Search, install, inspect, or manage skills from online registries",
"/cron": "Manage scheduled tasks (list, add/create, edit, pause, resume, run, remove)",
"/reload-mcp": "Reload MCP servers from config.yaml",
"/browser": "Connect browser tools to your live Chrome (usage: /browser connect|disconnect|status)",
"/plugins": "List installed plugins and their status",
},
"Info": {
"/help": "Show this help message",
"/usage": "Show token usage for the current session",
"/insights": "Show usage insights and analytics (last 30 days)",
"/platforms": "Show gateway/messaging platform status",
"/paste": "Check clipboard for an image and attach it",
},
"Exit": {
"/quit": "Exit the CLI (also: /exit, /q)",
},
}
# ---------------------------------------------------------------------------
# CommandDef dataclass
# ---------------------------------------------------------------------------
# Flat dict for backwards compatibility and autocomplete
COMMANDS = {}
for category_commands in COMMANDS_BY_CATEGORY.values():
COMMANDS.update(category_commands)
@dataclass(frozen=True)
class CommandDef:
"""Definition of a single slash command."""
name: str # canonical name without slash: "background"
description: str # human-readable description
category: str # "Session", "Configuration", etc.
aliases: tuple[str, ...] = () # alternative names: ("bg",)
args_hint: str = "" # argument placeholder: "<prompt>", "[name]"
cli_only: bool = False # only available in CLI
gateway_only: bool = False # only available in gateway/messaging
# ---------------------------------------------------------------------------
# Central registry -- single source of truth
# ---------------------------------------------------------------------------
COMMAND_REGISTRY: list[CommandDef] = [
# Session
CommandDef("new", "Start a new session (fresh session ID + history)", "Session",
aliases=("reset",)),
CommandDef("clear", "Clear screen and start a new session", "Session",
cli_only=True),
CommandDef("history", "Show conversation history", "Session",
cli_only=True),
CommandDef("save", "Save the current conversation", "Session",
cli_only=True),
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
CommandDef("title", "Set a title for the current session", "Session",
args_hint="[name]"),
CommandDef("compress", "Manually compress conversation context", "Session"),
CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"),
CommandDef("stop", "Kill all running background processes", "Session"),
CommandDef("background", "Run a prompt in the background", "Session",
aliases=("bg",), args_hint="<prompt>"),
CommandDef("status", "Show session info", "Session",
gateway_only=True),
CommandDef("sethome", "Set this chat as the home channel", "Session",
gateway_only=True, aliases=("set-home",)),
CommandDef("resume", "Resume a previously-named session", "Session",
args_hint="[name]"),
# Configuration
CommandDef("config", "Show current configuration", "Configuration",
cli_only=True),
CommandDef("model", "Show or change the current model", "Configuration",
args_hint="[name]"),
CommandDef("provider", "Show available providers and current provider",
"Configuration"),
CommandDef("prompt", "View/set custom system prompt", "Configuration",
cli_only=True, args_hint="[text]"),
CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"),
CommandDef("verbose", "Cycle tool progress display: off -> new -> all -> verbose",
"Configuration", cli_only=True),
CommandDef("reasoning", "Manage reasoning effort and display", "Configuration",
args_hint="[level|show|hide]"),
CommandDef("skin", "Show or change the display skin/theme", "Configuration",
cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]"),
# Tools & Skills
CommandDef("tools", "List available tools", "Tools & Skills",
cli_only=True),
CommandDef("toolsets", "List available toolsets", "Tools & Skills",
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]"),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)),
CommandDef("plugins", "List installed plugins and their status",
"Tools & Skills", cli_only=True),
# Info
CommandDef("help", "Show available commands", "Info"),
CommandDef("usage", "Show token usage for the current session", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
CommandDef("platforms", "Show gateway/messaging platform status", "Info",
cli_only=True, aliases=("gateway",)),
CommandDef("paste", "Check clipboard for an image and attach it", "Info",
cli_only=True),
CommandDef("update", "Update Hermes Agent to the latest version", "Info",
gateway_only=True),
# Exit
CommandDef("quit", "Exit the CLI", "Exit",
cli_only=True, aliases=("exit", "q")),
]
# ---------------------------------------------------------------------------
# Derived lookups -- rebuilt once at import time
# ---------------------------------------------------------------------------
def _build_command_lookup() -> dict[str, CommandDef]:
"""Map every name and alias to its CommandDef."""
lookup: dict[str, CommandDef] = {}
for cmd in COMMAND_REGISTRY:
lookup[cmd.name] = cmd
for alias in cmd.aliases:
lookup[alias] = cmd
return lookup
_COMMAND_LOOKUP: dict[str, CommandDef] = _build_command_lookup()
def resolve_command(name: str) -> CommandDef | None:
"""Resolve a command name or alias to its CommandDef.
Accepts names with or without the leading slash.
"""
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
def _build_description(cmd: CommandDef) -> str:
"""Build a CLI-facing description string including usage hint."""
if cmd.args_hint:
return f"{cmd.description} (usage: /{cmd.name} {cmd.args_hint})"
return cmd.description
# Backwards-compatible flat dict: "/command" -> description
COMMANDS: dict[str, str] = {}
for _cmd in COMMAND_REGISTRY:
if not _cmd.gateway_only:
COMMANDS[f"/{_cmd.name}"] = _build_description(_cmd)
for _alias in _cmd.aliases:
COMMANDS[f"/{_alias}"] = f"{_cmd.description} (alias for /{_cmd.name})"
# Backwards-compatible categorized dict
COMMANDS_BY_CATEGORY: dict[str, dict[str, str]] = {}
for _cmd in COMMAND_REGISTRY:
if not _cmd.gateway_only:
_cat = COMMANDS_BY_CATEGORY.setdefault(_cmd.category, {})
_cat[f"/{_cmd.name}"] = COMMANDS[f"/{_cmd.name}"]
for _alias in _cmd.aliases:
_cat[f"/{_alias}"] = COMMANDS[f"/{_alias}"]
# ---------------------------------------------------------------------------
# Gateway helpers
# ---------------------------------------------------------------------------
# Set of all command names + aliases recognized by the gateway
GATEWAY_KNOWN_COMMANDS: frozenset[str] = frozenset(
name
for cmd in COMMAND_REGISTRY
if not cmd.cli_only
for name in (cmd.name, *cmd.aliases)
)
def gateway_help_lines() -> list[str]:
"""Generate gateway help text lines from the registry."""
lines: list[str] = []
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
continue
args = f" {cmd.args_hint}" if cmd.args_hint else ""
alias_parts: list[str] = []
for a in cmd.aliases:
# Skip internal aliases like reload_mcp (underscore variant)
if a.replace("-", "_") == cmd.name.replace("-", "_") and a != cmd.name:
continue
alias_parts.append(f"`/{a}`")
alias_note = f" (alias: {', '.join(alias_parts)})" if alias_parts else ""
lines.append(f"`/{cmd.name}{args}` -- {cmd.description}{alias_note}")
return lines
def telegram_bot_commands() -> list[tuple[str, str]]:
"""Return (command_name, description) pairs for Telegram setMyCommands.
Telegram command names cannot contain hyphens, so they are replaced with
underscores. Aliases are skipped -- Telegram shows one menu entry per
canonical command.
"""
result: list[tuple[str, str]] = []
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
continue
tg_name = cmd.name.replace("-", "_")
result.append((tg_name, cmd.description))
return result
def slack_subcommand_map() -> dict[str, str]:
"""Return subcommand -> /command mapping for Slack /hermes handler.
Maps both canonical names and aliases so /hermes bg do stuff works
the same as /hermes background do stuff.
"""
mapping: dict[str, str] = {}
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
continue
mapping[cmd.name] = f"/{cmd.name}"
for alias in cmd.aliases:
mapping[alias] = f"/{alias}"
return mapping
# ---------------------------------------------------------------------------
# Autocomplete
# ---------------------------------------------------------------------------
class SlashCommandCompleter(Completer):
"""Autocomplete for built-in slash commands and optional skill commands."""

View file

@ -1,218 +0,0 @@
# Checkpoint & Rollback — Implementation Plan
## Goal
Automatic filesystem snapshots before destructive file operations, with user-facing rollback. The agent never sees or interacts with this — it's transparent infrastructure.
## Design Principles
1. **Not a tool** — the LLM never knows about it. Zero prompt tokens, zero tool schema overhead.
2. **Once per turn** — checkpoint at most once per conversation turn (user message → agent response cycle), triggered lazily on the first file-mutating operation. Not on every write.
3. **Opt-in via config** — disabled by default, enabled with `checkpoints: true` in config.yaml.
4. **Works on any directory** — uses a shadow git repo completely separate from the user's project git. Works on git repos, non-git directories, anything.
5. **User-facing rollback**`/rollback` slash command (CLI + gateway) to list and restore checkpoints. Also `hermes rollback` CLI subcommand.
## Architecture
```
~/.hermes/checkpoints/
{sha256(abs_dir)[:16]}/ # Shadow git repo per working directory
HEAD, refs/, objects/... # Standard git internals
HERMES_WORKDIR # Original dir path (for display)
info/exclude # Default excludes (node_modules, .env, etc.)
```
### Core: CheckpointManager (new file: tools/checkpoint_manager.py)
Adapted from PR #559's CheckpointStore. Key changes from the PR:
- **Not a tool** — no schema, no registry entry, no handler
- **Turn-scoped deduplication** — tracks `_checkpointed_dirs: Set[str]` per turn
- **Configurable** — reads `checkpoints` config key
- **Pruning** — keeps last N snapshots per directory (default 50), prunes on take
```python
class CheckpointManager:
def __init__(self, enabled: bool = False, max_snapshots: int = 50):
self.enabled = enabled
self.max_snapshots = max_snapshots
self._checkpointed_dirs: Set[str] = set() # reset each turn
def new_turn(self):
"""Call at start of each conversation turn to reset dedup."""
self._checkpointed_dirs.clear()
def ensure_checkpoint(self, working_dir: str, reason: str = "auto") -> None:
"""Take a checkpoint if enabled and not already done this turn."""
if not self.enabled:
return
abs_dir = str(Path(working_dir).resolve())
if abs_dir in self._checkpointed_dirs:
return
self._checkpointed_dirs.add(abs_dir)
try:
self._take(abs_dir, reason)
except Exception as e:
logger.debug("Checkpoint failed (non-fatal): %s", e)
def list_checkpoints(self, working_dir: str) -> List[dict]:
"""List available checkpoints for a directory."""
...
def restore(self, working_dir: str, commit_hash: str) -> dict:
"""Restore files to a checkpoint state."""
...
def _take(self, working_dir: str, reason: str):
"""Shadow git: add -A + commit. Prune if over max_snapshots."""
...
def _prune(self, shadow_repo: Path):
"""Keep only last max_snapshots commits."""
...
```
### Integration Point: run_agent.py
The AIAgent already owns the conversation loop. Add CheckpointManager as an instance attribute:
```python
class AIAgent:
def __init__(self, ...):
...
# Checkpoint manager — reads config to determine if enabled
self._checkpoint_mgr = CheckpointManager(
enabled=config.get("checkpoints", False),
max_snapshots=config.get("checkpoint_max_snapshots", 50),
)
```
**Turn boundary** — in `run_conversation()`, call `new_turn()` at the start of each agent iteration (before processing tool calls):
```python
# Inside the main loop, before _execute_tool_calls():
self._checkpoint_mgr.new_turn()
```
**Trigger point** — in `_execute_tool_calls()`, before dispatching file-mutating tools:
```python
# Before the handle_function_call dispatch:
if function_name in ("write_file", "patch"):
# Determine working dir from the file path in the args
file_path = function_args.get("path", "") or function_args.get("old_string", "")
if file_path:
work_dir = str(Path(file_path).parent.resolve())
self._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
```
This means:
- First `write_file` in a turn → checkpoint (fast, one `git add -A && git commit`)
- Subsequent writes in the same turn → no-op (already checkpointed)
- Next turn (new user message) → fresh checkpoint eligibility
### Config
Add to `DEFAULT_CONFIG` in `hermes_cli/config.py`:
```python
"checkpoints": False, # Enable filesystem checkpoints before destructive ops
"checkpoint_max_snapshots": 50, # Max snapshots to keep per directory
```
User enables with:
```yaml
# ~/.hermes/config.yaml
checkpoints: true
```
### User-Facing Rollback
**CLI slash command** — add `/rollback` to `process_command()` in `cli.py`:
```
/rollback — List recent checkpoints for the current directory
/rollback <hash> — Restore files to that checkpoint
```
Shows a numbered list:
```
📸 Checkpoints for /home/user/project:
1. abc1234 2026-03-09 21:15 before write_file (3 files changed)
2. def5678 2026-03-09 20:42 before patch (1 file changed)
3. ghi9012 2026-03-09 20:30 before write_file (2 files changed)
Use /rollback <number> to restore, e.g. /rollback 1
```
**Gateway slash command** — add `/rollback` to gateway/run.py with the same behavior.
**CLI subcommand** — `hermes rollback` (optional, lower priority).
### What Gets Excluded (not checkpointed)
Same as the PR's defaults — written to the shadow repo's `info/exclude`:
```
node_modules/
dist/
build/
.env
.env.*
__pycache__/
*.pyc
.DS_Store
*.log
.cache/
.venv/
.git/
```
Also respects the project's `.gitignore` if present (shadow repo can read it via `core.excludesFile`).
### Safety
- `ensure_checkpoint()` wraps everything in try/except — a checkpoint failure never blocks the actual file operation
- Shadow repo is completely isolated — GIT_DIR + GIT_WORK_TREE env vars, never touches user's .git
- If git isn't installed, checkpoints silently disable
- Large directories: add a file count check — skip checkpoint if >50K files to avoid slowdowns
## Files to Create/Modify
| File | Change |
|------|--------|
| `tools/checkpoint_manager.py` | **NEW** — CheckpointManager class (adapted from PR #559) |
| `run_agent.py` | Add CheckpointManager init + trigger in `_execute_tool_calls()` |
| `hermes_cli/config.py` | Add `checkpoints` + `checkpoint_max_snapshots` to DEFAULT_CONFIG |
| `cli.py` | Add `/rollback` slash command handler |
| `gateway/run.py` | Add `/rollback` slash command handler |
| `tests/tools/test_checkpoint_manager.py` | **NEW** — tests (adapted from PR #559's tests) |
## What We Take From PR #559
- `_shadow_repo_path()` — deterministic path hashing ✅
- `_git_env()` — GIT_DIR/GIT_WORK_TREE isolation ✅
- `_run_git()` — subprocess wrapper with timeout ✅
- `_init_shadow_repo()` — shadow repo initialization ✅
- `DEFAULT_EXCLUDES` list ✅
- Test structure and patterns ✅
## What We Change From PR #559
- **Remove tool schema/registry** — not a tool
- **Remove injection into file_operations.py and patch_parser.py** — trigger from run_agent.py instead
- **Add turn-scoped deduplication** — one checkpoint per turn, not per operation
- **Add pruning** — keep last N snapshots
- **Add config flag** — opt-in, not mandatory
- **Add /rollback command** — user-facing restore UI
- **Add file count guard** — skip huge directories
## Implementation Order
1. `tools/checkpoint_manager.py` — core class with take/list/restore/prune
2. `tests/tools/test_checkpoint_manager.py` — tests
3. `hermes_cli/config.py` — config keys
4. `run_agent.py` — integration (init + trigger)
5. `cli.py``/rollback` slash command
6. `gateway/run.py``/rollback` slash command
7. Full test suite run + manual smoke test

View file

@ -272,18 +272,14 @@ class TestBackgroundInHelp:
assert "/background" in result
def test_background_is_known_command(self):
"""The /background command is in the _known_commands set."""
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"background"' in source
"""The /background command is in GATEWAY_KNOWN_COMMANDS."""
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
assert "background" in GATEWAY_KNOWN_COMMANDS
def test_bg_alias_is_known_command(self):
"""The /bg alias is in the _known_commands set."""
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"bg"' in source
"""The /bg alias is in GATEWAY_KNOWN_COMMANDS."""
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
assert "bg" in GATEWAY_KNOWN_COMMANDS
# ---------------------------------------------------------------------------

View file

@ -475,16 +475,15 @@ class TestDiscordPlayTtsSkip:
class TestVoiceInHelp:
def test_voice_in_help_output(self):
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_help_command)
assert "/voice" in source
"""The gateway help text includes /voice (generated from registry)."""
from hermes_cli.commands import gateway_help_lines
help_text = "\n".join(gateway_help_lines())
assert "/voice" in help_text
def test_voice_is_known_command(self):
from gateway.run import GatewayRunner
import inspect
source = inspect.getsource(GatewayRunner._handle_message)
assert '"voice"' in source
"""The /voice command is in GATEWAY_KNOWN_COMMANDS."""
from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS
assert "voice" in GATEWAY_KNOWN_COMMANDS
# =====================================================================

View file

@ -1,20 +1,20 @@
"""Tests for shared slash command definitions and autocomplete."""
"""Tests for the central command registry and autocomplete."""
from prompt_toolkit.completion import CompleteEvent
from prompt_toolkit.document import Document
from hermes_cli.commands import COMMANDS, SlashCommandCompleter
# All commands that must be present in the shared COMMANDS dict.
EXPECTED_COMMANDS = {
"/help", "/tools", "/toolsets", "/model", "/provider", "/prompt",
"/personality", "/clear", "/history", "/new", "/reset", "/retry",
"/undo", "/save", "/config", "/cron", "/skills", "/platforms",
"/verbose", "/reasoning", "/compress", "/title", "/usage", "/insights", "/paste",
"/reload-mcp", "/rollback", "/stop", "/background", "/bg", "/skin", "/voice", "/browser", "/quit",
"/plugins",
}
from hermes_cli.commands import (
COMMAND_REGISTRY,
COMMANDS,
COMMANDS_BY_CATEGORY,
CommandDef,
GATEWAY_KNOWN_COMMANDS,
SlashCommandCompleter,
gateway_help_lines,
resolve_command,
slack_subcommand_map,
telegram_bot_commands,
)
def _completions(completer: SlashCommandCompleter, text: str):
@ -26,21 +26,200 @@ def _completions(completer: SlashCommandCompleter, text: str):
)
class TestCommands:
def test_shared_commands_include_cli_specific_entries(self):
"""Entries that previously only existed in cli.py are now in the shared dict."""
assert COMMANDS["/paste"] == "Check clipboard for an image and attach it"
assert COMMANDS["/reload-mcp"] == "Reload MCP servers from config.yaml"
# ---------------------------------------------------------------------------
# CommandDef registry tests
# ---------------------------------------------------------------------------
def test_all_expected_commands_present(self):
"""Regression guard — every known command must appear in the shared dict."""
assert set(COMMANDS.keys()) == EXPECTED_COMMANDS
class TestCommandRegistry:
def test_registry_is_nonempty(self):
assert len(COMMAND_REGISTRY) > 30
def test_every_entry_is_commanddef(self):
for entry in COMMAND_REGISTRY:
assert isinstance(entry, CommandDef), f"Unexpected type: {type(entry)}"
def test_no_duplicate_canonical_names(self):
names = [cmd.name for cmd in COMMAND_REGISTRY]
assert len(names) == len(set(names)), f"Duplicate names: {[n for n in names if names.count(n) > 1]}"
def test_no_alias_collides_with_canonical_name(self):
"""An alias must not shadow another command's canonical name."""
canonical_names = {cmd.name for cmd in COMMAND_REGISTRY}
for cmd in COMMAND_REGISTRY:
for alias in cmd.aliases:
if alias in canonical_names:
# reset -> new is intentional (reset IS an alias for new)
target = next(c for c in COMMAND_REGISTRY if c.name == alias)
# This should only happen if the alias points to the same entry
assert resolve_command(alias).name == cmd.name or alias == cmd.name, \
f"Alias '{alias}' of '{cmd.name}' shadows canonical '{target.name}'"
def test_every_entry_has_valid_category(self):
valid_categories = {"Session", "Configuration", "Tools & Skills", "Info", "Exit"}
for cmd in COMMAND_REGISTRY:
assert cmd.category in valid_categories, f"{cmd.name} has invalid category '{cmd.category}'"
def test_cli_only_and_gateway_only_are_mutually_exclusive(self):
for cmd in COMMAND_REGISTRY:
assert not (cmd.cli_only and cmd.gateway_only), \
f"{cmd.name} cannot be both cli_only and gateway_only"
# ---------------------------------------------------------------------------
# resolve_command tests
# ---------------------------------------------------------------------------
class TestResolveCommand:
def test_canonical_name_resolves(self):
assert resolve_command("help").name == "help"
assert resolve_command("background").name == "background"
def test_alias_resolves_to_canonical(self):
assert resolve_command("bg").name == "background"
assert resolve_command("reset").name == "new"
assert resolve_command("q").name == "quit"
assert resolve_command("exit").name == "quit"
assert resolve_command("gateway").name == "platforms"
assert resolve_command("set-home").name == "sethome"
assert resolve_command("reload_mcp").name == "reload-mcp"
def test_leading_slash_stripped(self):
assert resolve_command("/help").name == "help"
assert resolve_command("/bg").name == "background"
def test_unknown_returns_none(self):
assert resolve_command("nonexistent") is None
assert resolve_command("") is None
# ---------------------------------------------------------------------------
# Derived dicts (backwards compat)
# ---------------------------------------------------------------------------
class TestDerivedDicts:
def test_commands_dict_excludes_gateway_only(self):
"""gateway_only commands should NOT appear in the CLI COMMANDS dict."""
for cmd in COMMAND_REGISTRY:
if cmd.gateway_only:
assert f"/{cmd.name}" not in COMMANDS, \
f"gateway_only command /{cmd.name} should not be in COMMANDS"
def test_commands_dict_includes_all_cli_commands(self):
for cmd in COMMAND_REGISTRY:
if not cmd.gateway_only:
assert f"/{cmd.name}" in COMMANDS, \
f"/{cmd.name} missing from COMMANDS dict"
def test_commands_dict_includes_aliases(self):
assert "/bg" in COMMANDS
assert "/reset" in COMMANDS
assert "/q" in COMMANDS
assert "/exit" in COMMANDS
assert "/reload_mcp" in COMMANDS
assert "/gateway" in COMMANDS
def test_commands_by_category_covers_all_categories(self):
registry_categories = {cmd.category for cmd in COMMAND_REGISTRY if not cmd.gateway_only}
assert set(COMMANDS_BY_CATEGORY.keys()) == registry_categories
def test_every_command_has_nonempty_description(self):
for cmd, desc in COMMANDS.items():
assert isinstance(desc, str) and len(desc) > 0, f"{cmd} has empty description"
# ---------------------------------------------------------------------------
# Gateway helpers
# ---------------------------------------------------------------------------
class TestGatewayKnownCommands:
def test_excludes_cli_only(self):
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
assert cmd.name not in GATEWAY_KNOWN_COMMANDS, \
f"cli_only command '{cmd.name}' should not be in GATEWAY_KNOWN_COMMANDS"
def test_includes_gateway_commands(self):
for cmd in COMMAND_REGISTRY:
if not cmd.cli_only:
assert cmd.name in GATEWAY_KNOWN_COMMANDS
for alias in cmd.aliases:
assert alias in GATEWAY_KNOWN_COMMANDS
def test_bg_alias_in_gateway(self):
assert "bg" in GATEWAY_KNOWN_COMMANDS
assert "background" in GATEWAY_KNOWN_COMMANDS
def test_is_frozenset(self):
assert isinstance(GATEWAY_KNOWN_COMMANDS, frozenset)
class TestGatewayHelpLines:
def test_returns_nonempty_list(self):
lines = gateway_help_lines()
assert len(lines) > 10
def test_excludes_cli_only_commands(self):
lines = gateway_help_lines()
joined = "\n".join(lines)
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
assert f"`/{cmd.name}" not in joined, \
f"cli_only command /{cmd.name} should not be in gateway help"
def test_includes_alias_note_for_bg(self):
lines = gateway_help_lines()
bg_line = [l for l in lines if "/background" in l]
assert len(bg_line) == 1
assert "/bg" in bg_line[0]
class TestTelegramBotCommands:
def test_returns_list_of_tuples(self):
cmds = telegram_bot_commands()
assert len(cmds) > 10
for name, desc in cmds:
assert isinstance(name, str)
assert isinstance(desc, str)
def test_no_hyphens_in_command_names(self):
"""Telegram does not support hyphens in command names."""
for name, _ in telegram_bot_commands():
assert "-" not in name, f"Telegram command '{name}' contains a hyphen"
def test_excludes_cli_only(self):
names = {name for name, _ in telegram_bot_commands()}
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
tg_name = cmd.name.replace("-", "_")
assert tg_name not in names
class TestSlackSubcommandMap:
def test_returns_dict(self):
mapping = slack_subcommand_map()
assert isinstance(mapping, dict)
assert len(mapping) > 10
def test_values_are_slash_prefixed(self):
for key, val in slack_subcommand_map().items():
assert val.startswith("/"), f"Slack mapping for '{key}' should start with /"
def test_includes_aliases(self):
mapping = slack_subcommand_map()
assert "bg" in mapping
assert "reset" in mapping
def test_excludes_cli_only(self):
mapping = slack_subcommand_map()
for cmd in COMMAND_REGISTRY:
if cmd.cli_only:
assert cmd.name not in mapping
# ---------------------------------------------------------------------------
# Autocomplete (SlashCommandCompleter)
# ---------------------------------------------------------------------------
class TestSlashCommandCompleter:
# -- basic prefix completion -----------------------------------------
@ -55,7 +234,7 @@ class TestSlashCommandCompleter:
def test_builtin_completion_display_meta_shows_description(self):
completions = _completions(SlashCommandCompleter(), "/help")
assert len(completions) == 1
assert completions[0].display_meta_text == "Show this help message"
assert completions[0].display_meta_text == "Show available commands"
# -- exact-match trailing space --------------------------------------

View file

@ -35,7 +35,9 @@ class TestSlashCommandPrefixMatching:
raise RecursionError("process_command called too many times")
return original(self_inner, cmd)
with patch.object(type(cli_obj), 'process_command', counting_process_command):
# Mock show_config since the test is about recursion, not config display
with patch.object(type(cli_obj), 'process_command', counting_process_command), \
patch.object(cli_obj, 'show_config'):
try:
cli_obj.process_command("/con set key value")
except RecursionError:
@ -57,7 +59,9 @@ class TestSlashCommandPrefixMatching:
raise RecursionError("Infinite recursion detected")
return original_pc(self_inner, cmd)
with patch.object(HermesCLI, 'process_command', guarded):
# Mock show_config since the test is about recursion, not config display
with patch.object(HermesCLI, 'process_command', guarded), \
patch.object(cli_obj, 'show_config'):
try:
cli_obj.process_command("/config set key value")
except RecursionError:

View file

@ -6,10 +6,10 @@ description: "Complete reference for interactive CLI and messaging slash command
# Slash Commands Reference
Hermes has two slash-command surfaces:
Hermes has two slash-command surfaces, both driven by a central `COMMAND_REGISTRY` in `hermes_cli/commands.py`:
- **Interactive CLI slash commands**handled by `cli.py` / `hermes_cli/commands.py`
- **Messaging slash commands**handled by `gateway/run.py`
- **Interactive CLI slash commands**dispatched by `cli.py`, with autocomplete from the registry
- **Messaging slash commands**dispatched by `gateway/run.py`, with help text and platform menus generated from the registry
Installed skills are also exposed as dynamic slash commands on both surfaces. That includes bundled skills like `/plan`, which opens plan mode and saves markdown plans under `.hermes/plans/` relative to the active workspace/backend working directory.