1use std::fs;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use crate::client_config::{
6 CLAUDE_ABSENT_BACKUP_SENTINEL, claude_settings_backup_path_for as claude_settings_backup_path,
7 claude_settings_path, codex_config_path, codex_switch_state_path,
8};
9use crate::file_replace::write_text_file;
10use anyhow::{Context, Result, anyhow};
11use toml::Value;
12use toml_edit::{
13 Document as EditableTomlDocument, Item as EditableTomlItem, Table as EditableTomlTable,
14 Value as EditableTomlValue, value as editable_toml_value,
15};
16
17fn read_config_text(path: &Path) -> Result<String> {
18 if !path.exists() {
19 return Ok(String::new());
20 }
21 let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
22 let mut buf = String::new();
23 file.read_to_string(&mut buf)
24 .with_context(|| format!("read {:?}", path))?;
25 Ok(buf)
26}
27
28fn atomic_write(path: &Path, data: &str) -> Result<()> {
29 write_text_file(path, data)
30}
31
32fn set_toml_value_preserving_decor(item: &mut EditableTomlItem, mut value: EditableTomlValue) {
33 if let Some(current) = item.as_value_mut() {
34 let decor = current.decor().clone();
35 *value.decor_mut() = decor;
36 *current = value;
37 } else {
38 *item = EditableTomlItem::Value(value);
39 }
40}
41
42fn set_toml_string(table: &mut EditableTomlTable, key: &str, value: impl Into<String>) {
43 let item = table.entry(key).or_insert(EditableTomlItem::None);
44 set_toml_value_preserving_decor(item, EditableTomlValue::from(value.into()));
45}
46
47fn toml_string(table: &EditableTomlTable, key: &str) -> Option<String> {
48 table
49 .get(key)
50 .and_then(EditableTomlItem::as_value)
51 .and_then(EditableTomlValue::as_str)
52 .map(ToOwned::to_owned)
53}
54
55fn local_helper_proxy_item(item: Option<&EditableTomlItem>) -> bool {
56 let Some(table) = item.and_then(EditableTomlItem::as_table) else {
57 return false;
58 };
59 let name_is_helper = toml_string(table, "name").as_deref() == Some("codex-helper");
60 let base_url_is_local = toml_string(table, "base_url")
61 .as_deref()
62 .is_some_and(|url| url.contains("127.0.0.1") || url.contains("localhost"));
63 name_is_helper || base_url_is_local
64}
65
66fn codex_text_points_to_local_helper(text: &str) -> Result<bool> {
67 if text.trim().is_empty() {
68 return Ok(false);
69 }
70 let doc = text.parse::<EditableTomlDocument>()?;
71 let root = doc.as_table();
72 if toml_string(root, "model_provider").as_deref() != Some("codex_proxy") {
73 return Ok(false);
74 }
75 Ok(root
76 .get("model_providers")
77 .and_then(EditableTomlItem::as_table)
78 .and_then(|table| table.get("codex_proxy"))
79 .is_some_and(|item| local_helper_proxy_item(Some(item))))
80}
81
82#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
83struct CodexSwitchState {
84 version: u32,
85 original_config_absent: bool,
86 original_model_provider: Option<String>,
87 original_codex_proxy: Option<Value>,
88 had_model_providers: bool,
89}
90
91impl CodexSwitchState {
92 fn from_codex_config_text(text: &str, original_config_absent: bool) -> Result<Self> {
93 let doc = if text.trim().is_empty() {
94 EditableTomlDocument::new()
95 } else {
96 text.parse::<EditableTomlDocument>()?
97 };
98 let root = doc.as_table();
99 let providers_table = root
100 .get("model_providers")
101 .and_then(EditableTomlItem::as_table);
102
103 Ok(Self {
104 version: 1,
105 original_config_absent,
106 original_model_provider: toml_string(root, "model_provider"),
107 original_codex_proxy: original_codex_proxy_value(text)?,
108 had_model_providers: providers_table.is_some(),
109 })
110 }
111}
112
113fn original_codex_proxy_value(text: &str) -> Result<Option<Value>> {
114 if text.trim().is_empty() {
115 return Ok(None);
116 }
117 let value = text.parse::<Value>()?;
118 Ok(value
119 .as_table()
120 .and_then(|root| root.get("model_providers"))
121 .and_then(Value::as_table)
122 .and_then(|providers| providers.get("codex_proxy"))
123 .cloned())
124}
125
126fn editable_item_from_toml_value(value: &Value) -> Result<EditableTomlItem> {
127 match value {
128 Value::Table(table) => {
129 let body = toml::to_string(table)?;
130 let doc = format!("[codex_proxy]\n{body}").parse::<EditableTomlDocument>()?;
131 doc.as_table()
132 .get("codex_proxy")
133 .cloned()
134 .ok_or_else(|| anyhow!("failed to parse stored codex_proxy state"))
135 }
136 _ => Err(anyhow!("stored codex_proxy state must be a TOML table")),
137 }
138}
139
140fn read_codex_switch_state() -> Result<Option<CodexSwitchState>> {
141 let path = codex_switch_state_path();
142 if !path.exists() {
143 return Ok(None);
144 }
145 let text = read_config_text(&path)?;
146 let state = serde_json::from_str::<CodexSwitchState>(&text)
147 .with_context(|| format!("parse {:?}", path))?;
148 Ok(Some(state))
149}
150
151fn write_codex_switch_state_if_absent(state: &CodexSwitchState) -> Result<()> {
152 let path = codex_switch_state_path();
153 if path.exists() {
154 return Ok(());
155 }
156 let text = serde_json::to_string_pretty(state)?;
157 atomic_write(&path, &text)
158}
159
160pub fn codex_switch_state_exists() -> bool {
161 codex_switch_state_path().exists()
162}
163
164enum CodexSwitchOffEdit {
165 Write(String),
166 RemoveFile,
167}
168
169fn switch_off_codex_toml(
170 current_text: &str,
171 original: &CodexSwitchState,
172) -> Result<CodexSwitchOffEdit> {
173 let mut doc = if current_text.trim().is_empty() {
174 EditableTomlDocument::new()
175 } else {
176 current_text.parse::<EditableTomlDocument>()?
177 };
178 let root = doc.as_table_mut();
179
180 let current_model_provider = toml_string(root, "model_provider");
181 let proxy_is_helper = root
182 .get("model_providers")
183 .and_then(EditableTomlItem::as_table)
184 .and_then(|table| table.get("codex_proxy"))
185 .map(|item| local_helper_proxy_item(Some(item)))
186 .unwrap_or(current_model_provider.as_deref() == Some("codex_proxy"));
187
188 if current_model_provider.as_deref() == Some("codex_proxy") && proxy_is_helper {
189 if let Some(provider) = original.original_model_provider.as_deref() {
190 set_toml_string(root, "model_provider", provider);
191 } else {
192 root.remove("model_provider");
193 }
194 }
195
196 let mut remove_model_providers = false;
197 if let Some(providers_table) = root
198 .get_mut("model_providers")
199 .and_then(EditableTomlItem::as_table_mut)
200 {
201 let proxy_is_helper = local_helper_proxy_item(providers_table.get("codex_proxy"));
202 if proxy_is_helper {
203 if let Some(original_proxy) = original.original_codex_proxy.as_ref() {
204 providers_table.insert(
205 "codex_proxy",
206 editable_item_from_toml_value(original_proxy)?,
207 );
208 } else {
209 providers_table.remove("codex_proxy");
210 }
211 }
212 remove_model_providers = !original.had_model_providers && providers_table.is_empty();
213 }
214 if remove_model_providers {
215 root.remove("model_providers");
216 }
217
218 if original.original_config_absent && root.is_empty() {
219 Ok(CodexSwitchOffEdit::RemoveFile)
220 } else {
221 Ok(CodexSwitchOffEdit::Write(doc.to_string()))
222 }
223}
224
225fn codex_config_text_with_switch_state(
226 current_text: &str,
227 state: &CodexSwitchState,
228) -> Result<String> {
229 let mut doc = if current_text.trim().is_empty() {
230 EditableTomlDocument::new()
231 } else {
232 current_text.parse::<EditableTomlDocument>()?
233 };
234 let root = doc.as_table_mut();
235 let current_model_provider = toml_string(root, "model_provider");
236 let proxy_is_helper = root
237 .get("model_providers")
238 .and_then(EditableTomlItem::as_table)
239 .and_then(|table| table.get("codex_proxy"))
240 .map(|item| local_helper_proxy_item(Some(item)))
241 .unwrap_or(current_model_provider.as_deref() == Some("codex_proxy"));
242
243 if current_model_provider.as_deref() != Some("codex_proxy") || !proxy_is_helper {
244 return Ok(current_text.to_string());
245 }
246
247 if let Some(provider) = state.original_model_provider.as_deref() {
248 set_toml_string(root, "model_provider", provider);
249 } else {
250 root.remove("model_provider");
251 }
252
253 let mut remove_model_providers = false;
254 if let Some(providers_table) = root
255 .get_mut("model_providers")
256 .and_then(EditableTomlItem::as_table_mut)
257 {
258 if let Some(original_proxy) = state.original_codex_proxy.as_ref() {
259 providers_table.insert(
260 "codex_proxy",
261 editable_item_from_toml_value(original_proxy)?,
262 );
263 } else {
264 providers_table.remove("codex_proxy");
265 }
266 remove_model_providers = !state.had_model_providers && providers_table.is_empty();
267 }
268 if remove_model_providers {
269 root.remove("model_providers");
270 }
271
272 Ok(doc.to_string())
273}
274
275pub fn codex_config_text_for_import() -> Result<Option<String>> {
276 let cfg_path = codex_config_path();
277 if !cfg_path.exists() {
278 return Ok(None);
279 }
280 let current_text = read_config_text(&cfg_path)?;
281 let Some(state) = read_codex_switch_state()? else {
282 return Ok(Some(current_text));
283 };
284 codex_config_text_with_switch_state(¤t_text, &state).map(Some)
285}
286
287fn switch_on_codex_toml(text: &str, port: u16) -> Result<String> {
288 let mut doc = if text.trim().is_empty() {
289 EditableTomlDocument::new()
290 } else {
291 text.parse::<EditableTomlDocument>()?
292 };
293 let root = doc.as_table_mut();
294
295 if !root.contains_key("model_providers") {
296 root.insert(
297 "model_providers",
298 EditableTomlItem::Table(EditableTomlTable::new()),
299 );
300 }
301 let providers_table = root
302 .get_mut("model_providers")
303 .and_then(EditableTomlItem::as_table_mut)
304 .ok_or_else(|| anyhow!("model_providers must be a table"))?;
305
306 if !providers_table.contains_key("codex_proxy") {
307 providers_table.insert(
308 "codex_proxy",
309 EditableTomlItem::Table(EditableTomlTable::new()),
310 );
311 }
312 let proxy_table = providers_table
313 .get_mut("codex_proxy")
314 .and_then(EditableTomlItem::as_table_mut)
315 .ok_or_else(|| anyhow!("model_providers.codex_proxy must be a table"))?;
316
317 set_toml_string(proxy_table, "name", "codex-helper");
318 set_toml_string(proxy_table, "base_url", format!("http://127.0.0.1:{port}"));
319 set_toml_string(proxy_table, "wire_api", "responses");
320 if !proxy_table.contains_key("request_max_retries") {
321 proxy_table.insert("request_max_retries", editable_toml_value(0));
322 }
323
324 set_toml_string(root, "model_provider", "codex_proxy");
325 Ok(doc.to_string())
326}
327
328#[derive(Debug, Clone)]
329pub struct CodexSwitchStatus {
330 pub enabled: bool,
332 pub model_provider: Option<String>,
334 pub base_url: Option<String>,
336 pub has_switch_state: bool,
338}
339
340pub fn codex_switch_status() -> Result<CodexSwitchStatus> {
341 let cfg_path = codex_config_path();
342 let state_path = codex_switch_state_path();
343
344 if !cfg_path.exists() {
345 return Ok(CodexSwitchStatus {
346 enabled: false,
347 model_provider: None,
348 base_url: None,
349 has_switch_state: state_path.exists(),
350 });
351 }
352
353 let text = read_config_text(&cfg_path)?;
354 if text.trim().is_empty() {
355 return Ok(CodexSwitchStatus {
356 enabled: false,
357 model_provider: None,
358 base_url: None,
359 has_switch_state: state_path.exists(),
360 });
361 }
362
363 let value: Value = match text.parse() {
364 Ok(v) => v,
365 Err(_) => {
366 return Ok(CodexSwitchStatus {
367 enabled: false,
368 model_provider: None,
369 base_url: None,
370 has_switch_state: state_path.exists(),
371 });
372 }
373 };
374 let table = match value.as_table() {
375 Some(t) => t,
376 None => {
377 return Ok(CodexSwitchStatus {
378 enabled: false,
379 model_provider: None,
380 base_url: None,
381 has_switch_state: state_path.exists(),
382 });
383 }
384 };
385
386 let model_provider = table
387 .get("model_provider")
388 .and_then(|v| v.as_str())
389 .map(|s| s.to_string());
390
391 if model_provider.as_deref() != Some("codex_proxy") {
392 return Ok(CodexSwitchStatus {
393 enabled: false,
394 model_provider,
395 base_url: None,
396 has_switch_state: state_path.exists(),
397 });
398 }
399
400 let empty_map = toml::map::Map::new();
401 let providers_table = table
402 .get("model_providers")
403 .and_then(|v| v.as_table())
404 .unwrap_or(&empty_map);
405 let empty_provider = toml::map::Map::new();
406 let proxy_table = providers_table
407 .get("codex_proxy")
408 .and_then(|v| v.as_table())
409 .unwrap_or(&empty_provider);
410
411 let base_url = proxy_table
412 .get("base_url")
413 .and_then(|v| v.as_str())
414 .map(|s| s.to_string());
415 let name = proxy_table
416 .get("name")
417 .and_then(|v| v.as_str())
418 .unwrap_or_default();
419
420 let is_local = base_url
421 .as_deref()
422 .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
423 let is_helper_name = name == "codex-helper";
424
425 Ok(CodexSwitchStatus {
426 enabled: is_local || is_helper_name,
427 model_provider,
428 base_url,
429 has_switch_state: state_path.exists(),
430 })
431}
432
433pub fn switch_on(port: u16) -> Result<()> {
435 let cfg_path = codex_config_path();
436 let state_path = codex_switch_state_path();
437 let text = read_config_text(&cfg_path)?;
438 if !state_path.exists() && codex_text_points_to_local_helper(&text)? {
439 return Err(anyhow!(
440 "Codex already points to the local codex-helper proxy, but no switch state was found at {:?}; refusing to treat the local proxy as the original provider. Please inspect ~/.codex/config.toml manually or run `codex-helper switch off` only if a switch state exists.",
441 state_path
442 ));
443 }
444 let state = CodexSwitchState::from_codex_config_text(&text, !cfg_path.exists())?;
445 write_codex_switch_state_if_absent(&state)?;
446 let new_text = switch_on_codex_toml(&text, port)?;
447 atomic_write(&cfg_path, &new_text)?;
448 Ok(())
449}
450
451pub fn switch_off() -> Result<()> {
453 let cfg_path = codex_config_path();
454 let state_path = codex_switch_state_path();
455 if state_path.exists() {
456 if !cfg_path.exists() {
457 fs::remove_file(&state_path)
458 .with_context(|| format!("remove stale switch state {:?}", state_path))?;
459 return Ok(());
460 }
461 let state = read_codex_switch_state()?.ok_or_else(|| {
462 anyhow!(
463 "missing Codex switch state at {:?}",
464 codex_switch_state_path()
465 )
466 })?;
467 let current_text = read_config_text(&cfg_path)?;
468 match switch_off_codex_toml(¤t_text, &state)? {
469 CodexSwitchOffEdit::RemoveFile => {
470 if cfg_path.exists() {
471 fs::remove_file(&cfg_path)
472 .with_context(|| format!("remove {:?} (restore absent)", cfg_path))?;
473 }
474 }
475 CodexSwitchOffEdit::Write(text) => {
476 atomic_write(&cfg_path, &text)
477 .with_context(|| format!("patch {:?} to disable local proxy", cfg_path))?;
478 }
479 }
480 fs::remove_file(&state_path)
481 .with_context(|| format!("remove stale switch state {:?}", state_path))?;
482 }
483 Ok(())
484}
485
486#[derive(Debug, Clone)]
487pub struct ClaudeSwitchStatus {
488 pub enabled: bool,
490 pub base_url: Option<String>,
492 pub has_backup: bool,
494 pub settings_path: PathBuf,
496}
497
498pub fn claude_switch_status() -> Result<ClaudeSwitchStatus> {
499 let settings_path = claude_settings_path();
500 let backup_path = claude_settings_backup_path(&settings_path);
501
502 if !settings_path.exists() {
503 return Ok(ClaudeSwitchStatus {
504 enabled: false,
505 base_url: None,
506 has_backup: backup_path.exists(),
507 settings_path,
508 });
509 }
510
511 let text = read_settings_text(&settings_path)?;
512 if text.trim().is_empty() {
513 return Ok(ClaudeSwitchStatus {
514 enabled: false,
515 base_url: None,
516 has_backup: backup_path.exists(),
517 settings_path,
518 });
519 }
520
521 let value: serde_json::Value = match serde_json::from_str(&text) {
522 Ok(v) => v,
523 Err(_) => {
524 return Ok(ClaudeSwitchStatus {
525 enabled: false,
526 base_url: None,
527 has_backup: backup_path.exists(),
528 settings_path,
529 });
530 }
531 };
532
533 let env_obj = value
534 .as_object()
535 .and_then(|o| o.get("env"))
536 .and_then(|v| v.as_object());
537
538 let base_url = env_obj
539 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
540 .and_then(|v| v.as_str())
541 .map(|s| s.to_string());
542
543 let enabled = base_url
544 .as_deref()
545 .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
546
547 Ok(ClaudeSwitchStatus {
548 enabled,
549 base_url,
550 has_backup: backup_path.exists(),
551 settings_path,
552 })
553}
554
555pub fn guard_codex_config_before_switch_on_interactive() -> Result<()> {
557 use std::io::{self, Write};
558
559 let cfg_path = codex_config_path();
560 let state_path = codex_switch_state_path();
561
562 if !cfg_path.exists() {
563 return Ok(());
564 }
565
566 let text = read_config_text(&cfg_path)?;
567 if text.trim().is_empty() {
568 return Ok(());
569 }
570
571 let value: Value = match text.parse() {
572 Ok(value) => value,
573 Err(_) => return Ok(()),
574 };
575 let table = match value.as_table() {
576 Some(table) => table,
577 None => return Ok(()),
578 };
579
580 let current_provider = table
581 .get("model_provider")
582 .and_then(|value| value.as_str())
583 .unwrap_or_default();
584 if current_provider != "codex_proxy" {
585 return Ok(());
586 }
587
588 let empty_map = toml::map::Map::new();
589 let providers_table = table
590 .get("model_providers")
591 .and_then(|value| value.as_table())
592 .unwrap_or(&empty_map);
593 let empty_provider = toml::map::Map::new();
594 let proxy_table = providers_table
595 .get("codex_proxy")
596 .and_then(|value| value.as_table())
597 .unwrap_or(&empty_provider);
598
599 let base_url = proxy_table
600 .get("base_url")
601 .and_then(|value| value.as_str())
602 .unwrap_or_default();
603 let name = proxy_table
604 .get("name")
605 .and_then(|value| value.as_str())
606 .unwrap_or_default();
607
608 let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
609 let is_helper_name = name == "codex-helper";
610 if !is_local && !is_helper_name {
611 return Ok(());
612 }
613
614 if !state_path.exists() {
615 eprintln!(
616 "Warning: Codex currently points to the local proxy ({base_url}), but no codex-helper switch state {:?} was found; please inspect ~/.codex/config.toml manually if this is unexpected.",
617 state_path
618 );
619 return Ok(());
620 }
621
622 let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
623 if !is_tty {
624 eprintln!(
625 "Notice: Codex currently points to local codex-helper ({base_url}) and switch state {:?} exists; run `codex-helper switch off` to disable the local proxy patch while preserving other config edits.",
626 state_path
627 );
628 return Ok(());
629 }
630
631 eprintln!(
632 "Codex currently points to local codex-helper ({base_url}), and switch state {:?} exists.\nThis usually means the previous run did not switch off cleanly.\nDisable the local proxy patch now while preserving other config edits? [Y/n] ",
633 state_path
634 );
635 eprint!("> ");
636 io::stdout().flush().ok();
637
638 let mut input = String::new();
639 if let Err(err) = io::stdin().read_line(&mut input) {
640 eprintln!("Failed to read input: {err}");
641 return Ok(());
642 }
643 let answer = input.trim();
644 let yes =
645 answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
646
647 if yes {
648 if let Err(err) = switch_off() {
649 eprintln!("Failed to disable local Codex proxy patch: {err}");
650 } else {
651 eprintln!("Disabled local Codex proxy patch.");
652 }
653 } else {
654 eprintln!("Keeping current Codex config unchanged.");
655 }
656
657 Ok(())
658}
659
660fn read_settings_text(path: &Path) -> Result<String> {
661 if !path.exists() {
662 return Ok(String::new());
663 }
664 let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
665 let mut buf = String::new();
666 file.read_to_string(&mut buf)
667 .with_context(|| format!("read {:?}", path))?;
668 Ok(buf)
669}
670
671pub fn claude_switch_on(port: u16) -> Result<()> {
672 let settings_path = claude_settings_path();
673 let backup_path = claude_settings_backup_path(&settings_path);
674
675 if settings_path.exists() && !backup_path.exists() {
676 fs::copy(&settings_path, &backup_path).with_context(|| {
677 format!(
678 "backup Claude settings {:?} -> {:?}",
679 settings_path, backup_path
680 )
681 })?;
682 } else if !settings_path.exists() && !backup_path.exists() {
683 if let Some(parent) = backup_path.parent() {
686 fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?;
687 }
688 fs::write(&backup_path, CLAUDE_ABSENT_BACKUP_SENTINEL)
689 .with_context(|| format!("write {:?}", backup_path))?;
690 }
691
692 let text = read_settings_text(&settings_path)?;
693 let mut value: serde_json::Value = if text.trim().is_empty() {
694 serde_json::json!({})
695 } else {
696 serde_json::from_str(&text).with_context(|| format!("parse {:?} as JSON", settings_path))?
697 };
698
699 let obj = value
700 .as_object_mut()
701 .ok_or_else(|| anyhow!("Claude settings root must be an object"))?;
702
703 let env_val = obj
704 .entry("env".to_string())
705 .or_insert_with(|| serde_json::json!({}));
706 let env_obj = env_val
707 .as_object_mut()
708 .ok_or_else(|| anyhow!("Claude settings env must be an object"))?;
709
710 let base_url = format!("http://127.0.0.1:{}", port);
711 env_obj.insert(
712 "ANTHROPIC_BASE_URL".to_string(),
713 serde_json::Value::String(base_url),
714 );
715
716 let new_text = serde_json::to_string_pretty(&value)?;
717 write_text_file(&settings_path, &new_text)
718 .with_context(|| format!("write {:?}", settings_path))?;
719
720 eprintln!(
721 "[EXPERIMENTAL] Updated {:?} to use local Claude proxy via codex-helper",
722 settings_path
723 );
724 Ok(())
725}
726
727pub fn claude_switch_off() -> Result<()> {
728 let settings_path = claude_settings_path();
729 let backup_path = claude_settings_backup_path(&settings_path);
730 if backup_path.exists() {
731 let text = read_settings_text(&backup_path)?;
732 if text.trim() == CLAUDE_ABSENT_BACKUP_SENTINEL {
733 if settings_path.exists() {
734 fs::remove_file(&settings_path)
735 .with_context(|| format!("remove {:?} (restore absent)", settings_path))?;
736 }
737 } else {
738 atomic_write(&settings_path, &text)
739 .with_context(|| format!("restore {:?} -> {:?}", backup_path, settings_path))?;
740 eprintln!(
741 "[EXPERIMENTAL] Restored Claude settings from backup {:?}",
742 backup_path
743 );
744 }
745 fs::remove_file(&backup_path)
746 .with_context(|| format!("remove stale backup {:?}", backup_path))?;
747 }
748 Ok(())
749}
750
751#[cfg(test)]
752#[allow(clippy::items_after_test_module)]
753mod tests {
754 use super::*;
755 use std::path::Path;
756 use std::sync::{Mutex, OnceLock};
757
758 struct ScopedEnv {
759 saved: Vec<(String, Option<String>)>,
760 }
761
762 impl ScopedEnv {
763 fn new() -> Self {
764 Self { saved: Vec::new() }
765 }
766
767 unsafe fn set_path(&mut self, key: &str, value: &Path) {
768 self.saved.push((key.to_string(), std::env::var(key).ok()));
769 unsafe { std::env::set_var(key, value) };
770 }
771 }
772
773 impl Drop for ScopedEnv {
774 fn drop(&mut self) {
775 for (key, old) in self.saved.drain(..).rev() {
776 unsafe {
777 match old {
778 Some(value) => std::env::set_var(&key, value),
779 None => std::env::remove_var(&key),
780 }
781 }
782 }
783 }
784 }
785
786 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
787 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
788 match LOCK.get_or_init(|| Mutex::new(())).lock() {
789 Ok(guard) => guard,
790 Err(err) => err.into_inner(),
791 }
792 }
793
794 struct TestEnv {
795 _lock: std::sync::MutexGuard<'static, ()>,
796 _env: ScopedEnv,
797 codex_home: PathBuf,
798 claude_home: PathBuf,
799 }
800
801 fn setup_temp_env() -> TestEnv {
802 let lock = env_lock();
803 let root =
804 std::env::temp_dir().join(format!("codex-helper-switch-test-{}", uuid::Uuid::new_v4()));
805 std::fs::create_dir_all(&root).expect("create temp root");
806
807 let codex_home = root.join(".codex");
808 let claude_home = root.join(".claude");
809 std::fs::create_dir_all(&codex_home).expect("create temp codex home");
810 std::fs::create_dir_all(&claude_home).expect("create temp claude home");
811
812 let mut scoped = ScopedEnv::new();
813 unsafe {
814 scoped.set_path("CODEX_HOME", &codex_home);
815 scoped.set_path("CLAUDE_HOME", &claude_home);
816 scoped.set_path("HOME", &root);
817 scoped.set_path("USERPROFILE", &root);
818 }
819
820 TestEnv {
821 _lock: lock,
822 _env: scoped,
823 codex_home,
824 claude_home,
825 }
826 }
827
828 fn write_file(path: &Path, content: &str) {
829 if let Some(parent) = path.parent() {
830 std::fs::create_dir_all(parent).expect("create parent directories");
831 }
832 std::fs::write(path, content).expect("write test file");
833 }
834
835 fn read_file(path: &Path) -> String {
836 std::fs::read_to_string(path).expect("read test file")
837 }
838
839 #[test]
840 fn codex_switch_on_preserves_unrelated_toml_comments_and_fields() {
841 let env = setup_temp_env();
842 let cfg_path = env.codex_home.join("config.toml");
843
844 let original = r#"# top comment
845model_provider = "openai"
846
847[model_providers.openai]
848# keep this comment
849name = "OpenAI"
850base_url = "https://api.openai.com/v1"
851request_max_retries = 3
852
853[projects."D:\\Work"]
854trust_level = "trusted"
855"#;
856
857 write_file(&cfg_path, original);
858 switch_on(3211).expect("switch_on should preserve editable TOML structure");
859
860 let updated = read_file(&cfg_path);
861 assert!(updated.contains("# top comment"));
862 assert!(updated.contains("# keep this comment"));
863 assert!(updated.contains("[model_providers.openai]"));
864 assert!(updated.contains("[projects."));
865 assert!(updated.contains("model_provider = \"codex_proxy\""));
866 assert!(updated.contains("[model_providers.codex_proxy]"));
867 assert!(updated.contains("base_url = \"http://127.0.0.1:3211\""));
868 }
869
870 #[test]
871 fn codex_switch_on_keeps_existing_proxy_retry_setting() {
872 let text = r#"
873model_provider = "codex_proxy"
874
875[model_providers.codex_proxy]
876name = "custom"
877base_url = "http://127.0.0.1:1111"
878request_max_retries = 5
879"#;
880
881 let updated = switch_on_codex_toml(text, 3333)
882 .expect("switch_on should update the local proxy provider in place");
883
884 assert!(updated.contains("request_max_retries = 5"));
885 assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
886 assert!(updated.contains("name = \"codex-helper\""));
887 }
888
889 #[test]
890 fn codex_switch_on_refuses_local_proxy_without_switch_state() {
891 let env = setup_temp_env();
892 let cfg_path = env.codex_home.join("config.toml");
893 let state_path = env.codex_home.join("codex-helper-switch-state.json");
894
895 write_file(
896 &cfg_path,
897 r#"
898model_provider = "codex_proxy"
899
900[model_providers.codex_proxy]
901name = "codex-helper"
902base_url = "http://127.0.0.1:3211"
903"#
904 .trim_start(),
905 );
906
907 let err = switch_on(3211).expect_err("switch_on should not snapshot a local proxy");
908 assert!(err.to_string().contains("no switch state was found"));
909 assert!(
910 !state_path.exists(),
911 "switch_on must not create state from an already-patched local proxy"
912 );
913 }
914
915 #[test]
916 fn codex_config_text_for_import_hides_proxy_created_from_absent_config() {
917 let env = setup_temp_env();
918 let cfg_path = env.codex_home.join("config.toml");
919
920 switch_on(3211).expect("switch_on should create config");
921 assert!(cfg_path.exists());
922
923 let import_text = codex_config_text_for_import()
924 .expect("read import view")
925 .expect("config exists");
926 assert!(
927 import_text.trim().is_empty(),
928 "import view should not expose helper proxy as a real upstream"
929 );
930 }
931
932 #[test]
933 fn codex_switch_off_clears_switch_state_and_refreshes_next_snapshot() {
934 let env = setup_temp_env();
935 let cfg_path = env.codex_home.join("config.toml");
936 let state_path = env.codex_home.join("codex-helper-switch-state.json");
937
938 let original = r#"
939model_provider = "openai"
940
941[model_providers.openai]
942name = "openai"
943base_url = "https://api.openai.com/v1"
944"#;
945 let updated = r#"
946model_provider = "packycode"
947
948[model_providers.packycode]
949name = "packycode"
950base_url = "https://codex-api.packycode.com/v1"
951"#;
952
953 write_file(&cfg_path, original.trim_start());
954 switch_on(3211).expect("first switch_on should succeed");
955 assert!(
956 state_path.exists(),
957 "switch state should exist while patched"
958 );
959 let state_text = read_file(&state_path);
960 assert!(state_text.contains("\"original_model_provider\": \"openai\""));
961 assert!(
962 !state_text.contains("api.openai.com"),
963 "switch state should not store the full Codex config"
964 );
965
966 switch_off().expect("first switch_off should succeed");
967 assert_eq!(read_file(&cfg_path), original.trim_start());
968 assert!(
969 !state_path.exists(),
970 "switch state should be removed after patch-off to avoid stale snapshots"
971 );
972
973 write_file(&cfg_path, updated.trim_start());
974 switch_on(3211).expect("second switch_on should succeed");
975 let state_text = read_file(&state_path);
976 assert!(state_text.contains("\"original_model_provider\": \"packycode\""));
977
978 switch_off().expect("second switch_off should succeed");
979 assert_eq!(read_file(&cfg_path), updated.trim_start());
980 assert!(
981 !state_path.exists(),
982 "switch state should be cleaned up after the second patch-off as well"
983 );
984 }
985
986 #[test]
987 fn codex_switch_off_preserves_codex_runtime_config_edits() {
988 let env = setup_temp_env();
989 let cfg_path = env.codex_home.join("config.toml");
990 let state_path = env.codex_home.join("codex-helper-switch-state.json");
991
992 let original = r#"
993model_provider = "openai"
994
995[model_providers.openai]
996name = "openai"
997base_url = "https://api.openai.com/v1"
998"#;
999
1000 write_file(&cfg_path, original.trim_start());
1001 switch_on(3211).expect("switch_on should succeed");
1002
1003 let mut during_run = read_file(&cfg_path);
1004 during_run.push_str(
1005 r#"
1006[projects."D:\\Projects\\rust\\codex-helper"]
1007trust_level = "trusted"
1008"#,
1009 );
1010 write_file(&cfg_path, &during_run);
1011
1012 switch_off().expect("switch_off should patch rather than restore whole file");
1013
1014 let updated = read_file(&cfg_path);
1015 assert!(updated.contains("model_provider = \"openai\""));
1016 assert!(updated.contains("[model_providers.openai]"));
1017 assert!(!updated.contains("[model_providers.codex_proxy]"));
1018 assert!(updated.contains("[projects."));
1019 assert!(updated.contains("trust_level = \"trusted\""));
1020 assert!(
1021 !state_path.exists(),
1022 "switch state should be removed after successful patch-off"
1023 );
1024 }
1025
1026 #[test]
1027 fn codex_switch_off_keeps_user_provider_change_made_during_run() {
1028 let env = setup_temp_env();
1029 let cfg_path = env.codex_home.join("config.toml");
1030
1031 let original = r#"
1032model_provider = "openai"
1033
1034[model_providers.openai]
1035name = "openai"
1036base_url = "https://api.openai.com/v1"
1037"#;
1038 let user_changed = r#"
1039model_provider = "packycode"
1040
1041[model_providers.openai]
1042name = "openai"
1043base_url = "https://api.openai.com/v1"
1044
1045[model_providers.codex_proxy]
1046name = "codex-helper"
1047base_url = "http://127.0.0.1:3211"
1048wire_api = "responses"
1049request_max_retries = 0
1050
1051[model_providers.packycode]
1052name = "packycode"
1053base_url = "https://codex-api.packycode.com/v1"
1054"#;
1055
1056 write_file(&cfg_path, original.trim_start());
1057 switch_on(3211).expect("switch_on should succeed");
1058 write_file(&cfg_path, user_changed.trim_start());
1059
1060 switch_off().expect("switch_off should not undo user's model_provider change");
1061
1062 let updated = read_file(&cfg_path);
1063 assert!(updated.contains("model_provider = \"packycode\""));
1064 assert!(updated.contains("[model_providers.packycode]"));
1065 assert!(!updated.contains("[model_providers.codex_proxy]"));
1066 }
1067
1068 #[test]
1069 fn codex_switch_off_preserves_new_config_when_original_was_absent() {
1070 let env = setup_temp_env();
1071 let cfg_path = env.codex_home.join("config.toml");
1072
1073 switch_on(3211).expect("switch_on should create config");
1074 let mut during_run = read_file(&cfg_path);
1075 during_run.push_str(
1076 r#"
1077[projects."D:\\Projects\\rust\\codex-helper"]
1078trust_level = "trusted"
1079"#,
1080 );
1081 write_file(&cfg_path, &during_run);
1082
1083 switch_off().expect("switch_off should remove only local proxy fields");
1084
1085 let updated = read_file(&cfg_path);
1086 assert!(!updated.contains("model_provider = \"codex_proxy\""));
1087 assert!(!updated.contains("[model_providers.codex_proxy]"));
1088 assert!(updated.contains("[projects."));
1089 assert!(updated.contains("trust_level = \"trusted\""));
1090 }
1091
1092 #[test]
1093 fn codex_switch_off_removes_empty_config_created_by_switch_on() {
1094 let env = setup_temp_env();
1095 let cfg_path = env.codex_home.join("config.toml");
1096
1097 switch_on(3211).expect("switch_on should create config");
1098 assert!(cfg_path.exists());
1099
1100 switch_off().expect("switch_off should restore absent config state");
1101
1102 assert!(
1103 !cfg_path.exists(),
1104 "config created only for the local proxy should be removed"
1105 );
1106 }
1107
1108 #[test]
1109 fn claude_switch_off_clears_backup_and_refreshes_next_snapshot() {
1110 let env = setup_temp_env();
1111 let settings_path = env.claude_home.join("settings.json");
1112 let backup_path = env.claude_home.join("settings.json.codex-helper-backup");
1113
1114 let original = r#"{
1115 "env": {
1116 "ANTHROPIC_BASE_URL": "https://api.anthropic.com/v1",
1117 "ANTHROPIC_API_KEY": "sk-ant-1"
1118 }
1119}"#;
1120 let updated = r#"{
1121 "env": {
1122 "ANTHROPIC_BASE_URL": "https://anthropic-proxy.example/v1",
1123 "ANTHROPIC_API_KEY": "sk-ant-2"
1124 }
1125}"#;
1126
1127 write_file(&settings_path, original);
1128 claude_switch_on(3211).expect("first claude_switch_on should succeed");
1129 assert!(
1130 backup_path.exists(),
1131 "backup should exist while switched on"
1132 );
1133
1134 claude_switch_off().expect("first claude_switch_off should succeed");
1135 assert_eq!(read_file(&settings_path), original);
1136 assert!(
1137 !backup_path.exists(),
1138 "backup should be removed after Claude restore to avoid stale snapshots"
1139 );
1140
1141 write_file(&settings_path, updated);
1142 claude_switch_on(3211).expect("second claude_switch_on should succeed");
1143 assert_eq!(read_file(&backup_path), updated);
1144
1145 claude_switch_off().expect("second claude_switch_off should succeed");
1146 assert_eq!(read_file(&settings_path), updated);
1147 assert!(
1148 !backup_path.exists(),
1149 "backup should be cleaned up after the second Claude restore as well"
1150 );
1151 }
1152}
1153
1154pub fn guard_claude_settings_before_switch_on_interactive() -> Result<()> {
1156 use std::io::{self, Write};
1157
1158 let settings_path = claude_settings_path();
1159 if !settings_path.exists() {
1160 return Ok(());
1161 }
1162 let backup_path = claude_settings_backup_path(&settings_path);
1163
1164 let text = read_settings_text(&settings_path)?;
1165 if text.trim().is_empty() {
1166 return Ok(());
1167 }
1168
1169 let value: serde_json::Value = match serde_json::from_str(&text) {
1170 Ok(value) => value,
1171 Err(_) => return Ok(()),
1172 };
1173 let obj = match value.as_object() {
1174 Some(obj) => obj,
1175 None => return Ok(()),
1176 };
1177 let env_obj = match obj.get("env").and_then(|value| value.as_object()) {
1178 Some(env_obj) => env_obj,
1179 None => return Ok(()),
1180 };
1181
1182 let base_url = env_obj
1183 .get("ANTHROPIC_BASE_URL")
1184 .and_then(|value| value.as_str())
1185 .unwrap_or_default();
1186
1187 let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
1188 if !is_local {
1189 return Ok(());
1190 }
1191
1192 if !backup_path.exists() {
1193 eprintln!(
1194 "Warning: Claude settings {:?} points ANTHROPIC_BASE_URL to a local address ({base_url}), but no backup file {:?} was found; please inspect this config file manually if this is unexpected.",
1195 settings_path, backup_path
1196 );
1197 return Ok(());
1198 }
1199
1200 let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
1201 if !is_tty {
1202 eprintln!(
1203 "Notice: Claude settings {:?} already points to the local proxy ({base_url}), and backup {:?} exists; run `codex-helper switch off --claude` if you want to restore the original config.",
1204 settings_path, backup_path
1205 );
1206 return Ok(());
1207 }
1208
1209 eprintln!(
1210 "Claude settings {:?} already points ANTHROPIC_BASE_URL to the local proxy ({base_url}), and backup {:?} exists.\nThis usually means the previous run did not switch off cleanly.\nRestore the original Claude settings now? [Y/n] ",
1211 settings_path, backup_path
1212 );
1213 eprint!("> ");
1214 io::stdout().flush().ok();
1215
1216 let mut input = String::new();
1217 if let Err(err) = io::stdin().read_line(&mut input) {
1218 eprintln!("Failed to read input: {err}");
1219 return Ok(());
1220 }
1221 let answer = input.trim();
1222 let yes =
1223 answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
1224
1225 if yes {
1226 if let Err(err) = claude_switch_off() {
1227 eprintln!("Failed to restore Claude settings: {err}");
1228 } else {
1229 eprintln!("Restored Claude settings from backup.");
1230 }
1231 } else {
1232 eprintln!("Keeping current Claude settings unchanged.");
1233 }
1234
1235 Ok(())
1236}