* feat: improve memory prioritization — user preferences over procedural knowledge Inspired by OpenAI Codex's memory prompt improvements (openai/codex#14493) which focus memory writes on user preferences and recurring patterns rather than procedural task details. Key insight: 'Optimize for reducing future user steering — the most valuable memory prevents the user from having to repeat themselves.' Changes: - MEMORY_GUIDANCE (prompt_builder.py): added prioritization hierarchy and the core principle about reducing user steering - MEMORY_SCHEMA (memory_tool.py): reordered WHEN TO SAVE list to put corrections first, added explicit PRIORITY guidance - Memory nudge (run_agent.py): now asks specifically about preferences, corrections, and workflow patterns instead of generic 'anything' - Memory flush (run_agent.py): now instructs to prioritize user preferences and corrections over task-specific details * feat: more aggressive skill creation and update prompting Press harder on skill updates — the agent should proactively patch skills when it encounters issues during use, not wait to be asked. Changes: - SKILLS_GUIDANCE: 'consider saving' → 'save'; added explicit instruction to patch skills immediately when found outdated/wrong - Skills header: added instruction to update loaded skills before finishing if they had missing steps or wrong commands - Skill nudge: more assertive ('save the approach' not 'consider saving'), now also prompts for updating existing skills used in the task - Skill nudge interval: lowered default from 15 to 10 iterations - skill_manage schema: added 'patch it immediately' to update triggers
659 lines
23 KiB
Python
659 lines
23 KiB
Python
#!/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 not allowed:
|
|
report = format_scan_report(result)
|
|
return f"Security scan blocked this skill ({reason}):\n{report}"
|
|
except Exception as e:
|
|
logger.warning("Security scan failed for %s: %s", skill_dir, e)
|
|
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:
|
|
pass
|
|
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="📝",
|
|
)
|