Skip to main content

codex_helper_core/
codex_integration.rs

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(&current_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    /// Whether Codex currently appears to be configured to use the local codex-helper proxy.
331    pub enabled: bool,
332    /// Current `model_provider` value (if any).
333    pub model_provider: Option<String>,
334    /// Current `model_providers.codex_proxy.base_url` (if any).
335    pub base_url: Option<String>,
336    /// Whether original switch metadata exists for disabling the local proxy patch.
337    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
433/// Switch Codex to use the local codex-helper model provider.
434pub 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
451/// Undo the local Codex proxy patch while preserving config edits made during the run.
452pub 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(&current_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    /// Whether Claude Code currently appears to be configured to use the local codex-helper proxy.
489    pub enabled: bool,
490    /// Current `env.ANTHROPIC_BASE_URL` value (if any).
491    pub base_url: Option<String>,
492    /// Whether a backup file exists for safe restore.
493    pub has_backup: bool,
494    /// The resolved settings file path (settings.json or legacy claude.json).
495    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
555/// Warn before replacing an existing local Codex proxy patch.
556pub 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 Claude Code has no settings yet, create a sentinel backup so we can restore
684        // to the "absent" state on claude_switch_off.
685        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
1154/// Warn before replacing an existing local Claude proxy patch.
1155pub 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}