跳到主要内容

第 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.pyvalidate / 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 转义:namedescription 都过 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>解析并打印 JSON0 成功 / 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.pySkillPropertiesto_dict
找文件(大小写兜底)skills-ref/src/skills_ref/parser.pyfind_skill_md
切 + 解析 frontmatterskills-ref/src/skills_ref/parser.pyparse_frontmatter
解析(不校验)skills-ref/src/skills_ref/parser.pyread_properties
校验入口skills-ref/src/skills_ref/validator.pyvalidatevalidate_metadata
name 校验 + NFKCskills-ref/src/skills_ref/validator.py_validate_name
字段白名单skills-ref/src/skills_ref/validator.pyALLOWED_FIELDS
长度常量skills-ref/src/skills_ref/validator.pyMAX_SKILL_NAME_LENGTH
拼 prompt XMLskills-ref/src/skills_ref/prompt.pyto_prompt
CLI 命令skills-ref/src/skills_ref/cli.pyvalidate_cmdto_prompt_cmd
i18n / NFKC 测试skills-ref/tests/test_validator.pytest_i18n_chinese_nametest_nfkc_normalization