feat: introduce skill management tool for agent-created skills and skills migration to ~/.hermes

- Added a new `skill_manager_tool` to enable agents to create, update, and delete their own skills, enhancing procedural memory capabilities.
- Updated the skills directory structure to support user-created skills in `~/.hermes/skills/`, allowing for better organization and management.
- Enhanced the CLI and documentation to reflect the new skill management functionalities, including detailed instructions on creating and modifying skills.
- Implemented a manifest-based syncing mechanism for bundled skills to ensure user modifications are preserved during updates.
This commit is contained in:
teknium1 2026-02-19 18:25:53 -08:00
parent d070b8698d
commit 4d5f29c74c
18 changed files with 1007 additions and 204 deletions

View file

@ -65,6 +65,12 @@ from .skills_tool import (
SKILLS_TOOL_DESCRIPTION
)
from .skill_manager_tool import (
skill_manage,
check_skill_manage_requirements,
SKILL_MANAGE_SCHEMA
)
# Browser automation tools (agent-browser + Browserbase)
from .browser_tool import (
browser_navigate,
@ -175,6 +181,10 @@ __all__ = [
'skill_view',
'check_skills_requirements',
'SKILLS_TOOL_DESCRIPTION',
# Skill management
'skill_manage',
'check_skill_manage_requirements',
'SKILL_MANAGE_SCHEMA',
# Browser automation tools
'browser_navigate',
'browser_snapshot',

544
tools/skill_manager_tool.py Normal file
View file

@ -0,0 +1,544 @@
#!/usr/bin/env python3
"""
Skill Manager Tool -- Agent-Managed Skill Creation & Editing
Allows the agent to create, update, and delete skills, turning successful
approaches into reusable procedural knowledge. New skills are created in
~/.hermes/skills/. Existing skills (bundled, hub-installed, or user-created)
can be modified or deleted wherever they live.
Skills are the agent's procedural memory: they capture *how to do a specific
type of task* based on proven experience. General memory (MEMORY.md, USER.md) is
broad and declarative. Skills are narrow and actionable.
Actions:
create -- Create a new skill (SKILL.md + directory structure)
edit -- Replace the SKILL.md content of a user skill (full rewrite)
patch -- Targeted find-and-replace within SKILL.md or any supporting file
delete -- Remove a user skill entirely
write_file -- Add/overwrite a supporting file (reference, template, script, asset)
remove_file-- Remove a supporting file from a user skill
Directory layout for user skills:
~/.hermes/skills/
my-skill/
SKILL.md
references/
templates/
scripts/
assets/
category-name/
another-skill/
SKILL.md
"""
import json
import os
import re
import shutil
from pathlib import Path
from typing import Dict, Any, Optional
import yaml
# All skills live in ~/.hermes/skills/ (single source of truth)
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
SKILLS_DIR = HERMES_HOME / "skills"
MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
# Characters allowed in skill names (filesystem-safe, URL-friendly)
VALID_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$')
# Subdirectories allowed for write_file/remove_file
ALLOWED_SUBDIRS = {"references", "templates", "scripts", "assets"}
def check_skill_manage_requirements() -> bool:
"""Skill management has no external requirements -- always available."""
return True
# =============================================================================
# Validation helpers
# =============================================================================
def _validate_name(name: str) -> Optional[str]:
"""Validate a skill name. Returns error message or None if valid."""
if not name:
return "Skill name is required."
if len(name) > MAX_NAME_LENGTH:
return f"Skill name exceeds {MAX_NAME_LENGTH} characters."
if not VALID_NAME_RE.match(name):
return (
f"Invalid skill name '{name}'. Use lowercase letters, numbers, "
f"hyphens, dots, and underscores. Must start with a letter or digit."
)
return None
def _validate_frontmatter(content: str) -> Optional[str]:
"""
Validate that SKILL.md content has proper frontmatter with required fields.
Returns error message or None if valid.
"""
if not content.strip():
return "Content cannot be empty."
if not content.startswith("---"):
return "SKILL.md must start with YAML frontmatter (---). See existing skills for format."
end_match = re.search(r'\n---\s*\n', content[3:])
if not end_match:
return "SKILL.md frontmatter is not closed. Ensure you have a closing '---' line."
yaml_content = content[3:end_match.start() + 3]
try:
parsed = yaml.safe_load(yaml_content)
except yaml.YAMLError as e:
return f"YAML frontmatter parse error: {e}"
if not isinstance(parsed, dict):
return "Frontmatter must be a YAML mapping (key: value pairs)."
if "name" not in parsed:
return "Frontmatter must include 'name' field."
if "description" not in parsed:
return "Frontmatter must include 'description' field."
if len(str(parsed["description"])) > MAX_DESCRIPTION_LENGTH:
return f"Description exceeds {MAX_DESCRIPTION_LENGTH} characters."
body = content[end_match.end() + 3:].strip()
if not body:
return "SKILL.md must have content after the frontmatter (instructions, procedures, etc.)."
return None
def _resolve_skill_dir(name: str, category: str = None) -> Path:
"""Build the directory path for a new skill, optionally under a category."""
if category:
return SKILLS_DIR / category / name
return SKILLS_DIR / name
def _find_skill(name: str) -> Optional[Dict[str, Any]]:
"""
Find a skill by name in ~/.hermes/skills/.
Returns {"path": Path} or None.
"""
if not SKILLS_DIR.exists():
return None
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
if skill_md.parent.name == name:
return {"path": skill_md.parent}
return None
def _validate_file_path(file_path: str) -> Optional[str]:
"""
Validate a file path for write_file/remove_file.
Must be under an allowed subdirectory and not escape the skill dir.
"""
if not file_path:
return "file_path is required."
normalized = Path(file_path)
# Prevent path traversal
if ".." in normalized.parts:
return "Path traversal ('..') is not allowed."
# Must be under an allowed subdirectory
if not normalized.parts or normalized.parts[0] not in ALLOWED_SUBDIRS:
allowed = ", ".join(sorted(ALLOWED_SUBDIRS))
return f"File must be under one of: {allowed}. Got: '{file_path}'"
# Must have a filename (not just a directory)
if len(normalized.parts) < 2:
return f"Provide a file path, not just a directory. Example: '{normalized.parts[0]}/myfile.md'"
return None
# =============================================================================
# Core actions
# =============================================================================
def _create_skill(name: str, content: str, category: str = None) -> Dict[str, Any]:
"""Create a new user skill with SKILL.md content."""
# Validate name
err = _validate_name(name)
if err:
return {"success": False, "error": err}
# Validate content
err = _validate_frontmatter(content)
if err:
return {"success": False, "error": err}
# Check for name collisions across all directories
existing = _find_skill(name)
if existing:
return {
"success": False,
"error": f"A skill named '{name}' already exists at {existing['path']}."
}
# Create the skill directory
skill_dir = _resolve_skill_dir(name, category)
skill_dir.mkdir(parents=True, exist_ok=True)
# Write SKILL.md
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(content, encoding="utf-8")
result = {
"success": True,
"message": f"Skill '{name}' created.",
"path": str(skill_dir.relative_to(SKILLS_DIR)),
"skill_md": str(skill_md),
}
if category:
result["category"] = category
result["hint"] = (
"To add reference files, templates, or scripts, use "
"skill_manage(action='write_file', name='{}', file_path='references/example.md', file_content='...')".format(name)
)
return result
def _edit_skill(name: str, content: str) -> Dict[str, Any]:
"""Replace the SKILL.md of any existing skill (full rewrite)."""
err = _validate_frontmatter(content)
if err:
return {"success": False, "error": err}
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Use skills_list() to see available skills."}
skill_md = existing["path"] / "SKILL.md"
skill_md.write_text(content, encoding="utf-8")
return {
"success": True,
"message": f"Skill '{name}' updated.",
"path": str(existing["path"]),
}
def _patch_skill(
name: str,
old_string: str,
new_string: str,
file_path: str = None,
replace_all: bool = False,
) -> Dict[str, Any]:
"""Targeted find-and-replace within a skill file.
Defaults to SKILL.md. Use file_path to patch a supporting file instead.
Requires a unique match unless replace_all is True.
"""
if not old_string:
return {"success": False, "error": "old_string is required for 'patch'."}
if new_string is None:
return {"success": False, "error": "new_string is required for 'patch'. Use an empty string to delete matched text."}
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
skill_dir = existing["path"]
if file_path:
# Patching a supporting file
err = _validate_file_path(file_path)
if err:
return {"success": False, "error": err}
target = skill_dir / file_path
else:
# Patching SKILL.md
target = skill_dir / "SKILL.md"
if not target.exists():
return {"success": False, "error": f"File not found: {target.relative_to(skill_dir)}"}
content = target.read_text(encoding="utf-8")
count = content.count(old_string)
if count == 0:
# Show a short preview of the file so the model can self-correct
preview = content[:500] + ("..." if len(content) > 500 else "")
return {
"success": False,
"error": "old_string not found in the file.",
"file_preview": preview,
}
if count > 1 and not replace_all:
return {
"success": False,
"error": (
f"old_string matched {count} times. Provide more surrounding context "
f"to make the match unique, or set replace_all=true to replace all occurrences."
),
"match_count": count,
}
new_content = content.replace(old_string, new_string) if replace_all else content.replace(old_string, new_string, 1)
# If patching SKILL.md, validate frontmatter is still intact
if not file_path:
err = _validate_frontmatter(new_content)
if err:
return {
"success": False,
"error": f"Patch would break SKILL.md structure: {err}",
}
target.write_text(new_content, encoding="utf-8")
replacements = count if replace_all else 1
return {
"success": True,
"message": f"Patched {'SKILL.md' if not file_path else file_path} in skill '{name}' ({replacements} replacement{'s' if replacements > 1 else ''}).",
}
def _delete_skill(name: str) -> Dict[str, Any]:
"""Delete a skill."""
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
skill_dir = existing["path"]
shutil.rmtree(skill_dir)
# Clean up empty category directories (don't remove SKILLS_DIR itself)
parent = skill_dir.parent
if parent != SKILLS_DIR and parent.exists() and not any(parent.iterdir()):
parent.rmdir()
return {
"success": True,
"message": f"Skill '{name}' deleted.",
}
def _write_file(name: str, file_path: str, file_content: str) -> Dict[str, Any]:
"""Add or overwrite a supporting file within any skill directory."""
err = _validate_file_path(file_path)
if err:
return {"success": False, "error": err}
if not file_content and file_content != "":
return {"success": False, "error": "file_content is required."}
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found. Create it first with action='create'."}
target = existing["path"] / file_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(file_content, encoding="utf-8")
return {
"success": True,
"message": f"File '{file_path}' written to skill '{name}'.",
"path": str(target),
}
def _remove_file(name: str, file_path: str) -> Dict[str, Any]:
"""Remove a supporting file from any skill directory."""
err = _validate_file_path(file_path)
if err:
return {"success": False, "error": err}
existing = _find_skill(name)
if not existing:
return {"success": False, "error": f"Skill '{name}' not found."}
skill_dir = existing["path"]
target = skill_dir / file_path
if not target.exists():
# List what's actually there for the model to see
available = []
for subdir in ALLOWED_SUBDIRS:
d = skill_dir / subdir
if d.exists():
for f in d.rglob("*"):
if f.is_file():
available.append(str(f.relative_to(skill_dir)))
return {
"success": False,
"error": f"File '{file_path}' not found in skill '{name}'.",
"available_files": available if available else None,
}
target.unlink()
# Clean up empty subdirectories
parent = target.parent
if parent != skill_dir and parent.exists() and not any(parent.iterdir()):
parent.rmdir()
return {
"success": True,
"message": f"File '{file_path}' removed from skill '{name}'.",
}
# =============================================================================
# Main entry point
# =============================================================================
def skill_manage(
action: str,
name: str,
content: str = None,
category: str = None,
file_path: str = None,
file_content: str = None,
old_string: str = None,
new_string: str = None,
replace_all: bool = False,
) -> str:
"""
Manage user-created skills. Dispatches to the appropriate action handler.
Returns JSON string with results.
"""
if action == "create":
if not content:
return json.dumps({"success": False, "error": "content is required for 'create'. Provide the full SKILL.md text (frontmatter + body)."}, ensure_ascii=False)
result = _create_skill(name, content, category)
elif action == "edit":
if not content:
return json.dumps({"success": False, "error": "content is required for 'edit'. Provide the full updated SKILL.md text."}, ensure_ascii=False)
result = _edit_skill(name, content)
elif action == "patch":
if not old_string:
return json.dumps({"success": False, "error": "old_string is required for 'patch'. Provide the text to find."}, ensure_ascii=False)
if new_string is None:
return json.dumps({"success": False, "error": "new_string is required for 'patch'. Use empty string to delete matched text."}, ensure_ascii=False)
result = _patch_skill(name, old_string, new_string, file_path, replace_all)
elif action == "delete":
result = _delete_skill(name)
elif action == "write_file":
if not file_path:
return json.dumps({"success": False, "error": "file_path is required for 'write_file'. Example: 'references/api-guide.md'"}, ensure_ascii=False)
if file_content is None:
return json.dumps({"success": False, "error": "file_content is required for 'write_file'."}, ensure_ascii=False)
result = _write_file(name, file_path, file_content)
elif action == "remove_file":
if not file_path:
return json.dumps({"success": False, "error": "file_path is required for 'remove_file'."}, ensure_ascii=False)
result = _remove_file(name, file_path)
else:
result = {"success": False, "error": f"Unknown action '{action}'. Use: create, edit, patch, delete, write_file, remove_file"}
return json.dumps(result, ensure_ascii=False)
# =============================================================================
# OpenAI Function-Calling Schema
# =============================================================================
SKILL_MANAGE_SCHEMA = {
"name": "skill_manage",
"description": (
"Manage skills (create, update, delete). Skills are your procedural "
"memory — reusable approaches for recurring task types. "
"New skills go to ~/.hermes/skills/; existing skills can be modified wherever they live.\n\n"
"Actions: create (full SKILL.md + optional category), "
"patch (old_string/new_string — preferred for fixes), "
"edit (full SKILL.md rewrite — major overhauls only), "
"delete, write_file, remove_file.\n\n"
"Create when: complex task succeeded (5+ calls), errors overcome, "
"user-corrected approach worked, non-trivial workflow discovered, "
"or user asks you to remember a procedure.\n"
"Update when: instructions stale/wrong, OS-specific failures, "
"missing steps or pitfalls found during use.\n\n"
"After difficult/iterative tasks, offer to save as a skill. "
"Skip for simple one-offs. Confirm with user before creating/deleting.\n\n"
"Good skills: trigger conditions, numbered steps with exact commands, "
"pitfalls section, verification steps. Use skill_view() to see format examples."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "patch", "edit", "delete", "write_file", "remove_file"],
"description": "The action to perform."
},
"name": {
"type": "string",
"description": (
"Skill name (lowercase, hyphens/underscores, max 64 chars). "
"Must match an existing skill for patch/edit/delete/write_file/remove_file."
)
},
"content": {
"type": "string",
"description": (
"Full SKILL.md content (YAML frontmatter + markdown body). "
"Required for 'create' and 'edit'. For 'edit', read the skill "
"first with skill_view() and provide the complete updated text."
)
},
"old_string": {
"type": "string",
"description": (
"Text to find in the file (required for 'patch'). Must be unique "
"unless replace_all=true. Include enough surrounding context to "
"ensure uniqueness."
)
},
"new_string": {
"type": "string",
"description": (
"Replacement text (required for 'patch'). Can be empty string "
"to delete the matched text."
)
},
"replace_all": {
"type": "boolean",
"description": "For 'patch': replace all occurrences instead of requiring a unique match (default: false)."
},
"category": {
"type": "string",
"description": (
"Optional category/domain for organizing the skill (e.g., 'devops', "
"'data-science', 'mlops'). Creates a subdirectory grouping. "
"Only used with 'create'."
)
},
"file_path": {
"type": "string",
"description": (
"Path to a supporting file within the skill directory. "
"For 'write_file'/'remove_file': required, must be under references/, "
"templates/, scripts/, or assets/. "
"For 'patch': optional, defaults to SKILL.md if omitted."
)
},
"file_content": {
"type": "string",
"description": "Content for the file. Required for 'write_file'."
},
},
"required": ["action", "name"],
},
}

View file

@ -40,7 +40,8 @@ logger = logging.getLogger(__name__)
# Paths
# ---------------------------------------------------------------------------
SKILLS_DIR = Path(__file__).parent.parent / "skills"
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
SKILLS_DIR = HERMES_HOME / "skills"
HUB_DIR = SKILLS_DIR / ".hub"
LOCK_FILE = HUB_DIR / "lock.json"
QUARANTINE_DIR = HUB_DIR / "quarantine"

150
tools/skills_sync.py Normal file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Skills Sync -- Manifest-based seeding of bundled skills into ~/.hermes/skills/.
On fresh install: copies all bundled skills from the repo's skills/ directory
into ~/.hermes/skills/ and records every skill name in a manifest file.
On update: copies only NEW bundled skills (names not in the manifest) so that
user deletions are permanent and user modifications are never overwritten.
The manifest lives at ~/.hermes/skills/.bundled_manifest and is a simple
newline-delimited list of skill names that have been offered to the user.
"""
import json
import os
import shutil
from pathlib import Path
from typing import List, Tuple
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
SKILLS_DIR = HERMES_HOME / "skills"
MANIFEST_FILE = SKILLS_DIR / ".bundled_manifest"
def _get_bundled_dir() -> Path:
"""Locate the bundled skills/ directory in the repo."""
return Path(__file__).parent.parent / "skills"
def _read_manifest() -> set:
"""Read the set of skill names already offered to the user."""
if not MANIFEST_FILE.exists():
return set()
try:
return set(
line.strip()
for line in MANIFEST_FILE.read_text(encoding="utf-8").splitlines()
if line.strip()
)
except (OSError, IOError):
return set()
def _write_manifest(names: set):
"""Write the manifest file."""
MANIFEST_FILE.parent.mkdir(parents=True, exist_ok=True)
MANIFEST_FILE.write_text(
"\n".join(sorted(names)) + "\n",
encoding="utf-8",
)
def _discover_bundled_skills(bundled_dir: Path) -> List[Tuple[str, Path]]:
"""
Find all SKILL.md files in the bundled directory.
Returns list of (skill_name, skill_directory_path) tuples.
"""
skills = []
if not bundled_dir.exists():
return skills
for skill_md in bundled_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
skill_dir = skill_md.parent
skill_name = skill_dir.name
skills.append((skill_name, skill_dir))
return skills
def _compute_relative_dest(skill_dir: Path, bundled_dir: Path) -> Path:
"""
Compute the destination path in SKILLS_DIR preserving the category structure.
e.g., bundled/skills/mlops/axolotl -> ~/.hermes/skills/mlops/axolotl
"""
rel = skill_dir.relative_to(bundled_dir)
return SKILLS_DIR / rel
def sync_skills(quiet: bool = False) -> dict:
"""
Sync bundled skills into ~/.hermes/skills/ using the manifest.
- Skills whose names are already in the manifest are skipped (even if deleted by user).
- New skills (not in manifest) are copied to SKILLS_DIR and added to the manifest.
Returns:
dict with keys: copied (list of names), skipped (int), total_bundled (int)
"""
bundled_dir = _get_bundled_dir()
if not bundled_dir.exists():
return {"copied": [], "skipped": 0, "total_bundled": 0}
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
manifest = _read_manifest()
bundled_skills = _discover_bundled_skills(bundled_dir)
copied = []
skipped = 0
for skill_name, skill_src in bundled_skills:
if skill_name in manifest:
skipped += 1
continue
dest = _compute_relative_dest(skill_src, bundled_dir)
try:
if dest.exists():
# Skill dir exists (maybe user created one with same name) -- don't overwrite
skipped += 1
else:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(skill_src, dest)
copied.append(skill_name)
if not quiet:
print(f" + {skill_name}")
except (OSError, IOError) as e:
if not quiet:
print(f" ! Failed to copy {skill_name}: {e}")
manifest.add(skill_name)
# Also copy DESCRIPTION.md files for categories (if not already present)
for desc_md in bundled_dir.rglob("DESCRIPTION.md"):
rel = desc_md.relative_to(bundled_dir)
dest_desc = SKILLS_DIR / rel
if not dest_desc.exists():
try:
dest_desc.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(desc_md, dest_desc)
except (OSError, IOError):
pass
_write_manifest(manifest)
return {
"copied": copied,
"skipped": skipped,
"total_bundled": len(bundled_skills),
}
if __name__ == "__main__":
print("Syncing bundled skills into ~/.hermes/skills/ ...")
result = sync_skills(quiet=False)
print(f"\nDone: {len(result['copied'])} new, {result['skipped']} skipped, "
f"{result['total_bundled']} total bundled.")

View file

@ -68,8 +68,11 @@ from typing import Dict, Any, List, Optional, Tuple
import yaml
# Default skills directory (relative to repo root)
SKILLS_DIR = Path(__file__).parent.parent / "skills"
# All skills live in ~/.hermes/skills/ (seeded from bundled skills/ on install).
# This is the single source of truth -- agent edits, hub installs, and bundled
# skills all coexist here without polluting the git repo.
HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
SKILLS_DIR = HERMES_HOME / "skills"
# Anthropic-recommended limits for progressive disclosure efficiency
MAX_NAME_LENGTH = 64
@ -77,13 +80,8 @@ MAX_DESCRIPTION_LENGTH = 1024
def check_skills_requirements() -> bool:
"""
Check if skills tool requirements are met.
Returns:
bool: True if the skills directory exists, False otherwise
"""
return SKILLS_DIR.exists() and SKILLS_DIR.is_dir()
"""Skills are always available -- the directory is created on first use if needed."""
return True
def _parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
@ -127,21 +125,11 @@ def _get_category_from_path(skill_path: Path) -> Optional[str]:
"""
Extract category from skill path based on directory structure.
For paths like: skills/03-fine-tuning/axolotl/SKILL.md
Returns: "03-fine-tuning"
Args:
skill_path: Path to SKILL.md file
Returns:
Category name or None if skill is at root level
For paths like: ~/.hermes/skills/mlops/axolotl/SKILL.md -> "mlops"
"""
try:
# Get path relative to skills directory
rel_path = skill_path.relative_to(SKILLS_DIR)
parts = rel_path.parts
# If there are at least 2 parts (category/skill/SKILL.md), return category
if len(parts) >= 3:
return parts[0]
return None
@ -194,18 +182,10 @@ def _parse_tags(tags_value) -> List[str]:
def _find_all_skills() -> List[Dict[str, Any]]:
"""
Recursively find all skills in the skills directory.
Recursively find all skills in ~/.hermes/skills/.
Returns metadata for progressive disclosure (tier 1):
- name (64 chars)
- description (1024 chars)
- category, path, tags, related_skills
- reference/template file counts
- estimated token count for full content
Skills can be:
1. Directories containing SKILL.md (preferred)
2. Flat .md files (legacy support)
- name, description, category
Returns:
List of skill metadata dicts
@ -215,9 +195,7 @@ def _find_all_skills() -> List[Dict[str, Any]]:
if not SKILLS_DIR.exists():
return skills
# Find all SKILL.md files recursively
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
# Skip hidden directories, hub state, and common non-skill folders
path_str = str(skill_md)
if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str:
continue
@ -228,10 +206,8 @@ def _find_all_skills() -> List[Dict[str, Any]]:
content = skill_md.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content)
# Get name from frontmatter or directory name (max 64 chars)
name = frontmatter.get('name', skill_dir.name)[:MAX_NAME_LENGTH]
# Get description from frontmatter or first paragraph (max 1024 chars)
description = frontmatter.get('description', '')
if not description:
for line in body.strip().split('\n'):
@ -240,93 +216,20 @@ def _find_all_skills() -> List[Dict[str, Any]]:
description = line
break
# Truncate description to limit
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
# Get category from path
category = _get_category_from_path(skill_md)
# Track the path internally for excluding from legacy search
skill_path = str(skill_dir.relative_to(SKILLS_DIR))
# Minimal entry for list - full details in skill_view()
skills.append({
"name": name,
"description": description,
"category": category,
"_path": skill_path # Internal only, removed before return
})
except Exception as e:
# Skip files that can't be read
continue
# Also find flat .md files at any level (legacy support)
# But exclude files in skill directories (already handled above)
skill_dirs = {s["_path"] for s in skills}
for md_file in SKILLS_DIR.rglob("*.md"):
# Skip SKILL.md files (already handled)
if md_file.name == "SKILL.md":
continue
# Skip hidden directories and hub state
path_str = str(md_file)
if '/.git/' in path_str or '/.github/' in path_str or '/.hub/' in path_str:
continue
# Skip files inside skill directories (they're references, not standalone skills)
rel_dir = str(md_file.parent.relative_to(SKILLS_DIR))
if any(rel_dir.startswith(sd) for sd in skill_dirs):
continue
# Skip common non-skill files
if md_file.name in ['README.md', 'CONTRIBUTING.md', 'CLAUDE.md', 'LICENSE']:
continue
if md_file.name.startswith('_'):
continue
try:
content = md_file.read_text(encoding='utf-8')
frontmatter, body = _parse_frontmatter(content)
name = frontmatter.get('name', md_file.stem)[:MAX_NAME_LENGTH]
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
break
if len(description) > MAX_DESCRIPTION_LENGTH:
description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
# Get category from parent directory if not at root
category = None
rel_path = md_file.relative_to(SKILLS_DIR)
if len(rel_path.parts) > 1:
category = rel_path.parts[0]
# Parse optional fields
tags = _parse_tags(frontmatter.get('tags', ''))
# Minimal entry for list - full details in skill_view()
skills.append({
"name": name,
"description": description,
"category": category
})
except Exception:
continue
# Strip internal _path field before returning
for skill in skills:
skill.pop("_path", None)
return skills
@ -390,7 +293,6 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str:
"message": "No skills directory found."
}, ensure_ascii=False)
# Scan for categories (top-level directories containing skills)
category_dirs = {}
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
category = _get_category_from_path(skill_md)
@ -399,22 +301,15 @@ def skills_categories(verbose: bool = False, task_id: str = None) -> str:
if category not in category_dirs:
category_dirs[category] = category_dir
# Build category list with descriptions
categories = []
for name in sorted(category_dirs.keys()):
category_dir = category_dirs[name]
description = _load_category_description(category_dir)
# Count skills in this category
skill_count = sum(1 for _ in category_dir.rglob("SKILL.md"))
cat_entry = {
"name": name,
"skill_count": skill_count
}
cat_entry = {"name": name, "skill_count": skill_count}
if description:
cat_entry["description"] = description
categories.append(cat_entry)
return json.dumps({
@ -445,14 +340,13 @@ def skills_list(category: str = None, task_id: str = None) -> str:
JSON string with minimal skill info: name, description, category
"""
try:
# Ensure skills directory exists
if not SKILLS_DIR.exists():
SKILLS_DIR.mkdir(parents=True, exist_ok=True)
return json.dumps({
"success": True,
"skills": [],
"categories": [],
"message": "Skills directory created. No skills available yet."
"message": "No skills found. Skills directory created at ~/.hermes/skills/"
}, ensure_ascii=False)
# Find all skills
@ -507,35 +401,34 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
if not SKILLS_DIR.exists():
return json.dumps({
"success": False,
"error": "Skills directory does not exist."
"error": "Skills directory does not exist yet. It will be created on first install."
}, ensure_ascii=False)
# Find the skill
skill_dir = None
skill_md = None
# Try direct path first (e.g., "03-fine-tuning/axolotl")
# Try direct path first (e.g., "mlops/axolotl")
direct_path = SKILLS_DIR / name
if direct_path.is_dir() and (direct_path / "SKILL.md").exists():
skill_dir = direct_path
skill_md = direct_path / "SKILL.md"
elif direct_path.with_suffix('.md').exists():
# Legacy flat file
skill_md = direct_path.with_suffix('.md')
else:
# Search for skill by name
# Search by directory name
if not skill_md:
for found_skill_md in SKILLS_DIR.rglob("SKILL.md"):
if found_skill_md.parent.name == name:
skill_dir = found_skill_md.parent
skill_md = found_skill_md
break
# Also check flat .md files
if not skill_md:
for found_md in SKILLS_DIR.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
break
# Legacy: flat .md files
if not skill_md:
for found_md in SKILLS_DIR.rglob(f"{name}.md"):
if found_md.name != "SKILL.md":
skill_md = found_md
break
if not skill_md or not skill_md.exists():
# List available skills in error message
@ -660,7 +553,8 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
if script_files:
linked_files["scripts"] = script_files
# Build response with agentskills.io standard fields when present
rel_path = str(skill_md.relative_to(SKILLS_DIR))
result = {
"success": True,
"name": frontmatter.get('name', skill_md.stem if not skill_dir else skill_dir.name),
@ -668,7 +562,7 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
"tags": tags,
"related_skills": related_skills,
"content": content,
"path": str(skill_md.relative_to(SKILLS_DIR)),
"path": rel_path,
"linked_files": linked_files if linked_files else None,
"usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'" if linked_files else None
}