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:
parent
36079c6646
commit
8da410ed95
7 changed files with 326 additions and 2 deletions
21
cli.py
21
cli.py
|
|
@ -893,6 +893,15 @@ from agent.skill_commands import (
|
||||||
_skill_commands = scan_skill_commands()
|
_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]:
|
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."""
|
"""Normalize a CLI skills flag into a deduplicated list of skill identifiers."""
|
||||||
if not skills:
|
if not skills:
|
||||||
|
|
@ -3759,6 +3768,18 @@ class HermesCLI:
|
||||||
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
|
self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]")
|
||||||
else:
|
else:
|
||||||
self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]")
|
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.)
|
# Check for skill slash commands (/gif-search, /axolotl, etc.)
|
||||||
elif base_cmd in _skill_commands:
|
elif base_cmd in _skill_commands:
|
||||||
user_instruction = cmd_original[len(base_cmd):].strip()
|
user_instruction = cmd_original[len(base_cmd):].strip()
|
||||||
|
|
|
||||||
|
|
@ -1588,6 +1588,21 @@ class GatewayRunner:
|
||||||
else:
|
else:
|
||||||
return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')."
|
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
|
# Skill slash commands: /skill-name loads the skill and sends to agent
|
||||||
if command:
|
if command:
|
||||||
try:
|
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]:
|
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("/"))
|
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:
|
def _build_description(cmd: CommandDef) -> str:
|
||||||
"""Build a CLI-facing description string including usage hint."""
|
"""Build a CLI-facing description string including usage hint."""
|
||||||
if cmd.args_hint:
|
if cmd.args_hint:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ Tool registration
|
||||||
-----------------
|
-----------------
|
||||||
``PluginContext.register_tool()`` delegates to ``tools.registry.register()``
|
``PluginContext.register_tool()`` delegates to ``tools.registry.register()``
|
||||||
so plugin-defined tools appear alongside the built-in tools.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -95,6 +101,7 @@ class LoadedPlugin:
|
||||||
module: Optional[types.ModuleType] = None
|
module: Optional[types.ModuleType] = None
|
||||||
tools_registered: List[str] = field(default_factory=list)
|
tools_registered: List[str] = field(default_factory=list)
|
||||||
hooks_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
|
enabled: bool = False
|
||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -141,6 +148,45 @@ class PluginContext:
|
||||||
self._manager._plugin_tool_names.add(name)
|
self._manager._plugin_tool_names.add(name)
|
||||||
logger.debug("Plugin %s registered tool: %s", self.manifest.name, 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 --------------------------------------------------
|
# -- hook registration --------------------------------------------------
|
||||||
|
|
||||||
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
def register_hook(self, hook_name: str, callback: Callable) -> None:
|
||||||
|
|
@ -172,6 +218,7 @@ class PluginManager:
|
||||||
self._plugins: Dict[str, LoadedPlugin] = {}
|
self._plugins: Dict[str, LoadedPlugin] = {}
|
||||||
self._hooks: Dict[str, List[Callable]] = {}
|
self._hooks: Dict[str, List[Callable]] = {}
|
||||||
self._plugin_tool_names: Set[str] = set()
|
self._plugin_tool_names: Set[str] = set()
|
||||||
|
self._plugin_commands: Dict[str, Callable] = {}
|
||||||
self._discovered: bool = False
|
self._discovered: bool = False
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
|
@ -325,6 +372,14 @@ class PluginManager:
|
||||||
for h in p.hooks_registered
|
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
|
loaded.enabled = True
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -420,6 +475,7 @@ class PluginManager:
|
||||||
"enabled": loaded.enabled,
|
"enabled": loaded.enabled,
|
||||||
"tools": len(loaded.tools_registered),
|
"tools": len(loaded.tools_registered),
|
||||||
"hooks": len(loaded.hooks_registered),
|
"hooks": len(loaded.hooks_registered),
|
||||||
|
"commands": len(loaded.commands_registered),
|
||||||
"error": loaded.error,
|
"error": loaded.error,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -454,3 +510,8 @@ def invoke_hook(hook_name: str, **kwargs: Any) -> None:
|
||||||
def get_plugin_tool_names() -> Set[str]:
|
def get_plugin_tool_names() -> Set[str]:
|
||||||
"""Return the set of tool names registered by plugins."""
|
"""Return the set of tool names registered by plugins."""
|
||||||
return get_plugin_manager()._plugin_tool_names
|
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,
|
PluginManifest,
|
||||||
get_plugin_manager,
|
get_plugin_manager,
|
||||||
get_plugin_tool_names,
|
get_plugin_tool_names,
|
||||||
|
get_plugin_command_handler,
|
||||||
discover_plugins,
|
discover_plugins,
|
||||||
invoke_hook,
|
invoke_hook,
|
||||||
)
|
)
|
||||||
|
|
@ -352,3 +353,148 @@ class TestPluginManagerList:
|
||||||
assert "enabled" in p
|
assert "enabled" in p
|
||||||
assert "tools" in p
|
assert "tools" in p
|
||||||
assert "hooks" 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
|
- Called exactly once at startup
|
||||||
- `ctx.register_tool()` puts your tool in the registry — the model sees it immediately
|
- `ctx.register_tool()` puts your tool in the registry — the model sees it immediately
|
||||||
- `ctx.register_hook()` subscribes to lifecycle events
|
- `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
|
- If this function crashes, the plugin is disabled but Hermes continues fine
|
||||||
|
|
||||||
## Step 6: Test it
|
## Step 6: Test it
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ sidebar_position: 20
|
||||||
|
|
||||||
# Plugins
|
# 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.
|
**→ [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 tools | `ctx.register_tool(name, schema, handler)` |
|
||||||
| Add hooks | `ctx.register_hook("post_tool_call", callback)` |
|
| 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"` |
|
| Ship data files | `Path(__file__).parent / "data" / "file.yaml"` |
|
||||||
| Bundle skills | Copy `skill.md` to `~/.hermes/skills/` at load time |
|
| Bundle skills | Copy `skill.md` to `~/.hermes/skills/` at load time |
|
||||||
| Gate on env vars | `requires_env: [API_KEY]` in plugin.yaml |
|
| 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_start` | Session begins |
|
||||||
| `on_session_end` | Session ends |
|
| `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
|
## Managing plugins
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue