@ww-ai-lab/openclaw-office 2026.4.13 → 2026.5.10-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.en.md +15 -0
  2. package/README.md +15 -0
  3. package/SKILL-WORKBENCH.md +173 -0
  4. package/bin/openclaw-office.js +269 -2
  5. package/bin/skills/skill-workbench-mermaid-guard/SKILL.md +204 -0
  6. package/bin/skills/skill-workbench-mermaid-guard/references/mermaid-normalization-checklist.md +61 -0
  7. package/dist/assets/{ActivityHeatmap-C7_SEVv4.js → ActivityHeatmap-C1srrtD6.js} +1 -1
  8. package/dist/assets/{CostPieChart-onTE06Lb.js → CostPieChart-jTDIKJKi.js} +3 -3
  9. package/dist/assets/MermaidPreview-BKB1lc9t.js +2 -0
  10. package/dist/assets/{NetworkGraph-CU-v0Mdg.js → NetworkGraph-C5O8YhFN.js} +1 -1
  11. package/dist/assets/{TokenLineChart-DhBq4KfY.js → TokenLineChart-BL-6i__E.js} +2 -2
  12. package/dist/assets/_baseUniq-CJ0amYv4.js +1 -0
  13. package/dist/assets/arc-DtWrmhPk.js +1 -0
  14. package/dist/assets/architectureDiagram-Q4EWVU46-DeSFln3-.js +36 -0
  15. package/dist/assets/band-CquvqAHh.js +1 -0
  16. package/dist/assets/blockDiagram-DXYQGD6D-CA9TMoZ4.js +132 -0
  17. package/dist/assets/c4Diagram-AHTNJAMY-YJafaBps.js +10 -0
  18. package/dist/assets/channel-CA6xFE7p.js +1 -0
  19. package/dist/assets/chunk-4BX2VUAB-nh2pX3Nx.js +1 -0
  20. package/dist/assets/chunk-4TB4RGXK-Ck4hmMbI.js +206 -0
  21. package/dist/assets/chunk-55IACEB6-Dohe3-od.js +1 -0
  22. package/dist/assets/chunk-EDXVE4YY-B6HcSB3P.js +1 -0
  23. package/dist/assets/chunk-FMBD7UC4-BgWtwZkB.js +15 -0
  24. package/dist/assets/chunk-OYMX7WX6-GrZYM6X2.js +231 -0
  25. package/dist/assets/chunk-QZHKN3VN-B9iAeCSA.js +1 -0
  26. package/dist/assets/chunk-YZCP3GAM-DBwBduPH.js +1 -0
  27. package/dist/assets/classDiagram-6PBFFD2Q-CcXJj7G2.js +1 -0
  28. package/dist/assets/classDiagram-v2-HSJHXN6E-CcXJj7G2.js +1 -0
  29. package/dist/assets/clone-BdsGUENm.js +1 -0
  30. package/dist/assets/cose-bilkent-S5V4N54A-4VahiGNV.js +1 -0
  31. package/dist/assets/cytoscape.esm-D_LviqZs.js +331 -0
  32. package/dist/assets/dagre-KV5264BT-uVa22zdh.js +4 -0
  33. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  34. package/dist/assets/diagram-5BDNPKRD-dFNyzXzn.js +10 -0
  35. package/dist/assets/diagram-G4DWMVQ6-Dh3XH00E.js +24 -0
  36. package/dist/assets/diagram-MMDJMWI5-BPU3P557.js +43 -0
  37. package/dist/assets/diagram-TYMM5635-BprwIid5.js +24 -0
  38. package/dist/assets/erDiagram-SMLLAGMA-C2-kxsoc.js +85 -0
  39. package/dist/assets/flowDiagram-DWJPFMVM-C17b_QIv.js +162 -0
  40. package/dist/assets/ganttDiagram-T4ZO3ILL-CrKN9rGM.js +292 -0
  41. package/dist/assets/generateCategoricalChart-5UKClosQ.js +69 -0
  42. package/dist/assets/gitGraphDiagram-UUTBAWPF-BRInkHx8.js +106 -0
  43. package/dist/assets/graph-Buf-jB2B.js +1 -0
  44. package/dist/assets/index-8hik0g_4.js +636 -0
  45. package/dist/assets/index-B2buQJXY.css +1 -0
  46. package/dist/assets/infoDiagram-42DDH7IO-B960AdCn.js +2 -0
  47. package/dist/assets/init-Dmth1JHB.js +1 -0
  48. package/dist/assets/ishikawaDiagram-UXIWVN3A-WJ0rfdQV.js +70 -0
  49. package/dist/assets/journeyDiagram-VCZTEJTY-3nEq0MOA.js +139 -0
  50. package/dist/assets/kanban-definition-6JOO6SKY-Bqh9YZ7G.js +89 -0
  51. package/dist/assets/katex-DkKDou_j.js +257 -0
  52. package/dist/assets/layout-BcJZcLro.js +1 -0
  53. package/dist/assets/linear-DSkd6W2C.js +1 -0
  54. package/dist/assets/mermaid.core-OcDtQnPI.js +309 -0
  55. package/dist/assets/min-DohY4Jor.js +1 -0
  56. package/dist/assets/mindmap-definition-QFDTVHPH-2n76TNG7.js +96 -0
  57. package/dist/assets/ordinal-DILIJJjt.js +1 -0
  58. package/dist/assets/pieDiagram-DEJITSTG-CibkTC3s.js +30 -0
  59. package/dist/assets/quadrantDiagram-34T5L4WZ-TZpmE7pM.js +7 -0
  60. package/dist/assets/requirementDiagram-MS252O5E-DGvHfmgu.js +84 -0
  61. package/dist/assets/sankeyDiagram-XADWPNL6-DIo1wo83.js +10 -0
  62. package/dist/assets/sequenceDiagram-FGHM5R23-B2kLdh9J.js +157 -0
  63. package/dist/assets/stateDiagram-FHFEXIEX-Dyn4zVfT.js +1 -0
  64. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BxgBPEmU.js +1 -0
  65. package/dist/assets/string-Bl2zznvy.js +1 -0
  66. package/dist/assets/time-DXBSQWow.js +1 -0
  67. package/dist/assets/timeline-definition-GMOUNBTQ-DMOONwDf.js +120 -0
  68. package/dist/assets/vennDiagram-DHZGUBPP-BgOX2eWB.js +34 -0
  69. package/dist/assets/wardley-RL74JXVD-Dfh9gZ6_.js +162 -0
  70. package/dist/assets/wardleyDiagram-NUSXRM2D-BLgGvp65.js +20 -0
  71. package/dist/assets/xychartDiagram-5P7HB3ND-C8rQGFQL.js +7 -0
  72. package/dist/index.html +2 -2
  73. package/package.json +7 -2
  74. package/dist/assets/generateCategoricalChart-BzAKybGu.js +0 -69
  75. package/dist/assets/index-DM-0MXK4.js +0 -509
  76. package/dist/assets/index-DpD6VLGh.css +0 -1
package/README.en.md CHANGED
@@ -43,6 +43,7 @@ Full system management interface with dedicated pages:
43
43
  | **Agents** | Agent list/create/delete, detail tabs (Overview, Channels, Cron, Skills, Tools, Files) |
44
44
  | **Channels** | Channel cards, configuration dialogs, stats, WhatsApp QR binding |
45
45
  | **Skills** | Skill marketplace, install options, skill detail dialogs |
46
+ | **Skill Workbench** ✨ | **Skills development platform** — nested routes (list / create / detail), AI-assisted creation and editing via side chat, automated FLOWCHART.md generation with pure-Markdown multi-chart preview |
46
47
  | **Cron** | Scheduled task management and statistics |
47
48
  | **Settings** | Provider management (add/edit/model editor), appearance, Gateway, developer, advanced, about, update |
48
49
 
@@ -60,6 +61,20 @@ Full system management interface with dedicated pages:
60
61
 
61
62
  ---
62
63
 
64
+ ## New: Skill Workbench (Skills Development Platform)
65
+
66
+ Starting from `2026.5.10-beta.1`, the console ships a new **Skill Workbench** — a full AI-assisted development environment for Skills:
67
+
68
+ - **Nested three-page routing**: `/skill-workbench` (list), `/skill-workbench/create` (wizard), `/skill-workbench/:slug` (detail)
69
+ - **Chat-side-panel driven development**: create and modify Skills through a conversation; file changes are flushed to disk in real time
70
+ - **Automated FLOWCHART.md generation**: a built-in guard skill (`skill-workbench-mermaid-guard`) produces compliant, colored Mermaid flowcharts (single- or multi-chart mode) on demand
71
+ - **Pure-Markdown multi-chart preview**: the flowchart panel renders multiple Mermaid fenced blocks directly through the Markdown pipeline
72
+ - **Zero-setup default skill install**: on first entry, the embedded server copies bundled default skills from the npm package into `~/.openclaw/workspace/skills/` — no manual install required
73
+
74
+ See the full guide in [SKILL-WORKBENCH.md](./SKILL-WORKBENCH.md).
75
+
76
+ ---
77
+
63
78
  ## Tech Stack
64
79
 
65
80
  | Layer | Technology |
package/README.md CHANGED
@@ -43,6 +43,7 @@
43
43
  | **Agents** | Agent 列表/创建/删除,详情多 Tab(Overview/Channels/Cron/Skills/Tools/Files) |
44
44
  | **Channels** | 渠道卡片、配置对话框、统计、WhatsApp QR 绑定流程 |
45
45
  | **Skills** | 技能市场、安装选项、技能详情 |
46
+ | **Skill Workbench** ✨ | **Skills 开发平台** — 三页面(列表 / 创建 / 详情)嵌套路由,聊天侧边栏驱动 AI 协作创建和修改 Skill,FLOWCHART.md 自动生成并提供纯 Markdown 多图预览 |
46
47
  | **Cron** | 定时任务管理和统计 |
47
48
  | **Settings** | Provider 管理(添加/编辑/模型编辑器)、外观/Gateway/开发者/高级/关于/更新 |
48
49
 
@@ -60,6 +61,20 @@
60
61
 
61
62
  ---
62
63
 
64
+ ## 新特性:Skill Workbench(Skills 开发平台)
65
+
66
+ 从 `2026.5.10-beta.1` 起,控制台新增了 **Skill Workbench**(Skills 开发平台),把「创建 Skill / 修改 Skill / 生成流程图」整合成一个完整的 AI 协作开发环境:
67
+
68
+ - **三页面嵌套路由**:`/skill-workbench` 列表页、`/skill-workbench/create` 创建向导、`/skill-workbench/:slug` 详情页
69
+ - **聊天侧边栏驱动开发**:在侧边栏和 AI 对话即可创建、修改 Skill,文件变动实时同步磁盘
70
+ - **FLOWCHART.md 自动生成**:内置 [`skill-workbench-mermaid-guard`](./SKILL-WORKBENCH.md) 守卫技能,一键生成符合规范的彩色 Mermaid 流程图,支持单图与多图模式
71
+ - **纯 Markdown 多图预览**:流程图预览完全走 Markdown 渲染链路,一次渲染多个 Mermaid 代码块
72
+ - **默认技能自动安装**:首次进入工作台时,嵌入式服务端会自动把 npm 包内置的默认 Skill 拷贝到 `~/.openclaw/workspace/skills/`,无需手工安装
73
+
74
+ 详细使用指南见 [SKILL-WORKBENCH.md](./SKILL-WORKBENCH.md)。
75
+
76
+ ---
77
+
63
78
  ## 技术栈
64
79
 
65
80
  | 层 | 技术 |
@@ -0,0 +1,173 @@
1
+ # Skill Workbench — Skills 开发平台使用指南
2
+
3
+ > 一个面向 OpenClaw Skills 的可视化开发平台,集成在 OpenClaw Office 控制台中。
4
+ > 发布版本:`2026.5.10-beta.1` 起正式提供。
5
+
6
+ ---
7
+
8
+ ## 1. 定位
9
+
10
+ **Skill Workbench** 把"Skill 开发"从写 Markdown 的纯文本工作流,升级为一个**聊天驱动 + 可视预览 + 一键生成流程图**的开发平台:
11
+
12
+ - 所有产物都是普通的 Markdown / YAML 文件,落盘在 `~/.openclaw/workspace/skills/<skill-slug>/` 下
13
+ - 聊天侧边栏直接挂载到 OpenClaw Gateway,借助 AI 与本地 Skill 文件工具完成增删改
14
+ - 详情页提供 `SKILL.md`、`FLOWCHART.md` 及其它自定义文件的切换浏览与原地编辑
15
+ - 流程图走纯 Markdown 渲染链路,天然支持**一份 FLOWCHART.md 内多个 Mermaid 代码块**
16
+
17
+ 可以把它理解为 "OpenClaw Skills 版的 Notion + VS Code":写作在前,文件在后。
18
+
19
+ ---
20
+
21
+ ## 2. 入口与路由
22
+
23
+ 工作台采用**嵌套路由三页面**结构:
24
+
25
+ | 路径 | 页面 | 作用 |
26
+ | ----------------------------------- | ---------------- | -------------------------------------------------------------- |
27
+ | `/skill-workbench` | 列表首页 | 展示本地已安装的 Skills,提供创建入口和搜索 |
28
+ | `/skill-workbench/create` | 创建向导 | 与 AI 对话,基于需求描述生成全新的 Skill 骨架与 `SKILL.md` |
29
+ | `/skill-workbench/:slug` | 详情页 | 浏览 / 编辑某个 Skill 的全部文件;默认展示 `FLOWCHART.md` 预览 |
30
+
31
+ 从控制台左侧导航即可进入;子路由之间通过 `AppShell` 内部的 `Outlet` 切换,不会打断底部 Chat Dock 的会话状态。
32
+
33
+ ---
34
+
35
+ ## 3. 创建一个新 Skill
36
+
37
+ 1. 进入 `/skill-workbench`,点击 **"新建 Skill"**。
38
+ 2. 在创建向导页的侧边栏与 AI 对话描述你的需求(例如"帮我生成一个能把长文章总结成三点摘要的 Skill")。
39
+ 3. AI 会基于内置 [`skill-workbench-creator`] 默认技能,产出:
40
+ - 规范的 `SKILL.md`(带 YAML frontmatter:`name`、`description`)
41
+ - 初版工作流程描述
42
+ 4. 保存后 Skill 立即落盘到 `~/.openclaw/workspace/skills/<slug>/`,并在列表页可见。
43
+ 5. 首次保存后通常**紧接着让 AI 生成 `FLOWCHART.md`**(见第 5 节)。
44
+
45
+ ---
46
+
47
+ ## 4. 编辑已有 Skill
48
+
49
+ 在 `/skill-workbench/:slug` 详情页:
50
+
51
+ - **左侧文件树** — 列出该 Skill 目录下所有文件,点击即可浏览;`FLOWCHART.md` 始终排在最上方
52
+ - **中间主视图** — 根据选中项切换:
53
+ - 选中 `FLOWCHART.md` → 进入纯 Markdown 多图预览
54
+ - 选中其它文件 → 进入 `SkillFileViewer` 的原地编辑器
55
+ - **右侧聊天栏(可折叠)** — 点击 "修改此 Skill" 开启专用会话:
56
+ - 会话 key 会带时间戳,保证与历史对话隔离
57
+ - 会话启动时会注入当前 Skill 目录与默认守卫技能作为 system 上下文
58
+ - 用自然语言描述修改点,AI 会直接用 `read` / `write` / `edit` 工具改盘
59
+
60
+ 所有变更都是**即时落盘**的——刷新页面即可看到最新内容;不需要保存按钮。
61
+
62
+ ---
63
+
64
+ ## 5. 流程图:FLOWCHART.md 一键生成
65
+
66
+ ### 交互
67
+
68
+ 在详情页选中 **FLOWCHART.md**:
69
+
70
+ - 若该 Skill 尚未有 `FLOWCHART.md`,预览区会显示 **"一键生成流程图"** 按钮
71
+ - 点击后工作台会:
72
+ 1. 自动发送符合 `skill-workbench-mermaid-guard` 规范的生成指令
73
+ 2. 流式写出 Mermaid 源
74
+ 3. 把完整的 `FLOWCHART.md` 写回到磁盘
75
+ 4. 预览区自动刷新为最终渲染结果
76
+
77
+ ### 纯 Markdown 多图预览
78
+
79
+ 预览面板不再做特殊裁剪:**整份 `FLOWCHART.md` 作为 Markdown 文档渲染**,内部的每一个 ```mermaid 代码块都会各自被 `MermaidPreview` 渲染成独立 SVG。这意味着:
80
+
81
+ - 一个 Skill 可以拥有一张总览图 + 若干分阶段子图
82
+ - 每个代码块前可以加 `##` 二级标题,互相之间可以穿插文字说明
83
+ - 预览体验与 GitHub / VS Code 等 Markdown 预览保持一致
84
+
85
+ ### 颜色 / 结构规范
86
+
87
+ 流程图生成严格遵循内置守卫技能 `skill-workbench-mermaid-guard`:
88
+
89
+ | 节点类型 | classDef | 颜色含义 | 使用场景 |
90
+ | -------------- | ----------- | -------- | ------------------------------------------------- |
91
+ | 开始节点 | `startNode` | 绿色 | 流程入口 |
92
+ | 成功结束 | `endOk` | 蓝色 | 正常/成功出口 |
93
+ | 失败/错误结束 | `endErr` | 红色 | 错误/拒绝出口 |
94
+ | 决策节点 | `decision` | 琥珀色 | `{...}` 菱形判断 |
95
+ | 普通步骤 | `process` | 浅蓝 | 执行 / 操作节点 |
96
+ | 阶段节点 | `phase` | 紫色 | 总览图中代表一个阶段(内部可能展开为一张子流程图)|
97
+
98
+ ---
99
+
100
+ ## 6. 默认技能的自动安装
101
+
102
+ 工作台依赖两个"默认技能"注入到 AI 的 system 上下文:
103
+
104
+ | Skill slug | 作用 |
105
+ | ------------------------------------ | ----------------------------------------------------- |
106
+ | `skill-workbench-creator` | 引导 AI 生成规范的 `SKILL.md` 骨架 |
107
+ | `skill-workbench-mermaid-guard` ✨ | 保障 `FLOWCHART.md` 输出的格式 / 颜色 / 规范 |
108
+
109
+ 从 `2026.5.10-beta.1` 起:
110
+
111
+ - `skill-workbench-mermaid-guard` 的完整源码随 npm 包一起发布(放在 `bin/skills/` 目录下)
112
+ - 嵌入式 Node 服务端新增 `POST /api/workspace-skills/ensure-defaults` 端点
113
+ - 前端在每次打开 / 修改工作台(调用 `enterWorkbench()`)之前,都会先调一次该端点
114
+ - 如果 `~/.openclaw/workspace/skills/<slug>/SKILL.md` 不存在,服务端会**自动递归拷贝**内置版本到用户工作区
115
+ - 已存在则不做任何事(幂等);整个过程对用户无感
116
+
117
+ 如果你手动删掉了 `~/.openclaw/workspace/skills/skill-workbench-mermaid-guard/`,下一次进入工作台会被重新生成。
118
+
119
+ ---
120
+
121
+ ## 7. 与 OpenClaw Gateway 的协作关系
122
+
123
+ 工作台的数据流如下:
124
+
125
+ ```
126
+ Browser ── /api/workspace-skills/* ──► Embedded Node server (bin/openclaw-office.js)
127
+ │ │
128
+ │ └─► Local FS: ~/.openclaw/workspace/skills/
129
+
130
+ └── WebSocket /gateway-ws ─────────► OpenClaw Gateway (chat & tool calls)
131
+ ```
132
+
133
+ - **文件 CRUD** 直接走嵌入式服务端,读写 `~/.openclaw/workspace/skills/`;完全不经 Gateway
134
+ - **聊天 / 工具调用** 走 Gateway WebSocket;AI 使用 Gateway 提供的 `read` / `write` / `edit` 等工具直接改本地文件
135
+ - **Git 提交**(可选)由嵌入式服务端 `commit` 端点完成,默认提交消息为 `chore(skill): update <slug>/<file> via OpenClaw Office`
136
+
137
+ ---
138
+
139
+ ## 8. 常见问题
140
+
141
+ **Q: 流程图生成一直卡在 "生成中…"?**
142
+ A: 检查 Gateway 连接状态(顶部应为 Connected),并确认所选 Agent 有可用的模型 Provider。生成完成的标志是 `FLOWCHART.md` 被写入磁盘;若 AI 只是回复了解释文字但没有真正写文件,请再次点击 "一键生成",守卫技能会强制直接调用 write 工具。
143
+
144
+ **Q: 自动安装默认技能失败怎么办?**
145
+ A: 进入工作台时浏览器控制台会打印 `[skill-workbench] ensure default skills failed:` 警告。失败不会阻塞工作台本身;你可以手动从本项目的 `bin/skills/skill-workbench-mermaid-guard/` 拷贝到 `~/.openclaw/workspace/skills/` 作为 fallback。
146
+
147
+ **Q: 我想用我自己的守卫技能替换默认的,行吗?**
148
+ A: 可以。只要在 `~/.openclaw/workspace/skills/skill-workbench-mermaid-guard/SKILL.md` 存在自定义实现,`ensure-defaults` 就不会覆盖——它只在缺失时安装。
149
+
150
+ **Q: 文件跟不上磁盘变化?**
151
+ A: 详情页的文件树与 FLOWCHART 预览会在每一轮聊天流结束时自动刷新;若仍有偏差,切换选中的文件或返回列表页再进入即可强制重新拉取。
152
+
153
+ ---
154
+
155
+ ## 9. 相关文件
156
+
157
+ - 前端入口:[src/components/pages/SkillWorkbenchPage.tsx](./src/components/pages/)、`SkillWorkbenchCreatePage.tsx`、`SkillWorkbenchDetailPage.tsx`
158
+ - 工作台状态:[src/store/console-stores/skill-workbench-store.ts](./src/store/console-stores/skill-workbench-store.ts)
159
+ - 嵌入式 API:[bin/openclaw-office.js](./bin/openclaw-office.js) 中的 `handleWorkspaceSkillsApi`
160
+ - 内置默认技能:[bin/skills/skill-workbench-mermaid-guard/](./bin/skills/skill-workbench-mermaid-guard/)
161
+ - 前端客户端:[src/gateway/workspace-skills-client.ts](./src/gateway/workspace-skills-client.ts)
162
+
163
+ ---
164
+
165
+ ## 10. 版本记录
166
+
167
+ - **2026.5.10-beta.1** — 首次对外发布 Skill Workbench
168
+ - 三页面嵌套路由
169
+ - FLOWCHART.md 纯 Markdown 多图预览
170
+ - 内置 `skill-workbench-mermaid-guard` 并提供 `ensure-defaults` 自动安装
171
+ - 创建模式首轮 session & skill 注入修复
172
+
173
+ [`skill-workbench-creator`]: https://github.com/openclaw/openclaw
@@ -3,10 +3,11 @@
3
3
  import { createServer, request as httpRequest } from "node:http";
4
4
  import { request as httpsRequest } from "node:https";
5
5
  import { readFileSync, existsSync, mkdirSync } from "node:fs";
6
- import { readFile, writeFile, access, readdir, unlink, mkdir } from "node:fs/promises";
7
- import { resolve, join, extname } from "node:path";
6
+ import { readFile, writeFile, access, readdir, unlink, mkdir, stat } from "node:fs/promises";
7
+ import { resolve, join, extname, dirname, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { networkInterfaces, homedir } from "node:os";
10
+ import { execFile } from "node:child_process";
10
11
 
11
12
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
12
13
  const distDir = resolve(__dirname, "..", "dist");
@@ -159,6 +160,9 @@ const runtimeConfig = JSON.stringify({
159
160
  });
160
161
  const configScript = `<script>window.__OPENCLAW_CONFIG__=${runtimeConfig};</script>`;
161
162
  const gatewayWsPrefixes = new Set(["/gateway-ws", "/api/gateway/ws"]);
163
+ const WORKSPACE_SKILLS_DIR = join(homedir(), ".openclaw", "workspace", "skills");
164
+ const BUNDLED_SKILLS_DIR = resolve(__dirname, "skills");
165
+ const DEFAULT_BUNDLED_SKILL_SLUGS = ["skill-workbench-mermaid-guard"];
162
166
 
163
167
  async function tryReadFile(filePath) {
164
168
  try {
@@ -379,6 +383,161 @@ async function safeReaddir(dir) {
379
383
  }
380
384
  }
381
385
 
386
+ function ensureInside(baseDir, targetPath) {
387
+ const normalizedBase = `${resolve(baseDir)}/`;
388
+ const normalizedTarget = resolve(targetPath);
389
+ if (normalizedTarget !== resolve(baseDir) && !normalizedTarget.startsWith(normalizedBase)) {
390
+ throw new Error("Invalid workspace skill path");
391
+ }
392
+ return normalizedTarget;
393
+ }
394
+
395
+ async function listWorkspaceSkillFiles(skillSlug) {
396
+ const skillDir = ensureInside(WORKSPACE_SKILLS_DIR, join(WORKSPACE_SKILLS_DIR, skillSlug));
397
+ const entries = await readdir(skillDir, { withFileTypes: true });
398
+ const files = [];
399
+
400
+ for (const entry of entries) {
401
+ const fullPath = join(skillDir, entry.name);
402
+ if (entry.isDirectory()) {
403
+ const childFiles = await listWorkspaceSkillFiles(join(skillSlug, entry.name));
404
+ files.push(...childFiles.map((file) => ({
405
+ ...file,
406
+ name: `${entry.name}/${file.name}`,
407
+ })));
408
+ continue;
409
+ }
410
+
411
+ const info = await stat(fullPath);
412
+ files.push({
413
+ name: entry.name,
414
+ size: info.size,
415
+ modifiedAt: info.mtimeMs,
416
+ });
417
+ }
418
+
419
+ return files.sort((left, right) => left.name.localeCompare(right.name));
420
+ }
421
+
422
+ async function readWorkspaceSkillFile(skillSlug, name) {
423
+ const skillDir = ensureInside(WORKSPACE_SKILLS_DIR, join(WORKSPACE_SKILLS_DIR, skillSlug));
424
+ const filePath = ensureInside(skillDir, join(skillDir, name));
425
+ const [content, info] = await Promise.all([
426
+ readFile(filePath, "utf-8"),
427
+ stat(filePath),
428
+ ]);
429
+
430
+ return {
431
+ name,
432
+ content,
433
+ size: info.size,
434
+ modifiedAt: info.mtime.toISOString(),
435
+ };
436
+ }
437
+
438
+ async function writeWorkspaceSkillFile(skillSlug, name, content) {
439
+ const skillDir = ensureInside(WORKSPACE_SKILLS_DIR, join(WORKSPACE_SKILLS_DIR, skillSlug));
440
+ const filePath = ensureInside(skillDir, join(skillDir, name));
441
+ await mkdir(dirname(filePath), { recursive: true });
442
+ await writeFile(filePath, content, "utf-8");
443
+ const info = await stat(filePath);
444
+ return { name, size: info.size, modifiedAt: info.mtime.toISOString() };
445
+ }
446
+
447
+ async function findGitRoot(startDir) {
448
+ let current = resolve(startDir);
449
+ for (let i = 0; i < 10; i++) {
450
+ try {
451
+ await access(join(current, ".git"));
452
+ return current;
453
+ } catch {
454
+ /* walk up */
455
+ }
456
+ const parent = dirname(current);
457
+ if (parent === current) return null;
458
+ current = parent;
459
+ }
460
+ return null;
461
+ }
462
+
463
+ function runGit(repoRoot, args) {
464
+ return new Promise((resolveRun) => {
465
+ execFile(
466
+ "git",
467
+ ["-C", repoRoot, ...args],
468
+ { timeout: 5000, encoding: "utf-8" },
469
+ (err, stdout, stderr) => {
470
+ const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
471
+ resolveRun({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), code });
472
+ },
473
+ );
474
+ });
475
+ }
476
+
477
+ async function copyDirRecursive(srcDir, destDir) {
478
+ await mkdir(destDir, { recursive: true });
479
+ const entries = await readdir(srcDir, { withFileTypes: true });
480
+ for (const entry of entries) {
481
+ const srcPath = join(srcDir, entry.name);
482
+ const destPath = join(destDir, entry.name);
483
+ if (entry.isDirectory()) {
484
+ await copyDirRecursive(srcPath, destPath);
485
+ } else if (entry.isFile()) {
486
+ const data = await readFile(srcPath);
487
+ await writeFile(destPath, data);
488
+ }
489
+ }
490
+ }
491
+
492
+ async function ensureBundledSkillInstalled(skillSlug) {
493
+ const targetDir = ensureInside(WORKSPACE_SKILLS_DIR, join(WORKSPACE_SKILLS_DIR, skillSlug));
494
+ const targetSkillMd = join(targetDir, "SKILL.md");
495
+ try {
496
+ await access(targetSkillMd);
497
+ return { slug: skillSlug, status: "present" };
498
+ } catch {
499
+ // Not installed yet — attempt to install from bundled source.
500
+ }
501
+
502
+ const bundledDir = resolve(BUNDLED_SKILLS_DIR, skillSlug);
503
+ const bundledSkillMd = join(bundledDir, "SKILL.md");
504
+ try {
505
+ await access(bundledSkillMd);
506
+ } catch {
507
+ return { slug: skillSlug, status: "missing_bundle" };
508
+ }
509
+
510
+ try {
511
+ await copyDirRecursive(bundledDir, targetDir);
512
+ return { slug: skillSlug, status: "installed" };
513
+ } catch (err) {
514
+ return { slug: skillSlug, status: "error", error: String(err?.message ?? err) };
515
+ }
516
+ }
517
+
518
+ async function commitWorkspaceSkillFile(skillSlug, name, message) {
519
+ const skillDir = ensureInside(WORKSPACE_SKILLS_DIR, join(WORKSPACE_SKILLS_DIR, skillSlug));
520
+ const filePath = ensureInside(skillDir, join(skillDir, name));
521
+ const repoRoot = await findGitRoot(dirname(filePath));
522
+ if (!repoRoot) return { committed: false, reason: "not_a_git_repo" };
523
+
524
+ const rel = relative(repoRoot, filePath);
525
+ const add = await runGit(repoRoot, ["add", "--", rel]);
526
+ if (add.code !== 0) {
527
+ return { committed: false, reason: "failure", error: add.stderr || add.stdout };
528
+ }
529
+ const commit = await runGit(repoRoot, ["commit", "--only", "--", rel, "-m", message]);
530
+ if (commit.code !== 0) {
531
+ const out = `${commit.stdout}\n${commit.stderr}`.toLowerCase();
532
+ if (out.includes("nothing to commit") || out.includes("no changes added")) {
533
+ return { committed: false, reason: "nothing_to_commit" };
534
+ }
535
+ return { committed: false, reason: "failure", error: commit.stderr || commit.stdout };
536
+ }
537
+ const rev = await runGit(repoRoot, ["rev-parse", "--short", "HEAD"]);
538
+ return { committed: true, commit: rev.stdout.trim() };
539
+ }
540
+
382
541
  async function readSessionMessages(sessionDir) {
383
542
  const files = await safeReaddir(sessionDir);
384
543
  const dayFiles = files.filter(isDayFile).sort();
@@ -568,6 +727,104 @@ async function handleChatCacheApi(req, res, pathname) {
568
727
  return false;
569
728
  }
570
729
 
730
+ async function handleWorkspaceSkillsApi(req, res, pathname) {
731
+ if (req.method === "OPTIONS") {
732
+ res.writeHead(204, {
733
+ "Access-Control-Allow-Origin": "*",
734
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
735
+ "Access-Control-Allow-Headers": "Content-Type",
736
+ });
737
+ res.end();
738
+ return true;
739
+ }
740
+
741
+ const url = new URL(req.url || "/", `http://${req.headers.host}`);
742
+
743
+ if (pathname === "/api/workspace-skills/list" && req.method === "GET") {
744
+ const skillSlug = url.searchParams.get("slug");
745
+ if (!skillSlug) {
746
+ sendJson(res, 400, { error: "Missing slug" });
747
+ return true;
748
+ }
749
+
750
+ const files = await listWorkspaceSkillFiles(skillSlug);
751
+ sendJson(res, 200, {
752
+ agentId: skillSlug,
753
+ workspace: "~/.openclaw/workspace/skills",
754
+ files,
755
+ });
756
+ return true;
757
+ }
758
+
759
+ if (pathname === "/api/workspace-skills/file" && req.method === "GET") {
760
+ const skillSlug = url.searchParams.get("slug");
761
+ const name = url.searchParams.get("name");
762
+ if (!skillSlug || !name) {
763
+ sendJson(res, 400, { error: "Missing slug or name" });
764
+ return true;
765
+ }
766
+
767
+ const file = await readWorkspaceSkillFile(skillSlug, name);
768
+ sendJson(res, 200, {
769
+ agentId: skillSlug,
770
+ workspace: "~/.openclaw/workspace/skills",
771
+ file,
772
+ });
773
+ return true;
774
+ }
775
+
776
+ if (pathname === "/api/workspace-skills/save" && req.method === "POST") {
777
+ const body = await readRequestBody(req);
778
+ const skillSlug = typeof body.slug === "string" ? body.slug : "";
779
+ const name = typeof body.name === "string" ? body.name : "";
780
+ const content = typeof body.content === "string" ? body.content : null;
781
+ if (!skillSlug || !name || content === null) {
782
+ sendJson(res, 400, { error: "Missing slug, name or content" });
783
+ return true;
784
+ }
785
+ const info = await writeWorkspaceSkillFile(skillSlug, name, content);
786
+ sendJson(res, 200, { agentId: skillSlug, file: info });
787
+ return true;
788
+ }
789
+
790
+ if (pathname === "/api/workspace-skills/commit" && req.method === "POST") {
791
+ const body = await readRequestBody(req);
792
+ const skillSlug = typeof body.slug === "string" ? body.slug : "";
793
+ const name = typeof body.name === "string" ? body.name : "";
794
+ const message =
795
+ typeof body.message === "string" && body.message.trim().length > 0
796
+ ? body.message
797
+ : `chore(skill): update ${skillSlug}/${name} via OpenClaw Office`;
798
+ if (!skillSlug || !name) {
799
+ sendJson(res, 400, { error: "Missing slug or name" });
800
+ return true;
801
+ }
802
+ const result = await commitWorkspaceSkillFile(skillSlug, name, message);
803
+ sendJson(res, 200, result);
804
+ return true;
805
+ }
806
+
807
+ if (pathname === "/api/workspace-skills/ensure-defaults" && req.method === "POST") {
808
+ const body = await readRequestBody(req);
809
+ const rawSlugs = Array.isArray(body.slugs) ? body.slugs : null;
810
+ const slugs = (rawSlugs && rawSlugs.length > 0 ? rawSlugs : DEFAULT_BUNDLED_SKILL_SLUGS)
811
+ .filter((s) => typeof s === "string" && s.length > 0 && !s.includes("/") && !s.includes(".."));
812
+ await mkdir(WORKSPACE_SKILLS_DIR, { recursive: true });
813
+ const results = [];
814
+ for (const slug of slugs) {
815
+ try {
816
+ results.push(await ensureBundledSkillInstalled(slug));
817
+ } catch (err) {
818
+ results.push({ slug, status: "error", error: String(err?.message ?? err) });
819
+ }
820
+ }
821
+ sendJson(res, 200, { results });
822
+ return true;
823
+ }
824
+
825
+ return false;
826
+ }
827
+
571
828
  const server = createServer(async (req, res) => {
572
829
  const url = new URL(req.url || "/", `http://${req.headers.host}`);
573
830
  const pathname = decodeURIComponent(url.pathname);
@@ -583,6 +840,16 @@ const server = createServer(async (req, res) => {
583
840
  }
584
841
  }
585
842
 
843
+ if (pathname.startsWith("/api/workspace-skills/")) {
844
+ try {
845
+ const handled = await handleWorkspaceSkillsApi(req, res, pathname);
846
+ if (handled) return;
847
+ } catch (err) {
848
+ sendJson(res, 500, { error: String(err) });
849
+ return;
850
+ }
851
+ }
852
+
586
853
  // Serve injected index.html for root and SPA routes
587
854
  if (pathname === "/" || pathname === "/index.html") {
588
855
  const html = await getIndexHtml();