From 8e0c48e6d25b0a31ef6f809f64afe1d28180d97f Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 28 Feb 2026 11:18:50 -0800 Subject: [PATCH] feat(skills): implement dynamic skill slash commands for CLI and gateway --- AGENTS.md | 14 +++++ README.md | 18 +++++++ agent/skill_commands.py | 114 ++++++++++++++++++++++++++++++++++++++++ cli.py | 67 +++++++++++++++++------ gateway/run.py | 52 +++++++++++++----- 5 files changed, 235 insertions(+), 30 deletions(-) create mode 100644 agent/skill_commands.py diff --git a/AGENTS.md b/AGENTS.md index f729bde9..d88fbf7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -179,6 +179,7 @@ The interactive CLI uses: Key components: - `HermesCLI` class - Main CLI controller with commands and conversation loop - `SlashCommandCompleter` - Autocomplete dropdown for `/commands` (type `/` to see all) +- `agent/skill_commands.py` - Scans skills and builds invocation messages (shared with gateway) - `load_cli_config()` - Loads config, sets environment variables for terminal - `build_welcome_banner()` - Displays ASCII art logo, tools, and skills summary @@ -191,9 +192,22 @@ CLI UX notes: - Pasting 5+ lines auto-saves to `~/.hermes/pastes/` and collapses to a reference - Multi-line input via Alt+Enter or Ctrl+J - `/commands` - Process user commands like `/help`, `/clear`, `/personality`, etc. +- `/skill-name` - Invoke installed skills directly (e.g., `/axolotl`, `/gif-search`) CLI uses `quiet_mode=True` when creating AIAgent to suppress verbose logging. +### Skill Slash Commands + +Every installed skill in `~/.hermes/skills/` is automatically registered as a slash command. +The skill name (from frontmatter or folder name) becomes the command: `axolotl` → `/axolotl`. + +Implementation (`agent/skill_commands.py`, shared between CLI and gateway): +1. `scan_skill_commands()` scans all SKILL.md files at startup +2. `build_skill_invocation_message()` loads the SKILL.md content and builds a user-turn message +3. The message includes the full skill content, a list of supporting files (not loaded), and the user's instruction +4. Supporting files can be loaded on demand via the `skill_view` tool +5. Injected as a **user message** (not system prompt) to preserve prompt caching + ### Adding CLI Commands 1. Add to `COMMANDS` dict with description diff --git a/README.md b/README.md index 4b407c26..1403c03b 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ See [docs/messaging.md](docs/messaging.md) for advanced WhatsApp configuration. | `/stop` | Stop the running agent | | `/sethome` | Set this chat as the home channel | | `/help` | Show available commands | +| `/` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) | ### DM Pairing (Alternative to Allowlists) @@ -421,6 +422,7 @@ Type `/` to see an autocomplete dropdown of all commands. | `/skills` | Search, install, inspect, or manage skills from registries | | `/platforms` | Show gateway/messaging platform status | | `/quit` | Exit (also: `/exit`, `/q`) | +| `/` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) | **Keybindings:** - `Enter` — send message @@ -820,6 +822,22 @@ Skills are on-demand knowledge documents the agent can load when needed. They fo All skills live in **`~/.hermes/skills/`** -- a single directory that is the source of truth. On fresh install, bundled skills are copied there from the repo. Hub-installed skills and agent-created skills also go here. The agent can modify or delete any skill. `hermes update` adds only genuinely new bundled skills (via a manifest) without overwriting your changes or re-adding skills you deleted. **Using Skills:** + +Every installed skill is automatically available as a slash command — type `/` to invoke it directly: + +```bash +# In the CLI or any messaging platform (Telegram, Discord, Slack, WhatsApp): +/gif-search funny cats +/axolotl help me fine-tune Llama 3 on my dataset +/github-pr-workflow create a PR for the auth refactor + +# Just the skill name (no prompt) loads the skill and lets the agent ask what you need: +/excalidraw +``` + +The skill's full instructions (SKILL.md) are loaded into the conversation, and any supporting files (references, templates, scripts) are listed for the agent to pull on demand via the `skill_view` tool. Type `/help` to see all available skill commands. + +You can also use skills through natural conversation: ```bash hermes --toolsets skills -q "What skills do you have?" hermes --toolsets skills -q "Show me the axolotl skill" diff --git a/agent/skill_commands.py b/agent/skill_commands.py new file mode 100644 index 00000000..fc11c531 --- /dev/null +++ b/agent/skill_commands.py @@ -0,0 +1,114 @@ +"""Skill slash commands — scan installed skills and build invocation messages. + +Shared between CLI (cli.py) and gateway (gateway/run.py) so both surfaces +can invoke skills via /skill-name commands. +""" + +import logging +from pathlib import Path +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + +_skill_commands: Dict[str, Dict[str, Any]] = {} + + +def scan_skill_commands() -> Dict[str, Dict[str, Any]]: + """Scan ~/.hermes/skills/ and return a mapping of /command -> skill info. + + Returns: + Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}. + """ + global _skill_commands + _skill_commands = {} + try: + from tools.skills_tool import SKILLS_DIR, _parse_frontmatter + if not SKILLS_DIR.exists(): + return _skill_commands + for skill_md in SKILLS_DIR.rglob("SKILL.md"): + path_str = str(skill_md) + if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str: + continue + try: + content = skill_md.read_text(encoding='utf-8') + frontmatter, body = _parse_frontmatter(content) + name = frontmatter.get('name', skill_md.parent.name) + description = frontmatter.get('description', '') + if not description: + for line in body.strip().split('\n'): + line = line.strip() + if line and not line.startswith('#'): + description = line[:80] + break + cmd_name = name.lower().replace(' ', '-').replace('_', '-') + _skill_commands[f"/{cmd_name}"] = { + "name": name, + "description": description or f"Invoke the {name} skill", + "skill_md_path": str(skill_md), + "skill_dir": str(skill_md.parent), + } + except Exception: + continue + except Exception: + pass + return _skill_commands + + +def get_skill_commands() -> Dict[str, Dict[str, Any]]: + """Return the current skill commands mapping (scan first if empty).""" + if not _skill_commands: + scan_skill_commands() + return _skill_commands + + +def build_skill_invocation_message(cmd_key: str, user_instruction: str = "") -> Optional[str]: + """Build the user message content for a skill slash command invocation. + + Args: + cmd_key: The command key including leading slash (e.g., "/gif-search"). + user_instruction: Optional text the user typed after the command. + + Returns: + The formatted message string, or None if the skill wasn't found. + """ + commands = get_skill_commands() + skill_info = commands.get(cmd_key) + if not skill_info: + return None + + skill_md_path = Path(skill_info["skill_md_path"]) + skill_dir = Path(skill_info["skill_dir"]) + skill_name = skill_info["name"] + + try: + content = skill_md_path.read_text(encoding='utf-8') + except Exception: + return f"[Failed to load skill: {skill_name}]" + + parts = [ + f'[SYSTEM: The user has invoked the "{skill_name}" skill, indicating they want you to follow its instructions. The full skill content is loaded below.]', + "", + content.strip(), + ] + + supporting = [] + for subdir in ("references", "templates", "scripts", "assets"): + subdir_path = skill_dir / subdir + if subdir_path.exists(): + for f in sorted(subdir_path.rglob("*")): + if f.is_file(): + rel = str(f.relative_to(skill_dir)) + supporting.append(rel) + + if supporting: + parts.append("") + parts.append("[This skill has supporting files you can load with the skill_view tool:]") + for sf in supporting: + parts.append(f"- {sf}") + parts.append(f'\nTo view any of these, use: skill_view(name="{skill_name}", file="")') + + if user_instruction: + parts.append("") + parts.append(f"The user has provided the following instruction alongside the skill invocation: {user_instruction}") + + return "\n".join(parts) diff --git a/cli.py b/cli.py index 89aa463d..a0ccdf55 100755 --- a/cli.py +++ b/cli.py @@ -682,17 +682,27 @@ COMMANDS = { } +# ============================================================================ +# Skill Slash Commands — dynamic commands generated from installed skills +# ============================================================================ + +from agent.skill_commands import scan_skill_commands, get_skill_commands, build_skill_invocation_message + +_skill_commands = scan_skill_commands() + + class SlashCommandCompleter(Completer): - """Autocomplete for /commands in the input area.""" + """Autocomplete for /commands and /skill-name in the input area.""" def get_completions(self, document, complete_event): text = document.text_before_cursor - # Only complete at the start of input, after / if not text.startswith("/"): return word = text[1:] # strip the leading / + + # Built-in commands for cmd, desc in COMMANDS.items(): - cmd_name = cmd[1:] # strip leading / from key + cmd_name = cmd[1:] if cmd_name.startswith(word): yield Completion( cmd_name, @@ -701,6 +711,17 @@ class SlashCommandCompleter(Completer): display_meta=desc, ) + # Skill commands + for cmd, info in _skill_commands.items(): + cmd_name = cmd[1:] + if cmd_name.startswith(word): + yield Completion( + cmd_name, + start_position=-len(word), + display=cmd, + display_meta=f"⚡ {info['description'][:50]}", + ) + def save_config_value(key_path: str, value: any) -> bool: """ @@ -1082,20 +1103,21 @@ class HermesCLI: ) def show_help(self): - """Display help information with kawaii ASCII art.""" - print() - print("+" + "-" * 50 + "+") - print("|" + " " * 14 + "(^_^)? Available Commands" + " " * 10 + "|") - print("+" + "-" * 50 + "+") - print() + """Display help information.""" + _cprint(f"\n{_BOLD}+{'-' * 50}+{_RST}") + _cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 10}|{_RST}") + _cprint(f"{_BOLD}+{'-' * 50}+{_RST}\n") for cmd, desc in COMMANDS.items(): - print(f" {cmd:<15} - {desc}") + _cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}") - print() - print(" Tip: Just type your message to chat with Hermes!") - print(" Multi-line: Alt+Enter for a new line") - print() + if _skill_commands: + _cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):") + for cmd, info in sorted(_skill_commands.items()): + _cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}") + + _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}\n") def show_tools(self): """Display available tools with kawaii ASCII art.""" @@ -1693,8 +1715,21 @@ class HermesCLI: elif cmd_lower == "/verbose": self._toggle_verbose() else: - self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") - self.console.print("[dim #B8860B]Type /help for available commands[/]") + # Check for skill slash commands (/gif-search, /axolotl, etc.) + base_cmd = cmd_lower.split()[0] + if base_cmd in _skill_commands: + user_instruction = cmd_original[len(base_cmd):].strip() + msg = build_skill_invocation_message(base_cmd, user_instruction) + if msg: + skill_name = _skill_commands[base_cmd]["name"] + print(f"\n⚡ Loading skill: {skill_name}") + if hasattr(self, '_pending_input'): + self._pending_input.put(msg) + else: + self.console.print(f"[bold red]Failed to load skill for {base_cmd}[/]") + else: + self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]") + self.console.print("[dim #B8860B]Type /help for available commands[/]") return True diff --git a/gateway/run.py b/gateway/run.py index c5d283a1..0fa76cde 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -636,6 +636,21 @@ class GatewayRunner: if command in ["sethome", "set-home"]: return await self._handle_set_home_command(event) + # Skill slash commands: /skill-name loads the skill and sends to agent + if command: + try: + from agent.skill_commands import get_skill_commands, build_skill_invocation_message + skill_cmds = get_skill_commands() + cmd_key = f"/{command}" + if cmd_key in skill_cmds: + user_instruction = event.get_command_args().strip() + msg = build_skill_invocation_message(cmd_key, user_instruction) + if msg: + event.text = msg + # Fall through to normal message processing with skill content + except Exception as e: + logger.debug("Skill command check failed (non-fatal): %s", e) + # Check for pending exec approval responses if source.chat_type != "dm": session_key_preview = f"agent:main:{source.platform.value}:{source.chat_type}:{source.chat_id}" @@ -1000,20 +1015,29 @@ class GatewayRunner: async def _handle_help_command(self, event: MessageEvent) -> str: """Handle /help command - list available commands.""" - return ( - "📖 **Hermes Commands**\n" - "\n" - "`/new` — Start a new conversation\n" - "`/reset` — Reset conversation history\n" - "`/status` — Show session info\n" - "`/stop` — Interrupt the running agent\n" - "`/model [name]` — Show or change the model\n" - "`/personality [name]` — Set a personality\n" - "`/retry` — Retry your last message\n" - "`/undo` — Remove the last exchange\n" - "`/sethome` — Set this chat as the home channel\n" - "`/help` — Show this message" - ) + lines = [ + "📖 **Hermes Commands**\n", + "`/new` — Start a new conversation", + "`/reset` — Reset conversation history", + "`/status` — Show session info", + "`/stop` — Interrupt the running agent", + "`/model [name]` — Show or change the model", + "`/personality [name]` — Set a personality", + "`/retry` — Retry your last message", + "`/undo` — Remove the last exchange", + "`/sethome` — Set this chat as the home channel", + "`/help` — Show this message", + ] + try: + from agent.skill_commands import get_skill_commands + skill_cmds = get_skill_commands() + if skill_cmds: + lines.append(f"\n⚡ **Skill Commands** ({len(skill_cmds)} installed):") + for cmd in sorted(skill_cmds): + lines.append(f"`{cmd}` — {skill_cmds[cmd]['description']}") + except Exception: + pass + return "\n".join(lines) async def _handle_model_command(self, event: MessageEvent) -> str: """Handle /model command - show or change the current model."""