mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-09-05 20:43:46 +08:00
## 中文 - 为 System Agent、34 个 Builtin Skill、28 个 Pointer/Collection 条目增加 catalog sidecar - 覆盖 147 个 collection child,并固定可证明的来源;无法证明的内容保持 listing-only/unknown - 增加 strict Schema、validator、collection check 与 CI 完整性门禁 ## English - Add catalog metadata sidecars for the System Agent, 34 built-in Skills, and 28 pointer/collection entries - Cover 147 collection children while keeping unverifiable facts listing-only or unknown - Add strict schemas, validators, deterministic collection checks, and CI completeness gates ## 验证 / Verification - Catalog validator 17/17 - Collection generator 4/4 - 63 sidecars, 147 children, zero errors
250 lines
9.3 KiB
Python
Executable File
250 lines
9.3 KiB
Python
Executable File
#!/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)
|
||
child: dict = {"id": child_id, "path": str(rel.parent)}
|
||
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:]))
|