From b2e124d082975e23e06122ed8441cc1ee0e13390 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 24 Apr 2026 03:10:52 -0700 Subject: [PATCH] refactor(commands): drop /provider, /plan handler, and clean up slash registry (#15047) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(commands): drop /provider and clean up slash registry * refactor(commands): drop /plan special handler — use plain skill dispatch --- agent/skill_commands.py | 28 +--- cli.py | 104 -------------- gateway/platforms/discord.py | 4 - gateway/run.py | 84 +----------- hermes_cli/commands.py | 14 +- hermes_cli/models.py | 2 +- .../hermes-agent/SKILL.md | 1 - tests/agent/test_skill_commands.py | 36 ----- tests/cli/test_cli_plan_command.py | 67 --------- tests/e2e/test_platform_commands.py | 8 -- .../test_command_bypass_active_session.py | 5 +- tests/gateway/test_discord_slash_commands.py | 2 +- tests/gateway/test_plan_command.py | 129 ------------------ tests/hermes_cli/test_commands.py | 5 +- tui_gateway/server.py | 23 ---- .../src/__tests__/createSlashHandler.test.ts | 30 ---- website/docs/reference/slash-commands.md | 10 +- website/docs/user-guide/features/skills.md | 2 +- website/docs/user-guide/messaging/index.md | 1 - .../autonomous-ai-agents-hermes-agent.md | 1 - 20 files changed, 21 insertions(+), 535 deletions(-) delete mode 100644 tests/cli/test_cli_plan_command.py delete mode 100644 tests/gateway/test_plan_command.py diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 9c130ab84..916e203a1 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -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 -can invoke skills via /skill-name commands and prompt-only built-ins like -/plan. +can invoke skills via /skill-name commands. """ import json import logging import re import subprocess -from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional @@ -18,7 +16,6 @@ from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) _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. _SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]") _SKILL_MULTI_HYPHEN = re.compile(r"-{2,}") @@ -128,27 +125,6 @@ def _expand_inline_shell( 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: """Load a skill by name/path and return (loaded_payload, skill_dir, display_name).""" raw_identifier = (skill_identifier or "").strip() diff --git a/cli.py b/cli.py index 90b8b4ffe..10315d6cf 100644 --- a/cli.py +++ b/cli.py @@ -1688,7 +1688,6 @@ def _looks_like_slash_command(text: str) -> bool: from agent.skill_commands import ( scan_skill_commands, build_skill_invocation_message, - build_plan_path, build_preloaded_skills_prompt, ) @@ -5427,79 +5426,6 @@ class HermesCLI: except Exception: 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): """Use prompt_toolkit-safe Rich rendering once the TUI is live.""" if getattr(self, "_app", None): @@ -6075,16 +6001,12 @@ class HermesCLI: self._handle_resume_command(cmd_original) elif canonical == "model": self._handle_model_switch(cmd_original) - elif canonical == "provider": - self._show_model_and_providers() elif canonical == "gquota": self._handle_gquota_command(cmd_original) elif canonical == "personality": # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) - elif canonical == "plan": - self._handle_plan_command(cmd_original) elif canonical == "retry": retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): @@ -6319,32 +6241,6 @@ class HermesCLI: 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): """Handle /background — run a prompt in a separate background session. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 2b89511ab..1c564ca8a 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2246,10 +2246,6 @@ class DiscordAdapter(BasePlatformAdapter): async def slash_usage(interaction: discord.Interaction): 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") async def slash_help(interaction: discord.Interaction): await self._run_simple_slash(interaction, "/help") diff --git a/gateway/run.py b/gateway/run.py index 206f6e399..0dad9af10 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3486,7 +3486,7 @@ class GatewayRunner: # running-agent guard. Reject gracefully rather than falling # through to interrupt + discard. Without this, commands # 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 # slash commands) would interrupt the agent AND get # silently discarded by the slash-command safety net, @@ -3673,34 +3673,9 @@ class GatewayRunner: if canonical == "model": return await self._handle_model_command(event) - if canonical == "provider": - return await self._handle_provider_command(event) - if canonical == "personality": 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": return await self._handle_retry_command(event) @@ -5823,63 +5798,6 @@ class GatewayRunner: 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: """Handle /personality command - list or set a personality.""" import yaml diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 87d73af58..e5e2a6c63 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -77,7 +77,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"), CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session", - aliases=("snap",), args_hint="[create|restore |prune]"), + cli_only=True, aliases=("snap",), args_hint="[create|restore |prune]"), CommandDef("stop", "Kill all running background processes", "Session"), CommandDef("approve", "Approve a pending dangerous command", "Session", gateway_only=True, args_hint="[session|always]"), @@ -104,9 +104,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ CommandDef("config", "Show current configuration", "Configuration", cli_only=True), CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"), - CommandDef("provider", "Show available providers and current provider", - "Configuration"), - CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info"), + CommandDef("gquota", "Show Google Gemini Code Assist quota usage", "Info", + cli_only=True), CommandDef("personality", "Set a predefined personality", "Configuration", args_hint="[name]"), @@ -124,7 +123,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[normal|fast|status]", subcommands=("normal", "fast", "status", "on", "off")), 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", 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", cli_only=True, args_hint="[subcommand]", 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", aliases=("reload_mcp",)), 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 the pending queue — which meant a mid-run /model (or /reasoning, /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 response. See issue #5057 / PRs #6252, #10370, #4665. diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 856ba9b8b..8663db231 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -682,7 +682,7 @@ def get_nous_recommended_aux_model( # --------------------------------------------------------------------------- # Canonical provider list — single source of truth for provider identity. # 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: # slug — internal provider ID (used in config.yaml, --provider flag) diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index d19471c80..4ed03a904 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -248,7 +248,6 @@ Type these during an interactive chat session. ``` /config Show config (CLI) /model [name] Show or change model -/provider Show provider info /personality [name] Set personality /reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) /verbose Cycle: off → new → all → verbose diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index bf8742690..6879baed8 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -1,13 +1,11 @@ """Tests for agent/skill_commands.py — skill slash command scanning and platform filtering.""" import os -from datetime import datetime from pathlib import Path from unittest.mock import patch import tools.skills_tool as skills_tool_module from agent.skill_commands import ( - build_plan_path, build_preloaded_skills_prompt, build_skill_invocation_message, resolve_skill_command_key, @@ -399,40 +397,6 @@ Generate some audio. assert 'file_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: """The activation message must expose the absolute skill directory and explain how to resolve relative paths, so skills with bundled scripts diff --git a/tests/cli/test_cli_plan_command.py b/tests/cli/test_cli_plan_command.py deleted file mode 100644 index 8f8205d75..000000000 --- a/tests/cli/test_cli_plan_command.py +++ /dev/null @@ -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 diff --git a/tests/e2e/test_platform_commands.py b/tests/e2e/test_platform_commands.py index 1b325ba02..1597e54cc 100644 --- a/tests/e2e/test_platform_commands.py +++ b/tests/e2e/test_platform_commands.py @@ -73,14 +73,6 @@ class TestSlashCommands: send_status = await send_and_capture(adapter, "/status", platform) 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 async def test_verbose_responds(self, adapter, platform): send = await send_and_capture(adapter, "/verbose", platform) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index ea910d30b..aae68b6b5 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -272,7 +272,7 @@ class TestCommandBypassActiveSession: # Tests: non-bypass-set commands (no dedicated Level-2 handler) also bypass # instead of interrupting + being discarded. Regression for the Discord # 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. # --------------------------------------------------------------------------- @@ -298,7 +298,6 @@ class TestAllResolvableCommandsBypassGuard: ("/undo", "undo"), ("/compress", "compress"), ("/usage", "usage"), - ("/provider", "provider"), ("/reload-mcp", "reload-mcp"), ("/sethome", "sethome"), ], @@ -326,7 +325,7 @@ class TestAllResolvableCommandsBypassGuard: for cmd in ( "model", "reasoning", "personality", "voice", "insights", "title", - "resume", "retry", "undo", "compress", "usage", "provider", + "resume", "retry", "undo", "compress", "usage", "reload-mcp", "sethome", "reset", ): assert should_bypass_active_session(cmd) is True, ( diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index 7e1f5d4a8..7b15a7ed0 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -164,7 +164,7 @@ async def test_auto_registers_missing_gateway_commands(adapter): # 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"} + expected_auto = {"debug", "yolo", "profile"} for name in expected_auto: assert name in tree_names, f"/{name} should be auto-registered on Discord" diff --git a/tests/gateway/test_plan_command.py b/tests/gateway/test_plan_command.py deleted file mode 100644 index d43f46cde..000000000 --- a/tests/gateway/test_plan_command.py +++ /dev/null @@ -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 diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index a27f99661..d77a076eb 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -189,11 +189,14 @@ class TestGatewayHelpLines: assert len(lines) > 10 def test_excludes_cli_only_commands_without_config_gate(self): + import re lines = gateway_help_lines() joined = "\n".join(lines) for cmd in COMMAND_REGISTRY: 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" def test_includes_alias_note_for_bg(self): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index b473b6237..eea4ebf35 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -3237,29 +3237,6 @@ def _(rid, params: dict) -> dict: # Fallback: no active run, treat as next-turn message 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}") diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 4bb8b6bda..eba1d56d8 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -281,36 +281,6 @@ describe('createSlashHandler', () => { expect(ctx.transcript.page).not.toHaveBeenCalled() 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 => ({ diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index bde142820..6e04bcd01 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -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. | | `/background ` (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 ` | 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) | ### Configuration @@ -47,7 +46,6 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in |---------|-------------| | `/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. | -| `/provider` | Show available providers and current provider | | `/personality` | Set a predefined personality | | `/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`. | @@ -127,7 +125,6 @@ The messaging gateway supports the following built-in commands inside Telegram, | `/status` | Show session info. | | `/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). | -| `/provider` | Show provider availability and auth status. | | `/personality [name]` | Set a personality overlay for the session. | | `/fast [normal\|fast\|status]` | Toggle fast mode — OpenAI Priority Processing / Anthropic Fast Mode. | | `/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. | | `/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. | -| `/snapshot [create\|restore \|prune]` (alias: `/snap`) | Create or restore state snapshots of Hermes config/state. | | `/background ` | 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` | Reload `.env` variables into the running session. | | `/yolo` | Toggle YOLO mode — skip all dangerous command approval prompts. | | `/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. | @@ -158,8 +152,8 @@ The messaging gateway supports the following built-in commands inside Telegram, ## 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. - `/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. diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index ff5a5c8ec..58cbd663e 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -32,7 +32,7 @@ Every installed skill is automatically available as a slash command: /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: diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index 03dbbc7f4..dcde46a6b 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -120,7 +120,6 @@ hermes gateway status --system # Linux only: inspect the system service |---------|-------------| | `/new` or `/reset` | Start a fresh conversation | | `/model [provider:model]` | Show or change the model (supports `provider:model` syntax) | -| `/provider` | Show available providers with auth status | | `/personality [name]` | Set a personality | | `/retry` | Retry the last message | | `/undo` | Remove the last exchange | diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index ff60380aa..efd632625 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -265,7 +265,6 @@ Type these during an interactive chat session. ``` /config Show config (CLI) /model [name] Show or change model -/provider Show provider info /personality [name] Set personality /reasoning [level] Set reasoning (none|minimal|low|medium|high|xhigh|show|hide) /verbose Cycle: off → new → all → verbose