codexmate 0.0.3 → 0.0.5

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/cli.js CHANGED
@@ -2,13 +2,15 @@
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
+ const crypto = require('crypto');
5
6
  const toml = require('@iarna/toml');
7
+ const JSON5 = require('json5');
6
8
  const { exec, execSync } = require('child_process');
7
9
  const http = require('http');
8
10
  const https = require('https');
9
11
  const readline = require('readline');
10
12
 
11
- const PORT = 3737;
13
+ const DEFAULT_WEB_PORT = 3737;
12
14
 
13
15
  // ============================================================================
14
16
  // 配置
@@ -20,12 +22,17 @@ const MODELS_FILE = path.join(CONFIG_DIR, 'models.json');
20
22
  const CURRENT_MODELS_FILE = path.join(CONFIG_DIR, 'provider-current-models.json');
21
23
  const INIT_MARK_FILE = path.join(CONFIG_DIR, 'codexmate-init.json');
22
24
  const CODEX_SESSIONS_DIR = path.join(CONFIG_DIR, 'sessions');
25
+ const OPENCLAW_DIR = path.join(os.homedir(), '.openclaw');
26
+ const OPENCLAW_CONFIG_FILE = path.join(OPENCLAW_DIR, 'openclaw.json');
27
+ const OPENCLAW_WORKSPACE_DIR = path.join(OPENCLAW_DIR, 'workspace');
23
28
  const CLAUDE_DIR = path.join(os.homedir(), '.claude');
24
29
  const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
25
30
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
31
+ const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
26
32
 
27
33
  const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gpt-4'];
28
34
  const SPEED_TEST_TIMEOUT_MS = 8000;
35
+ const HEALTH_CHECK_TIMEOUT_MS = 6000;
29
36
  const MAX_SESSION_LIST_SIZE = 300;
30
37
  const MAX_EXPORT_MESSAGES = 1000;
31
38
  const DEFAULT_SESSION_DETAIL_MESSAGES = 300;
@@ -38,10 +45,15 @@ const SESSION_CONTENT_READ_BYTES = SESSION_SUMMARY_READ_BYTES;
38
45
  const DEFAULT_CONTENT_SCAN_LIMIT = 10;
39
46
  const SESSION_SCAN_FACTOR = 4;
40
47
  const SESSION_SCAN_MIN_FILES = 800;
41
- const MAX_SESSION_PATH_LIST_SIZE = 2000;
42
- const AGENTS_FILE_NAME = 'AGENTS.md';
43
- const UTF8_BOM = '\ufeff';
44
- const BOOTSTRAP_TEXT_MARKERS = [
48
+ const MAX_SESSION_PATH_LIST_SIZE = 2000;
49
+ const AGENTS_FILE_NAME = 'AGENTS.md';
50
+ const UTF8_BOM = '\ufeff';
51
+ const MODELS_CACHE_TTL_MS = 60 * 1000;
52
+ const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
53
+ const MODELS_CACHE_MAX_ENTRIES = 50;
54
+ const MODELS_RESPONSE_MAX_BYTES = 1024 * 1024;
55
+ const MAX_RECENT_CONFIGS = 3;
56
+ const BOOTSTRAP_TEXT_MARKERS = [
45
57
  'agents.md instructions',
46
58
  '<instructions>',
47
59
  '<environment_context>',
@@ -49,6 +61,17 @@ const BOOTSTRAP_TEXT_MARKERS = [
49
61
  'codex cli'
50
62
  ];
51
63
 
64
+ const HTTP_KEEP_ALIVE_AGENT = new http.Agent({ keepAlive: true });
65
+ const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ keepAlive: true });
66
+
67
+ function resolveWebPort() {
68
+ const raw = process.env.CODEXMATE_PORT;
69
+ if (!raw) return DEFAULT_WEB_PORT;
70
+ const parsed = parseInt(raw, 10);
71
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_WEB_PORT;
72
+ return parsed;
73
+ }
74
+
52
75
  const EMPTY_CONFIG_FALLBACK_TEMPLATE = `model = "gpt-5.3-codex"
53
76
  model_reasoning_effort = "high"
54
77
  disable_response_storage = true
@@ -71,6 +94,8 @@ stream_idle_timeout_ms = 300000
71
94
 
72
95
  let g_initNotice = '';
73
96
  let g_sessionListCache = new Map();
97
+ let g_modelsCache = new Map();
98
+ let g_modelsInFlight = new Map();
74
99
 
75
100
  // ============================================================================
76
101
  // 工具函数
@@ -150,12 +175,88 @@ function readJsonFile(filePath, fallback = null) {
150
175
  }
151
176
  }
152
177
 
178
+ function readJsonArrayFile(filePath, fallback = []) {
179
+ if (!fs.existsSync(filePath)) {
180
+ return Array.isArray(fallback) ? [...fallback] : [];
181
+ }
182
+ try {
183
+ const content = fs.readFileSync(filePath, 'utf-8');
184
+ if (!content.trim()) {
185
+ return Array.isArray(fallback) ? [...fallback] : [];
186
+ }
187
+ const parsed = JSON.parse(content);
188
+ return Array.isArray(parsed) ? parsed : (Array.isArray(fallback) ? [...fallback] : []);
189
+ } catch (e) {
190
+ return Array.isArray(fallback) ? [...fallback] : [];
191
+ }
192
+ }
193
+
153
194
  function ensureDir(dirPath) {
154
195
  if (!fs.existsSync(dirPath)) {
155
196
  fs.mkdirSync(dirPath, { recursive: true });
156
197
  }
157
198
  }
158
199
 
200
+ function expandHomePath(value) {
201
+ if (typeof value !== 'string') {
202
+ return '';
203
+ }
204
+ const trimmed = value.trim();
205
+ if (!trimmed) {
206
+ return '';
207
+ }
208
+ if (trimmed === '~') {
209
+ return os.homedir();
210
+ }
211
+ if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
212
+ return path.resolve(os.homedir(), trimmed.slice(2));
213
+ }
214
+ return trimmed;
215
+ }
216
+
217
+ function resolveExistingDir(candidates = [], fallback = '') {
218
+ for (const raw of candidates) {
219
+ const candidate = expandHomePath(raw);
220
+ if (!candidate) continue;
221
+ try {
222
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
223
+ return candidate;
224
+ }
225
+ } catch (e) {}
226
+ }
227
+ return fallback;
228
+ }
229
+
230
+ function getCodexSessionsDir() {
231
+ const candidates = [];
232
+ const envCodexHome = process.env.CODEX_HOME;
233
+ if (envCodexHome) {
234
+ candidates.push(path.join(envCodexHome, 'sessions'));
235
+ }
236
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
237
+ if (xdgConfig) {
238
+ candidates.push(path.join(xdgConfig, 'codex', 'sessions'));
239
+ }
240
+ candidates.push(path.join(os.homedir(), '.config', 'codex', 'sessions'));
241
+ candidates.push(CODEX_SESSIONS_DIR);
242
+ return resolveExistingDir(candidates, CODEX_SESSIONS_DIR);
243
+ }
244
+
245
+ function getClaudeProjectsDir() {
246
+ const candidates = [];
247
+ const envClaudeHome = process.env.CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR;
248
+ if (envClaudeHome) {
249
+ candidates.push(path.join(envClaudeHome, 'projects'));
250
+ }
251
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
252
+ if (xdgConfig) {
253
+ candidates.push(path.join(xdgConfig, 'claude', 'projects'));
254
+ }
255
+ candidates.push(path.join(os.homedir(), '.config', 'claude', 'projects'));
256
+ candidates.push(CLAUDE_PROJECTS_DIR);
257
+ return resolveExistingDir(candidates, CLAUDE_PROJECTS_DIR);
258
+ }
259
+
159
260
  function hasUtf8Bom(text) {
160
261
  return typeof text === 'string' && text.charCodeAt(0) === 0xfeff;
161
262
  }
@@ -179,6 +280,239 @@ function normalizeLineEnding(text, lineEnding) {
179
280
  return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
180
281
  }
181
282
 
283
+ function isValidProviderName(name) {
284
+ return typeof name === 'string' && /^[a-zA-Z0-9._-]+$/.test(name.trim());
285
+ }
286
+
287
+ function buildModelsCandidates(baseUrl) {
288
+ const trimmed = typeof baseUrl === 'string' ? baseUrl.trim() : '';
289
+ if (!trimmed) return [];
290
+ if (/\/models\/?$/.test(trimmed)) {
291
+ return [trimmed];
292
+ }
293
+ const normalized = trimmed.replace(/\/+$/, '');
294
+ const candidates = [];
295
+ const pushUnique = (url) => {
296
+ if (url && !candidates.includes(url)) {
297
+ candidates.push(url);
298
+ }
299
+ };
300
+
301
+ if (/\/v1$/i.test(normalized)) {
302
+ pushUnique(normalized + '/models');
303
+ } else {
304
+ pushUnique(normalized + '/v1/models');
305
+ pushUnique(normalized + '/models');
306
+ }
307
+
308
+ pushUnique(trimmed);
309
+ return candidates;
310
+ }
311
+
312
+ function extractModelNames(payload) {
313
+ if (!payload || typeof payload !== 'object') return [];
314
+ const data = Array.isArray(payload.data)
315
+ ? payload.data
316
+ : (Array.isArray(payload.models) ? payload.models : []);
317
+ const names = [];
318
+ for (const item of data) {
319
+ if (typeof item === 'string') {
320
+ if (item.trim()) names.push(item.trim());
321
+ continue;
322
+ }
323
+ if (!item || typeof item !== 'object') continue;
324
+ const name = item.id || item.name || item.model || '';
325
+ if (typeof name === 'string' && name.trim()) {
326
+ names.push(name.trim());
327
+ }
328
+ }
329
+ return Array.from(new Set(names));
330
+ }
331
+
332
+ function hasModelsListPayload(payload) {
333
+ if (!payload || typeof payload !== 'object') return false;
334
+ return Array.isArray(payload.data) || Array.isArray(payload.models);
335
+ }
336
+
337
+ function hashModelsCacheValue(value) {
338
+ if (!value) return '';
339
+ try {
340
+ return crypto.createHash('sha256').update(String(value)).digest('hex');
341
+ } catch (e) {
342
+ return '';
343
+ }
344
+ }
345
+
346
+ function buildModelsCacheKey(baseUrl, apiKey) {
347
+ const normalizedUrl = typeof baseUrl === 'string'
348
+ ? baseUrl.trim().replace(/\/+$/, '')
349
+ : '';
350
+ const apiKeyHash = hashModelsCacheValue(apiKey);
351
+ return `${normalizedUrl}|${apiKeyHash}`;
352
+ }
353
+
354
+ function readModelsCacheEntry(cacheKey) {
355
+ if (!cacheKey) return null;
356
+ const entry = g_modelsCache.get(cacheKey);
357
+ if (!entry) return null;
358
+ if (Date.now() >= entry.expiresAt) {
359
+ g_modelsCache.delete(cacheKey);
360
+ return null;
361
+ }
362
+ g_modelsCache.delete(cacheKey);
363
+ g_modelsCache.set(cacheKey, entry);
364
+ return entry.result || null;
365
+ }
366
+
367
+ function writeModelsCacheEntry(cacheKey, result) {
368
+ if (!cacheKey) return;
369
+ const isNegative = !!(result && (result.error || result.unlimited));
370
+ const ttl = isNegative ? MODELS_NEGATIVE_CACHE_TTL_MS : MODELS_CACHE_TTL_MS;
371
+ const entry = {
372
+ result,
373
+ expiresAt: Date.now() + ttl
374
+ };
375
+ if (g_modelsCache.has(cacheKey)) {
376
+ g_modelsCache.delete(cacheKey);
377
+ }
378
+ g_modelsCache.set(cacheKey, entry);
379
+ while (g_modelsCache.size > MODELS_CACHE_MAX_ENTRIES) {
380
+ const oldestKey = g_modelsCache.keys().next().value;
381
+ g_modelsCache.delete(oldestKey);
382
+ }
383
+ }
384
+
385
+ async function fetchModelsFromBaseUrl(baseUrl, apiKey) {
386
+ const cacheKey = buildModelsCacheKey(baseUrl, apiKey);
387
+ const cached = readModelsCacheEntry(cacheKey);
388
+ if (cached) return cached;
389
+
390
+ const inFlight = g_modelsInFlight.get(cacheKey);
391
+ if (inFlight) return inFlight;
392
+
393
+ const promise = (async () => {
394
+ const result = await fetchModelsFromBaseUrlCore(baseUrl, apiKey);
395
+ writeModelsCacheEntry(cacheKey, result);
396
+ return result;
397
+ })();
398
+
399
+ g_modelsInFlight.set(cacheKey, promise);
400
+ promise.finally(() => {
401
+ g_modelsInFlight.delete(cacheKey);
402
+ });
403
+ return promise;
404
+ }
405
+
406
+ async function fetchModelsFromBaseUrlCore(baseUrl, apiKey) {
407
+ const candidates = buildModelsCandidates(baseUrl);
408
+ if (candidates.length === 0) return { error: 'Provider missing URL' };
409
+
410
+ let lastError = '';
411
+ for (const modelsUrl of candidates) {
412
+ let parsed;
413
+ try {
414
+ parsed = new URL(modelsUrl);
415
+ } catch (e) {
416
+ lastError = 'Invalid URL';
417
+ continue;
418
+ }
419
+
420
+ const transport = parsed.protocol === 'https:' ? https : http;
421
+ const agent = parsed.protocol === 'https:' ? HTTPS_KEEP_ALIVE_AGENT : HTTP_KEEP_ALIVE_AGENT;
422
+ const headers = {
423
+ 'User-Agent': 'codexmate-models',
424
+ 'Accept': 'application/json'
425
+ };
426
+ if (apiKey) {
427
+ headers['Authorization'] = `Bearer ${apiKey}`;
428
+ }
429
+
430
+ const result = await new Promise((innerResolve) => {
431
+ let settled = false;
432
+ const finish = (payload) => {
433
+ if (settled) return;
434
+ settled = true;
435
+ innerResolve(payload);
436
+ };
437
+ const req = transport.request(parsed, { method: 'GET', headers, agent }, (res) => {
438
+ const status = res.statusCode || 0;
439
+ const contentType = String(res.headers['content-type'] || '').toLowerCase();
440
+ if (status === 404 || status === 405 || status === 501) {
441
+ res.resume();
442
+ return finish({ unavailable: true });
443
+ }
444
+ let body = '';
445
+ let receivedBytes = 0;
446
+ res.on('data', chunk => {
447
+ receivedBytes += chunk.length || 0;
448
+ if (receivedBytes > MODELS_RESPONSE_MAX_BYTES) {
449
+ res.destroy();
450
+ return finish({ unavailable: true });
451
+ }
452
+ body += chunk;
453
+ });
454
+ res.on('end', () => {
455
+ if (settled) return;
456
+ if (status >= 400) {
457
+ return finish({ error: `Request failed: ${status}` });
458
+ }
459
+ if (contentType && !contentType.includes('application/json')) {
460
+ return finish({ unavailable: true });
461
+ }
462
+ try {
463
+ const payload = JSON.parse(body || '{}');
464
+ if (!hasModelsListPayload(payload)) {
465
+ return finish({ unavailable: true });
466
+ }
467
+ const models = extractModelNames(payload);
468
+ return finish({ models });
469
+ } catch (e) {
470
+ return finish({ unavailable: true });
471
+ }
472
+ });
473
+ });
474
+
475
+ req.setTimeout(SPEED_TEST_TIMEOUT_MS, () => {
476
+ req.destroy(new Error('timeout'));
477
+ });
478
+ req.on('error', (err) => {
479
+ finish({ error: err.message || 'Request failed' });
480
+ });
481
+ req.end();
482
+ });
483
+
484
+ if (result && Array.isArray(result.models)) {
485
+ return { models: result.models };
486
+ }
487
+ if (result && result.error) {
488
+ lastError = result.error;
489
+ continue;
490
+ }
491
+ }
492
+
493
+ if (lastError) {
494
+ return { error: lastError };
495
+ }
496
+ return { unlimited: true };
497
+ }
498
+
499
+ async function fetchProviderModels(providerName, overrides = {}) {
500
+ const { config } = readConfigOrVirtualDefault();
501
+ const targetProvider = providerName || config.model_provider || '';
502
+ if (!targetProvider) return { error: '未设置当前提供商' };
503
+
504
+ const providers = config.model_providers || {};
505
+ const provider = providers[targetProvider];
506
+ if (!provider) return { error: `提供商不存在: ${targetProvider}` };
507
+
508
+ const baseUrl = overrides.baseUrl || provider.base_url || '';
509
+ const apiKey = overrides.apiKey ?? provider.preferred_auth_method ?? '';
510
+ const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
511
+ if (res.unlimited) return { models: [], provider: targetProvider, unlimited: true };
512
+ if (res.error) return { error: res.error };
513
+ return { models: res.models || [], provider: targetProvider, unlimited: false };
514
+ }
515
+
182
516
  function resolveAgentsFilePath(params = {}) {
183
517
  const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
184
518
  ? params.baseDir.trim()
@@ -228,101 +562,840 @@ function readAgentsFile(params = {}) {
228
562
  }
229
563
  }
230
564
 
231
- function applyAgentsFile(params = {}) {
232
- const filePath = resolveAgentsFilePath(params);
233
- const dirCheck = validateAgentsBaseDir(filePath);
234
- if (dirCheck.error) {
235
- return { error: dirCheck.error };
565
+ function applyAgentsFile(params = {}) {
566
+ const filePath = resolveAgentsFilePath(params);
567
+ const dirCheck = validateAgentsBaseDir(filePath);
568
+ if (dirCheck.error) {
569
+ return { error: dirCheck.error };
570
+ }
571
+
572
+ const content = typeof params.content === 'string' ? params.content : '';
573
+ const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
574
+ const normalized = normalizeLineEnding(content, lineEnding);
575
+ const finalContent = ensureUtf8Bom(normalized);
576
+
577
+ try {
578
+ fs.writeFileSync(filePath, finalContent, 'utf-8');
579
+ return { success: true, path: filePath };
580
+ } catch (e) {
581
+ return { error: `写入 AGENTS.md 失败: ${e.message}` };
582
+ }
583
+ }
584
+
585
+ function resolveHomePath(input) {
586
+ const raw = typeof input === 'string' ? input.trim() : '';
587
+ if (!raw) return '';
588
+ if (raw === '~') return os.homedir();
589
+ if (raw.startsWith('~/') || raw.startsWith('~\\')) {
590
+ return path.join(os.homedir(), raw.slice(2));
591
+ }
592
+ return raw;
593
+ }
594
+
595
+ function resolveOpenclawWorkspaceDir(config) {
596
+ const workspace = config
597
+ && config.agents
598
+ && config.agents.defaults
599
+ && typeof config.agents.defaults.workspace === 'string'
600
+ ? config.agents.defaults.workspace
601
+ : '';
602
+ const resolved = resolveHomePath(workspace);
603
+ if (!resolved) {
604
+ return OPENCLAW_WORKSPACE_DIR;
605
+ }
606
+ if (path.isAbsolute(resolved)) {
607
+ return resolved;
608
+ }
609
+ return path.join(OPENCLAW_DIR, resolved);
610
+ }
611
+
612
+ function readOpenclawConfigFile() {
613
+ const filePath = OPENCLAW_CONFIG_FILE;
614
+ if (!fs.existsSync(filePath)) {
615
+ return {
616
+ exists: false,
617
+ path: filePath,
618
+ content: '',
619
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n'
620
+ };
621
+ }
622
+
623
+ try {
624
+ const raw = fs.readFileSync(filePath, 'utf-8');
625
+ return {
626
+ exists: true,
627
+ path: filePath,
628
+ content: stripUtf8Bom(raw),
629
+ lineEnding: detectLineEnding(raw)
630
+ };
631
+ } catch (e) {
632
+ return { error: `读取 OpenClaw 配置失败: ${e.message}` };
633
+ }
634
+ }
635
+
636
+ function parseOpenclawConfigText(content) {
637
+ const raw = stripUtf8Bom(typeof content === 'string' ? content : '');
638
+ if (!raw.trim()) {
639
+ return { ok: false, error: 'OpenClaw 配置内容不能为空' };
640
+ }
641
+ try {
642
+ const parsed = JSON5.parse(raw);
643
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
644
+ return { ok: false, error: '配置格式错误(根节点必须是对象)' };
645
+ }
646
+ return { ok: true, data: parsed };
647
+ } catch (e) {
648
+ return { ok: false, error: `配置解析失败: ${e.message}` };
649
+ }
650
+ }
651
+
652
+ function getOpenclawWorkspaceInfo() {
653
+ const readResult = readOpenclawConfigFile();
654
+ let workspaceDir = OPENCLAW_WORKSPACE_DIR;
655
+ let configError = readResult.error || '';
656
+ if (!configError && readResult.exists && readResult.content.trim()) {
657
+ const parsed = parseOpenclawConfigText(readResult.content);
658
+ if (parsed.ok) {
659
+ workspaceDir = resolveOpenclawWorkspaceDir(parsed.data);
660
+ } else {
661
+ configError = parsed.error || '';
662
+ }
663
+ }
664
+ return {
665
+ workspaceDir,
666
+ configError,
667
+ configPath: readResult.path || OPENCLAW_CONFIG_FILE
668
+ };
669
+ }
670
+
671
+ function readOpenclawAgentsFile() {
672
+ const workspaceInfo = getOpenclawWorkspaceInfo();
673
+ const baseDir = workspaceInfo.workspaceDir;
674
+ const filePath = path.join(baseDir, AGENTS_FILE_NAME);
675
+
676
+ if (!fs.existsSync(baseDir)) {
677
+ return {
678
+ exists: false,
679
+ path: filePath,
680
+ content: '',
681
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n',
682
+ workspaceDir: baseDir,
683
+ configError: workspaceInfo.configError,
684
+ baseDirMissing: true
685
+ };
686
+ }
687
+
688
+ const readResult = readAgentsFile({ baseDir });
689
+ return {
690
+ ...readResult,
691
+ workspaceDir: baseDir,
692
+ configError: workspaceInfo.configError
693
+ };
694
+ }
695
+
696
+ function applyOpenclawAgentsFile(params = {}) {
697
+ const workspaceInfo = getOpenclawWorkspaceInfo();
698
+ const baseDir = workspaceInfo.workspaceDir;
699
+ ensureDir(baseDir);
700
+ const result = applyAgentsFile({
701
+ ...params,
702
+ baseDir
703
+ });
704
+ return {
705
+ ...result,
706
+ workspaceDir: baseDir,
707
+ configError: workspaceInfo.configError
708
+ };
709
+ }
710
+
711
+ function applyOpenclawConfig(params = {}) {
712
+ const content = typeof params.content === 'string' ? params.content : '';
713
+ const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
714
+ const normalized = normalizeLineEnding(content, lineEnding);
715
+ const parsed = parseOpenclawConfigText(normalized);
716
+ if (!parsed.ok) {
717
+ return { success: false, error: parsed.error };
718
+ }
719
+
720
+ try {
721
+ ensureDir(OPENCLAW_DIR);
722
+ const backupPath = backupFileIfNeededOnce(OPENCLAW_CONFIG_FILE);
723
+ fs.writeFileSync(OPENCLAW_CONFIG_FILE, normalized, 'utf-8');
724
+ const result = {
725
+ success: true,
726
+ targetPath: OPENCLAW_CONFIG_FILE
727
+ };
728
+ if (backupPath) {
729
+ result.backupPath = backupPath;
730
+ }
731
+ return result;
732
+ } catch (e) {
733
+ return {
734
+ success: false,
735
+ error: e.message || '写入 OpenClaw 配置失败'
736
+ };
737
+ }
738
+ }
739
+
740
+ function readJsonObjectFromFile(filePath, fallback = {}) {
741
+ if (!fs.existsSync(filePath)) {
742
+ return { ok: true, exists: false, data: { ...fallback } };
743
+ }
744
+
745
+ try {
746
+ const content = fs.readFileSync(filePath, 'utf-8');
747
+ if (!content.trim()) {
748
+ return { ok: true, exists: true, data: { ...fallback } };
749
+ }
750
+
751
+ const parsed = JSON.parse(content);
752
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
753
+ return {
754
+ ok: false,
755
+ exists: true,
756
+ error: `配置文件格式错误(根节点必须是对象): ${filePath}`
757
+ };
758
+ }
759
+ return { ok: true, exists: true, data: parsed };
760
+ } catch (e) {
761
+ return {
762
+ ok: false,
763
+ exists: true,
764
+ error: `配置文件解析失败: ${e.message}`
765
+ };
766
+ }
767
+ }
768
+
769
+ function backupFileIfNeededOnce(filePath, backupPrefix = 'codexmate-backup') {
770
+ if (!fs.existsSync(filePath)) {
771
+ return '';
772
+ }
773
+
774
+ const dirPath = path.dirname(filePath);
775
+ const baseName = path.basename(filePath);
776
+ const existingPrefix = `${baseName}.${backupPrefix}-`;
777
+ const hasBackup = fs.readdirSync(dirPath).some(fileName =>
778
+ fileName.startsWith(existingPrefix) && fileName.endsWith('.bak')
779
+ );
780
+
781
+ if (hasBackup) {
782
+ return '';
783
+ }
784
+
785
+ const backupPath = path.join(dirPath, `${existingPrefix}${formatTimestampForFileName()}.bak`);
786
+ fs.copyFileSync(filePath, backupPath);
787
+ return backupPath;
788
+ }
789
+
790
+ function writeJsonAtomic(filePath, data) {
791
+ const dirPath = path.dirname(filePath);
792
+ ensureDir(dirPath);
793
+
794
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
795
+ const content = `${JSON.stringify(data, null, 2)}\n`;
796
+
797
+ try {
798
+ fs.writeFileSync(tmpPath, content, 'utf-8');
799
+ try {
800
+ fs.renameSync(tmpPath, filePath);
801
+ } catch (renameError) {
802
+ if (process.platform === 'win32') {
803
+ fs.copyFileSync(tmpPath, filePath);
804
+ fs.unlinkSync(tmpPath);
805
+ } else {
806
+ throw renameError;
807
+ }
808
+ }
809
+ } catch (e) {
810
+ if (fs.existsSync(tmpPath)) {
811
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
812
+ }
813
+ throw new Error(`写入 JSON 文件失败: ${e.message}`);
814
+ }
815
+ }
816
+
817
+ function normalizeRecentConfigs(items) {
818
+ if (!Array.isArray(items)) return [];
819
+ const output = [];
820
+ const seen = new Set();
821
+ for (const item of items) {
822
+ if (!item || typeof item !== 'object') continue;
823
+ const provider = typeof item.provider === 'string' ? item.provider.trim() : '';
824
+ const model = typeof item.model === 'string' ? item.model.trim() : '';
825
+ if (!provider || !model) continue;
826
+ const key = `${provider}::${model}`;
827
+ if (seen.has(key)) continue;
828
+ seen.add(key);
829
+ output.push({
830
+ provider,
831
+ model,
832
+ usedAt: typeof item.usedAt === 'string' ? item.usedAt : ''
833
+ });
834
+ }
835
+ return output;
836
+ }
837
+
838
+ function readRecentConfigs() {
839
+ return normalizeRecentConfigs(readJsonArrayFile(RECENT_CONFIGS_FILE, []));
840
+ }
841
+
842
+ function writeRecentConfigs(items) {
843
+ writeJsonAtomic(RECENT_CONFIGS_FILE, items);
844
+ }
845
+
846
+ function recordRecentConfig(provider, model) {
847
+ const providerName = typeof provider === 'string' ? provider.trim() : '';
848
+ const modelName = typeof model === 'string' ? model.trim() : '';
849
+ if (!providerName || !modelName) return;
850
+
851
+ const now = new Date().toISOString();
852
+ const current = readRecentConfigs();
853
+ const next = [{
854
+ provider: providerName,
855
+ model: modelName,
856
+ usedAt: now
857
+ }];
858
+
859
+ for (const item of current) {
860
+ if (item.provider === providerName && item.model === modelName) continue;
861
+ next.push(item);
862
+ }
863
+
864
+ const trimmed = next.slice(0, MAX_RECENT_CONFIGS);
865
+ writeRecentConfigs(trimmed);
866
+ }
867
+
868
+ function isValidHttpUrl(value) {
869
+ if (typeof value !== 'string' || !value.trim()) return false;
870
+ try {
871
+ const parsed = new URL(value);
872
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
873
+ } catch (e) {
874
+ return false;
875
+ }
876
+ }
877
+
878
+ function normalizeBaseUrl(value) {
879
+ if (typeof value !== 'string') return '';
880
+ return value.trim().replace(/\/+$/g, '');
881
+ }
882
+
883
+ function joinApiUrl(baseUrl, pathSuffix) {
884
+ const trimmed = normalizeBaseUrl(baseUrl);
885
+ if (!trimmed) return '';
886
+ const safeSuffix = String(pathSuffix || '').replace(/^\/+/g, '');
887
+ if (!safeSuffix) return trimmed;
888
+ if (/\/v1$/i.test(trimmed)) {
889
+ return `${trimmed}/${safeSuffix}`;
890
+ }
891
+ return `${trimmed}/v1/${safeSuffix}`;
892
+ }
893
+
894
+ function buildModelsProbeUrl(baseUrl) {
895
+ return joinApiUrl(baseUrl, 'models');
896
+ }
897
+
898
+ function normalizeWireApi(value) {
899
+ const raw = typeof value === 'string' ? value.trim().toLowerCase() : '';
900
+ if (!raw) return 'responses';
901
+ return raw.replace(/[\s-]/g, '_');
902
+ }
903
+
904
+ function buildModelProbeSpec(provider, modelName, baseUrl) {
905
+ const model = typeof modelName === 'string' ? modelName.trim() : '';
906
+ if (!model) return null;
907
+
908
+ const wireApi = normalizeWireApi(provider && provider.wire_api);
909
+ if (wireApi === 'chat_completions' || wireApi === 'chat') {
910
+ return {
911
+ url: joinApiUrl(baseUrl, 'chat/completions'),
912
+ body: {
913
+ model,
914
+ messages: [{ role: 'user', content: 'ping' }],
915
+ max_tokens: 1,
916
+ temperature: 0
917
+ }
918
+ };
919
+ }
920
+
921
+ if (wireApi === 'completions') {
922
+ return {
923
+ url: joinApiUrl(baseUrl, 'completions'),
924
+ body: {
925
+ model,
926
+ prompt: 'ping',
927
+ max_tokens: 1,
928
+ temperature: 0
929
+ }
930
+ };
931
+ }
932
+
933
+ return {
934
+ url: joinApiUrl(baseUrl, 'responses'),
935
+ body: {
936
+ model,
937
+ input: 'ping',
938
+ max_output_tokens: 1
939
+ }
940
+ };
941
+ }
942
+
943
+ function probeUrl(targetUrl, options = {}) {
944
+ return new Promise((resolve) => {
945
+ let parsed;
946
+ try {
947
+ parsed = new URL(targetUrl);
948
+ } catch (e) {
949
+ return resolve({ ok: false, error: 'Invalid URL' });
950
+ }
951
+
952
+ const transport = parsed.protocol === 'https:' ? https : http;
953
+ const headers = {
954
+ 'User-Agent': 'codexmate-health-check',
955
+ 'Accept': 'application/json'
956
+ };
957
+ if (options.apiKey) {
958
+ headers['Authorization'] = `Bearer ${options.apiKey}`;
959
+ }
960
+
961
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : HEALTH_CHECK_TIMEOUT_MS;
962
+ const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : 256 * 1024;
963
+ const start = Date.now();
964
+ const req = transport.request(parsed, { method: 'GET', headers }, (res) => {
965
+ const chunks = [];
966
+ let size = 0;
967
+ res.on('data', (chunk) => {
968
+ if (!chunk) return;
969
+ size += chunk.length;
970
+ if (size <= maxBytes) {
971
+ chunks.push(chunk);
972
+ }
973
+ });
974
+ res.on('end', () => {
975
+ const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : '';
976
+ resolve({
977
+ ok: true,
978
+ status: res.statusCode || 0,
979
+ durationMs: Date.now() - start,
980
+ body
981
+ });
982
+ });
983
+ });
984
+
985
+ req.setTimeout(timeoutMs, () => {
986
+ req.destroy(new Error('timeout'));
987
+ });
988
+
989
+ req.on('error', (err) => {
990
+ resolve({
991
+ ok: false,
992
+ error: err.message || 'request failed',
993
+ durationMs: Date.now() - start
994
+ });
995
+ });
996
+
997
+ req.end();
998
+ });
999
+ }
1000
+
1001
+ function probeJsonPost(targetUrl, body, options = {}) {
1002
+ return new Promise((resolve) => {
1003
+ let parsed;
1004
+ try {
1005
+ parsed = new URL(targetUrl);
1006
+ } catch (e) {
1007
+ return resolve({ ok: false, error: 'Invalid URL' });
1008
+ }
1009
+
1010
+ const transport = parsed.protocol === 'https:' ? https : http;
1011
+ const headers = {
1012
+ 'User-Agent': 'codexmate-health-check',
1013
+ 'Accept': 'application/json',
1014
+ 'Content-Type': 'application/json'
1015
+ };
1016
+ if (options.apiKey) {
1017
+ headers['Authorization'] = `Bearer ${options.apiKey}`;
1018
+ }
1019
+
1020
+ const payload = JSON.stringify(body || {});
1021
+ headers['Content-Length'] = Buffer.byteLength(payload);
1022
+
1023
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : HEALTH_CHECK_TIMEOUT_MS;
1024
+ const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : 256 * 1024;
1025
+ const start = Date.now();
1026
+ const req = transport.request(parsed, { method: 'POST', headers }, (res) => {
1027
+ const chunks = [];
1028
+ let size = 0;
1029
+ res.on('data', (chunk) => {
1030
+ if (!chunk) return;
1031
+ size += chunk.length;
1032
+ if (size <= maxBytes) {
1033
+ chunks.push(chunk);
1034
+ }
1035
+ });
1036
+ res.on('end', () => {
1037
+ const bodyText = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : '';
1038
+ resolve({
1039
+ ok: true,
1040
+ status: res.statusCode || 0,
1041
+ durationMs: Date.now() - start,
1042
+ body: bodyText
1043
+ });
1044
+ });
1045
+ });
1046
+
1047
+ req.setTimeout(timeoutMs, () => {
1048
+ req.destroy(new Error('timeout'));
1049
+ });
1050
+
1051
+ req.on('error', (err) => {
1052
+ resolve({
1053
+ ok: false,
1054
+ error: err.message || 'request failed',
1055
+ durationMs: Date.now() - start
1056
+ });
1057
+ });
1058
+
1059
+ req.write(payload);
1060
+ req.end();
1061
+ });
1062
+ }
1063
+
1064
+ function extractModelIds(payload) {
1065
+ const ids = [];
1066
+ const pushValue = (value) => {
1067
+ if (typeof value === 'string' && value.trim()) {
1068
+ ids.push(value.trim());
1069
+ }
1070
+ };
1071
+
1072
+ if (!payload) return ids;
1073
+
1074
+ if (Array.isArray(payload)) {
1075
+ for (const item of payload) {
1076
+ if (item && typeof item === 'object') {
1077
+ pushValue(item.id);
1078
+ pushValue(item.model);
1079
+ pushValue(item.name);
1080
+ } else {
1081
+ pushValue(item);
1082
+ }
1083
+ }
1084
+ return ids;
1085
+ }
1086
+
1087
+ if (Array.isArray(payload.data)) {
1088
+ for (const item of payload.data) {
1089
+ if (item && typeof item === 'object') {
1090
+ pushValue(item.id);
1091
+ pushValue(item.model);
1092
+ pushValue(item.name);
1093
+ } else {
1094
+ pushValue(item);
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ if (Array.isArray(payload.models)) {
1100
+ for (const item of payload.models) {
1101
+ if (item && typeof item === 'object') {
1102
+ pushValue(item.id);
1103
+ pushValue(item.model);
1104
+ pushValue(item.name);
1105
+ } else {
1106
+ pushValue(item);
1107
+ }
1108
+ }
1109
+ }
1110
+
1111
+ return ids;
1112
+ }
1113
+
1114
+ async function runRemoteHealthCheck(provider, modelName, options = {}) {
1115
+ const issues = [];
1116
+ const results = {};
1117
+ const baseUrl = normalizeBaseUrl(provider && provider.base_url ? provider.base_url : '');
1118
+ if (!baseUrl) {
1119
+ issues.push({
1120
+ code: 'remote-skip-base-url',
1121
+ message: '无法进行远程探测:base_url 为空',
1122
+ suggestion: '补全 base_url 或关闭远程探测'
1123
+ });
1124
+ return { issues, results };
1125
+ }
1126
+
1127
+ const requiresAuth = provider && provider.requires_openai_auth !== false;
1128
+ const apiKey = typeof provider.preferred_auth_method === 'string'
1129
+ ? provider.preferred_auth_method.trim()
1130
+ : '';
1131
+ const authValue = requiresAuth ? apiKey : (apiKey || '');
1132
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : HEALTH_CHECK_TIMEOUT_MS;
1133
+
1134
+ const baseProbe = await probeUrl(baseUrl, { apiKey: authValue, timeoutMs });
1135
+ results.base = {
1136
+ url: baseUrl,
1137
+ status: baseProbe.status || 0,
1138
+ ok: baseProbe.ok,
1139
+ durationMs: baseProbe.durationMs || 0
1140
+ };
1141
+
1142
+ if (!baseProbe.ok) {
1143
+ issues.push({
1144
+ code: 'remote-unreachable',
1145
+ message: `远程探测失败:${baseProbe.error || '无法连接'}`,
1146
+ suggestion: '检查网络与 base_url 可达性'
1147
+ });
1148
+ return { issues, results };
1149
+ }
1150
+
1151
+ if (baseProbe.status === 401 || baseProbe.status === 403) {
1152
+ issues.push({
1153
+ code: 'remote-auth-failed',
1154
+ message: '远程探测鉴权失败(401/403)',
1155
+ suggestion: '检查 API Key 或认证方式'
1156
+ });
1157
+ } else if (baseProbe.status >= 400) {
1158
+ issues.push({
1159
+ code: 'remote-http-error',
1160
+ message: `远程探测返回异常状态: ${baseProbe.status}`,
1161
+ suggestion: '检查 base_url 是否正确'
1162
+ });
1163
+ }
1164
+
1165
+ const modelsUrl = buildModelsProbeUrl(baseUrl);
1166
+ if (modelsUrl) {
1167
+ const modelsProbe = await probeUrl(modelsUrl, { apiKey: authValue, timeoutMs, maxBytes: 256 * 1024 });
1168
+ results.models = {
1169
+ url: modelsUrl,
1170
+ status: modelsProbe.status || 0,
1171
+ ok: modelsProbe.ok,
1172
+ durationMs: modelsProbe.durationMs || 0
1173
+ };
1174
+
1175
+ if (!modelsProbe.ok) {
1176
+ issues.push({
1177
+ code: 'remote-models-unreachable',
1178
+ message: `模型列表探测失败:${modelsProbe.error || '无法连接'}`,
1179
+ suggestion: '检查 base_url 是否包含 /v1 或关闭远程探测'
1180
+ });
1181
+ } else if (modelsProbe.status === 401 || modelsProbe.status === 403) {
1182
+ issues.push({
1183
+ code: 'remote-models-auth-failed',
1184
+ message: '模型列表鉴权失败(401/403)',
1185
+ suggestion: '检查 API Key 或认证方式'
1186
+ });
1187
+ } else if (modelsProbe.status >= 400) {
1188
+ issues.push({
1189
+ code: 'remote-models-http-error',
1190
+ message: `模型列表返回异常状态: ${modelsProbe.status}`,
1191
+ suggestion: '确认 /v1/models 可用'
1192
+ });
1193
+ } else {
1194
+ let payload = null;
1195
+ try {
1196
+ payload = modelsProbe.body ? JSON.parse(modelsProbe.body) : null;
1197
+ } catch (e) {
1198
+ issues.push({
1199
+ code: 'remote-models-parse',
1200
+ message: '模型列表解析失败(非 JSON)',
1201
+ suggestion: '确认 /v1/models 返回 JSON'
1202
+ });
1203
+ }
1204
+
1205
+ if (payload) {
1206
+ const ids = extractModelIds(payload);
1207
+ if (ids.length === 0) {
1208
+ issues.push({
1209
+ code: 'remote-models-empty',
1210
+ message: '模型列表为空或结构无法识别',
1211
+ suggestion: '确认 provider 是否兼容 /v1/models'
1212
+ });
1213
+ } else if (modelName && !ids.includes(modelName)) {
1214
+ issues.push({
1215
+ code: 'remote-model-unavailable',
1216
+ message: `远程模型列表中未找到: ${modelName}`,
1217
+ suggestion: '切换模型或确认模型名称'
1218
+ });
1219
+ }
1220
+ }
1221
+ }
1222
+ }
1223
+
1224
+ const modelProbeSpec = buildModelProbeSpec(provider, modelName, baseUrl);
1225
+ if (modelProbeSpec && modelProbeSpec.url) {
1226
+ const modelProbe = await probeJsonPost(modelProbeSpec.url, modelProbeSpec.body, {
1227
+ apiKey: authValue,
1228
+ timeoutMs,
1229
+ maxBytes: 256 * 1024
1230
+ });
1231
+
1232
+ results.modelProbe = {
1233
+ url: modelProbeSpec.url,
1234
+ status: modelProbe.status || 0,
1235
+ ok: modelProbe.ok,
1236
+ durationMs: modelProbe.durationMs || 0
1237
+ };
1238
+
1239
+ if (!modelProbe.ok) {
1240
+ issues.push({
1241
+ code: 'remote-model-probe-unreachable',
1242
+ message: `模型可用性探测失败:${modelProbe.error || '无法连接'}`,
1243
+ suggestion: '检查网络或模型接口是否可用'
1244
+ });
1245
+ } else if (modelProbe.status === 401 || modelProbe.status === 403) {
1246
+ issues.push({
1247
+ code: 'remote-model-probe-auth-failed',
1248
+ message: '模型可用性探测鉴权失败(401/403)',
1249
+ suggestion: '检查 API Key 或认证方式'
1250
+ });
1251
+ } else if (modelProbe.status >= 400) {
1252
+ issues.push({
1253
+ code: 'remote-model-probe-http-error',
1254
+ message: `模型可用性探测返回异常状态: ${modelProbe.status}`,
1255
+ suggestion: '检查模型或接口路径'
1256
+ });
1257
+ } else {
1258
+ let payload = null;
1259
+ try {
1260
+ payload = modelProbe.body ? JSON.parse(modelProbe.body) : null;
1261
+ } catch (e) {
1262
+ issues.push({
1263
+ code: 'remote-model-probe-parse',
1264
+ message: '模型可用性探测解析失败(非 JSON)',
1265
+ suggestion: '确认模型接口返回 JSON'
1266
+ });
1267
+ }
1268
+ if (payload && payload.error) {
1269
+ const message = typeof payload.error.message === 'string'
1270
+ ? payload.error.message
1271
+ : '模型接口返回错误';
1272
+ issues.push({
1273
+ code: 'remote-model-probe-error',
1274
+ message: `模型可用性探测失败:${message}`,
1275
+ suggestion: '检查模型名与权限'
1276
+ });
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ return { issues, results };
1282
+ }
1283
+
1284
+ async function buildConfigHealthReport(params = {}) {
1285
+ const issues = [];
1286
+ const status = readConfigOrVirtualDefault();
1287
+ const config = status.config || {};
1288
+
1289
+ if (status.isVirtual) {
1290
+ issues.push({
1291
+ code: 'config-missing',
1292
+ message: status.reason || '未检测到 config.toml',
1293
+ suggestion: '在模板编辑器中确认应用配置,生成可用的 config.toml'
1294
+ });
236
1295
  }
237
1296
 
238
- const content = typeof params.content === 'string' ? params.content : '';
239
- const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
240
- const normalized = normalizeLineEnding(content, lineEnding);
241
- const finalContent = ensureUtf8Bom(normalized);
1297
+ const providerName = typeof config.model_provider === 'string' ? config.model_provider.trim() : '';
1298
+ const modelName = typeof config.model === 'string' ? config.model.trim() : '';
1299
+ if (!providerName) {
1300
+ issues.push({
1301
+ code: 'provider-missing',
1302
+ message: '当前 provider 未设置',
1303
+ suggestion: '在模板中设置 model_provider'
1304
+ });
1305
+ }
242
1306
 
243
- try {
244
- fs.writeFileSync(filePath, finalContent, 'utf-8');
245
- return { success: true, path: filePath };
246
- } catch (e) {
247
- return { error: `写入 AGENTS.md 失败: ${e.message}` };
1307
+ if (!modelName) {
1308
+ issues.push({
1309
+ code: 'model-missing',
1310
+ message: '当前模型未设置',
1311
+ suggestion: '在模板中设置 model'
1312
+ });
248
1313
  }
249
- }
250
1314
 
251
- function readJsonObjectFromFile(filePath, fallback = {}) {
252
- if (!fs.existsSync(filePath)) {
253
- return { ok: true, exists: false, data: { ...fallback } };
1315
+ const providers = config.model_providers && typeof config.model_providers === 'object'
1316
+ ? config.model_providers
1317
+ : {};
1318
+ const provider = providerName ? providers[providerName] : null;
1319
+ if (providerName && !provider) {
1320
+ issues.push({
1321
+ code: 'provider-not-found',
1322
+ message: `当前 provider 未在配置中找到: ${providerName}`,
1323
+ suggestion: '检查 model_providers 是否包含该 provider 配置块'
1324
+ });
254
1325
  }
255
1326
 
256
- try {
257
- const content = fs.readFileSync(filePath, 'utf-8');
258
- if (!content.trim()) {
259
- return { ok: true, exists: true, data: { ...fallback } };
1327
+ if (provider && typeof provider === 'object') {
1328
+ const baseUrl = typeof provider.base_url === 'string' ? provider.base_url.trim() : '';
1329
+ if (!isValidHttpUrl(baseUrl)) {
1330
+ issues.push({
1331
+ code: 'base-url-invalid',
1332
+ message: '当前 provider 的 base_url 无效',
1333
+ suggestion: '请设置为 http/https 的完整 URL'
1334
+ });
260
1335
  }
261
1336
 
262
- const parsed = JSON.parse(content);
263
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
264
- return {
265
- ok: false,
266
- exists: true,
267
- error: `配置文件格式错误(根节点必须是对象): ${filePath}`
268
- };
1337
+ const requiresAuth = provider.requires_openai_auth;
1338
+ if (requiresAuth !== false) {
1339
+ const apiKey = typeof provider.preferred_auth_method === 'string'
1340
+ ? provider.preferred_auth_method.trim()
1341
+ : '';
1342
+ if (!apiKey) {
1343
+ issues.push({
1344
+ code: 'api-key-missing',
1345
+ message: '当前 provider 未配置 API Key',
1346
+ suggestion: '在模板中设置 preferred_auth_method'
1347
+ });
1348
+ }
269
1349
  }
270
- return { ok: true, exists: true, data: parsed };
271
- } catch (e) {
272
- return {
273
- ok: false,
274
- exists: true,
275
- error: `配置文件解析失败: ${e.message}`
276
- };
277
- }
278
- }
279
-
280
- function backupFileIfNeededOnce(filePath, backupPrefix = 'codexmate-backup') {
281
- if (!fs.existsSync(filePath)) {
282
- return '';
283
1350
  }
284
1351
 
285
- const dirPath = path.dirname(filePath);
286
- const baseName = path.basename(filePath);
287
- const existingPrefix = `${baseName}.${backupPrefix}-`;
288
- const hasBackup = fs.readdirSync(dirPath).some(fileName =>
289
- fileName.startsWith(existingPrefix) && fileName.endsWith('.bak')
290
- );
291
-
292
- if (hasBackup) {
293
- return '';
1352
+ if (modelName) {
1353
+ const models = readModels();
1354
+ if (!models.includes(modelName)) {
1355
+ issues.push({
1356
+ code: 'model-unavailable',
1357
+ message: `模型未在可用列表中找到: ${modelName}`,
1358
+ suggestion: '在模型列表中添加该模型或切换到已有模型'
1359
+ });
1360
+ }
294
1361
  }
295
1362
 
296
- const backupPath = path.join(dirPath, `${existingPrefix}${formatTimestampForFileName()}.bak`);
297
- fs.copyFileSync(filePath, backupPath);
298
- return backupPath;
299
- }
300
-
301
- function writeJsonAtomic(filePath, data) {
302
- const dirPath = path.dirname(filePath);
303
- ensureDir(dirPath);
304
-
305
- const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
306
- const content = `${JSON.stringify(data, null, 2)}\n`;
307
-
308
- try {
309
- fs.writeFileSync(tmpPath, content, 'utf-8');
310
- try {
311
- fs.renameSync(tmpPath, filePath);
312
- } catch (renameError) {
313
- if (process.platform === 'win32') {
314
- fs.copyFileSync(tmpPath, filePath);
315
- fs.unlinkSync(tmpPath);
316
- } else {
317
- throw renameError;
1363
+ const remoteEnabled = !!params.remote;
1364
+ let remote = null;
1365
+ if (remoteEnabled) {
1366
+ const baseUrl = provider && typeof provider.base_url === 'string' ? provider.base_url.trim() : '';
1367
+ if (!provider) {
1368
+ issues.push({
1369
+ code: 'remote-skip-provider',
1370
+ message: '无法进行远程探测:provider 未找到',
1371
+ suggestion: '检查 model_provider 配置或关闭远程探测'
1372
+ });
1373
+ } else if (!isValidHttpUrl(baseUrl)) {
1374
+ issues.push({
1375
+ code: 'remote-skip-base-url',
1376
+ message: '无法进行远程探测:base_url 无效',
1377
+ suggestion: '补全 base_url 或关闭远程探测'
1378
+ });
1379
+ } else {
1380
+ const timeoutMs = Number.isFinite(params.timeoutMs)
1381
+ ? Math.max(1000, Number(params.timeoutMs))
1382
+ : HEALTH_CHECK_TIMEOUT_MS;
1383
+ remote = await runRemoteHealthCheck(provider, modelName, { timeoutMs });
1384
+ if (remote && Array.isArray(remote.issues)) {
1385
+ issues.push(...remote.issues);
318
1386
  }
319
1387
  }
320
- } catch (e) {
321
- if (fs.existsSync(tmpPath)) {
322
- try { fs.unlinkSync(tmpPath); } catch (_) {}
323
- }
324
- throw new Error(`写入 JSON 文件失败: ${e.message}`);
325
1388
  }
1389
+
1390
+ return {
1391
+ ok: issues.length === 0,
1392
+ issues,
1393
+ summary: {
1394
+ currentProvider: providerName,
1395
+ currentModel: modelName
1396
+ },
1397
+ remote
1398
+ };
326
1399
  }
327
1400
 
328
1401
  function formatTimestampForFileName(value) {
@@ -530,6 +1603,8 @@ function applyConfigTemplate(params = {}) {
530
1603
  currentModels[activeProvider] = parsed.model;
531
1604
  writeCurrentModels(currentModels);
532
1605
 
1606
+ recordRecentConfig(activeProvider, parsed.model);
1607
+
533
1608
  return { success: true };
534
1609
  }
535
1610
 
@@ -651,9 +1726,24 @@ function consumeInitNotice() {
651
1726
  return notice;
652
1727
  }
653
1728
 
1729
+ function normalizePathForCompare(targetPath, options = {}) {
1730
+ const ignoreCase = !!options.ignoreCase;
1731
+ let resolved = '';
1732
+ try {
1733
+ resolved = fs.realpathSync.native ? fs.realpathSync.native(targetPath) : fs.realpathSync(targetPath);
1734
+ } catch (e) {
1735
+ resolved = path.resolve(targetPath);
1736
+ }
1737
+ return ignoreCase ? resolved.toLowerCase() : resolved;
1738
+ }
1739
+
654
1740
  function isPathInside(targetPath, rootPath) {
655
- const resolvedTarget = path.resolve(targetPath).toLowerCase();
656
- const resolvedRoot = path.resolve(rootPath).toLowerCase();
1741
+ if (!targetPath || !rootPath) {
1742
+ return false;
1743
+ }
1744
+ const ignoreCase = process.platform === 'win32';
1745
+ const resolvedTarget = normalizePathForCompare(targetPath, { ignoreCase });
1746
+ const resolvedRoot = normalizePathForCompare(rootPath, { ignoreCase });
657
1747
  if (resolvedTarget === resolvedRoot) {
658
1748
  return true;
659
1749
  }
@@ -1357,6 +2447,7 @@ function parseClaudeSessionSummary(filePath) {
1357
2447
  }
1358
2448
 
1359
2449
  function listCodexSessions(limit, options = {}) {
2450
+ const codexSessionsDir = getCodexSessionsDir();
1360
2451
  const scanFactor = Number.isFinite(Number(options.scanFactor))
1361
2452
  ? Math.max(1, Number(options.scanFactor))
1362
2453
  : SESSION_SCAN_FACTOR;
@@ -1372,7 +2463,7 @@ function listCodexSessions(limit, options = {}) {
1372
2463
  const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
1373
2464
  ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
1374
2465
  : Math.max(scanCount * 2, minFiles);
1375
- const files = collectRecentJsonlFiles(CODEX_SESSIONS_DIR, {
2466
+ const files = collectRecentJsonlFiles(codexSessionsDir, {
1376
2467
  returnCount: scanCount,
1377
2468
  maxFilesScanned
1378
2469
  });
@@ -1393,7 +2484,8 @@ function listCodexSessions(limit, options = {}) {
1393
2484
  }
1394
2485
 
1395
2486
  function listClaudeSessions(limit, options = {}) {
1396
- if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) {
2487
+ const claudeProjectsDir = getClaudeProjectsDir();
2488
+ if (!fs.existsSync(claudeProjectsDir)) {
1397
2489
  return [];
1398
2490
  }
1399
2491
 
@@ -1416,9 +2508,9 @@ function listClaudeSessions(limit, options = {}) {
1416
2508
  const sessions = [];
1417
2509
  let projectDirs = [];
1418
2510
  try {
1419
- projectDirs = fs.readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })
2511
+ projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
1420
2512
  .filter(entry => entry.isDirectory())
1421
- .map(entry => path.join(CLAUDE_PROJECTS_DIR, entry.name));
2513
+ .map(entry => path.join(claudeProjectsDir, entry.name));
1422
2514
  } catch (e) {
1423
2515
  projectDirs = [];
1424
2516
  }
@@ -1438,6 +2530,11 @@ function listClaudeSessions(limit, options = {}) {
1438
2530
  let filePath = typeof entry.fullPath === 'string' && entry.fullPath
1439
2531
  ? entry.fullPath
1440
2532
  : path.join(projectDir, `${sessionId}.jsonl`);
2533
+ filePath = expandHomePath(filePath);
2534
+ if (filePath && !path.isAbsolute(filePath)) {
2535
+ filePath = path.join(projectDir, filePath);
2536
+ }
2537
+ filePath = filePath ? path.resolve(filePath) : '';
1441
2538
 
1442
2539
  if (!fs.existsSync(filePath)) {
1443
2540
  continue;
@@ -1493,7 +2590,7 @@ function listClaudeSessions(limit, options = {}) {
1493
2590
  }
1494
2591
 
1495
2592
  if (sessions.length === 0) {
1496
- const fallbackFiles = collectRecentJsonlFiles(CLAUDE_PROJECTS_DIR, {
2593
+ const fallbackFiles = collectRecentJsonlFiles(claudeProjectsDir, {
1497
2594
  returnCount: scanCount,
1498
2595
  maxFilesScanned,
1499
2596
  ignoreSubPath: `${path.sep}subagents${path.sep}`
@@ -1625,14 +2722,15 @@ function listSessionPaths(params = {}) {
1625
2722
  }
1626
2723
 
1627
2724
  function resolveSessionFilePath(source, filePath, sessionId) {
1628
- const root = source === 'claude' ? CLAUDE_PROJECTS_DIR : CODEX_SESSIONS_DIR;
2725
+ const root = source === 'claude' ? getClaudeProjectsDir() : getCodexSessionsDir();
1629
2726
  if (!root || !fs.existsSync(root)) {
1630
2727
  return '';
1631
2728
  }
1632
2729
 
1633
2730
  if (typeof filePath === 'string' && filePath.trim()) {
1634
- const targetPath = path.resolve(filePath.trim());
1635
- if (fs.existsSync(targetPath) && isPathInside(targetPath, root)) {
2731
+ const expandedPath = expandHomePath(filePath.trim());
2732
+ const targetPath = expandedPath ? path.resolve(expandedPath) : '';
2733
+ if (targetPath && fs.existsSync(targetPath) && isPathInside(targetPath, root)) {
1636
2734
  return targetPath;
1637
2735
  }
1638
2736
  }
@@ -2161,18 +3259,263 @@ function runSpeedTest(targetUrl, apiKey) {
2161
3259
  // 命令
2162
3260
  // ============================================================================
2163
3261
 
3262
+ // 交互式配置向导
3263
+ async function cmdSetup() {
3264
+ console.log('\n交互式配置向导');
3265
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
3266
+ const lineQueue = [];
3267
+ let lineResolver = null;
3268
+ let rlClosed = false;
3269
+ rl.on('line', (line) => {
3270
+ if (lineResolver) {
3271
+ const resolve = lineResolver;
3272
+ lineResolver = null;
3273
+ resolve(line);
3274
+ } else {
3275
+ lineQueue.push(line);
3276
+ }
3277
+ });
3278
+ rl.on('close', () => {
3279
+ rlClosed = true;
3280
+ if (lineResolver) {
3281
+ const resolve = lineResolver;
3282
+ lineResolver = null;
3283
+ resolve('');
3284
+ }
3285
+ });
3286
+ const ask = async (question) => {
3287
+ if (question) {
3288
+ process.stdout.write(question);
3289
+ }
3290
+ if (lineQueue.length > 0) {
3291
+ return lineQueue.shift();
3292
+ }
3293
+ if (rlClosed) {
3294
+ return '';
3295
+ }
3296
+ return await new Promise(resolve => {
3297
+ lineResolver = resolve;
3298
+ });
3299
+ };
3300
+
3301
+ let providerName = '';
3302
+ let baseUrl = '';
3303
+ let apiKey = '';
3304
+ let modelName = '';
3305
+ let isCustomProvider = false;
3306
+
3307
+ try {
3308
+ const { config } = readConfigOrVirtualDefault();
3309
+ const providers = config.model_providers || {};
3310
+ const providerNames = Object.keys(providers);
3311
+ const defaultProvider = config.model_provider || providerNames[0] || '';
3312
+ let availableModels = [];
3313
+ let defaultModel = config.model || '';
3314
+ let modelFetchUnlimited = false;
3315
+
3316
+ while (true) {
3317
+ console.log('\n选择提供商:');
3318
+ if (providerNames.length > 0) {
3319
+ providerNames.forEach((name, index) => {
3320
+ console.log(` ${index + 1}. ${name}`);
3321
+ });
3322
+ console.log(` ${providerNames.length + 1}. 自定义`);
3323
+ } else {
3324
+ console.log(' (暂无提供商,需自定义)');
3325
+ }
3326
+
3327
+ const suffix = defaultProvider ? ` (默认 ${defaultProvider})` : '';
3328
+ const input = (await ask(`请输入序号或名称${suffix}: `)).trim();
3329
+
3330
+ if (!input) {
3331
+ if (defaultProvider) {
3332
+ providerName = defaultProvider;
3333
+ isCustomProvider = false;
3334
+ break;
3335
+ }
3336
+ isCustomProvider = true;
3337
+ break;
3338
+ }
3339
+
3340
+ if (/^\d+$/.test(input)) {
3341
+ const index = parseInt(input, 10);
3342
+ if (index >= 1 && index <= providerNames.length) {
3343
+ providerName = providerNames[index - 1];
3344
+ isCustomProvider = false;
3345
+ break;
3346
+ }
3347
+ if (index === providerNames.length + 1) {
3348
+ isCustomProvider = true;
3349
+ break;
3350
+ }
3351
+ console.log('提示: 序号无效,请重试。');
3352
+ continue;
3353
+ }
3354
+
3355
+ if (providers[input]) {
3356
+ providerName = input;
3357
+ isCustomProvider = false;
3358
+ break;
3359
+ }
3360
+
3361
+ if (isValidProviderName(input)) {
3362
+ providerName = input;
3363
+ isCustomProvider = true;
3364
+ break;
3365
+ }
3366
+
3367
+ console.log('提示: 名称仅支持字母/数字/._-');
3368
+ }
3369
+
3370
+ if (isCustomProvider && !providerName) {
3371
+ while (true) {
3372
+ const nameInput = (await ask('请输入自定义提供商名称(字母/数字/._-): ')).trim();
3373
+ if (!nameInput) {
3374
+ console.log('提示: 名称不能为空。');
3375
+ continue;
3376
+ }
3377
+ if (!isValidProviderName(nameInput)) {
3378
+ console.log('提示: 名称仅支持字母/数字/._-');
3379
+ continue;
3380
+ }
3381
+ providerName = nameInput;
3382
+ break;
3383
+ }
3384
+ }
3385
+
3386
+ if (isCustomProvider) {
3387
+ while (true) {
3388
+ const urlInput = (await ask('Base URL: ')).trim();
3389
+ if (!urlInput) {
3390
+ console.log('提示: Base URL 不能为空。');
3391
+ continue;
3392
+ }
3393
+ baseUrl = urlInput;
3394
+ break;
3395
+ }
3396
+ apiKey = (await ask('API Key (可空): ')).trim();
3397
+ }
3398
+
3399
+ let modelFetchError = '';
3400
+ if (providerName) {
3401
+ if (isCustomProvider) {
3402
+ const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
3403
+ if (res.unlimited) {
3404
+ modelFetchUnlimited = true;
3405
+ } else if (res.error) {
3406
+ modelFetchError = res.error;
3407
+ } else {
3408
+ availableModels = res.models || [];
3409
+ }
3410
+ } else {
3411
+ const res = await fetchProviderModels(providerName);
3412
+ if (res.unlimited) {
3413
+ modelFetchUnlimited = true;
3414
+ } else if (res.error) {
3415
+ modelFetchError = res.error;
3416
+ } else {
3417
+ availableModels = res.models || [];
3418
+ }
3419
+ }
3420
+ }
3421
+ if (modelFetchUnlimited) {
3422
+ console.log('提示: 提供商未提供模型列表,视为不限,请手动输入。');
3423
+ } else if (modelFetchError) {
3424
+ console.log(`提示: 获取模型列表失败: ${modelFetchError},请手动输入。`);
3425
+ }
3426
+ if (availableModels.length > 0) {
3427
+ if (!defaultModel || !availableModels.includes(defaultModel)) {
3428
+ defaultModel = availableModels[0];
3429
+ }
3430
+ }
3431
+
3432
+ while (true) {
3433
+ console.log('\n选择模型:');
3434
+ if (availableModels.length > 0) {
3435
+ availableModels.forEach((name, index) => {
3436
+ console.log(` ${index + 1}. ${name}`);
3437
+ });
3438
+ } else {
3439
+ console.log(' (暂无模型,将使用自定义输入)');
3440
+ }
3441
+
3442
+ const suffix = defaultModel ? ` (默认 ${defaultModel})` : '';
3443
+ const input = (await ask(`请输入序号或名称${suffix}: `)).trim();
3444
+
3445
+ if (!input) {
3446
+ if (defaultModel) {
3447
+ modelName = defaultModel;
3448
+ break;
3449
+ }
3450
+ console.log('提示: 模型不能为空。');
3451
+ continue;
3452
+ }
3453
+
3454
+ if (/^\d+$/.test(input)) {
3455
+ const index = parseInt(input, 10);
3456
+ if (index >= 1 && index <= availableModels.length) {
3457
+ modelName = availableModels[index - 1];
3458
+ break;
3459
+ }
3460
+ console.log('提示: 序号无效,请重试。');
3461
+ continue;
3462
+ }
3463
+
3464
+ modelName = input;
3465
+ break;
3466
+ }
3467
+
3468
+ console.log('\n即将应用:');
3469
+ console.log(' 提供商:', providerName);
3470
+ if (isCustomProvider) {
3471
+ console.log(' Base URL:', baseUrl);
3472
+ }
3473
+ console.log(' 模型:', modelName);
3474
+
3475
+ const confirm = (await ask('确认应用? (Y/n): ')).trim().toLowerCase();
3476
+ if (confirm === 'n' || confirm === 'no') {
3477
+ console.log('已取消');
3478
+ return;
3479
+ }
3480
+
3481
+ if (isCustomProvider) {
3482
+ if (providers[providerName]) {
3483
+ cmdUpdate(providerName, baseUrl, apiKey, true);
3484
+ } else {
3485
+ cmdAdd(providerName, baseUrl, apiKey, true);
3486
+ }
3487
+ }
3488
+
3489
+ const latestModels = readModels();
3490
+ if (modelName && !latestModels.includes(modelName)) {
3491
+ cmdAddModel(modelName, true);
3492
+ }
3493
+
3494
+ cmdSwitch(providerName, true);
3495
+ cmdUseModel(modelName, true);
3496
+
3497
+ console.log('✓ 已应用配置');
3498
+ console.log(' 提供商:', providerName);
3499
+ console.log(' 模型:', modelName);
3500
+ console.log();
3501
+ } catch (e) {
3502
+ console.error('错误:', e.message || e);
3503
+ process.exitCode = 1;
3504
+ } finally {
3505
+ rl.close();
3506
+ }
3507
+ }
3508
+
2164
3509
  // 显示当前状态
2165
3510
  function cmdStatus() {
2166
3511
  const { config, isVirtual } = readConfigOrVirtualDefault();
2167
3512
  const current = config.model_provider || '未设置';
2168
3513
  const currentModel = config.model || '未设置';
2169
- const models = readModels();
2170
- const currentModels = readCurrentModels();
2171
3514
 
2172
3515
  console.log('\n当前状态:');
2173
3516
  console.log(' 提供商:', current);
2174
3517
  console.log(' 模型:', currentModel);
2175
- console.log(' 模型列表:', models.length, '个');
3518
+ console.log(' 模型列表: 接口提供');
2176
3519
  if (isVirtual) {
2177
3520
  console.log(' 说明: 当前为虚拟默认配置(config.toml 尚未创建)');
2178
3521
  }
@@ -2212,21 +3555,30 @@ function cmdList() {
2212
3555
  }
2213
3556
 
2214
3557
  // 列出所有模型
2215
- function cmdModels() {
2216
- const models = readModels();
2217
- const currentModels = readCurrentModels();
2218
-
2219
- console.log('\n可用模型:');
2220
- models.forEach((m, i) => {
2221
- const users = Object.entries(currentModels)
2222
- .filter(([_, model]) => model === m)
2223
- .map(([name, _]) => name);
2224
- const usage = users.length > 0 ? users.join(', ') : '(未使用)';
2225
- console.log(` ${i + 1}. ${m}`);
2226
- if (users.length > 0) {
2227
- console.log(` → ${usage}`);
2228
- }
2229
- });
3558
+ async function cmdModels() {
3559
+ const res = await fetchProviderModels('');
3560
+ if (res.error) {
3561
+ console.error('错误: 获取模型列表失败:', res.error);
3562
+ process.exitCode = 1;
3563
+ return;
3564
+ }
3565
+ if (res.unlimited) {
3566
+ const label = res.provider ? ` (${res.provider})` : '';
3567
+ console.log(`\n可用模型${label}:`);
3568
+ console.log(' (接口未提供,视为不限)');
3569
+ console.log();
3570
+ return;
3571
+ }
3572
+ const models = Array.isArray(res.models) ? res.models : [];
3573
+ const label = res.provider ? ` (${res.provider})` : '';
3574
+ console.log(`\n可用模型${label}:`);
3575
+ if (models.length === 0) {
3576
+ console.log(' (空)');
3577
+ } else {
3578
+ models.forEach((m, i) => {
3579
+ console.log(` ${i + 1}. ${m}`);
3580
+ });
3581
+ }
2230
3582
  console.log();
2231
3583
  }
2232
3584
 
@@ -2271,19 +3623,20 @@ function cmdSwitch(providerName, silent = false) {
2271
3623
  console.log('✓ 当前模型:', targetModel);
2272
3624
  console.log();
2273
3625
  }
3626
+ recordRecentConfig(providerName, targetModel);
2274
3627
  return targetModel;
2275
3628
  }
2276
3629
 
2277
3630
  // 切换模型
2278
3631
  function cmdUseModel(modelName, silent = false) {
3632
+ if (!modelName) {
3633
+ if (!silent) console.error('错误: 模型名称必填');
3634
+ throw new Error('模型名称必填');
3635
+ }
2279
3636
  const models = readModels();
2280
3637
  if (!models.includes(modelName)) {
2281
- if (!silent) {
2282
- console.error('错误: 模型不存在:', modelName);
2283
- console.log('\n可用的模型:');
2284
- models.forEach(m => console.log(' -', m));
2285
- }
2286
- throw new Error('模型不存在');
3638
+ models.push(modelName);
3639
+ writeModels(models);
2287
3640
  }
2288
3641
 
2289
3642
  const config = readConfig();
@@ -2310,6 +3663,7 @@ function cmdUseModel(modelName, silent = false) {
2310
3663
  console.log('✓ 已切换模型:', modelName);
2311
3664
  console.log();
2312
3665
  }
3666
+ recordRecentConfig(currentProvider, modelName);
2313
3667
  }
2314
3668
 
2315
3669
  // 添加提供商
@@ -2523,52 +3877,6 @@ function maskKey(key) {
2523
3877
  return key.substring(0, 4) + '...' + key.substring(key.length - 4);
2524
3878
  }
2525
3879
 
2526
- // 应用到系统环境变量
2527
- function applyToSystemEnv(config = {}) {
2528
- try {
2529
- const apiKey = config.apiKey || '';
2530
-
2531
- // Windows 使用 setx 命令设置用户环境变量
2532
- if (process.platform === 'win32') {
2533
- const envVars = [
2534
- ['ANTHROPIC_API_KEY', apiKey],
2535
- ['ANTHROPIC_AUTH_TOKEN', apiKey],
2536
- ['ANTHROPIC_BASE_URL', config.baseUrl || 'https://open.bigmodel.cn/api/anthropic'],
2537
- ['CLAUDE_CODE_USE_KEY', '1'],
2538
- ['ANTHROPIC_MODEL', config.model || 'glm-4.7']
2539
- ];
2540
-
2541
- const errors = [];
2542
- for (const [key, value] of envVars) {
2543
- try {
2544
- // 转义值中的双引号,防止命令注入
2545
- const safeValue = value.replace(/"/g, '""');
2546
- execSync(`setx ${key} "${safeValue}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
2547
- } catch (e) {
2548
- errors.push(`${key}: ${e.message || '设置失败'}`);
2549
- }
2550
- }
2551
-
2552
- if (errors.length > 0) {
2553
- return {
2554
- success: false,
2555
- mode: 'env-vars',
2556
- error: `部分环境变量设置失败:\n${errors.join('\n')}`
2557
- };
2558
- }
2559
- return {
2560
- success: true,
2561
- mode: 'env-vars',
2562
- updatedKeys: envVars.map(([key]) => key)
2563
- };
2564
- } else {
2565
- return { success: false, mode: 'env-vars', error: '仅支持 Windows 系统' };
2566
- }
2567
- } catch (e) {
2568
- return { success: false, mode: 'env-vars', error: e.message };
2569
- }
2570
- }
2571
-
2572
3880
  // 应用到 Claude Code settings.json(跨平台)
2573
3881
  function applyToClaudeSettings(config = {}) {
2574
3882
  try {
@@ -2592,11 +3900,11 @@ function applyToClaudeSettings(config = {}) {
2592
3900
  const nextEnv = {
2593
3901
  ...currentEnv,
2594
3902
  ANTHROPIC_API_KEY: apiKey,
2595
- ANTHROPIC_AUTH_TOKEN: apiKey,
2596
3903
  ANTHROPIC_BASE_URL: baseUrl,
2597
- ANTHROPIC_MODEL: model,
2598
- CLAUDE_CODE_USE_KEY: '1'
3904
+ ANTHROPIC_MODEL: model
2599
3905
  };
3906
+ delete nextEnv.ANTHROPIC_AUTH_TOKEN;
3907
+ delete nextEnv.CLAUDE_CODE_USE_KEY;
2600
3908
 
2601
3909
  const nextSettings = {
2602
3910
  ...currentSettings,
@@ -2613,10 +3921,8 @@ function applyToClaudeSettings(config = {}) {
2613
3921
  targetPath: CLAUDE_SETTINGS_FILE,
2614
3922
  updatedKeys: [
2615
3923
  'env.ANTHROPIC_API_KEY',
2616
- 'env.ANTHROPIC_AUTH_TOKEN',
2617
3924
  'env.ANTHROPIC_BASE_URL',
2618
- 'env.ANTHROPIC_MODEL',
2619
- 'env.CLAUDE_CODE_USE_KEY'
3925
+ 'env.ANTHROPIC_MODEL'
2620
3926
  ]
2621
3927
  };
2622
3928
  if (backupPath) {
@@ -2845,7 +4151,31 @@ function cmdStart() {
2845
4151
  };
2846
4152
  break;
2847
4153
  case 'models':
2848
- result = { models: readModels() };
4154
+ {
4155
+ const providerName = params && typeof params.provider === 'string' ? params.provider : '';
4156
+ const res = await fetchProviderModels(providerName);
4157
+ if (res.error) {
4158
+ result = { error: res.error, models: [], source: 'remote' };
4159
+ } else if (res.unlimited) {
4160
+ result = { models: [], source: 'remote', provider: res.provider || '', unlimited: true };
4161
+ } else {
4162
+ result = { models: res.models || [], source: 'remote', provider: res.provider || '' };
4163
+ }
4164
+ }
4165
+ break;
4166
+ case 'models-by-url':
4167
+ {
4168
+ const baseUrl = params && typeof params.baseUrl === 'string' ? params.baseUrl : '';
4169
+ const apiKey = params && typeof params.apiKey === 'string' ? params.apiKey : '';
4170
+ const res = await fetchModelsFromBaseUrl(baseUrl, apiKey);
4171
+ if (res.error) {
4172
+ result = { error: res.error, models: [], source: 'remote' };
4173
+ } else if (res.unlimited) {
4174
+ result = { models: [], source: 'remote', unlimited: true };
4175
+ } else {
4176
+ result = { models: res.models || [], source: 'remote' };
4177
+ }
4178
+ }
2849
4179
  break;
2850
4180
  case 'get-config-template':
2851
4181
  result = getConfigTemplate(params || {});
@@ -2853,12 +4183,30 @@ function cmdStart() {
2853
4183
  case 'apply-config-template':
2854
4184
  result = applyConfigTemplate(params || {});
2855
4185
  break;
4186
+ case 'get-recent-configs':
4187
+ result = { items: readRecentConfigs() };
4188
+ break;
4189
+ case 'config-health-check':
4190
+ result = await buildConfigHealthReport(params || {});
4191
+ break;
2856
4192
  case 'get-agents-file':
2857
4193
  result = readAgentsFile(params || {});
2858
4194
  break;
2859
4195
  case 'apply-agents-file':
2860
4196
  result = applyAgentsFile(params || {});
2861
4197
  break;
4198
+ case 'get-openclaw-config':
4199
+ result = readOpenclawConfigFile();
4200
+ break;
4201
+ case 'apply-openclaw-config':
4202
+ result = applyOpenclawConfig(params || {});
4203
+ break;
4204
+ case 'get-openclaw-agents-file':
4205
+ result = readOpenclawAgentsFile();
4206
+ break;
4207
+ case 'apply-openclaw-agents-file':
4208
+ result = applyOpenclawAgentsFile(params || {});
4209
+ break;
2862
4210
  case 'switch':
2863
4211
  case 'use':
2864
4212
  case 'add':
@@ -2877,9 +4225,6 @@ function cmdStart() {
2877
4225
  case 'apply-claude-config':
2878
4226
  result = applyToClaudeSettings(params.config);
2879
4227
  break;
2880
- case 'apply-env':
2881
- result = applyToSystemEnv(params.config);
2882
- break;
2883
4228
  case 'export-config':
2884
4229
  result = {
2885
4230
  data: buildExportPayload(!!params.includeKeys)
@@ -2931,14 +4276,15 @@ function cmdStart() {
2931
4276
  }
2932
4277
  });
2933
4278
 
2934
- server.listen(PORT, () => {
2935
- console.log('\n✓ Web UI 已启动: http://localhost:' + PORT);
4279
+ const port = resolveWebPort();
4280
+ server.listen(port, () => {
4281
+ console.log('\n✓ Web UI 已启动: http://localhost:' + port);
2936
4282
  console.log(' 按 Ctrl+C 退出\n');
2937
4283
 
2938
4284
  // 打开浏览器
2939
4285
  const platform = process.platform;
2940
4286
  let command;
2941
- const url = `http://localhost:${PORT}`;
4287
+ const url = `http://localhost:${port}`;
2942
4288
 
2943
4289
  if (platform === 'win32') {
2944
4290
  command = `start "" "${url}"`;
@@ -2948,16 +4294,19 @@ function cmdStart() {
2948
4294
  command = `xdg-open "${url}"`;
2949
4295
  }
2950
4296
 
2951
- exec(command, (error) => {
2952
- if (error) console.warn('无法自动打开浏览器,请手动访问:', url);
2953
- });
4297
+ const disableBrowser = process.env.CODEXMATE_NO_BROWSER === '1';
4298
+ if (!disableBrowser) {
4299
+ exec(command, (error) => {
4300
+ if (error) console.warn('无法自动打开浏览器,请手动访问:', url);
4301
+ });
4302
+ }
2954
4303
  });
2955
4304
  }
2956
4305
 
2957
4306
  // ============================================================================
2958
4307
  // 主程序
2959
4308
  // ============================================================================
2960
- function main() {
4309
+ async function main() {
2961
4310
  const bootstrap = ensureManagedConfigBootstrap();
2962
4311
  if (bootstrap && bootstrap.notice) {
2963
4312
  console.log(`\n[Init] ${bootstrap.notice}`);
@@ -2968,6 +4317,7 @@ function main() {
2968
4317
  console.log('\nCodex Mate - Codex 提供商管理工具');
2969
4318
  console.log('\n用法:');
2970
4319
  console.log(' codexmate status 显示当前状态');
4320
+ console.log(' codexmate setup 交互式配置向导');
2971
4321
  console.log(' codexmate list 列出所有提供商');
2972
4322
  console.log(' codexmate models 列出所有模型');
2973
4323
  console.log(' codexmate switch <名称> 切换提供商');
@@ -2987,8 +4337,9 @@ function main() {
2987
4337
 
2988
4338
  switch (command) {
2989
4339
  case 'status': cmdStatus(); break;
4340
+ case 'setup': await cmdSetup(); break;
2990
4341
  case 'list': cmdList(); break;
2991
- case 'models': cmdModels(); break;
4342
+ case 'models': await cmdModels(); break;
2992
4343
  case 'switch': cmdSwitch(args[1]); break;
2993
4344
  case 'use': cmdUseModel(args[1]); break;
2994
4345
  case 'add': cmdAdd(args[1], args[2], args[3]); break;
@@ -3019,4 +4370,7 @@ function main() {
3019
4370
  }
3020
4371
  }
3021
4372
 
3022
- main();
4373
+ main().catch((err) => {
4374
+ console.error('错误:', err && err.message ? err.message : err);
4375
+ process.exit(1);
4376
+ });