Files
market/scripts/gen-collection-children.py
Yige 8f7953948f fix: 补齐市场静态事实并保持旧客户端兼容 (#184)
## 中文

补齐已有 owner 与已锁定上游内容可证明的许可证、技能类型和收录版本。保留 Anthropic Source Available
和其他附加条件,不把所有内容统一标为 MIT,不提升 listing-only 的安装准入。

README 保留最新的双语结构及以 manifest 为准的数量口径,增加静态目录不存访问量、下载量、评分或 Stars 的边界。

发布兼容性:**本 PR 不包含**两支团队新增的本地化字段,也不包含微信支付 `latestVersion:
1.0`;旧客户端会拒绝这些新字段,另以待发布 PR 保留,等匹配客户端发布后再合并。

验证:47 项目录校验测试、19 项 i18n 测试、5 项合集生成测试通过;完整目录/i18n/翻译新鲜度检查通过,6
个固定来源合集的生成检查通过,2 个浮动来源按既有规则明确跳过。发现并修复了生成器在 Windows 使用反斜杠造成的误报:输出改为 POSIX
路径,比较规则不放宽、不重写合集数据。

## English

Fill only license, type and listed-release facts supported by existing
owners or pinned upstream content. Preserve custom license restrictions
and acquisition eligibility. Keep the current bilingual README and
manifest-derived inventory; do not author usage analytics.

This PR deliberately excludes localized team fields and the two-part
WeChat Pay version that older clients reject. Those changes remain
pending a compatible client release.

Validation passes: 47 catalog-validator tests, 19 i18n tests, five
collection-generator tests, complete catalog and translation checks, and
generated inventory checks for six pinned collections. Two mutable
sources remain explicitly skipped. Fix Windows-generated path separators
without weakening comparison or changing declared children.
2026-09-22 01:09:03 +08:00

251 lines
9.3 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["pyyaml>=6.0"]
# ///
"""Generate `children` for collection entries in skills/<id>/entry.json.
A collection entry points at an upstream repo that holds several sibling skills
(e.g. larksuite/cli has skills/lark-im, skills/lark-doc, ...). One market card
covers them all; the client lets the user tick which ones to install. That needs
a declared child list so the detail page renders without touching the network.
For each requested entry this script:
1. clones the pinned ref (shallow, falling back to fetch-by-SHA);
2. walks the tree for SKILL.md files, skipping test fixtures and vendored dirs;
3. reads frontmatter name/description/version;
4. writes `children` back into entry.json, leaving every other field untouched.
Usage:
python3 scripts/gen-collection-children.py # all entries that already declare children
python3 scripts/gen-collection-children.py larksuite-cli # specific ids
python3 scripts/gen-collection-children.py --check # verify without writing
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS_DIR = REPO_ROOT / "skills"
# Path segments that never hold a publishable skill: test fixtures for skill
# linters look exactly like real skills (larksuite/cli ships six of them under
# scripts/skill-format-check/tests/), so excluding them is not optional.
EXCLUDED_SEGMENTS = {
".git", "node_modules", "upstream", ".cache", "vendor", "internal",
"test", "tests", "__tests__", "testdata", "test-data",
"fixture", "fixtures", "__fixtures__",
"example", "examples", "template", "templates",
}
# Descriptions run to several hundred characters upstream; the picker only has
# room for one line, so keep the first sentence.
MAX_SHORT_DESC = 160
def run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess:
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
def clone_pinned(repo_url: str, branch: str, ref: str | None, dest: Path) -> None:
"""Clone at the pinned ref. Mirrors the client's cloneGitToTemp behaviour."""
result = run(["git", "clone", "--quiet", "--single-branch", "-b", branch,
"--depth", "100", repo_url, str(dest)])
if result.returncode != 0:
raise RuntimeError(f"clone failed: {result.stderr.strip()[:300]}")
if not ref:
return
if run(["git", "checkout", "--quiet", ref], cwd=dest).returncode == 0:
return
# Pinned commit fell outside the shallow window — fetch that object alone.
# Both failures carry stderr: "cannot fetch pinned ref <sha>" alone cannot
# tell a missing commit object from a permissions or network problem.
fetched = run(["git", "fetch", "--quiet", "origin", ref, "--depth", "1"], cwd=dest)
if fetched.returncode != 0:
raise RuntimeError(f"cannot fetch pinned ref {ref}: {fetched.stderr.strip()[:300]}")
checked_out = run(["git", "checkout", "--quiet", ref], cwd=dest)
if checked_out.returncode != 0:
raise RuntimeError(f"cannot check out pinned ref {ref}: {checked_out.stderr.strip()[:300]}")
def parse_frontmatter(path: Path) -> dict:
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {}
match = re.match(r"^---\n(.*?)\n---", text, re.S)
if not match:
return {}
try:
data = yaml.safe_load(match.group(1))
except yaml.YAMLError:
return {}
return data if isinstance(data, dict) else {}
def first_sentence(text: str) -> str:
flat = " ".join(str(text).split())
match = re.search(r"^(.{1,%d}?[。.!?!?])\s" % MAX_SHORT_DESC, flat + " ")
candidate = match.group(1) if match else flat
if len(candidate) > MAX_SHORT_DESC:
candidate = candidate[:MAX_SHORT_DESC].rstrip() + "…"
return candidate
def normalize_version(raw) -> str | None:
"""Mirror the client's normalizeSemver: upstream writes `1.0` and `v1.2.3`."""
if raw is None:
return None
core = str(raw).strip().lstrip("vV").split("-")[0].split("+")[0]
match = re.match(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$", core)
if not match:
return None
major, minor, patch = match.groups()
return f"{int(major)}.{int(minor or 0)}.{int(patch or 0)}"
def discover_children(repo_dir: Path) -> list[dict]:
children: list[dict] = []
seen: dict[str, str] = {}
# Convention first: when the repo has a top-level skills/ directory, that is
# the published set. Scanning the whole tree instead picks up decoys such as
# larksuite/cli's internal/qualitygate/skillscan/testdata/skills/lark-demo,
# which is a linter fixture, not a shippable skill.
scan_root = repo_dir / "skills" if (repo_dir / "skills").is_dir() else repo_dir
for skill_md in sorted(scan_root.rglob("SKILL.md")):
rel = skill_md.relative_to(repo_dir)
segments = [s.lower() for s in rel.parts[:-1]]
if any(seg in EXCLUDED_SEGMENTS for seg in segments):
continue
if not segments:
continue # repo root itself is a single skill, not a collection
child_id = rel.parts[-2]
if not re.match(r"^[a-z0-9-]+$", child_id):
print(f" ! skipped {rel.parent}: directory name is not a valid skill id")
continue
if child_id in seen:
print(f" ! skipped {rel.parent}: id '{child_id}' already taken by {seen[child_id]}")
continue
seen[child_id] = str(rel.parent)
fm = parse_frontmatter(skill_md)
# Git catalog paths use POSIX separators on every contributor platform.
child: dict = {"id": child_id, "path": rel.parent.as_posix()}
name = fm.get("display_name") or fm.get("name")
if name and str(name) != child_id:
child["name"] = str(name)
version = normalize_version(fm.get("version"))
if version:
child["version"] = version
description = fm.get("description")
if description:
child["i18n"] = {
"zh-CN": {"shortDesc": first_sentence(description)},
"en-US": {"shortDesc": first_sentence(description)},
}
children.append(child)
return children
def process(entry_id: str, *, check: bool = False) -> bool:
entry_path = SKILLS_DIR / entry_id / "entry.json"
if not entry_path.exists():
print(f"{entry_id}: no entry.json")
return False
entry = json.loads(entry_path.read_text(encoding="utf-8"))
source = entry.get("source", {})
if source.get("kind") != "git":
print(f"{entry_id}: only git sources can be collections (kind={source.get('kind')})")
return False
if check and source.get("path") is not None:
print(f"{entry_id}: stale — collection source.path must be omitted")
return False
if check and not source.get("ref"):
print(
f"{entry_id}: SKIP — source.ref is not pinned; "
"a mutable source cannot have a deterministic generated-output check"
)
return True
print(f"{entry_id}: cloning {source['repoUrl']} …")
with tempfile.TemporaryDirectory(prefix=f"collection-{entry_id}-") as tmp:
repo_dir = Path(tmp) / "repo"
try:
clone_pinned(source["repoUrl"], source.get("repoBranch", "main"), source.get("ref"), repo_dir)
except RuntimeError as err:
print(f"{entry_id}: {err}")
return False
children = discover_children(repo_dir)
if not children:
print(f"{entry_id}: no sub-skills found — not a collection?")
return False
if check:
if entry.get("children") != children:
print(
f"{entry_id}: stale — declared children differ from the "
f"{len(children)} children discovered at the pinned source"
)
return False
print(f"{entry_id}: OK ({len(children)} children)")
return True
entry.pop("children", None)
entry["children"] = children
# source.path is mutually exclusive with children (the client rejects both).
entry.get("source", {}).pop("path", None)
entry_path.write_text(json.dumps(entry, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"{entry_id}: wrote {len(children)} children")
return True
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="compare generated children with entry.json without writing files",
)
parser.add_argument("ids", nargs="*", help="collection entry IDs (default: every declared collection)")
args = parser.parse_args(argv)
ids = args.ids
if not ids:
ids = sorted(
p.parent.name
for p in SKILLS_DIR.glob("*/entry.json")
if "children" in json.loads(p.read_text(encoding="utf-8"))
)
if not ids:
print("no collection entries found; pass ids explicitly")
return 1
failed = [entry_id for entry_id in ids if not process(entry_id, check=args.check)]
if failed:
print(f"\nfailed: {', '.join(failed)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))