Files
skills/scripts/check_skills.py
T
2026-10-14 00:33:28 -07:00

115 lines
4.3 KiB
Python
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.
"""检查仓库里的每个技能(仅用标准库)。
用法:
python scripts/check_skills.py # 检查所有顶层技能目录(含 SKILL.md 的目录)
python scripts/check_skills.py bugfix # 只检查指定技能
检查项: frontmatter 存在; name 与目录名一致且符合命名规则; description 非空且 <= 1024 字符;
所有 .md 中的相对链接可解析; SKILL.md 正文 < 500 行、约 < 5000 tokens(超出只警告)。
完整的规范校验请再运行官方校验器: agentskills validate <技能目录>(pip install skills-ref)。
"""
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
LINK_RE = re.compile(r"\]\(([^)#\s]+)(?:#[^)]*)?\)")
def frontmatter(text):
if not text.startswith("---"):
return None, text
parts = text.split("---", 2)
if len(parts) < 3:
return None, text
return parts[1], parts[2]
def scalar(block, key):
"""读取顶层 key 的值,支持单行、引号、以及 >- / | 多行写法。"""
lines = block.splitlines()
for i, line in enumerate(lines):
m = re.match(rf"^{key}:\s*(.*)$", line)
if not m:
continue
value = m.group(1).strip()
if value in (">", ">-", "|", "|-", ""):
body = []
for nxt in lines[i + 1:]:
if nxt and not nxt.startswith((" ", "\t")):
break
body.append(nxt.strip())
joiner = " " if value.startswith(">") or value == "" else "\n"
return joiner.join(b for b in body if b).strip()
return value.strip("'\"")
return None
def check(skill_dir):
errors, warnings = [], []
skill_md = skill_dir / "SKILL.md"
text = skill_md.read_text(encoding="utf-8")
fm, body = frontmatter(text)
if fm is None:
return ["SKILL.md 缺少 YAML frontmatter(--- ... ---)"], warnings
name = scalar(fm, "name")
if name != skill_dir.name:
errors.append(f"name '{name}' 必须与目录名 '{skill_dir.name}' 一致")
if not name or not NAME_RE.match(name) or len(name) > 64:
errors.append(f"name '{name}' 不合规:只能用小写字母/数字/单个连字符,且 <= 64 字符")
desc = scalar(fm, "description")
if not desc:
errors.append("description 为空")
elif len(desc) > 1024:
errors.append(f"description 长度 {len(desc)} > 1024")
for md in sorted(skill_dir.rglob("*.md")):
for target in LINK_RE.findall(md.read_text(encoding="utf-8")):
if target.startswith(("http://", "https://", "mailto:")):
continue
if not (md.parent / target).resolve().exists():
errors.append(f"断链: {md.relative_to(ROOT)} -> {target}")
body_lines = body.count("\n")
body_tokens = len(body) // 4
if body_lines >= 500:
warnings.append(f"SKILL.md 正文 {body_lines} 行(建议 < 500,细节移到 references/)")
if body_tokens >= 5000:
warnings.append(f"SKILL.md 正文约 {body_tokens} tokens(建议 < 5000)")
return errors, warnings + [f"正文 {body_lines} 行,约 {body_tokens} tokens"]
def main(argv):
# Windows 下被管道/重定向时 stdout 可能是 cp1252/GBK,中文会抛 UnicodeEncodeError
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
if argv:
dirs = [ROOT / a for a in argv]
else:
dirs = sorted(p for p in ROOT.iterdir() if p.is_dir() and (p / "SKILL.md").is_file())
if not dirs:
print("没有找到技能(顶层目录中没有 SKILL.md)")
return 1
failed = 0
for d in dirs:
if not (d / "SKILL.md").is_file():
print(f"[失败] {d.name}: 没有 SKILL.md")
failed += 1
continue
errors, notes = check(d)
print(f"[{'失败' if errors else '通过'}] {d.name}")
for e in errors:
print(f" 错误: {e}")
for n in notes:
print(f" 提示: {n}")
failed += bool(errors)
print(f"\n共 {len(dirs)} 个技能,{failed} 个失败")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))