diff --git a/cli.py b/cli.py index 20996aecc..3a3e8108f 100644 --- a/cli.py +++ b/cli.py @@ -5484,7 +5484,8 @@ class HermesCLI: version = f" v{p['version']}" if p["version"] else "" tools = f"{p['tools']} tools" if p["tools"] else "" hooks = f"{p['hooks']} hooks" if p["hooks"] else "" - parts = [x for x in [tools, hooks] if x] + commands = f"{p['commands']} commands" if p.get("commands") else "" + parts = [x for x in [tools, hooks, commands] if x] detail = f" ({', '.join(parts)})" if parts else "" error = f" — {p['error']}" if p["error"] else "" print(f" {status} {p['name']}{version}{detail}{error}") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c8a0628fa..48ea5bb59 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -450,7 +450,7 @@ def _collect_gateway_skill_entries( name = sanitize_name(cmd_name) if sanitize_name else cmd_name if not name: continue - desc = "Plugin command" + desc = plugin_cmds[cmd_name].get("description", "Plugin command") if len(desc) > desc_limit: desc = desc[:desc_limit - 3] + "..." plugin_pairs.append((name, desc)) @@ -1139,6 +1139,22 @@ class SlashCommandCompleter(Completer): display_meta=f"⚡ {short_desc}", ) + # Plugin-registered slash commands + try: + from hermes_cli.plugins import get_plugin_commands + for cmd_name, cmd_info in get_plugin_commands().items(): + if cmd_name.startswith(word): + desc = str(cmd_info.get("description", "Plugin command")) + short_desc = desc[:50] + ("..." if len(desc) > 50 else "") + yield Completion( + self._completion_text(cmd_name, word), + start_position=-len(word), + display=f"/{cmd_name}", + display_meta=f"🔌 {short_desc}", + ) + except Exception: + pass + # --------------------------------------------------------------------------- # Inline auto-suggest (ghost text) for slash commands diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 9d78ca47f..5e8ff8e4f 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -112,6 +112,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 @@ -211,6 +212,53 @@ class PluginContext: } logger.debug("Plugin %s registered CLI command: %s", self.manifest.name, name) + # -- slash command registration ------------------------------------------- + + def register_command( + self, + name: str, + handler: Callable, + description: str = "", + ) -> None: + """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions. + + The handler signature is ``fn(raw_args: str) -> str | None``. + It may also be an async callable — the gateway dispatch handles both. + + Unlike ``register_cli_command()`` (which creates ``hermes `` + terminal commands), this registers in-session slash commands that users + invoke during a conversation. + + Names conflicting with built-in commands are rejected with a warning. + """ + clean = name.lower().strip().lstrip("/").replace(" ", "-") + if not clean: + logger.warning( + "Plugin '%s' tried to register a command with an empty name.", + self.manifest.name, + ) + return + + # Reject if it conflicts with a built-in command + try: + from hermes_cli.commands import resolve_command + if resolve_command(clean) is not None: + logger.warning( + "Plugin '%s' tried to register command '/%s' which conflicts " + "with a built-in command. Skipping.", + self.manifest.name, clean, + ) + return + except Exception: + pass # If commands module isn't available, skip the check + + self._manager._plugin_commands[clean] = { + "handler": handler, + "description": description or "Plugin command", + "plugin": self.manifest.name, + } + logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) + # -- context engine registration ----------------------------------------- def register_context_engine(self, engine) -> None: @@ -323,6 +371,7 @@ class PluginManager: self._plugin_tool_names: Set[str] = set() self._cli_commands: Dict[str, dict] = {} self._context_engine = None # Set by a plugin via register_context_engine() + self._plugin_commands: Dict[str, dict] = {} # Slash commands registered by plugins self._discovered: bool = False self._cli_ref = None # Set by CLI after plugin discovery # Plugin skill registry: qualified name → metadata dict. @@ -485,6 +534,10 @@ class PluginManager: for h in p.hooks_registered } ) + loaded.commands_registered = [ + c for c in self._plugin_commands + if self._plugin_commands[c].get("plugin") == manifest.name + ] loaded.enabled = True except Exception as exc: @@ -598,6 +651,7 @@ class PluginManager: "enabled": loaded.enabled, "tools": len(loaded.tools_registered), "hooks": len(loaded.hooks_registered), + "commands": len(loaded.commands_registered), "error": loaded.error, } ) @@ -699,6 +753,20 @@ def get_plugin_context_engine(): return get_plugin_manager()._context_engine +def get_plugin_command_handler(name: str) -> Optional[Callable]: + """Return the handler for a plugin-registered slash command, or ``None``.""" + entry = get_plugin_manager()._plugin_commands.get(name) + return entry["handler"] if entry else None + + +def get_plugin_commands() -> Dict[str, dict]: + """Return the full plugin commands dict (name → {handler, description, plugin}). + + Safe to call before discovery — returns an empty dict if no plugins loaded. + """ + return get_plugin_manager()._plugin_commands + + def get_plugin_toolsets() -> List[tuple]: """Return plugin toolsets as ``(key, label, description)`` tuples. diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 7be1be617..acc63e906 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -18,6 +18,8 @@ from hermes_cli.plugins import ( PluginManager, PluginManifest, get_plugin_manager, + get_plugin_command_handler, + get_plugin_commands, get_pre_tool_call_block_message, discover_plugins, invoke_hook, @@ -605,7 +607,160 @@ class TestPreLlmCallTargetRouting: assert "plain text C" in _plugin_user_context -# NOTE: TestPluginCommands removed – register_command() was never implemented -# in PluginContext (hermes_cli/plugins.py). The tests referenced _plugin_commands, -# commands_registered, get_plugin_command_handler, and GATEWAY_KNOWN_COMMANDS -# integration — all of which are unimplemented features. +# ── TestPluginCommands ──────────────────────────────────────────────────── + + +class TestPluginCommands: + """Tests for plugin slash command registration via register_command().""" + + def test_register_command_basic(self): + """register_command() stores handler, description, and plugin name.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + handler = lambda args: f"echo {args}" + ctx.register_command("mycmd", handler, description="My custom command") + + assert "mycmd" in mgr._plugin_commands + entry = mgr._plugin_commands["mycmd"] + assert entry["handler"] is handler + assert entry["description"] == "My custom command" + assert entry["plugin"] == "test-plugin" + + def test_register_command_normalizes_name(self): + """Names are lowercased, stripped, and leading slashes removed.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + ctx.register_command("/MyCmd ", lambda a: a, description="test") + assert "mycmd" in mgr._plugin_commands + assert "/MyCmd " not in mgr._plugin_commands + + def test_register_command_empty_name_rejected(self, caplog): + """Empty name after normalization is rejected with a warning.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + with caplog.at_level(logging.WARNING): + ctx.register_command("", lambda a: a) + assert len(mgr._plugin_commands) == 0 + assert "empty name" in caplog.text + + def test_register_command_builtin_conflict_rejected(self, caplog): + """Commands that conflict with built-in names are rejected.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + with caplog.at_level(logging.WARNING): + ctx.register_command("help", lambda a: a) + assert "help" not in mgr._plugin_commands + assert "conflicts" in caplog.text.lower() + + def test_register_command_default_description(self): + """Missing description defaults to 'Plugin command'.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + ctx.register_command("status-cmd", lambda a: a) + assert mgr._plugin_commands["status-cmd"]["description"] == "Plugin command" + + def test_get_plugin_command_handler_found(self): + """get_plugin_command_handler() returns the handler for a registered command.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + handler = lambda args: f"result: {args}" + ctx.register_command("mycmd", handler, description="test") + + with patch("hermes_cli.plugins._plugin_manager", mgr): + result = get_plugin_command_handler("mycmd") + assert result is handler + + def test_get_plugin_command_handler_not_found(self): + """get_plugin_command_handler() returns None for unregistered commands.""" + mgr = PluginManager() + with patch("hermes_cli.plugins._plugin_manager", mgr): + assert get_plugin_command_handler("nonexistent") is None + + def test_get_plugin_commands_returns_dict(self): + """get_plugin_commands() returns the full commands dict.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + ctx.register_command("cmd-a", lambda a: a, description="A") + ctx.register_command("cmd-b", lambda a: a, description="B") + + with patch("hermes_cli.plugins._plugin_manager", mgr): + cmds = get_plugin_commands() + assert "cmd-a" in cmds + assert "cmd-b" in cmds + assert cmds["cmd-a"]["description"] == "A" + + def test_commands_tracked_on_loaded_plugin(self, tmp_path, monkeypatch): + """Commands registered during discover_and_load() are tracked on LoadedPlugin.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + _make_plugin_dir( + plugins_dir, "cmd-plugin", + register_body=( + 'ctx.register_command("mycmd", lambda a: "ok", description="Test")' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + loaded = mgr._plugins["cmd-plugin"] + assert loaded.enabled + assert "mycmd" in loaded.commands_registered + + def test_commands_in_list_plugins_output(self, tmp_path, monkeypatch): + """list_plugins() includes command count.""" + plugins_dir = tmp_path / "hermes_test" / "plugins" + _make_plugin_dir( + plugins_dir, "cmd-plugin", + register_body=( + 'ctx.register_command("mycmd", lambda a: "ok", description="Test")' + ), + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test")) + + mgr = PluginManager() + mgr.discover_and_load() + + info = mgr.list_plugins() + assert len(info) == 1 + assert info[0]["commands"] == 1 + + def test_handler_receives_raw_args(self): + """The handler is called with the raw argument string.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + received = [] + ctx.register_command("echo", lambda args: received.append(args) or "ok") + + handler = mgr._plugin_commands["echo"]["handler"] + handler("hello world") + assert received == ["hello world"] + + def test_multiple_plugins_register_different_commands(self): + """Multiple plugins can each register their own commands.""" + mgr = PluginManager() + + for plugin_name, cmd_name in [("plugin-a", "cmd-a"), ("plugin-b", "cmd-b")]: + manifest = PluginManifest(name=plugin_name, source="user") + ctx = PluginContext(manifest, mgr) + ctx.register_command(cmd_name, lambda a: a, description=f"From {plugin_name}") + + assert "cmd-a" in mgr._plugin_commands + assert "cmd-b" in mgr._plugin_commands + assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a" + assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b"