OpenWork 开源桌面应用实操:把一次聊天变成可发布复用的技能
本文基于 openwork 仓库 commit 3b41381(2026-08-03)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/different-ai/openwork 最新代码与文档为准。
这条链路真正省掉的不是”写技能文件”,而是”想清楚技能该放在哪个目录、叫什么名字、怎么让同事拿到”这三件事;而它新增的负担,是你从此要维护一份存在于组织侧、和你本地工作区不完全同源的技能副本。 把这句话记住,后面每一节都是在给它补证据。
先做个消歧:本文说的 OpenWork 是 different-ai 这个组织下的开源桌面应用项目(仓库地址见上方版本标注),不是同名的职场点评网站,也不是”开放工作”这类泛指。它的定位仓库 README 里写得很直白——README 把自己定位成 Claude Cowork 与 Codex 的开源替代品,支持 macOS、Windows、Linux。这是项目自己的说法,不是本文替它下的判断。
一、入口只有一句话,代价藏在这句话之后
packages/docs/start-here/do-work-with-it/create-a-skill-from-chat.mdx 这份文档只有二十来行,流程简单到有点反直觉:
- 先在聊天里把活干完,别关线程;
- 用大白话说一句
Turn what we just did into a reusable skill for me; - OpenWork 把技能存进你的 OpenWork Cloud 组织,作为一个私有 plugin;
- 按提示给它起个清晰的名字,让它确认存了什么;
- 以后按名字调用,或者描述同类任务让它自己匹配触发。
这里有两个点值得你停一下。
第一,文档明确写了:这个技能不会写进你的 workspace,也不会共享给组织。也就是说,“生成”和”落到你手边的代码目录里”是两件事。文档甚至补了一句——如果你要的是 workspace 本地技能,就明说,那样它应该只建本地那一份,而不是两份都建。这条约定的存在本身就说明:两条存储路径确实同时存在,模糊表述会让你不知道东西去了哪。
第二,文档结尾那句 Note 的分量比正文还重:创建技能不等于发布,分享是你另外选择的一步。对比一下你熟悉的那些做法就明白差别了——Claude Code 的技能机制是把 SKILL.md 放进仓库目录,提交即共享;superpowers 那一套写技能的方法关注的是技能文件本身怎么写才够硬;技能的组织与分层方式讨论的是几十个技能之间怎么排布不打架。本文这篇的分工是第四件事:技能从哪里来、到哪里去,也就是生成与分发这条链路的机制和账单。
理解这个设计取向的关键,在另一份文档 skills-plugins-and-mcp.mdx 里:它写了一句”Everything is a plugin under the hood”(底层一切皆 plugin),所以你让 OpenWork 建一个技能时,它建的是一个只装着这一个技能的小 plugin。这解释了为什么生成产物会以”私有 plugin”的形态待在组织侧——单位从一开始就统一成了 plugin,分发通道才能只做一套。
二、技能落到磁盘上长什么样
聊天生成那条路走的是云侧,但 OpenWork 同样支持本地技能,而本地这一侧的代码是完全可读的。apps/server/src/skills.ts 是这块的核心,读完它你对”我的技能到底在哪、为什么没被识别”就有底了。
写入端很短:upsertSkill 调 projectSkillsDir(workspaceRoot) 拿到基准目录,然后在下面建 <技能名>/SKILL.md。而 apps/server/src/workspace-files.ts 里这个函数就一行——目录是工作区下的 .opencode/skills。
读取端复杂得多。listSkills 先做一件容易被忽略的事:从当前工作区目录逐级往上走,每走一层都记下来,直到遇到 .git 才停:
async function findWorkspaceRoots(workspaceRoot: string): Promise<string[]> {
const roots: string[] = [];
let current = resolve(workspaceRoot);
while (true) {
roots.push(current);
const gitPath = join(current, ".git");
if (await exists(gitPath)) break;
const parent = resolve(current, "..");
if (parent === current) break;
current = parent;
}
return roots;
}
每一层里它都会去看两个目录:.opencode/skills 和 .claude/skills,都算 project 作用域。打开全局开关时再补四个 home 目录下的位置:~/.config/opencode/skills、~/.claude/skills、~/.agents/skills、~/.agent/skills,算 global 作用域。兼容既有生态的意图很明显——它不要求你把已有技能搬家。
目录布局支持两种:扁平的 <dir>/<技能名>/SKILL.md,以及多一层的 <dir>/<域名>/<技能名>/SKILL.md。代码注释说明了第二种的用途:全局技能常按域分类,而且 marketplace 下发的 plugin 包会被塞进一个 plugin 文件夹做命名空间。
解析单个技能时有三道会让技能”消失”的关卡,都在 parseSkillEntry 里,且全部是静默返回 null,不抛错、不打日志:
validateSkillName不过:名字必须匹配/^[a-z0-9]+(-[a-z0-9]+)*$/,长度 1 到 64。大写字母、下划线、中文、连续连字符,一律出局。validateDescription不过:描述必须是 1 到 1024 个字符,空描述直接淘汰。- frontmatter 里写的
name和所在目录名对不上:if (name !== entryName) return null;
最后还有一道去重:所有目录扫完后按 name 建一个 Set,先出现的留下,后出现的同名项被丢弃。而目录的入列顺序是固定的——工作区从内往外,然后才是全局。所以同名技能的实际优先级由这个顺序决定,靠近你的工作区那一份会赢。
触发条件的取法也值得记:先看 frontmatter 的 trigger,没有就看 when,还没有就调 extractTriggerFromBody 去正文里找标题为 “When to use” 的那一节,取该节第一个非空条目(会剥掉列表符号和序号)。你不显式写 trigger 也能有触发描述,代价是它取的是你正文的第一句,写得随意就会变成一个含糊的触发条件。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 技能扫描与写入 | 决定技能从哪些目录被发现、名字与描述怎么校验、重名谁胜出 | apps/server/src/skills.ts | 技能死活不生效、或两个同名技能只认一个时 |
| 名称与描述校验规则 | kebab-case 正则、描述长度、MCP 配置合法性 | apps/server/src/validators.ts | 起名被拒、或描述留空导致技能被忽略时 |
| 本地技能基准目录 | 把工作区技能钉在 .opencode/skills 下 | apps/server/src/workspace-files.ts | 想手动 cd 过去改文件时 |
| frontmatter 读写 | 解析与重建 SKILL.md 头部 YAML | apps/server/src/frontmatter.ts | 排查 frontmatter 名字与目录名冲突时 |
| HTTP 路由与审批钩子 | 技能的增删查接口、审批、审计、热重载事件 | apps/server/src/server.ts | 从外部调接口写技能、或想知道谁改了什么时 |
| 云端 plugin 安装 | 把组织侧下发的对象写成本地文件并做命名空间隔离 | apps/server/src/cloud-plugins.ts | 同事发布的技能出现在你本地、或刷新后被覆盖时 |
| 生成与发布的操作说明 | 聊天生成技能、marketplace 发布与复制的官方步骤 | packages/docs/start-here/do-work-with-it/ | 第一次走这条链路时 |
三、发布被挪到了组织控制面,桌面端只负责”看”
publish-and-copy-a-skill.mdx 开头一句话就定了调:发布是 OpenWork Cloud 里的管理员动作,不是桌面端的 marketplace 编辑器。具体路径是管理员在 Cloud 面板里用 New marketplace 和 Create marketplace 建一个组织 marketplace,plugin 发布到那里之后,有访问权的同事才能看到它带的技能。访问范围可以是全组织、指定团队,或具体成员。
桌面端这边的角色被压得很窄。文档写得很明确:桌面应用在 Settings > Extensions 展示分配到的 marketplace 内容,它不创建、也不 fork marketplace。另一份 share-your-setup.mdx 补齐了细节——用 Skills 过滤器或搜索找技能,点进去 View details 看描述;本地技能可以显示位置或移除,而 OpenWork Connect 带来的技能是只读的;管理员刚发布完或刚改了你的权限,你需要点 Refresh。
这个分工带来一条硬约束:你不能就地改一个别人共享给你的技能。文档给了两条正路。一是在你自己控制的 marketplace 里做一份副本再改那份副本——代价是从此存在两份,上游更新不会自动流到你这里,你得自己盯。二是改动比较轻的时候,写一个新技能,让它先去用标准技能,再追加你的额外指令——这条更省事,代价是运行时多一跳,且依赖模型真的按顺序照做。
组织侧下发的技能落到本地时,路径规则在 cloud-plugins.ts 里写得很死:技能一律安装到 .opencode/skills/<命名空间>/<技能名>/SKILL.md,命名空间由 plugin 名和 id 推出来。这就接上了第二节说的多一层目录布局。同一份代码里还有一个细节值得你知道——卸载时,如果路径匹配 .opencode/skills/<x>/<y>/SKILL.md 这个形状,它删的是整个技能目录,不是单个文件。你如果往那个目录里塞了自己的参考资料,会一起没。
删除本地技能也有类似的连带效应。deleteSkill 先按扁平路径找,找不到就回头调 listSkills 找同名的 project 作用域技能,然后 rm 掉 dirname(item.path)。也就是说,你删一个名字,实际删掉的可能是命名空间下那个由 plugin 装进来的目录。
四、边界与代价:它明确不管的事
这一节请慢读,因为放弃的东西比得到的更需要你提前知情。
它不管你的技能内容对不对。 校验只有三样:名字合法、描述非空且不超长、frontmatter 名字与目录名一致。正文写得再离谱也照收。这意味着”从聊天生成”的技能质量完全取决于那次对话的质量——如果那次是靠你在旁边反复纠偏才做对的,生成出来的技能大概率把纠偏过程丢了,只留下了结果的形状。
它不管版本与变更历史。 技能生成后待在组织侧,本地这一份是被写入或被覆盖的产物。你熟悉的 diff、blame、review 在这条链路上不自动成立。仓库根目录有一份 skills-lock.json,结构是 version 加一个 skills 映射,每项带 source、sourceType、skillPath、computedHash —— 项目自己在给引入的技能做来源与哈希登记。这说明维护者也认为”技能来自哪、有没有被改过”是个需要显式记录的问题,而不是能靠直觉管住的东西。
它不管错误提示。 前面说过的三道校验和去重逻辑全部静默。你的技能没生效时,界面上不会告诉你是名字非法、描述为空、还是被一个同名技能压住了。排查方式只能是回到 skills.ts 的规则逐条对照。
它不适合什么场景。 至少三类:技能里的步骤高度依赖你本机独有的路径或环境时,共享出去对方跑不通;任务的正确性依赖精确的数据契约而不是自然语言步骤时,结构化输出与约束那一类做法比一份 Markdown 技能更靠谱;技能需要频繁改、且改动必须被评审时,走组织 marketplace 的副本机制会比走版本控制笨重得多。
权限与数据流向,这块必须说清楚。 这是一个装在你机器上的桌面应用,围绕它的这套体系会碰到这几件事:
- 模型凭据集中保管。 文档里有单独的接入指引,组织侧也可以下发受管的模型服务。方便的另一面是暴露面收敛到了一处——它被拿到,等于你所有模型调用被拿到。具体的分级与限制规则会调整,以官方最新说明为准。
- 第三方服务授权是真授权。
connect-services.mdx列了 Gmail、Google Calendar、Google Drive、Slack、Notion、Linear 等,流程是在浏览器里到服务商页面点同意。授权一次之后,agent 就能在那个系统里动作。文档里给的示例是”总结我最新的五封邮件”这种——你要清楚,能读五封就能读全部,授权范围是你在服务商页面上勾的那个范围,不是你这句话的范围。 - 组织侧的可见与可控。 README 描述 Den 这个控制面时提到:按规模供给推理并控制哪些成员和团队能用哪个模型服务商、设置桌面策略、限制本地模型访问、控制组织允许的应用版本、通过 marketplace 发布技能与插件并分配给组织/团队/个人。换句话说,装了它并加入组织,管理员对你这台机器上这个应用的能力边界是有话语权的。
- 写技能这个动作本身带审批与审计。 服务端那条
POST /workspace/:id/skills路由在真正写盘前会走requireApproval,动作名skills.upsert,并把将要写入的路径一起带上;写完调recordAudit追加一条审计,再发一个skills的热重载事件通知前端。而apps/server/src/approvals.ts里有一条你必须知道的分支:审批服务的 mode 如果是"auto",requestApproval直接返回放行,根本不弹。挂起等待的那条路超时后会返回不允许,原因记为timeout。审批是否真的拦得住,取决于这个 mode 配成了什么。 - 许可证是分层的。 根目录
LICENSE先声明分层:/ee目录下的内容按ee/LICENSE里定义的许可证(根 LICENSE 在括号里把它叫作 Fair Source License),目录之外的部分才是 MIT(Copyright 2026 Different AI)。而打开ee/LICENSE会看到文件自己的抬头是 Functional Source License, Version 1.1, MIT Future License,缩写 FSL-1.1-MIT。两处措辞并不完全一致,判断权责时以ee/LICENSE原文为准。而ee/apps/下正是那 10 个企业侧应用(den 系列的 api、web、controller、gateway 等),ee/packages/下 3 个。所以你看到的团队控制面、组织 marketplace 这些能力,不在 MIT 那一侧。能不能商用、能不能改,一律以许可证原文为准,本文不提供法律意见。
五、上手与避坑清单
每条都写清楚为什么会踩,以及怎么绕开。
一、别在聊天里含糊说”存成技能”。
为什么会踩:默认路径是存到云端组织作为私有 plugin,不写工作区。你以为它在你仓库里,git status 却干干净净,接着你会怀疑是不是没保存成功。
怎么避:明确说清你要的是工作区本地技能还是组织侧的。文档专门提醒过,说清楚它才只建你要的那一份,不会两边都建。
二、技能名只用小写字母、数字和单个连字符。
为什么会踩:正则是 /^[a-z0-9]+(-[a-z0-9]+)*$/。My_Skill、ci--deploy、结尾带连字符的名字全都不合法。走接口写会拿到 400,走目录扫描则是被静默跳过——后者最难查。
怎么避:起名前先按这个正则自查一遍,长度控制在 64 以内。
三、frontmatter 里的 name 必须等于目录名。
为什么会踩:你复制一个技能目录改名,忘了改里面的 name,扫描时 name !== entryName 直接返回 null,技能凭空消失,且没有任何提示。走写入接口则会拿到 invalid_skill_name,说 frontmatter 名字必须与载荷名字一致。
怎么避:把”改目录名就改 frontmatter”当成一个动作。排查技能失踪时,这一条优先于其它猜测。
四、描述不能空着。
为什么会踩:validateDescription 要求 1 到 1024 个字符,空描述让技能在扫描阶段就被淘汰。手写技能时描述常常是最后才补的那一项。
怎么避:先写描述再写正文。顺带一提,描述也是模型判断要不要用这个技能的主要依据,写清楚触发场景比写清楚功能更有用。
五、同名技能只有一份会生效,别指望覆盖。
为什么会踩:去重是先到先得,而目录顺序是工作区由内向外、再到全局。你在全局放了一份”新版”,工作区里那份旧的会一直赢,你却以为自己更新了。
怎么避:改就改生效的那一份。不确定哪份生效时,按扫描顺序自己推一遍:工作区当前目录 → 逐级向上到 .git → 全局四个目录,每层内先 .opencode/skills 后 .claude/skills。
六、别在 plugin 命名空间目录里放私货。
为什么会踩:组织下发的技能装在 .opencode/skills/<命名空间>/<技能名>/ 下,卸载逻辑对这个形状的路径是删整个目录。你放在旁边的笔记、样例数据会陪葬。
怎么避:自己的东西放自己的技能目录,命名空间目录当成只读的下发区。
七、别在共享技能上就地改。 为什么会踩:桌面端不创建也不 fork marketplace,OpenWork Connect 带来的技能是只读的,你改不动;即使能改,也会影响所有人。 怎么避:按文档给的两条路走——在你能控制的 marketplace 里做副本并改副本,或者写一个新技能引用标准技能再叠加自己的指令。选前者就把”上游更新后要重新对齐”写进你的例行事项。
八、先确认审批模式再声称”有人工把关”。
为什么会踩:skills.upsert 这条审批看着很稳,但 mode 为 "auto" 时直接放行。你对外说”技能变更都要审批”,实际可能一次都没弹过。
怎么避:跑一次改动,看它到底弹不弹;同时去看审计文件里有没有对应的记录——apps/server/src/audit.ts 里写的是 jsonl 追加,工作区那份落在 .opencode/openwork/audit.jsonl。这类”看起来有护栏、实际取决于一个配置项”的情况,在 agent 工具里很常见,和最小权限怎么落地是同一类问题。
九、给外部服务授权前,先想清楚范围。
为什么会踩:Connect 的体验被刻意做得很轻,一个 Connect 按钮加一次浏览器同意就完成了,容易在没细看勾选项的情况下点过去。而授权之后 agent 是真能在那个系统里动手的。
怎么避:把授权当成”给一个会自己决定做什么的程序开账号”来审。涉及 MCP 那一层的加固思路,可以对照MCP 授权加固那套做法。
六、收尾
回到开头那句判断。这条链路省掉的是决策成本——你不用再纠结技能放哪个目录、叫什么、怎么让同事拿到,说一句话就有了。它新增的是同源性负担:技能有了云侧和本地两个存在形态,分发靠 marketplace 而不是版本控制,改共享技能要靠副本,而副本天然会漂移。这笔账划不划算,取决于你的技能是”几个人各自的手法”还是”一个系统的操作规程”。文档里的判断是后者更值得沉淀成能共享的单元。
留一份自检清单给你,照着走一遍再上手:
- 你要的技能是本地的还是组织侧的,说出口的那句话里有没有讲清楚;
- 技能名过没过 kebab-case 正则,frontmatter 的
name和目录名一不一致; - 描述是不是空的,触发场景写没写进去;
- 有没有同名技能会在扫描顺序上压住你这份;
- 审批模式是不是
"auto",你以为的把关存不存在; - 授权出去的服务,权限范围你确认过没有。
接下来该读哪个文件:想弄清技能为什么不生效,读 apps/server/src/skills.ts 和 apps/server/src/validators.ts;想弄清组织下发的东西怎么落到你磁盘上,读 apps/server/src/cloud-plugins.ts;想按官方步骤跑通发布,读 packages/docs/start-here/do-work-with-it/ 下那几份 mdx,以及 packages/docs/cloud/team-quickstart.mdx 里的管理员路径。这个仓库的文档不算多但写得实,packages/docs/ 一共 57 份 mdx,其中 model-context-protocol/ 那 10 份是各家客户端的接入指南;架构层面的说明在根目录的 docs/ 里,20 份 md。全仓 3490 个受版本控制的文件,apps/ 4 个、packages/ 12 个,apps/server/src/ 顶层就有 138 个 .ts —— 心里有这个体量感,再决定要读到多深。
本篇属于一个把开源AI 工作流桌面应用 OpenWork逐层拆开讲的系列,整体地图见 OpenWork 是什么:把技能与 MCP 打包成能力的开源桌面应用;沿着这条线往下,还可以看 开源项目 OpenWork 不装桌面应用也能用:两个 MCP 工具接进 Agent 和 OpenWork 开源桌面应用:技能、插件与 MCP 各在哪一层起作用。