生成、同步与校验:一个新 skill 是怎么来的
在 CLI-Anything 里,给 agent 读的那份 SKILL.md 基本不是人手敲出来的。它有一条固定的流水线:先从 agent-harness 目录抽信息生成,再从 harness 本地同步到仓库根的 skills/,最后由一个 CI 脚本校验两边有没有对不上。这三段各由一个 Python 脚本负责,都在仓库里能直接翻到。
我们采集时(2026-08-10,对应仓库快照 39634a6)把这三个脚本连同它们的产物一起读了一遍。本文只讲这条链路本身——某份 SKILL.md 里有哪些字段、正文分几节、skills/ 目录长什么样,另有专门篇目在讲,这里不重复。
第一段:生成器在哪,它到底读了什么
生成器是 cli-anything-plugin/skill_generator.py,我们采集时 wc -l 数出来是 586 行。它的输入只有一个:某个软件的 agent-harness 目录路径。生成指南给的命令行用法是 python skill_generator.py /path/to/software/agent-harness(cli-anything-plugin/guides/skill-generation.md:80-83),另外支持 -o/--output 与 -t/--template 两个参数(skill_generator.py:563-576)。
关键在于它从哪几个文件里抠信息。这部分集中在 skill_generator.py:79-152,可以列成一张表:
| 抽取项 | 来源 | 行号 |
|---|---|---|
| 软件包目录 | cli_anything/ 下第一个含 __init__.py 的子目录 | :98-105 |
| intro 与系统包安装命令 | cli_anything/<software>/README.md | :107-115 |
| version | agent-harness/setup.py 里的 version= 正则 | :117-122、:202-208 |
| 命令组 | cli_anything/<software>/<software>_cli.py 的 Click 装饰器正则 | :124-129、:211 起 |
这张表里有两处值得单独盯一眼。
一是 version:正则取不到时,生成器会静默回落到 1.0.0(skill_generator.py:119)。也就是说一份 SKILL.md 上写着 1.0.0,既可能是 setup.py 里真写了这个值,也可能是根本没匹配上——从产物上看不出区别,要判断只能回去看那份 setup.py。
二是系统包安装命令:生成器只识别 apt install、brew install、apt-get install 三种反引号模式(skill_generator.py:179-199),README 里换个写法(比如 dnf、winget、或者不加反引号)就抓不到。这一点对读者是有实际后果的:SKILL.md 的 Installation 一节里出现的那些安装命令,是从 README 文本里正则捞出来照抄的,抄进去之后会由 agent 或者你自己在本机执行。这类 harness 本来就要在本机跑外部程序、并且需要你自行装好宿主软件,Installation 节里的每一行都值得先自己看一眼再敲。
模板是 cli-anything-plugin/templates/SKILL.md.template,123 行,用 Jinja2 占位符;环境里没有 jinja2 时会回退到 generate_skill_md_simple()(skill_generator.py:372-387、:410)。也就是同一个 harness,装没装 jinja2 生成出来的东西走的是两条不同的代码路径。
生成器一次写两份产物(skill_generator.py:535-552):一份 canonical,落在 <repo_root>/skills/<skill_name>/SKILL.md;一份 compatibility,落在 <agent-harness>/cli_anything/<software>/skills/SKILL.md。刚生成完,这两份是一致的。
另外,仓库里的生成器不止一份:mubu/agent-harness/skill_generator.py(434 行)与 zotero/agent-harness/skill_generator.py(297 行)是两份复制品,我们比对三者的 md5 各不相同,但没有逐行核对它们的具体差异。
第二段:同步脚本,以及它自己制造的差异
同步脚本是 .github/scripts/sync_root_skills.py,93 行,文件开头的 docstring 原文是 Sync repo-root skills/ from harness-local SKILL.md files.(:2)。方向写得很明确:从 harness 本地同步到仓库根,单向。
它靠两条 glob 找源(:60-61):*/agent-harness/cli_anything/*/skills/SKILL.md 和 */agent-harness/cli_anything/*/SKILL.md。我们用 Python 直接 exec 这个脚本、调它的 _discover_sources(),在本仓的文件树上得到 68 个源。
skill id 的归一化规则在 :13-23:优先取 cli_anything 后一级的包名,下划线转连字符,加上 cli-anything- 前缀;取不到再退回顶层目录名。
真正制造差异的是写入前的那一步(:26-55):脚本会把 frontmatter 里第一个 name:(包括它后面的缩进续行)整体改写成 name: "<skill_id>";如果原本没有 name,就插到最前面。
到这里,本篇最反直觉的那处就出来了。生成模板输出的 name 是一个折叠标量,写法是 name: >- 换行再缩进 {{ skill_name }}(templates/SKILL.md.template:2-3);而同步脚本落到 skills/ 里的那一份,name 被改写成了双引号单行形式。于是:生成器刚写完的两份是一致的,同步脚本一跑,仓库根那份的第 2 行必然与 harness 本地那份不同。
这不是有人手改造成的,是这条流水线自带的。我们把 68 个 packaged 源与其仓库根对应文件逐一做 md5 比对:完全相同 20 个,内容不同 43 个,另有 5 个的 packaged name 值与仓库根 skill id 对不上。那 43 个「不同」再拆开看——分别比较 yaml.safe_load 之后的 frontmatter 与正文字节——正文差异 0 个、frontmatter 解析值差异 0 个,全部 43 个只是 YAML 序列化风格的差异。
这一条你可以自己核:拿 gimp 的两份对比,diff skills/cli-anything-gimp/SKILL.md gimp/agent-harness/cli_anything/gimp/skills/SKILL.md,输出只有第 2 行那一处。
顺带一句,同步的覆盖是破坏性的。脚本自己在注释里写明「the mirror may hold edits the source lacks」,并对每一次覆盖打印一行 overwrite:,免得静默丢东西(:80-85)。
第三段:校验器怎么判,CI 在哪触发
校验脚本是 .github/scripts/validate_root_skills.py,114 行,docstring 原文是 Validate that deep harness SKILL.md files are mirrored in repo-root skills/.(:2)。
它把问题分成两类结果(:44-71):
- clobber:镜像里有源里没有的行。重跑同步会把这些行删掉,所以必须回到源文件去改。
- stale:源领先于镜像。这类直接重跑同步就能修好。
clobber 的报错文案里有一句原文是 You edited a GENERATED file.(:81),并且最多列 10 行差异,多出来的用 … and N more line(s) 折叠(:91-94)。
CI 挂在 .github/workflows/check-root-skills.yml,35 行。触发路径是 */agent-harness/**、skills/** 以及这两个脚本本身,用 Python 3.10,唯一一个步骤就是 python3 .github/scripts/validate_root_skills.py(:5-19、:32、:35)。我们采集时 .github/workflows/ 下共 6 个 workflow:check-codex-skill.yml、check-root-skills.yml、deploy-pages.yml、pr-labeler-tests.yml、pr-labeler.yml、publish-cli-hub.yml,其中只有 check-root-skills.yml 校验 skills/。
我们复用了脚本里的 _discover_sources() 与 _canonical_skill_id() 在本地文件树上模拟了一遍(注意:这是 Python 求值,不是 CI 实跑):68 个源生成的 68 个 id 全部在 skills/ 里存在;反过来,skills/ 里有 2 个目录不由同步产生——cli-anything-iterm2 与 cli-hub-meta-skill。校验是「源必须在镜像里有对应」这一个方向,镜像里多出来的目录不在它的判定范围内。
这条闭环没盖住的地方
同步脚本的两条 glob 都要求路径里有 cli_anything/。这就决定了几件事,照实列:
- 有两份
SKILL.md落在agent-harness/skills/下,不在 glob 范围内,因此也不受这个 CI 校验。 一份是firefly-iii/agent-harness/skills/cli-anything-firefly-iii/SKILL.md,281 行、version: "1.0.0";而skills/cli-anything-firefly-iii/SKILL.md是 709 行、version: "2.0.0"。另一份是openrefine/agent-harness/skills/cli-anything-openrefine/SKILL.md,只有 10 行,正文自称This compatibility copy mirrors skills/cli-anything-openrefine/SKILL.md at the standalone output root.(:9),而仓库根与 deep packaged 两份都是 72 行。差异就是这样,我们不推断原因。 unimol_tools的 packaged 副本少一层目录:它实际在unimol_tools/agent-harness/cli_anything/unimol_tools/SKILL.md,与生成指南画的目录树(cli_anything/<software>/skills/SKILL.md,guides/skill-generation.md:70-76)不一致;同步脚本靠第二条 glob 把它兜住了。sketch这个 harness 根本没有SKILL.md:find sketch -iname '*skill*'无输出,registry.json里 sketch 的skill_md也是null。注册表里有条目不等于这条链路已经为它跑通过——这正是「注册表里有名字」和「agent 拿得到一份能力描述」是两件事的例子。
测试盖到哪一层
生成器本身是有测试的:cli-anything-plugin/tests/test_skill_generator.py,536 行,grep -c 'def test_' 数出 38 个测试函数,覆盖元数据抽取、Click 命名消歧、版本抽取、模板回退、默认与自定义输出路径、没有 README、setup.py 畸形这些边界。
另一处容易找错地方:仓库顶层有个叫 skill_generation/ 的目录,名字看着像放生成器的,但 find skill_generation -type f | wc -l 只有 1 —— 唯一的文件是 skill_generation/tests/test_skill_path.py(198 行),没有生成脚本、没有模板、没有 README。真正的生成器在 cli-anything-plugin/ 下,这份测试还要跨目录去 cli-anything-plugin/repl_skin.py 取源文件(test_skill_path.py:37)。
这份测试验的是运行时那一段:ReplSkin 能不能从自身 __file__ 自动定位到 skills/SKILL.md、路径是不是绝对路径、缺文件时 skill_path 是不是 None、显式传参能不能覆盖、banner 有没有 Skill: 行(test_skill_path.py:74-139)。运行时的查找顺序也在这一层:先向上找仓库根的 skills/<skill-id>/SKILL.md,找不到再退回包内的 ../skills/SKILL.md(repl_skin.py:141-156)。
它还带一个硬编码的 harness 列表 HARNESSES,共 18 项(adguardhome、anygen、audacity、blender、comfyui、drawio、gimp、inkscape、kdenlive、libreoffice、mermaid、mubu、notebooklm、novita、obs-studio、ollama、shotcut、zoom,test_skill_path.py:145-164),对每一项断言 packaged SKILL.md 存在、有 YAML frontmatter、含 ## Command Groups、至少一行 | \``,以及 setup.py含package_data(:166-198)。也就是说 68 份 packaged SKILL.md` 里,只有 18 份进了这组结构断言。
顺便记一个数字口径的差:根 README 有一处写的是 all 18 diverse, production-ready harnesses(README.md:1582),这是 README 自称;我们采集时数到的 harness 目录是 69 个。上面那个硬编码列表也仍是 18 项。差异就到这里。
除此之外,我们没有在仓库里发现针对 SKILL.md 的 lint 或 markdown 规则脚本;顶层也没有 scripts/ 目录(ls scripts 无输出)。
你要加一个新 skill,这条路怎么走
按上面几个脚本的语义把顺序串一遍:
- 把 harness 目录按规范做好——尤其是
cli_anything/<software>/README.md(intro 和安装命令从这里抠)、<software>_cli.py(命令组从这里的 Click 装饰器抠)、agent-harness/setup.py(version 从这里抠,抠不到会静默变成1.0.0)。 - 跑
python skill_generator.py /path/to/software/agent-harness,它会同时写出仓库根与 packaged 两份。 - 打包时记得在
setup.py的package_data里带上"skills/*.md"(guides/skill-generation.md:127-135),否则 packaged 那份不会随包分发。 - 要改内容,改源(packaged 那一侧),别去改
skills/里的镜像——校验器把这种情况判成 clobber,报错文案就是那句You edited a GENERATED file.。 - 本地想提前知道 CI 会不会红,就跑
python3 .github/scripts/validate_root_skills.py,跟 workflow 里的唯一步骤是同一条。
以上为按仓库中的脚本语义与文档命令串起来的示例流程,未经实测,以官方文档与 --help 的实际输出为准。
最后交代一下我们这次的核实边界:上面所有结论都来自静态读文件与用 Python 复用脚本函数在本地文件树上求值,我们没有运行过 validate_root_skills.py、没有跑过 pytest、也没有触发过任何一次 CI;npx skills 这个外部工具的真实行为不在本仓内,我们只记录了 README 里的命令文本,没有核实。
本文依据 CLI-Anything 官方仓库(github.com/HKUDS/CLI-Anything)的 README、registry.json、
docs/ 与各 agent-harness/ 下的源码整理,核对日 2026-08-10,对应仓库快照 39634a6。
本文内容为仓库源码与文档口径,我们没有安装或运行过其中任何一个 harness,
也没有在本机驱动过任何一款宿主软件,因此不涉及实际操控效果的任何描述。
这类 harness 会在本机执行外部程序与脚本,是否使用请结合自身环境评估。
注册表与 harness 内容随上游更新而变动,请以仓库最新内容为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。