mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
1733cb3a13
commit
b5c1fe78aa
12 changed files with 1498 additions and 3 deletions
229
hermes_cli/bundles.py
Normal file
229
hermes_cli/bundles.py
Normal 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)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue