Files
hermes-agent/hermes_cli/bundles.py
T
Teknium b5c1fe78aa 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.
2026-05-18 21:38:05 -07:00

230 lines
7.2 KiB
Python

"""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)