Skip to main content

codex_helper_core/
codex_integration.rs

1use std::env;
2use std::fs;
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result, anyhow};
7use dirs::home_dir;
8use toml::Value;
9
10const ABSENT_BACKUP_SENTINEL: &str = "# codex-helper-backup:absent";
11
12fn codex_home() -> PathBuf {
13    if let Ok(dir) = env::var("CODEX_HOME") {
14        return PathBuf::from(dir);
15    }
16    home_dir()
17        .unwrap_or_else(|| PathBuf::from("."))
18        .join(".codex")
19}
20
21fn codex_config_path() -> PathBuf {
22    codex_home().join("config.toml")
23}
24
25fn codex_config_backup_path() -> PathBuf {
26    codex_home().join("config.toml.codex-helper-backup")
27}
28
29fn read_config_text(path: &PathBuf) -> Result<String> {
30    if !path.exists() {
31        return Ok(String::new());
32    }
33    let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
34    let mut buf = String::new();
35    file.read_to_string(&mut buf)
36        .with_context(|| format!("read {:?}", path))?;
37    Ok(buf)
38}
39
40fn atomic_write(path: &PathBuf, data: &str) -> Result<()> {
41    if let Some(parent) = path.parent() {
42        fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?;
43    }
44    let tmp = path.with_extension("tmp.codex-helper");
45    {
46        let mut f = fs::File::create(&tmp).with_context(|| format!("create {:?}", tmp))?;
47        f.write_all(data.as_bytes())
48            .with_context(|| format!("write {:?}", tmp))?;
49        f.sync_all().ok();
50    }
51    fs::rename(&tmp, path).with_context(|| format!("rename {:?} -> {:?}", tmp, path))?;
52    Ok(())
53}
54
55#[derive(Debug, Clone)]
56pub struct CodexSwitchStatus {
57    /// Whether Codex currently appears to be configured to use the local codex-helper proxy.
58    pub enabled: bool,
59    /// Current `model_provider` value (if any).
60    pub model_provider: Option<String>,
61    /// Current `model_providers.codex_proxy.base_url` (if any).
62    pub base_url: Option<String>,
63    /// Whether a backup file exists for safe restore.
64    pub has_backup: bool,
65}
66
67pub fn codex_switch_status() -> Result<CodexSwitchStatus> {
68    let cfg_path = codex_config_path();
69    let backup_path = codex_config_backup_path();
70
71    if !cfg_path.exists() {
72        return Ok(CodexSwitchStatus {
73            enabled: false,
74            model_provider: None,
75            base_url: None,
76            has_backup: backup_path.exists(),
77        });
78    }
79
80    let text = read_config_text(&cfg_path)?;
81    if text.trim().is_empty() {
82        return Ok(CodexSwitchStatus {
83            enabled: false,
84            model_provider: None,
85            base_url: None,
86            has_backup: backup_path.exists(),
87        });
88    }
89
90    let value: Value = match text.parse() {
91        Ok(v) => v,
92        Err(_) => {
93            return Ok(CodexSwitchStatus {
94                enabled: false,
95                model_provider: None,
96                base_url: None,
97                has_backup: backup_path.exists(),
98            });
99        }
100    };
101    let table = match value.as_table() {
102        Some(t) => t,
103        None => {
104            return Ok(CodexSwitchStatus {
105                enabled: false,
106                model_provider: None,
107                base_url: None,
108                has_backup: backup_path.exists(),
109            });
110        }
111    };
112
113    let model_provider = table
114        .get("model_provider")
115        .and_then(|v| v.as_str())
116        .map(|s| s.to_string());
117
118    if model_provider.as_deref() != Some("codex_proxy") {
119        return Ok(CodexSwitchStatus {
120            enabled: false,
121            model_provider,
122            base_url: None,
123            has_backup: backup_path.exists(),
124        });
125    }
126
127    let empty_map = toml::map::Map::new();
128    let providers_table = table
129        .get("model_providers")
130        .and_then(|v| v.as_table())
131        .unwrap_or(&empty_map);
132    let empty_provider = toml::map::Map::new();
133    let proxy_table = providers_table
134        .get("codex_proxy")
135        .and_then(|v| v.as_table())
136        .unwrap_or(&empty_provider);
137
138    let base_url = proxy_table
139        .get("base_url")
140        .and_then(|v| v.as_str())
141        .map(|s| s.to_string());
142    let name = proxy_table
143        .get("name")
144        .and_then(|v| v.as_str())
145        .unwrap_or_default();
146
147    let is_local = base_url
148        .as_deref()
149        .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
150    let is_helper_name = name == "codex-helper";
151
152    Ok(CodexSwitchStatus {
153        enabled: is_local || is_helper_name,
154        model_provider,
155        base_url,
156        has_backup: backup_path.exists(),
157    })
158}
159
160/// Switch Codex to use the local codex-helper model provider.
161pub fn switch_on(port: u16) -> Result<()> {
162    let cfg_path = codex_config_path();
163    let backup_path = codex_config_backup_path();
164
165    // Backup once if original exists and no backup yet.
166    if cfg_path.exists() && !backup_path.exists() {
167        fs::copy(&cfg_path, &backup_path)
168            .with_context(|| format!("backup {:?} -> {:?}", cfg_path, backup_path))?;
169    } else if !cfg_path.exists() && !backup_path.exists() {
170        // If Codex has no config.toml yet, create a sentinel backup so we can restore
171        // to the "absent" state on switch_off.
172        atomic_write(&backup_path, ABSENT_BACKUP_SENTINEL)?;
173    }
174
175    let text = read_config_text(&cfg_path)?;
176    let mut table: toml::Table = if text.trim().is_empty() {
177        toml::Table::new()
178    } else {
179        text.parse::<Value>()?
180            .as_table()
181            .cloned()
182            .ok_or_else(|| anyhow!("config.toml root must be table"))?
183    };
184
185    // Ensure [model_providers] table exists.
186    let providers = table
187        .entry("model_providers")
188        .or_insert_with(|| Value::Table(toml::Table::new()));
189
190    let providers_table = providers
191        .as_table_mut()
192        .ok_or_else(|| anyhow!("model_providers must be a table"))?;
193
194    let base_url = format!("http://127.0.0.1:{}", port);
195    let mut proxy_table = providers_table
196        .get("codex_proxy")
197        .and_then(|v| v.as_table())
198        .cloned()
199        .unwrap_or_else(toml::Table::new);
200    proxy_table.insert("name".into(), Value::String("codex-helper".into()));
201    proxy_table.insert("base_url".into(), Value::String(base_url));
202    proxy_table.insert("wire_api".into(), Value::String("responses".into()));
203    // Avoid double-retry (Codex retries + codex-helper retries) by default.
204    proxy_table
205        .entry("request_max_retries")
206        .or_insert(Value::Integer(0));
207
208    providers_table.insert("codex_proxy".into(), Value::Table(proxy_table));
209    table.insert("model_provider".into(), Value::String("codex_proxy".into()));
210
211    let new_text = toml::to_string_pretty(&table)?;
212    atomic_write(&cfg_path, &new_text)?;
213    Ok(())
214}
215
216/// Restore Codex config.toml from backup if present.
217pub fn switch_off() -> Result<()> {
218    let cfg_path = codex_config_path();
219    let backup_path = codex_config_backup_path();
220    if backup_path.exists() {
221        let text = read_config_text(&backup_path)?;
222        if text.trim() == ABSENT_BACKUP_SENTINEL {
223            if cfg_path.exists() {
224                fs::remove_file(&cfg_path)
225                    .with_context(|| format!("remove {:?} (restore absent)", cfg_path))?;
226            }
227        } else {
228            fs::copy(&backup_path, &cfg_path)
229                .with_context(|| format!("restore {:?} -> {:?}", backup_path, cfg_path))?;
230        }
231    }
232    Ok(())
233}
234
235#[derive(Debug, Clone)]
236pub struct ClaudeSwitchStatus {
237    /// Whether Claude Code currently appears to be configured to use the local codex-helper proxy.
238    pub enabled: bool,
239    /// Current `env.ANTHROPIC_BASE_URL` value (if any).
240    pub base_url: Option<String>,
241    /// Whether a backup file exists for safe restore.
242    pub has_backup: bool,
243    /// The resolved settings file path (settings.json or legacy claude.json).
244    pub settings_path: PathBuf,
245}
246
247pub fn claude_switch_status() -> Result<ClaudeSwitchStatus> {
248    let settings_path = claude_settings_path();
249    let backup_path = claude_settings_backup_path(&settings_path);
250
251    if !settings_path.exists() {
252        return Ok(ClaudeSwitchStatus {
253            enabled: false,
254            base_url: None,
255            has_backup: backup_path.exists(),
256            settings_path,
257        });
258    }
259
260    let text = read_settings_text(&settings_path)?;
261    if text.trim().is_empty() {
262        return Ok(ClaudeSwitchStatus {
263            enabled: false,
264            base_url: None,
265            has_backup: backup_path.exists(),
266            settings_path,
267        });
268    }
269
270    let value: serde_json::Value = match serde_json::from_str(&text) {
271        Ok(v) => v,
272        Err(_) => {
273            return Ok(ClaudeSwitchStatus {
274                enabled: false,
275                base_url: None,
276                has_backup: backup_path.exists(),
277                settings_path,
278            });
279        }
280    };
281
282    let env_obj = value
283        .as_object()
284        .and_then(|o| o.get("env"))
285        .and_then(|v| v.as_object());
286
287    let base_url = env_obj
288        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
289        .and_then(|v| v.as_str())
290        .map(|s| s.to_string());
291
292    let enabled = base_url
293        .as_deref()
294        .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
295
296    Ok(ClaudeSwitchStatus {
297        enabled,
298        base_url,
299        has_backup: backup_path.exists(),
300        settings_path,
301    })
302}
303
304/// 在再次切换到本地代理之前,对 Codex 配置做一次守护性检查:
305/// - 如发现已存在备份文件,且当前 model_provider 已指向本地代理(127.0.0.1 / codex-helper),
306///   则在交互式终端中询问用户是否先恢复原始配置;非交互环境下仅打印告警。
307pub fn guard_codex_config_before_switch_on_interactive() -> Result<()> {
308    use std::io::{self, Write};
309
310    let cfg_path = codex_config_path();
311    let backup_path = codex_config_backup_path();
312
313    if !cfg_path.exists() {
314        return Ok(());
315    }
316
317    let text = read_config_text(&cfg_path)?;
318    if text.trim().is_empty() {
319        return Ok(());
320    }
321
322    let value: Value = match text.parse() {
323        Ok(v) => v,
324        Err(_) => return Ok(()),
325    };
326    let table = match value.as_table() {
327        Some(t) => t,
328        None => return Ok(()),
329    };
330
331    let current_provider = table
332        .get("model_provider")
333        .and_then(|v| v.as_str())
334        .unwrap_or_default();
335    if current_provider != "codex_proxy" {
336        return Ok(());
337    }
338
339    let empty_map = toml::map::Map::new();
340    let providers_table = table
341        .get("model_providers")
342        .and_then(|v| v.as_table())
343        .unwrap_or(&empty_map);
344    let empty_provider = toml::map::Map::new();
345    let proxy_table = providers_table
346        .get("codex_proxy")
347        .and_then(|v| v.as_table())
348        .unwrap_or(&empty_provider);
349
350    let base_url = proxy_table
351        .get("base_url")
352        .and_then(|v| v.as_str())
353        .unwrap_or_default();
354    let name = proxy_table
355        .get("name")
356        .and_then(|v| v.as_str())
357        .unwrap_or_default();
358
359    // 仅当当前 provider 看起来是“本地 codex-helper 代理”时才触发守护逻辑。
360    let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
361    let is_helper_name = name == "codex-helper";
362    if !is_local && !is_helper_name {
363        return Ok(());
364    }
365
366    // 如果没有备份文件,无法安全恢复,只打印告警。
367    if !backup_path.exists() {
368        eprintln!(
369            "警告:检测到 Codex 当前 model_provider 指向本地地址 ({base_url}),\
370但未找到备份文件 {:?};如非预期,请手动检查 ~/.codex/config.toml。",
371            backup_path
372        );
373        return Ok(());
374    }
375
376    // 非交互环境:打印提示但不阻断。
377    let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
378    if !is_tty {
379        eprintln!(
380            "注意:检测到 Codex 当前已指向本地代理 codex-helper ({base_url}),\
381且存在备份文件 {:?};如需恢复原始配置,可运行 `codex-helper switch-off`。",
382            backup_path
383        );
384        return Ok(());
385    }
386
387    // 交互模式:询问是否先恢复原始配置。
388    eprintln!(
389        "检测到 Codex 当前已指向本地代理 codex-helper ({base_url}),且存在备份文件 {:?}。\n\
390这通常意味着上一次 codex-helper 未通过 switch-off 恢复配置。\n\
391是否现在恢复原始 Codex 配置? [Y/n] ",
392        backup_path
393    );
394    eprint!("> ");
395    io::stdout().flush().ok();
396
397    let mut input = String::new();
398    if let Err(err) = io::stdin().read_line(&mut input) {
399        eprintln!("读取输入失败:{err}");
400        return Ok(());
401    }
402    let answer = input.trim();
403    let yes =
404        answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
405
406    if yes {
407        if let Err(err) = switch_off() {
408            eprintln!("恢复 Codex 原始配置失败:{err}");
409        } else {
410            eprintln!("已根据备份恢复 Codex 原始配置。");
411        }
412    } else {
413        eprintln!("保留当前 Codex 配置不变。");
414    }
415
416    Ok(())
417}
418
419fn claude_home() -> PathBuf {
420    if let Ok(dir) = env::var("CLAUDE_HOME") {
421        return PathBuf::from(dir);
422    }
423    home_dir()
424        .unwrap_or_else(|| PathBuf::from("."))
425        .join(".claude")
426}
427
428fn claude_settings_path() -> PathBuf {
429    let dir = claude_home();
430    let settings = dir.join("settings.json");
431    if settings.exists() {
432        return settings;
433    }
434    let legacy = dir.join("claude.json");
435    if legacy.exists() {
436        return legacy;
437    }
438    settings
439}
440
441fn claude_settings_backup_path(path: &Path) -> PathBuf {
442    let mut backup = path.to_path_buf();
443    let file_name = backup
444        .file_name()
445        .map(|n| n.to_string_lossy().to_string())
446        .unwrap_or_else(|| "settings.json".to_string());
447    backup.set_file_name(format!("{file_name}.codex-helper-backup"));
448    backup
449}
450
451const CLAUDE_ABSENT_BACKUP_SENTINEL: &str = "{\"__codex_helper_backup_absent\":true}";
452
453fn read_settings_text(path: &Path) -> Result<String> {
454    if !path.exists() {
455        return Ok(String::new());
456    }
457    let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
458    let mut buf = String::new();
459    file.read_to_string(&mut buf)
460        .with_context(|| format!("read {:?}", path))?;
461    Ok(buf)
462}
463
464/// 将 Claude Code 的 settings.json 指向本地 codex-helper 代理(实验性)。
465pub fn claude_switch_on(port: u16) -> Result<()> {
466    let settings_path = claude_settings_path();
467    let backup_path = claude_settings_backup_path(&settings_path);
468
469    if settings_path.exists() && !backup_path.exists() {
470        fs::copy(&settings_path, &backup_path).with_context(|| {
471            format!(
472                "backup Claude settings {:?} -> {:?}",
473                settings_path, backup_path
474            )
475        })?;
476    } else if !settings_path.exists() && !backup_path.exists() {
477        // If Claude Code has no settings yet, create a sentinel backup so we can restore
478        // to the "absent" state on claude_switch_off.
479        if let Some(parent) = backup_path.parent() {
480            fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?;
481        }
482        fs::write(&backup_path, CLAUDE_ABSENT_BACKUP_SENTINEL)
483            .with_context(|| format!("write {:?}", backup_path))?;
484    }
485
486    let text = read_settings_text(&settings_path)?;
487    let mut value: serde_json::Value = if text.trim().is_empty() {
488        serde_json::json!({})
489    } else {
490        serde_json::from_str(&text).with_context(|| format!("parse {:?} as JSON", settings_path))?
491    };
492
493    let obj = value
494        .as_object_mut()
495        .ok_or_else(|| anyhow!("Claude settings root must be an object"))?;
496
497    let env_val = obj
498        .entry("env".to_string())
499        .or_insert_with(|| serde_json::json!({}));
500    let env_obj = env_val
501        .as_object_mut()
502        .ok_or_else(|| anyhow!("Claude settings env must be an object"))?;
503
504    let base_url = format!("http://127.0.0.1:{}", port);
505    env_obj.insert(
506        "ANTHROPIC_BASE_URL".to_string(),
507        serde_json::Value::String(base_url),
508    );
509
510    let new_text = serde_json::to_string_pretty(&value)?;
511    if let Some(parent) = settings_path.parent() {
512        fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?;
513    }
514    let tmp = settings_path.with_extension("tmp.codex-helper");
515    {
516        let mut f = fs::File::create(&tmp).with_context(|| format!("create {:?}", tmp))?;
517        f.write_all(new_text.as_bytes())
518            .with_context(|| format!("write {:?}", tmp))?;
519        f.sync_all().ok();
520    }
521    fs::rename(&tmp, &settings_path)
522        .with_context(|| format!("rename {:?} -> {:?}", tmp, settings_path))?;
523
524    eprintln!(
525        "[EXPERIMENTAL] Updated {:?} to use local Claude proxy via codex-helper",
526        settings_path
527    );
528    Ok(())
529}
530
531/// 从备份恢复 Claude settings.json(若存在)。
532pub fn claude_switch_off() -> Result<()> {
533    let settings_path = claude_settings_path();
534    let backup_path = claude_settings_backup_path(&settings_path);
535    if backup_path.exists() {
536        let text = read_settings_text(&backup_path)?;
537        if text.trim() == CLAUDE_ABSENT_BACKUP_SENTINEL {
538            if settings_path.exists() {
539                fs::remove_file(&settings_path)
540                    .with_context(|| format!("remove {:?} (restore absent)", settings_path))?;
541            }
542        } else {
543            fs::copy(&backup_path, &settings_path)
544                .with_context(|| format!("restore {:?} -> {:?}", backup_path, settings_path))?;
545            eprintln!(
546                "[EXPERIMENTAL] Restored Claude settings from backup {:?}",
547                backup_path
548            );
549        }
550    }
551    Ok(())
552}
553
554/// Claude settings Guard:在修改 settings.json 之前,如发现当前已指向本地代理且存在备份,
555/// 则在交互模式下询问是否先恢复;非交互环境仅打印提示。
556pub fn guard_claude_settings_before_switch_on_interactive() -> Result<()> {
557    use std::io::{self, Write};
558
559    let settings_path = claude_settings_path();
560    if !settings_path.exists() {
561        return Ok(());
562    }
563    let backup_path = claude_settings_backup_path(&settings_path);
564
565    let text = read_settings_text(&settings_path)?;
566    if text.trim().is_empty() {
567        return Ok(());
568    }
569
570    let value: serde_json::Value = match serde_json::from_str(&text) {
571        Ok(v) => v,
572        Err(_) => return Ok(()),
573    };
574    let obj = match value.as_object() {
575        Some(o) => o,
576        None => return Ok(()),
577    };
578    let env_obj = match obj.get("env").and_then(|v| v.as_object()) {
579        Some(e) => e,
580        None => return Ok(()),
581    };
582
583    let base_url = env_obj
584        .get("ANTHROPIC_BASE_URL")
585        .and_then(|v| v.as_str())
586        .unwrap_or_default();
587
588    let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
589    if !is_local {
590        return Ok(());
591    }
592
593    if !backup_path.exists() {
594        eprintln!(
595            "警告:检测到 Claude settings {:?} 的 ANTHROPIC_BASE_URL 指向本地地址 ({base_url}),\
596但未找到备份文件 {:?};如非预期,请手动检查该文件。",
597            settings_path, backup_path
598        );
599        return Ok(());
600    }
601
602    let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
603    if !is_tty {
604        eprintln!(
605            "注意:检测到 Claude settings {:?} 已指向本地代理 ({base_url}),且存在备份 {:?};\
606如需恢复原始配置,可运行 `codex-helper switch-off --claude`。",
607            settings_path, backup_path
608        );
609        return Ok(());
610    }
611
612    eprintln!(
613        "检测到 Claude settings {:?} 的 ANTHROPIC_BASE_URL 已指向本地代理 ({base_url}),且存在备份文件 {:?}。\n\
614这通常意味着上一次 codex-helper 未通过 switch-off --claude 恢复配置。\n\
615是否现在恢复原始 Claude settings? [Y/n] ",
616        settings_path, backup_path
617    );
618    eprint!("> ");
619    io::stdout().flush().ok();
620
621    let mut input = String::new();
622    if let Err(err) = io::stdin().read_line(&mut input) {
623        eprintln!("读取输入失败:{err}");
624        return Ok(());
625    }
626    let answer = input.trim();
627    let yes =
628        answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
629
630    if yes {
631        if let Err(err) = claude_switch_off() {
632            eprintln!("恢复 Claude settings 失败:{err}");
633        } else {
634            eprintln!("已根据备份恢复 Claude settings。");
635        }
636    } else {
637        eprintln!("保留当前 Claude settings 不变。");
638    }
639
640    Ok(())
641}