数据截至 (上游 commit 69ef37e9424c)
第 2 章 · 参考库 skills-ref 逐文件走读
本章把规范“翻译成代码”看一遍。
skills-ref是个故意做得很小的 Python 库(README 标明仅供演示,skills-ref/README.md:5-6),它把第 1 章的规范落成三个动作:解析 → 校验 → 生成 prompt。模块都在skills-ref/src/skills_ref/,依赖只有click(CLI)和strictyaml(YAML)(skills-ref/pyproject.toml:11-14)。
2.0 模块全景
6 个源文件,职责清晰(skills-ref/src/skills_ref/):
| 文件 | 职责 |
|---|---|
models.py | 数据类 SkillProperties:解析结果的结构 |
parser.py | 找到并解析 SKILL.md,抽出 frontmatter |
validator.py | 校验 frontmatter 是否合规 |
prompt.py | 把多个 skill 拼成 <available_skills> XML |
errors.py | 异常层级:SkillError → ParseError / ValidationError |
cli.py | validate / read-properties / to-prompt 三个子命令 |
一条数据流(从磁盘到模型):
skill 目录 产物
┌──────────┐ find_skill_md ┌───────────────┐ read_properties ┌─────────────────┐
│ SKILL.md │ ───────────────▶ │ parse_ │ ────────────────▶ │ SkillProperties │
└──────────┘ (大小写兜底) │ frontmatter │ │ (name/desc/...) │
└───────┬────────┘ └────────┬────────┘
│ metadata dict │
▼ ▼
validate_metadata to_prompt
(返回 error 列表) <available_skills> XML
2.1 models.py:解析结果的形状
SkillProperties 是个 dataclass,把 frontmatter 的字段一一对应(skills-ref/src/skills_ref/models.py:7-26)。两个必填 + 四个可选,metadata 默认空 dict。
一个细节值得看:to_dict() 序列化时剔除 None,且 allowed_tools 这个 Python 属性名要还原成带连字符的 allowed-tools key(skills-ref/src/skills_ref/models.py:28-39):
# 示意,非源码:to_dict 的核心逻辑
result = {"name": self.name, "description": self.description}
if self.allowed_tools is not None:
result["allowed-tools"] = self.allowed_tools # 注意 key 改回连字符
if self.metadata: # 空 dict 也省略
result["metadata"] = self.metadata
重点看:Python 标识符不能带连字符,所以内部叫 allowed_tools,输出 JSON 时手动改回 allowed-tools,保证和规范字段名一致。
2.2 parser.py:抽 frontmatter,为什么手切 ---
先找文件:大小写兜底
find_skill_md 优先 SKILL.md,但也接受小写 skill.md(skills-ref/src/skills_ref/parser.py:12-27)。这是对现实世界文件命名差异的容忍。
再切 frontmatter:split("---", 2)
核心是 parse_frontmatter(skills-ref/src/skills_ref/parser.py:30-64)。它不用通用的 frontmatter 库,而是手切:
# 示意,非源码:frontmatter 切分思路
if not content.startswith("---"):
raise ParseError("必须以 YAML frontmatter(---)开头")
parts = content.split("---", 2) # 最多切 3 段:''、yaml、body
if len(parts) < 3:
raise ParseError("frontmatter 没用 --- 正确闭合")
frontmatter_str, body = parts[1], parts[2].strip()
重点看:split("---", 2) 的第三个参数限制只切 2 刀,所以正文里再出现 --- 不会被误判——这是个简单但关键的健壮性处理。
解析 YAML:为什么是 strictyaml
切出 YAML 串后交给 strictyaml.load(...)(skills-ref/src/skills_ref/parser.py:52-56)。strictyaml 是 YAML 的一个严格子集实现,它默认把标量当字符串、禁用 YAML 那些“惊吓特性”(如 yes/no 被解析成布尔)。对 skill 这种“元数据基本都是字符串”的场景很合适。
解析后还做了一步:把 metadata 子映射的所有 key/value 强制转成字符串(skills-ref/src/skills_ref/parser.py:61-62),对应规范里“metadata 是 string→string 映射”的约定。
read_properties:解析 ≠ 校验
read_properties 把上面拼起来,产出 SkillProperties(skills-ref/src/skills_ref/parser.py:67-112)。它的文档串特意说明:它只做解析,不做完整校验(:71)——只检查 name/description 存在且非空,其余交给 validate()。这种“解析和校验分离”是个干净的设计:你可以先廉价读出属性,再决定要不要跑完整校验。
2.3 validator.py:命名规则,以及 i18n 的巧思
校验入口 validate(skill_dir)(skills-ref/src/skills_ref/validator.py:150-177)按顺序:确认路径存在且是目录 → 找到 SKILL.md → 解析 → 调 validate_metadata。返回一个错误字符串列表(空列表 = 合法),而不是抛异常——便于一次报告所有问题。
只允 许已知字段
_validate_metadata_fields 把出现的 key 和白名单 ALLOWED_FIELDS 做差集,有多余字段就报错(skills-ref/src/skills_ref/validator.py:104-115、白名单见 :15-22)。这意味着写错字段名(如 descriptions)会被当成“未知字段”拦下。
name 校验:i18n + NFKC 是亮点
_validate_name(skills-ref/src/skills_ref/validator.py:25-67)把第 1 章那些 name 规则逐条实现。最有意思的两点:
第一,先做 NFKC 归一化再比较(:37):
# 示意,非源码:为什么要归一化
name = unicodedata.normalize("NFKC", name.strip())
重点看:café 可以用“预组合的 é(U+00E9)”或“e + 组合重音(U+0301)”两种字节表示。目录名和 SKILL.md 里若用了不同写法,不归一化就会“看起来一样却不相等”。NFKC 把两者都规整到同一形式,目录名匹配才稳。测试 test_nfkc_normalization 专门验证了这点(skills-ref/tests/test_validator.py:267-290)。
第二,“小写”和“字母数字”都走 Unicode 而非 ASCII(:45-54):
# 示意,非源码:i18n 友好的两条判断
if name != name.lower(): # Unicode 大小写,不止 a-z
errors.append("name 必须小写")
if not all(c.isalnum() or c == "-" for c in name): # str.isalnum 认识中文/俄文
errors.append("含非法字符")
所以 技能(中文)、мой-навык(俄文带连字符)、навык(俄文小写)都合法,而 НАВЫК(俄文大写)被拒。这一组行为有专门测试(skills-ref/tests/test_validator.py:165-218)。
目录名必须等于 name
校验末尾把 skill_dir.name(同样 NFKC 归一化)和 name 比对,不等就报错(skills-ref/src/skills_ref/validator.py:60-65)。对应规范的硬约束;测试见 test_name_directory_mismatch(skills-ref/tests/test_validator.py:107-117)。
长度上限
三个常量写死在文件顶部(skills-ref/src/skills_ref/validator.py:10-12):name 64、description 1024、compatibility 500——和规范表格完全一致。
2.4 prompt.py:拼 <available_skills>
to_prompt(skill_dirs) 把一组 skill 拼成喂给模型的 XML(skills-ref/src/skills_ref/prompt.py:9-58)。每个 skill 输出三段:<name>、<description>、<location>(绝对路径指向 SKILL.md)。
两个细节:
- 空输入兜底:没有 skill 时直接返回空的
<available_skills></available_skills>(:32-33),避免畸形输出。 - HTML 转义:
name和description都过html.escape(:46、:50),防止描述里的<、&破坏 XML 结构。<location>是自己生成的路径,没转义。
库注释明确:这个 XML 格式是 Anthropic 推荐给 Claude 模型的,其他客户端可以按自己模型偏好换格式(skills-ref/src/skills_ref/prompt.py:11-13)。也就是说——XML 不是规范强制,只是参考实现的默认。
2.5 cli.py:三个子命令
基于 click 的命令组(skills-ref/src/skills_ref/cli.py):
| 命令 | 干什么 | 退出码约定 |
|---|---|---|
validate <path> | 校验 skill,打印错误 | 0 合法 / 1 有错(:27-50) |
read-properties <path> | 解析并打印 JSON | 0 成功 / 1 解析错(:53-73) |
to-prompt <path...> | 生成 <available_skills> | 0 成功 / 1 错误(:76-101) |
一个贴心处理 _is_skill_md_file:如果用户直接传了 .../SKILL.md 文件而非目录,自动取其父目录(skills-ref/src/skills_ref/cli.py:15-17,各命令开头都调它)。
2.6 代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| 解析结果数据类 | skills-ref/src/skills_ref/models.py | SkillProperties、to_dict |
| 找文件(大小写兜底) | skills-ref/src/skills_ref/parser.py | find_skill_md |
| 切 + 解析 frontmatter | skills-ref/src/skills_ref/parser.py | parse_frontmatter |
| 解析(不校验) | skills-ref/src/skills_ref/parser.py | read_properties |
| 校验入口 | skills-ref/src/skills_ref/validator.py | validate、validate_metadata |
| name 校验 + NFKC | skills-ref/src/skills_ref/validator.py | _validate_name |
| 字段白名单 | skills-ref/src/skills_ref/validator.py | ALLOWED_FIELDS |
| 长度常量 | skills-ref/src/skills_ref/validator.py | MAX_SKILL_NAME_LENGTH 等 |
| 拼 prompt XML | skills-ref/src/skills_ref/prompt.py | to_prompt |
| CLI 命令 | skills-ref/src/skills_ref/cli.py | validate_cmd、to_prompt_cmd |
| i18n / NFKC 测试 | skills-ref/tests/test_validator.py | test_i18n_chinese_name、test_nfkc_normalization |