diff --git a/cli.py b/cli.py index 9bfb01b8b..d6281e7d7 100755 --- a/cli.py +++ b/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() diff --git a/gateway/run.py b/gateway/run.py index 8c34935c1..040ac6773 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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: diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1c687f6d3..319f116c8 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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: diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index b807db40d..2c14f0ed7 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 1ea4fcb8a..d2b259454 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -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 diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index c141f895a..3df958c82 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -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 diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 7f58d84d3..967c037f9 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -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 ```