| |
| """ |
| 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__) |
|
|
| |
| |
| 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: |
| |
| report = format_scan_report(result) |
| logger.warning("Agent-created skill has security findings: %s", reason) |
| |
| return None |
| except Exception as e: |
| logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True) |
| return None |
|
|
| import yaml |
|
|
|
|
| |
| HERMES_HOME = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) |
| SKILLS_DIR = HERMES_HOME / "skills" |
|
|
| MAX_NAME_LENGTH = 64 |
| MAX_DESCRIPTION_LENGTH = 1024 |
|
|
| |
| VALID_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$') |
|
|
| |
| ALLOWED_SUBDIRS = {"references", "templates", "scripts", "assets"} |
|
|
|
|
| def check_skill_manage_requirements() -> bool: |
| """Skill management has no external requirements -- always available.""" |
| return True |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| if ".." in normalized.parts: |
| return "Path traversal ('..') is not allowed." |
|
|
| |
| 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}'" |
|
|
| |
| 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: |
| |
| try: |
| os.unlink(temp_path) |
| except OSError: |
| logger.error("Failed to remove temporary file %s during atomic write", temp_path, exc_info=True) |
| raise |
|
|
|
|
| |
| |
| |
|
|
| def _create_skill(name: str, content: str, category: str = None) -> Dict[str, Any]: |
| """Create a new user skill with SKILL.md content.""" |
| |
| err = _validate_name(name) |
| if err: |
| return {"success": False, "error": err} |
|
|
| |
| err = _validate_frontmatter(content) |
| if err: |
| return {"success": False, "error": err} |
|
|
| |
| existing = _find_skill(name) |
| if existing: |
| return { |
| "success": False, |
| "error": f"A skill named '{name}' already exists at {existing['path']}." |
| } |
|
|
| |
| skill_dir = _resolve_skill_dir(name, category) |
| skill_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| skill_md = skill_dir / "SKILL.md" |
| _atomic_write_text(skill_md, content) |
|
|
| |
| 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" |
| |
| original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None |
| _atomic_write_text(skill_md, content) |
|
|
| |
| 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: |
| |
| err = _validate_file_path(file_path) |
| if err: |
| return {"success": False, "error": err} |
| target = skill_dir / file_path |
| else: |
| |
| 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: |
| |
| 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 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 |
| _atomic_write_text(target, new_content) |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| original_content = target.read_text(encoding="utf-8") if target.exists() else None |
| _atomic_write_text(target, file_content) |
|
|
| |
| 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(): |
| |
| 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() |
|
|
| |
| 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}'.", |
| } |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
| 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"], |
| }, |
| } |
|
|
|
|
| |
| 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="π", |
| ) |
|
|