diff --git a/agent/skill_bundles.py b/agent/skill_bundles.py new file mode 100644 index 00000000000..10836b359fe --- /dev/null +++ b/agent/skill_bundles.py @@ -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 ``/`` from the CLI or gateway loads every +referenced skill's full content into a single user message, the same way +``/`` 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 + ``/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}") diff --git a/cli.py b/cli.py index f04721840c9..a22a836b7fa 100644 --- a/cli.py +++ b/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 — / + # 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 ``/``. + """ + 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" --skill --skill {_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 /. " + 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), diff --git a/gateway/run.py b/gateway/run.py index f8d0b724636..7de7a111964 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 — + # / 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 ``/`` 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 --skill --skill `\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 `/` to load all its skills.") + return "\n".join(lines) + # ------------------------------------------------------------------ # Slash-command confirmation primitive (generic) # ------------------------------------------------------------------ diff --git a/hermes_cli/bundles.py b/hermes_cli/bundles.py new file mode 100644 index 00000000000..76f6c7a992e --- /dev/null +++ b/hermes_cli/bundles.py @@ -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 ``/`` 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 --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 `` 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) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 446a453b5ad..9fc0472f512 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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 / 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): diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3168c4818fc..c8cdadc847a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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 / 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. `/` 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 # ========================================================================= diff --git a/tests/agent/test_skill_bundles.py b/tests/agent/test_skill_bundles.py new file mode 100644 index 00000000000..fa9e42d43ec --- /dev/null +++ b/tests/agent/test_skill_bundles.py @@ -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) diff --git a/tests/gateway/test_bundles_command.py b/tests/gateway/test_bundles_command.py new file mode 100644 index 00000000000..e50a819a106 --- /dev/null +++ b/tests/gateway/test_bundles_command.py @@ -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 ``/``. + +The actual ``/`` → 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" diff --git a/tests/hermes_cli/test_bundles.py b/tests/hermes_cli/test_bundles.py new file mode 100644 index 00000000000..b089530ca98 --- /dev/null +++ b/tests/hermes_cli/test_bundles.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a00b44320da..661b08bdb1b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -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 = [ diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index cf105858292..6889aea3e72 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -62,6 +62,7 @@ hermes [global-options] [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 `/` 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 ` instead. +## `hermes bundles` + +```bash +hermes bundles +``` + +Skill bundles group several skills under one `/` slash command. Invoking the bundle loads every referenced skill into a single combined user message. Storage: `~/.hermes/skill-bundles/.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 ` | Show one bundle's name, description, skills, and file path | +| `create ` | Create a new bundle. Pass `--skill ` (repeat) or omit for interactive entry. `--description`, `--instruction`, `--force` available. | +| `delete ` | 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 `/` loads one. + ## `hermes curator` ```bash diff --git a/website/docs/user-guide/features/skills.md b/website/docs/user-guide/features/skills.md index 9959bcce112..ebf9369f64b 100644 --- a/website/docs/user-guide/features/skills.md +++ b/website/docs/user-guide/features/skills.md @@ -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 `/`, 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/.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 `/`. +- `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 `/` 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.