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