fix(skills): 修复 14 个 pointer 条目安装失败 + 7 个合集声明 children (#81)

## 背景 / Background

市场里 25 个 pointer 条目中有 **14 个点安装必然失败**,客户端返回 502「源仓库中未找到 SKILL.md(pointer
指向有误)」。

根因是 `entry.json` 的 `source` 指错:客户端按 `source.path` 定位内容(缺省则取仓库根),而这些条目的
`path` 缺失或 URL 指向了 HTML 页面,`contentDir/SKILL.md` 自然不存在。

Of the 25 pointer entries, **14 always failed to install** with a 502.
The `source` pointers were wrong: `path` was missing (so the client
looked at the repo root) or `repoUrl` pointed at an HTML page instead of
the content.

## 改动 / Changes

### 1. 补 `source.path`(5 个)

| 条目 | path | 上游 SKILL.md 实际位置 |
|---|---|---|
| `impeccable` | `plugin/skills/impeccable` | 根目录没有;`plugin/` 与
`.claude/` 两份内容逐字节一致,取中立的分发目录 |
| `ai-news-radar` | `skills/ai-news-radar` | 仓库另有 `skills/radar`,取与条目 id
对应的 |
| `taste-skill` | `skills/taste-skill` | 同名子目录 |
| `wechatpay-skills` | `wechatpay-payment-integration` | 仓库另有
`wechatpay-product-coupon` 未覆盖,见下方遗留项 |
| `luckin-my-coffee` | `my-coffee` | zip 内有一层顶层目录 |

### 2. 修正 web 类条目的源地址(2 个)

`amap-jsapi-skill` / `dingtalk-api` 原先指向 ClawHub 的**网页**(`content-type:
text/html`),客户端只在 markdown 时才写成 `SKILL.md`,HTML 会落成 `index.html`,校验必失败。

- `amap-jsapi-skill` → 改用 ClawHub 文件 API(返回 `text/markdown`)。该技能在
ClawHub 的发布包 `version.files` 只有一个 `SKILL.md`,单文件抓取正是对的形态。
- `dingtalk-api` → ClawHub 包有 38 个文件(`scripts/*.ts` 等),单文件抓不全,改指内容完整的
GitHub 上游 `ogenes/dingtalk-api` 并锁 ref。

### 3. 合集条目声明 `children`(7 个,147 个子技能)

这 7 个的上游是「一个仓库装着 N 个平级技能」,没有单一 SKILL.md 可指,靠改 `path`
修不了。配合客户端新增的合集能力(desirecore 主仓库 PR),`entry.json`
现在可声明子技能清单,市场仍是一个条目,安装时由用户勾选装哪几个。

| 条目 | 子技能数 |
|---|---|
| `marketingskills` | 49 |
| `mattpocock-skills` | 35 |
| `larksuite-cli` | 27 |
| `baoyu-skills` | 21 |
| `wecom-cli` | 7 |
| `khazix-skills` | 5 |
| `netease-skills` | 3 |

新增 `scripts/gen-collection-children.py`:按 `entry.json` 锁定的 ref
克隆上游、扫描子技能、读 frontmatter 生成 `children`。约定「仓库有顶层 `skills/` 就只扫它」——否则会收录
`larksuite/cli` 的 linter 测试夹具
`internal/qualitygate/skillscan/testdata/skills/lark-demo`。

### 4. `netease-skills` 分支修正

`repoBranch` 写的是 `main`,上游默认分支是 `master`,此前 clone 必然失败(`Could not find
main`)。

### 5. 版本号

`manifest.json` `1.2.24` → `1.2.25`。

## 验证 / Verification

- `uv run scripts/i18n/validate-i18n.py` — OK
- `uv run scripts/i18n/validate-i18n.py --online` — OK
- **真实运行的 agent-service 上逐个安装**(独立 home + standalone 服务,非模拟):18 个非合集
pointer 条目 **18/18 成功**并完整落盘;7 个合集条目 **7/7 成功**,并验证了部分安装、追加、取消勾选卸载、装到
Agent 私有目录。

## 遗留项 / Known gaps

- `wechatpay-skills` 只覆盖了 `wechatpay-payment-integration`,同仓库的
`wechatpay-product-coupon` 未上架。条目名是复数,后续可拆成两条或改成合集。
- `children[].i18n` 目前只有 `shortDesc`(取自上游
description),**没有中文名**——子技能名沿用上游 id(如 `lark-approval`),以保证与上游文档、Agent
加载路径一致。宝玉 / Matt Pocock / Marketing 三个合集的描述本身就是英文。如需中文化,可接
`i18n-translate.yml` 覆盖 `children` 字段。
- 在线校验目前只查 URL 可达性,**查不出 `path` 指向的目录里有没有 SKILL.md**——这 14
个坏条目当初就是这么一路绿灯合进主干的。建议后续给 `--online` 加这条断言。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: xieyuanxiang <xieyuanxiang123@gmail.com>
This commit is contained in:
2026-08-09 16:25:20 +08:00
committed by GitHub
parent b1f0719d40
commit 27589d013d
16 changed files with 2073 additions and 16 deletions

View File

@@ -0,0 +1,219 @@
#!/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
"""
from __future__ import annotations
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) -> 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
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
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() -> int:
ids = sys.argv[1:]
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)]
if failed:
print(f"\nfailed: {', '.join(failed)}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())