feat(discord): register skills under /skill command group with category subcommands (#9909)

Instead of consuming one top-level slash command slot per skill (hitting the
100-command limit with ~26 built-ins + 74 skills), skills are now organized
under a single /skill group command with category-based subcommand groups:

  /skill creative ascii-art [args]
  /skill media gif-search [args]
  /skill mlops axolotl [args]

Discord supports 25 subcommand groups × 25 subcommands = 625 max skills,
well beyond the previous 74-slot ceiling.

Categories are derived from the skill directory structure:
- skills/creative/ascii-art/ → category 'creative'
- skills/mlops/training/axolotl/ → category 'mlops' (top-level parent)
- skills/dogfood/ → uncategorized (direct subcommand)

Changes:
- hermes_cli/commands.py: add discord_skill_commands_by_category() with
  category grouping, hub/disabled filtering, Discord limit enforcement
- gateway/platforms/discord.py: replace top-level skill registration with
  _register_skill_group() using app_commands.Group hierarchy
- tests: 7 new tests covering group creation, category grouping,
  uncategorized skills, hub exclusion, deep nesting, empty skills,
  and handler dispatch

Inspired by Discord community suggestion from bottium.
This commit is contained in:
Teknium 2026-04-14 16:27:02 -07:00 committed by GitHub
parent 039023f497
commit 10494b42a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 436 additions and 25 deletions

View file

@ -1028,3 +1028,154 @@ class TestDiscordSkillCommands:
assert len(name) <= _CMD_NAME_LIMIT, (
f"Name '{name}' is {len(name)} chars (limit {_CMD_NAME_LIMIT})"
)
# ---------------------------------------------------------------------------
# Discord skill commands grouped by category
# ---------------------------------------------------------------------------
from hermes_cli.commands import discord_skill_commands_by_category # noqa: E402
class TestDiscordSkillCommandsByCategory:
"""Tests for discord_skill_commands_by_category() — /skill group registration."""
def test_groups_skills_by_category(self, tmp_path, monkeypatch):
"""Skills nested 2+ levels deep should be grouped by top-level category."""
from unittest.mock import patch
fake_skills_dir = str(tmp_path / "skills")
# Create the directory structure so resolve() works
for p in [
"skills/creative/ascii-art",
"skills/creative/excalidraw",
"skills/media/gif-search",
]:
(tmp_path / p).mkdir(parents=True, exist_ok=True)
(tmp_path / p / "SKILL.md").write_text("---\nname: test\n---\n")
fake_cmds = {
"/ascii-art": {
"name": "ascii-art",
"description": "Generate ASCII art",
"skill_md_path": f"{fake_skills_dir}/creative/ascii-art/SKILL.md",
},
"/excalidraw": {
"name": "excalidraw",
"description": "Hand-drawn diagrams",
"skill_md_path": f"{fake_skills_dir}/creative/excalidraw/SKILL.md",
},
"/gif-search": {
"name": "gif-search",
"description": "Search for GIFs",
"skill_md_path": f"{fake_skills_dir}/media/gif-search/SKILL.md",
},
}
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
assert "creative" in categories
assert "media" in categories
assert len(categories["creative"]) == 2
assert len(categories["media"]) == 1
assert uncategorized == []
assert hidden == 0
def test_root_level_skills_are_uncategorized(self, tmp_path, monkeypatch):
"""Skills directly under SKILLS_DIR (only 1 path component) → uncategorized."""
from unittest.mock import patch
fake_skills_dir = str(tmp_path / "skills")
(tmp_path / "skills" / "dogfood").mkdir(parents=True, exist_ok=True)
(tmp_path / "skills" / "dogfood" / "SKILL.md").write_text("")
fake_cmds = {
"/dogfood": {
"name": "dogfood",
"description": "QA testing",
"skill_md_path": f"{fake_skills_dir}/dogfood/SKILL.md",
},
}
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
assert categories == {}
assert len(uncategorized) == 1
assert uncategorized[0][0] == "dogfood"
def test_hub_skills_excluded(self, tmp_path, monkeypatch):
"""Skills under .hub should be excluded."""
from unittest.mock import patch
fake_skills_dir = str(tmp_path / "skills")
(tmp_path / "skills" / ".hub" / "some-skill").mkdir(parents=True, exist_ok=True)
(tmp_path / "skills" / ".hub" / "some-skill" / "SKILL.md").write_text("")
fake_cmds = {
"/some-skill": {
"name": "some-skill",
"description": "Hub skill",
"skill_md_path": f"{fake_skills_dir}/.hub/some-skill/SKILL.md",
},
}
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
assert categories == {}
assert uncategorized == []
def test_deep_nested_skills_use_top_category(self, tmp_path, monkeypatch):
"""Skills like mlops/training/axolotl should group under 'mlops'."""
from unittest.mock import patch
fake_skills_dir = str(tmp_path / "skills")
(tmp_path / "skills" / "mlops" / "training" / "axolotl").mkdir(parents=True, exist_ok=True)
(tmp_path / "skills" / "mlops" / "training" / "axolotl" / "SKILL.md").write_text("")
(tmp_path / "skills" / "mlops" / "inference" / "vllm").mkdir(parents=True, exist_ok=True)
(tmp_path / "skills" / "mlops" / "inference" / "vllm" / "SKILL.md").write_text("")
fake_cmds = {
"/axolotl": {
"name": "axolotl",
"description": "Fine-tuning with Axolotl",
"skill_md_path": f"{fake_skills_dir}/mlops/training/axolotl/SKILL.md",
},
"/vllm": {
"name": "vllm",
"description": "vLLM inference",
"skill_md_path": f"{fake_skills_dir}/mlops/inference/vllm/SKILL.md",
},
}
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
with (
patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds),
patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"),
):
categories, uncategorized, hidden = discord_skill_commands_by_category(
reserved_names=set(),
)
# Both should be under 'mlops' regardless of sub-category
assert "mlops" in categories
names = {n for n, _d, _k in categories["mlops"]}
assert "axolotl" in names
assert "vllm" in names
assert len(uncategorized) == 0