diff --git a/api/commands.py b/api/commands.py index dac86c39..12a0077a 100644 --- a/api/commands.py +++ b/api/commands.py @@ -53,4 +53,68 @@ def list_commands(_registry=None) -> list[dict[str, Any]]: '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: + return f"Plugin command error: {exc}" diff --git a/api/routes.py b/api/routes.py index 9c08268b..16aaf797 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4539,6 +4539,22 @@ def handle_post(handler, parsed) -> bool: if parsed.path == "/api/clarify/respond": return _handle_clarify_respond(handler, body) + # ── Commands (POST) ── + if parsed.path == "/api/commands/exec": + from api.commands import execute_plugin_command + + command = str(body.get("command", "") or "").strip() + if not command: + return bad(handler, "command is required") + try: + return j(handler, {"output": execute_plugin_command(command)}) + except ValueError as e: + return bad(handler, str(e), 400) + except KeyError: + return bad(handler, "Plugin command not found", 404) + except RuntimeError as e: + return bad(handler, _sanitize_error(e), 500) + # ── Skills (POST) ── if parsed.path == "/api/skills/save": return _handle_skill_save(handler, body) diff --git a/static/commands.js b/static/commands.js index 6875135e..bd2f146f 100644 --- a/static/commands.js +++ b/static/commands.js @@ -79,6 +79,18 @@ function getMatchingCommands(prefix){ matches.push(skill); seen.add(skill.name); } + // Include agent/plugin commands from /api/commands metadata + for(const cmd of (_agentCommandCache||[])){ + const name=String(cmd&&cmd.name||'').toLowerCase(); + if(!name.startsWith(q)||seen.has(name))continue; + if(cmd.cli_only)continue; + matches.push({ + name, + desc:String(cmd&&cmd.description||'').trim()||'Agent command', + source:cmd.category==='Plugin'?'plugin':'agent', + }); + seen.add(name); + } return matches; } @@ -191,9 +203,10 @@ function _getSlashSubArgOptions(spec){ return Promise.resolve([]); } +let _agentCommandCacheReady=false; async function loadAgentCommandMetadata(force=false){ - if(_agentCommandCache&&!force) return _agentCommandCache; - if(_agentCommandCachePromise&&!force) return _agentCommandCachePromise; + if(_agentCommandCacheReady&&!force)return _agentCommandCache||[]; + if(_agentCommandCachePromise&&!force)return _agentCommandCachePromise; _agentCommandCachePromise=(async()=>{ try{ const data=await api('/api/commands'); @@ -201,6 +214,7 @@ async function loadAgentCommandMetadata(force=false){ }catch(_){ _agentCommandCache=[]; }finally{ + _agentCommandCacheReady=true; _agentCommandCachePromise=null; } return _agentCommandCache; @@ -229,6 +243,16 @@ function cliOnlyCommandResponse(cmdName, meta){ return `\`/${name}\` is a Hermes CLI-only command and cannot run inside the WebUI.${detail}${extra}`; } +async function executeAgentPluginCommand(text,_meta){ + const command=String(text||'').trim(); + if(!command) throw new Error('command is required'); + const data=await api('/api/commands/exec',{ + method:'POST', + body:JSON.stringify({command}) + }); + return String(data&&data.output||'(no output)'); +} + function _parseSlashAutocomplete(text){ if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null; const raw=text.slice(1); @@ -1105,6 +1129,10 @@ function refreshSlashCommandDropdown(){ function ensureSkillCommandsLoadedForAutocomplete(){ if(_skillCommandCacheReady||_skillCommandLoadPromise)return; loadSkillCommands().then(()=>{refreshSlashCommandDropdown();}); + // Also preload agent/plugin command metadata for autocomplete + if(!_agentCommandCacheReady&&!_agentCommandCachePromise){ + loadAgentCommandMetadata().then(()=>{refreshSlashCommandDropdown();}); + } } // ── Autocomplete dropdown ─────────────────────────────────────────────────── diff --git a/static/messages.js b/static/messages.js index 560ff623..fc7c6d97 100644 --- a/static/messages.js +++ b/static/messages.js @@ -166,6 +166,21 @@ async function send(){ renderMessages(); $('msg').value='';autoResize();hideCmdDropdown();return; } + if(_agentCmd&&_agentCmd.category==='Plugin'){ + if(!S.session){await newSession();await renderSessionList();} + S.messages.push({role:'user',content:text,_ts:Date.now()/1000}); + let _pluginOutput='(no output)'; + try{ + _pluginOutput=typeof executeAgentPluginCommand==='function' + ? await executeAgentPluginCommand(text,_agentCmd) + : 'Plugin command runtime unavailable in WebUI.'; + }catch(e){ + _pluginOutput=`Plugin command error: ${e&&e.message||e}`; + } + S.messages.push({role:'assistant',content:String(_pluginOutput||'(no output)'),_ts:Date.now()/1000}); + renderMessages(); + $('msg').value='';autoResize();hideCmdDropdown();return; + } } } if(!S.session){await newSession();await renderSessionList();} diff --git a/tests/test_cli_only_slash_commands.py b/tests/test_cli_only_slash_commands.py index b1449d32..4a396dc9 100644 --- a/tests/test_cli_only_slash_commands.py +++ b/tests/test_cli_only_slash_commands.py @@ -170,14 +170,16 @@ def test_send_intercepts_cli_only_commands_before_agent_round_trip(): def test_unknown_slash_commands_still_fall_through_to_agent(): - """Only known cli_only commands should be intercepted.""" + """Only explicitly supported metadata-backed commands should be intercepted.""" intercept_idx = MESSAGES_JS.find("Slash command intercept") normal_send_idx = MESSAGES_JS.find("const activeSid=S.session.session_id", intercept_idx) intercept = MESSAGES_JS[intercept_idx:normal_send_idx] assert "if(_agentCmd&&_agentCmd.cli_only)" in intercept + assert "if(_agentCmd&&_agentCmd.category==='Plugin')" in intercept assert "if(_parsedCmd&&!_cmd)" in intercept assert "if(!_agentCmd" not in intercept + assert "if(_agentCmd){" not in intercept assert "else" not in intercept[intercept.find("if(_agentCmd&&_agentCmd.cli_only)") :]