emma-hermes/docs/plans/centralize-command-registry.md

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: /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)

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

  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