mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
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:
21
cli.py
21
cli.py
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user