feat(plugins): add slash command registration for plugins (#2359)

Plugins can now register slash commands via ctx.register_command()
in their register() function. Commands automatically appear in:
- /help and COMMANDS_BY_CATEGORY (under 'Plugins' category)
- Tab autocomplete in CLI
- Telegram bot menu
- Slack subcommand mapping
- Gateway dispatch

Handler signature: handler(args: str) -> str | None
Async handlers are supported in gateway context.

Changes:
- commands.py: add register_plugin_command() and rebuild_lookups()
- plugins.py: add register_command() to PluginContext, track in
  PluginManager._plugin_commands and LoadedPlugin.commands_registered
- cli.py: dispatch plugin commands in process_command()
- gateway/run.py: dispatch plugin commands before skill commands
- tests: 5 new tests for registration, help, tracking, handler, gateway
- docs: update plugins feature page and build guide
This commit is contained in:
Teknium
2026-03-21 16:00:30 -07:00
committed by GitHub
parent 36079c6646
commit 8da410ed95
7 changed files with 326 additions and 2 deletions

21
cli.py
View File

@@ -893,6 +893,15 @@ from agent.skill_commands import (
_skill_commands = scan_skill_commands()
def _get_plugin_cmd_handler_names() -> set:
"""Return plugin command names (without slash prefix) for dispatch matching."""
try:
from hermes_cli.plugins import get_plugin_manager
return set(get_plugin_manager()._plugin_commands.keys())
except Exception:
return set()
def _parse_skills_argument(skills: str | list[str] | tuple[str, ...] | None) -> list[str]:
"""Normalize a CLI skills flag into a deduplicated list of skill identifiers."""
if not skills:
@@ -3759,6 +3768,18 @@ class HermesCLI:
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
else:
self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
# Check for plugin-registered slash commands
elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names():
from hermes_cli.plugins import get_plugin_command_handler
plugin_handler = get_plugin_command_handler(base_cmd.lstrip("/"))
if plugin_handler:
user_args = cmd_original[len(base_cmd):].strip()
try:
result = plugin_handler(user_args)
if result:
_cprint(str(result))
except Exception as e:
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
# Check for skill slash commands (/gif-search, /axolotl, etc.)
elif base_cmd in _skill_commands:
user_instruction = cmd_original[len(base_cmd):].strip()

View File

@@ -1588,6 +1588,21 @@ class GatewayRunner:
else:
return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')."
# Plugin-registered slash commands
if command:
try:
from hermes_cli.plugins import get_plugin_command_handler
plugin_handler = get_plugin_command_handler(command)
if plugin_handler:
user_args = event.get_command_args().strip()
import asyncio as _aio
result = plugin_handler(user_args)
if _aio.iscoroutine(result):
result = await result
return str(result) if result else None
except Exception as e:
logger.debug("Plugin command dispatch failed (non-fatal): %s", e)
# Skill slash commands: /skill-name loads the skill and sends to agent
if command:
try:

View File

@@ -137,7 +137,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
# ---------------------------------------------------------------------------
# Derived lookups -- rebuilt once at import time
# Derived lookups -- rebuilt once at import time, refreshed by rebuild_lookups()
# ---------------------------------------------------------------------------
def _build_command_lookup() -> dict[str, CommandDef]:
@@ -161,6 +161,58 @@ def resolve_command(name: str) -> CommandDef | None:
return _COMMAND_LOOKUP.get(name.lower().lstrip("/"))
def register_plugin_command(cmd: CommandDef) -> None:
"""Append a plugin-defined command to the registry and refresh lookups."""
COMMAND_REGISTRY.append(cmd)
rebuild_lookups()
def rebuild_lookups() -> None:
"""Rebuild all derived lookup dicts from the current COMMAND_REGISTRY.
Called after plugin commands are registered so they appear in help,
autocomplete, gateway dispatch, Telegram menu, and Slack mapping.
"""
global GATEWAY_KNOWN_COMMANDS
_COMMAND_LOOKUP.clear()
_COMMAND_LOOKUP.update(_build_command_lookup())
COMMANDS.clear()
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})"
COMMANDS_BY_CATEGORY.clear()
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}"]
SUBCOMMANDS.clear()
for cmd in COMMAND_REGISTRY:
if cmd.subcommands:
SUBCOMMANDS[f"/{cmd.name}"] = list(cmd.subcommands)
for cmd in COMMAND_REGISTRY:
key = f"/{cmd.name}"
if key in SUBCOMMANDS or not cmd.args_hint:
continue
m = _PIPE_SUBS_RE.search(cmd.args_hint)
if m:
SUBCOMMANDS[key] = m.group(0).split("|")
GATEWAY_KNOWN_COMMANDS = frozenset(
name
for cmd in COMMAND_REGISTRY
if not cmd.cli_only
for name in (cmd.name, *cmd.aliases)
)
def _build_description(cmd: CommandDef) -> str:
"""Build a CLI-facing description string including usage hint."""
if cmd.args_hint:

View File

@@ -23,6 +23,12 @@ Tool registration
-----------------
``PluginContext.register_tool()`` delegates to ``tools.registry.register()``
so plugin-defined tools appear alongside the built-in tools.
Slash command registration
--------------------------
``PluginContext.register_command()`` adds a slash command to the central
``COMMAND_REGISTRY`` so it appears in /help, autocomplete, and gateway
dispatch. Handlers receive the argument string and return a response.
"""
from __future__ import annotations
@@ -95,6 +101,7 @@ class LoadedPlugin:
module: Optional[types.ModuleType] = None
tools_registered: List[str] = field(default_factory=list)
hooks_registered: List[str] = field(default_factory=list)
commands_registered: List[str] = field(default_factory=list)
enabled: bool = False
error: Optional[str] = None
@@ -141,6 +148,45 @@ class PluginContext:
self._manager._plugin_tool_names.add(name)
logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
# -- command registration ------------------------------------------------
def register_command(
self,
name: str,
handler: Callable,
description: str = "",
aliases: tuple[str, ...] = (),
args_hint: str = "",
cli_only: bool = False,
gateway_only: bool = False,
) -> None:
"""Register a slash command in the central command registry.
The *handler* is called with a single ``args`` string (everything
after the command name) and should return a string to display to the
user, or ``None`` for no output. Async handlers are also supported
(they will be awaited in the gateway).
The command automatically appears in ``/help``, tab-autocomplete,
Telegram bot menu, Slack subcommand mapping, and gateway dispatch.
"""
from hermes_cli.commands import CommandDef, register_plugin_command
cmd_def = CommandDef(
name=name,
description=description or f"Plugin command: {name}",
category="Plugins",
aliases=aliases,
args_hint=args_hint,
cli_only=cli_only,
gateway_only=gateway_only,
)
register_plugin_command(cmd_def)
self._manager._plugin_commands[name] = handler
for alias in aliases:
self._manager._plugin_commands[alias] = handler
logger.debug("Plugin %s registered command: /%s", self.manifest.name, name)
# -- hook registration --------------------------------------------------
def register_hook(self, hook_name: str, callback: Callable) -> None:
@@ -172,6 +218,7 @@ class PluginManager:
self._plugins: Dict[str, LoadedPlugin] = {}
self._hooks: Dict[str, List[Callable]] = {}
self._plugin_tool_names: Set[str] = set()
self._plugin_commands: Dict[str, Callable] = {}
self._discovered: bool = False
# -----------------------------------------------------------------------
@@ -325,6 +372,14 @@ class PluginManager:
for h in p.hooks_registered
}
)
loaded.commands_registered = [
c for c in self._plugin_commands
if c not in {
n
for name, p in self._plugins.items()
for n in p.commands_registered
}
]
loaded.enabled = True
except Exception as exc:
@@ -420,6 +475,7 @@ class PluginManager:
"enabled": loaded.enabled,
"tools": len(loaded.tools_registered),
"hooks": len(loaded.hooks_registered),
"commands": len(loaded.commands_registered),
"error": loaded.error,
}
)
@@ -454,3 +510,8 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> None:
def get_plugin_tool_names() -> Set[str]:
"""Return the set of tool names registered by plugins."""
return get_plugin_manager()._plugin_tool_names
def get_plugin_command_handler(name: str) -> Optional[Callable]:
"""Return the handler for a plugin-registered slash command, or None."""
return get_plugin_manager()._plugin_commands.get(name)

View File

@@ -19,6 +19,7 @@ from hermes_cli.plugins import (
PluginManifest,
get_plugin_manager,
get_plugin_tool_names,
get_plugin_command_handler,
discover_plugins,
invoke_hook,
)
@@ -352,3 +353,148 @@ class TestPluginManagerList:
assert "enabled" in p
assert "tools" in p
assert "hooks" in p
assert "commands" in p
# ── TestPluginCommands ────────────────────────────────────────────────────
class TestPluginCommands:
"""Tests for plugin slash command registration."""
def test_register_command_adds_to_registry(self, tmp_path, monkeypatch):
"""PluginContext.register_command() adds a CommandDef to COMMAND_REGISTRY."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
plugin_dir = plugins_dir / "cmd_plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "cmd_plugin"}))
(plugin_dir / "__init__.py").write_text(
'def _greet(args):\n'
' return f"Hello, {args or \'world\'}!"\n'
'\n'
'def register(ctx):\n'
' ctx.register_command(\n'
' name="greet",\n'
' handler=_greet,\n'
' description="Greet someone",\n'
' args_hint="[name]",\n'
' aliases=("hi",),\n'
' )\n'
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
# Command handler is registered
assert "greet" in mgr._plugin_commands
assert "hi" in mgr._plugin_commands
assert mgr._plugin_commands["greet"]("Alice") == "Hello, Alice!"
assert mgr._plugin_commands["greet"]("") == "Hello, world!"
# CommandDef is in the registry
from hermes_cli.commands import resolve_command
cmd_def = resolve_command("greet")
assert cmd_def is not None
assert cmd_def.name == "greet"
assert cmd_def.description == "Greet someone"
assert cmd_def.category == "Plugins"
assert "hi" in cmd_def.aliases
# Alias resolves to same CommandDef
assert resolve_command("hi") is cmd_def
def test_register_command_appears_in_help(self, tmp_path, monkeypatch):
"""Plugin commands appear in COMMANDS dict for /help display."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
plugin_dir = plugins_dir / "help_plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "help_plugin"}))
(plugin_dir / "__init__.py").write_text(
'def register(ctx):\n'
' ctx.register_command(\n'
' name="myhelpcmd",\n'
' handler=lambda args: "ok",\n'
' description="My help command",\n'
' )\n'
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
from hermes_cli.commands import COMMANDS, COMMANDS_BY_CATEGORY
assert "/myhelpcmd" in COMMANDS
assert "Plugins" in COMMANDS_BY_CATEGORY
assert "/myhelpcmd" in COMMANDS_BY_CATEGORY["Plugins"]
def test_register_command_tracks_on_loaded_plugin(self, tmp_path, monkeypatch):
"""LoadedPlugin.commands_registered tracks plugin commands."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
plugin_dir = plugins_dir / "tracked_plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "tracked_plugin"}))
(plugin_dir / "__init__.py").write_text(
'def register(ctx):\n'
' ctx.register_command(\n'
' name="tracked",\n'
' handler=lambda args: "ok",\n'
' aliases=("tr",),\n'
' )\n'
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
loaded = mgr._plugins["tracked_plugin"]
assert "tracked" in loaded.commands_registered
assert "tr" in loaded.commands_registered
def test_get_plugin_command_handler(self, tmp_path, monkeypatch):
"""get_plugin_command_handler() returns handler or None."""
import hermes_cli.plugins as plugins_mod
plugins_dir = tmp_path / "hermes_test" / "plugins"
plugin_dir = plugins_dir / "handler_plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "handler_plugin"}))
(plugin_dir / "__init__.py").write_text(
'def register(ctx):\n'
' ctx.register_command(\n'
' name="dostuff",\n'
' handler=lambda args: "did stuff",\n'
' )\n'
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
monkeypatch.setattr(plugins_mod, "_plugin_manager", mgr)
handler = get_plugin_command_handler("dostuff")
assert handler is not None
assert handler("") == "did stuff"
assert get_plugin_command_handler("nonexistent") is None
def test_gateway_known_commands_updated(self, tmp_path, monkeypatch):
"""Plugin commands appear in GATEWAY_KNOWN_COMMANDS for gateway dispatch."""
plugins_dir = tmp_path / "hermes_test" / "plugins"
plugin_dir = plugins_dir / "gw_plugin"
plugin_dir.mkdir(parents=True)
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "gw_plugin"}))
(plugin_dir / "__init__.py").write_text(
'def register(ctx):\n'
' ctx.register_command(\n'
' name="gwcmd",\n'
' handler=lambda args: "gw ok",\n'
' )\n'
)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
mgr = PluginManager()
mgr.discover_and_load()
from hermes_cli import commands as cmd_mod
assert "gwcmd" in cmd_mod.GATEWAY_KNOWN_COMMANDS

View File

@@ -232,6 +232,7 @@ def register(ctx):
- Called exactly once at startup
- `ctx.register_tool()` puts your tool in the registry — the model sees it immediately
- `ctx.register_hook()` subscribes to lifecycle events
- `ctx.register_command()` adds a slash command to `/help`, autocomplete, and gateway dispatch
- If this function crashes, the plugin is disabled but Hermes continues fine
## Step 6: Test it

View File

@@ -4,7 +4,7 @@ sidebar_position: 20
# Plugins
Hermes has a plugin system for adding custom tools, hooks, and integrations without modifying core code.
Hermes has a plugin system for adding custom tools, hooks, slash commands, and integrations without modifying core code.
**→ [Build a Hermes Plugin](/docs/guides/build-a-hermes-plugin)** — step-by-step guide with a complete working example.
@@ -30,6 +30,7 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable
|-----------|-----|
| Add tools | `ctx.register_tool(name, schema, handler)` |
| Add hooks | `ctx.register_hook("post_tool_call", callback)` |
| Add slash commands | `ctx.register_command("mycommand", handler)` |
| Ship data files | `Path(__file__).parent / "data" / "file.yaml"` |
| Bundle skills | Copy `skill.md` to `~/.hermes/skills/` at load time |
| Gate on env vars | `requires_env: [API_KEY]` in plugin.yaml |
@@ -54,6 +55,33 @@ Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable
| `on_session_start` | Session begins |
| `on_session_end` | Session ends |
## Slash commands
Plugins can register slash commands that work in both CLI and messaging platforms:
```python
def register(ctx):
ctx.register_command(
name="greet",
handler=lambda args: f"Hello, {args or 'world'}!",
description="Greet someone",
args_hint="[name]",
aliases=("hi",),
)
```
The handler receives the argument string (everything after `/greet`) and returns a string to display. Registered commands automatically appear in `/help`, tab autocomplete, Telegram bot menu, and Slack subcommand mapping.
| Parameter | Description |
|-----------|-------------|
| `name` | Command name without slash |
| `handler` | Callable that takes `args: str` and returns `str | None` |
| `description` | Shown in `/help` |
| `args_hint` | Usage hint, e.g. `"[name]"` |
| `aliases` | Tuple of alternative names |
| `cli_only` | Only available in CLI |
| `gateway_only` | Only available in messaging platforms |
## Managing plugins
```