mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(skills): implement dynamic skill slash commands for CLI and gateway
This commit is contained in:
parent
2205b22409
commit
8e0c48e6d2
5 changed files with 235 additions and 30 deletions
14
AGENTS.md
14
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
|
||||
|
|
|
|||
18
README.md
18
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 |
|
||||
| `/<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
114
agent/skill_commands.py
Normal 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
67
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue