#!/usr/bin/env python3 """ Skill Manager Tool -- Agent-Managed Skill Creation & Editing Lets the agent create, patch, and delete skills — its procedural memory (narrow, actionable "how to do X"), as opposed to MEMORY.md/USER.md (broad, declarative). New skills land in ~/.hermes/skills/ (or ``skills.create_dir``); existing skills (bundled, hub-installed, user-created) are modified in place. Actions: create, edit (legacy full rewrite), patch, delete, write_file, remove_file. Layout: ``/[category/]/SKILL.md`` plus optional ``references/ templates/ scripts/ assets/`` subdirs. """ import contextvars as _ctxvars import json import logging import re import shutil import threading from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import yaml from hermes_constants import get_hermes_home, display_hermes_home from utils import atomic_write_text, is_truthy_value from hermes_cli.config import cfg_get from agent.skill_utils import ( extract_skill_description, is_skill_description_truncated_for_prompt, parse_frontmatter as _parse_frontmatter, SKILL_PROMPT_DESC_LIMIT, ) from tools.skill_manager_guards import ( # noqa: F401 — re-exported for callers/tests _BackgroundReviewReadMarks, _background_review_has_read, _background_review_preflight, _background_review_read_before_write_guard, _background_review_read_paths, _background_review_write_guard, _containing_skills_root, _curator_consolidation_delete_guard, _is_path_redirect, _maybe_auto_propose_org_edit, _org_mirror_write_guard, _pinned_guard, _reset_background_review_read_marks, _validate_delete_target, _is_background_review, mark_background_review_skill_read, ) from tools.skill_manager_batch import ( # noqa: F401 _BATCH_MAX_OPS, _BATCH_OP_ACTIONS, _skill_manage_batch, ) logger = logging.getLogger(__name__) # External hub installs are always scanned; agent-created skills only when # skills.guard_agent_created is on. try: from tools.skills_guard import scan_skill, should_allow_install, format_scan_report _GUARD_AVAILABLE = True except ImportError: _GUARD_AVAILABLE = False def _guard_agent_created_enabled() -> bool: """skills.guard_agent_created (default False): the agent can already run the same code via terminal() ungated, so the scan is opt-in belt-and-suspenders.""" try: from hermes_cli.config import load_config return is_truthy_value( cfg_get(load_config(), "skills", "guard_agent_created"), default=False, ) except Exception: return False def _security_scan_skill(skill_dir: Path) -> Optional[str]: """Scan a skill directory after write. Returns error string if blocked, else None. No-op when skills.guard_agent_created is disabled (the default). An "ask" verdict means dangerous findings for an agent-created skill — surfaced as an error so the agent can retry with the flagged content removed. """ if not _GUARD_AVAILABLE or not _guard_agent_created_enabled(): return None try: result = scan_skill(skill_dir, source="agent-created") allowed, reason = should_allow_install(result) if allowed is None: logger.warning("Agent-created skill blocked (dangerous findings): %s", reason) if allowed is not True: return f"Security scan blocked this skill ({reason}):\n{format_scan_report(result)}" except Exception as e: logger.warning("Security scan failed for %s: %s", skill_dir, e, exc_info=True) return None # All skills live in ~/.hermes/skills/ (single source of truth) HERMES_HOME = get_hermes_home() SKILLS_DIR = HERMES_HOME / "skills" _SKILLS_DIR_AT_IMPORT = SKILLS_DIR def _skills_dir() -> Path: """Active profile's skills directory at call time. Long-lived multi-profile runtimes import this module once and later bind a different profile per session. Honor an explicitly patched module-level ``SKILLS_DIR`` (tests), otherwise resolve from the live HERMES_HOME. """ configured = Path(SKILLS_DIR) if configured != _SKILLS_DIR_AT_IMPORT: return configured return get_hermes_home() / "skills" MAX_NAME_LENGTH = 64 MAX_DESCRIPTION_LENGTH = 1024 MAX_SKILL_CONTENT_CHARS = 100_000 # ~36k tokens at 2.75 chars/token MAX_SKILL_FILE_BYTES = 1_048_576 # 1 MiB per supporting file VALID_NAME_RE = re.compile(r'^[a-z0-9][a-z0-9._-]*$') # filesystem-safe, URL-friendly ALLOWED_SUBDIRS = {"references", "templates", "scripts", "assets"} # for write_file/remove_file _FRONTMATTER_END_RE = re.compile(r'\n---\s*\n') def _display_create_dir() -> str: """Display string for the skill-creation directory (schema/instruction text); follows ``skills.create_dir`` when configured.""" try: from agent.skill_utils import display_skill_create_dir return display_skill_create_dir() except Exception: return f"{display_hermes_home()}/skills/" def _err(message: str) -> Dict[str, Any]: return {"success": False, "error": message} # ============================================================================= # 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_category(category: Optional[str]) -> Optional[str]: """Validate an optional category name used as a single directory segment.""" if category is None: return None if not isinstance(category, str): return "Category must be a string." category = category.strip() if not category: return None invalid = ( f"Invalid category '{category}'. Use lowercase letters, numbers, " "hyphens, dots, and underscores. Categories must be a single directory name." ) if "/" in category or "\\" in category: return invalid if len(category) > MAX_NAME_LENGTH: return f"Category exceeds {MAX_NAME_LENGTH} characters." return None if VALID_NAME_RE.match(category) else invalid def _validate_frontmatter(content: str, *, new_skill: bool = False) -> Optional[str]: """Validate SKILL.md frontmatter (name + description) and a non-empty body. ``new_skill`` (create path only) also enforces the SKILL_PROMPT_DESC_LIMIT budget so new skills never lose routing signal to index truncation; edit and patch skip it so existing over-limit skills remain maintainable. """ if not content.strip(): return "Content cannot be empty." content = content.lstrip("\ufeff") # tolerate a Windows UTF-8 BOM if not content.startswith("---"): return "SKILL.md must start with YAML frontmatter (---). See existing skills for format." end_match = _FRONTMATTER_END_RE.search(content[3:]) if not end_match: return "SKILL.md frontmatter is not closed. Ensure you have a closing '---' line." try: parsed = yaml.safe_load(content[3:end_match.start() + 3]) 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." desc = str(parsed["description"]) if len(desc) > MAX_DESCRIPTION_LENGTH: return f"Description exceeds {MAX_DESCRIPTION_LENGTH} characters." if new_skill and len(desc.strip().strip("'\"")) > SKILL_PROMPT_DESC_LIMIT: return ( f"Description is {len(desc.strip())} chars — new skills must fit the " f"{SKILL_PROMPT_DESC_LIMIT}-char system-prompt budget (one sentence, " f"trigger first, ends with a period). The skill index truncates " f"longer descriptions to {SKILL_PROMPT_DESC_LIMIT - 3} chars + '...', " f"destroying the routing signal. Move detail into the skill body." ) if not content[end_match.end() + 3:].strip(): return "SKILL.md must have content after the frontmatter (instructions, procedures, etc.)." return None def _validate_content_size(content: str, label: str = "SKILL.md") -> Optional[str]: """Error message when content exceeds the agent-write character limit, else None.""" if len(content) > MAX_SKILL_CONTENT_CHARS: return ( f"{label} content is {len(content):,} characters " f"(limit: {MAX_SKILL_CONTENT_CHARS:,}). " f"Consider splitting into a smaller SKILL.md with supporting files " f"in references/ or templates/." ) return None def _description_preview(content: str) -> str: """First 120 chars of the frontmatter description (verbose notifications); '' on any failure.""" try: fm_end = _FRONTMATTER_END_RE.search(content[3:]) if fm_end: parsed = yaml.safe_load(content[3:fm_end.start() + 3]) return str(parsed.get("description", ""))[:120] except Exception: pass return "" def _resolve_skill_dir(name: str, category: str = None) -> Path: """Directory for a new skill; honors ``skills.create_dir`` (e.g. a shared fleet directory) and falls back to the profile-local skills dir.""" base = _skills_dir() try: from agent.skill_utils import get_skill_create_dir create_dir = get_skill_create_dir() if create_dir is not None: base = create_dir except Exception: logger.debug("skills.create_dir lookup failed", exc_info=True) return base / category / name if category else base / name def _iter_skill_dirs(root: Path): """Yield every non-excluded skill directory (parent of a SKILL.md) under ``root``.""" from agent.skill_utils import is_excluded_skill_path for skill_md in root.rglob("SKILL.md"): if not is_excluded_skill_path(skill_md): yield skill_md.parent def _find_skill(name: str) -> Optional[Dict[str, Any]]: """Find a skill by name across the local skills dir then skills.external_dirs. Accepts the bare directory name (``axolotl``) and the categorized relative path (``mlops/axolotl``) — the two forms skill_view resolves. Bare lookups compare the skill's own dir name so category-nested skills still match. Returns ``{"path": Path}`` or None. """ from agent.skill_utils import get_all_skills_dirs # The categorized form matches the skill dir RELATIVE to the local root # (never external dirs — relative_to raises there). local_root = None if "/" in name or "\\" in name: try: local_root = _skills_dir().resolve() except OSError: logger.debug( "skills dir resolve failed; categorized lookups fall back to the unresolved path", exc_info=True, ) local_root = _skills_dir() for skills_dir in get_all_skills_dirs(): if not skills_dir.exists(): continue for skill_dir in _iter_skill_dirs(skills_dir): if skill_dir.name == name: return {"path": skill_dir} if local_root is not None: try: rel = skill_dir.resolve().relative_to(local_root) except ValueError: continue if rel.as_posix() == name: # POSIX form so it works on Windows too return {"path": skill_dir} return None def _find_skill_in_other_profiles(name: str) -> List[Tuple[str, Path]]: """``(profile_name, skill_dir)`` pairs for OTHER profiles holding ``name``. Lets the "not found" error explain a wrong-profile mistake. Fail-quiet: empty list when discovery fails. """ matches: List[Tuple[str, Path]] = [] try: from hermes_constants import get_default_hermes_root root = get_default_hermes_root() except Exception: return matches _active = _skills_dir() active_dir = _active.resolve() if _active.exists() else _active # Every profile's skills dir EXCEPT the active one (already searched). candidates: List[Tuple[str, Path]] = [("default", root / "skills")] profiles_root = root / "profiles" try: if profiles_root.is_dir(): candidates += [(e.name, e / "skills") for e in profiles_root.iterdir() if e.is_dir()] except OSError: pass for profile_name, skills_dir in candidates: try: if skills_dir.resolve() == active_dir or not skills_dir.is_dir(): continue for skill_dir in _iter_skill_dirs(skills_dir): if skill_dir.name == name: matches.append((profile_name, skill_dir)) break # one match per profile is enough except (OSError, RuntimeError): continue return matches def _skill_not_found_error(name: str, suffix: str = "") -> str: """"Skill not found" error naming other profiles that hold the skill; ``suffix`` is appended after the hint.""" from agent.file_safety import _resolve_active_profile_name active = _resolve_active_profile_name() base = f"Skill '{name}' not found in active profile '{active}'." others = _find_skill_in_other_profiles(name) if len(others) == 1: other_profile, other_path = others[0] base += ( f" A skill by that name exists in profile " f"'{other_profile}' ({other_path}). To edit it, switch " f"profiles (`hermes -p {other_profile}`) or edit the file " f"directly (file tools / terminal)." ) elif others: names = ", ".join(f"'{p}'" for p, _ in others) base += ( f" Skills by that name exist in other profiles: {names}. " f"Switch profiles (`hermes -p `) to edit there, or " f"edit the files directly (file tools / terminal)." ) else: base += " Use skills_list() to see available skills." return base + suffix def _validate_file_path(file_path: str) -> Optional[str]: """Validate a write_file/remove_file path: under an allowed subdir, no escape.""" from tools.path_security import has_traversal_component if not file_path: return "file_path is required." normalized = Path(file_path) # Traversal is checked before any allow-listing so the SKILL.md exception # can never be reached by a traversal-laden path. if has_traversal_component(file_path): return "Path traversal ('..') is not allowed." # SKILL.md lives at the skill root; accept 'SKILL.md' and '/SKILL.md'. if normalized.name == "SKILL.md" and len(normalized.parts) in (1, 2): return None 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 _resolve_skill_target(skill_dir: Path, file_path: str) -> Tuple[Optional[Path], Optional[str]]: """Resolve a supporting-file path and ensure it stays within the skill directory.""" from tools.path_security import validate_within_dir target = skill_dir / file_path error = validate_within_dir(target, skill_dir) return (None, error) if error else (target, None) def _resolve_supporting_file(skill_dir: Path, file_path: str): """``_validate_file_path`` + ``_resolve_skill_target``. Returns (target, None) or (None, error_dict).""" err = _validate_file_path(file_path) if err: return None, _err(err) target, err = _resolve_skill_target(skill_dir, file_path) if err: return None, _err(err) return target, None def _locate_for_write(name: str, action: str, not_found_suffix: str = ""): """Find the skill and run the org-mirror + background-review write guards. Returns ``(skill_dir, None)`` or ``(None, error_dict)``. """ existing = _find_skill(name) if not existing: return None, _err(_skill_not_found_error(name, not_found_suffix)) skill_dir = existing["path"] guard = ( _org_mirror_write_guard(name, skill_dir, action) or _background_review_write_guard(name, skill_dir, action) ) if guard: return None, guard return skill_dir, None def _write_scanned(target: Path, content: str, skill_dir: Path, original: Optional[str]) -> Optional[str]: """Atomically write ``content``, then security-scan the skill; on block, restore ``original`` (or unlink a newly created file) and return the error.""" atomic_write_text(target, content, preserve_mode=True, create_mode=0o644) scan_error = _security_scan_skill(skill_dir) if scan_error: if original is not None: atomic_write_text(target, original, preserve_mode=True) else: target.unlink(missing_ok=True) return scan_error def _attach_org_note(result: Dict[str, Any], name: str, skill_dir: Path) -> None: org_note = _maybe_auto_propose_org_edit(name, skill_dir) if org_note: result["org_sharing"] = org_note result["message"] = f"{result['message']} {org_note}" # ============================================================================= # Core actions # ============================================================================= def _add_description_prompt_preview(result: Dict[str, Any], content: str) -> None: """Append a system_prompt_preview field when the description will be truncated.""" fm, _ = _parse_frontmatter(content) if is_skill_description_truncated_for_prompt(fm): result["system_prompt_preview"] = ( f"System prompt will show: \"{extract_skill_description(fm)}\" — " f"keep the trigger self-contained in the first " f"{SKILL_PROMPT_DESC_LIMIT - 3} chars." ) def _attach_lint_findings(result: Dict[str, Any], skill_md: Path) -> None: """Attach ADVISORY skill-authoring-convention findings (never a hard block; hard rejects already ran in _validate_frontmatter).""" try: from tools.skill_linter import lint_skill # local import: optional path findings = lint_skill(skill_md) except Exception: return if not findings: return result["lint_warnings"] = [ {"severity": f.severity, "rule": f.rule, "message": f.message} for f in findings ] result["lint_hint"] = ( "The skill was created. These are advisory authoring-convention " "findings (not blockers) — fix them with skill_manage(action='patch') " "to match Hermes skill standards." ) 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) or _validate_category(category) or _validate_frontmatter(content, new_skill=True) or _validate_content_size(content) ) if err: return _err(err) existing = _find_skill(name) if existing: return _err(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, preserve_mode=True, create_mode=0o644) scan_error = _security_scan_skill(skill_dir) if scan_error: shutil.rmtree(skill_dir, ignore_errors=True) return _err(scan_error) try: _display_path = str(skill_dir.relative_to(_skills_dir())) except ValueError: _display_path = str(skill_dir) # created under skills.create_dir result = { "success": True, "message": f"Skill '{name}' created.", "path": _display_path, "skill_md": str(skill_md), "_change": {"description": _description_preview(content)}, } 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) ) _add_description_prompt_preview(result, content) _attach_lint_findings(result, skill_md) 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) or _validate_content_size(content) if err: return _err(err) skill_dir, guard = _locate_for_write(name, "edit") if guard: return guard skill_md = skill_dir / "SKILL.md" read_guard = _background_review_read_before_write_guard(name, skill_md, "edit", "SKILL.md") if read_guard: return read_guard # SKILL.md always exists here (_find_skill requires it), so a blocked scan restores it. original_content = skill_md.read_text(encoding="utf-8") if skill_md.exists() else None scan_error = _write_scanned(skill_md, content, skill_dir, original_content) if scan_error: return _err(scan_error) result = { "success": True, "message": f"Skill '{name}' updated (full rewrite).", "path": str(skill_dir), "_change": {"description": _description_preview(content)}, } _attach_org_note(result, name, skill_dir) _add_description_prompt_preview(result, content) return result 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 SKILL.md (default) or a supporting file. Requires a unique match unless replace_all is True.""" if not old_string: # A bare "required" error is a dead end: the model retries blindly and # often escapes to action='write_file', clobbering the whole file. return _err( "old_string is required for 'patch' and must be the EXACT text currently in the " "file. Read the target file first (read_file on the skill's SKILL.md, or the file " "named by file_path) and copy the snippet verbatim, then retry 'patch'. " "Do NOT fall back to action='write_file' — that rewrites the entire file and " "destroys unrelated content." ) if new_string is None: return _err("new_string is required for 'patch'. Use an empty string to delete matched text.") # No old_string == new_string guard here: fuzzy_find_and_replace rejects # that with a richer error (file_preview) this layer cannot produce. skill_dir, guard = _locate_for_write(name, "patch") if guard: return guard if file_path: target, err = _resolve_supporting_file(skill_dir, file_path) if err: return err else: target = skill_dir / "SKILL.md" if not target.exists(): return _err(f"File not found: {target.relative_to(skill_dir)}") target_label = file_path or "SKILL.md" read_guard = _background_review_read_before_write_guard(name, target, "patch", target_label) if read_guard: return read_guard content = target.read_text(encoding="utf-8") # Same fuzzy engine as the file patch tool (whitespace/indent/escape # normalization, block anchors) so minor formatting mismatches don't fail. from tools.fuzzy_match import fuzzy_find_and_replace new_content, match_count, _strategy, match_error = fuzzy_find_and_replace( content, old_string, new_string, replace_all ) if match_error: try: from tools.fuzzy_match import format_no_match_hint match_error += format_no_match_hint(match_error, match_count, old_string, content) except Exception: pass return _err(match_error) | {"file_preview": content[:500] + ("..." if len(content) > 500 else "")} err = _validate_content_size(new_content, label=target_label) if err: return _err(err) if not file_path: err = _validate_frontmatter(new_content) if err: return _err(f"Patch would break SKILL.md structure: {err}") scan_error = _write_scanned(target, new_content, skill_dir, content) if scan_error: return _err(scan_error) result = { "success": True, "message": f"Patched {target_label} in skill '{name}' ({match_count} replacement{'s' if match_count > 1 else ''}).", "_change": { "old": old_string[:200] + ("…" if len(old_string) > 200 else ""), "new": new_string[:200] + ("…" if len(new_string) > 200 else ""), }, } _attach_org_note(result, name, skill_dir) return result def _delete_skill(name: str, absorbed_into: Optional[str] = None) -> Dict[str, Any]: """Delete a skill. ``absorbed_into``: ``None`` = undeclared (legacy path, accepted); ``""`` = explicit prune with no forwarding target; ``""`` = content absorbed into that umbrella, which must exist on disk (validated here so the model can't claim a nonexistent umbrella). """ skill_dir, guard = _locate_for_write(name, "delete") if guard: return guard fail_closed = _curator_consolidation_delete_guard(name, absorbed_into) if fail_closed: return fail_closed pinned_err = _pinned_guard(name) if pinned_err: return _err(pinned_err) absorbed_target = absorbed_into.strip() if isinstance(absorbed_into, str) else "" is_consolidation = bool(absorbed_target) if is_consolidation: if absorbed_target == name: return _err(f"absorbed_into='{absorbed_target}' cannot equal the skill being deleted.") if not _find_skill(absorbed_target): return _err( f"absorbed_into='{absorbed_target}' does not exist. " f"Create or patch the umbrella skill first, then retry the delete." ) skills_root = _containing_skills_root(skill_dir) unsafe = _validate_delete_target(skill_dir) # defense-in-depth before rmtree if unsafe: return _err(unsafe) # During the curator consolidation pass a verified consolidation must be # RECOVERABLE (`hermes curator restore`), so route through the archive # primitive instead of rmtree. Foreground deletes keep hard-delete semantics. absorbed_note = f" Content absorbed into '{absorbed_target}'." if is_consolidation else "" if _is_background_review(): try: from tools.skill_usage import archive_skill ok, archive_msg = archive_skill(name) except Exception as e: return _err(f"failed to archive '{name}': {e}") if not ok: return _err(archive_msg) return { "success": True, "message": f"Skill '{name}' archived ({archive_msg}).{absorbed_note}", "_archived": True, } shutil.rmtree(skill_dir) _rmdir_if_empty(skill_dir.parent, skills_root) # empty category dir, never the root return {"success": True, "message": f"Skill '{name}' deleted.{absorbed_note}"} def _rmdir_if_empty(parent: Path, stop: Path) -> None: if parent != stop and parent.exists() and not any(parent.iterdir()): parent.rmdir() 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 _err(err) if not file_content and file_content != "": return _err("file_content is required.") content_bytes = len(file_content.encode("utf-8")) if content_bytes > MAX_SKILL_FILE_BYTES: return _err( f"File content is {content_bytes:,} bytes " f"(limit: {MAX_SKILL_FILE_BYTES:,} bytes / 1 MiB). " f"Consider splitting into smaller files." ) err = _validate_content_size(file_content, label=file_path) if err: return _err(err) skill_dir, guard = _locate_for_write(name, "write_file", " Create it first with action='create'.") if guard: return guard target, err = _resolve_supporting_file(skill_dir, file_path) if err: return err if target.exists(): read_guard = _background_review_read_before_write_guard(name, target, "write_file", file_path) if read_guard: return read_guard target.parent.mkdir(parents=True, exist_ok=True) original_content = target.read_text(encoding="utf-8") if target.exists() else None scan_error = _write_scanned(target, file_content, skill_dir, original_content) if scan_error: return _err(scan_error) result = { "success": True, "message": f"File '{file_path}' written to skill '{name}'.", "path": str(target), } _attach_org_note(result, name, skill_dir) return result 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 _err(err) existing = _find_skill(name) if not existing: return _err(_skill_not_found_error(name)) skill_dir = existing["path"] guard = _background_review_write_guard(name, skill_dir, "remove_file") if guard: return guard target, err = _resolve_supporting_file(skill_dir, file_path) if err: return err if not target.exists(): available = [ str(f.relative_to(skill_dir)) for subdir in ALLOWED_SUBDIRS if (skill_dir / subdir).exists() for f in (skill_dir / subdir).rglob("*") if f.is_file() ] return { "success": False, "error": f"File '{file_path}' not found in skill '{name}'.", "available_files": available if available else None, } read_guard = _background_review_read_before_write_guard(name, target, "remove_file", file_path) if read_guard: return read_guard target.unlink() _rmdir_if_empty(target.parent, skill_dir) return {"success": True, "message": f"File '{file_path}' removed from skill '{name}'."} # ============================================================================= # Main entry point # ============================================================================= # Set while replaying an already-approved staged skill write so skill_manage() # does not re-gate (and re-stage) it. _skill_gate_bypass: "_ctxvars.ContextVar[bool]" = _ctxvars.ContextVar( "skill_gate_bypass", default=False ) _GATED_ACTIONS = {"create", "edit", "patch", "delete", "write_file", "remove_file"} def _run_write_gate(build_staging): """Shared write-gate evaluation. Returns None to proceed, or a JSON tool result when blocked/staged. ``build_staging(wa)`` -> (payload, gist) is only called when the decision is to stage. Fails open if write_approval cannot be imported.""" try: from tools import write_approval as wa except Exception: return None # fail open decision = wa.evaluate_gate(wa.SKILLS) if decision.allow: return None if decision.blocked: return tool_error(decision.message, success=False) payload, gist = build_staging(wa) record = wa.stage_write(wa.SKILLS, payload, summary=gist, origin=wa.current_origin()) return json.dumps( {"success": True, "staged": True, "pending_id": record["id"], "gist": gist, "message": decision.message}, ensure_ascii=False, ) def _apply_skill_write_gate(action, name, **payload_kwargs): """Flat-shape write gate: stage the full skill_manage kwargs so approval can replay them. Bypassed during approved-pending replay.""" if action not in _GATED_ACTIONS or _skill_gate_bypass.get(): return None def _staging(wa): payload = {"action": action, "name": name} payload.update({k: v for k, v in payload_kwargs.items() if v is not None}) gist = wa.skill_gist( action, name, content=payload_kwargs.get("content") or "", file_path=payload_kwargs.get("file_path") or "", old_string=payload_kwargs.get("old_string") or "", new_string=payload_kwargs.get("new_string") or "", ) return payload, gist return _run_write_gate(_staging) _FLAT_OP_KEYS = ("content", "category", "file_path", "file_content", "old_string", "new_string") def _skill_manage_from(payload: Dict[str, Any], **extra) -> str: """Call ``skill_manage`` with the flat-shape fields taken from ``payload``.""" return skill_manage( action=payload.get("action", ""), name=payload.get("name", ""), replace_all=payload.get("replace_all", False), **{k: payload.get(k) for k in _FLAT_OP_KEYS}, **extra, ) def apply_skill_pending(payload: Dict[str, Any]) -> str: """Replay a staged skill write, bypassing the gate. Returns the tool result JSON string. Called by the /skills approve handler.""" token = _skill_gate_bypass.set(True) try: return _skill_manage_from( payload, absorbed_into=payload.get("absorbed_into"), operations=payload.get("operations"), ) finally: _skill_gate_bypass.reset(token) # Debounce state for the sync push hook: a burst of skill_manage writes # collapses into one push after a quiet window, on a daemon timer. _sync_push_timer = None _sync_push_lock = None _SYNC_PUSH_DEBOUNCE_S = 5.0 def _maybe_debounced_sync_push(skill_name: str) -> None: """Schedule a debounced best-effort sync push after a skill write. Fast-path: skills not opted into sync do nothing (no auth, no network). The push runs via ``skills_sync_client.maybe_push_skills`` which enforces the access gate and swallows errors. Never blocks the caller (M1-C). """ global _sync_push_timer, _sync_push_lock try: from tools.skill_usage import is_sync_enabled if not is_sync_enabled(skill_name): return except Exception: return if _sync_push_lock is None: _sync_push_lock = threading.Lock() def _fire(): try: from tools.skills_sync_client import maybe_push_skills maybe_push_skills(message=f"sync: {skill_name}") except Exception: pass with _sync_push_lock: if _sync_push_timer is not None: _sync_push_timer.cancel() # only sets an Event; never raises _sync_push_timer = threading.Timer(_SYNC_PUSH_DEBOUNCE_S, _fire) _sync_push_timer.daemon = True _sync_push_timer.start() def _act_create(a): if not a["content"]: return tool_error("content is required for 'create'. Provide the full SKILL.md text (frontmatter + body).", success=False) return _create_skill(a["name"], a["content"], a["category"]) def _act_edit(a): # Legacy alias for a full rewrite (old transcripts/callers; not in the schema). if not a["content"]: return tool_error("content is required for a full rewrite. Provide the full updated SKILL.md text.", success=False) return _edit_skill(a["name"], a["content"]) def _act_patch(a): # Two shapes: old_string/new_string = targeted replacement; # content (alone) = full SKILL.md rewrite (absorbs the old 'edit'). if a["content"] and (a["old_string"] or a["new_string"] is not None): return tool_error( "Pass EITHER content (full SKILL.md rewrite) OR " "old_string/new_string (targeted replacement), not both.", success=False, ) if a["content"]: return _edit_skill(a["name"], a["content"]) # Targeted-replacement validation lives in _patch_skill so the public # tool and the helper return the same actionable guidance. return _patch_skill(a["name"], a["old_string"], a["new_string"], a["file_path"], a["replace_all"]) def _act_write_file(a): if not a["file_path"]: return tool_error("file_path is required for 'write_file'. Example: 'references/api-guide.md'", success=False) if a["file_content"] is None: return tool_error("file_content is required for 'write_file'.", success=False) return _write_file(a["name"], a["file_path"], a["file_content"]) def _act_remove_file(a): if not a["file_path"]: return tool_error("file_path is required for 'remove_file'.", success=False) return _remove_file(a["name"], a["file_path"]) # action -> handler(args dict). Handlers return a result dict, or a JSON string # (tool_error) for argument-shape errors. _ACTION_HANDLERS = { "create": _act_create, "edit": _act_edit, "patch": _act_patch, "delete": lambda a: _delete_skill(a["name"], absorbed_into=a["absorbed_into"]), "write_file": _act_write_file, "remove_file": _act_remove_file, } def _record_success(action, name, result, *, file_path, absorbed_into, task_id, session_id, ledger_before) -> None: """Post-mutation side effects, all best-effort (never break the tool): audit ledger, prompt-cache clear, curator telemetry, debounced sync push.""" try: from tools import skill_ledger as _ledger _post = _find_skill(name) _evidence = {} if action == "delete": # consolidation vs prune, and whether the recoverable archive handled it _evidence["absorbed_into"] = absorbed_into _evidence["archived"] = bool(result.get("_archived")) if session_id: _evidence["session_id"] = session_id if file_path: _evidence["file_path"] = file_path _ledger.record_mutation( action, name, before=ledger_before if ledger_before is not None else [], after_root=_post["path"] if _post else None, evidence=_evidence, ) except Exception: pass try: from agent.prompt_builder import clear_skills_system_prompt_cache clear_skills_system_prompt_cache(clear_snapshot=True) except Exception: pass # Curator telemetry: only the background review fork marks a skill as # agent-created — foreground creates are user-directed and belong to the # user. A recoverable curator archive keeps its record as STATE_ARCHIVED # (so `hermes curator status`/`restore` see it); only a hard delete forgets. try: from tools.skill_usage import bump_patch, forget, record_created from tools.skill_provenance import is_background_review if action == "create": record_created(name, agent_created=is_background_review(), task_id=task_id, session_id=session_id) elif action in {"patch", "edit", "write_file", "remove_file"}: bump_patch(name, action=action, task_id=task_id, session_id=session_id) elif action == "delete" and not result.get("_archived"): forget(name) except Exception: pass # Sync push only runs AFTER the write gate passed (staged writes returned # early), so un-reviewed content is never pushed. Inert unless the access # gate is open, a sync URL is configured, and the skill is opted in. try: _maybe_debounced_sync_push(name) except Exception: pass 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, absorbed_into: str = None, task_id: str = None, session_id: str = None, operations=None, ) -> str: """Manage user-created skills; dispatches to the action handler. ``operations``: batch shape — a list of {action, ...} dicts applied atomically (see _skill_manage_batch). When set, the flat single-op fields are ignored and ``action`` may be omitted/'batch'. Returns a JSON string. """ if operations is not None: return _skill_manage_batch( operations, default_name=name or None, task_id=task_id, session_id=session_id, ) preflight = _background_review_preflight(action, name) if preflight is not None: return json.dumps(preflight, ensure_ascii=False) # Approval gate: when on, stages the write for review (skills are too large # to review inline, so they always stage regardless of origin); bypassed # when replaying an already-approved staged write. args = dict( content=content, category=category, file_path=file_path, file_content=file_content, old_string=old_string, new_string=new_string, replace_all=replace_all, absorbed_into=absorbed_into, ) gate_result = _apply_skill_write_gate(action, name, **args) if gate_result is not None: return gate_result # Audit ledger: capture pre-mutation state. Telemetry, not a gate — failures # must NEVER block the mutation. delete destroys the whole package and # consolidation may have re-homed support files first, so complete the # capture from the newest curator backup or a restore is hollow. _ledger_before = None try: from tools import skill_ledger as _ledger _pre = _find_skill(name) _ledger_before = _ledger.capture_before( _pre["path"] if _pre else None, complete_package=(action == "delete"), skill=name, ) except Exception: pass handler = _ACTION_HANDLERS.get(action) if handler is None: result = _err(f"Unknown action '{action}'. Use: create, edit, patch, delete, write_file, remove_file") else: result = handler({"name": name, **args}) if isinstance(result, str): return result # tool_error JSON for argument-shape problems if result.get("success"): _record_success( action, name, result, file_path=file_path, absorbed_into=absorbed_into, task_id=task_id, session_id=session_id, ledger_before=_ledger_before, ) return json.dumps(result, ensure_ascii=False) # ============================================================================= # OpenAI Function-Calling Schema # ============================================================================= SKILL_MANAGE_SCHEMA = { "name": "skill_manage", # ONE call shape (memory-tool pattern, maintainer-directed): the call # IS an operations array — each op names its skill and action; a # single edit is a list of one. The legacy flat shape (top-level # action/name/content/...) is still ACCEPTED by the handler for old # transcripts and staged-write replay, but no longer advertised. "description": ( "Create, update, or delete skills — your procedural memory for " "recurring task types. The call is an operations array (a single " "edit is a list of one); it applies atomically — any failure rolls " "every touched skill back. Ops: create (full SKILL.md; lands in " f"{_display_create_dir()}; must precede that skill's other " "ops), patch (targeted old_string/new_string fix — preferred; " "content alone REPLACES the whole file, read it via skill_view() " "first), write_file/remove_file (supporting files), delete (sole " "op only). Existing skills are modified wherever they live. Keep " "the description's first 57 chars a self-contained trigger: 'Use " "when . .' — skill_view() shows " "format conventions." ), "parameters": { "type": "object", "properties": { "operations": { "type": "array", "description": "Ordered ops; each names its target skill.", "items": { "type": "object", "properties": { "name": { "type": "string", "description": ( "Skill name (lowercase, hyphens/underscores, " "max 64 chars); an existing skill's name " "unless creating." ) }, "action": { "type": "string", "enum": ["create", "patch", "delete", "write_file", "remove_file"] }, "content": { "type": "string", "description": ( "Full SKILL.md text (YAML frontmatter + " "markdown body) for create, or a full " "rewrite on patch." ) }, "category": { "type": "string", "description": "Optional category subdir for create (e.g. 'devops')." }, # patch args: same fuzzy-matching semantics as the # `patch` tool — teach only skill-specific facts here. "old_string": { "type": "string", "description": "Text to find (patch; same matching semantics as the patch tool)." }, "new_string": { "type": "string", "description": "Replacement (patch); empty string deletes the match." }, "replace_all": { "type": "boolean", "description": "patch: replace all occurrences (default false)." }, "file_path": { "type": "string", "description": ( "Path RELATIVE to the skill's own directory, " "e.g. 'references/api.md' — no leading slash, " "never absolute. write_file/remove_file: " "required; first segment references/, " "templates/, scripts/, or assets/. patch: " "optional (default SKILL.md)." ) }, "file_content": { "type": "string", "description": "Content for write_file." } }, "required": ["name", "action"] } }, # NOTE: the handler also accepts the legacy flat single-op shape # (top-level action/name/content/old_string/new_string/ # replace_all/category/file_path/file_content) — old transcripts # and staged-write replay depend on it — plus `absorbed_into` on # delete ops (curator-only vocabulary; the curator's prompt # documents it and the delete guard's error re-teaches it). # None are advertised. }, "required": ["operations"], }, } # --- Registry --- from tools.registry import registry, tool_error registry.register( name="skill_manage", toolset="skills", schema=SKILL_MANAGE_SCHEMA, handler=lambda args, **kw: _skill_manage_from( args, absorbed_into=args.get("absorbed_into"), operations=args.get("operations"), task_id=kw.get("task_id"), session_id=kw.get("session_id")), emoji="📝", )