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 +250 -0
- package/bin/index.js +8 -0
- package/package.json +36 -0
- package/src/config/claude.js +89 -0
- package/src/config/codex.js +141 -0
- package/src/config/gemini.js +87 -0
- package/src/config/index.js +9 -0
- package/src/config/openclaw.js +167 -0
- package/src/config/opencode.js +163 -0
- package/src/index.js +297 -0
- package/src/utils.js +141 -0
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
|
+
[](https://www.npmjs.com/package/ai-cli-switch)
|
|
7
|
+
[](https://www.npmjs.com/package/ai-cli-switch)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[](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
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
|
+
}
|