"""Official optional-skill provenance: hub-lock backfill and restore. Profile-scoped paths and patchable helpers resolve through ``_ss()`` at call time so ``tools.skills_sync`` patches work.""" import json import logging from contextlib import suppress from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Dict, Iterator, List, Optional, Set, Tuple from agent.skill_utils import is_excluded_skill_path from utils import atomic_write_text logger = logging.getLogger("tools.skills_sync") def _ss(): """Live ``tools.skills_sync`` module (imported lazily: it imports helpers from this module).""" from tools import skills_sync return skills_sync def _content_hash(directory: Path) -> str: """Hub-lock hash style; provenance metadata only, so fall back to local MD5 sans guard deps.""" try: from tools.skills_guard import content_hash return content_hash(directory) except Exception: return _ss()._dir_hash(directory) def _safe_rel_install_path(path: Path, base: Path) -> str: """Normalized relative POSIX path; rejects traversal/absolute paths.""" pure = PurePosixPath(posix := path.relative_to(base).as_posix()) parts = [part for part in pure.parts if part not in {"", "."}] if pure.is_absolute() or not parts or ".." in parts: raise ValueError(f"Unsafe optional skill path: {posix}") return "/".join(parts) # Only generated runtime state, never generic cache/ or arbitrary dotfiles. This # is updater ownership, not the security scanner's content-integrity policy. _RUNTIME_CACHE_DIRS = frozenset({"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"}) def _is_runtime_cache(path: Path, skill_dir: Path) -> bool: """Whether a skill-relative path is disposable Python/tool runtime state. Install prefixes may themselves contain a cache directory name. Legacy sibling bytecode is ignored only alongside its source; source-less .pyc files can be deliberately shipped or user-owned content. """ relative = path.relative_to(skill_dir) if any(part in _RUNTIME_CACHE_DIRS for part in relative.parts[:-1]): return True if path.name in _RUNTIME_CACHE_DIRS and path.is_dir(): return True return path.suffix in {".pyc", ".pyo"} and path.with_suffix(".py").is_file() def _ignore_runtime_cache(directory: str, names: List[str]) -> List[str]: """copytree callback: don't seed generated caches into managed packages.""" root = Path(directory) return [name for name in names if _is_runtime_cache(root / name, root)] def _skill_file_list(skill_dir: Path) -> List[str]: """List package files for provenance/diff, excluding generated runtime caches.""" return [f.relative_to(skill_dir).as_posix() for f in sorted(skill_dir.rglob("*")) if not _is_runtime_cache(f, skill_dir) and f.is_file()] def _load_hub_lock() -> Optional[dict]: """Parse the skills-hub lock; None when missing or unreadable.""" try: return json.loads((_ss()._skills_dir() / ".hub" / "lock.json").read_text(encoding="utf-8")) except (FileNotFoundError, json.JSONDecodeError, OSError): return None def _hub_lock_entries(data: Optional[dict]) -> List[dict]: return [e for e in ((data or {}).get("installed") or {}).values() if isinstance(e, dict)] def _read_hub_install_paths() -> Set[str]: """Hub-lock install paths as POSIX strings. Hub-installed skills are owned by the hub: rename recovery must not move them even when content matches a bundled origin hash (dangling lock).""" return {str(e["install_path"]).strip("/") for e in _hub_lock_entries(_load_hub_lock()) if e.get("install_path")} def _iter_optional_skills(optional_dir: Path, *, root_relative: bool) -> Iterator[Tuple[Path, Path, str]]: """Yield ``(skill_md, src, install_path)`` for every safe official optional skill.""" for skill_md in sorted(optional_dir.rglob("SKILL.md")): if (is_excluded_skill_path(skill_md.relative_to(optional_dir), root=optional_dir) if root_relative else is_excluded_skill_path(skill_md)): continue try: yield skill_md, skill_md.parent, _safe_rel_install_path(skill_md.parent, optional_dir) except ValueError as e: logger.debug("Skipping optional skill with unsafe path %s: %s", skill_md.parent, e) def _optional_skill_index() -> Dict[str, Tuple[str, str, Path]]: """Official optional skills keyed by BOTH folder name and frontmatter name (hub-lock slug or user-facing name). Values are ``(folder_name, install_path, source_dir)``.""" ss = _ss() optional_dir = ss._get_optional_dir() index: Dict[str, Tuple[str, str, Path]] = {} if optional_dir.exists(): for skill_md, src, install_path in _iter_optional_skills(optional_dir, root_relative=True): value = (src.name, install_path, src) index[src.name] = index[ss._read_skill_name(skill_md, src.name)] = value return index def _move_to_restore_backup(path: Path, backup_root: Path) -> str: """Move an existing skill directory into a restore backup, preserving rel path.""" ss = _ss() rel = path.relative_to(ss._skills_dir()) target, suffix = backup_root / rel, 0 while target.exists(): suffix += 1 target = (backup_root / rel).with_name(f"{rel.name}-{suffix}") ss._move_dir(path, target) return rel.as_posix() def restore_official_optional_skill(name: str, *, restore: bool = False) -> dict: """Restore one or all official optional skills from repo source. ``restore=False`` only backfills exact-match provenance; ``restore=True`` also backs up matching active copies and copies the official source into its canonical path.""" ss = _ss() index = _optional_skill_index() if index and name in {"all", "*"}: targets = sorted(set(index.values()), key=lambda item: item[1]) elif name in index: targets = [index[name]] else: message = f"Official optional skill not found: {name}" if index else "No official optional skills directory found." return {"ok": False, "message": message, "restored": [], "backfilled": [], "backed_up": []} restored: List[str] = [] backed_up: List[str] = [] timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") backup_root = ss._skills_dir() / ".restore-backups" / f"official-optional-{timestamp}" for folder_name, install_path, src in targets if restore else []: dest = ss._skills_dir() / Path(*install_path.split("/")) canonical_ok = dest.exists() and ss._dir_hash(dest) == ss._dir_hash(src) # Active copies by frontmatter name or folder slug (the curator may have moved the skill # into another category); ``dest`` itself is handled below. names = {folder_name, ss._read_skill_name(src / "SKILL.md", folder_name)} for md in ss._iter_active_skill_mds(sort=True): match = md.parent if match != dest and match.exists() and ( match.name == folder_name or ss._read_skill_name(md, match.name) in names): backed_up.append(_move_to_restore_backup(match, backup_root)) if dest.exists() and not canonical_ok: backed_up.append(_move_to_restore_backup(dest, backup_root)) if not dest.exists(): ss._copy_dir(src, dest) restored.append(folder_name) return { "ok": True, "message": "Official optional skill repair complete.", "restored": restored, "backfilled": _backfill_optional_provenance(quiet=True), "backed_up": backed_up, "backup_dir": str(backup_root) if backed_up else ""} def _index_installed_skill_dirs_by_name() -> Dict[str, List[Path]]: """Installed skills by directory name in one active-tree scan, skipping anything that resolves outside the skills tree (symlinks/external).""" ss = _ss() index: Dict[str, List[Path]] = {} root = ss._skills_dir().resolve() for skill_md in ss._iter_active_skill_mds(): with suppress(OSError, ValueError): skill_md.parent.resolve().relative_to(root) index.setdefault(skill_md.parent.name, []).append(skill_md.parent) return index def _relocated_dest(src_name: str, index: Dict[str, List[Path]]) -> Optional[Tuple[Path, str]]: """``(dest, install_path)`` of a UNIQUE same-directory-name match, else None: the active tree may hold a skill under a DIFFERENT category than the repo (upstream reorganized, installed copy kept its place); ambiguity gives no basis to pick.""" if len(candidates := index.get(src_name, [])) != 1: return None try: return candidates[0], _safe_rel_install_path(candidates[0], _ss()._skills_dir()) except ValueError as e: logger.debug("Skipping relocated optional skill %s: %s", candidates[0], e) return None def _backfill_optional_provenance(quiet: bool = False) -> List[str]: """Mark already-present official optional skills as hub-installed: formerly bundled (or hand-copied) skills now under optional-skills/ get official provenance when byte-identical to the source; modified/local skills are left alone.""" ss = _ss() optional_dir = ss._get_optional_dir() if not optional_dir.exists(): return [] data = _load_hub_lock() if data is None: data = {"version": 1, "installed": {}} installed = data.setdefault("installed", {}) existing_paths = {entry.get("install_path") for entry in _hub_lock_entries(data)} backfilled: List[str] = [] installed_dir_index: Optional[Dict[str, List[Path]]] = None for _skill_md, src, install_path in _iter_optional_skills(optional_dir, root_relative=False): lock_name = src.name if lock_name in installed or install_path in existing_paths: continue dest = ss._skills_dir() / Path(*install_path.split("/")) if not dest.is_dir(): if installed_dir_index is None: installed_dir_index = _index_installed_skill_dirs_by_name() if (found := _relocated_dest(src.name, installed_dir_index)) is None: continue dest, install_path = found # still requires a byte-identical hash below if install_path in existing_paths or ss._dir_hash(dest) != ss._dir_hash(src): continue timestamp = datetime.now(timezone.utc).isoformat() installed[lock_name] = { "source": "official", "identifier": f"official/{install_path}", "trust_level": "builtin", "scan_verdict": "backfilled", "content_hash": _content_hash(dest), "install_path": install_path, "files": _skill_file_list(dest), "metadata": {"backfilled_from": "optional-skills"}, "installed_at": timestamp, "updated_at": timestamp} existing_paths.add(install_path) backfilled.append(lock_name) if not quiet: print(f" = {lock_name} (official optional provenance backfilled)") if backfilled: # atomic: a mid-write crash must not wipe provenance (reader resets bad JSON) atomic_write_text(ss._skills_dir() / ".hub" / "lock.json", tmp_prefix=".lock_", content=json.dumps(data, indent=2, ensure_ascii=False) + "\n") return backfilled