SKILL.md 安全编辑与上传前校验
为什么需要这个技能:给技能改元数据(改名 / 升版本 / 补 description)看着是小事, 但改坏了不会立刻报错 —— 文件照样存在、内容看着完整,直到打包上传被平台拒收、 或者 YAML 在某处静默解析成别的结构。 本技能把"改 → 验"固化成两步,杜绝这类返工。
0. 最短路径
# 1) 改完先验源目录(支持传目录或 SKILL.md 文件,可多个)
python scripts/check_frontmatter.py --upload <技能目录> [<技能目录2> ...]
# 2) ⚠️ 只要打算公开发布(开放平台 / 公开仓库)→ 先扫敏感信息
python scripts/scan_sensitive.py <技能目录> [<技能目录2> ...]
# 3) 打包(内含"打包前强制 LF 化 + 打包后解包复验",一步到位)
python scripts/pack_and_verify.py <技能目录> [输出zip]
三步都输出 ✅ 才去上传 / push。
第 2 步只在自己用、不发布时可以跳过;一旦要公开,绝不能跳。
退出码:校验器与扫描器都是 0 = 通过 / 1 = 有问题 ——
可直接当 CI 门禁用(本仓库的 GitHub Actions 就是这么挂的)。
1. 五个实测踩到的坑(前两个让 YAML 静默失效,后三个让平台静默拒收)
坑 ① CRLF 文件的游离 \r(2026-09-20 实测)
症状:yaml.safe_load 报 could not find expected ':',
错误位置指向一个看起来正常的中文句子,例如:
line 35, column 1:
。v1.3 起把「发现变化 → 引导找到原因」提为总纲 ...
^
为什么会这样:Windows 上的 SKILL.md 通常是 CRLF 换行。用
i = s.find('\n') # ← 只找到 \n,\r 留在前面
val = s[j:i] # ← val 末尾带着 \r
val = val.rstrip('。') # ← 末尾其实是 \r,rstrip 完全没生效
s = s[:j] + val + '。新句子' + s[i:] # ← \r 把句子劈成两行
拼接后 \r 夹在句中(YAML 规范把 \r 也当行分隔符),
后半句跑到行首、无缩进 → 被当成一个新的 key → 语法错误。
对策(三条,至少用一条):
| 做法 | 写法 |
|---|---|
| 读文件时不转换换行 | io.open(p, 'r', encoding='utf-8', newline='') |
| 定位行尾用 CRLF | i = s.find('\r\n') |
用「整行匹配」代替「找 \n 再切片」 |
re.sub(r'^description_zh:.*$', new, s, flags=re.M) |
| 修完必查裸 CR | re.findall(r'\r(?!\n)', s) 必须为空 |
收尾必做:
re.sub(r'\r(?!\n)', '', s)清除游离\r, 然后重新yaml.safe_load确认。
坑 ② 单行 plain scalar 里的半角 :
症状:mapping values are not allowed here,错误位置指向英文句子里的冒号:
... m deep dive. Its core principle: a report must turn every change ...
^
原因:description_zh / description_en 这类单行 plain scalar,
值里出现半角冒号+空格(: )就会被 YAML 解析成 key: value 映射,直接报错。
对策:
- 中文全角
:是安全的,可放心用。 - 英文句子避免
:,改写成is that/—/ 直接去掉冒号。 - 若确实需要,把该字段改成块标量(
description_zh: |+ 两空格缩进)或加引号。 - ⚠️
description: |块标量内部不受此限 —— 块标量里:安全。 只有单行 plain scalar 要小心。
坑 ③ 官方校验器查得比平台少(2026-09-20 实测,最隐蔽)
症状:本地全部检查通过 —— package_skill.py 报 Skill is valid!、
quick_validate.py 报 Skill is valid!、YAML 也能解析 —— 传到平台上却报「解析失败」。
真因:翻 quick_validate.py 源码,它对 frontmatter 只查两条:
if 'name:' not in frontmatter: return False, "Missing 'name' in frontmatter"
if 'description:' not in frontmatter: return False, "Missing 'description' in frontmatter"
而平台实际要 name / version / display_name / description / description_zh / description_en。
中间那四个字段官方校验器一个都不查,所以它永远报 valid。
# 反例(本机实测,报 valid 但上传被拒)
python "$SC/scripts/quick_validate.py" <技能目录> # → Skill is valid! ❌ 不可信
修法:上传前必须跑本脚本的 --upload 模式,它是按平台实际要求查的:
python scripts/check_frontmatter.py --upload <技能目录> # 缺字段会报「平台会拒收」
⭐ 结论:官方校验器 + 打包器 = 只保证"格式像技能",不保证"能上传"。 上传前唯一的准绳是本脚本的
--upload。
坑 ④ 描述字段有长度上限(2026-09-20 实测,英文描述 1000 字符)
症状:字段齐全、YAML 正常、本地所有检查都过 —— 平台仍报「解析失败」:
解析失败:
Skill 英文描述: 当前 1330 字符, 上限 1000 字符
真因:平台对描述类字段另有长度上限,而官方校验器与打包器都不检查长度
(quick_validate.py 只看字段名在不在,package_skill.py 更是什么都不看)。
| 字段 | 平台上限 | 实测依据 |
|---|---|---|
description_en |
1000 字符 | ✅ 平台报错原文(2026-09-20 两次上传实测) |
description_zh |
未实测 | ⚠️ 保守压在 ≤ 400 |
description |
未实测 | ⚠️ 保守压在 ≤ 1000(已过审的版本实测 972) |
display_name / display_name_en |
未实测 | 很短,基本不触线 |
⭐ 写法建议(留 5% 余量):
description_en目标 ≤ 950、description_zh≤ 400、description≤ 1000。 超了先砍"细节枚举"(15 个板块的完整清单可压成「十五个板块包括…」), 必须保住"触发词 + 第一性原理" —— 决定召回的就是这两样。⛔ 反面教材:本技能文档上一版曾写「长度量级 zh 约 370 / en 约 1,100 字符」, 把超限的写法当成了参照值 → 直接导致
taobao-item-ops-report首次上传被拒。 参照值只能从"已通过审核的版本"里量,且必须核对是否 ≤ 平台上限。
校验器已内置这条检查(--upload 模式下 description_en 超限判 ✗,其余超限告警)。
坑 ⑤ 打包是二次污染点:包内字节 ≠ 源目录(2026-09-20 实测,最容易被漏)
症状:源目录已经 check_frontmatter.py --upload 全绿,
上传后平台仍报「解析失败」。
真因:校验器看的是源目录,平台收的是 zip 包里的字节 —— 这是两份东西。
实测 sycm-ops-daily-report v1.3:源目录明明已修干净,
zip 内的 SKILL.md 仍有 1357 个裸 CR(整份文件是 CRLF)。
为什么会复发:在 Windows 上只要用编辑器 / IDE / 脚本写过一次 SKILL.md, 行尾符就可能被静默写回 CRLF —— 上一版修过,不代表这一版没被写回。 (这正是「上一版能过不代表这一版能过」的第二重含义:不只内容会变,字节也会变。)
对策:把「校验 → 打包 → 复验」串成一条链,用 scripts/pack_and_verify.py 一步做完:
python scripts/pack_and_verify.py <技能目录> [输出zip] [--root 包内根名]
# 不带输出路径时默认写到 <技能目录>/../dist/<技能名>.zip
它会依次做三件事:
- 打包前对源目录全量 LF 化(
.md/.html/.sh/.py/.json/.css/.js,保留 BOM 状态) - 打包(固定时间戳 → 内容哈希稳定,便于比对两次打包是否一致)
- 打包后解包复验包内字节:裸 CR / BOM / 非 UTF-8 / 6 字段齐全 / 长度上限 /
zip 完整性 / 目录名与
name一致
输出 ✅ 可以上传 才算过;退出码 0 / 1 可直接串进脚本。
⭐ 一句话:打包后必须再验一次,验的是包里的字节,不是源目录。 「源目录校验通过」不等于「包是干净的」。
2. 三条纪律
纪律 ① 改内容必须同步升 version
症状:文档正文里出现 v1.3 这类版本标记(如某节标题写着「(v1.3 打通)」),
但 frontmatter 还是 version: 1.2.0。
为什么危险:平台审核按 version 判断版本,审核通过的版本号与实际内容不符,
后续排查"这个功能是哪个版本加的"会全错。
对策:改完内容后对账 ——
marks = set(re.findall(r'v(\d+\.\d+)', body)) # 正文里的版本标记
assert not [x for x in marks if x > version] # 不许有比 frontmatter 更新的标记
校验器已内置这条检查。
⚠️ 校验器的一个已知误报点:文档里举例提到的版本号(如
`v1.3`) 不是本技能的版本标记。校验器已剔除代码块与行内代码后再扫描 —— 所以写文档时把"举例的版本号"放进反引号里,即可自动豁免。
纪律 ② 必填字段一个都不能少
实测:先后两次因 frontmatter 缺字段被开放平台在上传第 1 步「配置技能」直接拒收。
平台拒收时的原话(照抄,用于自查时对号入座):
解析失败:
缺少 Skill 中文描述(description_zh),请在 SKILL.md frontmatter 中填写
缺少 Skill 英文描述(description_en),请在 SKILL.md frontmatter 中填写
| 字段 | 说明 | 平台要求 | 长度上限 |
|---|---|---|---|
name |
小写字母/数字/连字符,必须与目录名一致 | 必填 | — |
version |
X.Y.Z |
必填 | — |
display_name |
中文可读名(给人看) | 必填 | 短,不触线 |
description |
召回的关键(触发词写全,技能名基本不参与召回) | 必填 | 保守 ≤ 1000 |
display_name_en |
英文可读名 | 建议 | 短,不触线 |
description_zh |
中文描述,单行 plain scalar | 必填(缺 → 「解析失败」) | 保守 ≤ 400 |
description_en |
英文描述,单行 plain scalar | 必填(缺 → 「解析失败」) | 实测 1000(超 → 「解析失败」) |
agent_created |
自建技能标 true |
自建时标 | — |
🔴 两个多语言描述字段是硬门槛,不是"建议" —— 2026-09-20 实测: 只写了
description、字段齐全度看着"很标准",平台照样拒收。 写法照抄已通过审核的同类技能(本机参照sycm-ops-daily-report): 单行 plain scalar、值内不含半角:、长度在平台上限内 (description_en≤ 1000,实测;详见坑 ④)。 ⛔ 别再照抄"en 约 1,100 字符"这种量级 —— 那是超限值,会直接被拒。
⚠️
agent_created: true是权限边界:只有自己创建的技能才可被修改。 别人写的技能(无此字段)不要改,即使内容有误 —— 应提示用户或另建新技能。
纪律 ③ 公开发布前必扫敏感信息(2026-09-20 实测踩到)
症状:技能功能完全正常、格式全部合规、平台审核照样能过 —— 但正文里躺着你的店铺名、合作方的店名、你和 AI 之间的内部称呼。 传开放平台 → 所有下载者可见;推公开仓库 → 全世界可见,且永久留在 git 历史里。
真因:技能是"实战里长出来的",写案例时会顺手把真实店名写进去。 这类问题不报错、不崩、不影响功能,所以自己根本发现不了 —— 直到有人问你"这家店是你的?"
实测(2026-09-20):某店铺日报技能在公开发布前扫描,查出来:
- 自有店铺名 × 6 —— 作为"实测案例"写在正文里
- 合作方店铺名 × 5 —— 把人家"单品转化下降 = 断色"的经营问题当成案例写了
- 内部称呼 × 15 —— 内部昵称 + 「原话:…」这种引用,对外人完全莫名其妙, 还等于告诉所有人"这是一份内部对话记录"
风险分档,不是一回事:
| 类型 | 风险 | 说明 |
|---|---|---|
| 密钥 / Token / 邮箱 / 手机号 | 🔴 最高 | 会被直接滥用 |
| 合作方 / 别人的店 | 🔴 最高 | 暴露别人的经营问题 = 商业关系风险,不只是隐私 |
| 自己的店 / 品牌 | 🟡 中 | 别人能摸到你的店 → 看你的打法、抄你的词 |
| 内部称呼 / 个人标识 | 🟡 中 | 对外人无意义,还暴露内部对话痕迹 |
对策:scripts/scan_sensitive.py + 一份词表。
python scripts/scan_sensitive.py --init-words # 一次性:生成词表模板
python scripts/scan_sensitive.py <技能目录> # 以后每次发布前跑
词表固定放 ~/.workbuddy/sensitive-words.txt(存在即自动加载,不用每次敲参数),
把你的店铺名 / 品牌名 / 合作方名 / 内部称呼逐行写进去。
扫描器查两类东西:
- 通用模式(无需配置):邮箱、手机号、密钥 / Token、本机绝对路径、≥8 位长数字
- 词表命中:你自己的那份清单
结果分 🔴 高危 / 🟡 待确认 / ⚪ 提示 三档,并自动降噪 ——
800000000000 判为示例 ID、20xxxxxx 判为日期、含 <占位符> 的路径直接跳过。
退出码 1 表示有高危,可直接串进发布脚本。
⭐ 脱敏手法:案例全留,只换主体名。 「
某工业品店实测:08-24~08-31 完全无券 8 天」和「A 店实测:…」说服力完全一样 —— 读者要的是"有真实案例",不是"案例里的店叫什么"。引内部原话改成「实战经验:」+ 叙述句。⛔ 公开仓库是"一次泄漏、永久留存":
git rm掉文件不算,内容还在历史 commit 里。 所以必须在第一次 push 之前扫干净 —— 那是唯一成本最低的时间点。
3. 安全编辑的标准流程
- 备份:把 SKILL.md 复制成
SKILL.md.<原因>_bak(例如SKILL.md.presanitize_bak) ⚠️ 最好移到技能目录外(如<工作区>/_skill_backups/)。 打包器现在会跳过疑似备份文件(_bak/.bak/_backup/.orig/.old/~), 但把备份留在技能目录里终究是隐患 —— 换别的工具打包就漏进去了。 - 读原文:
newline=''读,先 dump repr 看清真实字节,不要凭显示猜 - 替换:优先用锚点整段替换,不要并行多次 Edit 同一文件(会互相覆盖)
- 清游离 CR:
re.sub(r'\r(?!\n)', '', s) - 写回:
newline=''写,不要做换行符转换 - 校验:
python scripts/check_frontmatter.py --upload <目录>+ 内容关键词断言 - ⚠️ 扫敏感信息:
python scripts/scan_sensitive.py <目录>自己用可跳;只要打算公开发布(开放平台 / 公开仓库)绝不能跳 —— 见纪律 ③ - 打包:
python scripts/pack_and_verify.py <技能目录> <输出zip>(自带"打包前 LF 化 + 备份排除",见坑 ⑤) - 验包:打包器已内置 —— 它重新解 zip、对包内字节复跑 CR / 编码 / 6 字段 / 长度检查 (⚠️ 不要跳过;源目录干净 ≠ 包干净)
4. 排障速查
| 报错 | 真因 | 修法 |
|---|---|---|
could not find expected ':' |
游离 \r 把句子劈成两行 |
见坑 ①,清裸 CR |
mapping values are not allowed here |
单行 scalar 里有 : |
见坑 ②,改写或改块标量 |
| 平台报「解析失败:缺少 Skill 中文描述(description_zh)/ 英文描述(description_en)」 | 缺上传必填字段 | 见坑 ③,补两个多语言描述 |
| 平台报「解析失败:Skill 英文描述: 当前 N 字符, 上限 1000 字符」 | 字段超长 | 见坑 ④,压缩到上限内(en 目标 ≤ 950) |
| 其他平台拒收(无明确报错) | 缺必填字段 | 补齐 name/version/display_name/description/description_zh/description_en |
| 源目录校验全绿、上传仍报「解析失败」 | 包内字节 ≠ 源目录(打包环节被写回 CRLF) | 见坑 ⑤,改用 pack_and_verify.py,务必验包内字节 |
| 发布后有人问"这家店是你的?" | 正文带着真实店铺名 / 合作方名 / 内部称呼 | 见纪律 ③,跑 scan_sensitive.py 脱敏后重发(公开仓库还需清 git 历史) |
包里出现 xxx_bak 备份文件 |
备份留在技能目录里了 | 移到技能目录外;打包器会跳过疑似备份并打印提示 |
Skill is valid! 但 YAML 报错 |
打包器校验 ≠ YAML 校验 | 两者都要过,别只信打包器 |
Skill is valid! 但平台上不去 |
官方校验器只查 name/description 两条 |
见坑 ③,必须跑本脚本 --upload |
目录名与 name 不一致 |
改名时漏改目录 | mv 目录,并重打包 |
| 正文标了更高版本、frontmatter 未升 | 改了内容忘升 version |
若是本技能自己的版本标记 → 升 version;若是引用外部规范的版本号 → 忽略(或放进反引号让校验器豁免) |
⭐ 最重要的一条:
package_skill.py报Skill is valid!不代表能上传成功 —— 2026-09-20 实测两件事同时发生:打包器报 valid,yaml.safe_load失败; 打包器又报 valid,平台却报「解析失败」。 原因是官方quick_validate.py只检查name和description两个字段(源码实测), 平台要的version/display_name/description_zh/description_en一个都不查。 所以上传前必须跑本脚本的--upload模式,不能拿打包器的输出当准绳。同样地,"上一版能过"也不代表"这一版能过" —— 2026-09-20 的两次拒收分别是 "字段缺失" 与 "字段超长",都是内容改动引入的,与格式对不对无关。 但凡动过 frontmatter,就必须重跑
--upload。第三重:"源目录干净"也不代表"包干净" —— 见坑 ⑤。 2026-09-20 实测
sycm-ops-daily-reportv1.3:源目录--upload全绿, zip 内却藏着 1357 个裸 CR。Windows 上每次写文件都可能把 CRLF 带回来。 所以"验源目录"与"验包内字节"是两件事,必须都做(pack_and_verify.py一次做完)。
5. 官方标准字段 vs 平台扩展字段(2026-09-20 查证 agentskills.io/specification)
SKILL.md 是开放标准(Anthropic 发起,70+ 工具采纳)。
WorkBuddy 用的是「开放标准 + 自家扩展」,所以跨工具发布
(GitHub / Claude Code / Codex CLI / Cursor)前必须分清哪些字段是谁的:
| 字段 | 归属 | 换到别的工具 |
|---|---|---|
name / description |
✅ 官方标准 | 照读,召回能力不丢 |
license / compatibility / metadata / allowed-tools |
✅ 官方标准(可选) | 照读 |
version / display_name / description_zh / description_en |
⚠️ WorkBuddy 扩展 | 静默忽略(不报错,也不生效) |
官方字段的硬约束(照抄规范,别凭记忆)
| 字段 | 约束 |
|---|---|
name |
≤64 字符;小写字母 / 数字 / 连字符;不得连续连字符;必须与父目录名一致 |
description |
≤ 1024 字符(WorkBuddy 实测更严:1000 —— 取更严的) |
compatibility |
≤ 500 字符 |
license |
许可证名,或引用随包附带的许可证文件名(本仓库统一 MIT) |
版本号放哪:一个必须做的取舍
官方建议把版本放 metadata.version(metadata 是 string→string 映射),
WorkBuddy 则要顶层 version。两者冲突。
⛔ 本项目的取舍:只用顶层
version,不加metadata.version。理由:平台对描述字段的要求是「单行 plain scalar」——这说明它的解析器 可能是逐行 key 提取,而不是完整 YAML 解析。若如此,嵌套的
version:缩进行 会被误当顶层字段,与version撞车 → 直接「解析失败」。 而收益极小(各工具实际只读name/description做召回)。 不为"正宗"去冒上传被拒的险 —— 已经被拒两次了,教训够贵。
已采纳的官方字段
三个技能均已加 license: MIT 与 compatibility(扁平单行 scalar,零嵌套)。
校验器 check_frontmatter.py 会对 compatibility 做 ≤500 长度告警
(不影响 WorkBuddy 上传,故只告警不判 fail)。