mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-05 07:41:39 +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
83
cli.py
83
cli.py
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue