15 KiB
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:
/bgalias mention - Gateway
_known_commandshas 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)
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:
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:
# --- 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:
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()
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()
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()
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()
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.
# 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
- Telegram missing commands:
/rollback,/background,/bgautomatically added - Slack missing commands:
/voice,/update,/reload-mcpautomatically added - Gateway duplicate "reasoning": Eliminated (generated from registry)
- Gateway dead code: Second
"reasoning"dispatch branch removed - 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
COMMANDSflat dict andCOMMANDS_BY_CATEGORYdict are rebuilt from the registry, so any code importing them continues to work unchangedSlashCommandCompletercontinues to read fromCOMMANDSdict- No config changes, no user-facing behavior changes
Risks
- Import ordering:
gateway/run.pyimporting fromhermes_cli/commands.py— verify no circular import. Currentlygateway/run.pydoesn't import fromhermes_cli/at all. Need to confirm this works or move the registry to a shared location (e.g.,commands_registry.pyat 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