OpenWork 开源桌面应用实操:把一次聊天变成可发布复用的技能

2026-08-04

本文基于 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 这份文档只有二十来行,流程简单到有点反直觉:

  1. 先在聊天里把活干完,别关线程;
  2. 用大白话说一句 Turn what we just did into a reusable skill for me
  3. OpenWork 把技能存进你的 OpenWork Cloud 组织,作为一个私有 plugin
  4. 按提示给它起个清晰的名字,让它确认存了什么;
  5. 以后按名字调用,或者描述同类任务让它自己匹配触发。

这里有两个点值得你停一下。

第一,文档明确写了:这个技能不会写进你的 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 是这块的核心,读完它你对”我的技能到底在哪、为什么没被识别”就有底了。

写入端很短:upsertSkillprojectSkillsDir(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/skillsapps/server/src/workspace-files.ts想手动 cd 过去改文件时
frontmatter 读写解析与重建 SKILL.md 头部 YAMLapps/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 marketplaceCreate 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 作用域技能,然后 rmdirname(item.path)。也就是说,你删一个名字,实际删掉的可能是命名空间下那个由 plugin 装进来的目录。

四、边界与代价:它明确不管的事

这一节请慢读,因为放弃的东西比得到的更需要你提前知情。

它不管你的技能内容对不对。 校验只有三样:名字合法、描述非空且不超长、frontmatter 名字与目录名一致。正文写得再离谱也照收。这意味着”从聊天生成”的技能质量完全取决于那次对话的质量——如果那次是靠你在旁边反复纠偏才做对的,生成出来的技能大概率把纠偏过程丢了,只留下了结果的形状。

它不管版本与变更历史。 技能生成后待在组织侧,本地这一份是被写入或被覆盖的产物。你熟悉的 diff、blame、review 在这条链路上不自动成立。仓库根目录有一份 skills-lock.json,结构是 version 加一个 skills 映射,每项带 sourcesourceTypeskillPathcomputedHash —— 项目自己在给引入的技能做来源与哈希登记。这说明维护者也认为”技能来自哪、有没有被改过”是个需要显式记录的问题,而不是能靠直觉管住的东西。

它不管错误提示。 前面说过的三道校验和去重逻辑全部静默。你的技能没生效时,界面上不会告诉你是名字非法、描述为空、还是被一个同名技能压住了。排查方式只能是回到 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_Skillci--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 而不是版本控制,改共享技能要靠副本,而副本天然会漂移。这笔账划不划算,取决于你的技能是”几个人各自的手法”还是”一个系统的操作规程”。文档里的判断是后者更值得沉淀成能共享的单元。

留一份自检清单给你,照着走一遍再上手:

  1. 你要的技能是本地的还是组织侧的,说出口的那句话里有没有讲清楚;
  2. 技能名过没过 kebab-case 正则,frontmatter 的 name 和目录名一不一致;
  3. 描述是不是空的,触发场景写没写进去;
  4. 有没有同名技能会在扫描顺序上压住你这份;
  5. 审批模式是不是 "auto",你以为的把关存不存在;
  6. 授权出去的服务,权限范围你确认过没有。

接下来该读哪个文件:想弄清技能为什么不生效,读 apps/server/src/skills.tsapps/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 工具接进 AgentOpenWork 开源桌面应用:技能、插件与 MCP 各在哪一层起作用

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。