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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 326 additions and 2 deletions

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)