#!/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 logging import os import re import shutil import tempfile from pathlib import Path from typing import Dict, Any, Optional logger = logging.getLogger(__name__) # Import security scanner — agent-created skills get the same scrutiny as # community hub installs. try: from tools.skills_guard import scan_skill, should_allow_install, format_scan_report _GUARD_AVAILABLE = True except ImportError: _GUARD_AVAILABLE = False def _security_scan_skill(skill_dir: Path) -> Optional[str]: """Scan a skill directory after write. Returns error string if blocked, else None.""" if not _GUARD_AVAILABLE: return None try: result = scan_skill(skill_dir, source="agent-created") allowed, reason = should_allow_install(result) if allowed is False: report = format_scan_report(result) return f"Security scan blocked this skill ({reason}):\n{report}" if allowed is None: # "ask" — allow but include the warning so the user sees the findings report = format_scan_report(result) logger.warning("Agent-created skill has security findings: %s", reason) # Don't block — return None to allow, but log the warning return None except Exception as e: logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True) return None 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 def _atomic_write_text(file_path: Path, content: str, encoding: str = "utf-8") -> None: """ Atomically write text content to a file. Uses a temporary file in the same directory and os.replace() to ensure the target file is never left in a partially-written state if the process crashes or is interrupted. Args: file_path: Target file path content: Content to write encoding: Text encoding (default: utf-8) """ file_path.parent.mkdir(parents=True, exist_ok=True) fd, temp_path = tempfile.mkstemp( dir=str(file_path.parent), prefix=f".{file_path.name}.tmp.", suffix="", ) try: with os.fdopen(fd, "w", encoding=encoding) as f: f.write(content) os.replace(temp_path, file_path) except Exception: # Clean up temp file on error try: os.unlink(temp_path) except OSError: logger.error("Failed to remove temporary file %s during atomic write", temp_path, exc_info=True) raise # ============================================================================= # 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 atomically skill_md = skill_dir / "SKILL.md" _atomic_write_text(skill_md, content) # Security scan — roll back on block scan_error = _security_scan_skill(skill_dir) if scan_error: shutil.rmtree(skill_dir, ignore_errors=True) return {"success": False, "error": scan_error} 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" # Back up original content for rollback original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None _atomic_write_text(skill_md, content) # Security scan — roll back on block scan_error = _security_scan_skill(existing["path"]) if scan_error: if original_content is not None: _atomic_write_text(skill_md, original_content) return {"success": False, "error": scan_error} 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}", } original_content = content # for rollback _atomic_write_text(target, new_content) # Security scan — roll back on block scan_error = _security_scan_skill(skill_dir) if scan_error: _atomic_write_text(target, original_content) return {"success": False, "error": scan_error} 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) # Back up for rollback original_content = target.read_text(encoding="utf-8") if target.exists() else None _atomic_write_text(target, file_content) # Security scan — roll back on block scan_error = _security_scan_skill(existing["path"]) if scan_error: if original_content is not None: _atomic_write_text(target, original_content) else: target.unlink(missing_ok=True) return {"success": False, "error": scan_error} 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. " "If you used a skill and hit issues not covered by it, patch it immediately.\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"], }, } # --- Registry --- from tools.registry import registry registry.register( name="skill_manage", toolset="skills", schema=SKILL_MANAGE_SCHEMA, handler=lambda args, **kw: skill_manage( action=args.get("action", ""), name=args.get("name", ""), content=args.get("content"), category=args.get("category"), file_path=args.get("file_path"), file_content=args.get("file_content"), old_string=args.get("old_string"), new_string=args.get("new_string"), replace_all=args.get("replace_all", False)), emoji="📝", )