351 lines
15 KiB
Markdown
351 lines
15 KiB
Markdown
|
|
# 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
|