@yufengtadian/freedom-cli 1.13.1 → 1.13.2

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 (38) hide show
  1. package/README.md +87 -13
  2. package/lib/agents.js +318 -0
  3. package/lib/build.js +168 -39
  4. package/lib/cli.js +160 -4
  5. package/lib/config.js +20 -1
  6. package/lib/desktop.js +133 -0
  7. package/lib/dev.js +149 -0
  8. package/lib/mcp.js +217 -0
  9. package/lib/release.js +90 -0
  10. package/lib/security.js +113 -54
  11. package/lib/shell.js +5 -2
  12. package/lib/tui.js +37 -1
  13. package/lib/verify.js +43 -8
  14. package/package.json +2 -1
  15. package/shell/darwin-arm64/freedom-shell +0 -0
  16. package/shell/linux-x64/freedom-shell +0 -0
  17. package/shell/win-x64/freedom-shell.exe +0 -0
  18. package/skill/freedom/SKILL.md +159 -0
  19. package/templates/desktop/app.html +559 -0
  20. package/templates/desktop/backend/desktop.mjs +317 -0
  21. package/templates/desktop/freedom.config.js +29 -0
  22. package/templates/desktop/icon.ico +0 -0
  23. package/templates/go/pkg/freedom/anti_debug_windows.go +114 -11
  24. package/templates/go/pkg/freedom/assets/freedom.js +3 -0
  25. package/templates/go/pkg/freedom/freedom.go +52 -9
  26. package/templates/go/pkg/freedom/resources.go +68 -14
  27. package/templates/go/pkg/freedom/securetemp.go +61 -0
  28. package/templates/go/pkg/freedom/securetemp_other.go +20 -0
  29. package/templates/go/pkg/freedom/securetemp_windows.go +25 -0
  30. package/templates/go/pkg/freedom/security.go +240 -61
  31. package/templates/go/pkg/freedom/store.go +8 -0
  32. package/templates/go/pkg/freedom/window_other.go +7 -1
  33. package/templates/go/pkg/freedom/window_windows.go +185 -17
  34. package/templates/installer/app.nsi +43 -0
  35. package/templates/project/freedom.config.js +14 -0
  36. package/templates/project/freedom.d.ts +77 -0
  37. package/templates/project-minimal/freedom.config.js +14 -0
  38. package/templates/project-minimal/freedom.d.ts +77 -0
package/README.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # freedom-cli
2
2
 
3
- Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.13.1)。
3
+ Freedom 桌面壳打包工具:把你的 Web 前端一键打包成跨平台桌面应用(v1.13.2)。
4
4
 
5
5
  基于自研 Freedom WebView 壳层(对标 Wails / Tauri):前端完全自由、后端可任意语言、渲染复用系统 WebView(Windows WebView2 / macOS WKWebView / Linux WebKitGTK),产物为单个可执行文件 + resources 目录,前端页面内存加载,不占本地端口。
6
6
 
7
7
  **v1.13.x 框架线回归 + 稳定性收口**(v1.13.0 功能,v1.13.1 为文档勘误发布):
8
8
  - **freedom-cli 源码回归主仓库**:本 CLI 与框架源码同仓维护(`freedom-cli/` 目录,`templates/go` 为框架源码快照),npm 包与 GitHub Release 一一对应,历史"源码丢失停在 1.12.18"的断档已修复;
9
9
  - **三平台通用壳经 tag CI 自动发布**:推送 `vX.Y.Z` tag 即由 `build.yml` 在 win / mac(Apple Silicon)/ linux runner 上编译通用壳并自动创建 GitHub Release,资产命名 `freedom-shell-<plat>`;`freedom shell download <plat>` 直接拉取对应版本资产(默认定位 `v<包版本>`,可用 `FREEDOM_SHELL_TAG` 覆盖),包内另自带三平台壳兜底,均随包分发;
10
- - **运行时资源层回归**:壳从 exe 同目录 `resources/` 读取 `config.json`(窗口 / 后端配置覆盖)与前端页面,high 模式下为加密 `app.bin`(FRDM1 容器)+ `.integrity` 清单;JS 侧构建加密 → Go 壳内存解密已有跨语言黄金向量与端到端互验(改名 / 篡改即拒绝运行);
10
+ - **运行时资源层回归**:壳从 exe 同目录 `resources/` 读取 `config.json`(窗口 / 后端配置覆盖)与前端页面,high 模式下前端页面、配置与 **`backend/**` 后端源码**统一封进加密 `app.bin`(FRDM2 容器:构建期随机盐 + PBKDF2 60 万次派生 + Encrypt-then-MAC 覆盖头部)+ `.integrity` 清单,磁盘不留明文后端源码;JS 侧构建加密 → Go 壳内存解密已有跨语言黄金向量与端到端互验(改名 / 篡改 / 整体替换即拒绝运行);
11
11
  - **多窗口(M2)随 v1.13.0 壳可用**:前端 `window.freedom.window.create / close / list / focus` 开二级窗口,Go 侧 `App.NewWindow / Window.Close`;次级窗口独立消息泵、页面源支持内联 HTML / URL;
12
12
  - **销毁竞态收口**:修复 webview2 在 `Destroy` 中泵出滞留 dispatch 回调导致的随机崩溃(0xc0000005,多窗口 / 快速关闭场景),回收后所有排队回调按拆除旗标自我作废,并配套红绿回归测试;
13
13
  - **v1.13.1**:文档同步(本 README 更新至 v1.13.x 真实现状、壳 CI 章节勘误),无功能与壳二进制变更。
14
+ - **v1.13.2 反逆向加固(FRDM2)**:容器升 FRDM2(构建期随机盐 + PBKDF2 60 万次 + enc/mac 域分离 + MAC 覆盖容器头部),主密钥改为掩码表运行时组装(壳二进制 `strings` 直取不到),`backend/**` 后端源码一并入容器并在运行期解密到私有临时目录(退出即删、崩溃残留按 PID 回收),前端产物 `sourceMappingURL` 构建期抹除,anti-debug 扩到六道信号,本地与 CI 壳统一 `-trimpath -s -w`。**旧版本 CLI 产出的 FRDM1 资源包在新壳上明确拒绝运行(不静默降级),须重新 `freedom build`**;三平台壳随本版本经 CI 重编发布。
14
15
 
15
16
  **v1.12.17 安全模式全面落地**:三档安全模式 `freedom security <none|basic|high>` 正式随包分发——high 档把 resources 加密为 `app.bin`(AES-256-CTR + HMAC-SHA256 + PBKDF2 密钥派生),配合 `.integrity` 完整性校验、anti-debug 与进程隐藏,磁盘无明文、篡改即拒运行;三平台预编译壳经 CI 重建分发,补齐 v1.12.16 仅重编 win-x64 壳的缺口。
16
17
 
@@ -169,6 +170,14 @@ export default {
169
170
  icon: undefined, // 应用图标:Windows 用 .ico(推荐多尺寸),macOS 用 .icns
170
171
  outDir: 'dist', // 产物目录:'dist'(默认)| '.'(项目根目录)| 任意路径
171
172
  // backend: { command: 'node', args: ['backend/main.mjs'] }, // 任意语言后端进程
173
+ // staticHtml: 'app.html', // 跳过 npm/vite,直接内嵌该单文件 HTML(零网络;纯静态页与 freedom 自举界面用)
174
+ // singleInstance: true, // 二次启动转发参数给已运行实例后退出(前端收 app.secondInstance)
175
+ // dev: { command: 'npm run dev' }, // freedom dev 拉起的前端 dev server 命令
176
+ // updater: { // 应用自更新(freedom keygen 生成密钥,公钥填这里)
177
+ // manifestURL: 'https://your.host/latest.json',
178
+ // publicKey: '<freedom keygen 输出的 base64 公钥>',
179
+ // requireSignature: false, // true 时产物还须通过平台代码签名复核(仅 Windows)
180
+ // },
172
181
  };
173
182
  ```
174
183
 
@@ -185,7 +194,7 @@ export default {
185
194
  | --- | --- | --- | --- |
186
195
  | `none` | 明文 `index.html` + `config.json`(默认,兼容历史产物) | 解包即读 | 公开页面 / 调试 / 快速分发 |
187
196
  | `basic` | 同上明文,构建时额外输出加固建议 | 低 | 需要提示、暂不加密 |
188
- | `high` | 整体加密为 `resources/app.bin` + `.integrity`,磁盘**无任何明文** HTML/配置 | 高(需逆向壳 + 还原派生密钥) | 防源码提取、防资源篡改的正式分发 |
197
+ | `high` | 前端 HTML + 配置 + **后端源码**整体加密为 `resources/app.bin` + `.integrity`,磁盘**无任何明文** | 高(需逆向壳 + 还原派生密钥) | 防源码提取、防资源篡改的正式分发 |
189
198
 
190
199
  **切换方式**(二选一,`--security` 可临时覆盖配置文件):
191
200
 
@@ -195,16 +204,18 @@ freedom build --security high # 单次构建生效(不改配置)
195
204
  freedom build # 读取配置中的 security 值
196
205
  ```
197
206
 
198
- **high 模式原理**:
199
- - 构建时:前端 HTML + 配置序列化后,用 **AES-256-CTR** 加密为 `app.bin`(容器头 `FRDM1` + 16B IV + 16B 认证标签),并生成 `.integrity` 完整性清单(`app.bin` 与 `backend/**` 各文件的 HMAC-SHA256);
200
- - 加密密钥由 **PBKDF2-HMAC-SHA256**(6 万次迭代)按「应用可执行文件名」派生,不同应用密钥不同,暴力破解成本高;
201
- - 壳启动时:先校验 `.integrity`(防整体替换 / 篡改 / exe 改名),再恒定时间比对 HMAC 认证标签(Encrypt-then-MAC),最后内存中解密加载——**磁盘始终无明文**;
202
- - 解密 / 校验失败即拒绝运行(不静默回退明文,防降级攻击);
203
- - 另内置 **anti-debug**(`IsDebuggerPresent` / `CheckRemoteDebuggerPresent` 命中即退出)与进程隐藏加固。
207
+ **high 模式原理(FRDM2 容器)**:
208
+ - 容器布局:`FRDM2`(5B) + `salt`(16B,**每次构建随机**) + `iv`(16B) + `tag`(16B) + 密文;密钥 = **PBKDF2-HMAC-SHA256**(60 万次迭代,主密钥经字节表掩码内置于壳与 CLI)按「应用可执行文件名 + 容器随机盐」派生,再由固定标签做 **HMAC 域分离**得到独立的加密钥与认证钥;
209
+ - 认证为 **Encrypt-then-MAC 且覆盖容器头部**(magic/salt/iv 参与计算),故改盐、改 IV、翻转任意一字节都会认证失败;`.integrity` 清单绑定同一盐值,**整体替换成另一个合法容器**同样拒绝运行;
210
+ - **后端源码不再明文落盘**:`backend/**`(含 POSIX 权限位)作为条目进入容器,磁盘上不再有 `resources/backend/`;壳启动解密到仅属主可访问的一次性临时目录(0700 / 文件 0600,执行位按容器记录还原)供子进程执行,进程退出即删除;若被强杀或崩溃来不及删,**下次启动会按目录名内嵌的 PID 自动回收残留**;
211
+ - 构建期抹除前端产物中的 `sourceMappingURL` 引用(防 source map 还原源码),壳二进制一律 `-trimpath -ldflags "-s -w"`(Windows 另加 `-H windowsgui`);
212
+ - 解密 / 校验失败即拒绝运行(不静默回退明文,防降级攻击;旧版 `FRDM1` 容器明确报"版本不支持");
213
+ - 另内置 **anti-debug**:`IsDebuggerPresent`、`CheckRemoteDebuggerPresent`、直读 `PEB.BeingDebugged`、`NtQueryInformationProcess` 的 `ProcessDebugPort` / `ProcessDebugObjectHandle` / `ProcessDebugFlags` 共六道独立信号,任一确证即静默退出(退出码 77),在资源解密前后各检测一次;所有探测遵循"取不到即视为未命中",杜绝误杀;
214
+ - 进程隐藏加固(`-H windowsgui`,无控制台窗口)。
204
215
 
205
- **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中。更高强度建议:壳编译时设置 `-ldflags "-s -w"` 剥离符号表(CI 编译壳时已可选开启)、对核心业务保留服务端校验。若需"怎么都解不开",请把真正敏感的密钥 / 逻辑放到你的后端。
216
+ **加固上限说明**:`high` 大幅提高破解门槛,但**任何客户端可执行程序都无法做到绝对不可破解**——密钥最终存在于壳二进制与运行时内存中,反调试只抬升动态分析成本、不是不可绕过的墙。真正敏感的密钥与业务逻辑仍应留在服务端。
206
217
 
207
- **互斥规则**:切换安全模式重新构建时,CLI 会自动清理另一模式的遗留产物(`app.bin`/`.integrity` 与明文 `index.html`/`config.json` 只能存其一),避免壳误加载旧资源。
218
+ **互斥规则**:切换安全模式重新构建时,CLI 会自动清理另一模式的遗留产物(`app.bin`/`.integrity` 与明文 `index.html`/`config.json`/`backend/` 只能存其一),避免壳误加载旧资源。
208
219
 
209
220
  ## 前端
210
221
 
@@ -226,21 +237,84 @@ window.freedom.window.minimize(); // 窗口控制
226
237
  ## 命令
227
238
 
228
239
  ```
229
- freedom tui # 交互式终端界面
240
+ freedom # 选择显示方式:终端 TUI / Freedom Desktop(图形界面)
241
+ freedom tui # 直达交互式终端界面
242
+ freedom desktop [--rebuild] [--no-launch] # 直达 Freedom Desktop(图形界面,由 freedom 自身打包)
230
243
  freedom init <目录> [--force]
231
- freedom build [--platform win-x64|darwin-arm64|linux-x64|all] [--no-cache] [--security none|basic|high]
244
+ freedom dev [--port <n>|--url <u>] [--command <cmd>] # 热更开发流:拉起 vite 并把壳窗口指到 dev server
245
+ freedom build [--platform win-x64|darwin-arm64|linux-x64|all] [--no-cache] [--security none|basic|high] [--installer]
232
246
  freedom titlebar <native|frameless>
233
247
  freedom security <none|basic|high> # 设置安全模式(写入配置)
234
248
  freedom icon <path> # 设置应用图标(Windows 用 .ico,macOS 用 .icns)
235
249
  freedom config [get|set]
236
250
  freedom shell list|download <platform>|build <platform>
237
251
  freedom dmg [--platform <plat>] # 在 macOS 上把 .app 打包为 .dmg
252
+ freedom keygen [--force] # 生成应用自更新 ed25519 密钥对(私钥留 .freedom/keys/,公钥进配置)
253
+ freedom manifest --artifact <产物> --url <下载地址> [--version x] [--notes txt] # 产出签名更新清单 dist/latest.json
254
+ freedom agents [--home <dir>] # Agent 集成支持矩阵(本机磁盘证据判定 ready/convention/unknown)
255
+ freedom agents install --what <mcp|skill> --agent <key|all> # 等价于下面两条
256
+ freedom skill install --agent <key|all> [--dry-run] [--skills-dir <path>]
257
+ freedom mcp install --agent <key|all> [--dry-run] [--config <path> --format json|toml|yaml]
258
+ freedom mcp serve # stdio MCP 服务本体(一般由 agent 自动拉起)
238
259
  freedom version # 显示版本并检测最新版本
239
260
  freedom update # 检查新版本并给出升级命令(同 check-update)
240
261
  freedom tutorial
241
262
  freedom help
242
263
  ```
243
264
 
265
+ ## 两种显示:终端 TUI 与 Freedom Desktop
266
+
267
+ 裸跑 `freedom` 会先让你选显示方式:
268
+
269
+ - **终端 TUI** —— 零依赖 ANSI 界面(`freedom tui` 直达),新建 / 打包 / 配置 / 壳管理。
270
+ - **Freedom Desktop** —— 图形窗口(`freedom desktop` 直达)。它是 **freedom 自己打包出来的产品**:
271
+ 模板在包内 `templates/desktop/`,首次运行同步到 `~/.freedom/desktop/`,再走与用户项目**同一条
272
+ `freedom build` 代码路径**产出 `freedom-desktop.exe`(壳 + `resources/`),随后拉起它。
273
+ 前端是零构建的单文件页面(配置项 `staticHtml` 直通,**不跑 npm / vite,零网络**),
274
+ 后端是零依赖 Node 进程(`resources/backend/desktop.mjs`,NDJSON/stdio),它再以子进程调起 `freedom` CLI——
275
+ 因此**界面能力恒等于 CLI 能力**,CLI 升级界面即升级。
276
+ CLI 版本或模板内容变化时(stamp = CLI 版本 + 模板哈希)自动重打包,未变化则复用产物;
277
+ `--rebuild` 强制重建(需先关闭已开窗口,Windows 会锁定运行中的 exe),`--no-launch` 只准备产物。
278
+
279
+ 界面分区:概览 / 项目 / 打包 / 配置 / 壳与后端 / 发布 / Agent 集成,底部输出区实时滚动 CLI 与 dev server 日志。
280
+
281
+ ## Agent 集成:把 Freedom 交给编码 Agent
282
+
283
+ - `freedom skill install --agent <key|all>`:把 `skill/freedom/SKILL.md`(框架架构、配置键表、SDK 面、
284
+ NDJSON 协议、CLI 命令、坑清单)复制进各 agent 的 skills 目录。
285
+ - `freedom mcp install --agent <key|all>`:把 `freedom mcp serve` 注册进各 agent 的 MCP 配置。
286
+ MCP 工具面:`freedom_build / init / verify / config / shell / release / agents / guide`。
287
+ - `freedom agents [--home <dir>]`:打印支持矩阵。**是否可写一律按本机磁盘证据判定**——
288
+ `ready`(配置文件已在,合并写入)/ `convention`(仅主目录在,按同族约定新建并明确提示)/
289
+ `unknown`(本机无足迹,只输出可粘贴片段,绝不凭记忆造路径)。`--home` 换一棵家目录树预览取证结果。
290
+ - `freedom agents install --what <mcp|skill> --agent <key|all>`:上面两条安装入口的合并写法,
291
+ 不带 `--what` 时默认 `mcp`;`agents <其它子命令>` 直接报错退出,不会静默回落成矩阵。
292
+ - 写入是**幂等合并**:保留既有其它 server 条目,二次安装原地替换不产生重复;改前留 `<file>.bak` 备份;
293
+ `--dry-run` 只预览不落盘。未取证的 agent 用 `--config <真实路径> --format <json|toml|yaml>` 覆写。
294
+ - 已按本机取证登记的 agent:Claude Code / Claude Desktop / Codex CLI / Qoder / CodeBuddy / Zcode / Cursor /
295
+ Hermes(YAML)等;其余(Trae、OpenCode、Gemini CLI、Pi、Tianshu、WorkBuddy、DeepSeek harness、Oh My Pi)
296
+ 在无足迹的机器上一律降级为片段 + 覆写通道。
297
+
298
+ ## 开发热更(freedom dev)
299
+
300
+ `freedom dev` 对标 `tauri dev` / `wails dev`:它拉起项目自己的前端 dev server(默认 `npm run dev`,可用配置 `dev.command` 或 `--command` 覆盖),
301
+ 从其输出里解析 `http://localhost:<port>`(也可用 `--port` / `--url` 直接指定),把随包通用壳复制到 `.freedom/dev/` 并写入
302
+ `url` 模式的 `resources/config.json`(默认开开发者工具),随后拉起壳窗口——改代码由 vite HMR 直接反映到窗口里,不必反复重打包。
303
+ `.freedom/dev/` 是开发临时目录,与正式产物 `dist/` 互不影响;配置了 `backend/` 时会一并镜像复制,任意语言后端同样能在 dev 流里联调。
304
+ `Ctrl-C` 退出时会树杀 dev server 与壳进程(Windows 走 `taskkill /T /F`,不留孤儿子进程)。
305
+
306
+ ## 安装包与自更新发布环
307
+
308
+ - `freedom build --installer`:每个目标平台额外产出 `<应用名>-<平台>-portable.zip`(解压即用的便携包);
309
+ Windows 再产出已填充的 NSIS 脚本 `<应用名>-setup-<版本>.nsi`,本机装有 NSIS(`makensis` 在 PATH)时直接编译出
310
+ `<应用名>-setup-<版本>.exe`,否则给出指引让你在装有 NSIS 的机器上一条命令编译(缺 makensis 属环境能力而非产物缺陷)。
311
+ - 应用自更新(对标 electron-updater / tauri updater)三步:
312
+ 1. `freedom keygen` 生成 ed25519 密钥对,私钥存 `.freedom/keys/update_ed25519`(发布方资产,勿入库 / 勿分发);
313
+ 2. 公钥写入 `freedom.config.js` 的 `updater.publicKey`(连同 `manifestURL`),`freedom build` 会透传进产物的 `config.json`;
314
+ 3. 发版时 `freedom manifest --artifact dist/<产物> --url https://.../<产物>` 产出签名清单 `dist/latest.json`,上传到 `manifestURL` 即可。
315
+ 运行时前端用 `freedom.update.check()` / `freedom.update.install()`,清单验签(payload `freedom-update-v1\n<version>\n<url>\n<sha256>`)
316
+ 与 sha256 校验都在壳内完成(`updater.go`),签名与 Go 侧验签有跨语言回归测试守着。
317
+
244
318
  ## 任意语言后端
245
319
 
246
320
  壳与后端进程通过 stdin/stdout 的 NDJSON/JSON-RPC 桥接(协议语言无关),因此后端可用任意语言实现(Node / Python / Rust / Go / C#…)。
package/lib/agents.js ADDED
@@ -0,0 +1,318 @@
1
+ 'use strict';
2
+
3
+ // freedom agents / skill / mcp —— 把 Freedom 使用技能与 MCP 服务装进各家 Agent
4
+ //
5
+ // 设计纪律(铁律 19):注册表只登记「候选路径 + 写入格式」,是否可写一律在安装时按磁盘
6
+ // 证据判定——配置文件存在 = ready(合并写入,保留既有条目、写前 .bak 备份);目录存在但
7
+ // 配置文件缺失 = create(新建后写入);皆无 = unverified,只打印可粘贴片段,绝不凭记忆
8
+ // 造路径。未取证的 agent 与任意私有布局经 --config <path> --format <json|toml|yaml> 覆写。
9
+ //
10
+ // 幂等:同一 server 名 / 同一 skill 目录重复安装为原地替换,不产生重复条目。
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { packageRoot, copyDir } = require('./utils');
16
+ const theme = require('./theme');
17
+ const { paint, ok, err, warn, dim, bold, section, C } = theme;
18
+
19
+ const SERVER_NAME = 'freedom';
20
+
21
+ // mcp: { file: 相对 HOME 的配置文件, format: json|toml|yaml, path: JSON/YAML 容器键路径 }
22
+ // skills: 相对 HOME 的 skill 根目录(每个 agent 一个)
23
+ const AGENTS = [
24
+ { key: 'claude-code', name: 'Claude Code', mcp: { file: '.claude.json', format: 'json', path: ['mcpServers'] }, skills: '.claude/skills' },
25
+ { key: 'claude-desktop', name: 'Claude Desktop', mcp: { file: 'AppData/Roaming/Claude/claude_desktop_config.json', format: 'json', path: ['mcpServers'] }, skills: 'AppData/Roaming/Claude/skills' },
26
+ { key: 'codex', name: 'Codex CLI', mcp: { file: '.codex/config.toml', format: 'toml' }, skills: '.codex/skills' },
27
+ { key: 'qoder', name: 'Qoder', mcp: { file: '.qoder/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.qoder/skills' },
28
+ { key: 'codebuddy', name: 'CodeBuddy', mcp: { file: '.codebuddy/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.codebuddy/skills' },
29
+ { key: 'zcode', name: 'Zcode', mcp: { file: '.zcode/cli/config.json', format: 'json', path: ['mcp', 'servers'] }, skills: '.zcode/skills' },
30
+ { key: 'cursor', name: 'Cursor', mcp: { file: '.cursor/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.cursor/skills' },
31
+ { key: 'hermes', name: 'Hermes', mcp: { file: '.hermes/config.yaml', format: 'yaml', path: ['mcp_servers'] }, skills: '.hermes/skills' },
32
+ { key: 'tianshu', name: 'Tianshu Harness', mcp: { file: '.tianshu/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.tianshu/skills' },
33
+ { key: 'pi', name: 'Pi Agent', mcp: { file: '.pi/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.pi/skills' },
34
+ { key: 'workbuddy', name: 'WorkBuddy', mcp: { file: '.workbuddy/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.workbuddy/skills' },
35
+ { key: 'opencode', name: 'OpenCode', mcp: { file: '.config/opencode/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.opencode/skills' },
36
+ { key: 'gemini-cli', name: 'Gemini CLI', mcp: { file: '.gemini/settings.json', format: 'json', path: ['mcpServers'] }, skills: '.gemini/skills' },
37
+ { key: 'deepseek-harness', name: 'DeepSeek Harness', mcp: { file: '.deepseek/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.deepseek/skills' },
38
+ { key: 'oh-my-pi', name: 'Oh My Pi', mcp: { file: '.oh-my-pi/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.oh-my-pi/skills' },
39
+ { key: 'trae', name: 'Trae', mcp: { file: '.trae/mcp.json', format: 'json', path: ['mcpServers'] }, skills: '.trae/skills' },
40
+ ];
41
+
42
+ function homeDir() {
43
+ return process.env.FREEDOM_AGENT_HOME || os.homedir();
44
+ }
45
+
46
+ function skillSource() {
47
+ return path.join(packageRoot(), 'skill', 'freedom');
48
+ }
49
+
50
+ // MCP 服务定义:stdio 拉起 `node <pkg>/bin/freedom.js mcp serve`
51
+ function serverDef() {
52
+ return {
53
+ command: process.execPath,
54
+ args: [path.join(packageRoot(), 'bin', 'freedom.js'), 'mcp', 'serve'],
55
+ };
56
+ }
57
+
58
+ function agentByKey(key) {
59
+ return AGENTS.find((a) => a.key === key) || null;
60
+ }
61
+
62
+ function resolveAgentKeys(raw) {
63
+ const all = AGENTS.map((a) => a.key);
64
+ if (!raw || raw === 'all') return all;
65
+ const out = [];
66
+ for (const seg of String(raw).split(/[,,\s]+/)) {
67
+ if (!seg) continue;
68
+ if (!all.includes(seg)) throw new Error(`未知 agent:${seg}。可选:${all.join(' / ')} 或 all`);
69
+ if (!out.includes(seg)) out.push(seg);
70
+ }
71
+ if (!out.length) throw new Error('--agent 缺少取值。');
72
+ return out;
73
+ }
74
+
75
+ // ---- 状态探测(全部基于本轮磁盘证据) ----
76
+ function probe(agent) {
77
+ const home = homeDir();
78
+ const mcpFile = path.join(home, agent.mcp.file);
79
+ const skillsDir = path.join(home, agent.skills);
80
+ // ready = 路径本机取证;convention = 仅 agent 主目录在(按同族约定新建,标注提示);unknown = 无足迹(只给片段)
81
+ const mcpState = fs.existsSync(mcpFile) ? 'ready'
82
+ : (fs.existsSync(path.dirname(mcpFile)) ? 'convention' : 'unknown');
83
+ const skillState = fs.existsSync(skillsDir) ? 'ready'
84
+ : (fs.existsSync(path.dirname(skillsDir)) ? 'convention' : 'unknown');
85
+ return { mcpFile, skillsDir, mcpState, skillState };
86
+ }
87
+
88
+ function matrix() {
89
+ return AGENTS.map((a) => ({ key: a.key, name: a.name, ...probe(a) }));
90
+ }
91
+
92
+ // ---- JSON 配置:按键路径 upsert,保留其余条目 ----
93
+ function upsertJson(text, containerPath, name, value) {
94
+ let obj;
95
+ try {
96
+ obj = text.trim() ? JSON.parse(text) : {};
97
+ } catch (e) {
98
+ throw new Error(`JSON 配置无法解析(拒绝覆盖以免损坏):${e.message}`);
99
+ }
100
+ let node = obj;
101
+ for (const k of containerPath) {
102
+ if (node[k] === undefined) node[k] = {};
103
+ else if (typeof node[k] !== 'object' || node[k] === null) throw new Error(`配置键 ${k} 已存在且不是对象,拒绝改写。`);
104
+ node = node[k];
105
+ }
106
+ const had = name in node;
107
+ node[name] = value;
108
+ return { text: JSON.stringify(obj, null, 2) + '\n', had };
109
+ }
110
+
111
+ // ---- TOML:[mcp_servers.<name>] 块级替换 / 追加 ----
112
+ function tomlValue(v) {
113
+ if (Array.isArray(v)) return `[${v.map(tomlValue).join(', ')}]`;
114
+ return JSON.stringify(v);
115
+ }
116
+
117
+ function tomlBlock(name, def) {
118
+ const lines = [`[mcp_servers.${name}]`];
119
+ for (const [k, v] of Object.entries(def)) lines.push(`${k} = ${tomlValue(v)}`);
120
+ return lines.join('\n') + '\n';
121
+ }
122
+
123
+ function upsertToml(text, name, def) {
124
+ const lines = text.split(/\r?\n/);
125
+ const header = `[mcp_servers.${name}]`;
126
+ const start = lines.findIndex((l) => l.trim() === header);
127
+ const had = start >= 0;
128
+ let out;
129
+ if (had) {
130
+ let end = start + 1;
131
+ while (end < lines.length && !/^\s*\[/.test(lines[end])) end++;
132
+ out = [...lines.slice(0, start), ...tomlBlock(name, def).trimEnd().split('\n'), ...lines.slice(end)];
133
+ } else {
134
+ out = [...lines, '', ...tomlBlock(name, def).trimEnd().split('\n')];
135
+ }
136
+ return { text: out.join('\n').replace(/\n*$/, '\n'), had };
137
+ }
138
+
139
+ // ---- YAML:mcp_servers: 下按缩进 upsert 一个条目 ----
140
+ function yamlEntry(indent, name, def) {
141
+ const p = ' '.repeat(indent);
142
+ const lines = [`${p}${name}:`];
143
+ lines.push(`${p} type: stdio`);
144
+ lines.push(`${p} command: ${JSON.stringify(def.command)}`);
145
+ if (Array.isArray(def.args) && def.args.length) {
146
+ lines.push(`${p} args:`);
147
+ for (const a of def.args) lines.push(`${p} - ${JSON.stringify(a)}`);
148
+ }
149
+ lines.push(`${p} enabled: true`);
150
+ return lines;
151
+ }
152
+
153
+ function upsertYaml(text, containerPath, name, def) {
154
+ const lines = text.split(/\r?\n/);
155
+ const key = containerPath[containerPath.length - 1];
156
+ const head = lines.findIndex((l) => new RegExp(`^${key}:\\s*$`).test(l));
157
+ const entry = (ownIndent) => yamlEntry(ownIndent, name, def);
158
+ if (head < 0) {
159
+ return { text: lines.join('\n').replace(/\n*$/, '\n') + `\n${key}:\n` + entry(2).join('\n') + '\n', had: false };
160
+ }
161
+ // 既有子条目缩进(取 head 之后第一条非空行);无子条目时用 2
162
+ let own = 2;
163
+ for (let i = head + 1; i < lines.length; i++) {
164
+ if (!lines[i].trim()) continue;
165
+ const m = lines[i].match(/^(\s+)/);
166
+ own = m ? m[1].length : 0;
167
+ break;
168
+ }
169
+ const start = lines.findIndex((l, i) => i > head && new RegExp(`^ {${own}}${name}:`).test(l));
170
+ const had = start >= 0;
171
+ let out;
172
+ if (had) {
173
+ let end = start + 1;
174
+ while (end < lines.length && (!lines[end].trim() || indentOf(lines[end]) > own)) end++;
175
+ out = [...lines.slice(0, start), ...entry(own), ...lines.slice(end)];
176
+ } else {
177
+ out = [...lines.slice(0, head + 1), ...entry(own), ...lines.slice(head + 1)];
178
+ }
179
+ return { text: out.join('\n').replace(/\n*$/, '\n'), had };
180
+ }
181
+
182
+ function indentOf(line) {
183
+ const m = line.match(/^(\s+)/);
184
+ return m ? m[1].length : 0;
185
+ }
186
+
187
+ function backup(file) {
188
+ fs.mkdirSync(path.dirname(file), { recursive: true });
189
+ if (!fs.existsSync(file)) return null;
190
+ const bak = `${file}.bak`;
191
+ fs.copyFileSync(file, bak);
192
+ return bak;
193
+ }
194
+
195
+ function snippet(agent, what) {
196
+ if (what === 'mcp') {
197
+ const body = JSON.stringify({ [SERVER_NAME]: serverDef() }, null, 2);
198
+ if (agent.mcp.format === 'toml') {
199
+ return [` ${dim('# 无 mcp_servers 块,手工追加到 ~/.codex/config.toml:')}`, ...tomlBlock(SERVER_NAME, serverDef()).trimEnd().split('\n').map((l) => ' ' + dim(l))];
200
+ }
201
+ return [
202
+ ` ${dim('目标配置文件未取证,请把以下条目并入你的 MCP 配置(容器键通常是 mcpServers / mcp.servers):')}`,
203
+ ...body.split('\n').map((l) => ` ${dim(l)}`),
204
+ ` ${dim(`或指定路径直接写入:freedom mcp install --agent ${agent.key} --config <path> --format json|toml|yaml`)}`,
205
+ ];
206
+ }
207
+ return [
208
+ ` ${dim('未发现该 agent 的 skill 目录,手工复制:')}`,
209
+ ` ${dim(`${skillSource()} → <agent skills 根目录>/freedom`)}`,
210
+ ` ${dim(`或指定目录直接安装:freedom skill install --agent ${agent.key} --skills-dir <path>`)}`,
211
+ ];
212
+ }
213
+
214
+ // ---- 安装主流程 ----
215
+ function installMcpOne(agent, { config, format, dryRun }) {
216
+ const pr = probe(agent);
217
+ const file = config || pr.mcpFile;
218
+ const fmt = format || agent.mcp.format;
219
+ const cpath = agent.mcp.path || ['mcpServers'];
220
+
221
+ if (!config && pr.mcpState === 'unknown') {
222
+ return { agent, state: 'snippet', lines: [` ${warn(`${agent.key}: 未找到配置目录 ${path.dirname(pr.mcpFile)}(未取证,不写入)`)}`, ...snippet(agent, 'mcp')] };
223
+ }
224
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : (fmt === 'json' ? '{}\n' : fmt === 'toml' ? '' : '');
225
+ const up = fmt === 'toml' ? upsertToml(text, SERVER_NAME, serverDef())
226
+ : fmt === 'yaml' ? upsertYaml(text, cpath, SERVER_NAME, serverDef())
227
+ : fmt === 'json' ? upsertJson(text, cpath, SERVER_NAME, serverDef())
228
+ : (() => { throw new Error(`不支持的 --format:${fmt}(可选 json|toml|yaml)`); })();
229
+
230
+ if (dryRun) {
231
+ return { agent, state: up.had ? 'replace(dry)' : 'add(dry)', lines: [` ${dim(`${file} · ${fmt} · ${up.had ? '替换既有条目' : '新增条目'}`)}`] };
232
+ }
233
+ const bak = backup(file);
234
+ fs.writeFileSync(file, up.text, 'utf8');
235
+ return {
236
+ agent,
237
+ state: up.had ? 'replaced' : 'added',
238
+ lines: [` ${ok(`${file}`)} ${dim(`· ${fmt} · ${up.had ? '已替换既有 freedom 条目' : '已写入'}${bak ? ` · 备份 ${path.basename(bak)}` : ''}`)}`,
239
+ ...(config || pr.mcpState === 'ready' ? [] : [` ${warn('该路径按同族约定新建(本机未取证);若该 agent 不读此文件,请用 --config 指定真实路径。')}`]),
240
+ ` ${dim(`重启 ${agent.name} 后生效;未自动信任前请在该 agent 内确认。`)}`],
241
+ };
242
+ }
243
+
244
+ function installSkillOne(agent, { skillsDir, dryRun }) {
245
+ const pr = probe(agent);
246
+ const src = skillSource();
247
+ if (!fs.existsSync(src)) throw new Error(`技能源缺失:${src}`);
248
+ const destBase = skillsDir || pr.skillsDir;
249
+ if (!skillsDir && pr.skillState === 'unknown') {
250
+ return { agent, state: 'snippet', lines: [` ${warn(`${agent.key}: 未找到 skill 根目录 ${destBase}(未取证,不写入)`)}`, ...snippet(agent, 'skill')] };
251
+ }
252
+ const dest = path.join(destBase, 'freedom');
253
+ if (dryRun) return { agent, state: 'dry', lines: [` ${dim(`${src} → ${dest}`)}`] };
254
+ if (fs.existsSync(dest)) fs.rmSync(dest, { recursive: true, force: true });
255
+ copyDir(src, dest);
256
+ return {
257
+ agent,
258
+ state: 'installed',
259
+ lines: [` ${ok(dest)}`,
260
+ ...(skillsDir || pr.skillState === 'ready' ? [] : [` ${warn('该 skill 根目录按同族约定新建(本机未取证);若不被读取,请用 --skills-dir 指定真实目录。')}`])],
261
+ };
262
+ }
263
+
264
+ async function install({ what = 'mcp', agent = 'all', dryRun = false, config = null, format = null, skillsDir = null, home = null } = {}) {
265
+ const prevHome = process.env.FREEDOM_AGENT_HOME;
266
+ if (home) process.env.FREEDOM_AGENT_HOME = path.resolve(home);
267
+ let out;
268
+ try {
269
+ out = await installRun({ what, agent, dryRun, config, format, skillsDir });
270
+ } finally {
271
+ if (prevHome === undefined) delete process.env.FREEDOM_AGENT_HOME;
272
+ else process.env.FREEDOM_AGENT_HOME = prevHome;
273
+ }
274
+ return out;
275
+ }
276
+
277
+ async function installRun({ what, agent, dryRun, config, format, skillsDir }) {
278
+ if (config) config = path.resolve(config);
279
+ if (skillsDir) skillsDir = path.resolve(skillsDir);
280
+ const keys = resolveAgentKeys(agent);
281
+ if (config && keys.length > 1) throw new Error('--config 指向单一目标文件,只能配合 --agent <单个 key> 使用。');
282
+ const results = [];
283
+ console.log(section(what === 'skill' ? '安装 Freedom 使用技能' : '注册 Freedom MCP 服务'));
284
+ if (dryRun) console.log(` ${warn('--dry-run 预览模式:不写任何文件')}`);
285
+ for (const key of keys) {
286
+ const a = agentByKey(key);
287
+ const fn = what === 'skill' ? installSkillOne : installMcpOne;
288
+ let r;
289
+ try {
290
+ r = fn(a, { config, format, dryRun, skillsDir });
291
+ } catch (e) {
292
+ r = { agent: a, state: 'failed', lines: [` ${err(`${key}: ${e.message}`)}`] };
293
+ }
294
+ results.push({ key, state: r.state });
295
+ console.log(`${paint(`${r.state.padEnd(9)}`, C.fg.cyan, C.bold)} ${bold(a.name)} ${dim(`(${key})`)}`);
296
+ for (const l of r.lines) console.log(l);
297
+ }
298
+ const bad = results.filter((x) => x.state === 'failed').length;
299
+ console.log('');
300
+ console.log(` ${dim('汇总:')}${results.length} 个 agent${bad ? `,${err(`${bad} 个失败`)}` : ''}${dryRun ? ` ${dim('(预览,未落盘)')}` : ''}`);
301
+ return { results, code: bad ? 1 : 0 };
302
+ }
303
+
304
+ // 打印支持矩阵(freedom agents)
305
+ function printMatrix() {
306
+ console.log(section('Agent 集成支持矩阵'));
307
+ console.log(` ${dim('ready=配置文件/技能目录已存在(合并写入) convention=仅主目录在(按同族约定新建并提示) unknown=本机未取证(仅打印片段)')}`);
308
+ console.log('');
309
+ for (const r of matrix()) {
310
+ const cell = (s) => (s === 'ready' ? `${ok('ready ')}` : s === 'convention' ? `${paint('conventn ', C.fg.yellow)}` : dim('unknown'));
311
+ console.log(` ${paint(r.key.padEnd(17), C.fg.cyan, C.bold)} MCP ${cell(r.mcpState)} ${dim(r.mcpFile)} · SKILL ${cell(r.skillState)} ${dim(r.skillsDir)}`);
312
+ }
313
+ console.log('');
314
+ console.log(` ${dim('用法:')}${paint('freedom skill install --agent all', C.fg.white)} / ${paint('freedom mcp install --agent <key> [--dry-run] [--config <path> --format <json|toml|yaml>]', C.fg.white)}`);
315
+ return 0;
316
+ }
317
+
318
+ module.exports = { install, printMatrix, upsertJson, upsertToml, upsertYaml };