mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-01 07:01:41 +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
410
agent/skill_bundles.py
Normal file
410
agent/skill_bundles.py
Normal 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
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),
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
# =========================================================================
|
||||
|
|
|
|||
337
tests/agent/test_skill_bundles.py
Normal file
337
tests/agent/test_skill_bundles.py
Normal 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)
|
||||
115
tests/gateway/test_bundles_command.py
Normal file
115
tests/gateway/test_bundles_command.py
Normal 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"
|
||||
94
tests/hermes_cli/test_bundles.py
Normal file
94
tests/hermes_cli/test_bundles.py
Normal 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
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue