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 pub enabled: bool,
59 pub model_provider: Option<String>,
61 pub base_url: Option<String>,
63 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
160pub fn switch_on(port: u16) -> Result<()> {
162 let cfg_path = codex_config_path();
163 let backup_path = codex_config_backup_path();
164
165 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 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 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 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
216pub 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 pub enabled: bool,
239 pub base_url: Option<String>,
241 pub has_backup: bool,
243 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
304pub 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 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 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 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 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
464pub 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 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
531pub 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
554pub 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}