"""命名 custom skill source 的配置、发现与 Git 管理。""" from __future__ import annotations import subprocess from pathlib import Path from typing import Any from skiff import yaml_io from skiff.paths import CONFIG_FILE, SOURCES_DIR RESERVED_SOURCES = {"builtin", "catalog", "owned", "registry"} def validate_source_name(name: str) -> None: from skiff.skills import validate_skill_name validate_skill_name(name) if name in RESERVED_SOURCES: raise SystemExit(f"source 名称为保留字: {name}") def load_sources(path: Path | None = None) -> dict[str, dict[str, Any]]: path = path or CONFIG_FILE if not path.is_file(): return {} data = yaml_io.safe_load(path.read_text(encoding="utf-8")) or {} if not isinstance(data, dict): raise SystemExit(f"skiff 配置格式错误: {path}") raw = data.get("sources", {}) if raw is None: return {} if not isinstance(raw, dict): raise SystemExit(f"skiff 配置 sources 格式错误: {path}") return {str(name): entry for name, entry in raw.items() if isinstance(entry, dict)} def save_sources(sources: dict[str, dict[str, Any]], path: Path | None = None) -> None: path = path or CONFIG_FILE path.parent.mkdir(parents=True, exist_ok=True) path.write_text( yaml_io.safe_dump({"sources": sources}, allow_unicode=True, sort_keys=False), encoding="utf-8", ) def source_checkout(name: str, entry: dict[str, Any]) -> Path: local_path = entry.get("local_path") if local_path: return Path(str(local_path)).expanduser().resolve() checkout = entry.get("checkout") if checkout: return Path(str(checkout)).expanduser().resolve() return (SOURCES_DIR / name).resolve() def source_skills_root(name: str, entry: dict[str, Any]) -> Path: checkout = source_checkout(name, entry) raw_subpath = str(entry.get("skills_path", "skills") or "skills") subpath = Path(raw_subpath) if subpath.is_absolute(): raise SystemExit(f"source {name!r} 的 skills_path 必须是仓库内相对路径") root = (checkout / subpath).resolve() try: root.relative_to(checkout) except ValueError: raise SystemExit(f"source {name!r} 的 skills_path 不能超出仓库目录") from None return root def discover_source_skills(name: str, entry: dict[str, Any]) -> dict[str, Path]: root = source_skills_root(name, entry) if (root / "SKILL.md").is_file(): return {name: root} if not root.is_dir(): return {} return { item.name: item for item in sorted(root.iterdir()) if item.is_dir() and not item.name.startswith("_") and (item / "SKILL.md").is_file() } def list_source_skills(name: str, entry: dict[str, Any]) -> list[str]: return list(discover_source_skills(name, entry)) def fetch_source(name: str, entry: dict[str, Any]) -> Path: if entry.get("local_path"): checkout = source_checkout(name, entry) if not checkout.is_dir(): raise SystemExit(f"source {name!r} 的本地仓库不存在: {checkout}") return checkout repo = entry.get("repo") if not repo: raise SystemExit(f"source {name!r} 缺少 repo 或 local_path") checkout = source_checkout(name, entry) ref = str(entry.get("ref", "main") or "main") if checkout.exists(): if not (checkout / ".git").exists(): raise SystemExit(f"source checkout 已存在但不是 Git 仓库: {checkout}") subprocess.run(["git", "-C", str(checkout), "fetch", "--all", "--tags"], check=True) subprocess.run(["git", "-C", str(checkout), "checkout", ref], check=True) subprocess.run(["git", "-C", str(checkout), "pull", "--ff-only"], check=True) else: checkout.parent.mkdir(parents=True, exist_ok=True) subprocess.run( [ "git", "clone", "--depth", "1", "--branch", ref, "--", str(repo), str(checkout), ], check=True, ) return checkout