125 lines
4.1 KiB
Python
125 lines
4.1 KiB
Python
"""命名 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
|