134 lines
5.6 KiB
Python
134 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
||
"""Validate the distribution repository and its plugin manifest."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||
SOURCES_PATH = REPO_ROOT / ".dist-sync" / "sources.json"
|
||
VERSIONS_PATH = REPO_ROOT / ".dist-sync" / "versions.json"
|
||
MARKETPLACE_PATH = REPO_ROOT / ".claude-plugin" / "marketplace.json"
|
||
|
||
|
||
class ValidationError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def read_json(path: Path) -> Any:
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, json.JSONDecodeError) as error:
|
||
raise ValidationError(f"无法读取 JSON:{path}: {error}") from error
|
||
|
||
|
||
def frontmatter_name(skill_file: Path) -> str:
|
||
try:
|
||
lines = skill_file.read_text(encoding="utf-8-sig").splitlines()
|
||
except (OSError, UnicodeDecodeError) as error:
|
||
raise ValidationError(f"无法读取 {skill_file}: {error}") from error
|
||
if not lines or lines[0].strip() != "---":
|
||
raise ValidationError(f"SKILL.md 缺少 YAML frontmatter:{skill_file}")
|
||
for line in lines[1:]:
|
||
if line.strip() == "---":
|
||
break
|
||
if line.startswith("name:"):
|
||
return line.split(":", 1)[1].strip().strip("'\"")
|
||
raise ValidationError(f"SKILL.md frontmatter 缺少 name:{skill_file}")
|
||
|
||
|
||
def main() -> int:
|
||
sources_doc = read_json(SOURCES_PATH)
|
||
sources = sources_doc.get("sources") if isinstance(sources_doc, dict) else None
|
||
if not isinstance(sources, list) or not sources:
|
||
raise ValidationError("sources.json 必须包含非空 sources 数组")
|
||
|
||
allowlisted: set[str] = set()
|
||
for source in sources:
|
||
if not isinstance(source, dict) or not all(
|
||
isinstance(source.get(field), str) and source[field] for field in ("name", "repository", "asset")
|
||
):
|
||
raise ValidationError("sources.json 包含无效来源")
|
||
name = source["name"]
|
||
if name in allowlisted:
|
||
raise ValidationError(f"sources.json 包含重复名称:{name}")
|
||
allowlisted.add(name)
|
||
|
||
versions = read_json(VERSIONS_PATH)
|
||
if not isinstance(versions, dict) or not all(
|
||
isinstance(name, str) and isinstance(tag, str) and tag for name, tag in versions.items()
|
||
):
|
||
raise ValidationError("versions.json 必须是非空 tag 字符串映射")
|
||
unknown_versions = set(versions) - allowlisted
|
||
if unknown_versions:
|
||
raise ValidationError(f"versions.json 包含未列入同步白名单的 skill:{sorted(unknown_versions)}")
|
||
|
||
marketplace = read_json(MARKETPLACE_PATH)
|
||
plugins = marketplace.get("plugins") if isinstance(marketplace, dict) else None
|
||
if not isinstance(plugins, list) or not plugins:
|
||
raise ValidationError("marketplace.json 必须包含非空 plugins 数组")
|
||
|
||
manifest_names: list[str] = []
|
||
manifest_paths: set[Path] = set()
|
||
for plugin in plugins:
|
||
skills = plugin.get("skills") if isinstance(plugin, dict) else None
|
||
if not isinstance(skills, list) or not skills:
|
||
raise ValidationError("marketplace.json 的每个 plugin 都必须列出 skills")
|
||
for raw_path in skills:
|
||
if not isinstance(raw_path, str) or not raw_path.startswith("./skills/"):
|
||
raise ValidationError(f"无效 marketplace skill 路径:{raw_path!r}")
|
||
relative = Path(raw_path[2:])
|
||
skill_dir = (REPO_ROOT / relative).resolve()
|
||
skills_root = (REPO_ROOT / "skills").resolve()
|
||
if skill_dir.parent != skills_root:
|
||
raise ValidationError(f"skill 必须直接位于 skills/<name>:{raw_path}")
|
||
if skill_dir in manifest_paths:
|
||
raise ValidationError(f"marketplace.json 重复列出路径:{raw_path}")
|
||
manifest_paths.add(skill_dir)
|
||
|
||
skill_file = skill_dir / "SKILL.md"
|
||
if not skill_file.is_file():
|
||
raise ValidationError(f"marketplace 路径缺少 SKILL.md:{raw_path}")
|
||
actual_name = frontmatter_name(skill_file)
|
||
if actual_name != skill_dir.name:
|
||
raise ValidationError(
|
||
f"{raw_path}/SKILL.md 的 name 为 {actual_name!r},目录名为 {skill_dir.name!r}"
|
||
)
|
||
manifest_names.append(actual_name)
|
||
|
||
if len(manifest_names) != len(set(manifest_names)):
|
||
raise ValidationError("marketplace.json 中存在重复的 skill name")
|
||
|
||
disk_dirs = {
|
||
path.resolve()
|
||
for path in (REPO_ROOT / "skills").iterdir()
|
||
if path.is_dir() and not path.name.startswith(".")
|
||
}
|
||
unlisted = disk_dirs - manifest_paths
|
||
missing = manifest_paths - disk_dirs
|
||
if unlisted:
|
||
raise ValidationError(f"skills/ 中存在未列入 marketplace 的目录:{sorted(path.name for path in unlisted)}")
|
||
if missing:
|
||
raise ValidationError(f"marketplace 指向不存在的目录:{sorted(path.name for path in missing)}")
|
||
|
||
for skill_dir in sorted(manifest_paths):
|
||
for path in skill_dir.rglob("*"):
|
||
if not path.is_file() or path.stat().st_size > 1024:
|
||
continue
|
||
if path.read_bytes().startswith(b"version https://git-lfs.github.com/spec/v1"):
|
||
raise ValidationError(f"发现 Git LFS pointer,分发副本缺少真实文件:{path.relative_to(REPO_ROOT)}")
|
||
|
||
print(f"分发仓校验通过:{len(manifest_names)} 个 skill,{len(allowlisted)} 个 Release 同步来源")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
raise SystemExit(main())
|
||
except ValidationError as error:
|
||
print(f"校验失败:{error}", file=sys.stderr)
|
||
raise SystemExit(1)
|