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:
|
Key components:
|
||||||
- `HermesCLI` class - Main CLI controller with commands and conversation loop
|
- `HermesCLI` class - Main CLI controller with commands and conversation loop
|
||||||
- `SlashCommandCompleter` - Autocomplete dropdown for `/commands` (type `/` to see all)
|
- `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
|
- `load_cli_config()` - Loads config, sets environment variables for terminal
|
||||||
- `build_welcome_banner()` - Displays ASCII art logo, tools, and skills summary
|
- `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
|
- Pasting 5+ lines auto-saves to `~/.hermes/pastes/` and collapses to a reference
|
||||||
- Multi-line input via Alt+Enter or Ctrl+J
|
- Multi-line input via Alt+Enter or Ctrl+J
|
||||||
- `/commands` - Process user commands like `/help`, `/clear`, `/personality`, etc.
|
- `/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.
|
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
|
### Adding CLI Commands
|
||||||
|
|
||||||
1. Add to `COMMANDS` dict with description
|
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 |
|
| `/stop` | Stop the running agent |
|
||||||
| `/sethome` | Set this chat as the home channel |
|
| `/sethome` | Set this chat as the home channel |
|
||||||
| `/help` | Show available commands |
|
| `/help` | Show available commands |
|
||||||
|
| `/<skill-name>` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) |
|
||||||
|
|
||||||
### DM Pairing (Alternative to Allowlists)
|
### 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 |
|
| `/skills` | Search, install, inspect, or manage skills from registries |
|
||||||
| `/platforms` | Show gateway/messaging platform status |
|
| `/platforms` | Show gateway/messaging platform status |
|
||||||
| `/quit` | Exit (also: `/exit`, `/q`) |
|
| `/quit` | Exit (also: `/exit`, `/q`) |
|
||||||
|
| `/<skill-name>` | Invoke any installed skill (e.g., `/axolotl`, `/gif-search`) |
|
||||||
|
|
||||||
**Keybindings:**
|
**Keybindings:**
|
||||||
- `Enter` — send message
|
- `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.
|
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:**
|
**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
|
```bash
|
||||||
hermes --toolsets skills -q "What skills do you have?"
|
hermes --toolsets skills -q "What skills do you have?"
|
||||||
hermes --toolsets skills -q "Show me the axolotl skill"
|
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)
|
||||||
63
cli.py
63
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):
|
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):
|
def get_completions(self, document, complete_event):
|
||||||
text = document.text_before_cursor
|
text = document.text_before_cursor
|
||||||
# Only complete at the start of input, after /
|
|
||||||
if not text.startswith("/"):
|
if not text.startswith("/"):
|
||||||
return
|
return
|
||||||
word = text[1:] # strip the leading /
|
word = text[1:] # strip the leading /
|
||||||
|
|
||||||
|
# Built-in commands
|
||||||
for cmd, desc in COMMANDS.items():
|
for cmd, desc in COMMANDS.items():
|
||||||
cmd_name = cmd[1:] # strip leading / from key
|
cmd_name = cmd[1:]
|
||||||
if cmd_name.startswith(word):
|
if cmd_name.startswith(word):
|
||||||
yield Completion(
|
yield Completion(
|
||||||
cmd_name,
|
cmd_name,
|
||||||
|
|
@ -701,6 +711,17 @@ class SlashCommandCompleter(Completer):
|
||||||
display_meta=desc,
|
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:
|
def save_config_value(key_path: str, value: any) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1082,20 +1103,21 @@ class HermesCLI:
|
||||||
)
|
)
|
||||||
|
|
||||||
def show_help(self):
|
def show_help(self):
|
||||||
"""Display help information with kawaii ASCII art."""
|
"""Display help information."""
|
||||||
print()
|
_cprint(f"\n{_BOLD}+{'-' * 50}+{_RST}")
|
||||||
print("+" + "-" * 50 + "+")
|
_cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 10}|{_RST}")
|
||||||
print("|" + " " * 14 + "(^_^)? Available Commands" + " " * 10 + "|")
|
_cprint(f"{_BOLD}+{'-' * 50}+{_RST}\n")
|
||||||
print("+" + "-" * 50 + "+")
|
|
||||||
print()
|
|
||||||
|
|
||||||
for cmd, desc in COMMANDS.items():
|
for cmd, desc in COMMANDS.items():
|
||||||
print(f" {cmd:<15} - {desc}")
|
_cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}")
|
||||||
|
|
||||||
print()
|
if _skill_commands:
|
||||||
print(" Tip: Just type your message to chat with Hermes!")
|
_cprint(f"\n ⚡ {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):")
|
||||||
print(" Multi-line: Alt+Enter for a new line")
|
for cmd, info in sorted(_skill_commands.items()):
|
||||||
print()
|
_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):
|
def show_tools(self):
|
||||||
"""Display available tools with kawaii ASCII art."""
|
"""Display available tools with kawaii ASCII art."""
|
||||||
|
|
@ -1692,6 +1714,19 @@ class HermesCLI:
|
||||||
self._show_gateway_status()
|
self._show_gateway_status()
|
||||||
elif cmd_lower == "/verbose":
|
elif cmd_lower == "/verbose":
|
||||||
self._toggle_verbose()
|
self._toggle_verbose()
|
||||||
|
else:
|
||||||
|
# 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:
|
else:
|
||||||
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
|
self.console.print(f"[bold red]Unknown command: {cmd_lower}[/]")
|
||||||
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
self.console.print("[dim #B8860B]Type /help for available commands[/]")
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,21 @@ class GatewayRunner:
|
||||||
if command in ["sethome", "set-home"]:
|
if command in ["sethome", "set-home"]:
|
||||||
return await self._handle_set_home_command(event)
|
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
|
# Check for pending exec approval responses
|
||||||
if source.chat_type != "dm":
|
if source.chat_type != "dm":
|
||||||
session_key_preview = f"agent:main:{source.platform.value}:{source.chat_type}:{source.chat_id}"
|
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:
|
async def _handle_help_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /help command - list available commands."""
|
"""Handle /help command - list available commands."""
|
||||||
return (
|
lines = [
|
||||||
"📖 **Hermes Commands**\n"
|
"📖 **Hermes Commands**\n",
|
||||||
"\n"
|
"`/new` — Start a new conversation",
|
||||||
"`/new` — Start a new conversation\n"
|
"`/reset` — Reset conversation history",
|
||||||
"`/reset` — Reset conversation history\n"
|
"`/status` — Show session info",
|
||||||
"`/status` — Show session info\n"
|
"`/stop` — Interrupt the running agent",
|
||||||
"`/stop` — Interrupt the running agent\n"
|
"`/model [name]` — Show or change the model",
|
||||||
"`/model [name]` — Show or change the model\n"
|
"`/personality [name]` — Set a personality",
|
||||||
"`/personality [name]` — Set a personality\n"
|
"`/retry` — Retry your last message",
|
||||||
"`/retry` — Retry your last message\n"
|
"`/undo` — Remove the last exchange",
|
||||||
"`/undo` — Remove the last exchange\n"
|
"`/sethome` — Set this chat as the home channel",
|
||||||
"`/sethome` — Set this chat as the home channel\n"
|
"`/help` — Show this message",
|
||||||
"`/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:
|
async def _handle_model_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /model command - show or change the current model."""
|
"""Handle /model command - show or change the current model."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue