Merge PR #2089 into stage-339

support slash commands implemented in hermes plugin
by @plerohellec
This commit is contained in:
nesquena-hermes
2026-05-11 17:43:57 +00:00
5 changed files with 128 additions and 3 deletions
+64
View File
@@ -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}"
+16
View File
@@ -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)
+30 -2
View File
@@ -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 ───────────────────────────────────────────────────
+15
View File
@@ -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();}
+3 -1
View File
@@ -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)") :]