"""Expose hermes-agent's COMMAND_REGISTRY to the webui frontend. This module is the single integration point with hermes_cli.commands. If hermes-agent is unavailable the endpoint degrades to an empty list so the frontend can still load with WEBUI_ONLY commands. """ from __future__ import annotations import logging from typing import Any logger = logging.getLogger(__name__) # Commands that are gateway_only in the agent registry -- webui never # wants to expose them (sethome, restart, update etc.) even if a future # agent version drops the gateway_only flag. /commands is the agent's # own command-listing command; webui has its own /help that calls # cmdHelp() locally, so /commands would be redundant and confusing. _NEVER_EXPOSE: frozenset[str] = frozenset({ 'sethome', 'restart', 'update', 'commands', }) def list_commands(_registry=None) -> list[dict[str, Any]]: """Return COMMAND_REGISTRY entries as JSON-friendly dicts. Returns empty list if hermes_cli is not installed (graceful degradation -- the frontend has its own fallback minimum set). Args: _registry: Optional injected registry for testing. When None (production), imports COMMAND_REGISTRY from hermes_cli. """ if _registry is None: try: from hermes_cli.commands import COMMAND_REGISTRY as _registry except ImportError: logger.warning("hermes_cli.commands not importable -- /api/commands returns []") return [] out: list[dict[str, Any]] = [] for cmd in _registry: if cmd.gateway_only: continue if cmd.name in _NEVER_EXPOSE: continue out.append({ 'name': cmd.name, 'description': cmd.description, 'category': cmd.category, 'aliases': list(cmd.aliases), 'args_hint': cmd.args_hint, 'subcommands': list(cmd.subcommands), 'cli_only': bool(cmd.cli_only), 'gateway_only': bool(cmd.gateway_only), }) # Include plugin-registered slash commands try: from hermes_cli.plugins import get_plugin_commands plugin_cmds = get_plugin_commands() or {} existing_names = {c['name'] for c in out} for cmd_name, cmd_info in plugin_cmds.items(): if cmd_name in existing_names or cmd_name in _NEVER_EXPOSE: continue out.append({ 'name': cmd_name, 'description': str(cmd_info.get('description', 'Plugin command')), 'category': 'Plugin', 'aliases': [], 'args_hint': str(cmd_info.get('args_hint', '')), 'subcommands': [], 'cli_only': False, 'gateway_only': False, }) except Exception: pass return out def execute_plugin_command(command: str) -> str: """Execute a plugin-registered slash command and return printable output. Unknown commands raise ``KeyError`` so the HTTP layer can return 404. Plugin handler failures are returned as output text instead of surfacing as transport errors, matching Hermes' existing slash-command UX. """ raw = str(command or "").strip() if not raw: raise ValueError("command is required") cmd_text = raw[1:] if raw.startswith("/") else raw cmd_parts = cmd_text.split(maxsplit=1) cmd_base = (cmd_parts[0] if cmd_parts else "").strip().lower() cmd_arg = cmd_parts[1] if len(cmd_parts) > 1 else "" if not cmd_base: raise ValueError("command is required") try: from hermes_cli.plugins import ( get_plugin_command_handler, resolve_plugin_command_result, ) except ImportError as exc: raise RuntimeError("plugin command runtime unavailable") from exc try: handler = get_plugin_command_handler(cmd_base) except Exception as exc: raise RuntimeError(f"plugin command lookup failed: {exc}") from exc if not handler: raise KeyError(cmd_base) try: result = resolve_plugin_command_result(handler(cmd_arg)) return str(result or "(no output)") except Exception as exc: # Don't leak raw exception str (paths, env, internal state) to the # user-facing chat. Type name is enough for the user to know what # class of failure occurred; full traceback lives in the server log. logger.warning("Plugin command %r failed", cmd_base, exc_info=exc) return f"Plugin command error: {type(exc).__name__}"