feat(gateway): expose plugin slash commands natively on all platforms + decision-capable command hook

Plugin slash commands now surface as first-class commands in every gateway
enumerator — Discord native slash picker, Telegram BotCommand menu, Slack
/hermes subcommand map — without a separate per-platform plugin API.

The existing 'command:<name>' gateway hook gains a decision protocol via
HookRegistry.emit_collect(): handlers that return a dict with
{'decision': 'deny'|'handled'|'rewrite'|'allow'} can intercept slash
command dispatch before core handling runs, unifying what would otherwise
have been a parallel 'pre_gateway_command' hook surface.

Changes:

- gateway/hooks.py: add HookRegistry.emit_collect() that fires the same
  handler set as emit() but collects non-None return values. Backward
  compatible — fire-and-forget telemetry hooks still work via emit().
- hermes_cli/plugins.py: add optional 'args_hint' param to
  register_command() so plugins can opt into argument-aware native UI
  registration (Discord arg picker, future platforms).
- hermes_cli/commands.py: add _iter_plugin_command_entries() helper and
  merge plugin commands into telegram_bot_commands() and
  slack_subcommand_map(). New is_gateway_known_command() recognizes both
  built-in and plugin commands so the gateway hook fires for either.
- gateway/platforms/discord.py: extract _build_auto_slash_command helper
  from the COMMAND_REGISTRY auto-register loop and reuse it for
  plugin-registered commands. Built-in name conflicts are skipped.
- gateway/run.py: before normal slash dispatch, call emit_collect on
  command:<canonical> and honor deny/handled/rewrite/allow decisions.
  Hook now fires for plugin commands too.
- scripts/release.py: AUTHOR_MAP entry for @Magaav.
- Tests: emit_collect semantics, plugin command surfacing per platform,
  decision protocol (deny/handled/rewrite/allow + non-dict tolerance),
  Discord plugin auto-registration + conflict skipping, is_gateway_known_command.

Salvaged from #14131 (@Magaav). Original PR added a parallel
'pre_gateway_command' hook and a platform-keyed plugin command
registry; this re-implementation reuses the existing 'command:<name>'
hook and treats plugin commands as platform-agnostic so the same
capability reaches Telegram and Slack without new API surface.

Co-authored-by: Magaav <73175452+Magaav@users.noreply.github.com>
This commit is contained in:
Teknium
2026-04-22 15:01:50 -07:00
committed by Teknium
parent c96a548bde
commit 51ca575994
11 changed files with 778 additions and 58 deletions

View File

@@ -1208,3 +1208,119 @@ class TestDiscordSkillCommandsByCategory:
assert "axolotl" in names
assert "vllm" in names
assert len(uncategorized) == 0
# ---------------------------------------------------------------------------
# Plugin slash command integration
# ---------------------------------------------------------------------------
class TestPluginCommandEnumeration:
"""Plugin commands registered via ctx.register_command() must be surfaced
by every gateway enumerator (Telegram menu, Slack subcommand map, etc.).
"""
def _patch_plugin_commands(self, monkeypatch, commands):
"""Monkeypatch hermes_cli.plugins.get_plugin_commands() to a fixed dict."""
from hermes_cli import plugins as _plugins_mod
monkeypatch.setattr(
_plugins_mod, "get_plugin_commands", lambda: dict(commands)
)
def test_plugin_command_appears_in_telegram_menu(self, monkeypatch):
"""/metricas registered by a plugin must appear in Telegram BotCommand menu."""
self._patch_plugin_commands(monkeypatch, {
"metricas": {
"handler": lambda _a: "ok",
"description": "Metrics dashboard",
"args_hint": "dias:7",
"plugin": "metrics-plugin",
}
})
names = {name for name, _desc in telegram_bot_commands()}
assert "metricas" in names
def test_plugin_command_appears_in_slack_subcommand_map(self, monkeypatch):
"""/hermes metricas must route through the Slack subcommand map."""
self._patch_plugin_commands(monkeypatch, {
"metricas": {
"handler": lambda _a: "ok",
"description": "Metrics",
"args_hint": "",
"plugin": "metrics-plugin",
}
})
mapping = slack_subcommand_map()
assert mapping.get("metricas") == "/metricas"
def test_plugin_command_does_not_shadow_builtin_in_slack(self, monkeypatch):
"""If a plugin registers a name that collides with a built-in, the built-in mapping wins."""
self._patch_plugin_commands(monkeypatch, {
"status": {
"handler": lambda _a: "plugin-status",
"description": "Plugin status",
"args_hint": "",
"plugin": "shadow-plugin",
}
})
mapping = slack_subcommand_map()
# Built-in /status must still be present and not overwritten.
assert mapping.get("status") == "/status"
def test_plugin_command_with_hyphens_sanitized_for_telegram(self, monkeypatch):
"""Plugin names containing hyphens must be underscore-normalized for Telegram."""
self._patch_plugin_commands(monkeypatch, {
"my-plugin-cmd": {
"handler": lambda _a: "ok",
"description": "desc",
"args_hint": "",
"plugin": "p",
}
})
names = {name for name, _desc in telegram_bot_commands()}
assert "my_plugin_cmd" in names
assert "my-plugin-cmd" not in names
def test_is_gateway_known_command_recognizes_plugin_commands(self, monkeypatch):
"""is_gateway_known_command() must return True for plugin commands."""
from hermes_cli.commands import is_gateway_known_command
self._patch_plugin_commands(monkeypatch, {
"metricas": {
"handler": lambda _a: "ok",
"description": "Metrics",
"args_hint": "",
"plugin": "p",
}
})
assert is_gateway_known_command("metricas") is True
assert is_gateway_known_command("definitely-not-registered") is False
def test_is_gateway_known_command_still_recognizes_builtins(self, monkeypatch):
"""Built-in commands must remain known even when plugin discovery fails."""
from hermes_cli import plugins as _plugins_mod
from hermes_cli.commands import is_gateway_known_command
def _boom():
raise RuntimeError("plugin system down")
monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom)
assert is_gateway_known_command("status") is True
assert is_gateway_known_command(None) is False
assert is_gateway_known_command("") is False
def test_plugin_enumerator_handles_missing_plugin_manager(self, monkeypatch):
"""Enumerators must never raise when plugin discovery raises."""
from hermes_cli import plugins as _plugins_mod
def _boom():
raise RuntimeError("plugin system down")
monkeypatch.setattr(_plugins_mod, "get_plugin_commands", _boom)
# Both calls should succeed and just return the built-in set.
tg_names = {name for name, _desc in telegram_bot_commands()}
slack_names = set(slack_subcommand_map())
assert "status" in tg_names
assert "status" in slack_names

View File

@@ -787,6 +787,33 @@ class TestPluginCommands:
assert entry["handler"] is handler
assert entry["description"] == "My custom command"
assert entry["plugin"] == "test-plugin"
# args_hint defaults to empty string when not passed.
assert entry["args_hint"] == ""
def test_register_command_with_args_hint(self):
"""args_hint is stored and surfaced for gateway-native UI registration."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
ctx.register_command(
"metricas",
lambda a: a,
description="Metrics dashboard",
args_hint="dias:7 formato:json",
)
entry = mgr._plugin_commands["metricas"]
assert entry["args_hint"] == "dias:7 formato:json"
def test_register_command_args_hint_whitespace_trimmed(self):
"""args_hint leading/trailing whitespace is stripped."""
mgr = PluginManager()
manifest = PluginManifest(name="test-plugin", source="user")
ctx = PluginContext(manifest, mgr)
ctx.register_command("foo", lambda a: a, args_hint=" <file> ")
assert mgr._plugin_commands["foo"]["args_hint"] == "<file>"
def test_register_command_normalizes_name(self):
"""Names are lowercased, stripped, and leading slashes removed."""