ai-cli-switch 1.0.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 ADDED
@@ -0,0 +1,250 @@
1
+ # ai-cli-switch
2
+
3
+ > 一条命令配置所有 AI CLI 工具 — Claude Code · Codex · Gemini CLI · OpenCode · OpenClaw
4
+ > Supports any Base URL: official APIs, self-hosted proxies, or third-party relay services.
5
+
6
+ [![npm version](https://img.shields.io/npm/v/ai-cli-switch)](https://www.npmjs.com/package/ai-cli-switch)
7
+ [![npm downloads](https://img.shields.io/npm/dm/ai-cli-switch)](https://www.npmjs.com/package/ai-cli-switch)
8
+ [![Node.js](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen)](https://nodejs.org)
9
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
10
+
11
+ ---
12
+
13
+ ## Demo
14
+
15
+ ```
16
+ $ npx ai-cli-switch
17
+
18
+ ─────────────────────────────────────────
19
+ ___ ____ ________ ____
20
+ / | / _/ / ____/ / / __/___ _
21
+ / /| | / / / / / / / /_/ __ `/
22
+ / ___ |_/ / / /___/ /___/ __/ /_/ /
23
+ /_/ |_/___/ \____/_____/_/ \__, /
24
+ /____/
25
+
26
+ AI CLI Config v1.0.0
27
+ 快速配置 Claude Code / Codex / Gemini / OpenCode / OpenClaw
28
+ 支持自定义 Base URL,兼容任意 API 中转服务
29
+ ─────────────────────────────────────────
30
+
31
+ ? 是否需要设置网络代理? 否
32
+ ⠋ 正在检测环境...
33
+ ✔ 环境检测完成
34
+
35
+ 平台 macOS (arm64)
36
+ Node.js v22.0.0
37
+ Shell /bin/zsh
38
+ 用户目录 /Users/yourname
39
+
40
+ 检测到 2 个已安装工具:
41
+
42
+ ● Claude Code
43
+ ● Gemini CLI
44
+
45
+ ? 选择要配置的工具 Claude Code
46
+ ? Base URL(留空使用官方地址,或填入你的中转地址)
47
+ https://api.anthropic.com ← 默认官方,直接改成中转地址即可
48
+
49
+ ? API Key ********
50
+
51
+ ⠋ 正在测试 API 连接...
52
+ ✔ API 连接测试通过
53
+ ✔ 配置写入成功
54
+ 配置文件: /Users/yourname/.claude/settings.json
55
+ 备份文件: /Users/yourname/.claude/settings.json.bak.1709123456789
56
+ ✔ 自检通过,一切正常
57
+ └ 完成
58
+
59
+ ? 是否继续配置其他工具? 否
60
+
61
+ ─────────────────────────────────────────
62
+
63
+ ✅ 全部配置完成!
64
+
65
+ 你的 AI CLI 工具已配置完毕,尽情享用吧!
66
+
67
+ ─────────────────────────────────────────
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 简介
73
+
74
+ `ai-cli-switch` 是一个通用的 AI CLI 配置助手,支持:
75
+
76
+ - **官方 API** — `api.anthropic.com` / `api.openai.com` / `generativelanguage.googleapis.com`
77
+ - **第三方中转服务** — 如 78code.cc、openrouter 等
78
+ - **自建 API 代理** — 本地或云端任意地址
79
+
80
+ 无需手动编辑配置文件,交互式填写 Base URL 和 API Key 即可完成。
81
+
82
+ ---
83
+
84
+ ## 支持的工具
85
+
86
+ | 工具 | 说明 | 默认 Base URL |
87
+ |------|------|---------------|
88
+ | **Claude Code** | Anthropic 官方 CLI | `https://api.anthropic.com` |
89
+ | **Codex** | OpenAI Codex CLI | `https://api.openai.com/v1` |
90
+ | **Gemini CLI** | Google Gemini 命令行 | `https://generativelanguage.googleapis.com` |
91
+ | **OpenCode** | 开源 AI 编程助手(Claude/OpenAI/Gemini) | 视模型而定 |
92
+ | **OpenClaw** | 开源 AI 编程助手(Claude/OpenAI/Gemini) | 视模型而定 |
93
+
94
+ ---
95
+
96
+ ## 快速开始
97
+
98
+ ### 无需安装,直接运行
99
+
100
+ ```bash
101
+ npx ai-cli-switch
102
+ ```
103
+
104
+ ### 全局安装
105
+
106
+ ```bash
107
+ npm install -g ai-cli-switch
108
+ ai-cli-switch
109
+ ```
110
+
111
+ ---
112
+
113
+ ## Base URL 填写示例
114
+
115
+ | 场景 | Base URL 示例 |
116
+ |------|---------------|
117
+ | Anthropic 官方 | `https://api.anthropic.com` |
118
+ | OpenAI 官方 | `https://api.openai.com/v1` |
119
+ | Gemini 官方 | `https://generativelanguage.googleapis.com` |
120
+ | 78code 中转(Claude) | `https://www.78code.cc` |
121
+ | 78code 中转(OpenAI) | `https://www.78code.cc/v1` |
122
+ | 自建代理(本地) | `http://127.0.0.1:8080` |
123
+ | 云端代理 | `https://your-proxy.example.com` |
124
+
125
+ ---
126
+
127
+ ## 各工具配置详情
128
+
129
+ ### Claude Code
130
+
131
+ 写入 `~/.claude/settings.json`:
132
+
133
+ ```json
134
+ {
135
+ "env": {
136
+ "ANTHROPIC_API_KEY": "<your-api-key>",
137
+ "ANTHROPIC_BASE_URL": "<your-base-url>"
138
+ }
139
+ }
140
+ ```
141
+
142
+ ### Codex (OpenAI)
143
+
144
+ - `~/.codex/auth.json` → `{ "OPENAI_API_KEY": "..." }`
145
+ - `~/.codex/config.toml` → 添加自定义 provider 段落(provider ID 根据 Base URL hostname 自动生成)
146
+
147
+ ### Gemini CLI
148
+
149
+ 写入 `~/.gemini/.env`:
150
+
151
+ ```
152
+ GEMINI_API_KEY=<your-api-key>
153
+ GOOGLE_GEMINI_BASE_URL=<your-base-url>
154
+ ```
155
+
156
+ ### OpenCode
157
+
158
+ 写入 `~/.config/opencode/opencode.json`,provider ID 格式为 `{hostname}-{model-type}`。
159
+
160
+ ### OpenClaw
161
+
162
+ 写入 `~/.openclaw/openclaw.json`,格式同 OpenCode。
163
+
164
+ ---
165
+
166
+ ## 配置备份
167
+
168
+ 每次写入前自动备份原配置文件:
169
+
170
+ ```
171
+ ~/.claude/settings.json.bak.1709123456789
172
+ ```
173
+
174
+ ---
175
+
176
+ ## 前置要求
177
+
178
+ - **Node.js** >= 18.0.0([下载](https://nodejs.org))
179
+ - 至少安装了一个上述 AI CLI 工具
180
+
181
+ ### 安装 Claude Code(示例)
182
+
183
+ ```bash
184
+ npm install -g @anthropic-ai/claude-code
185
+ ```
186
+
187
+ ---
188
+
189
+ ## 常见问题
190
+
191
+ **Q: 提示"未检测到任何已安装的 AI CLI 工具"?**
192
+
193
+ 请先安装对应工具:
194
+
195
+ ```bash
196
+ npm install -g @anthropic-ai/claude-code # Claude Code
197
+ npm install -g @openai/codex # Codex
198
+ npm install -g @google/gemini-cli # Gemini CLI
199
+ ```
200
+
201
+ **Q: API 连接测试失败?**
202
+
203
+ - 检查 Base URL 是否正确(末尾不要加 `/`)
204
+ - 检查 API Key 是否有效
205
+ - 国内网络可在启动时选择设置代理
206
+
207
+ **Q: 如何手动验证 Claude Code 配置?**
208
+
209
+ ```bash
210
+ cat ~/.claude/settings.json
211
+ ```
212
+
213
+ ---
214
+
215
+ ## 项目结构
216
+
217
+ ```
218
+ ai-cli-switch/
219
+ ├── bin/
220
+ │ └── index.js # CLI 入口
221
+ ├── src/
222
+ │ ├── index.js # 主流程(交互逻辑)
223
+ │ ├── utils.js # 工具函数
224
+ │ └── config/
225
+ │ ├── claude.js # Claude Code 配置模块
226
+ │ ├── codex.js # Codex 配置模块
227
+ │ ├── gemini.js # Gemini CLI 配置模块
228
+ │ ├── opencode.js # OpenCode 配置模块
229
+ │ └── openclaw.js # OpenClaw 配置模块
230
+ └── package.json
231
+ ```
232
+
233
+ ---
234
+
235
+ ## Fork 为自己的专属版
236
+
237
+ 如果你运营自己的 API 中转服务,可以 fork 本仓库:
238
+
239
+ 1. 修改 `src/index.js` 中 `DEFAULT_BASE_URLS` 为你的服务地址
240
+ 2. 修改 `package.json` 中的 `name` 和 `bin` 字段
241
+ 3. 更新 banner 和完成页面中的品牌信息
242
+ 4. 发布到 npm,用户即可一键使用你的专属配置工具
243
+
244
+ 示例:[78code-ai](https://github.com/zxyyang/78code) — 基于本工具的 78code.cc 专属版
245
+
246
+ ---
247
+
248
+ ## License
249
+
250
+ MIT
package/bin/index.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from '../src/index.js';
4
+
5
+ main().catch((err) => {
6
+ console.error(err);
7
+ process.exit(1);
8
+ });
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "ai-cli-switch",
3
+ "version": "1.0.0",
4
+ "description": "One-command configuration tool for AI CLI tools — Claude Code, Codex, Gemini CLI, OpenCode & OpenClaw. Bring your own Base URL.",
5
+ "type": "module",
6
+ "bin": {
7
+ "ai-cli-switch": "bin/index.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "keywords": [
14
+ "ai",
15
+ "cli",
16
+ "claude-code",
17
+ "codex",
18
+ "gemini",
19
+ "opencode",
20
+ "openclaw",
21
+ "api-key",
22
+ "configuration",
23
+ "base-url",
24
+ "proxy"
25
+ ],
26
+ "author": "",
27
+ "license": "MIT",
28
+ "dependencies": {
29
+ "@inquirer/prompts": "^7.0.0",
30
+ "chalk": "^5.3.0",
31
+ "ora": "^8.0.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }
@@ -0,0 +1,89 @@
1
+ import path from 'path';
2
+ import fs from 'fs';
3
+ import { HOME, readJsonFile, writeJsonFile, backupFile, deepMerge, commandExists } from '../utils.js';
4
+
5
+ const CONFIG_DIR = path.join(HOME, '.claude');
6
+ const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json');
7
+ const LEGACY_PATH = path.join(CONFIG_DIR, 'claude.json');
8
+
9
+ function getSettingsPath() {
10
+ if (fs.existsSync(SETTINGS_PATH)) return SETTINGS_PATH;
11
+ if (fs.existsSync(LEGACY_PATH)) return LEGACY_PATH;
12
+ return SETTINGS_PATH; // 默认新建
13
+ }
14
+
15
+ export const claude = {
16
+ id: 'claude',
17
+ name: 'Claude Code',
18
+ command: 'claude',
19
+
20
+ isInstalled() {
21
+ return commandExists('claude');
22
+ },
23
+
24
+ getConfigPath() {
25
+ return getSettingsPath();
26
+ },
27
+
28
+ readConfig() {
29
+ return readJsonFile(getSettingsPath());
30
+ },
31
+
32
+ async configure({ apiKey, baseUrl, model }) {
33
+ const configPath = getSettingsPath();
34
+ const existing = readJsonFile(configPath) || {};
35
+
36
+ // 备份
37
+ const backupPath = backupFile(configPath);
38
+
39
+ // 构建要写入的 env 字段
40
+ const envUpdate = {};
41
+ if (apiKey) envUpdate.ANTHROPIC_API_KEY = apiKey;
42
+ if (baseUrl) envUpdate.ANTHROPIC_BASE_URL = baseUrl;
43
+ if (model) {
44
+ envUpdate.ANTHROPIC_MODEL = model;
45
+ envUpdate.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
46
+ }
47
+
48
+ // 深度合并:保留原有配置,只更新密钥相关字段
49
+ const merged = deepMerge(existing, { env: envUpdate });
50
+ writeJsonFile(configPath, merged);
51
+
52
+ return { configPath, backupPath };
53
+ },
54
+
55
+ async testApiKey(apiKey, baseUrl) {
56
+ const url = baseUrl || 'https://api.anthropic.com';
57
+ try {
58
+ const res = await fetch(`${url}/v1/messages`, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'x-api-key': apiKey,
63
+ 'anthropic-version': '2023-06-01',
64
+ },
65
+ body: JSON.stringify({
66
+ model: 'claude-haiku-4-5-20251001',
67
+ max_tokens: 1,
68
+ messages: [{ role: 'user', content: 'hi' }],
69
+ }),
70
+ signal: AbortSignal.timeout(15000),
71
+ });
72
+ // 200/400/403 等都说明连接正常, 仅 401 说明密钥无效
73
+ if (res.status === 401) {
74
+ return { ok: false, error: `认证失败 (HTTP 401)` };
75
+ }
76
+ return { ok: true };
77
+ } catch (err) {
78
+ return { ok: false, error: err.message };
79
+ }
80
+ },
81
+
82
+ getFields() {
83
+ return [
84
+ { key: 'apiKey', label: 'API Key', type: 'password', required: true, placeholder: 'sk-ant-...' },
85
+ { key: 'baseUrl', label: 'Base URL', type: 'text', required: false, default: 'https://api.anthropic.com' },
86
+ { key: 'model', label: '默认模型 (可选)', type: 'text', required: false, placeholder: 'claude-sonnet-4-6' },
87
+ ];
88
+ },
89
+ };
@@ -0,0 +1,141 @@
1
+ import path from 'path';
2
+ import { HOME, readJsonFile, writeJsonFile, readTextFile, writeTextFile, backupFile, commandExists } from '../utils.js';
3
+
4
+ const CONFIG_DIR = path.join(HOME, '.codex');
5
+ const AUTH_PATH = path.join(CONFIG_DIR, 'auth.json');
6
+ const CONFIG_TOML_PATH = path.join(CONFIG_DIR, 'config.toml');
7
+
8
+ /**
9
+ * 从 Base URL 提取可用作 provider ID 的短名称
10
+ * 例:https://api.openai.com/v1 → openai-com
11
+ * http://127.0.0.1:8080 → localhost
12
+ */
13
+ function providerIdFromUrl(url) {
14
+ try {
15
+ const { hostname } = new URL(url);
16
+ return hostname.replace(/\./g, '-').replace(/^www-/, '');
17
+ } catch {
18
+ return 'custom';
19
+ }
20
+ }
21
+
22
+ /**
23
+ * 在 TOML 内容中设置或替换顶层 key = "value"
24
+ */
25
+ function setTomlTopLevelKey(content, key, value) {
26
+ const lines = content.split('\n');
27
+ const regex = new RegExp(`^${key}\\s*=`);
28
+ // 布尔值和数字不加引号
29
+ const formatted = (value === 'true' || value === 'false' || !isNaN(value)) ? value : `"${value}"`;
30
+ const newLine = `${key} = ${formatted}`;
31
+
32
+ for (let i = 0; i < lines.length; i++) {
33
+ if (regex.test(lines[i])) {
34
+ lines[i] = newLine;
35
+ return lines.join('\n');
36
+ }
37
+ }
38
+
39
+ // 没找到,在第一个 [section] 之前插入
40
+ let insertIdx = 0;
41
+ for (let i = 0; i < lines.length; i++) {
42
+ if (lines[i].startsWith('[')) {
43
+ insertIdx = i;
44
+ break;
45
+ }
46
+ insertIdx = i + 1;
47
+ }
48
+ lines.splice(insertIdx, 0, newLine);
49
+ return lines.join('\n');
50
+ }
51
+
52
+ /**
53
+ * 删除 TOML 中的某个 [section] 块(包含其下所有键值对)
54
+ */
55
+ function removeTomlSection(content, sectionName) {
56
+ const lines = content.split('\n');
57
+ const result = [];
58
+ let inTargetSection = false;
59
+
60
+ for (const line of lines) {
61
+ // 检测 section 开始
62
+ if (line.match(/^\[.+\]/)) {
63
+ inTargetSection = line.trim() === `[${sectionName}]`;
64
+ if (inTargetSection) continue; // 跳过目标 section header
65
+ }
66
+ if (!inTargetSection) {
67
+ result.push(line);
68
+ }
69
+ }
70
+ return result.join('\n');
71
+ }
72
+
73
+ export const codex = {
74
+ id: 'codex',
75
+ name: 'Codex (OpenAI)',
76
+ command: 'codex',
77
+
78
+ isInstalled() {
79
+ return commandExists('codex');
80
+ },
81
+
82
+ getConfigPath() {
83
+ return AUTH_PATH;
84
+ },
85
+
86
+ readConfig() {
87
+ return readJsonFile(AUTH_PATH);
88
+ },
89
+
90
+ async configure({ apiKey, baseUrl }) {
91
+ // 1. 写入 auth.json(只写 OPENAI_API_KEY)
92
+ const backupPath = backupFile(AUTH_PATH);
93
+ writeJsonFile(AUTH_PATH, {
94
+ OPENAI_API_KEY: apiKey,
95
+ });
96
+
97
+ // 2. 更新 config.toml(保留用户的 mcp_servers、projects 等)
98
+ if (baseUrl) {
99
+ const providerId = providerIdFromUrl(baseUrl);
100
+ backupFile(CONFIG_TOML_PATH);
101
+ let toml = readTextFile(CONFIG_TOML_PATH) || '';
102
+
103
+ // 设置顶层 key
104
+ toml = setTomlTopLevelKey(toml, 'model_provider', providerId);
105
+ toml = setTomlTopLevelKey(toml, 'disable_response_storage', 'true');
106
+
107
+ // 移除旧的同名 section(如果存在)
108
+ toml = removeTomlSection(toml, `model_providers.${providerId}`);
109
+
110
+ // 追加新的 provider section
111
+ const providerSection = `
112
+ [model_providers.${providerId}]
113
+ name = "${providerId}"
114
+ base_url = "${baseUrl}"
115
+ wire_api = "responses"
116
+ requires_openai_auth = true
117
+ `;
118
+ toml = toml.trimEnd() + '\n' + providerSection;
119
+
120
+ writeTextFile(CONFIG_TOML_PATH, toml);
121
+ }
122
+
123
+ return { configPath: AUTH_PATH, backupPath };
124
+ },
125
+
126
+ async testApiKey(apiKey, baseUrl) {
127
+ const url = baseUrl || 'https://api.openai.com';
128
+ try {
129
+ const res = await fetch(`${url}/models`, {
130
+ headers: { Authorization: `Bearer ${apiKey}` },
131
+ signal: AbortSignal.timeout(15000),
132
+ });
133
+ if (res.status === 401) {
134
+ return { ok: false, error: `认证失败 (HTTP 401)` };
135
+ }
136
+ return { ok: true };
137
+ } catch (err) {
138
+ return { ok: false, error: err.message };
139
+ }
140
+ },
141
+ };
@@ -0,0 +1,87 @@
1
+ import path from 'path';
2
+ import { HOME, readJsonFile, writeJsonFile, readTextFile, writeTextFile, backupFile, commandExists } from '../utils.js';
3
+
4
+ const CONFIG_DIR = path.join(HOME, '.gemini');
5
+ const ENV_PATH = path.join(CONFIG_DIR, '.env');
6
+ const SETTINGS_PATH = path.join(CONFIG_DIR, 'settings.json');
7
+
8
+ function parseEnv(content) {
9
+ const result = {};
10
+ for (const line of content.split('\n')) {
11
+ const trimmed = line.trim();
12
+ if (!trimmed || trimmed.startsWith('#')) continue;
13
+ const eqIdx = trimmed.indexOf('=');
14
+ if (eqIdx === -1) continue;
15
+ const key = trimmed.slice(0, eqIdx).trim();
16
+ let val = trimmed.slice(eqIdx + 1).trim();
17
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
18
+ val = val.slice(1, -1);
19
+ }
20
+ result[key] = val;
21
+ }
22
+ return result;
23
+ }
24
+
25
+ function serializeEnv(obj) {
26
+ return Object.entries(obj)
27
+ .map(([k, v]) => `${k}=${v}`)
28
+ .join('\n') + '\n';
29
+ }
30
+
31
+ export const gemini = {
32
+ id: 'gemini',
33
+ name: 'Gemini CLI',
34
+ command: 'gemini',
35
+
36
+ isInstalled() {
37
+ return commandExists('gemini');
38
+ },
39
+
40
+ getConfigPath() {
41
+ return ENV_PATH;
42
+ },
43
+
44
+ readConfig() {
45
+ const content = readTextFile(ENV_PATH);
46
+ return content ? parseEnv(content) : null;
47
+ },
48
+
49
+ async configure({ apiKey, baseUrl }) {
50
+ // 1. 写入 .env(API Key + Base URL)
51
+ const existingEnv = readTextFile(ENV_PATH);
52
+ const backupPath = backupFile(ENV_PATH);
53
+
54
+ const env = existingEnv ? parseEnv(existingEnv) : {};
55
+ if (apiKey) env.GEMINI_API_KEY = apiKey;
56
+ if (baseUrl) env.GOOGLE_GEMINI_BASE_URL = baseUrl;
57
+
58
+ writeTextFile(ENV_PATH, serializeEnv(env));
59
+
60
+ // 2. 写入 settings.json(切换认证模式为 api-key)
61
+ const existingSettings = readJsonFile(SETTINGS_PATH) || {};
62
+ backupFile(SETTINGS_PATH);
63
+
64
+ if (!existingSettings.security) existingSettings.security = {};
65
+ if (!existingSettings.security.auth) existingSettings.security.auth = {};
66
+ existingSettings.security.auth.selectedType = 'gemini-api-key';
67
+
68
+ writeJsonFile(SETTINGS_PATH, existingSettings);
69
+
70
+ return { configPath: ENV_PATH, backupPath };
71
+ },
72
+
73
+ async testApiKey(apiKey, baseUrl) {
74
+ const url = baseUrl
75
+ ? `${baseUrl}/v1beta/models?key=${apiKey}`
76
+ : `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`;
77
+ try {
78
+ const res = await fetch(url, { signal: AbortSignal.timeout(15000) });
79
+ if (res.status === 401) {
80
+ return { ok: false, error: `认证失败 (HTTP 401)` };
81
+ }
82
+ return { ok: true };
83
+ } catch (err) {
84
+ return { ok: false, error: err.message };
85
+ }
86
+ },
87
+ };
@@ -0,0 +1,9 @@
1
+ import { claude } from './claude.js';
2
+ import { codex } from './codex.js';
3
+ import { gemini } from './gemini.js';
4
+ import { opencode } from './opencode.js';
5
+ import { openclaw } from './openclaw.js';
6
+
7
+ export const tools = { claude, codex, gemini, opencode, openclaw };
8
+
9
+ export const toolList = [claude, codex, gemini, opencode, openclaw];
@@ -0,0 +1,167 @@
1
+ import path from 'path';
2
+ import { HOME, readJsonFile, writeJsonFile, backupFile, deepMerge, commandExists } from '../utils.js';
3
+
4
+ const CONFIG_DIR = path.join(HOME, '.openclaw');
5
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'openclaw.json');
6
+
7
+ // 模型提供商预设(provider ID 在运行时根据 baseUrl 动态生成)
8
+ const PROVIDER_PRESETS = {
9
+ claude: {
10
+ providerSuffix: 'claude',
11
+ api: 'anthropic-messages',
12
+ model: { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6' },
13
+ contextWindow: 200000,
14
+ maxTokens: 65536,
15
+ },
16
+ openai: {
17
+ providerSuffix: 'openai',
18
+ api: 'openai-completions',
19
+ model: { id: 'gpt-4o', name: 'GPT-4o' },
20
+ contextWindow: 128000,
21
+ maxTokens: 16384,
22
+ },
23
+ gemini: {
24
+ providerSuffix: 'gemini',
25
+ api: 'openai-completions',
26
+ model: { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro' },
27
+ contextWindow: 1000000,
28
+ maxTokens: 65536,
29
+ },
30
+ };
31
+
32
+ /**
33
+ * 从 Base URL 提取 provider ID 前缀
34
+ */
35
+ function hostFromUrl(url) {
36
+ try {
37
+ const { hostname } = new URL(url);
38
+ return hostname.replace(/\./g, '-').replace(/^www-/, '');
39
+ } catch {
40
+ return 'custom';
41
+ }
42
+ }
43
+
44
+ export const openclaw = {
45
+ id: 'openclaw',
46
+ name: 'OpenClaw',
47
+ command: 'openclaw',
48
+ needsModelChoice: true,
49
+
50
+ isInstalled() {
51
+ return commandExists('openclaw');
52
+ },
53
+
54
+ getConfigPath() {
55
+ return CONFIG_PATH;
56
+ },
57
+
58
+ readConfig() {
59
+ return readJsonFile(CONFIG_PATH);
60
+ },
61
+
62
+ getModelChoices() {
63
+ return [
64
+ { label: 'Claude (Anthropic)', value: 'claude' },
65
+ { label: 'OpenAI (GPT)', value: 'openai' },
66
+ { label: 'Gemini (Google)', value: 'gemini' },
67
+ ];
68
+ },
69
+
70
+ async configure({ apiKey, baseUrl, modelChoice }) {
71
+ const existing = readJsonFile(CONFIG_PATH) || {};
72
+ const backupPath = backupFile(CONFIG_PATH);
73
+
74
+ const preset = PROVIDER_PRESETS[modelChoice];
75
+ const host = hostFromUrl(baseUrl);
76
+ const providerId = `${host}-${preset.providerSuffix}`;
77
+ const modelRef = `${providerId}/${preset.model.id}`;
78
+
79
+ const providerConfig = {
80
+ [providerId]: {
81
+ baseUrl: baseUrl,
82
+ apiKey: apiKey,
83
+ api: preset.api,
84
+ models: [
85
+ {
86
+ id: preset.model.id,
87
+ name: preset.model.name,
88
+ reasoning: false,
89
+ input: ['text'],
90
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
91
+ contextWindow: preset.contextWindow,
92
+ maxTokens: preset.maxTokens,
93
+ },
94
+ ],
95
+ },
96
+ };
97
+
98
+ const update = {
99
+ models: {
100
+ mode: 'merge',
101
+ providers: providerConfig,
102
+ },
103
+ agents: {
104
+ defaults: {
105
+ model: {
106
+ primary: modelRef,
107
+ },
108
+ models: {
109
+ [modelRef]: {},
110
+ },
111
+ },
112
+ },
113
+ };
114
+
115
+ const merged = deepMerge(existing, update);
116
+ writeJsonFile(CONFIG_PATH, merged);
117
+
118
+ return { configPath: CONFIG_PATH, backupPath };
119
+ },
120
+
121
+ async testApiKey(apiKey, baseUrl, modelChoice) {
122
+ if (modelChoice === 'claude') {
123
+ return testAnthropic(apiKey, baseUrl);
124
+ }
125
+ return testOpenAICompat(apiKey, baseUrl);
126
+ },
127
+ };
128
+
129
+ async function testAnthropic(apiKey, baseUrl) {
130
+ try {
131
+ const res = await fetch(`${baseUrl}/v1/messages`, {
132
+ method: 'POST',
133
+ headers: {
134
+ 'Content-Type': 'application/json',
135
+ 'x-api-key': apiKey,
136
+ 'anthropic-version': '2023-06-01',
137
+ },
138
+ body: JSON.stringify({
139
+ model: 'claude-haiku-4-5-20251001',
140
+ max_tokens: 1,
141
+ messages: [{ role: 'user', content: 'hi' }],
142
+ }),
143
+ signal: AbortSignal.timeout(15000),
144
+ });
145
+ if (res.status === 401) {
146
+ return { ok: false, error: `认证失败 (HTTP 401)` };
147
+ }
148
+ return { ok: true };
149
+ } catch (err) {
150
+ return { ok: false, error: err.message };
151
+ }
152
+ }
153
+
154
+ async function testOpenAICompat(apiKey, baseUrl) {
155
+ try {
156
+ const res = await fetch(`${baseUrl}/models`, {
157
+ headers: { Authorization: `Bearer ${apiKey}` },
158
+ signal: AbortSignal.timeout(15000),
159
+ });
160
+ if (res.status === 401) {
161
+ return { ok: false, error: `认证失败 (HTTP 401)` };
162
+ }
163
+ return { ok: true };
164
+ } catch (err) {
165
+ return { ok: false, error: err.message };
166
+ }
167
+ }
@@ -0,0 +1,163 @@
1
+ import path from 'path';
2
+ import { HOME, readJsonFile, writeJsonFile, backupFile, deepMerge, commandExists } from '../utils.js';
3
+
4
+ const CONFIG_DIR = path.join(HOME, '.config', 'opencode');
5
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'opencode.json');
6
+
7
+ // 模型提供商预设(provider ID 在运行时根据 baseUrl 动态生成)
8
+ const PROVIDER_PRESETS = {
9
+ claude: {
10
+ providerSuffix: 'claude',
11
+ npm: '@ai-sdk/anthropic',
12
+ namePrefix: 'Custom Claude',
13
+ model: 'claude-sonnet-4-6',
14
+ modelName: 'Claude Sonnet 4.6',
15
+ contextWindow: 200000,
16
+ maxTokens: 65536,
17
+ },
18
+ openai: {
19
+ providerSuffix: 'openai',
20
+ npm: '@ai-sdk/openai-compatible',
21
+ namePrefix: 'Custom OpenAI',
22
+ model: 'gpt-4o',
23
+ modelName: 'GPT-4o',
24
+ contextWindow: 128000,
25
+ maxTokens: 16384,
26
+ },
27
+ gemini: {
28
+ providerSuffix: 'gemini',
29
+ npm: '@ai-sdk/openai-compatible',
30
+ namePrefix: 'Custom Gemini',
31
+ model: 'gemini-2.5-pro',
32
+ modelName: 'Gemini 2.5 Pro',
33
+ contextWindow: 1000000,
34
+ maxTokens: 65536,
35
+ },
36
+ };
37
+
38
+ /**
39
+ * 从 Base URL 提取 provider ID 前缀
40
+ */
41
+ function hostFromUrl(url) {
42
+ try {
43
+ const { hostname } = new URL(url);
44
+ return hostname.replace(/\./g, '-').replace(/^www-/, '');
45
+ } catch {
46
+ return 'custom';
47
+ }
48
+ }
49
+
50
+ export const opencode = {
51
+ id: 'opencode',
52
+ name: 'OpenCode',
53
+ command: 'opencode',
54
+ needsModelChoice: true,
55
+
56
+ isInstalled() {
57
+ return commandExists('opencode');
58
+ },
59
+
60
+ getConfigPath() {
61
+ return CONFIG_PATH;
62
+ },
63
+
64
+ readConfig() {
65
+ return readJsonFile(CONFIG_PATH);
66
+ },
67
+
68
+ getModelChoices() {
69
+ return [
70
+ { label: 'Claude (Anthropic)', value: 'claude' },
71
+ { label: 'OpenAI (GPT)', value: 'openai' },
72
+ { label: 'Gemini (Google)', value: 'gemini' },
73
+ ];
74
+ },
75
+
76
+ async configure({ apiKey, baseUrl, modelChoice }) {
77
+ const existing = readJsonFile(CONFIG_PATH) || {};
78
+ const backupPath = backupFile(CONFIG_PATH);
79
+
80
+ const preset = PROVIDER_PRESETS[modelChoice];
81
+ const host = hostFromUrl(baseUrl);
82
+ const providerId = `${host}-${preset.providerSuffix}`;
83
+ const providerName = `${preset.namePrefix} (${host})`;
84
+
85
+ const providerConfig = {
86
+ [providerId]: {
87
+ npm: preset.npm,
88
+ name: providerName,
89
+ options: {
90
+ baseURL: baseUrl,
91
+ apiKey: apiKey,
92
+ },
93
+ models: {
94
+ [preset.model]: {
95
+ name: preset.modelName,
96
+ limit: {
97
+ context: preset.contextWindow,
98
+ output: preset.maxTokens,
99
+ },
100
+ },
101
+ },
102
+ },
103
+ };
104
+
105
+ const update = {
106
+ provider: providerConfig,
107
+ model: `${providerId}/${preset.model}`,
108
+ };
109
+
110
+ const merged = deepMerge(existing, update);
111
+ writeJsonFile(CONFIG_PATH, merged);
112
+
113
+ return { configPath: CONFIG_PATH, backupPath };
114
+ },
115
+
116
+ async testApiKey(apiKey, baseUrl, modelChoice) {
117
+ // 根据选择的模型类型用不同的测试方式
118
+ if (modelChoice === 'claude') {
119
+ return testAnthropic(apiKey, baseUrl);
120
+ }
121
+ return testOpenAICompat(apiKey, baseUrl);
122
+ },
123
+ };
124
+
125
+ async function testAnthropic(apiKey, baseUrl) {
126
+ try {
127
+ const res = await fetch(`${baseUrl}/v1/messages`, {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Content-Type': 'application/json',
131
+ 'x-api-key': apiKey,
132
+ 'anthropic-version': '2023-06-01',
133
+ },
134
+ body: JSON.stringify({
135
+ model: 'claude-haiku-4-5-20251001',
136
+ max_tokens: 1,
137
+ messages: [{ role: 'user', content: 'hi' }],
138
+ }),
139
+ signal: AbortSignal.timeout(15000),
140
+ });
141
+ if (res.status === 401) {
142
+ return { ok: false, error: `认证失败 (HTTP 401)` };
143
+ }
144
+ return { ok: true };
145
+ } catch (err) {
146
+ return { ok: false, error: err.message };
147
+ }
148
+ }
149
+
150
+ async function testOpenAICompat(apiKey, baseUrl) {
151
+ try {
152
+ const res = await fetch(`${baseUrl}/models`, {
153
+ headers: { Authorization: `Bearer ${apiKey}` },
154
+ signal: AbortSignal.timeout(15000),
155
+ });
156
+ if (res.status === 401) {
157
+ return { ok: false, error: `认证失败 (HTTP 401)` };
158
+ }
159
+ return { ok: true };
160
+ } catch (err) {
161
+ return { ok: false, error: err.message };
162
+ }
163
+ }
package/src/index.js ADDED
@@ -0,0 +1,297 @@
1
+ import { select, password, input } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import os from 'os';
5
+ import { toolList } from './config/index.js';
6
+ import { IS_WIN, getPlatformName } from './utils.js';
7
+
8
+ // ==================== 默认官方 Base URL ====================
9
+ const DEFAULT_BASE_URLS = {
10
+ claude: 'https://api.anthropic.com',
11
+ openai: 'https://api.openai.com/v1',
12
+ gemini: 'https://generativelanguage.googleapis.com',
13
+ };
14
+
15
+ const DEFAULT_TOOL_BASE_URLS = {
16
+ claude: DEFAULT_BASE_URLS.claude,
17
+ codex: DEFAULT_BASE_URLS.openai,
18
+ gemini: DEFAULT_BASE_URLS.gemini,
19
+ };
20
+
21
+ // ==================== 是/否选择(上下箭头) ====================
22
+ async function confirmSelect(message, defaultYes = true) {
23
+ const choices = defaultYes
24
+ ? [
25
+ { name: `${chalk.green('>')} 是`, value: true },
26
+ { name: ` 否`, value: false },
27
+ ]
28
+ : [
29
+ { name: `${chalk.red('>')} 否`, value: false },
30
+ { name: ` 是`, value: true },
31
+ ];
32
+ return await select({ message, choices });
33
+ }
34
+
35
+ // ==================== Banner ====================
36
+ function showBanner() {
37
+ const line = chalk.gray(' ─────────────────────────────────────────');
38
+ console.log('');
39
+ console.log(line);
40
+ console.log(chalk.cyan.bold(' ___ ____ ________ ____ '));
41
+ console.log(chalk.cyan.bold(' / | / _/ / ____/ / / __/___ _ '));
42
+ console.log(chalk.cyan.bold(' / /| | / / / / / / / /_/ __ `/ '));
43
+ console.log(chalk.cyan.bold(' / ___ |_/ / / /___/ /___/ __/ /_/ / '));
44
+ console.log(chalk.cyan.bold('/_/ |_/___/ \\____/_____/_/ \\__, / '));
45
+ console.log(chalk.cyan.bold(' /____/ '));
46
+ console.log('');
47
+ console.log(chalk.bold.white(' AI CLI Config') + chalk.gray(' v1.0.0'));
48
+ console.log(chalk.gray(' 快速配置 Claude Code / Codex / Gemini / OpenCode / OpenClaw'));
49
+ console.log(chalk.gray(' 支持自定义 Base URL,兼容任意 API 中转服务'));
50
+ console.log(line);
51
+ console.log('');
52
+ }
53
+
54
+ // ==================== 环境检测 ====================
55
+ async function detectEnvironment() {
56
+ const spinner = ora({ text: '正在检测环境...', spinner: 'dots12' }).start();
57
+
58
+ const platform = getPlatformName();
59
+ const arch = os.arch();
60
+ const shell = IS_WIN
61
+ ? (process.env.ComSpec || 'cmd.exe')
62
+ : (process.env.SHELL || '/bin/sh');
63
+
64
+ const installed = [];
65
+ for (const tool of toolList) {
66
+ if (tool.isInstalled()) installed.push(tool);
67
+ }
68
+
69
+ spinner.succeed(chalk.bold('环境检测完成'));
70
+ console.log('');
71
+
72
+ // 系统信息表格
73
+ const info = [
74
+ ['平台', `${platform} (${arch})`],
75
+ ['Node.js', process.version],
76
+ ['Shell', shell],
77
+ ['用户目录', os.homedir()],
78
+ ];
79
+ for (const [label, value] of info) {
80
+ console.log(` ${chalk.gray(label.padEnd(10))} ${chalk.white(value)}`);
81
+ }
82
+ console.log('');
83
+
84
+ // 已安装工具
85
+ if (installed.length === 0) {
86
+ console.log(chalk.red.bold(' 未检测到任何已安装的 AI CLI 工具'));
87
+ } else {
88
+ console.log(chalk.bold(` 检测到 ${installed.length} 个已安装工具:`));
89
+ console.log('');
90
+ for (const tool of installed) {
91
+ console.log(` ${chalk.green('●')} ${chalk.white(tool.name)}`);
92
+ }
93
+ }
94
+ console.log('');
95
+
96
+ return installed;
97
+ }
98
+
99
+ // ==================== 选择工具 ====================
100
+ async function selectTool(installed) {
101
+ return await select({
102
+ message: '选择要配置的工具',
103
+ choices: installed.map((tool) => ({
104
+ name: ` ${tool.name}`,
105
+ value: tool,
106
+ })),
107
+ });
108
+ }
109
+
110
+ // ==================== 去除末尾斜杠 ====================
111
+ function trimTrailingSlash(url) {
112
+ return url ? url.replace(/\/+$/, '') : url;
113
+ }
114
+
115
+ // ==================== 获取默认 Base URL ====================
116
+ function getDefaultBaseUrl(tool, modelChoice) {
117
+ if (tool.needsModelChoice && modelChoice) {
118
+ return DEFAULT_BASE_URLS[modelChoice] || DEFAULT_BASE_URLS.openai;
119
+ }
120
+ return DEFAULT_TOOL_BASE_URLS[tool.id] || '';
121
+ }
122
+
123
+ // ==================== 配置单个工具 ====================
124
+ async function configureOneTool(installed) {
125
+ const tool = await selectTool(installed);
126
+
127
+ // 如果需要选模型提供商(OpenCode / OpenClaw)
128
+ let modelChoice = null;
129
+ if (tool.needsModelChoice) {
130
+ console.log('');
131
+ modelChoice = await select({
132
+ message: `${tool.name} 要使用哪个模型`,
133
+ choices: tool.getModelChoices().map((c) => ({
134
+ name: ` ${c.label}`,
135
+ value: c.value,
136
+ })),
137
+ });
138
+ }
139
+
140
+ // 输入 Base URL(可修改默认值)
141
+ console.log('');
142
+ const defaultUrl = getDefaultBaseUrl(tool, modelChoice);
143
+ const rawBaseUrl = await input({
144
+ message: 'Base URL(留空使用官方地址,或填入你的中转地址)',
145
+ default: defaultUrl,
146
+ validate: (v) => {
147
+ if (!v) return true; // 允许空(后续使用默认)
148
+ if (!/^https?:\/\/.+/.test(v)) return '请输入有效的 URL,如 https://api.anthropic.com';
149
+ return true;
150
+ },
151
+ });
152
+ const baseUrl = trimTrailingSlash(rawBaseUrl || defaultUrl);
153
+
154
+ console.log('');
155
+ console.log(chalk.bold(` ┌ 配置 ${tool.name}`));
156
+ if (baseUrl) {
157
+ console.log(chalk.gray(` │ Base URL: ${baseUrl}`));
158
+ }
159
+ console.log(chalk.gray(' │'));
160
+
161
+ // 输入 API Key
162
+ const apiKey = await password({
163
+ message: 'API Key',
164
+ mask: '*',
165
+ validate: (v) => {
166
+ if (!v) return 'API Key 不能为空';
167
+ return true;
168
+ },
169
+ });
170
+
171
+ if (!apiKey) {
172
+ console.log(chalk.yellow(' └ 未输入 API Key,已取消'));
173
+ return false;
174
+ }
175
+
176
+ // 连接测试
177
+ console.log('');
178
+ const spinner = ora({ text: '正在测试 API 连接...', spinner: 'dots12' }).start();
179
+ const testResult = tool.testApiKey
180
+ ? await tool.testApiKey(apiKey, baseUrl, modelChoice)
181
+ : { ok: true };
182
+
183
+ if (testResult.ok) {
184
+ spinner.succeed(chalk.green('API 连接测试通过'));
185
+ } else {
186
+ spinner.fail(chalk.red(`API 连接测试失败: ${testResult.error}`));
187
+ console.log('');
188
+ const proceed = await confirmSelect('连接测试失败,是否仍然写入配置?', false);
189
+ if (!proceed) {
190
+ console.log(chalk.yellow('\n └ 已取消配置\n'));
191
+ return false;
192
+ }
193
+ }
194
+
195
+ // 写入配置
196
+ const writeSpinner = ora({ text: '正在写入配置...', spinner: 'dots12' }).start();
197
+ try {
198
+ const result = await tool.configure({ apiKey, baseUrl, modelChoice });
199
+ writeSpinner.succeed(chalk.green('配置写入成功'));
200
+ console.log(` ${chalk.gray('配置文件:')} ${chalk.cyan(result.configPath)}`);
201
+ if (result.backupPath) {
202
+ console.log(` ${chalk.gray('备份文件:')} ${chalk.gray(result.backupPath)}`);
203
+ }
204
+ } catch (err) {
205
+ writeSpinner.fail(chalk.red(`配置写入失败: ${err.message}`));
206
+ return false;
207
+ }
208
+
209
+ // 自检
210
+ const checkSpinner = ora({ text: '正在进行自检...', spinner: 'dots12' }).start();
211
+ try {
212
+ const config = tool.readConfig();
213
+ if (config) {
214
+ checkSpinner.succeed(chalk.green('自检通过,一切正常'));
215
+ } else {
216
+ checkSpinner.warn(chalk.yellow('配置文件写入后读取为空,可能存在权限问题'));
217
+ }
218
+ } catch {
219
+ checkSpinner.warn(chalk.yellow('配置文件读取失败'));
220
+ }
221
+
222
+ console.log(chalk.gray(' └ 完成'));
223
+ return true;
224
+ }
225
+
226
+ // ==================== 完成展示 ====================
227
+ function showCompletion() {
228
+ const line = chalk.gray(' ─────────────────────────────────────────');
229
+ console.log('');
230
+ console.log(line);
231
+ console.log('');
232
+ console.log(chalk.green.bold(' ✅ 全部配置完成!'));
233
+ console.log('');
234
+ console.log(chalk.gray(' 你的 AI CLI 工具已配置完毕,尽情享用吧!'));
235
+ console.log('');
236
+ console.log(line);
237
+ console.log('');
238
+ }
239
+
240
+ // ==================== 代理设置 ====================
241
+ async function setupProxy() {
242
+ const useProxy = await confirmSelect('是否需要设置网络代理?', false);
243
+ if (!useProxy) return;
244
+
245
+ console.log('');
246
+ const proxyUrl = await input({
247
+ message: '代理地址',
248
+ validate: (v) => {
249
+ if (!v) return '代理地址不能为空';
250
+ if (!/^https?:\/\/.+/.test(v)) return '请输入有效的代理地址,如 http://127.0.0.1:7890';
251
+ return true;
252
+ },
253
+ });
254
+
255
+ try {
256
+ const { ProxyAgent, setGlobalDispatcher } = await import('undici');
257
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
258
+ console.log(chalk.green(` 代理已设置: ${proxyUrl}`));
259
+ } catch {
260
+ // undici 不可用时回退到环境变量
261
+ process.env.HTTP_PROXY = proxyUrl;
262
+ process.env.HTTPS_PROXY = proxyUrl;
263
+ console.log(chalk.green(` 代理已设置 (env): ${proxyUrl}`));
264
+ }
265
+ console.log('');
266
+ }
267
+
268
+ // ==================== 主流程 ====================
269
+ export async function main() {
270
+ showBanner();
271
+
272
+ // 0. 代理设置
273
+ await setupProxy();
274
+
275
+ // 1. 环境检测
276
+ const installed = await detectEnvironment();
277
+
278
+ if (installed.length === 0) {
279
+ console.log(chalk.red(' 请先安装至少一个 AI CLI 工具后再运行本配置工具。'));
280
+ console.log('');
281
+ return;
282
+ }
283
+
284
+ // 2. 配置工具(循环,直到用户不想继续)
285
+ await configureOneTool(installed);
286
+
287
+ while (installed.length > 1) {
288
+ console.log('');
289
+ const more = await confirmSelect('是否继续配置其他工具?', false);
290
+ if (!more) break;
291
+ console.log('');
292
+ await configureOneTool(installed);
293
+ }
294
+
295
+ // 3. 完成
296
+ showCompletion();
297
+ }
package/src/utils.js ADDED
@@ -0,0 +1,141 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execSync } from 'child_process';
5
+
6
+ export const HOME = os.homedir();
7
+ export const IS_WIN = process.platform === 'win32';
8
+ export const IS_MAC = process.platform === 'darwin';
9
+ export const IS_LINUX = process.platform === 'linux';
10
+
11
+ /**
12
+ * 获取友好的平台名称
13
+ */
14
+ export function getPlatformName() {
15
+ if (IS_MAC) return 'macOS';
16
+ if (IS_WIN) return 'Windows';
17
+ if (IS_LINUX) return 'Linux';
18
+ return process.platform;
19
+ }
20
+
21
+ /**
22
+ * 安全读取 JSON 文件,不存在则返回 null
23
+ */
24
+ export function readJsonFile(filePath) {
25
+ try {
26
+ const content = fs.readFileSync(filePath, 'utf-8');
27
+ return JSON.parse(content);
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 原子写入文件(跨平台兼容)
35
+ * macOS/Linux: rename 原子操作
36
+ * Windows: 先删目标再 rename(Windows rename 不覆盖)
37
+ */
38
+ function atomicWriteFile(filePath, content) {
39
+ const dir = path.dirname(filePath);
40
+ if (!fs.existsSync(dir)) {
41
+ fs.mkdirSync(dir, { recursive: true });
42
+ }
43
+ const tmpPath = filePath + '.tmp';
44
+ fs.writeFileSync(tmpPath, content, 'utf-8');
45
+ if (IS_WIN) {
46
+ try { fs.unlinkSync(filePath); } catch {}
47
+ }
48
+ fs.renameSync(tmpPath, filePath);
49
+ }
50
+
51
+ /**
52
+ * 原子写入 JSON 文件
53
+ */
54
+ export function writeJsonFile(filePath, data) {
55
+ atomicWriteFile(filePath, JSON.stringify(data, null, 2) + '\n');
56
+ }
57
+
58
+ /**
59
+ * 安全读取文本文件
60
+ */
61
+ export function readTextFile(filePath) {
62
+ try {
63
+ return fs.readFileSync(filePath, 'utf-8');
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 原子写入文本文件
71
+ */
72
+ export function writeTextFile(filePath, content) {
73
+ atomicWriteFile(filePath, content);
74
+ }
75
+
76
+ /**
77
+ * 备份文件(如果存在)
78
+ */
79
+ export function backupFile(filePath) {
80
+ if (fs.existsSync(filePath)) {
81
+ const backupPath = filePath + '.bak.' + Date.now();
82
+ fs.copyFileSync(filePath, backupPath);
83
+ return backupPath;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * 检测命令是否存在(跨平台)
90
+ * macOS/Linux: which
91
+ * Windows: where
92
+ */
93
+ export function commandExists(cmd) {
94
+ try {
95
+ const checkCmd = IS_WIN ? `where ${cmd}` : `which ${cmd}`;
96
+ execSync(checkCmd, { stdio: 'pipe' });
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 测试 URL 连通性
105
+ */
106
+ export async function testConnection(url, timeout = 10000) {
107
+ try {
108
+ const controller = new AbortController();
109
+ const timer = setTimeout(() => controller.abort(), timeout);
110
+ const res = await fetch(url, {
111
+ method: 'HEAD',
112
+ signal: controller.signal,
113
+ });
114
+ clearTimeout(timer);
115
+ return { ok: true, status: res.status };
116
+ } catch (err) {
117
+ return { ok: false, error: err.message };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * 深度合并对象(只覆盖指定的 key,不删除已有的 key)
123
+ */
124
+ export function deepMerge(target, source) {
125
+ const result = { ...target };
126
+ for (const key of Object.keys(source)) {
127
+ if (
128
+ source[key] &&
129
+ typeof source[key] === 'object' &&
130
+ !Array.isArray(source[key]) &&
131
+ target[key] &&
132
+ typeof target[key] === 'object' &&
133
+ !Array.isArray(target[key])
134
+ ) {
135
+ result[key] = deepMerge(target[key], source[key]);
136
+ } else {
137
+ result[key] = source[key];
138
+ }
139
+ }
140
+ return result;
141
+ }