{"repo":"NousResearch/hermes-agent","public_id":"8110","display_label":"External skills missing from Telegram/Discord slash commands","summary":"A bug where skills loaded from external directories are excluded from slash command registration in Telegram and Discord gateways. The members include a bug report and two fix attempts addressing this registration gap.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":18741,"merged_at":"2026-05-02T08:36:58+00:00"},"resolution":{"state":"resolved","by":"pr-18741"},"freshness":{"state":"stale","last_verified_at":"2026-05-02T08:36:58+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":8110,"pr_number":18741,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-02T08:36:58+00:00"}],"members":[{"kind":"issue","number":8110,"title":"Bug: Telegram slash command registration excludes external skills from skills.external_dirs","url":null,"state":"closed"},{"kind":"pr","number":8790,"title":"fix(gateway): include external_dirs skills in Telegram/Discord slash command registration","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18741","number":18741}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":18741,"title":"fix(gateway): include external_dirs skills in Telegram/Discord slash commands (salvage #8790)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/commands.py","tests/hermes_cli/test_commands.py"],"fix_hunks":[{"file":"hermes_cli/commands.py","hunks":["@@ -611,13 +611,26 @@ def _collect_gateway_skill_entries(\n     try:\n         from agent.skill_commands import get_skill_commands\n         from tools.skills_tool import SKILLS_DIR\n+        from agent.skill_utils import get_external_skills_dirs\n         _skills_dir = str(SKILLS_DIR.resolve())\n-        _hub_dir = str((SKILLS_DIR / \".hub\").resolve())\n+        _hub_dir = str((SKILLS_DIR / \".hub\").resolve()).rstrip(\"/\") + \"/\"\n+        # Build set of allowed directory prefixes: local skills dir + any\n+        # user-configured ``skills.external_dirs``. Ensure each prefix ends\n+        # with ``/`` so ``/my-skills`` does not also match ``/my-skills-extra``.\n+        # Without this widening, external skills are visible in\n+        # ``hermes skills list`` and the agent's ``/skill-name`` dispatch but\n+        # silently excluded from gateway slash menus (#8110).\n+        _allowed_prefixes = [_skills_dir.rstrip(\"/\") + \"/\"]\n+        _allowed_prefixes.extend(\n+            str(d).rstrip(\"/\") + \"/\" for d in get_external_skills_dirs()\n+        )\n         skill_cmds = get_skill_commands()\n         for cmd_key in sorted(skill_cmds):\n             info = skill_cmds[cmd_key]\n             skill_path = info.get(\"skill_md_path\", \"\")\n-            if not skill_path.startswith(_skills_dir):\n+            if not skill_path:\n+                continue\n+            if not any(skill_path.startswith(prefix) for prefix in _allowed_prefixes):\n                 continue\n             if skill_path.startswith(_hub_dir):\n                 continue"]},{"file":"tests/hermes_cli/test_commands.py","hunks":["@@ -899,6 +899,73 @@ def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch):\n         assert \"my_enabled_skill\" in menu_names\n         assert \"my_disabled_skill\" not in menu_names\n \n+    def test_external_dir_skills_included_in_telegram_menu(self, tmp_path, monkeypatch):\n+        \"\"\"External skills (``skills.external_dirs``) must appear in the Telegram menu.\n+\n+        Regression test for #8110 — external skills were visible to the\n+        agent and CLI but silently excluded from gateway slash menus\n+        because ``_collect_gateway_skill_entries`` only accepted skills\n+        whose path started with ``SKILLS_DIR``.\n+\n+        Also verifies the trailing-slash boundary: a directory that\n+        simply shares a prefix with a configured ``external_dirs`` entry\n+        (``/tmp/my-skills-extra`` vs ``/tmp/my-skills``) must NOT be\n+        admitted.\n+        \"\"\"\n+        from unittest.mock import patch\n+\n+        local_dir = tmp_path / \"skills\"\n+        local_dir.mkdir()\n+        external_dir = tmp_path / \"my-skills\"\n+        external_dir.mkdir()\n+        lookalike_dir = tmp_path / \"my-skills-extra\"\n+        lookalike_dir.mkdir()\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        (tmp_path / \"config.yaml\").write_text(\n+            f\"skills:\\n  external_dirs:\\n    - {external_dir}\\n\"\n+        )\n+\n+        fake_cmds = {\n+            \"/local-one\": {\n+                \"name\": \"local-one\",\n+                \"description\": \"Local\",\n+                \"skill_md_path\": f\"{local_dir}/local-one/SKILL.md\",\n+                \"skill_dir\": f\"{local_dir}/local-one\",\n+            },\n+            \"/morning-briefing\": {\n+                \"name\": \"morning-briefing\",\n+                \"description\": \"External skill\",\n+                \"skill_md_path\": f\"{external_dir}/morning-briefing/SKILL.md\",\n+                \"skill_dir\": f\"{external_dir}/morning-briefing\",\n+            },\n+            \"/lookalike-skill\": {\n+                \"name\": \"lookalike-skill\",\n+                \"description\": \"Lives in a sibling dir that shares a prefix\",\n+                \"skill_md_path\": f\"{lookalike_dir}/lookalike-skill/SKILL.md\",\n+                \"skill_dir\": f\"{lookalike_dir}/lookalike-skill\",\n+            },\n+        }\n+\n+        with (\n+            patch(\"agent.skill_commands.get_skill_commands\", return_value=fake_cmds),\n+            patch(\"tools.skills_tool.SKILLS_DIR\", local_dir),\n+            patch(\n+                \"agent.skill_utils.get_external_skills_dirs\",\n+                return_value=[external_dir],\n+            ),\n+        ):\n+            menu, _ = telegram_menu_commands(max_commands=100)\n+\n+        menu_names = {n for n, _ in menu}\n+        assert \"local_one\" in menu_names, \"local skill must appear\"\n+        assert \"morning_briefing\" in menu_names, (\n+            \"external skill from skills.external_dirs must appear (fixes #8110)\"\n+        )\n+        assert \"lookalike_skill\" not in menu_names, (\n+            \"prefix-match sibling directories must not be admitted\"\n+        )\n+\n     def test_special_chars_in_skill_names_sanitized(self, tmp_path, monkeypatch):\n         \"\"\"Skills with +, /, or other special chars produce valid Telegram names.\"\"\"\n         from unittest.mock import patch\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}