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

@ -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."""