From 46176c8029ce5c6cd7f0314d8672f9098d0b92fa Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 16 Mar 2026 23:21:03 -0700 Subject: [PATCH] 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 --- AGENTS.md | 46 ++- CONTRIBUTING.md | 2 +- cli.py | 75 ++--- docs/plans/centralize-command-registry.md | 350 ---------------------- gateway/platforms/slack.py | 23 +- gateway/platforms/telegram.py | 24 +- gateway/run.py | 91 +++--- hermes_cli/commands.py | 276 +++++++++++++---- plans/checkpoint-rollback.md | 218 -------------- tests/gateway/test_background_command.py | 16 +- tests/gateway/test_voice_command.py | 15 +- tests/hermes_cli/test_commands.py | 223 ++++++++++++-- tests/test_cli_prefix_matching.py | 8 +- website/docs/reference/slash-commands.md | 6 +- 14 files changed, 571 insertions(+), 802 deletions(-) delete mode 100644 docs/plans/centralize-command-registry.md delete mode 100644 plans/checkpoint-rollback.md diff --git a/AGENTS.md b/AGENTS.md index 5c31f1d1b..c1fa098bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. `""`, `"[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. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b940000e0..d866539ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/cli.py b/cli.py index 5ac561c14..0cd37ece8 100755 --- a/cli.py +++ b/cli.py @@ -3266,18 +3266,25 @@ class HermesCLI: # Lowercase only for dispatch matching; preserve original case for arguments cmd_lower = command.lower().strip() cmd_original = command.strip() + + # 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 cmd_lower in ("/quit", "/exit", "/q"): + 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 ") 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) diff --git a/docs/plans/centralize-command-registry.md b/docs/plans/centralize-command-registry.md deleted file mode 100644 index 71bef8e8b..000000000 --- a/docs/plans/centralize-command-registry.md +++ /dev/null @@ -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: "", "[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=""), - 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 diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index aa9ee49e4..cc8ebea53 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -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 diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 47d2ae551..cec1d7b68 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -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( diff --git a/gateway/run.py b/gateway/run.py index 696469219..da8953fcc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1285,45 +1285,47 @@ 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, "command": command, "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 ` — 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 diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 68b112c97..9663c165a 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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 )", - "/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: "", "[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=""), + 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.""" diff --git a/plans/checkpoint-rollback.md b/plans/checkpoint-rollback.md deleted file mode 100644 index 1fa3f4ee3..000000000 --- a/plans/checkpoint-rollback.md +++ /dev/null @@ -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 — 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 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 diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index 0d6d7bef1..f22a187c0 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 9c9d5753a..e04fde767 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -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 # ===================================================================== diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index fba55dbee..3c4fb8201 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -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 -------------------------------------- diff --git a/tests/test_cli_prefix_matching.py b/tests/test_cli_prefix_matching.py index ffec91957..d5174555e 100644 --- a/tests/test_cli_prefix_matching.py +++ b/tests/test_cli_prefix_matching.py @@ -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: diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 9a27a7131..c3de04697 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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.