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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1498 additions and 3 deletions

410
agent/skill_bundles.py Normal file
View file

@ -0,0 +1,410 @@
"""Skill bundles — aliases that load multiple skills under one slash command.
A skill bundle is a small YAML file that names a set of skills to load
together. Invoking ``/<bundle-name>`` from the CLI or gateway loads every
referenced skill's full content into a single user message, the same way
``/<skill-name>`` does but for N skills at once.
Storage
-------
Bundles live in ``~/.hermes/skill-bundles/*.yaml`` (and the equivalent
profile-aware directory under ``HERMES_HOME``). Each file looks like::
name: backend-dev
description: Backend feature work code review, testing, PR workflow.
skills:
- github-code-review
- test-driven-development
- github-pr-workflow
instruction: |
Optional extra guidance to inject above the skill bodies.
The file's stem is treated as a fallback name when ``name:`` is absent, so
dropping a YAML into the directory is enough to register a new bundle.
Conflict resolution
-------------------
If a bundle and a skill share the same slash name, the bundle wins. The
slash command dispatch checks bundles first, then falls back to skills.
This is the intended behavior a user who names a bundle ``research``
explicitly wants ``/research`` to mean their bundle, not whatever skill
happens to share the slug.
Public API
----------
- :func:`get_skill_bundles` return ``{"/slug": bundle_info}``
- :func:`resolve_bundle_command_key` map a user-typed command to its slug
- :func:`build_bundle_invocation_message` produce the full user message
- :func:`reload_bundles` re-scan disk and return a diff
- :func:`list_bundles` return rich info for display (``hermes bundles``)
- :func:`save_bundle` / :func:`delete_bundle` file-level operations
"""
from __future__ import annotations
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import yaml
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# Slug normalization — matches agent/skill_commands.py so a bundle and a
# skill called "Foo Bar" both resolve to "/foo-bar".
_BUNDLE_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_BUNDLE_MULTI_HYPHEN = re.compile(r"-{2,}")
_bundles_cache: Dict[str, Dict[str, Any]] = {}
_bundles_cache_mtime: Optional[float] = None
def _bundles_dir() -> Path:
"""Return the canonical bundles directory under HERMES_HOME.
Honors ``HERMES_BUNDLES_DIR`` for tests; falls back to
``<HERMES_HOME>/skill-bundles``.
"""
override = os.environ.get("HERMES_BUNDLES_DIR")
if override:
return Path(override).expanduser()
return get_hermes_home() / "skill-bundles"
def _slugify(name: str) -> str:
cmd = name.lower().replace(" ", "-").replace("_", "-")
cmd = _BUNDLE_INVALID_CHARS.sub("", cmd)
cmd = _BUNDLE_MULTI_HYPHEN.sub("-", cmd).strip("-")
return cmd
def _iter_bundle_files() -> List[Path]:
base = _bundles_dir()
if not base.exists():
return []
files: List[Path] = []
for ext in ("*.yaml", "*.yml"):
files.extend(sorted(base.glob(ext)))
return files
def _max_mtime(files: List[Path]) -> float:
"""Highest mtime across the bundle files plus the dir itself.
Watching the directory mtime catches deletions; watching individual
files catches edits. Together they're a cheap freshness check.
"""
base = _bundles_dir()
mtimes = []
if base.exists():
try:
mtimes.append(base.stat().st_mtime)
except OSError:
pass
for f in files:
try:
mtimes.append(f.stat().st_mtime)
except OSError:
continue
return max(mtimes) if mtimes else 0.0
def _load_bundle_file(path: Path) -> Optional[Dict[str, Any]]:
"""Parse a single bundle YAML file. Returns ``None`` on any error.
Errors are logged at WARNING level. We don't raise — a broken bundle
shouldn't take down slash command discovery.
"""
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("Could not read bundle %s: %s", path, exc)
return None
try:
data = yaml.safe_load(raw)
except yaml.YAMLError as exc:
logger.warning("Invalid YAML in bundle %s: %s", path, exc)
return None
if not isinstance(data, dict):
logger.warning("Bundle %s is not a mapping; skipping", path)
return None
name = str(data.get("name") or path.stem).strip()
if not name:
logger.warning("Bundle %s has no name; skipping", path)
return None
skills = data.get("skills") or []
if not isinstance(skills, list) or not skills:
logger.warning("Bundle %s has no skills list; skipping", path)
return None
skills = [str(s).strip() for s in skills if str(s).strip()]
if not skills:
logger.warning("Bundle %s has empty skills list; skipping", path)
return None
description = str(data.get("description") or "").strip()
instruction = str(data.get("instruction") or "").strip()
slug = _slugify(name)
if not slug:
logger.warning("Bundle %s yielded empty slug; skipping", path)
return None
return {
"name": name,
"slug": slug,
"description": description or f"Load {len(skills)} skills as a bundle",
"skills": skills,
"instruction": instruction,
"path": str(path),
}
def scan_bundles() -> Dict[str, Dict[str, Any]]:
"""Scan the bundles directory and rebuild the cache.
Returns the same mapping as :func:`get_skill_bundles` ``"/slug"``
bundle info dict. Later bundles with a duplicate slug are skipped with
a warning (first wins, alphabetical order).
"""
global _bundles_cache, _bundles_cache_mtime
files = _iter_bundle_files()
out: Dict[str, Dict[str, Any]] = {}
for f in files:
info = _load_bundle_file(f)
if not info:
continue
key = f"/{info['slug']}"
if key in out:
logger.warning(
"Duplicate bundle slug %s from %s; keeping %s",
key, f, out[key]["path"],
)
continue
out[key] = info
_bundles_cache = out
_bundles_cache_mtime = _max_mtime(files)
return out
def get_skill_bundles() -> Dict[str, Dict[str, Any]]:
"""Return the current bundle mapping, rescanning when disk changed.
Cheap to call repeatedly: only rescans when the bundles directory or
any bundle file's mtime is newer than the cached snapshot.
"""
files = _iter_bundle_files()
current_mtime = _max_mtime(files)
if not _bundles_cache or _bundles_cache_mtime != current_mtime:
scan_bundles()
return _bundles_cache
def resolve_bundle_command_key(command: str) -> Optional[str]:
"""Resolve a user-typed command to its canonical bundle slash key.
Hyphens and underscores are treated interchangeably to mirror the
skill-command behavior (Telegram converts hyphens to underscores in
bot command names).
"""
if not command:
return None
cmd_key = f"/{command.replace('_', '-')}"
return cmd_key if cmd_key in get_skill_bundles() else None
def reload_bundles() -> Dict[str, Any]:
"""Re-scan the bundles directory and return a diff.
Mirrors :func:`agent.skill_commands.reload_skills` so callers can use
the same display logic. Returns a dict with ``added``, ``removed``,
``unchanged``, and ``total`` keys.
"""
def _snapshot(cmds: Dict[str, Dict[str, Any]]) -> Dict[str, str]:
return {k.lstrip("/"): (v or {}).get("description", "") for k, v in cmds.items()}
before = _snapshot(_bundles_cache)
new = scan_bundles()
after = _snapshot(new)
added_names = sorted(set(after) - set(before))
removed_names = sorted(set(before) - set(after))
unchanged = sorted(set(after) & set(before))
return {
"added": [{"name": n, "description": after[n]} for n in added_names],
"removed": [{"name": n, "description": before[n]} for n in removed_names],
"unchanged": unchanged,
"total": len(after),
}
def list_bundles() -> List[Dict[str, Any]]:
"""Return a sorted list of bundle info dicts for display."""
bundles = get_skill_bundles()
return sorted(bundles.values(), key=lambda b: b["slug"])
def build_bundle_invocation_message(
cmd_key: str,
user_instruction: str = "",
task_id: str | None = None,
) -> Optional[Tuple[str, List[str], List[str]]]:
"""Build the user message content for a bundle slash command invocation.
Returns ``(message, loaded_skill_names, missing_skill_names)`` or
``None`` if the bundle wasn't found.
A bundle that references skills the user doesn't have installed still
loads the agent gets a note about which ones were skipped. This is
the same forgiving stance ``build_preloaded_skills_prompt`` uses for
``-s`` CLI preloading.
"""
bundles = get_skill_bundles()
info = bundles.get(cmd_key)
if not info:
return None
# Late import to avoid pulling tools/* at module import time and to
# keep skill_bundles cheap to import in test environments.
from agent.skill_commands import _load_skill_payload, _build_skill_message
loaded_names: List[str] = []
missing: List[str] = []
skill_blocks: List[str] = []
seen: set[str] = set()
bundle_name = info["name"]
skills = info["skills"]
extra_instruction = info.get("instruction") or ""
for skill_id in skills:
identifier = (skill_id or "").strip()
if not identifier or identifier in seen:
continue
seen.add(identifier)
loaded = _load_skill_payload(identifier, task_id=task_id)
if not loaded:
missing.append(identifier)
continue
loaded_skill, skill_dir, skill_name = loaded
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
except Exception:
pass
activation_note = (
f'[Loaded as part of the "{bundle_name}" skill bundle.]'
)
skill_blocks.append(
_build_skill_message(
loaded_skill,
skill_dir,
activation_note,
session_id=task_id,
)
)
loaded_names.append(skill_name)
if not skill_blocks:
return None
# Header — tells the agent this is a bundle, lists the skills, and
# provides any author-supplied instruction.
header_lines = [
f'[IMPORTANT: The user has invoked the "{bundle_name}" skill bundle, '
f"loading {len(loaded_names)} skills together. Treat every skill below "
"as active guidance for this turn.]",
"",
f"Bundle: {bundle_name}",
f"Skills loaded: {', '.join(loaded_names)}",
]
if missing:
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
if extra_instruction:
header_lines.extend(["", f"Bundle instruction: {extra_instruction}"])
if user_instruction:
header_lines.extend(
["", f"User instruction: {user_instruction}"]
)
header = "\n".join(header_lines)
return ("\n\n".join([header, *skill_blocks]), loaded_names, missing)
# ---------------------------------------------------------------------------
# File-level CRUD helpers — used by `hermes bundles` CLI subcommand.
# ---------------------------------------------------------------------------
def bundle_path_for(name: str) -> Path:
"""Return the canonical filesystem path for a bundle name."""
slug = _slugify(name)
if not slug:
raise ValueError(f"Bundle name {name!r} normalizes to an empty slug")
return _bundles_dir() / f"{slug}.yaml"
def save_bundle(
name: str,
skills: List[str],
description: str = "",
instruction: str = "",
overwrite: bool = False,
) -> Path:
"""Write a bundle to disk and invalidate the cache.
Raises ``FileExistsError`` if the target exists and ``overwrite`` is
False. Raises ``ValueError`` if the inputs are unusable.
"""
name = (name or "").strip()
if not name:
raise ValueError("Bundle name is required")
cleaned_skills = [str(s).strip() for s in skills if str(s).strip()]
if not cleaned_skills:
raise ValueError("Bundle must reference at least one skill")
path = bundle_path_for(name)
if path.exists() and not overwrite:
raise FileExistsError(f"Bundle already exists at {path}")
path.parent.mkdir(parents=True, exist_ok=True)
payload: Dict[str, Any] = {"name": name, "skills": cleaned_skills}
if description:
payload["description"] = description
if instruction:
payload["instruction"] = instruction
path.write_text(
yaml.safe_dump(payload, sort_keys=False, allow_unicode=True),
encoding="utf-8",
)
scan_bundles() # refresh cache
return path
def delete_bundle(name: str) -> Path:
"""Delete a bundle by name. Returns the deleted path.
Raises ``FileNotFoundError`` if the bundle doesn't exist.
"""
path = bundle_path_for(name)
if not path.exists():
raise FileNotFoundError(f"No bundle at {path}")
path.unlink()
scan_bundles()
return path
def get_bundle(name: str) -> Optional[Dict[str, Any]]:
"""Look up a bundle by name (slug-normalized)."""
slug = _slugify(name)
return get_skill_bundles().get(f"/{slug}")

83
cli.py
View file

@ -2447,8 +2447,13 @@ from agent.skill_commands import (
build_skill_invocation_message,
build_preloaded_skills_prompt,
)
from agent.skill_bundles import (
get_skill_bundles,
build_bundle_invocation_message,
)
_skill_commands = scan_skill_commands()
_skill_bundles = get_skill_bundles()
def _get_plugin_cmd_handler_names() -> set:
@ -5577,6 +5582,17 @@ class HermesCLI:
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}"
)
_bundles_now = get_skill_bundles()
if _bundles_now:
_cprint(f"\n{_BOLD}Skill Bundles{_RST} ({len(_bundles_now)} installed):")
for cmd, info in sorted(_bundles_now.items()):
skill_count = len(info.get("skills", []))
desc = info.get("description") or f"Load {skill_count} skills"
ChatConsole().print(
f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] "
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
@ -8003,6 +8019,8 @@ class HermesCLI:
elif canonical == "reload-skills":
with self._busy_command(self._slow_command_status(cmd_original)):
self._reload_skills()
elif canonical == "bundles":
self._handle_bundles_command(cmd_original)
elif canonical == "browser":
self._handle_browser_command(cmd_original)
elif canonical == "plugins":
@ -8139,6 +8157,30 @@ class HermesCLI:
_cprint(str(result))
except Exception as e:
_cprint(f"\033[1;31mPlugin command error: {e}{_RST}")
# Skill bundles take precedence over individual skills — /<bundle>
# loads multiple skills at once. Rescans cheaply when files change.
elif base_cmd in get_skill_bundles():
user_instruction = cmd_original[len(base_cmd):].strip()
bundle_result = build_bundle_invocation_message(
base_cmd, user_instruction, task_id=self.session_id
)
if bundle_result:
msg, loaded_names, missing = bundle_result
bundle_info = get_skill_bundles()[base_cmd]
print(
f"\n⚡ Loading bundle: {bundle_info['name']} "
f"({len(loaded_names)} skills)"
)
if missing:
ChatConsole().print(
f"[yellow]Skipped missing skills: {', '.join(missing)}[/]"
)
if hasattr(self, '_pending_input'):
self._pending_input.put(msg)
else:
ChatConsole().print(
f"[bold red]Failed to load bundle for {base_cmd}[/]"
)
# Check for skill slash commands (/gif-search, /axolotl, etc.)
elif base_cmd in _skill_commands:
user_instruction = cmd_original[len(base_cmd):].strip()
@ -8158,7 +8200,7 @@ class HermesCLI:
# that execution-time resolution agrees with tab-completion.
from hermes_cli.commands import COMMANDS
typed_base = cmd_lower.split()[0]
all_known = set(COMMANDS) | set(_skill_commands)
all_known = set(COMMANDS) | set(_skill_commands) | set(get_skill_bundles())
matches = [c for c in all_known if c.startswith(typed_base)]
if len(matches) > 1:
# Prefer an exact match (typed the full command name)
@ -8359,6 +8401,44 @@ class HermesCLI:
"""
return try_launch_chrome_debug(port, system)
def _handle_bundles_command(self, cmd: str) -> None:
"""In-session ``/bundles`` — show installed skill bundles.
Mirrors ``hermes bundles list`` but renders inside the running
CLI so users can discover what's available without dropping out
of their session. Bundles are loaded via ``/<bundle-name>``.
"""
try:
from agent.skill_bundles import list_bundles, _bundles_dir
except Exception as exc:
_cprint(f"\033[1;31mBundle subsystem unavailable: {exc}{_RST}")
return
bundles = list_bundles()
if not bundles:
_cprint(" No skill bundles installed.")
_cprint(
f" {_DIM}Create one with: hermes bundles create "
f"<name> --skill <s1> --skill <s2>{_RST}"
)
_cprint(f" {_DIM}Directory: {_bundles_dir()}{_RST}")
return
_cprint(f"\n{_BOLD}Skill Bundles{_RST} ({len(bundles)} installed):")
for info in bundles:
skill_count = len(info.get("skills", []))
desc = info.get("description") or f"Load {skill_count} skills"
ChatConsole().print(
f" [bold {_accent_hex()}]/{info['slug']:<20}[/] "
f"[dim]-[/] {_escape(desc)} [dim]({skill_count} skills)[/]"
)
for s in info.get("skills", []):
ChatConsole().print(f" [dim]· {_escape(s)}[/]")
_cprint(
f"\n {_DIM}Invoke a bundle with /<slug>. "
f"Manage with `hermes bundles`.{_RST}"
)
def _handle_browser_command(self, cmd: str):
"""Handle /browser connect|disconnect|status — manage live Chrome CDP connection."""
import platform as _plat
@ -12782,6 +12862,7 @@ class HermesCLI:
_completer = SlashCommandCompleter(
skill_commands_provider=lambda: get_skill_commands(),
command_filter=cli_ref._command_available,
skill_bundles_provider=lambda: get_skill_bundles(),
)
input_area = TextArea(
height=Dimension(min=1, max=8, preferred=1),

View file

@ -6923,6 +6923,9 @@ class GatewayRunner:
if canonical == "reload-skills":
return await self._handle_reload_skills_command(event)
if canonical == "bundles":
return await self._handle_bundles_command(event)
if canonical == "approve":
return await self._handle_approve_command(event)
@ -7051,6 +7054,34 @@ class GatewayRunner:
# round-trip so /claude_code from Telegram autocomplete still resolves
# to the claude-code skill.
if command:
# Skill bundles take precedence over individual skill commands —
# /<bundle> loads multiple skills at once. Mirrors CLI dispatch.
_bundle_handled = False
try:
from agent.skill_bundles import (
build_bundle_invocation_message,
resolve_bundle_command_key,
)
bundle_key = resolve_bundle_command_key(command)
if bundle_key is not None:
user_instruction = event.get_command_args().strip()
bundle_result = build_bundle_invocation_message(
bundle_key, user_instruction, task_id=_quick_key
)
if bundle_result:
msg, _loaded, missing = bundle_result
event.text = msg
_bundle_handled = True
if missing:
logger.info(
"Bundle %s skipped missing skills: %s",
bundle_key, ", ".join(missing),
)
# Fall through to normal message processing with bundle content
except Exception as exc:
logger.debug("Bundle dispatch failed (non-fatal): %s", exc)
if command and not locals().get("_bundle_handled", False):
try:
from agent.skill_commands import (
get_skill_commands,
@ -12687,6 +12718,41 @@ class GatewayRunner:
logger.warning("Skills reload failed: %s", e)
return t("gateway.reload_skills.failed", error=e)
async def _handle_bundles_command(self, event: MessageEvent) -> str:
"""Handle /bundles — list installed skill bundles.
Mirrors the CLI ``/bundles`` handler. Returns a single text
message suitable for any gateway adapter; bundles are loaded by
invoking the bundle's own ``/<slug>`` command, not by this one.
"""
try:
from agent.skill_bundles import list_bundles, _bundles_dir
except Exception as exc:
logger.warning("Bundles command unavailable: %s", exc)
return f"Bundles subsystem unavailable: {exc}"
bundles = list_bundles()
if not bundles:
return (
"No skill bundles installed.\n"
"Create one on the host with:\n"
" `hermes bundles create <name> --skill <s1> --skill <s2>`\n"
f"Directory: `{_bundles_dir()}`"
)
lines = [f"**Skill Bundles** ({len(bundles)} installed):", ""]
for info in bundles:
skill_count = len(info.get("skills", []))
desc = info.get("description") or f"Load {skill_count} skills"
lines.append(
f"• `/{info['slug']}` — {desc} _({skill_count} skills)_"
)
for s in info.get("skills", []):
lines.append(f" · {s}")
lines.append("")
lines.append("Invoke a bundle with `/<slug>` to load all its skills.")
return "\n".join(lines)
# ------------------------------------------------------------------
# Slash-command confirmation primitive (generic)
# ------------------------------------------------------------------

229
hermes_cli/bundles.py Normal file
View file

@ -0,0 +1,229 @@
"""Implementation of the ``hermes bundles`` CLI subcommand.
Mirrors the structure of ``hermes_cli/skills_hub.py`` but for skill
bundles. Bundles are tiny YAML files that name a set of skills to load
together via a single ``/<bundle>`` slash command.
Subcommands:
- list: show all bundles
- show: dump one bundle's contents
- create: build a new bundle from arguments or interactively
- delete: remove a bundle
- reload: re-scan the bundles directory
"""
from __future__ import annotations
import sys
from typing import List, Optional
from rich.console import Console
from rich.table import Table
from agent.skill_bundles import (
_bundles_dir,
delete_bundle,
get_bundle,
list_bundles,
reload_bundles,
save_bundle,
scan_bundles,
)
def _console() -> Console:
# Bind to stderr so piping `hermes bundles list | grep …` doesn't
# garble rich markup with table styling. Tables and headings still
# render to a terminal; pure text columns survive piping.
return Console()
def _cmd_list(args) -> None:
c = _console()
bundles = list_bundles()
if not bundles:
c.print(
f"[dim]No bundles installed yet. Create one with:\n"
f" hermes bundles create <name> --skill skill1 --skill skill2[/]\n"
f"Bundles directory: [bold]{_bundles_dir()}[/]"
)
return
table = Table(title=f"Skill Bundles ({len(bundles)})", show_lines=False)
table.add_column("Command", style="bold cyan")
table.add_column("Name", style="bold")
table.add_column("Skills", justify="right")
table.add_column("Description")
for info in bundles:
skill_count = len(info.get("skills", []))
table.add_row(
f"/{info['slug']}",
info["name"],
str(skill_count),
info.get("description") or "",
)
c.print(table)
c.print(f"\n[dim]Bundles directory: {_bundles_dir()}[/]")
def _cmd_show(args) -> None:
c = _console()
info = get_bundle(args.name)
if not info:
c.print(f"[bold red]Bundle {args.name!r} not found.[/]")
sys.exit(1)
c.print(f"[bold cyan]/{info['slug']}[/] [bold]{info['name']}[/]")
if info.get("description"):
c.print(f" {info['description']}")
c.print(f" [dim]File: {info['path']}[/]")
c.print(f" [bold]Skills ({len(info['skills'])}):[/]")
for s in info["skills"]:
c.print(f" - {s}")
if info.get("instruction"):
c.print(f" [bold]Instruction:[/]\n {info['instruction']}")
def _cmd_create(args) -> None:
c = _console()
name = args.name
skills: List[str] = list(args.skill or [])
description = args.description or ""
instruction = args.instruction or ""
overwrite = bool(args.force)
if not skills:
# Interactive prompt for skills if none were passed on the CLI.
c.print(
"[dim]No skills passed via --skill. Enter one skill name per line.\n"
"Submit an empty line to finish.[/]"
)
try:
while True:
line = input("skill> ").strip()
if not line:
break
skills.append(line)
except (EOFError, KeyboardInterrupt):
c.print("\n[yellow]Cancelled.[/]")
sys.exit(1)
if not skills:
c.print("[bold red]A bundle must reference at least one skill.[/]")
sys.exit(1)
try:
path = save_bundle(
name,
skills,
description=description,
instruction=instruction,
overwrite=overwrite,
)
except FileExistsError as exc:
c.print(f"[bold red]{exc}[/]\n[dim]Pass --force to overwrite.[/]")
sys.exit(1)
except ValueError as exc:
c.print(f"[bold red]{exc}[/]")
sys.exit(1)
c.print(f"[bold green]Created bundle:[/] {path}")
info = get_bundle(name)
if info:
c.print(
f" Invoke with: [bold cyan]/{info['slug']}[/] "
f"(loads {len(info['skills'])} skills)"
)
def _cmd_delete(args) -> None:
c = _console()
try:
path = delete_bundle(args.name)
except FileNotFoundError as exc:
c.print(f"[bold red]{exc}[/]")
sys.exit(1)
c.print(f"[bold green]Deleted bundle:[/] {path}")
def _cmd_reload(args) -> None:
c = _console()
diff = reload_bundles()
if diff["added"]:
c.print(f"[bold green]Added ({len(diff['added'])}):[/]")
for entry in diff["added"]:
c.print(f" + {entry['name']}{entry.get('description', '')}")
if diff["removed"]:
c.print(f"[bold red]Removed ({len(diff['removed'])}):[/]")
for entry in diff["removed"]:
c.print(f" - {entry['name']}")
if not diff["added"] and not diff["removed"]:
c.print(f"[dim]No changes. {diff['total']} bundle(s) loaded.[/]")
else:
c.print(f"[dim]Total bundles now: {diff['total']}[/]")
def register_cli(subparser) -> None:
"""Build the ``hermes bundles`` argparse tree.
Called from ``hermes_cli/main.py`` where it owns the top-level
``bundles`` subparser. Keeping registration here means the bundles
subcommand's argparse tree lives next to its handlers.
"""
subs = subparser.add_subparsers(dest="bundles_action")
p_list = subs.add_parser("list", help="List installed skill bundles")
p_list.set_defaults(_bundles_handler=_cmd_list)
p_show = subs.add_parser("show", help="Show one bundle's contents")
p_show.add_argument("name", help="Bundle name")
p_show.set_defaults(_bundles_handler=_cmd_show)
p_create = subs.add_parser(
"create",
help="Create a new skill bundle",
description=(
"Create a new bundle. Skills can be passed via --skill (repeat for "
"multiple) or entered interactively when omitted."
),
)
p_create.add_argument("name", help="Bundle name (becomes the /slash command)")
p_create.add_argument(
"--skill", "-s", action="append", default=[],
help="Skill name to include (repeat for multiple)",
)
p_create.add_argument(
"--description", "-d", default="",
help="Human-readable description shown in /help and `hermes bundles list`",
)
p_create.add_argument(
"--instruction", "-i", default="",
help="Extra guidance prepended to the loaded skill content",
)
p_create.add_argument(
"--force", "-f", action="store_true",
help="Overwrite an existing bundle with the same name",
)
p_create.set_defaults(_bundles_handler=_cmd_create)
p_delete = subs.add_parser("delete", help="Delete a skill bundle")
p_delete.add_argument("name", help="Bundle name")
p_delete.set_defaults(_bundles_handler=_cmd_delete)
p_reload = subs.add_parser(
"reload", help="Re-scan the bundles directory and report changes"
)
p_reload.set_defaults(_bundles_handler=_cmd_reload)
# Ensure a fresh scan when any bundles subcommand runs.
scan_bundles()
def bundles_command(args) -> None:
"""Dispatch ``hermes bundles <subcommand>`` to the right handler."""
handler = getattr(args, "_bundles_handler", None)
if handler is None:
# No subcommand given — default to list.
_cmd_list(args)
return
handler(args)

View file

@ -165,6 +165,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
subcommands=("search", "browse", "inspect", "install")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
cli_only=True, args_hint="[subcommand]",
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
@ -1122,9 +1124,11 @@ class SlashCommandCompleter(Completer):
self,
skill_commands_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
command_filter: Callable[[str], bool] | None = None,
skill_bundles_provider: Callable[[], Mapping[str, dict[str, Any]]] | None = None,
) -> None:
self._skill_commands_provider = skill_commands_provider
self._command_filter = command_filter
self._skill_bundles_provider = skill_bundles_provider
# Cached project file list for fuzzy @ completions
self._file_cache: list[str] = []
self._file_cache_time: float = 0.0
@ -1146,6 +1150,14 @@ class SlashCommandCompleter(Completer):
except Exception:
return {}
def _iter_skill_bundles(self) -> Mapping[str, dict[str, Any]]:
if self._skill_bundles_provider is None:
return {}
try:
return self._skill_bundles_provider() or {}
except Exception:
return {}
# Commands that open pickers when run without arguments.
# These should NOT receive a trailing space in completions because:
# - The TUI's submit handler applies completions on Enter if input differs
@ -1625,6 +1637,19 @@ class SlashCommandCompleter(Completer):
display_meta=desc,
)
for cmd, info in self._iter_skill_bundles().items():
cmd_name = cmd[1:]
if cmd_name.startswith(word):
description = str(info.get("description", "Skill bundle"))
short_desc = description[:50] + ("..." if len(description) > 50 else "")
skill_count = len(info.get("skills", []))
yield Completion(
self._completion_text(cmd_name, word),
start_position=-len(word),
display=cmd,
display_meta=f"{short_desc} ({skill_count} skills)",
)
for cmd, info in self._iter_skill_commands().items():
cmd_name = cmd[1:]
if cmd_name.startswith(word):

View file

@ -9904,7 +9904,7 @@ def _build_provider_choices() -> list[str]:
# to parse.
_BUILTIN_SUBCOMMANDS = frozenset(
{
"acp", "auth", "backup", "checkpoints", "claw", "completion",
"acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion",
"computer-use",
"config", "cron", "curator", "dashboard", "debug", "doctor",
"dump", "fallback", "gateway", "hooks", "import", "insights",
@ -11338,6 +11338,22 @@ Examples:
skills_parser.set_defaults(func=cmd_skills)
# =========================================================================
# bundles command — skill bundles (alias /<name> for multiple skills)
# =========================================================================
bundles_parser = subparsers.add_parser(
"bundles",
help="Create, list, and manage skill bundles (aliases for multiple skills)",
description=(
"Skill bundles let you load several skills under one slash "
"command. `/<bundle>` from the CLI or gateway loads every "
"referenced skill at once."
),
)
from hermes_cli.bundles import register_cli as _bundles_register, bundles_command
_bundles_register(bundles_parser)
bundles_parser.set_defaults(func=bundles_command)
# =========================================================================
# plugins command
# =========================================================================

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

View file

@ -5234,9 +5234,11 @@ def _(rid, params: dict) -> dict:
from prompt_toolkit.formatted_text import to_plain_text
from agent.skill_commands import get_skill_commands
from agent.skill_bundles import get_skill_bundles
completer = SlashCommandCompleter(
skill_commands_provider=lambda: get_skill_commands()
skill_commands_provider=lambda: get_skill_commands(),
skill_bundles_provider=lambda: get_skill_bundles(),
)
doc = Document(text, len(text))
items = [

View file

@ -62,6 +62,7 @@ hermes [global-options] <command> [subcommand/options]
| `hermes config` | Show, edit, migrate, and query configuration files. |
| `hermes pairing` | Approve or revoke messaging pairing codes. |
| `hermes skills` | Browse, install, publish, audit, and configure skills. |
| `hermes bundles` | Group several skills under a single `/<name>` slash command. See [Skill Bundles](../user-guide/features/skills.md#skill-bundles). |
| `hermes curator` | Background skill maintenance — status, run, pause, pin. See [Curator](../user-guide/features/curator.md). |
| `hermes memory` | Configure external memory provider. Plugin-specific subcommands (e.g. `hermes honcho`) register automatically when their provider is active. |
| `hermes acp` | Run Hermes as an ACP server for editor integration. |
@ -825,6 +826,40 @@ Notes:
- `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`.
- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead.
## `hermes bundles`
```bash
hermes bundles <subcommand>
```
Skill bundles group several skills under one `/<bundle-name>` slash command. Invoking the bundle loads every referenced skill into a single combined user message. Storage: `~/.hermes/skill-bundles/<slug>.yaml`. See [Skill Bundles](../user-guide/features/skills.md#skill-bundles) for the YAML schema and behavior.
Subcommands:
| Subcommand | Description |
|------------|-------------|
| `list` | List installed bundles (default when no subcommand given) |
| `show <name>` | Show one bundle's name, description, skills, and file path |
| `create <name>` | Create a new bundle. Pass `--skill <id>` (repeat) or omit for interactive entry. `--description`, `--instruction`, `--force` available. |
| `delete <name>` | Remove a bundle file |
| `reload` | Re-scan `~/.hermes/skill-bundles/` and report added/removed bundles |
Examples:
```bash
hermes bundles create backend-dev \
--skill github-code-review \
--skill test-driven-development \
--skill github-pr-workflow \
-d "Backend feature work"
hermes bundles list
hermes bundles show backend-dev
hermes bundles delete backend-dev
```
In a chat session, `/bundles` lists installed bundles and `/<bundle-name>` loads one.
## `hermes curator`
```bash

View file

@ -230,6 +230,91 @@ Paths support `~` expansion and `${VAR}` environment variable substitution.
All four skills appear in your skill index. If you create a new skill called `my-custom-workflow` locally, it shadows the external version.
## Skill Bundles
Skill bundles are tiny YAML files that group several skills under a single slash command. When you run `/<bundle-name>`, every skill listed in the bundle loads at once — useful when a particular task always benefits from the same set of skills together.
### Quick example
```bash
# Create a bundle for backend feature work
hermes bundles create backend-dev \
--skill github-code-review \
--skill test-driven-development \
--skill github-pr-workflow \
-d "Backend feature work — review, test, PR workflow"
```
Then in the CLI or any gateway platform:
```
/backend-dev refactor the auth middleware
```
The agent receives all three skills loaded into one user message, with any text after the slash command attached as a user instruction.
### YAML schema
Bundles live in **`~/.hermes/skill-bundles/<slug>.yaml`** and look like this:
```yaml
name: backend-dev
description: Backend feature work — review, test, PR workflow.
skills:
- github-code-review
- test-driven-development
- github-pr-workflow
instruction: |
Always start by writing failing tests, then implement.
Open the PR through the standard workflow with co-author tags.
```
Fields:
- `name` (optional — defaults to the filename stem) — the bundle's display name. Normalized to a hyphen slug for the slash command (`Backend Dev``/backend-dev`).
- `description` (optional) — short text shown in `/bundles` and `hermes bundles list`.
- `skills` (required, non-empty list) — skill names or paths relative to your skills directory. Use the same identifier you'd pass to `/<skill-name>`.
- `instruction` (optional) — extra guidance prepended to the loaded skill content. Useful for codifying "how we always use these together."
### Managing bundles
```bash
# List all installed bundles
hermes bundles list
# Inspect one bundle
hermes bundles show backend-dev
# Create a bundle interactively (omit --skill flags to enter them one per line)
hermes bundles create research
# Overwrite an existing bundle
hermes bundles create backend-dev --skill ... --force
# Delete a bundle
hermes bundles delete backend-dev
# Re-scan ~/.hermes/skill-bundles/ and report changes
hermes bundles reload
```
From inside a chat session, `/bundles` lists every installed bundle and its skills.
### Behavior
- **Bundles take precedence over individual skills** when slugs collide. If you name a bundle `research` and you also have a skill called `research`, `/research` invokes the bundle. This is intentional — you opted into the bundle by naming it.
- **Missing skills are skipped, not fatal.** If a bundle lists `skill-foo` and you haven't installed it, the bundle still loads the skills that do resolve, and the agent gets a note listing what was skipped.
- **Bundles work in every surface** — interactive CLI, TUI, dashboard chat, and every gateway platform (Telegram, Discord, Slack, …) — because dispatch is centralized in the same place as individual skill commands.
- **Bundles do not invalidate the prompt cache.** They generate a fresh user message at invocation time, the same way `/<skill-name>` does — no system prompt mutation.
### When bundles beat installing each skill manually
Use a bundle when:
- You always pair the same skills for a recurring task (`/backend-dev`, `/release-prep`, `/incident-response`).
- You want a one-character-shorter mental model than typing several `/skill` invocations in a row.
- You want to ship a team-wide "task profile" by checking the bundle YAML into a shared dotfiles repo and symlinking it into `~/.hermes/skill-bundles/`.
A bundle is just a YAML alias — it doesn't install skills for you. The skills themselves must already be present (in `~/.hermes/skills/` or an external skill directory). Otherwise the bundle invocation just skips the missing ones.
## Agent-Managed Skills (skill_manage tool)
The agent can create, update, and delete its own skills via the `skill_manage` tool. This is the agent's **procedural memory** — when it figures out a non-trivial workflow, it saves the approach as a skill for future reuse.