cabletidy 0.1.0 → 0.2.0

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.
package/README.md CHANGED
@@ -31,7 +31,9 @@ Codex 的可选模型设置目前只支持与本机官方 GPT 目录明确对应
31
31
 
32
32
  ## 安装与运行
33
33
 
34
- 推荐 Node.js 24,也支持 Node.js 22.13 及以上的 22.x 版本;完整版本约束见 `package.json`。目前验证平台为 Linux,macOS 和 Windows 尚待验证。
34
+ 推荐 Node.js 24,也支持 Node.js 22.13 及以上的 22.x 版本;完整版本约束见 `package.json`。CI 在 Linux、macOS 和 Windows 上执行源码测试与安装冒烟测试,平台验证结果以对应提交的 CI 为准。Windows 使用系统自带的 Windows PowerShell 查询进程身份;Codex 模型目录查询还需要允许 PowerShell `Add-Type` 调用 Windows Job Object API。
35
+
36
+ `0.2.0` 的变更与升级注意事项见 [发布说明](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/releases/0.2.0.md)。
35
37
 
36
38
  ### 从 npm 官方源安装
37
39
 
@@ -52,7 +54,7 @@ npm exec --yes --registry=https://registry.npmjs.org/ --package=cabletidy -- cab
52
54
  拿到 `.tgz` 安装包后,无需 clone 仓库即可安装:
53
55
 
54
56
  ```bash
55
- npm install -g /absolute/path/cabletidy-0.1.0.tgz
57
+ npm install -g /absolute/path/cabletidy-0.2.0.tgz
56
58
  cabletidy --version
57
59
  cabletidy start
58
60
  ```
@@ -60,7 +62,7 @@ cabletidy start
60
62
  也可以不做全局安装,直接运行同一个安装包:
61
63
 
62
64
  ```bash
63
- npm exec --yes --package=/absolute/path/cabletidy-0.1.0.tgz -- cabletidy start
65
+ npm exec --yes --package=/absolute/path/cabletidy-0.2.0.tgz -- cabletidy start
64
66
  ```
65
67
 
66
68
  安装包仍依赖本机 Node.js;npm 会下载尚未缓存的运行时依赖,因此 `.tgz` 本身不代表完全离线安装。安装过程不会启动服务或修改客户端配置。
@@ -77,6 +79,7 @@ cabletidy status
77
79
  ```
78
80
 
79
81
  `status` 同时显示 daemon 是否在线、管理台 URL、配置版本和当前配置套装列表;每套配置包含 ID、名称、目标 CLI、本地接入 URL 和启用状态,不再需要单独的 URL 或 Web 状态命令。
82
+ `status` 和不带参数的命令只读取状态,不创建数据目录、配置、密钥或备份。尚未初始化时,只提示未启动以及运行 `cabletidy start`,不返回预设的管理台地址。
80
83
 
81
84
  升级时先停止服务,再安装最新版本并重新启动:
82
85
 
@@ -93,7 +96,7 @@ cabletidy start
93
96
  npm ci
94
97
  npm start
95
98
 
96
- # 生成可安装的 cabletidy-0.1.0.tgz。
99
+ # 生成可安装的 cabletidy-0.2.0.tgz。
97
100
  npm pack
98
101
  ```
99
102
 
@@ -106,7 +109,7 @@ npm test
106
109
  npm run test:package
107
110
  ```
108
111
 
109
- 安装冒烟测试会在临时目录打包、安装、启动和卸载,验证安装后的 CLI、Web 资源及用户数据保留;需要能够获取 npm 依赖。Linux 上还验证 SIGINT / SIGTERM 和端口冲突。分发方案的取舍、维护者发布流程与后续服务管理设计见 [安装与分发决策](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/installation-research.md)。
112
+ 安装冒烟测试会在临时目录打包、安装、启动和卸载,验证安装后的 CLI、Web 资源、重复启动、重启地址及用户数据保留;需要能够获取 npm 依赖。Linux / macOS 验证 SIGINT / SIGTERM;Windows 直接执行 npm 的 `.cmd` 入口,验证强制终止后的旧锁恢复。Windows 的退出处理逻辑通过测试 IPC 触发验证,真实终端 Ctrl+C 的事件投递仍需交互验收。分发方案的取舍、维护者发布流程与后续服务管理设计见 [安装与分发决策](https://github.com/ZY-WANG-0304/cabletidy/blob/main/docs/installation-research.md)。
110
113
 
111
114
  使用 Codex 可选模型设置时,本机需要可执行支持 `debug models --bundled` 的 Codex CLI,daemon 的 `PATH` 必须包含它。目录读取失败不会阻止纯透传配置的创建、代理启动、预览或应用,但会阻止新增模型设置或应用包含模型设置的 Codex 接入;管理台可在更新 Codex 后刷新模型列表。已在 Codex 0.154.0 验证目录加载和实际请求。
112
115
 
@@ -120,15 +123,27 @@ CABLETIDY_HOME="$PWD/.cabletidy-dev" cabletidy start
120
123
 
121
124
  源码运行时,将命令末尾的 `cabletidy start` 换成 `npm start`。
122
125
 
123
- Web 管理台与所有 Virtual Provider 共用 `127.0.0.1:43100`。每份配置通过 `/<配置ID>/v1/...` 接入,例如 `http://127.0.0.1:43100/codex-main/v1/responses`;管理接口仍使用 `/api/v1/...`。
126
+ 首次执行 `start` 且数据目录中没有 `config.json` 时,先尝试监听 `127.0.0.1:43100`;若端口已被占用,则直接由操作系统分配空闲端口。监听成功后将实际端口保存到 `config.json` 的 `web.port`,后续启动复用该端口。多人共用服务器时,各用户的数据目录独立,首次启动会自动避开已占用端口。
127
+
128
+ 已有配置中的端口(包括手动指定的端口)被占用时,启动会明确报错,不会自动更换。可以停止占用端口的服务,或修改 `web.port` 后重启,并重新应用客户端配置。实际地址以启动输出或 `cabletidy status` 为准。
129
+
130
+ 同一份数据目录只允许运行一个实例,启动和退出清理期间均持有 `daemon.lock`。锁记录主机名、持有者 PID 和进程启动标识:Linux 使用系统启动 ID 与进程启动时钟,macOS 使用 `ps` 的启动时间,Windows 使用 PowerShell 的 `StartTime`。正常退出或启动失败后释放;只有能识别锁的代次、确认原进程已退出或 PID 已被复用,且锁至少 10 秒未更新时才自动回收。被暂停的实例仍然持有锁,恢复后不会因锁被误回收而崩溃。
131
+
132
+ 旧版本的空锁目录、缺少启动标识的存活 PID、进程查询不可用或来自另一主机的锁,可能无法确认归属。此时启动返回 `ELOCKUNKNOWN` 并列出锁目录路径;继续等待不会补全缺失信息。请检查并停止使用该数据目录的 CableTidy,确认没有运行中或暂停中的实例后,手动删除提示的 `daemon.lock` 目录并重新启动,保留 `config.json` 和 `secrets.json`。不要仅凭 `status` 离线就删除锁,暂停中的实例也可能无法响应探测。
133
+
134
+ Web 管理台与所有 Virtual Provider 共用最终分配的端口。以下示例假设端口为 `43100`;每份配置通过 `/<配置ID>/v1/...` 接入,例如 `http://127.0.0.1:43100/codex-main/v1/responses`;管理接口仍使用 `/api/v1/...`。
124
135
 
125
136
  ## Web 配置管理
126
137
 
127
138
  管理台直接显示配置列表,首次使用与后续使用采用相同的操作流程,不设置介绍页或配置向导。点击“新建配置”填写:
128
139
 
129
- - 配置名称(可选);留空时按 `Codex / <上游主机名>` 自动生成展示名称。
140
+ - 目标 CLI:Codex 或 Claude Code。
141
+ - 配置名称(可选);留空时按 `<CLI 名称> / <上游主机名>` 自动生成展示名称。
130
142
  - 上游地址和 API Key。
131
- - 模型设置(可选):需要改名时选择 Codex 官方模型并填写上游真实模型名;默认无需添加。
143
+ - Claude Code 上游可选 `x-api-key` 或 `Authorization: Bearer` 认证。
144
+ - 模型设置(可选):Codex 选择官方模型,Claude Code 填写客户端请求中的完整模型 ID;上游模型 ID 留空时直接透传。
145
+ - Claude Code 的“模型别名”(可选):Opus / Sonnet / Fable / Haiku 从本次填写的客户端模型 ID 中选择,也可由 Claude 默认决定。
146
+ - Claude Code 的“默认模型”(可选):启动模型和子代理模型均以下拉选项选择。启动模型支持 Claude 官方的 `best`、`opus`、`sonnet`、`fable`、`haiku`、`opusplan`;子代理模型支持 `opus`、`sonnet`、`fable`、`haiku`。两者都可选择对应的默认行为,或当前配置中的客户端模型 ID;保存别名本身,不提前转换为具体模型 ID。
132
147
 
133
148
  点击“创建配置”后,服务端自动校验、保存并使配置生效,成功后进入详情页。本地服务入口和路由自动生成。配置 ID 按配置名称规范化生成,Virtual Provider ID 为 `cabletidy_<配置ID>`,并直接用作 Codex 的 `model_provider`;URL 路径使用不带此前缀的配置 ID。留空名称时,自动生成的名称也会参与 ID 规范化;如果多个配置使用同一上游主机且都留空名称,需要为后续配置填写不同名称。
134
149
 
@@ -136,7 +151,7 @@ Web 管理台与所有 Virtual Provider 共用 `127.0.0.1:43100`。每份配置
136
151
 
137
152
  仅在存在实际未保存修改时,切换页面、返回列表或关闭页面会提示确认;改回原值后不再拦截。点击“刷新模型列表”同步更新沿用官方定义的元数据和最新限制,保留手动覆盖值与其他表单输入。
138
153
 
139
- “应用到 Codex”保持独立,只有主动点击时才修改 Codex 配置文件。测试连通性和配置预览使用已保存的配置。
154
+ “应用到 Codex”和“应用到 Claude Code”保持独立,只有主动点击时才修改客户端配置文件。测试连通性和配置预览使用已保存的配置。
140
155
 
141
156
  按需在 Codex 套装详情页中添加模型设置;仅覆盖上下文时无需填写上游模型 ID。模型能力与上下文默认折叠,元数据默认“沿用官方定义”;
142
157
  展开并选择“覆盖上游限制”后可设置 context window 和图片输入,已有覆盖值或待确认的旧策略会自动展开。不提供固定 reasoning effort 或未实现的 compact 控件。
@@ -212,9 +227,78 @@ curl http://127.0.0.1:43100/codex-main/v1/models
212
227
  Claude Code 适配器会自动注入固定的 `cabletidy-local` 占位值以满足客户端认证检查,
213
228
  它不是密钥,本地服务不会校验这个值。Generic CLI 只生成本地地址和模型环境变量。
214
229
 
230
+ ## Claude Code 接入
231
+
232
+ 新建配置时选择 Claude Code,填写 Anthropic Messages 兼容上游地址、认证方式和凭据。Anthropic 官方 API 和提供同协议的中转服务使用同一接入流程,每份配置仍只连接一个上游。
233
+
234
+ 默认透传客户端模型名,无需登记模型。按需添加“客户端完整模型 ID → 上游模型 ID”映射;仅改名不会限制工具、图片、thinking 等能力,具体能力由客户端和上游决定。已有配置中的显式能力限制仍然生效。Claude Code 的 `sonnet`、`opus` 等别名会在客户端解析,映射应填写实际发到 API 的完整模型 ID。
235
+
236
+ 模型映射只提供“客户端模型 ID”和“上游模型 ID”。客户端输入框支持搜索官方建议,也可直接填写自定义 ID;上游 ID 手动填写,留空时原样透传。建议目录内置于 `web/claude-models.js`,根据 [Anthropic 模型文档](https://platform.claude.com/docs/en/about-claude/models/overview) 整理,记录核对日期和来源,随 CableTidy 发版更新,无需额外 API Key。目录只辅助输入,不限制保存或请求中的模型名,也不会自动加入 `/v1/models`。
237
+
238
+ “Claude Code 模型选择”分为“模型别名”和“默认模型”:前者包含 Opus/Sonnet/Fable/Haiku 的别名指向,后者包含下拉选择的启动模型和子代理模型。启动模型和子代理模型的下拉都提供默认行为、对应的官方别名和当前配置客户端模型 ID;创建页使用本次填写的模型映射,详情页使用已保存的模型映射,不使用上游模型 ID 或其他配置的模型。模型发现开关保留在表单底部,不单独分组。四个家族别名分别生成官方的 `ANTHROPIC_DEFAULT_<家族>_MODEL` 变量,Fable 使用 `ANTHROPIC_DEFAULT_FABLE_MODEL`。模型改名时同步更新引用它的家族别名、启动模型和子代理模型;删除映射时,引用该客户端 ID 的选择回到默认。启动模型或子代理模型选择的官方别名始终保留原文,由 Claude 解析,即使与内部模型 ID 同名也不会提前展开。保存后需要重新应用客户端配置。已不在候选列表中的旧默认值会在下一次正常保存时清空,页面不会自动写回配置。
239
+
240
+ 选择默认行为时 CableTidy 不指定该字段对应的模型;再次应用会恢复此前接管的对应环境变量原值,由 Claude 自身配置决定。选择启动模型通过 `ANTHROPIC_MODEL` 设置,选择子代理模型通过 `CLAUDE_CODE_SUBAGENT_MODEL` 设置,后续每次启动都会生效。上下文、reasoning effort 和压缩策略仍由 Claude Code 管理,不生成没有实际效果的逐模型窗口或 compact 设置。
241
+
242
+ 启动模型下拉不提供 `[1m]` 选项。`[1m]` 是 Claude Code 自身的上下文模式控制,Claude Code 会在发送上游请求前移除该后缀;CableTidy 无法通过 `/v1/models` 判断或保证上游支持 1M,也没有专门的 1M 能力验证,因此不在界面中暴露此配置。
243
+
244
+ ### 预览、应用与撤销
245
+
246
+ 点击“预览配置”不会写入文件。预览仅展示 CableTidy 生成的字段、目标路径和冲突提示,不回显原客户端文件中的凭据。“应用到 Claude Code”增量合并用户配置的 `env`:默认位置为 `~/.claude/settings.json`,Windows 为 `%USERPROFILE%\.claude\settings.json`;设置了 `CLAUDE_CONFIG_DIR` 时使用该目录。
247
+
248
+ 接入的核心配置如下,Base URL 不额外带 `/v1`:
249
+
250
+ ```json
251
+ {
252
+ "env": {
253
+ "ANTHROPIC_BASE_URL": "http://127.0.0.1:43100/claude-main",
254
+ "ANTHROPIC_AUTH_TOKEN": "cabletidy-local"
255
+ }
256
+ }
257
+ ```
258
+
259
+ 生成的完整配置还会将用户环境中的旧 API Key、OAuth Token、自定义请求头置空,并将云厂商接入开关设为 `0`,使该配置使用 Messages 本地入口。原值会保留供撤销使用。权限、Hooks、MCP、根级 `model` 和其他用户设置保持不变;项目配置、`--settings` 或企业 managed settings 仍可能覆盖用户配置。应用后重新启动 Claude Code,用 `/status` 核对 Base URL 和认证来源。
260
+
261
+ 用户只填写上游凭据。本地不生成、保存或校验 Key;`cabletidy-local` 是公开占位值,仅满足 Claude 客户端认证检查,不会转发给上游。直接调用四个本地接口可以完全不带认证头。真实上游认证始终来自 CableTidy 的 secrets store。
262
+
263
+ 应用前会在 CableTidy 的 `backups` 目录备份原文件,在 Claude 配置目录的 `.cabletidy-settings.json` 中记录接管字段及原值,文件权限为 `0600`。切换到另一份 Claude 配置后,撤销仍恢复最初接入前的值。点击当前已应用配置的“撤销接入”恢复这些字段,保留应用后新增的无关设置;不会停止本地服务。
264
+
265
+ 如果接管字段已被手工修改,预览列出冲突字段,应用和撤销均停止写入,不覆盖修改,也不显示冲突字段的秘密值。请先核对备份并保留自己的修改后处理冲突。无效 JSON、非普通文件和符号链接文件不会被替换。已有 `claude.env.v1` 配置继续兼容,新配置使用 `claude.settings.json.v1`。
266
+
267
+ 管理台仅展示实际应用到 `settings.json` 的 JSON 片段,包含将合并的 `env` 字段,并显示目标路径、变更字段和冲突提示。点击“应用到 Claude Code”后仍显示同一种内容,不展示独立文件、Shell 命令或 PowerShell 等其他接入方式。
268
+
269
+ ### 本地接口
270
+
271
+ 所有路径均相对于 `ANTHROPIC_BASE_URL`,共享配置的启停状态:
272
+
273
+ | 方法 | 路径 | 行为 |
274
+ | --- | --- | --- |
275
+ | POST | `/v1/messages` | 普通及 SSE 推理;保留查询参数、协议扩展字段和 `anthropic-*` 请求头 |
276
+ | POST | `/v1/messages/count_tokens` | 使用相同上游和模型映射,返回上游计数;不要求 `max_tokens`,上游不支持时保留其错误 |
277
+ | GET | `/v1/models` | 只返回当前配置显式设置的客户端模型,协议字段 `display_name` 使用客户端 ID;支持 `limit=1..1000` 和 `after_id` |
278
+ | HEAD | `/api/hello` | 本地返回 204、不请求上游,仅确认本地入口可达 |
279
+
280
+ `/messages` 保留为旧版兼容路径。标准接口应使用 `/v1/messages`。SSE 保留 ping、工具参数增量和流内错误,客户端断开时取消上游请求;模型改名只修改 Messages 响应及 `message_start` 的模型字段,不改工具数据。上游错误保留原状态码和响应体,重试、请求 ID 和 Anthropic 限流头随响应转发。
281
+
282
+ 模型发现需在客户端开启 `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1`,可通过管理台复选框生成。纯透传配置返回空列表,不影响其他模型请求;模型列表不是白名单,也不自动发现上游模型。Claude 会按自身版本过滤发现条目,不含 `claude` 或 `anthropic` 的别名可能不显示。Token counting 为官方可选接口,上游不支持时 Claude 可退回上下文估算。
283
+
284
+ “测试连通性”与 Codex 使用相同逻辑:使用已保存的连接向上游 Base URL 发起 GET 请求,检查 HTTP 可达性并展示耗时,不发起模型推理,也不判断认证、模型或流式推理是否可用。
285
+
286
+ ### 验证与范围
287
+
288
+ `npm test` 包含模拟上游的协议、免认证入口、配置合并、恢复、冲突与管理台测试。安装本机 Claude Code 后可执行原生 CLI 冒烟测试,它使用临时配置目录和本地模拟上游,不调用真实模型,也不修改日常 Claude 配置:
289
+
290
+ ```bash
291
+ npm run test:claude
292
+
293
+ # 多个安装版本共存时,可指定要验证的可执行文件。
294
+ CABLETIDY_CLAUDE_BIN=/absolute/path/claude npm run test:claude
295
+ ```
296
+
297
+ 首版仅支持 Anthropic Messages 兼容上游。HTTP/HTTPS 企业网络代理、动态上游凭据、Bedrock/Vertex 原生协议转换和内置 `claude gateway` 托管不在本版范围。模型网关不代表 npm、Git、MCP 子进程或 Claude 其他联网流量也经过 CableTidy。
298
+
215
299
  ## 其他 CLI
216
300
 
217
- Claude Code 使用 Anthropic Messages Virtual Provider,目标配置通过管理台预览。未来的 Gemini CLI、OpenCode 和其他编程 CLI 可以增加各自的 Target Adapter;它们不需要被强行改成 OpenAI 配置。
301
+ 未来的 Gemini CLI、OpenCode 和其他编程 CLI 可以增加各自的 Target Adapter;它们不需要被强行改成 OpenAI 配置。
218
302
 
219
303
  ## CLI
220
304
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cabletidy",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "A local multi-target coding CLI gateway and provider manager.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,9 +32,14 @@
32
32
  "dev": "node --watch src/server.mjs",
33
33
  "test": "node --test test/*.test.mjs",
34
34
  "test:package": "node --test scripts/package-smoke.mjs",
35
+ "test:claude": "node --test scripts/claude-smoke.mjs",
35
36
  "prepublishOnly": "npm test && npm run test:package"
36
37
  },
37
38
  "dependencies": {
39
+ "proper-lockfile": "^4.1.2",
38
40
  "toml-eslint-parser": "^1.0.3"
41
+ },
42
+ "devDependencies": {
43
+ "cross-spawn": "^7.0.6"
39
44
  }
40
45
  }
@@ -0,0 +1,172 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import lockfile from "proper-lockfile";
6
+ import { backupFile, writeJsonAtomic } from "./config.mjs";
7
+
8
+ const STATE_FILE = ".cabletidy-settings.json";
9
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
+ const digest = (value) => crypto.createHash("sha256").update(value ?? "<absent>").digest("hex");
11
+ const snapshot = (env, key) => Object.hasOwn(env, key) ? { present: true, value: env[key] } : { present: false };
12
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
13
+
14
+ function parseSettings(text, file) {
15
+ try { return JSON.parse(text); }
16
+ catch { throw new Error(`Invalid JSON in ${file}; the file was left unchanged.`); }
17
+ }
18
+
19
+ export function claudeConfigHome(options = {}) {
20
+ return path.resolve(options.claudeHome || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"));
21
+ }
22
+
23
+ async function readOptional(file) {
24
+ try {
25
+ const stat = await fs.lstat(file);
26
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Expected a regular file: ${file}`);
27
+ return await fs.readFile(file, "utf8");
28
+ } catch (error) {
29
+ if (error.code === "ENOENT") return null;
30
+ throw error;
31
+ }
32
+ }
33
+
34
+ async function readState(home) {
35
+ const file = path.join(home, "settings.json");
36
+ const text = await readOptional(file);
37
+ const settings = text === null ? {} : parseSettings(text, file);
38
+ if (!record(settings) || (settings.env !== undefined && !record(settings.env))) {
39
+ throw new Error(`Invalid Claude settings object: ${file}`);
40
+ }
41
+ const stateText = await readOptional(path.join(home, STATE_FILE));
42
+ let state = stateText === null ? null : parseSettings(stateText, path.join(home, STATE_FILE));
43
+ if (state?.pending) {
44
+ // Recover either side of an interrupted atomic settings replacement.
45
+ if (digest(text) === state.pending.before) state = state.pending.previous;
46
+ else if (digest(text) === state.pending.after) delete state.pending;
47
+ else throw new Error("Claude settings changed during an interrupted apply; restore from backup before retrying.");
48
+ }
49
+ if (state !== null && (state.version !== 1 || !record(state.fields) || typeof state.bindingId !== "string")) {
50
+ throw new Error(`Invalid CableTidy ownership record: ${path.join(home, STATE_FILE)}`);
51
+ }
52
+ for (const [key, field] of Object.entries(state?.fields || {})) {
53
+ if (!key.startsWith("ANTHROPIC_") && !key.startsWith("CLAUDE_CODE_")) throw new Error("Invalid managed environment key");
54
+ if (!record(field) || !record(field.before) || typeof field.applied !== "string") throw new Error("Invalid managed environment record");
55
+ }
56
+ return { file, text, settings, state, stateText };
57
+ }
58
+
59
+ function plan(snapshotData, vars, bindingId) {
60
+ const { settings, state } = snapshotData;
61
+ const next = structuredClone(settings);
62
+ const env = next.env || {};
63
+ const fields = {};
64
+ const conflicts = [];
65
+ const changed = [];
66
+ for (const [key, field] of Object.entries(state?.fields || {})) {
67
+ if (!same(snapshot(env, key), { present: true, value: field.applied })) conflicts.push(`env.${key}`);
68
+ }
69
+ for (const key of new Set([...Object.keys(state?.fields || {}), ...Object.keys(vars)])) {
70
+ const current = snapshot(env, key);
71
+ const old = state?.fields[key];
72
+ if (Object.hasOwn(vars, key)) {
73
+ fields[key] = { before: old?.before || current, applied: vars[key] };
74
+ env[key] = vars[key];
75
+ } else if (old.before.present) env[key] = old.before.value;
76
+ else delete env[key];
77
+ if (!same(current, snapshot(env, key))) changed.push(`env.${key}`);
78
+ }
79
+ if (Object.keys(env).length || (state?.hadEnv ?? Object.hasOwn(settings, "env"))) next.env = env;
80
+ else delete next.env;
81
+ return {
82
+ next, conflicts, changed,
83
+ state: {
84
+ version: 1, bindingId, fields,
85
+ hadEnv: state?.hadEnv ?? Object.hasOwn(settings, "env"),
86
+ },
87
+ };
88
+ }
89
+
90
+ function diagnostics(settings, vars) {
91
+ const warnings = [];
92
+ const env = settings.env || {};
93
+ for (const key of Object.keys(vars)) {
94
+ if (Object.hasOwn(env, key) && env[key] !== vars[key]) warnings.push(`Will replace env.${key}; its original value is retained for restore.`);
95
+ }
96
+ if (settings.apiKeyHelper) warnings.push("An existing apiKeyHelper is preserved. Verify the active credential source with /status.");
97
+ if (settings.forceLoginMethod) warnings.push("An existing forceLoginMethod may require a different login method.");
98
+ warnings.push("Project settings, --settings and managed policies can override user settings. Restart Claude Code and check /status after applying.");
99
+ return warnings;
100
+ }
101
+
102
+ export async function prepareClaudeSettings(artifacts, options = {}) {
103
+ const home = claudeConfigHome(options);
104
+ const current = await readState(home);
105
+ const staged = plan(current, artifacts.environment.vars, artifacts.bindingId);
106
+ return {
107
+ ...artifacts,
108
+ settingsPath: current.file,
109
+ changes: staged.changed,
110
+ canApply: staged.conflicts.length === 0,
111
+ canRestore: current.state?.bindingId === artifacts.bindingId,
112
+ conflicts: staged.conflicts,
113
+ warnings: [...(artifacts.warnings || []), ...diagnostics(current.settings, artifacts.environment.vars)],
114
+ };
115
+ }
116
+
117
+ async function withSettingsLock(options, operation) {
118
+ const home = claudeConfigHome(options);
119
+ await fs.mkdir(home, { recursive: true, mode: 0o700 });
120
+ const release = await lockfile.lock(home, { realpath: true, retries: { retries: 20, minTimeout: 25, maxTimeout: 100 } });
121
+ try { return await operation(home); }
122
+ finally { await release(); }
123
+ }
124
+
125
+ async function commit(home, current, staged, options, restoring) {
126
+ if (staged.conflicts.length) {
127
+ throw new Error(`Claude settings were edited after applying CableTidy: ${staged.conflicts.join(", ")}. Preserve your edits before retrying.`);
128
+ }
129
+ const backups = options.paths?.backups || path.join(os.homedir(), ".cabletidy", "backups");
130
+ await fs.mkdir(backups, { recursive: true, mode: 0o700 });
131
+ const stateFile = path.join(home, STATE_FILE);
132
+ const backup = await backupFile(current.file, backups, "claude-settings.json");
133
+ await backupFile(stateFile, backups, "claude-settings-state.json");
134
+ if (await readOptional(current.file) !== current.text || await readOptional(stateFile) !== current.stateText) {
135
+ throw new Error("Claude settings changed during apply; preview and retry.");
136
+ }
137
+ const contents = `${JSON.stringify(staged.next, null, 2)}\n`;
138
+ await writeJsonAtomic(stateFile, {
139
+ ...staged.state,
140
+ pending: { before: digest(current.text), after: digest(contents), previous: current.state },
141
+ });
142
+ try {
143
+ if (await readOptional(current.file) !== current.text) throw new Error("Claude settings changed during apply; preview and retry.");
144
+ await writeJsonAtomic(current.file, staged.next);
145
+ } catch (error) {
146
+ if (current.stateText !== null) await writeJsonAtomic(stateFile, JSON.parse(current.stateText));
147
+ else await fs.unlink(stateFile);
148
+ throw error;
149
+ }
150
+ // A pending record remains recoverable if finalizing the ledger fails.
151
+ if (restoring) await fs.unlink(stateFile);
152
+ else await writeJsonAtomic(stateFile, staged.state);
153
+ return { applied: [current.file], backup, changes: staged.changed, mode: restoring ? "restored" : "managed_proxy" };
154
+ }
155
+
156
+ export function applyClaudeSettings(artifacts, options = {}) {
157
+ return withSettingsLock(options, async (home) => {
158
+ const current = await readState(home);
159
+ const staged = plan(current, artifacts.environment.vars, artifacts.bindingId);
160
+ const result = await commit(home, current, staged, options, false);
161
+ return { ...result, environment: artifacts.environment, warnings: diagnostics(current.settings, artifacts.environment.vars) };
162
+ });
163
+ }
164
+
165
+ export function restoreClaudeSettings(bindingId, options = {}) {
166
+ return withSettingsLock(options, async (home) => {
167
+ const current = await readState(home);
168
+ if (!current.state) throw new Error("No Claude settings are managed by CableTidy.");
169
+ if (current.state.bindingId !== bindingId) throw new Error(`Claude Code currently uses another CableTidy configuration: ${current.state.bindingId}`);
170
+ return commit(home, current, plan(current, {}, bindingId), options, true);
171
+ });
172
+ }
package/src/cli.mjs CHANGED
@@ -31,7 +31,12 @@ async function main() {
31
31
  const paths = getPaths();
32
32
 
33
33
  if (command === "status") {
34
- const [config, runtime] = await Promise.all([loadConfig(paths), readRuntimeInfo(paths)]);
34
+ const config = await loadConfig(paths);
35
+ if (!config) {
36
+ print({ runtime: { status: "offline", liveness: "尚未启动,未找到配置;请执行 cabletidy start" } });
37
+ return;
38
+ }
39
+ const runtime = await readRuntimeInfo(paths);
35
40
  const daemon = runtime ? await probeDaemon(runtime) : { online: false, reason: "runtime.json 不存在" };
36
41
  const web = runtime?.web || {
37
42
  host: config.web.listenHost,
@@ -1,4 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+ import { powershell } from "./process-identity.mjs";
2
4
  import { clientModelIdForProfile, effectiveCapabilities } from "./model-resolver.mjs";
3
5
 
4
6
  let cached;
@@ -7,11 +9,16 @@ let activeCommand;
7
9
 
8
10
  async function executeCodex(args) {
9
11
  const timeout = AbortSignal.timeout(15_000);
10
- const command = spawn("codex", args, {
12
+ const windows = process.platform === "win32";
13
+ const command = spawn(windows ? powershell : "codex", windows ? [
14
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File",
15
+ fileURLToPath(new URL("./windows-codex.ps1", import.meta.url)),
16
+ args[0] === "--version" ? "version" : "models", String(process.pid),
17
+ ] : args, {
11
18
  windowsHide: true,
12
19
  stdio: ["ignore", "pipe", "pipe"],
13
- // Keep npm launcher descendants in a group we can stop on forced daemon exit.
14
- detached: process.platform !== "win32",
20
+ // Windows ignores console Ctrl+C inside the supervisor; POSIX uses a group.
21
+ detached: !windows,
15
22
  });
16
23
  activeCommand = command;
17
24
  const closed = new Promise(resolve => {
@@ -21,7 +28,8 @@ async function executeCodex(args) {
21
28
  const completed = new Promise((resolve, reject) => {
22
29
  command.once("error", reject);
23
30
  command.once("exit", (code, signal) => {
24
- if (code !== 0) reject(new Error(`codex exited with ${signal || code}`));
31
+ if (code !== 0) reject(Object.assign(new Error(`codex exited with ${signal || code}`),
32
+ windows && code === 127 ? { code: "ENOENT" } : {}));
25
33
  });
26
34
  // Descendants may hold the pipes open after the launcher has exited.
27
35
  onTimeout = () => reject(timeout.reason);
@@ -56,6 +64,7 @@ async function readCommandOutput(stream) {
56
64
  export function forceStopCodexCatalog() {
57
65
  if (!activeCommand?.pid) return;
58
66
  try {
67
+ // Terminating the Windows supervisor closes its Job Object and its entire tree.
59
68
  if (process.platform === "win32") activeCommand.kill("SIGKILL");
60
69
  else process.kill(-activeCommand.pid, "SIGKILL");
61
70
  } catch (error) {
package/src/config.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
+ import { setTimeout as delay } from "node:timers/promises";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { normalizeConfigurationIdentities } from "../web/config-identity.js";
@@ -27,6 +28,7 @@ export function getPaths(home = DEFAULT_HOME) {
27
28
  config: path.join(home, "config.json"),
28
29
  secrets: path.join(home, "secrets.json"),
29
30
  runtime: path.join(home, "runtime.json"),
31
+ lock: path.join(home, "daemon.lock"),
30
32
  backups: path.join(home, "backups"),
31
33
  };
32
34
  }
@@ -37,7 +39,6 @@ export function defaultConfig() {
37
39
  revision: 0,
38
40
  daemon: {},
39
41
  web: {
40
- enabled: true,
41
42
  listenHost: "127.0.0.1",
42
43
  port: 43100,
43
44
  },
@@ -76,7 +77,7 @@ export function normalizeConfig(input = {}) {
76
77
  // lifetime settings are no longer part of the effective configuration.
77
78
  delete config.web.sessionTtlSeconds;
78
79
  if (isRecord(config.daemon)) delete config.daemon.proxyPortRange;
79
- migratePrototypeCodexFields(config);
80
+ discardObsoleteCodexFields(config);
80
81
  for (const provider of Object.values(config.virtualProviders)) {
81
82
  if (isRecord(provider)) {
82
83
  delete provider.localAuth;
@@ -92,18 +93,13 @@ export function normalizeConfig(input = {}) {
92
93
  return config;
93
94
  }
94
95
 
95
- function migratePrototypeCodexFields(config) {
96
- // Read legacy prototype keys once, translate useful values, and discard
97
- // profile-file metadata before the config reaches runtime or disk.
96
+ function discardObsoleteCodexFields(config) {
98
97
  for (const upstream of Object.values(config.upstreams)) {
99
98
  if (!isRecord(upstream)) continue;
100
99
  // These policy placeholders never affected runtime behavior.
101
100
  for (const field of ["requestMaxRetries", "streamMaxRetries", "streamIdleTimeoutMs", "requiresOpenaiAuth", "supportsWebsockets"]) {
102
101
  delete upstream[field];
103
102
  }
104
- if (!upstream.integration && upstream.providerFormat === "codex.toml.v1") {
105
- upstream.integration = "codex-native-provider";
106
- }
107
103
  delete upstream.envKey;
108
104
  delete upstream.codexNative;
109
105
  delete upstream.providerFormat;
@@ -112,30 +108,7 @@ function migratePrototypeCodexFields(config) {
112
108
 
113
109
  for (const model of Object.values(config.models)) {
114
110
  if (!isRecord(model)) continue;
115
- const legacy = isRecord(model.targetOverrides?.codex)
116
- ? model.targetOverrides.codex
117
- : isRecord(model.legacyCodex)
118
- ? model.legacyCodex
119
- : null;
120
- if (isRecord(legacy)) {
121
- if (model.clientModelId === undefined && legacy.model) {
122
- model.clientModelId = legacy.model;
123
- }
124
- if (model.contextWindow === undefined && legacy.modelContextWindow !== undefined) {
125
- model.contextWindow = legacy.modelContextWindow;
126
- }
127
- if (model.compact === undefined && legacy.modelAutoCompactTokenLimit !== undefined) {
128
- model.compact = {
129
- strategy: "auto",
130
- tokenLimit: legacy.modelAutoCompactTokenLimit,
131
- };
132
- }
133
- if (model.personality === undefined && legacy.personality) {
134
- model.personality = legacy.personality;
135
- }
136
- }
137
- // Profile IDs, profile file paths, and other target-only fields belonged
138
- // to the old prototype. They are migration input, not CableTidy state.
111
+ // Prototype metadata is discarded, never promoted into current fields.
139
112
  delete model.legacyCodex;
140
113
  if (isRecord(model.targetOverrides)) {
141
114
  delete model.targetOverrides.codex;
@@ -147,12 +120,6 @@ function migratePrototypeCodexFields(config) {
147
120
 
148
121
  for (const binding of Object.values(config.bindings)) {
149
122
  if (!isRecord(binding)) continue;
150
- if (!binding.integration && binding.providerFormat === "codex.toml.v1") {
151
- binding.integration = "codex-native-provider";
152
- }
153
- if (binding.targetFormat === "codex.toml.v1") {
154
- binding.targetFormat = "codex.config.toml.v1";
155
- }
156
123
  delete binding.providerFormat;
157
124
  if (isRecord(binding.codex)) {
158
125
  for (const key of ["profileFiles", "sourceUpstream", "sourceProviderId"]) {
@@ -165,7 +132,7 @@ function migratePrototypeCodexFields(config) {
165
132
  }
166
133
 
167
134
  export function publicConfig(config) {
168
- const copy = structuredClone(normalizeConfig(config));
135
+ const copy = normalizeConfig(config);
169
136
  redactSecretFields(copy);
170
137
  return copy;
171
138
  }
@@ -179,38 +146,50 @@ export function stripSecretFields(value) {
179
146
  export async function ensureStore(paths = getPaths()) {
180
147
  await fs.mkdir(paths.home, { recursive: true, mode: 0o700 });
181
148
  await fs.mkdir(paths.backups, { recursive: true, mode: 0o700 });
182
- try {
183
- await fs.access(paths.config);
184
- } catch {
185
- await writeJsonAtomic(paths.config, defaultConfig(), 0o600);
186
- }
187
- try {
188
- await fs.access(paths.secrets);
189
- } catch {
190
- await writeJsonAtomic(paths.secrets, {}, 0o600);
191
- }
192
149
  }
193
150
 
194
151
  export async function loadConfig(paths = getPaths()) {
195
- await ensureStore(paths);
196
- const raw = await fs.readFile(paths.config, "utf8");
197
- return normalizeConfig(JSON.parse(raw));
152
+ try {
153
+ const raw = await fs.readFile(paths.config, "utf8");
154
+ return normalizeConfig(JSON.parse(raw));
155
+ } catch (error) {
156
+ if (error.code === "ENOENT") return null;
157
+ throw error;
158
+ }
198
159
  }
199
160
 
200
161
  export async function loadSecrets(paths = getPaths()) {
201
- await ensureStore(paths);
202
- const raw = await fs.readFile(paths.secrets, "utf8");
203
- const parsed = JSON.parse(raw);
204
- return isRecord(parsed) ? parsed : {};
162
+ try {
163
+ const raw = await fs.readFile(paths.secrets, "utf8");
164
+ const parsed = JSON.parse(raw);
165
+ return isRecord(parsed) ? parsed : {};
166
+ } catch (error) {
167
+ if (error.code === "ENOENT") return {};
168
+ throw error;
169
+ }
205
170
  }
206
171
 
207
172
  export async function writeJsonAtomic(file, value, mode = 0o600) {
208
173
  const directory = path.dirname(file);
209
174
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
210
175
  const temporary = `${file}.${process.pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
211
- await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
212
- await fs.chmod(temporary, mode);
213
- await fs.rename(temporary, file);
176
+ try {
177
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode });
178
+ await fs.chmod(temporary, mode);
179
+ const deadline = Date.now() + 1000;
180
+ while (true) {
181
+ try {
182
+ await fs.rename(temporary, file);
183
+ return;
184
+ } catch (error) {
185
+ // Windows readers and antivirus can briefly deny an atomic replacement.
186
+ if (!["EPERM", "EACCES", "EBUSY"].includes(error.code) || Date.now() >= deadline) throw error;
187
+ await delay(50);
188
+ }
189
+ }
190
+ } finally {
191
+ await fs.rm(temporary, { force: true }).catch(() => {});
192
+ }
214
193
  }
215
194
 
216
195
  export async function saveConfig(config, paths = getPaths()) {
@@ -222,16 +201,17 @@ export async function saveSecrets(secrets, paths = getPaths()) {
222
201
  }
223
202
 
224
203
  export async function backupFile(file, backupsDirectory, label) {
204
+ let contents;
225
205
  try {
226
- const contents = await fs.readFile(file);
227
- const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
228
- const backup = path.join(backupsDirectory, `${stamp}-${label}`);
229
- await fs.writeFile(backup, contents, { mode: 0o600 });
230
- return backup;
206
+ contents = await fs.readFile(file);
231
207
  } catch (error) {
232
208
  if (error.code === "ENOENT") return null;
233
209
  throw error;
234
210
  }
211
+ const stamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
212
+ const backup = path.join(backupsDirectory, `${stamp}-${label}`);
213
+ await fs.writeFile(backup, contents, { mode: 0o600 });
214
+ return backup;
235
215
  }
236
216
 
237
217
  export function secretRefForUpstream(id) {