fix: auto-register all gateway commands as Discord slash commands (#10528)

Discord's _register_slash_commands() had a hardcoded list of ~27 commands
while COMMAND_REGISTRY defines 34+ gateway-available commands. Missing
commands (debug, branch, rollback, snapshot, profile, yolo, fast, reload,
commands) were invisible in Discord's / autocomplete — users couldn't
discover them.

Add a dynamic catch-all loop after the explicit registrations that
iterates COMMAND_REGISTRY, skips already-registered commands, and
auto-registers the rest using discord.app_commands.Command(). Commands
with args_hint get an optional string parameter; parameterless commands
get a simple callback.

This ensures any future commands added to COMMAND_REGISTRY automatically
appear on Discord without needing a manual entry in discord.py.

Telegram and Slack already derive dynamically from COMMAND_REGISTRY
via telegram_bot_commands() and slack_subcommand_map() — no changes
needed there.
This commit is contained in:
Teknium 2026-04-15 14:25:27 -07:00 committed by GitHub
parent c4674cbe21
commit 22d22cd75c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 121 additions and 0 deletions

View file

@ -1802,6 +1802,76 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_btw(interaction: discord.Interaction, question: str):
await self._run_simple_slash(interaction, f"/btw {question}")
# ── Auto-register any gateway-available commands not yet on the tree ──
# This ensures new commands added to COMMAND_REGISTRY in
# hermes_cli/commands.py automatically appear as Discord slash
# commands without needing a manual entry here.
try:
from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates
already_registered = set()
try:
already_registered = {cmd.name for cmd in tree.get_commands()}
except Exception:
pass
config_overrides = _resolve_config_gates()
for cmd_def in COMMAND_REGISTRY:
if not _is_gateway_available(cmd_def, config_overrides):
continue
# Discord command names: lowercase, hyphens OK, max 32 chars.
discord_name = cmd_def.name.lower()[:32]
if discord_name in already_registered:
continue
# Skip aliases that overlap with already-registered names
# (aliases for explicitly registered commands are handled above).
desc = (cmd_def.description or f"Run /{cmd_def.name}")[:100]
has_args = bool(cmd_def.args_hint)
if has_args:
# Command takes optional arguments — create handler with
# an optional ``args`` string parameter.
def _make_args_handler(_name: str, _hint: str):
@discord.app_commands.describe(args=f"Arguments: {_hint}"[:100])
async def _handler(interaction: discord.Interaction, args: str = ""):
await self._run_simple_slash(
interaction, f"/{_name} {args}".strip()
)
_handler.__name__ = f"auto_slash_{_name.replace('-', '_')}"
return _handler
handler = _make_args_handler(cmd_def.name, cmd_def.args_hint)
else:
# Parameterless command.
def _make_simple_handler(_name: str):
async def _handler(interaction: discord.Interaction):
await self._run_simple_slash(interaction, f"/{_name}")
_handler.__name__ = f"auto_slash_{_name.replace('-', '_')}"
return _handler
handler = _make_simple_handler(cmd_def.name)
auto_cmd = discord.app_commands.Command(
name=discord_name,
description=desc,
callback=handler,
)
try:
tree.add_command(auto_cmd)
already_registered.add(discord_name)
except Exception:
# Silently skip commands that fail registration (e.g.
# name conflict with a subcommand group).
pass
logger.debug(
"Discord auto-registered %d commands from COMMAND_REGISTRY",
len(already_registered),
)
except Exception as e:
logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e)
# Register skills under a single /skill command group with category
# subcommand groups. This uses 1 top-level slot instead of N,
# supporting up to 25 categories × 25 skills = 625 skills.

View file

@ -134,6 +134,57 @@ async def test_registers_native_restart_slash_command(adapter):
)
# ------------------------------------------------------------------
# Auto-registration from COMMAND_REGISTRY
# ------------------------------------------------------------------
@pytest.mark.asyncio
async def test_auto_registers_missing_gateway_commands(adapter):
"""Commands in COMMAND_REGISTRY that aren't explicitly registered should
be auto-registered by the dynamic catch-all block."""
adapter._run_simple_slash = AsyncMock()
adapter._register_slash_commands()
tree_names = set(adapter._client.tree.commands.keys())
# These commands are gateway-available but were not in the original
# hardcoded registration list — they should be auto-registered.
expected_auto = {"debug", "yolo", "reload", "profile"}
for name in expected_auto:
assert name in tree_names, f"/{name} should be auto-registered on Discord"
@pytest.mark.asyncio
async def test_auto_registered_command_dispatches_correctly(adapter):
"""Auto-registered commands should dispatch via _run_simple_slash."""
adapter._run_simple_slash = AsyncMock()
adapter._register_slash_commands()
# /debug has no args — test parameterless dispatch
debug_cmd = adapter._client.tree.commands["debug"]
interaction = SimpleNamespace()
adapter._run_simple_slash.reset_mock()
await debug_cmd.callback(interaction)
adapter._run_simple_slash.assert_awaited_once_with(interaction, "/debug")
@pytest.mark.asyncio
async def test_auto_registered_command_with_args(adapter):
"""Auto-registered commands with args_hint should accept an optional args param."""
adapter._run_simple_slash = AsyncMock()
adapter._register_slash_commands()
# /branch has args_hint="[name]" — test dispatch with args
branch_cmd = adapter._client.tree.commands["branch"]
interaction = SimpleNamespace()
adapter._run_simple_slash.reset_mock()
await branch_cmd.callback(interaction, args="my-branch")
adapter._run_simple_slash.assert_awaited_once_with(
interaction, "/branch my-branch"
)
# ------------------------------------------------------------------
# _handle_thread_create_slash — success, session dispatch, failure
# ------------------------------------------------------------------