1. 从一堆散落的技能文件夹说起
如果你同时用 Claude Code 和 Codex 这两套 AI 编程助手,大概率经历过这样的场景:在 Claude Code 里调教好了一个"自动生成单元测试"的技能,换到 Codex 里想复用,发现格式对不上;在 Codex 里攒了一套"代码审查清单"的提示词模板,想在 Claude Code 里跑,又得手动改一遍目录结构和元数据。时间一长,两个工具各自的技能目录里塞满了重复、半成品、命名混乱的文件,想找某个特定技能得翻半天。
awesome-skills这个项目要解决的就是这件事——把分散在不同 AI 编程工具里的技能(Skills)聚合到一处,用统一的目录结构和元数据规范管理,再按需分发回各个工具。说白了,它扮演的是一个"技能中枢"的角色:你只管往里丢技能,它负责整理、去重、分类、导出。
这篇文章适合三类人看:一是同时使用多个 AI 编程助手、被技能管理搞得头大的开发者;二是想给自己团队搭建内部技能库的技术负责人;三是对"技能聚合"这个模式本身感兴趣、想理解其设计思路的工程师。我会从目录结构设计、元数据规范、聚合逻辑、分发机制、实际踩坑几个角度,把这类项目的核心要点拆开讲清楚。文中涉及的具体实现细节,部分是基于同类项目的常见做法做的合理推演,我会明确标注哪些是通用实践、哪些需要你根据自己环境调整。
先给一个整体认知:技能聚合这件事,难点不在"把文件复制到一起",而在于不同工具对技能的定义方式、触发条件、依赖声明各不相同。Claude Code 的技能通常以 Markdown 文件加 frontmatter 的形式存在,Codex 的技能可能是一段配置加提示词模板。要让它们共存,必须抽象出一层中间表示,这就是整个项目的技术核心。
2. 技能聚合到底在聚合什么
2.1 拆解"技能"这个概念的三个层次
很多人一上来就想写代码,结果发现连"要聚合什么"都没想清楚。我建议先把"技能"拆成三个层次来看,这样后面设计目录结构时心里有数。
第一个层次是触发描述。这是技能被调用的入口,通常是一段自然语言,告诉 AI"当用户提出某类需求时,你应该启用这个技能"。比如"当用户要求为现有函数补充测试用例时"就是一条触发描述。不同工具对触发描述的格式要求不同,有的放在 frontmatter 的description字段,有的单独写在一个trigger.md里。
第二个层次是执行逻辑。这是技能的主体,可能是一段详细的步骤说明、一组提示词模板、甚至是一段可执行脚本。Claude Code 的技能主体通常是 Markdown 正文,Codex 的可能包含更多结构化配置。
第三个层次是依赖与约束。比如这个技能需要访问哪些文件、依赖哪些外部命令、有没有前置技能必须先执行。这一层最容易被忽略,但恰恰是聚合时最容易出问题的地方——两个工具对依赖的声明方式可能完全不同。
把这三层想清楚之后,聚合的目标就明确了:为每个技能建立一份统一的中间表示,包含触发描述、执行逻辑、依赖约束三部分,再针对不同工具做格式转换。
2.2 为什么不能简单地"复制粘贴"
有人会问:我直接把 Claude Code 的技能文件夹整个复制到 Codex 的目录下不就行了?实测下来,这样做在简单场景下能跑通,但很快就会遇到三个问题。
第一个问题是元数据字段不兼容。Claude Code 的技能 frontmatter 里可能有allowed-tools字段,Codex 根本不认这个字段,读的时候直接报错或者静默忽略。反过来,Codex 的某些配置字段 Claude Code 也不识别。
第二个问题是命名冲突。两个工具里可能都有叫code-review的技能,但内容完全不同。直接复制会导致覆盖,而且你很难发现覆盖发生了。
第三个问题是版本漂移。你在 Claude Code 里更新了某个技能,Codex 里那份还是旧的,时间一长两边行为不一致,排查起来非常痛苦。
awesome-skills这类项目的价值就在于,它用一层中间表示把这些问题隔离掉了。你只维护一份源文件,导出时自动转换格式,冲突时给出明确提示,更新时同步到所有目标工具。
2.3 一个最小可用的目录结构设计
基于上面的分析,我推荐的最小目录结构是这样的:
awesome-skills/ ├── skills/ # 技能源文件目录 │ ├── generate-tests/ │ │ ├── skill.yaml # 统一元数据 │ │ ├── trigger.md # 触发描述 │ │ ├── body.md # 执行逻辑 │ │ └── deps.yaml # 依赖声明 │ └── code-review/ │ └── ... ├── adapters/ # 各工具适配器 │ ├── claude-code.js │ └── codex.js ├── dist/ # 导出产物 │ ├── claude-code/ │ └── codex/ └── config.yaml # 全局配置这个结构的关键在于skills/目录下的每个技能都是工具无关的,所有工具相关的转换逻辑都放在adapters/里。dist/目录是导出产物,可以随时删除重建,不要手动改里面的文件。
注意:
dist/目录一定要加进.gitignore。我见过有人把导出产物也提交到仓库,结果每次导出都产生大量无意义的 diff,代码审查时根本看不清真正的改动。
3. 统一元数据格式的设计取舍
3.1 用 YAML 还是 JSON 还是 TOML
元数据格式的选择看似小事,实际影响很大。我对比过三种方案,最后倾向 YAML,理由如下。
JSON 的问题是不支持注释。技能元数据里经常需要写"这个字段为什么这么填"的说明,JSON 做不到。而且 JSON 对多行字符串的支持很差,触发描述稍微长一点就得用\n拼接,可读性极差。
TOML 的可读性不错,也支持注释,但嵌套结构表达起来比较啰嗦。技能的依赖声明往往是嵌套的(依赖列表里每一项又有自己的属性),TOML 写起来层级一深就很别扭。
YAML 兼顾了注释、多行字符串和嵌套结构,虽然缩进敏感容易出错,但配合编辑器的 YAML 插件基本能避免。下面是一个典型的skill.yaml:
name: generate-tests version: 1.2.0 description: 为现有函数自动生成单元测试用例 author: internal-team tags: - testing - automation triggers: - pattern: "为.*生成测试" priority: high - pattern: "补充测试用例" priority: medium compatibility: claude-code: min-version: "1.0.0" codex: min-version: "0.9.0"这里有几个设计细节值得说。version字段用语义化版本,方便后续做兼容性检查。triggers用列表而不是单个字符串,因为一个技能往往有多种触发方式,用优先级区分。compatibility字段声明这个技能在哪些工具、哪些版本上验证过,导出时可以据此过滤。
3.2 触发描述的正则与自然语言之争
触发描述到底该用正则还是自然语言,这是个有争议的点。我两种都试过,结论是两者都要,但用途不同。
正则适合精确匹配场景。比如你有一个技能专门处理"把 Python 2 代码转成 Python 3",触发词很明确,用正则Python\s*2.*转.*Python\s*3就能精准命中,不会误触发。
自然语言适合语义匹配场景。比如"代码审查"这个技能,用户可能说"帮我看看这段代码有没有问题"、"review 一下"、"检查下代码质量",这些用正则很难穷举,但用自然语言描述交给 AI 判断就很自然。
实际项目里我的做法是:triggers列表里每一项可以指定type: regex或type: semantic,导出到不同工具时按工具的能力选择。Claude Code 对自然语言触发的支持更好,就优先导出 semantic 类型的触发;Codex 如果对正则支持更稳,就优先导出 regex 类型。
3.3 依赖声明怎么写才不会变成一团乱麻
依赖声明是最容易设计失败的部分。我踩过的坑是:一开始把依赖写成一个简单的字符串列表,结果发现无法表达"这个依赖是可选的"、"这个依赖只在某个工具下需要"这类信息。
后来改成结构化声明,每个依赖是一个对象:
dependencies: - name: file-reader type: skill required: true - name: git-cli type: command required: false condition: "only when git operations needed" - name: claude-code-specific-helper type: skill required: true platforms: - claude-codetype区分依赖的是另一个技能还是外部命令。required区分必需和可选。condition用自然语言描述触发条件,导出时转成对应工具的格式。platforms限定只在某些工具下需要这个依赖。
这样设计之后,导出到 Claude Code 时,platforms不含 claude-code 的依赖会被自动过滤掉,不会产生"依赖了一个不存在的技能"这种错误。
4. 聚合逻辑:去重、分类与冲突处理
4.1 基于内容哈希的去重
技能重复是聚合时最常见的问题。我的做法是对技能的执行逻辑部分(body.md)计算内容哈希,哈希相同就认为是重复技能。
这里有个细节:不能对整个技能目录算哈希,因为元数据里的version、author这些字段不同不代表技能逻辑不同。只对body.md算哈希,能准确识别出"逻辑相同但元数据不同"的重复项。
发现重复后怎么处理?我的策略是保留元数据更完整的那一份,把另一份的独有元数据合并进来。比如 A 技能有tags: [testing],B 技能有tags: [automation],逻辑相同,合并后tags: [testing, automation]。如果两份元数据有冲突(比如version不同),则保留版本号更高的,并在日志里记录冲突。
// 伪代码示意去重逻辑 function dedupeSkills(skills) { const hashMap = new Map(); for (const skill of skills) { const hash = sha256(skill.body); if (hashMap.has(hash)) { const existing = hashMap.get(hash); hashMap.set(hash, mergeMetadata(existing, skill)); } else { hashMap.set(hash, skill); } } return Array.from(hashMap.values()); }4.2 自动分类的两种思路
技能一多,不分类就没法管理。我试过两种自动分类思路。
第一种是基于标签的分类。每个技能在元数据里声明tags,聚合时按标签聚类。优点是简单可控,缺点是依赖人工打标签的质量,标签体系一乱分类就失效。
第二种是基于触发描述的语义聚类。把所有技能的触发描述向量化,用聚类算法自动分组。优点是能发现人工没注意到的关联,缺点是聚类结果不稳定,同样的输入跑两次可能得到不同的分组。
实际项目里我用的是混合方案:以标签分类为主,语义聚类作为辅助,用来发现"标签不同但语义相近"的技能,提示人工复核是否应该合并标签。这样既保证了分类的稳定性,又保留了发现新关联的能力。
4.3 命名冲突的三种解决策略
命名冲突的处理策略取决于你的使用场景,我总结了三种,各有适用场合。
策略一:前缀隔离。给每个技能加上来源前缀,比如cc-generate-tests表示来自 Claude Code,cx-generate-tests表示来自 Codex。优点是绝对不会冲突,缺点是名字变长,调用时容易记错。
策略二:优先级覆盖。配置一个优先级列表,冲突时高优先级的来源覆盖低优先级的。优点是名字干净,缺点是低优先级的技能被静默覆盖,容易出问题。
策略三:显式重命名。检测到冲突时中断聚合,要求人工在配置里显式指定重命名规则。优点是安全,缺点是自动化程度低。
我的建议是默认用策略三,在配置里提供策略一和策略二作为可选。因为命名冲突往往意味着两个技能确实有差异,静默处理容易埋雷。让开发者显式决定怎么处理,虽然麻烦一点,但避免了后续排查的痛苦。
5. 分发机制:导出到不同工具的适配器
5.1 适配器模式的接口设计
适配器是整个项目的扩展点。每支持一个新工具,就写一个适配器。适配器需要实现统一的接口,我设计的接口是这样的:
class SkillAdapter { // 工具名称 get name() { throw new Error('not implemented'); } // 检查环境是否满足 async checkEnvironment() { throw new Error('not implemented'); } // 将统一技能转换为该工具格式 async convert(skill) { throw new Error('not implemented'); } // 写入目标目录 async write(skill, targetDir) { throw new Error('not implemented'); } // 验证导出结果 async validate(targetDir) { throw new Error('not implemented'); } }checkEnvironment用来检查目标工具是否安装、版本是否满足要求。convert做格式转换。write负责落盘。validate在导出后做一次自检,比如检查必需文件是否都存在、元数据是否能被目标工具正确解析。
这个接口设计的关键是把"转换"和"写入"分开。转换是纯函数,输入统一技能、输出目标格式,不涉及 IO,方便单元测试。写入才涉及文件系统操作。这样测试时只需要验证转换逻辑,不用真的去写文件。
5.2 Claude Code 适配器的转换要点
Claude Code 的技能格式相对简单,主要是 Markdown 加 frontmatter。转换时的要点有几个。
第一,frontmatter 字段映射。统一元数据里的name、description、tags直接映射过去,triggers需要拼接成一段自然语言描述放进description里,因为 Claude Code 的触发判断主要靠 description 的语义。
第二,依赖的处理。Claude Code 没有显式的依赖声明机制,所以依赖信息需要写进技能正文的开头,用一段说明文字告诉 AI"执行本技能前需要先确保 X 可用"。
第三,文件命名。Claude Code 对技能文件名有约定,通常是SKILL.md放在以技能名命名的目录下。导出时要按这个约定组织目录结构。
async convert(skill) { const frontmatter = { name: skill.name, description: buildDescription(skill.triggers, skill.description), tags: skill.tags, }; const body = buildBodyWithDeps(skill.body, skill.dependencies); return { path: `${skill.name}/SKILL.md`, content: `---\n${yaml.stringify(frontmatter)}---\n\n${body}`, }; }5.3 Codex 适配器的差异处理
Codex 的格式和 Claude Code 差异较大,转换时要注意几点。
Codex 可能更依赖结构化的配置而非纯 Markdown,所以convert方法里需要把技能正文拆解成配置项。触发描述可能需要转成正则或关键词列表,而不是自然语言。
依赖声明在 Codex 里可能有专门的字段,需要把统一格式的依赖转换成 Codex 的依赖格式。这里要注意platforms字段的过滤——只保留适用于 Codex 的依赖。
还有一个容易忽略的点:Codex 对技能的执行顺序可能有要求,而 Claude Code 可能没有。如果统一元数据里没有声明顺序信息,导出到 Codex 时可能需要根据依赖关系自动推断一个合理的顺序。
5.4 导出后的验证不能省
我强烈建议在导出后加一步验证。验证内容包括:目标目录结构是否符合工具约定、必需文件是否都存在、元数据能否被正确解析、依赖引用的技能是否都已导出。
验证失败时不要静默跳过,要明确报错并指出哪个技能、哪个字段出了问题。我见过太多项目导出时悄无声息地丢字段,等到实际使用时才发现技能行为不对,排查成本极高。
# 导出并验证的典型命令 awesome-skills export --target claude-code --output ./dist/claude-code awesome-skills validate --target claude-code --dir ./dist/claude-code6. 实操中踩过的坑与应对
6.1 编码问题导致的元数据解析失败
这个坑我踩得最冤。某个技能的body.md里包含了一个特殊字符,保存时用了 GBK 编码,聚合脚本按 UTF-8 读取,直接解析失败。更坑的是,失败信息只显示"YAML 解析错误在第 15 行",但第 15 行看起来完全正常,因为问题出在编码而不是语法。
应对方法:在读取任何文件时显式指定 UTF-8 编码,并在解析失败时输出原始字节的十六进制表示。这样一眼就能看出是不是编码问题。另外,在项目根目录放一个.editorconfig强制统一编码,能从源头减少这类问题。
6.2 符号链接导致的循环引用
有次我把技能目录做成了符号链接,指向另一个位置的实际文件。结果聚合脚本递归遍历时陷入了循环,跑了十几分钟没结束,最后栈溢出崩溃。
应对方法:遍历目录时记录已访问的 inode,遇到重复的 inode 就跳过。或者更简单粗暴:默认不跟随符号链接,需要跟随的话在配置里显式开启。我现在的做法是默认不跟随,因为技能目录用符号链接的场景其实很少,真需要的话手动处理更安全。
6.3 版本号比较的陷阱
语义化版本比较看起来简单,实际有坑。比如1.10.0和1.9.0谁大?字符串比较会认为1.9.0大,因为9大于1。必须按数字分段比较。
还有一个坑是预发布版本。1.0.0-beta和1.0.0谁大?按语义化版本规范,1.0.0-beta小于1.0.0。但很多简单的比较函数会忽略预发布标识,导致判断错误。
应对方法:用成熟的语义化版本库,不要自己写比较函数。JavaScript 生态里semver库就很成熟,Python 里packaging.version也够用。自己写的话,至少要把预发布版本的比较逻辑写对。
6.4 导出产物的增量更新
每次导出都全量重建dist/目录,在技能数量少的时候没问题,技能一多就慢。我试过做增量更新,只重新导出有变化的技能,结果遇到了新问题:删除的技能没有从dist/里清理掉,导致目标工具里残留了已经不存在的技能。
应对方法:维护一份导出清单,记录上次导出了哪些技能,本次导出时对比清单,把不在清单里的技能从dist/删除。这样既实现了增量更新,又保证了产物和源文件一致。
// 增量导出时清理已删除的技能 const lastManifest = readManifest('dist/.manifest.json'); const currentSkills = new Set(skills.map(s => s.name)); for (const oldSkill of lastManifest.skills) { if (!currentSkills.has(oldSkill)) { await removeSkillFromDist(oldSkill); } } writeManifest('dist/.manifest.json', { skills: Array.from(currentSkills) });6.5 多工具同时导出时的资源竞争
如果你配置了同时导出到 Claude Code 和 Codex,两个适配器可能同时写文件,导致资源竞争。我遇到过两个适配器同时写同一个日志文件,日志内容交错在一起,完全没法看。
应对方法:每个适配器写自己的日志文件,或者用一个串行队列保证同一时间只有一个适配器在写。我倾向于前者,因为日志分开更清晰,排查问题时不用在一堆交错的内容里找自己关心的部分。
7. 把这套模式用到你自己的场景
7.1 从最小可用版本开始
如果你打算自己搭一套类似的技能聚合系统,我的建议是先做最小可用版本,别一上来就追求大而全。
最小可用版本只需要三个功能:读取技能目录、按统一格式解析、导出到一个目标工具。去重、分类、冲突处理这些都可以后面再加。先把主流程跑通,你才能发现真正的痛点在哪里,而不是凭空想象需求。
我见过有人一开始就设计了复杂的插件系统、支持十几种工具、内置了机器学习分类,结果主流程都没跑通,项目就烂尾了。技能聚合这件事,核心价值在于"统一管理",其他都是锦上添花。
7.2 元数据格式要留扩展余地
设计元数据格式时,一定要留一个extensions字段,允许存放工具特有的配置。这样遇到统一格式表达不了的信息时,不用改格式,直接塞进extensions里,由对应工具的适配器去解析。
name: some-skill # ... 通用字段 extensions: claude-code: custom-field: value codex: another-field: value这个设计的好处是向后兼容。以后新增工具时,老技能的元数据不用改,新工具特有的配置放进extensions就行。适配器读取时,只关心自己那部分extensions,其他忽略。
7.3 给技能写测试用例
技能本身也需要测试。我的做法是为每个技能写一组输入输出样例,放在技能目录下的tests/里。聚合系统在导出前跑一遍测试,确保技能行为符合预期。
测试用例的格式可以很简单,就是一组"输入描述"和"期望行为"的配对。比如:
# tests/cases.yaml - input: "为这个函数生成测试" expect_trigger: true - input: "今天天气怎么样" expect_trigger: false这样至少能验证触发逻辑是否正确。执行逻辑的测试更复杂,可能需要实际调用 AI 工具,成本较高,可以按需做。
7.4 定期审查技能库
技能库和代码库一样,会随着时间积累垃圾。我建议每个月做一次技能库审查,检查内容包括:哪些技能半年没被调用过、哪些技能的触发描述已经过时、哪些技能之间有重复或高度相似。
审查时可以借助聚合系统生成的统计信息,比如每个技能的调用次数、最近调用时间、与其他技能的相似度。这些数据能帮你快速定位需要清理的技能。
提示:删除技能前先归档,别直接删。有些技能可能只是暂时不用,过段时间又需要了。归档到一个
archived/目录,保留元数据和正文,需要时能快速恢复。
7.5 团队协作时的权限管理
如果技能库是团队共用的,权限管理就很重要。我的做法是按目录划分权限:skills/目录所有人可读,但只有技能维护者能写;adapters/目录只有核心开发者能改;dist/目录自动生成,所有人只读。
具体实现可以用 Git 的分支保护规则,也可以用文件系统的权限控制。关键是让改动有迹可循,每个技能的修改都能追溯到具体的人和原因。技能库是团队资产,不能让它变成无人维护的垃圾堆。
8. 关于技能聚合这件事的一些个人体会
做这类项目最大的感受是:技术难度不高,但设计决策很多。每一个决策单独看都不复杂,但组合起来就决定了整个系统好不好用。元数据格式怎么设计、冲突怎么处理、导出怎么验证,这些决策没有标准答案,只能根据实际使用场景权衡。
我自己的经验是,优先保证可追溯性和可恢复性。技能聚合系统本质上是个"转换器",转换过程中信息丢失是最大的风险。所以每一步转换都要有日志,每个导出产物都要能追溯到源文件,出问题时能快速定位和恢复。宁可系统慢一点、啰嗦一点,也不要为了追求简洁而牺牲可追溯性。
另外,不要过度设计。我一开始想支持所有能想到的工具,结果适配器写了一大堆,每个都半成品。后来砍到只支持两个最常用的工具,把这两个做扎实,反而更有价值。技能聚合的核心是"聚合",不是"支持尽可能多的工具",想清楚这一点能少走很多弯路。
最后分享一个实用技巧:给聚合系统加一个--dry-run模式,只输出将要执行的操作,不实际写文件。在批量操作前先 dry-run 一遍,能避免很多误操作。这个模式实现起来很简单,但用起来非常省心。