from __future__ import annotations import shutil import uuid from pathlib import Path from ..models import Patch from ..storage import atomic_write_text, sha256_file def _validate_skill_text(text: str) -> None: if not text.strip(): raise ValueError("SKILL.md is empty") if text.startswith("---"): end = text.find("\n---", 3) if end < 0: raise ValueError("SKILL.md has an unterminated YAML frontmatter") frontmatter = text[3:end].strip() try: import yaml parsed = yaml.safe_load(frontmatter) if frontmatter else {} except Exception as exc: raise ValueError(f"invalid SKILL.md frontmatter: {exc}") from exc if parsed is not None and not isinstance(parsed, dict): raise ValueError("SKILL.md frontmatter must be a mapping") def apply_patches( current_package: Path, candidate_package: Path, patches: list[Patch] ) -> str: skill = current_package / "SKILL.md" if not skill.is_file(): raise ValueError(f"missing {skill}") if not patches: raise ValueError("at least one patch is required") actual_hash = sha256_file(skill) text = skill.read_text(encoding="utf-8") for index, patch in enumerate(patches, start=1): if not patch.skill_hash: raise ValueError(f"patch {index} missing generation-time skill_hash") if actual_hash != patch.skill_hash: raise ValueError( f"patch {index} was not generated from the current SKILL.md" ) if not patch.old_text: raise ValueError(f"patch {index} old_text must not be empty") matches = text.count(patch.old_text) if matches != 1: raise ValueError( f"patch {index} old_text must match exactly once; found {matches}" ) changed = text.replace(patch.old_text, patch.new_text, 1) if changed == text: raise ValueError(f"patch {index} does not change SKILL.md") _validate_skill_text(changed) text = changed token = uuid.uuid4().hex temporary = candidate_package.with_name(f".{candidate_package.name}.{token}.tmp") backup = candidate_package.with_name(f".{candidate_package.name}.{token}.bak") shutil.copytree(current_package, temporary) try: atomic_write_text(temporary / "SKILL.md", text) if candidate_package.exists(): candidate_package.rename(backup) try: temporary.rename(candidate_package) except Exception: if backup.exists() and not candidate_package.exists(): backup.rename(candidate_package) raise finally: if temporary.exists(): shutil.rmtree(temporary) if backup.exists() and candidate_package.exists(): shutil.rmtree(backup) return sha256_file(candidate_package / "SKILL.md")