mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
b798062501
commit
46176c8029
14 changed files with 571 additions and 802 deletions
46
AGENTS.md
46
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. `"<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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
75
cli.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
|
|||
|
|
@ -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 --------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue