feat(skills): implement dynamic skill slash commands for CLI and gateway

This commit is contained in:
teknium1 2026-02-28 11:18:50 -08:00
parent 2205b22409
commit 8e0c48e6d2
5 changed files with 235 additions and 30 deletions

View file

@ -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

View file

@ -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 |
| `/<skill-name>` | 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`) |
| `/<skill-name>` | 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 `/<skill-name>` 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"

114
agent/skill_commands.py Normal file
View file

@ -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="<path>")')
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)

67
cli.py
View file

@ -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

View file

@ -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."""