diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 8a434ea799..d40572d55b 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -217,6 +217,25 @@ def get_skill_commands() -> Dict[str, Dict[str, Any]]: return _skill_commands +def resolve_skill_command_key(command: str) -> Optional[str]: + """Resolve a user-typed /command to its canonical skill_cmds key. + + Skills are always stored with hyphens — ``scan_skill_commands`` normalizes + spaces and underscores to hyphens when building the key. Hyphens and + underscores are treated interchangeably in user input: this matches + ``_check_unavailable_skill`` and accommodates Telegram bot-command names + (which disallow hyphens, so ``/claude-code`` is registered as + ``/claude_code`` and comes back in the underscored form). + + Returns the matching ``/slug`` key from ``get_skill_commands()`` or + ``None`` if no match. + """ + if not command: + return None + cmd_key = f"/{command.replace('_', '-')}" + return cmd_key if cmd_key in get_skill_commands() else None + + def build_skill_invocation_message( cmd_key: str, user_instruction: str = "", diff --git a/gateway/run.py b/gateway/run.py index 2b7ebe4eb8..197427a17b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2112,7 +2112,10 @@ class GatewayRunner: if command: try: from hermes_cli.plugins import get_plugin_command_handler - plugin_handler = get_plugin_command_handler(command) + # Normalize underscores to hyphens so Telegram's underscored + # autocomplete form matches plugin commands registered with + # hyphens. See hermes_cli/commands.py:_build_telegram_menu. + plugin_handler = get_plugin_command_handler(command.replace("_", "-")) if plugin_handler: user_args = event.get_command_args().strip() import asyncio as _aio @@ -2123,13 +2126,20 @@ class GatewayRunner: except Exception as e: logger.debug("Plugin command dispatch failed (non-fatal): %s", e) - # Skill slash commands: /skill-name loads the skill and sends to agent + # Skill slash commands: /skill-name loads the skill and sends to agent. + # resolve_skill_command_key() handles the Telegram underscore/hyphen + # round-trip so /claude_code from Telegram autocomplete still resolves + # to the claude-code skill. if command: try: - from agent.skill_commands import get_skill_commands, build_skill_invocation_message + from agent.skill_commands import ( + get_skill_commands, + build_skill_invocation_message, + resolve_skill_command_key, + ) skill_cmds = get_skill_commands() - cmd_key = f"/{command}" - if cmd_key in skill_cmds: + cmd_key = resolve_skill_command_key(command) + if cmd_key is not None: # Check per-platform disabled status before executing. # get_skill_commands() only applies the *global* disabled # list at scan time; per-platform overrides need checking diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 6b3e551e18..cda4d89eb6 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -10,6 +10,7 @@ from agent.skill_commands import ( build_plan_path, build_preloaded_skills_prompt, build_skill_invocation_message, + resolve_skill_command_key, scan_skill_commands, ) @@ -101,6 +102,53 @@ class TestScanSkillCommands: assert "/disabled-skill" not in result +class TestResolveSkillCommandKey: + """Telegram bot-command names disallow hyphens, so the menu registers + skills with hyphens swapped for underscores. When Telegram autocomplete + sends the underscored form back, we need to find the hyphenated key. + """ + + def test_hyphenated_form_matches_directly(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "claude-code") + scan_skill_commands() + assert resolve_skill_command_key("claude-code") == "/claude-code" + + def test_underscore_form_resolves_to_hyphenated_skill(self, tmp_path): + """/claude_code from Telegram autocomplete must resolve to /claude-code.""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "claude-code") + scan_skill_commands() + assert resolve_skill_command_key("claude_code") == "/claude-code" + + def test_single_word_command_resolves(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "investigate") + scan_skill_commands() + assert resolve_skill_command_key("investigate") == "/investigate" + + def test_unknown_command_returns_none(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "claude-code") + scan_skill_commands() + assert resolve_skill_command_key("does_not_exist") is None + assert resolve_skill_command_key("does-not-exist") is None + + def test_empty_command_returns_none(self, tmp_path): + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + scan_skill_commands() + assert resolve_skill_command_key("") is None + + def test_hyphenated_command_is_not_mangled(self, tmp_path): + """A user-typed /foo-bar (hyphen) must not trigger the underscore fallback.""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "foo-bar") + scan_skill_commands() + assert resolve_skill_command_key("foo-bar") == "/foo-bar" + # Underscore form also works (Telegram round-trip) + assert resolve_skill_command_key("foo_bar") == "/foo-bar" + + class TestBuildPreloadedSkillsPrompt: def test_builds_prompt_for_multiple_named_skills(self, tmp_path): with patch("tools.skills_tool.SKILLS_DIR", tmp_path):