feat(skills): add skill bundles — alias /<name> loads multiple skills (#28373)

Skill bundles are tiny YAML files in ~/.hermes/skill-bundles/ that
group several skills under one slash command. Invoking /<bundle-name>
from any surface (CLI, TUI, dashboard, any gateway platform) loads
every referenced skill into a single combined user message.

Use cases:
- /backend-dev → loads github-code-review + test-driven-development
  + github-pr-workflow as one bundle.
- /research → loads several research skills together.
- Team task profiles shared via dotfiles.

Behavior:
- Bundles take precedence over individual skills when slugs collide.
- Missing skills are skipped with a note, not fatal.
- No system-prompt mutation — bundles generate a fresh user message
  at invocation time, the same way /<skill> does. Prompt cache stays
  intact.
- Works in CLI dispatch, gateway dispatch, autocomplete (CLI + TUI),
  /help display.

Schema (~/.hermes/skill-bundles/<slug>.yaml):
    name: backend-dev
    description: Backend feature work.
    skills:
      - github-code-review
      - test-driven-development
    instruction: |
      Optional extra guidance prepended to the loaded skills.

New module: agent/skill_bundles.py — load, scan, resolve, build
invocation message, save, delete. yaml.safe_load only; broken
bundles log a warning and are skipped, never raise.

New CLI subcommand: hermes bundles {list,show,create,delete,reload}.
Implementation in hermes_cli/bundles.py; wired in hermes_cli/main.py.
'bundles' added to _BUILTIN_SUBCOMMANDS so plugin discovery skips it.

New in-session slash command: /bundles lists installed bundles in
both CLI and gateway. /<bundle-name> dispatch added to CLI (cli.py)
and gateway (gateway/run.py) before the existing /<skill-name> path.

Autocomplete: SlashCommandCompleter gained an optional
skill_bundles_provider parameter that defaults to None — the prompt
shows '▣ <description> (N skills)' for bundles vs '' for skills.

Tests:
- tests/agent/test_skill_bundles.py — 33 tests covering slugify,
  scan/cache freshness, resolve (including underscore→hyphen
  Telegram alias), build_bundle_invocation_message (loading, missing
  skills, user/bundle instruction injection, dedup), save/delete,
  reload diff, list sort.
- tests/hermes_cli/test_bundles.py — 8 tests for the CLI
  subcommand (create/list/show/delete/reload, --force, missing
  bundle errors).
- tests/gateway/test_bundles_command.py — 4 tests for the gateway
  handler and bundle resolution priority.

Live E2E: verified subprocess invocations of hermes bundles
{list,create,show,reload,delete} round-trip correctly against an
isolated HERMES_HOME.

Docs:
- website/docs/user-guide/features/skills.md — new 'Skill Bundles'
  section with quick example, YAML schema, management commands,
  behavior notes.
- website/docs/reference/cli-commands.md — 'hermes bundles' added to
  the top-level command table and given its own subcommand section.
This commit is contained in:
Teknium
2026-05-18 21:38:05 -07:00
committed by GitHub
parent 1733cb3a13
commit b5c1fe78aa
12 changed files with 1498 additions and 3 deletions

View File

@@ -0,0 +1,337 @@
"""Tests for agent/skill_bundles.py — YAML-defined skill bundles."""
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from agent.skill_bundles import (
_slugify,
build_bundle_invocation_message,
delete_bundle,
get_bundle,
get_skill_bundles,
list_bundles,
reload_bundles,
resolve_bundle_command_key,
save_bundle,
scan_bundles,
)
def _make_bundle_yaml(
bundles_dir: Path, slug: str, skills: list[str],
description: str = "", instruction: str = "", name: str | None = None,
) -> Path:
bundles_dir.mkdir(parents=True, exist_ok=True)
lines = []
if name is not None:
lines.append(f"name: {name}")
else:
lines.append(f"name: {slug}")
if description:
lines.append(f"description: {description}")
lines.append("skills:")
for s in skills:
lines.append(f" - {s}")
if instruction:
lines.append(f"instruction: |")
for ln in instruction.splitlines():
lines.append(f" {ln}")
path = bundles_dir / f"{slug}.yaml"
path.write_text("\n".join(lines) + "\n")
return path
def _make_skill(skills_dir: Path, name: str, body: str = "Do the thing.") -> Path:
skill_dir = skills_dir / name
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: Description for {name}\n---\n\n# {name}\n\n{body}\n"
)
return skill_dir
@pytest.fixture
def bundles_env(tmp_path, monkeypatch):
"""Isolated bundles dir + skills dir."""
bundles_dir = tmp_path / "skill-bundles"
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
# Patch SKILLS_DIR so skill loading hits our temp tree.
import tools.skills_tool as skills_tool_module
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir)
# Reset module-level cache between tests.
import agent.skill_bundles as mod
mod._bundles_cache = {}
mod._bundles_cache_mtime = None
return bundles_dir, skills_dir
class TestSlugify:
def test_basic(self):
assert _slugify("Backend Dev") == "backend-dev"
def test_underscores(self):
assert _slugify("backend_dev") == "backend-dev"
def test_strips_invalid_chars(self):
assert _slugify("hello, world!") == "hello-world"
def test_collapses_hyphens(self):
assert _slugify("a--b---c") == "a-b-c"
def test_empty(self):
assert _slugify("") == ""
assert _slugify("!!!") == ""
class TestScanBundles:
def test_empty_dir(self, bundles_env):
bundles_dir, _ = bundles_env
result = scan_bundles()
assert result == {}
def test_finds_bundle(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "backend", ["skill-a", "skill-b"])
result = scan_bundles()
assert "/backend" in result
assert result["/backend"]["name"] == "backend"
assert result["/backend"]["skills"] == ["skill-a", "skill-b"]
def test_skips_invalid_yaml(self, bundles_env):
bundles_dir, _ = bundles_env
bundles_dir.mkdir(parents=True)
(bundles_dir / "broken.yaml").write_text("{not: valid yaml: [")
_make_bundle_yaml(bundles_dir, "good", ["skill-a"])
result = scan_bundles()
assert "/good" in result
assert "/broken" not in result
def test_skips_bundle_without_skills(self, bundles_env):
bundles_dir, _ = bundles_env
bundles_dir.mkdir(parents=True)
(bundles_dir / "noskills.yaml").write_text("name: noskills\nskills: []\n")
result = scan_bundles()
assert "/noskills" not in result
def test_duplicate_slug_first_wins(self, bundles_env):
bundles_dir, _ = bundles_env
# Two files normalizing to the same slug. Sort order is by filename:
# 'alpha-dup.yaml' sorts before 'alpha.yaml' (`-` < `.` in ASCII), so
# the first-seen file wins.
_make_bundle_yaml(bundles_dir, "alpha", ["s1"], name="alpha")
_make_bundle_yaml(bundles_dir, "alpha-dup", ["s2"], name="ALPHA")
result = scan_bundles()
assert "/alpha" in result
# alpha-dup.yaml is scanned first → its skills win
assert result["/alpha"]["skills"] == ["s2"]
def test_uses_filename_as_fallback_name(self, bundles_env):
bundles_dir, _ = bundles_env
bundles_dir.mkdir(parents=True)
(bundles_dir / "fallback.yaml").write_text("skills:\n - foo\n")
result = scan_bundles()
assert "/fallback" in result
assert result["/fallback"]["name"] == "fallback"
class TestGetSkillBundles:
def test_returns_cache(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "a", ["s1"])
first = get_skill_bundles()
# Second call should hit cache (no rescan unless mtime changed).
second = get_skill_bundles()
assert first is second or first == second
def test_rescans_on_change(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "a", ["s1"])
assert "/a" in get_skill_bundles()
# Add a second bundle and bump mtime.
import time as _t
_t.sleep(0.05) # ensure mtime granularity is exceeded
_make_bundle_yaml(bundles_dir, "b", ["s2"])
os.utime(bundles_dir, None)
result = get_skill_bundles()
assert "/a" in result
assert "/b" in result
class TestResolveBundleCommandKey:
def test_exact_match(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "my-bundle", ["s1"])
scan_bundles()
assert resolve_bundle_command_key("my-bundle") == "/my-bundle"
def test_underscore_alias(self, bundles_env):
"""Telegram converts hyphens to underscores in command names."""
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "my-bundle", ["s1"])
scan_bundles()
assert resolve_bundle_command_key("my_bundle") == "/my-bundle"
def test_unknown(self, bundles_env):
scan_bundles()
assert resolve_bundle_command_key("missing") is None
def test_empty(self, bundles_env):
assert resolve_bundle_command_key("") is None
class TestBuildBundleInvocationMessage:
def test_loads_all_skills(self, bundles_env):
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a", body="Skill A content.")
_make_skill(skills_dir, "skill-b", body="Skill B content.")
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-b"])
scan_bundles()
result = build_bundle_invocation_message("/combo")
assert result is not None
msg, loaded, missing = result
assert set(loaded) == {"skill-a", "skill-b"}
assert missing == []
assert "Skill A content." in msg
assert "Skill B content." in msg
assert "combo" in msg
def test_skips_missing_skills(self, bundles_env):
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a")
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-ghost"])
scan_bundles()
result = build_bundle_invocation_message("/combo")
assert result is not None
msg, loaded, missing = result
assert loaded == ["skill-a"]
assert missing == ["skill-ghost"]
assert "skill-ghost" in msg # called out in header
def test_unknown_bundle_returns_none(self, bundles_env):
scan_bundles()
assert build_bundle_invocation_message("/nope") is None
def test_no_loadable_skills_returns_none(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "ghost", ["nonexistent-skill"])
scan_bundles()
result = build_bundle_invocation_message("/ghost")
assert result is None
def test_includes_user_instruction(self, bundles_env):
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a")
_make_bundle_yaml(bundles_dir, "combo", ["skill-a"])
scan_bundles()
result = build_bundle_invocation_message(
"/combo", user_instruction="extra context here"
)
assert result is not None
msg, _, _ = result
assert "extra context here" in msg
def test_includes_bundle_instruction(self, bundles_env):
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a")
_make_bundle_yaml(
bundles_dir, "combo", ["skill-a"],
instruction="Always check tests first.",
)
scan_bundles()
result = build_bundle_invocation_message("/combo")
assert result is not None
msg, _, _ = result
assert "Always check tests first." in msg
def test_dedupes_skills(self, bundles_env):
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a")
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-a"])
scan_bundles()
result = build_bundle_invocation_message("/combo")
assert result is not None
_, loaded, _ = result
assert loaded == ["skill-a"]
class TestSaveAndDeleteBundle:
def test_save_creates_file(self, bundles_env):
bundles_dir, _ = bundles_env
path = save_bundle("test-bundle", ["s1", "s2"], description="d", instruction="i")
assert path.exists()
assert path.parent == bundles_dir
content = path.read_text()
assert "test-bundle" in content
assert "s1" in content
assert "s2" in content
assert "description: d" in content
def test_save_refuses_overwrite_by_default(self, bundles_env):
save_bundle("dup", ["s1"])
with pytest.raises(FileExistsError):
save_bundle("dup", ["s2"])
def test_save_overwrites_with_force(self, bundles_env):
save_bundle("dup", ["s1"])
save_bundle("dup", ["s2"], overwrite=True)
info = get_bundle("dup")
assert info is not None
assert info["skills"] == ["s2"]
def test_save_requires_skills(self, bundles_env):
with pytest.raises(ValueError):
save_bundle("empty", [])
def test_save_requires_name(self, bundles_env):
with pytest.raises(ValueError):
save_bundle("", ["s1"])
def test_delete_removes_file(self, bundles_env):
bundles_dir, _ = bundles_env
save_bundle("doomed", ["s1"])
assert get_bundle("doomed") is not None
delete_bundle("doomed")
assert get_bundle("doomed") is None
def test_delete_missing_raises(self, bundles_env):
with pytest.raises(FileNotFoundError):
delete_bundle("ghost")
class TestReloadBundles:
def test_reports_added_and_removed(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "old", ["s1"])
scan_bundles() # populate cache with {old}
# Mutate the disk WITHOUT going through save/delete helpers (which
# would refresh the cache mid-way). reload_bundles() diffs the
# in-memory cache against the freshly-scanned disk state.
(bundles_dir / "old.yaml").unlink()
_make_bundle_yaml(bundles_dir, "new", ["s2"])
diff = reload_bundles()
added_names = {e["name"] for e in diff["added"]}
removed_names = {e["name"] for e in diff["removed"]}
assert "new" in added_names
assert "old" in removed_names
assert diff["total"] == 1
class TestListBundles:
def test_sorted_by_slug(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle_yaml(bundles_dir, "zebra", ["s1"])
_make_bundle_yaml(bundles_dir, "apple", ["s2"])
_make_bundle_yaml(bundles_dir, "mango", ["s3"])
scan_bundles()
info_list = list_bundles()
slugs = [b["slug"] for b in info_list]
assert slugs == sorted(slugs)

View File

@@ -0,0 +1,115 @@
"""Tests for the ``/bundles`` gateway slash command handler.
Verifies that:
- ``_handle_bundles_command`` returns useful text when no bundles are
installed and when several are.
- Bundle dispatch in ``_handle_message`` rewrites ``event.text`` to the
combined skill content when the user types ``/<bundle-slug>``.
The actual ``/<bundle-slug>`` → combined-message build is tested in
``tests/agent/test_skill_bundles.py``; this file only checks the gateway
glue (handler wiring, dispatch ordering, event.text rewrite).
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent
from gateway.session import SessionSource
def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)
def _make_event(text: str) -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
def _make_runner():
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner.hooks = SimpleNamespace(
emit=AsyncMock(),
emit_collect=AsyncMock(return_value=[]),
loaded_hooks=False,
)
return runner
@pytest.fixture
def bundles_env(tmp_path, monkeypatch):
bundles_dir = tmp_path / "skill-bundles"
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
import tools.skills_tool as skills_tool_module
monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir)
import agent.skill_bundles as mod
mod._bundles_cache = {}
mod._bundles_cache_mtime = None
return bundles_dir, skills_dir
def _make_skill(skills_dir, name, body="content"):
sd = skills_dir / name
sd.mkdir(parents=True, exist_ok=True)
(sd / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: desc {name}\n---\n\n# {name}\n\n{body}\n"
)
def _make_bundle(bundles_dir, slug, skills):
bundles_dir.mkdir(parents=True, exist_ok=True)
(bundles_dir / f"{slug}.yaml").write_text(
f"name: {slug}\nskills:\n" + "\n".join(f" - {s}" for s in skills) + "\n"
)
class TestHandleBundlesCommand:
def test_empty(self, bundles_env):
runner = _make_runner()
result = asyncio.run(runner._handle_bundles_command(_make_event("/bundles")))
assert "No skill bundles" in result
def test_with_bundles(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle(bundles_dir, "research", ["alpha", "beta"])
runner = _make_runner()
result = asyncio.run(runner._handle_bundles_command(_make_event("/bundles")))
assert "research" in result
assert "/research" in result
assert "2 skills" in result
class TestBundleResolutionPriority:
"""Verify resolve_bundle_command_key picks bundles over skills."""
def test_bundle_resolves(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle(bundles_dir, "research", ["alpha"])
from agent.skill_bundles import resolve_bundle_command_key
assert resolve_bundle_command_key("research") == "/research"
def test_underscore_alias(self, bundles_env):
bundles_dir, _ = bundles_env
_make_bundle(bundles_dir, "my-bundle", ["alpha"])
from agent.skill_bundles import resolve_bundle_command_key
assert resolve_bundle_command_key("my_bundle") == "/my-bundle"

View File

@@ -0,0 +1,94 @@
"""Tests for hermes_cli/bundles.py — the `hermes bundles` CLI subcommand."""
import argparse
import sys
from pathlib import Path
import pytest
from hermes_cli.bundles import (
bundles_command,
register_cli,
)
@pytest.fixture
def bundles_env(tmp_path, monkeypatch):
bundles_dir = tmp_path / "skill-bundles"
monkeypatch.setenv("HERMES_BUNDLES_DIR", str(bundles_dir))
# Reset module-level cache between tests.
import agent.skill_bundles as mod
mod._bundles_cache = {}
mod._bundles_cache_mtime = None
return bundles_dir
def _parse(argv):
parser = argparse.ArgumentParser()
register_cli(parser)
return parser.parse_args(argv)
class TestBundlesCli:
def test_create_and_list(self, bundles_env, capsys):
args = _parse(["create", "my-bundle", "--skill", "a", "--skill", "b", "-d", "desc"])
bundles_command(args)
out = capsys.readouterr().out
assert "Created bundle" in out
# File should exist
assert (bundles_env / "my-bundle.yaml").exists()
args = _parse(["list"])
bundles_command(args)
out = capsys.readouterr().out
assert "my-bundle" in out
def test_show(self, bundles_env, capsys):
bundles_command(_parse(["create", "x", "--skill", "s1", "--skill", "s2"]))
capsys.readouterr() # clear
bundles_command(_parse(["show", "x"]))
out = capsys.readouterr().out
assert "/x" in out
assert "s1" in out
assert "s2" in out
def test_delete(self, bundles_env, capsys):
bundles_command(_parse(["create", "doomed", "--skill", "s1"]))
capsys.readouterr()
bundles_command(_parse(["delete", "doomed"]))
out = capsys.readouterr().out
assert "Deleted bundle" in out
assert not (bundles_env / "doomed.yaml").exists()
def test_create_refuses_overwrite(self, bundles_env, capsys):
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
capsys.readouterr()
with pytest.raises(SystemExit) as ei:
bundles_command(_parse(["create", "dup", "--skill", "s2"]))
assert ei.value.code == 1
out = capsys.readouterr().out
assert "already exists" in out.lower() or "--force" in out.lower()
def test_create_force_overwrites(self, bundles_env, capsys):
bundles_command(_parse(["create", "dup", "--skill", "s1"]))
capsys.readouterr()
bundles_command(_parse(["create", "dup", "--skill", "s2", "--force"]))
out = capsys.readouterr().out
assert "Created bundle" in out
def test_create_requires_skills(self, bundles_env, capsys, monkeypatch):
# Simulate user pressing Ctrl-D immediately at the interactive prompt.
monkeypatch.setattr("builtins.input", lambda *_a, **_kw: (_ for _ in ()).throw(EOFError()))
with pytest.raises(SystemExit):
bundles_command(_parse(["create", "empty"]))
def test_show_missing(self, bundles_env, capsys):
with pytest.raises(SystemExit) as ei:
bundles_command(_parse(["show", "ghost"]))
assert ei.value.code == 1
def test_reload(self, bundles_env, capsys):
# Reload on an empty dir reports no changes.
bundles_command(_parse(["reload"]))
out = capsys.readouterr().out
assert "No changes" in out or "0" in out