Skip to content

Commit 0f55e21

Browse files
committed
Merge pull request #7 from ymkiux/chore/claude-share-e2e-split
feat: add claude share command and split e2e tests
2 parents 823db5b + 86ad763 commit 0f55e21

12 files changed

Lines changed: 846 additions & 658 deletions

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ npm link
152152
| `codexmate use <model>` | Switch model |
153153
| `codexmate add <name> <URL> [API key]` | Add a provider |
154154
| `codexmate delete <provider>` | Delete a provider |
155+
| `codexmate claude <BaseURL> <API key> [model]` | Write Claude Code config to `~/.claude/settings.json` |
155156
| `codexmate models` | List all models |
156157
| `codexmate add-model <model>` | Add a model |
157158
| `codexmate delete-model <model>` | Delete a model |
@@ -180,6 +181,13 @@ codexmate run
180181
- Manage multiple Claude Code profiles
181182
- Configure API key, Base URL, and model
182183
- Default write to `env` in `~/.claude/settings.json`: `env.ANTHROPIC_API_KEY` / `env.ANTHROPIC_BASE_URL` / `env.ANTHROPIC_MODEL`
184+
- One-liner apply via CLI:
185+
186+
```bash
187+
codexmate claude https://api.example.com/v1 sk-ant-xxx claude-3-7-sonnet
188+
```
189+
190+
- In the Web UI, each Claude configuration card now has a "Share Import Command" button that copies a one-click import command (for example: `codexmate claude <BaseURL> <API Key> <Model>`).
183191

184192
### OpenClaw Config Mode
185193

README.zh-CN.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ npm link
152152
| `codexmate use <模型名称>` | 切换模型 |
153153
| `codexmate add <名称> <URL> [API密钥]` | 添加新提供商 |
154154
| `codexmate delete <提供商名称>` | 删除提供商 |
155+
| `codexmate claude <BaseURL> <API密钥> [模型]` | 一键写入 Claude Code 配置到 `~/.claude/settings.json` |
155156
| `codexmate models` | 列出所有模型 |
156157
| `codexmate add-model <模型名称>` | 添加模型 |
157158
| `codexmate delete-model <模型名称>` | 删除模型 |
@@ -180,6 +181,13 @@ codexmate run
180181
- 管理多个 Claude Code 配置方案
181182
- 配置 API Key、Base URL 和模型
182183
- 默认写入 `~/.claude/settings.json``env` 字段:`env.ANTHROPIC_API_KEY` / `env.ANTHROPIC_BASE_URL` / `env.ANTHROPIC_MODEL`
184+
- CLI 一行应用示例:
185+
186+
```bash
187+
codexmate claude https://api.example.com/v1 sk-ant-xxx claude-3-7-sonnet
188+
```
189+
190+
- Web 界面中每个 Claude 配置卡片新增“分享导入命令”按钮,可复制一条 `codexmate claude <BaseURL> <API Key> <模型>` 命令便于分享。
183191

184192
### OpenClaw 配置模式
185193

cli.js

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
7373
const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
7474
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
7575
const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
76+
const DEFAULT_CLAUDE_MODEL = 'glm-4.7';
7677

7778
const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gpt-4'];
7879
const SPEED_TEST_TIMEOUT_MS = 8000;
@@ -3091,6 +3092,23 @@ function buildExportPayload(includeKeys) {
30913092
};
30923093
}
30933094

3095+
function buildClaudeSharePayload(config = {}) {
3096+
const apiKey = typeof config.apiKey === 'string' ? config.apiKey : '';
3097+
const baseUrl = typeof config.baseUrl === 'string' ? config.baseUrl : '';
3098+
const model = typeof config.model === 'string' ? config.model : '';
3099+
3100+
if (!baseUrl) return { error: 'Claude Base URL 未设置' };
3101+
if (!apiKey) return { error: 'Claude API 密钥未设置' };
3102+
3103+
return {
3104+
payload: {
3105+
baseUrl: baseUrl.trim(),
3106+
apiKey: apiKey.trim(),
3107+
model: (model && model.trim()) || DEFAULT_CLAUDE_MODEL
3108+
}
3109+
};
3110+
}
3111+
30943112
function buildProviderSharePayload(params = {}) {
30953113
const name = typeof params.name === 'string' ? params.name.trim() : '';
30963114
if (!name) {
@@ -3940,7 +3958,7 @@ function applyToClaudeSettings(config = {}) {
39403958
}
39413959

39423960
const baseUrl = (config.baseUrl || 'https://open.bigmodel.cn/api/anthropic').trim();
3943-
const model = (config.model || 'glm-4.7').trim();
3961+
const model = (config.model || DEFAULT_CLAUDE_MODEL).trim();
39443962
const readResult = readJsonObjectFromFile(CLAUDE_SETTINGS_FILE, {});
39453963
if (!readResult.ok) {
39463964
return { success: false, mode: 'settings-file', error: readResult.error };
@@ -4017,6 +4035,51 @@ function readClaudeSettingsInfo() {
40174035
};
40184036
}
40194037

4038+
// CLI: 一行写入 Claude Code 配置
4039+
function cmdClaude(baseUrl, apiKey, model, silent = false) {
4040+
const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
4041+
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
4042+
const normalizedModel = typeof model === 'string' && model.trim()
4043+
? model.trim()
4044+
: DEFAULT_CLAUDE_MODEL;
4045+
4046+
if (!normalizedBaseUrl || !normalizedKey) {
4047+
if (!silent) {
4048+
console.error('用法: codexmate claude <BaseURL> <API密钥> [模型]');
4049+
console.log('\n示例:');
4050+
console.log(' codexmate claude https://open.bigmodel.cn/api/anthropic sk-ant-xxx glm-4.7');
4051+
}
4052+
throw new Error('BaseURL 和 API 密钥必填');
4053+
}
4054+
4055+
const result = applyToClaudeSettings({
4056+
baseUrl: normalizedBaseUrl,
4057+
apiKey: normalizedKey,
4058+
model: normalizedModel
4059+
});
4060+
4061+
if (!result || result.success === false) {
4062+
const message = (result && result.error) || '应用 Claude 配置失败';
4063+
if (!silent) console.error('错误:', message);
4064+
throw new Error(message);
4065+
}
4066+
4067+
if (!silent) {
4068+
console.log('✓ 已写入 Claude Code 配置');
4069+
console.log(' Base URL:', normalizedBaseUrl);
4070+
console.log(' 模型:', normalizedModel);
4071+
if (result.targetPath) {
4072+
console.log(' 目标文件:', result.targetPath);
4073+
}
4074+
if (result.backupPath) {
4075+
console.log(' 已自动备份:', result.backupPath);
4076+
}
4077+
console.log();
4078+
}
4079+
4080+
return result;
4081+
}
4082+
40204083
function commandExists(command, args = '') {
40214084
try {
40224085
execSync(`${command} ${args}`, { stdio: 'ignore' });
@@ -4548,6 +4611,9 @@ function cmdStart(options = {}) {
45484611
case 'apply-claude-config':
45494612
result = applyToClaudeSettings(params.config);
45504613
break;
4614+
case 'export-claude-share':
4615+
result = buildClaudeSharePayload(params && params.config ? params.config : {});
4616+
break;
45514617
case 'export-provider':
45524618
result = buildProviderSharePayload(params || {});
45534619
break;
@@ -4702,6 +4768,7 @@ async function main() {
47024768
console.log(' codexmate use <模型> 切换模型');
47034769
console.log(' codexmate add <名称> <URL> [密钥]');
47044770
console.log(' codexmate delete <名称> 删除提供商');
4771+
console.log(' codexmate claude <BaseURL> <API密钥> [模型] 写入 Claude Code 配置');
47054772
console.log(' codexmate add-model <模型> 添加模型');
47064773
console.log(' codexmate delete-model <模型> 删除模型');
47074774
console.log(' codexmate run [--host <HOST>] 启动 Web 界面');
@@ -4724,6 +4791,7 @@ async function main() {
47244791
case 'use': cmdUseModel(args[1]); break;
47254792
case 'add': cmdAdd(args[1], args[2], args[3]); break;
47264793
case 'delete': cmdDelete(args[1]); break;
4794+
case 'claude': cmdClaude(args[1], args[2], args[3]); break;
47274795
case 'add-model': cmdAddModel(args[1]); break;
47284796
case 'delete-model': cmdDeleteModel(args[1]); break;
47294797
case 'run': cmdStart(parseStartOptions(args.slice(1))); break;

tests/e2e/helpers.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
const http = require('http');
4+
const os = require('os');
5+
const { spawnSync, spawn } = require('child_process');
6+
const { writeJsonAtomic } = require('../../lib/cli-file-utils');
7+
const { normalizeWireApi, buildModelProbeSpec } = require('../../lib/cli-models-utils');
8+
9+
const debug = (...args) => {
10+
if (process.env.E2E_DEBUG) {
11+
console.error('[e2e]', ...args);
12+
}
13+
};
14+
15+
function assert(condition, message) {
16+
if (!condition) {
17+
throw new Error(message);
18+
}
19+
}
20+
21+
function fileMode(filePath) {
22+
return fs.existsSync(filePath) ? (fs.statSync(filePath).mode & 0o777) : 0;
23+
}
24+
25+
function captureFileState(filePath) {
26+
const state = {
27+
path: filePath,
28+
exists: false,
29+
readable: true,
30+
content: '',
31+
error: ''
32+
};
33+
34+
state.exists = fs.existsSync(filePath);
35+
if (!state.exists) {
36+
return state;
37+
}
38+
39+
try {
40+
state.content = fs.readFileSync(filePath, 'utf-8');
41+
} catch (e) {
42+
state.readable = false;
43+
state.error = e && e.message ? e.message : String(e);
44+
}
45+
return state;
46+
}
47+
48+
function assertFileUnchanged(state, label) {
49+
if (!state || !state.readable) return;
50+
const name = label || state.path;
51+
if (state.exists) {
52+
assert(fs.existsSync(state.path), `${name} disappeared during e2e`);
53+
const current = fs.readFileSync(state.path, 'utf-8');
54+
assert(current === state.content, `${name} changed during e2e`);
55+
return;
56+
}
57+
assert(!fs.existsSync(state.path), `${name} should not be created during e2e`);
58+
}
59+
60+
function runSync(node, args, options = {}) {
61+
const result = spawnSync(node, args, {
62+
encoding: 'utf-8',
63+
...options
64+
});
65+
return result;
66+
}
67+
68+
function runWithInput(node, args, input, options = {}) {
69+
return new Promise((resolve) => {
70+
let child;
71+
try {
72+
child = spawn(node, args, { ...options, stdio: ['pipe', 'pipe', 'pipe'] });
73+
} catch (err) {
74+
return resolve({
75+
status: 1,
76+
stdout: '',
77+
stderr: err && err.message ? err.message : String(err)
78+
});
79+
}
80+
let stdout = '';
81+
let stderr = '';
82+
child.stdout.on('data', chunk => stdout += chunk.toString());
83+
child.stderr.on('data', chunk => stderr += chunk.toString());
84+
child.on('error', (err) => {
85+
resolve({
86+
status: 1,
87+
stdout,
88+
stderr: stderr || (err && err.message ? err.message : String(err))
89+
});
90+
});
91+
child.on('close', (code) => resolve({ status: code, stdout, stderr }));
92+
if (input) {
93+
child.stdin.write(input);
94+
}
95+
child.stdin.end();
96+
});
97+
}
98+
99+
function postJson(port, payload, timeoutMs = 2000) {
100+
return new Promise((resolve, reject) => {
101+
const data = JSON.stringify(payload);
102+
const req = http.request({
103+
hostname: '127.0.0.1',
104+
port,
105+
path: '/api',
106+
method: 'POST',
107+
headers: {
108+
'Content-Type': 'application/json',
109+
'Content-Length': Buffer.byteLength(data)
110+
}
111+
}, (res) => {
112+
let body = '';
113+
res.setEncoding('utf-8');
114+
res.on('data', chunk => body += chunk);
115+
res.on('end', () => {
116+
try {
117+
resolve(JSON.parse(body || '{}'));
118+
} catch (e) {
119+
reject(new Error('Invalid JSON response'));
120+
}
121+
});
122+
});
123+
124+
req.on('error', reject);
125+
req.setTimeout(timeoutMs, () => {
126+
req.destroy(new Error('Request timeout'));
127+
});
128+
req.write(data);
129+
req.end();
130+
});
131+
}
132+
133+
async function waitForServer(port, retries = 20, delayMs = 200) {
134+
let lastError;
135+
for (let i = 0; i < retries; i++) {
136+
try {
137+
await postJson(port, { action: 'status' }, 1000);
138+
return;
139+
} catch (e) {
140+
lastError = e;
141+
debug(`wait retry ${i + 1}/${retries}: ${e && e.message ? e.message : e}`);
142+
await new Promise(resolve => setTimeout(resolve, delayMs));
143+
}
144+
}
145+
throw lastError || new Error('Server not ready');
146+
}
147+
148+
function startLocalServer(options = {}) {
149+
const mode = options.mode || 'list';
150+
const modelsPath = options.modelsPath || '/models';
151+
const status = options.status || 200;
152+
return new Promise((resolve, reject) => {
153+
const server = http.createServer((req, res) => {
154+
if (req.url && req.url.startsWith(modelsPath)) {
155+
if (mode === 'none') {
156+
res.writeHead(404, { 'Content-Type': 'application/json' });
157+
res.end(JSON.stringify({ error: 'not found' }));
158+
return;
159+
}
160+
if (mode === 'html') {
161+
res.writeHead(status, { 'Content-Type': 'text/html' });
162+
res.end('<!doctype html><html><body>ok</body></html>');
163+
return;
164+
}
165+
res.writeHead(status, { 'Content-Type': 'application/json' });
166+
res.end(JSON.stringify({
167+
data: [
168+
{ id: 'e2e2-model' },
169+
{ id: 'e2e2-model-2' }
170+
]
171+
}));
172+
return;
173+
}
174+
res.writeHead(status, { 'Content-Type': 'application/json' });
175+
res.end(JSON.stringify({ ok: true }));
176+
});
177+
server.on('error', reject);
178+
server.listen(0, '127.0.0.1', () => {
179+
const address = server.address();
180+
resolve({ server, port: address.port });
181+
});
182+
});
183+
}
184+
185+
function closeServer(server) {
186+
return new Promise((resolve) => {
187+
if (!server) return resolve();
188+
try {
189+
server.close(() => resolve());
190+
} catch (e) {
191+
resolve();
192+
}
193+
});
194+
}
195+
196+
module.exports = {
197+
fs,
198+
path,
199+
os,
200+
debug,
201+
assert,
202+
fileMode,
203+
captureFileState,
204+
assertFileUnchanged,
205+
runSync,
206+
runWithInput,
207+
postJson,
208+
waitForServer,
209+
startLocalServer,
210+
closeServer,
211+
writeJsonAtomic,
212+
normalizeWireApi,
213+
buildModelProbeSpec
214+
};

0 commit comments

Comments
 (0)