refactor(commands): drop /provider, /plan handler, and clean up slash registry (#15047)

* refactor(commands): drop /provider and clean up slash registry

* refactor(commands): drop /plan special handler — use plain skill dispatch
This commit is contained in:
Teknium 2026-04-24 03:10:52 -07:00 committed by GitHub
parent b29287258a
commit b2e124d082
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 21 additions and 535 deletions

View file

@ -1,15 +1,13 @@
"""Shared slash command helpers for skills and built-in prompt-style modes. """Shared slash command helpers for skills.
Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces
can invoke skills via /skill-name commands and prompt-only built-ins like can invoke skills via /skill-name commands.
/plan.
""" """
import json import json
import logging import logging
import re import re
import subprocess import subprocess
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@ -18,7 +16,6 @@ from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_skill_commands: Dict[str, Dict[str, Any]] = {} _skill_commands: Dict[str, Dict[str, Any]] = {}
_PLAN_SLUG_RE = re.compile(r"[^a-z0-9]+")
# Patterns for sanitizing skill names into clean hyphen-separated slugs. # Patterns for sanitizing skill names into clean hyphen-separated slugs.
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
@ -128,27 +125,6 @@ def _expand_inline_shell(
return _INLINE_SHELL_RE.sub(_replace, content) return _INLINE_SHELL_RE.sub(_replace, content)
def build_plan_path(
user_instruction: str = "",
*,
now: datetime | None = None,
) -> Path:
"""Return the default workspace-relative markdown path for a /plan invocation.
Relative paths are intentional: file tools are task/backend-aware and resolve
them against the active working directory for local, docker, ssh, modal,
daytona, and similar terminal backends. That keeps the plan with the active
workspace instead of the Hermes host's global home directory.
"""
slug_source = (user_instruction or "").strip().splitlines()[0] if user_instruction else ""
slug = _PLAN_SLUG_RE.sub("-", slug_source.lower()).strip("-")
if slug:
slug = "-".join(part for part in slug.split("-")[:8] if part)[:48].strip("-")
slug = slug or "conversation-plan"
timestamp = (now or datetime.now()).strftime("%Y-%m-%d_%H%M%S")
return Path(".hermes") / "plans" / f"{timestamp}-{slug}.md"
def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None: def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None:
"""Load a skill by name/path and return (loaded_payload, skill_dir, display_name).""" """Load a skill by name/path and return (loaded_payload, skill_dir, display_name)."""
raw_identifier = (skill_identifier or "").strip() raw_identifier = (skill_identifier or "").strip()

104
cli.py
View file

@ -1688,7 +1688,6 @@ def _looks_like_slash_command(text: str) -> bool:
from agent.skill_commands import ( from agent.skill_commands import (
scan_skill_commands, scan_skill_commands,
build_skill_invocation_message, build_skill_invocation_message,
build_plan_path,
build_preloaded_skills_prompt, build_preloaded_skills_prompt,
) )
@ -5427,79 +5426,6 @@ class HermesCLI:
except Exception: except Exception:
return False return False
def _show_model_and_providers(self):
"""Show current model + provider and list all authenticated providers.
Shows current model + provider, then lists all authenticated
providers with their available models.
"""
from hermes_cli.models import (
curated_models_for_provider, list_available_providers,
normalize_provider, _PROVIDER_LABELS,
get_pricing_for_provider, format_model_pricing_table,
)
from hermes_cli.auth import resolve_provider as _resolve_provider
# Resolve current provider
raw_provider = normalize_provider(self.provider)
if raw_provider == "auto":
try:
current = _resolve_provider(
self.requested_provider,
explicit_api_key=self._explicit_api_key,
explicit_base_url=self._explicit_base_url,
)
except Exception:
current = "openrouter"
else:
current = raw_provider
current_label = _PROVIDER_LABELS.get(current, current)
print(f"\n Current: {self.model} via {current_label}")
print()
# Show all authenticated providers with their models
providers = list_available_providers()
authed = [p for p in providers if p["authenticated"]]
unauthed = [p for p in providers if not p["authenticated"]]
if authed:
print(" Authenticated providers & models:")
for p in authed:
is_active = p["id"] == current
marker = " ← active" if is_active else ""
print(f" [{p['id']}]{marker}")
curated = curated_models_for_provider(p["id"])
# Fetch pricing for providers that support it (openrouter, nous)
pricing_map = get_pricing_for_provider(p["id"]) if p["id"] in ("openrouter", "nous") else {}
if curated and pricing_map:
cur_model = self.model if is_active else ""
for line in format_model_pricing_table(curated, pricing_map, current_model=cur_model):
print(line)
elif curated:
for mid, desc in curated:
current_marker = " ← current" if (is_active and mid == self.model) else ""
print(f" {mid}{current_marker}")
elif p["id"] == "custom":
from hermes_cli.models import _get_custom_base_url
custom_url = _get_custom_base_url()
if custom_url:
print(f" endpoint: {custom_url}")
if is_active:
print(f" model: {self.model} ← current")
print(" (use hermes model to change)")
else:
print(" (use hermes model to change)")
print()
if unauthed:
names = ", ".join(p["label"] for p in unauthed)
print(f" Not configured: {names}")
print(" Run: hermes setup")
print()
print(" To change model or provider, use: hermes model")
def _output_console(self): def _output_console(self):
"""Use prompt_toolkit-safe Rich rendering once the TUI is live.""" """Use prompt_toolkit-safe Rich rendering once the TUI is live."""
if getattr(self, "_app", None): if getattr(self, "_app", None):
@ -6075,16 +6001,12 @@ class HermesCLI:
self._handle_resume_command(cmd_original) self._handle_resume_command(cmd_original)
elif canonical == "model": elif canonical == "model":
self._handle_model_switch(cmd_original) self._handle_model_switch(cmd_original)
elif canonical == "provider":
self._show_model_and_providers()
elif canonical == "gquota": elif canonical == "gquota":
self._handle_gquota_command(cmd_original) self._handle_gquota_command(cmd_original)
elif canonical == "personality": elif canonical == "personality":
# Use original case (handler lowercases the personality name itself) # Use original case (handler lowercases the personality name itself)
self._handle_personality_command(cmd_original) self._handle_personality_command(cmd_original)
elif canonical == "plan":
self._handle_plan_command(cmd_original)
elif canonical == "retry": elif canonical == "retry":
retry_msg = self.retry_last() retry_msg = self.retry_last()
if retry_msg and hasattr(self, '_pending_input'): if retry_msg and hasattr(self, '_pending_input'):
@ -6319,32 +6241,6 @@ class HermesCLI:
return True return True
def _handle_plan_command(self, cmd: str):
"""Handle /plan [request] — load the bundled plan skill."""
parts = cmd.strip().split(maxsplit=1)
user_instruction = parts[1].strip() if len(parts) > 1 else ""
plan_path = build_plan_path(user_instruction)
msg = build_skill_invocation_message(
"/plan",
user_instruction,
task_id=self.session_id,
runtime_note=(
"Save the markdown plan with write_file to this exact relative path "
f"inside the active workspace/backend cwd: {plan_path}"
),
)
if not msg:
ChatConsole().print("[bold red]Failed to load the bundled /plan skill[/]")
return
_cprint(f" 📝 Plan mode queued via skill. Markdown plan target: {plan_path}")
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
ChatConsole().print("[bold red]Plan mode unavailable: input queue not initialized[/]")
def _handle_background_command(self, cmd: str): def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session. """Handle /background <prompt> — run a prompt in a separate background session.

View file

@ -2246,10 +2246,6 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_usage(interaction: discord.Interaction): async def slash_usage(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/usage") await self._run_simple_slash(interaction, "/usage")
@tree.command(name="provider", description="Show available providers")
async def slash_provider(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/provider")
@tree.command(name="help", description="Show available commands") @tree.command(name="help", description="Show available commands")
async def slash_help(interaction: discord.Interaction): async def slash_help(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/help") await self._run_simple_slash(interaction, "/help")

View file

@ -3486,7 +3486,7 @@ class GatewayRunner:
# running-agent guard. Reject gracefully rather than falling # running-agent guard. Reject gracefully rather than falling
# through to interrupt + discard. Without this, commands # through to interrupt + discard. Without this, commands
# like /model, /reasoning, /voice, /insights, /title, # like /model, /reasoning, /voice, /insights, /title,
# /resume, /retry, /undo, /compress, /usage, /provider, # /resume, /retry, /undo, /compress, /usage,
# /reload-mcp, /sethome, /reset (all registered as Discord # /reload-mcp, /sethome, /reset (all registered as Discord
# slash commands) would interrupt the agent AND get # slash commands) would interrupt the agent AND get
# silently discarded by the slash-command safety net, # silently discarded by the slash-command safety net,
@ -3673,34 +3673,9 @@ class GatewayRunner:
if canonical == "model": if canonical == "model":
return await self._handle_model_command(event) return await self._handle_model_command(event)
if canonical == "provider":
return await self._handle_provider_command(event)
if canonical == "personality": if canonical == "personality":
return await self._handle_personality_command(event) return await self._handle_personality_command(event)
if canonical == "plan":
try:
from agent.skill_commands import build_plan_path, build_skill_invocation_message
user_instruction = event.get_command_args().strip()
plan_path = build_plan_path(user_instruction)
event.text = build_skill_invocation_message(
"/plan",
user_instruction,
task_id=_quick_key,
runtime_note=(
"Save the markdown plan with write_file to this exact relative path "
f"inside the active workspace/backend cwd: {plan_path}"
),
)
if not event.text:
return "Failed to load the bundled /plan skill."
canonical = None
except Exception as e:
logger.exception("Failed to prepare /plan command")
return f"Failed to enter plan mode: {e}"
if canonical == "retry": if canonical == "retry":
return await self._handle_retry_command(event) return await self._handle_retry_command(event)
@ -5823,63 +5798,6 @@ class GatewayRunner:
return "\n".join(lines) return "\n".join(lines)
async def _handle_provider_command(self, event: MessageEvent) -> str:
"""Handle /provider command - show available providers."""
import yaml
from hermes_cli.models import (
list_available_providers,
normalize_provider,
_PROVIDER_LABELS,
)
# Resolve current provider from config
current_provider = "openrouter"
model_cfg = {}
config_path = _hermes_home / 'config.yaml'
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
current_provider = model_cfg.get("provider", current_provider)
except Exception:
pass
current_provider = normalize_provider(current_provider)
if current_provider == "auto":
try:
from hermes_cli.auth import resolve_provider as _resolve_provider
current_provider = _resolve_provider(current_provider)
except Exception:
current_provider = "openrouter"
# Detect custom endpoint from config base_url
if current_provider == "openrouter":
_cfg_base = model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else ""
if _cfg_base and "openrouter.ai" not in _cfg_base:
current_provider = "custom"
current_label = _PROVIDER_LABELS.get(current_provider, current_provider)
lines = [
f"🔌 **Current provider:** {current_label} (`{current_provider}`)",
"",
"**Available providers:**",
]
providers = list_available_providers()
for p in providers:
marker = " ← active" if p["id"] == current_provider else ""
auth = "" if p["authenticated"] else ""
aliases = f" _(also: {', '.join(p['aliases'])})_" if p["aliases"] else ""
lines.append(f"{auth} `{p['id']}` — {p['label']}{aliases}{marker}")
lines.append("")
lines.append("Switch: `/model provider:model-name`")
lines.append("Setup: `hermes setup`")
return "\n".join(lines)
async def _handle_personality_command(self, event: MessageEvent) -> str: async def _handle_personality_command(self, event: MessageEvent) -> str:
"""Handle /personality command - list or set a personality.""" """Handle /personality command - list or set a personality."""
import yaml import yaml

View file

@ -77,7 +77,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("rollback", "List or restore filesystem checkpoints", "Session", CommandDef("rollback", "List or restore filesystem checkpoints", "Session",
args_hint="[number]"), args_hint="[number]"),
CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session", CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session",
aliases=("snap",), args_hint="[create|restore <id>|prune]"), cli_only=True, aliases=("snap",), args_hint="[create|restore <id>|prune]"),
CommandDef("stop", "Kill all running background processes", "Session"), CommandDef("stop", "Kill all running background processes", "Session"),
CommandDef("approve", "Approve a pending dangerous command", "Session", CommandDef("approve", "Approve a pending dangerous command", "Session",
gateway_only=True, args_hint="[session|always]"), gateway_only=True, args_hint="[session|always]"),
@ -104,9 +104,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("config", "Show current configuration", "Configuration", CommandDef("config", "Show current configuration", "Configuration",
cli_only=True), cli_only=True),
CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"), CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"),
CommandDef("provider", "Show available providers and current provider", CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info",
"Configuration"), cli_only=True),
CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info"),
CommandDef("personality", "Set a predefined personality", "Configuration", CommandDef("personality", "Set a predefined personality", "Configuration",
args_hint="[name]"), args_hint="[name]"),
@ -124,7 +123,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
args_hint="[normal|fast|status]", args_hint="[normal|fast|status]",
subcommands=("normal", "fast", "status", "on", "off")), subcommands=("normal", "fast", "status", "on", "off")),
CommandDef("skin", "Show or change the display skin/theme", "Configuration", CommandDef("skin", "Show or change the display skin/theme", "Configuration",
args_hint="[name]"), cli_only=True, args_hint="[name]"),
CommandDef("voice", "Toggle voice mode", "Configuration", CommandDef("voice", "Toggle voice mode", "Configuration",
args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")),
@ -139,7 +138,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]", cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"), CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills",
cli_only=True),
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
aliases=("reload_mcp",)), aliases=("reload_mcp",)),
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills", CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
@ -317,7 +317,7 @@ def should_bypass_active_session(command_name: str | None) -> bool:
safety net in gateway.run discards any command text that reaches safety net in gateway.run discards any command text that reaches
the pending queue which meant a mid-run /model (or /reasoning, the pending queue which meant a mid-run /model (or /reasoning,
/voice, /insights, /title, /resume, /retry, /undo, /compress, /voice, /insights, /title, /resume, /retry, /undo, /compress,
/usage, /provider, /reload-mcp, /sethome, /reset) would silently /usage, /reload-mcp, /sethome, /reset) would silently
interrupt the agent AND get discarded, producing a zero-char interrupt the agent AND get discarded, producing a zero-char
response. See issue #5057 / PRs #6252, #10370, #4665. response. See issue #5057 / PRs #6252, #10370, #4665.

View file

@ -682,7 +682,7 @@ def get_nous_recommended_aux_model(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Canonical provider list — single source of truth for provider identity. # Canonical provider list — single source of truth for provider identity.
# Every code path that lists, displays, or iterates providers derives from # Every code path that lists, displays, or iterates providers derives from
# this list: hermes model, /model, /provider, list_authenticated_providers. # this list: hermes model, /model, list_authenticated_providers.
# #
# Fields: # Fields:
# slug — internal provider ID (used in config.yaml, --provider flag) # slug — internal provider ID (used in config.yaml, --provider flag)

View file

@ -248,7 +248,6 @@ Type these during an interactive chat session.
``` ```
/config Show config (CLI) /config Show config (CLI)
/model [name] Show or change model /model [name] Show or change model
/provider Show provider info
/personality [name] Set personality /personality [name] Set personality
/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) /reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide)
/verbose Cycle: off → new → all → verbose /verbose Cycle: off → new → all → verbose

View file

@ -1,13 +1,11 @@
"""Tests for agent/skill_commands.py — skill slash command scanning and platform filtering.""" """Tests for agent/skill_commands.py — skill slash command scanning and platform filtering."""
import os import os
from datetime import datetime
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import tools.skills_tool as skills_tool_module import tools.skills_tool as skills_tool_module
from agent.skill_commands import ( from agent.skill_commands import (
build_plan_path,
build_preloaded_skills_prompt, build_preloaded_skills_prompt,
build_skill_invocation_message, build_skill_invocation_message,
resolve_skill_command_key, resolve_skill_command_key,
@ -399,40 +397,6 @@ Generate some audio.
assert 'file_path="<path>"' in msg assert 'file_path="<path>"' in msg
class TestPlanSkillHelpers:
def test_build_plan_path_uses_workspace_relative_dir_and_slugifies_request(self):
path = build_plan_path(
"Implement OAuth login + refresh tokens!",
now=datetime(2026, 3, 15, 9, 30, 45),
)
assert path == Path(".hermes") / "plans" / "2026-03-15_093045-implement-oauth-login-refresh-tokens.md"
def test_plan_skill_message_can_include_runtime_save_path_note(self, tmp_path):
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_skill(
tmp_path,
"plan",
body="Save plans under .hermes/plans in the active workspace and do not execute the work.",
)
scan_skill_commands()
msg = build_skill_invocation_message(
"/plan",
"Add a /plan command",
runtime_note=(
"Save the markdown plan with write_file to this exact relative path inside "
"the active workspace/backend cwd: .hermes/plans/plan.md"
),
)
assert msg is not None
assert "Save plans under $HERMES_HOME/plans" not in msg
assert ".hermes/plans" in msg
assert "Add a /plan command" in msg
assert ".hermes/plans/plan.md" in msg
assert "Runtime note:" in msg
class TestSkillDirectoryHeader: class TestSkillDirectoryHeader:
"""The activation message must expose the absolute skill directory and """The activation message must expose the absolute skill directory and
explain how to resolve relative paths, so skills with bundled scripts explain how to resolve relative paths, so skills with bundled scripts

View file

@ -1,67 +0,0 @@
"""Tests for the /plan CLI slash command."""
from unittest.mock import MagicMock, patch
from agent.skill_commands import scan_skill_commands
from cli import HermesCLI
def _make_cli():
cli_obj = HermesCLI.__new__(HermesCLI)
cli_obj.config = {}
cli_obj.console = MagicMock()
cli_obj.agent = None
cli_obj.conversation_history = []
cli_obj.session_id = "sess-123"
cli_obj._pending_input = MagicMock()
return cli_obj
def _make_plan_skill(skills_dir):
skill_dir = skills_dir / "plan"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: plan
description: Plan mode skill.
---
# Plan
Use the current conversation context when no explicit instruction is provided.
Save plans under the active workspace's .hermes/plans directory.
"""
)
class TestCLIPlanCommand:
def test_plan_command_queues_plan_skill_message(self, tmp_path, monkeypatch):
cli_obj = _make_cli()
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
result = cli_obj.process_command("/plan Add OAuth login")
assert result is True
cli_obj._pending_input.put.assert_called_once()
queued = cli_obj._pending_input.put.call_args[0][0]
assert "Plan mode skill" in queued
assert "Add OAuth login" in queued
assert ".hermes/plans" in queued
assert str(tmp_path / "plans") not in queued
assert "active workspace/backend cwd" in queued
assert "Runtime note:" in queued
def test_plan_without_args_uses_skill_context_guidance(self, tmp_path, monkeypatch):
cli_obj = _make_cli()
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
cli_obj.process_command("/plan")
queued = cli_obj._pending_input.put.call_args[0][0]
assert "current conversation context" in queued
assert ".hermes/plans/" in queued
assert "conversation-plan.md" in queued

View file

@ -73,14 +73,6 @@ class TestSlashCommands:
send_status = await send_and_capture(adapter, "/status", platform) send_status = await send_and_capture(adapter, "/status", platform)
send_status.assert_called_once() send_status.assert_called_once()
@pytest.mark.asyncio
async def test_provider_shows_current_provider(self, adapter, platform):
send = await send_and_capture(adapter, "/provider", platform)
send.assert_called_once()
response_text = send.call_args[1].get("content") or send.call_args[0][1]
assert "provider" in response_text.lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_verbose_responds(self, adapter, platform): async def test_verbose_responds(self, adapter, platform):
send = await send_and_capture(adapter, "/verbose", platform) send = await send_and_capture(adapter, "/verbose", platform)

View file

@ -272,7 +272,7 @@ class TestCommandBypassActiveSession:
# Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass # Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass
# instead of interrupting + being discarded. Regression for the Discord # instead of interrupting + being discarded. Regression for the Discord
# ghost-slash-command bug where /model, /reasoning, /voice, /insights, /title, # ghost-slash-command bug where /model, /reasoning, /voice, /insights, /title,
# /resume, /retry, /undo, /compress, /usage, /provider, /reload-mcp, # /resume, /retry, /undo, /compress, /usage, /reload-mcp,
# /sethome, /reset silently interrupted the running agent. # /sethome, /reset silently interrupted the running agent.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -298,7 +298,6 @@ class TestAllResolvableCommandsBypassGuard:
("/undo", "undo"), ("/undo", "undo"),
("/compress", "compress"), ("/compress", "compress"),
("/usage", "usage"), ("/usage", "usage"),
("/provider", "provider"),
("/reload-mcp", "reload-mcp"), ("/reload-mcp", "reload-mcp"),
("/sethome", "sethome"), ("/sethome", "sethome"),
], ],
@ -326,7 +325,7 @@ class TestAllResolvableCommandsBypassGuard:
for cmd in ( for cmd in (
"model", "reasoning", "personality", "voice", "insights", "title", "model", "reasoning", "personality", "voice", "insights", "title",
"resume", "retry", "undo", "compress", "usage", "provider", "resume", "retry", "undo", "compress", "usage",
"reload-mcp", "sethome", "reset", "reload-mcp", "sethome", "reset",
): ):
assert should_bypass_active_session(cmd) is True, ( assert should_bypass_active_session(cmd) is True, (

View file

@ -164,7 +164,7 @@ async def test_auto_registers_missing_gateway_commands(adapter):
# These commands are gateway-available but were not in the original # These commands are gateway-available but were not in the original
# hardcoded registration list — they should be auto-registered. # hardcoded registration list — they should be auto-registered.
expected_auto = {"debug", "yolo", "reload", "profile"} expected_auto = {"debug", "yolo", "profile"}
for name in expected_auto: for name in expected_auto:
assert name in tree_names, f"/{name} should be auto-registered on Discord" assert name in tree_names, f"/{name} should be auto-registered on Discord"

View file

@ -1,129 +0,0 @@
"""Tests for the /plan gateway slash command."""
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent.skill_commands import scan_skill_commands
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionEntry, SessionSource
def _make_runner():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
runner.adapters = {}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner.session_store = MagicMock()
runner.session_store.get_or_create_session.return_value = SessionEntry(
session_key="agent:main:telegram:dm:c1:u1",
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
platform=Platform.TELEGRAM,
chat_type="dm",
)
runner.session_store.load_transcript.return_value = []
runner.session_store.has_any_sessions.return_value = True
runner.session_store.append_to_transcript = MagicMock()
runner.session_store.rewrite_transcript = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._show_reasoning = False
runner._is_user_authorized = lambda _source: True
runner._set_session_env = lambda _context: None
runner._run_agent = AsyncMock(
return_value={
"final_response": "planned",
"messages": [],
"tools": [],
"history_offset": 0,
"last_prompt_tokens": 0,
}
)
return runner
def _make_event(text="/plan"):
return MessageEvent(
text=text,
source=SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
),
message_id="m1",
)
def _make_plan_skill(skills_dir):
skill_dir = skills_dir / "plan"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"""---
name: plan
description: Plan mode skill.
---
# Plan
Use the current conversation context when no explicit instruction is provided.
Save plans under the active workspace's .hermes/plans directory.
"""
)
class TestGatewayPlanCommand:
@pytest.mark.asyncio
async def test_plan_command_loads_skill_and_runs_agent(self, monkeypatch, tmp_path):
import gateway.run as gateway_run
runner = _make_runner()
event = _make_event("/plan Add OAuth login")
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
monkeypatch.setattr(
"agent.model_metadata.get_model_context_length",
lambda *_args, **_kwargs: 100_000,
)
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
result = await runner._handle_message(event)
assert result == "planned"
forwarded = runner._run_agent.call_args.kwargs["message"]
assert "Plan mode skill" in forwarded
assert "Add OAuth login" in forwarded
assert ".hermes/plans" in forwarded
assert str(tmp_path / "plans") not in forwarded
assert "active workspace/backend cwd" in forwarded
assert "Runtime note:" in forwarded
@pytest.mark.asyncio
async def test_plan_command_appears_in_help_output_via_skill_listing(self, tmp_path):
runner = _make_runner()
event = _make_event("/help")
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
_make_plan_skill(tmp_path)
scan_skill_commands()
result = await runner._handle_help_command(event)
assert "/plan" in result

View file

@ -189,11 +189,14 @@ class TestGatewayHelpLines:
assert len(lines) > 10 assert len(lines) > 10
def test_excludes_cli_only_commands_without_config_gate(self): def test_excludes_cli_only_commands_without_config_gate(self):
import re
lines = gateway_help_lines() lines = gateway_help_lines()
joined = "\n".join(lines) joined = "\n".join(lines)
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if cmd.cli_only and not cmd.gateway_config_gate: if cmd.cli_only and not cmd.gateway_config_gate:
assert f"`/{cmd.name}" not in joined, \ # Word-boundary match so `/reload` doesn't match `/reload-mcp`
pattern = rf'`/{re.escape(cmd.name)}(?![-_\w])'
assert not re.search(pattern, joined), \
f"cli_only command /{cmd.name} should not be in gateway help" f"cli_only command /{cmd.name} should not be in gateway help"
def test_includes_alias_note_for_bg(self): def test_includes_alias_note_for_bg(self):

View file

@ -3237,29 +3237,6 @@ def _(rid, params: dict) -> dict:
# Fallback: no active run, treat as next-turn message # Fallback: no active run, treat as next-turn message
return _ok(rid, {"type": "send", "message": arg}) return _ok(rid, {"type": "send", "message": arg})
if name == "plan":
try:
from agent.skill_commands import (
build_skill_invocation_message as _bsim,
build_plan_path,
)
user_instruction = arg or ""
plan_path = build_plan_path(user_instruction)
msg = _bsim(
"/plan",
user_instruction,
task_id=session.get("session_key", "") if session else "",
runtime_note=(
"Save the markdown plan with write_file to this exact relative path "
f"inside the active workspace/backend cwd: {plan_path}"
),
)
if msg:
return _ok(rid, {"type": "send", "message": msg})
except Exception as e:
return _err(rid, 5030, f"plan skill failed: {e}")
return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")

View file

@ -281,36 +281,6 @@ describe('createSlashHandler', () => {
expect(ctx.transcript.page).not.toHaveBeenCalled() expect(ctx.transcript.page).not.toHaveBeenCalled()
expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet') expect(ctx.transcript.sys).toHaveBeenCalledWith('no conversation yet')
}) })
it('handles send-type dispatch for /plan command', async () => {
const planMessage = 'Plan skill content loaded'
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new Error('pending-input command'))
}
if (method === 'command.dispatch') {
return Promise.resolve({ type: 'send', message: planMessage })
}
return Promise.resolve({})
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
const h = createSlashHandler(ctx)
expect(h('/plan create a REST API')).toBe(true)
await vi.waitFor(() => {
expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage)
})
})
}) })
const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({ const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({

View file

@ -38,7 +38,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
| `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. | | `/agents` (alias: `/tasks`) | Show active agents and running tasks across the current session. |
| `/background <prompt>` (alias: `/bg`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). | | `/background <prompt>` (alias: `/bg`) | Run a prompt in a separate background session. The agent processes your prompt independently — your current session stays free for other work. Results appear as a panel when the task finishes. See [CLI Background Sessions](/docs/user-guide/cli#background-sessions). |
| `/btw <question>` | Ephemeral side question using session context (no tools, not persisted). Useful for quick clarifications without affecting the conversation history. | | `/btw <question>` | Ephemeral side question using session context (no tools, not persisted). Useful for quick clarifications without affecting the conversation history. |
| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `.hermes/plans/` relative to the active workspace/backend working directory. |
| `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) | | `/branch [name]` (alias: `/fork`) | Branch the current session (explore a different path) |
### Configuration ### Configuration
@ -47,7 +46,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in
|---------|-------------| |---------|-------------|
| `/config` | Show current configuration | | `/config` | Show current configuration |
| `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. | | `/model [model-name]` | Show or change the current model. Supports: `/model claude-sonnet-4`, `/model provider:model` (switch providers), `/model custom:model` (custom endpoint), `/model custom:name:model` (named custom provider), `/model custom` (auto-detect from endpoint). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider, exit the session and run `hermes model` from your terminal. |
| `/provider` | Show available providers and current provider |
| `/personality` | Set a predefined personality | | `/personality` | Set a predefined personality |
| `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. | | `/verbose` | Cycle tool progress display: off → new → all → verbose. Can be [enabled for messaging](#notes) via config. |
| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. | | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. Options: `normal`, `fast`, `status`. |
@ -127,7 +125,6 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/status` | Show session info. | | `/status` | Show session info. |
| `/stop` | Kill all running background processes and interrupt the running agent. | | `/stop` | Kill all running background processes and interrupt the running agent. |
| `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), and auto-detect (`/model custom`). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider or set up API keys, use `hermes model` from your terminal (outside the chat session). | | `/model [provider:model]` | Show or change the model. Supports provider switches (`/model zai:glm-5`), custom endpoints (`/model custom:model`), named custom providers (`/model custom:local:qwen`), and auto-detect (`/model custom`). Use `--global` to persist the change to config.yaml. **Note:** `/model` can only switch between already-configured providers. To add a new provider or set up API keys, use `hermes model` from your terminal (outside the chat session). |
| `/provider` | Show provider availability and auth status. |
| `/personality [name]` | Set a personality overlay for the session. | | `/personality [name]` | Set a personality overlay for the session. |
| `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. | | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. |
| `/retry` | Retry the last message. | | `/retry` | Retry the last message. |
@ -141,11 +138,8 @@ The messaging gateway supports the following built-in commands inside Telegram,
| `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display. | | `/reasoning [level\|show\|hide]` | Change reasoning effort or toggle reasoning display. |
| `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. | | `/voice [on\|off\|tts\|join\|channel\|leave\|status]` | Control spoken replies in chat. `join`/`channel`/`leave` manage Discord voice-channel mode. |
| `/rollback [number]` | List or restore filesystem checkpoints. | | `/rollback [number]` | List or restore filesystem checkpoints. |
| `/snapshot [create\|restore <id>\|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. |
| `/background <prompt>` | Run a prompt in a separate background session. Results are delivered back to the same chat when the task finishes. See [Messaging Background Sessions](/docs/user-guide/messaging/#background-sessions). | | `/background <prompt>` | Run a prompt in a separate background session. Results are delivered back to the same chat when the task finishes. See [Messaging Background Sessions](/docs/user-guide/messaging/#background-sessions). |
| `/plan [request]` | Load the bundled `plan` skill to write a markdown plan instead of executing the work. Plans are saved under `.hermes/plans/` relative to the active workspace/backend working directory. |
| `/reload-mcp` (alias: `/reload_mcp`) | Reload MCP servers from config. | | `/reload-mcp` (alias: `/reload_mcp`) | Reload MCP servers from config. |
| `/reload` | Reload `.env` variables into the running session. |
| `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. |
| `/commands [page]` | Browse all commands and skills (paginated). | | `/commands [page]` | Browse all commands and skills (paginated). |
| `/approve [session\|always]` | Approve and execute a pending dangerous command. `session` approves for this session only; `always` adds to permanent allowlist. | | `/approve [session\|always]` | Approve and execute a pending dangerous command. `session` approves for this session only; `always` adds to permanent allowlist. |
@ -158,8 +152,8 @@ The messaging gateway supports the following built-in commands inside Telegram,
## Notes ## Notes
- `/skin`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/terminal-setup`, `/statusbar`, and `/plugins` are **CLI-only** commands. - `/skin`, `/snapshot`, `/gquota`, `/reload`, `/tools`, `/toolsets`, `/browser`, `/config`, `/cron`, `/skills`, `/platforms`, `/paste`, `/image`, `/terminal-setup`, `/statusbar`, and `/plugins` are **CLI-only** commands.
- `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config. - `/verbose` is **CLI-only by default**, but can be enabled for messaging platforms by setting `display.tool_progress_command: true` in `config.yaml`. When enabled, it cycles the `display.tool_progress` mode and saves to config.
- `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands. - `/sethome`, `/update`, `/restart`, `/approve`, `/deny`, and `/commands` are **messaging-only** commands.
- `/status`, `/background`, `/voice`, `/reload-mcp`, `/rollback`, `/snapshot`, `/debug`, `/fast`, and `/yolo` work in **both** the CLI and the messaging gateway. - `/status`, `/background`, `/voice`, `/reload-mcp`, `/rollback`, `/debug`, `/fast`, and `/yolo` work in **both** the CLI and the messaging gateway.
- `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord. - `/voice join`, `/voice channel`, and `/voice leave` are only meaningful on Discord.

View file

@ -32,7 +32,7 @@ Every installed skill is automatically available as a slash command:
/excalidraw /excalidraw
``` ```
The bundled `plan` skill is a good example of a skill-backed slash command with custom behavior. Running `/plan [request]` tells Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `.hermes/plans/` relative to the active workspace/backend working directory. The bundled `plan` skill is a good example. Running `/plan [request]` loads the skill's instructions, telling Hermes to inspect context if needed, write a markdown implementation plan instead of executing the task, and save the result under `.hermes/plans/` relative to the active workspace/backend working directory.
You can also interact with skills through natural conversation: You can also interact with skills through natural conversation:

View file

@ -120,7 +120,6 @@ hermes gateway status --system # Linux only: inspect the system service
|---------|-------------| |---------|-------------|
| `/new` or `/reset` | Start a fresh conversation | | `/new` or `/reset` | Start a fresh conversation |
| `/model [provider:model]` | Show or change the model (supports `provider:model` syntax) | | `/model [provider:model]` | Show or change the model (supports `provider:model` syntax) |
| `/provider` | Show available providers with auth status |
| `/personality [name]` | Set a personality | | `/personality [name]` | Set a personality |
| `/retry` | Retry the last message | | `/retry` | Retry the last message |
| `/undo` | Remove the last exchange | | `/undo` | Remove the last exchange |

View file

@ -265,7 +265,6 @@ Type these during an interactive chat session.
``` ```
/config Show config (CLI) /config Show config (CLI)
/model [name] Show or change model /model [name] Show or change model
/provider Show provider info
/personality [name] Set personality /personality [name] Set personality
/reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) /reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide)
/verbose Cycle: off → new → all → verbose /verbose Cycle: off → new → all → verbose