Skip to main content

lean_ctx/
proxy_setup.rs

1use std::path::Path;
2
3use crate::marked_block;
4
5const PROXY_ENV_START: &str = "# >>> lean-ctx proxy env >>>";
6const PROXY_ENV_END: &str = "# <<< lean-ctx proxy env <<<";
7
8const DEFAULT_PROXY_PORT: u16 = 4444;
9
10pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
11    install_shell_exports(home, port, quiet);
12    install_claude_env(home, port, quiet);
13    install_codex_env(home, port, quiet);
14}
15
16pub fn preview_proxy_cleanup(home: &Path) {
17    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
18    let settings_path = settings_dir.join("settings.json");
19    if let Ok(content) = std::fs::read_to_string(&settings_path) {
20        if content.contains("ANTHROPIC_BASE_URL") {
21            let cfg = crate::core::config::Config::load();
22            if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
23                println!("  Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
24            } else {
25                println!("  Would remove ANTHROPIC_BASE_URL from Claude Code settings");
26            }
27        }
28    }
29
30    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
31    let codex_path = codex_dir.join("config.toml");
32    if let Ok(content) = std::fs::read_to_string(codex_path) {
33        if content.contains("OPENAI_BASE_URL") {
34            println!("  Would remove OPENAI_BASE_URL from Codex CLI config");
35        }
36    }
37}
38
39pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
40    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
41        let label = format!(
42            "proxy env from ~/{}",
43            rc.file_name().unwrap_or_default().to_string_lossy()
44        );
45        marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
46    }
47
48    let fish_config = home.join(".config/fish/config.fish");
49    if fish_config.exists() {
50        marked_block::remove_from_file(
51            &fish_config,
52            PROXY_ENV_START,
53            PROXY_ENV_END,
54            quiet,
55            "proxy env from ~/.config/fish/config.fish",
56        );
57    }
58
59    let ps_profile =
60        dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
61    if let Some(ref ps) = ps_profile {
62        if ps.exists() {
63            marked_block::remove_from_file(
64                ps,
65                PROXY_ENV_START,
66                PROXY_ENV_END,
67                quiet,
68                "proxy env from PowerShell profile",
69            );
70        }
71    }
72
73    uninstall_claude_env(home, quiet);
74    uninstall_codex_env(home, quiet);
75}
76
77fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
78    if !is_proxy_reachable(port) {
79        if !quiet {
80            println!("  Skipping shell proxy exports (proxy not running on port {port})");
81        }
82        return;
83    }
84
85    let base = format!("http://127.0.0.1:{port}");
86
87    let posix_block = format!(
88        r#"{PROXY_ENV_START}
89export GEMINI_API_BASE_URL="{base}"
90{PROXY_ENV_END}"#
91    );
92
93    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
94        if rc.exists() {
95            let label = format!(
96                "proxy env in ~/{}",
97                rc.file_name().unwrap_or_default().to_string_lossy()
98            );
99            marked_block::upsert(
100                rc,
101                PROXY_ENV_START,
102                PROXY_ENV_END,
103                &posix_block,
104                quiet,
105                &label,
106            );
107        }
108    }
109
110    let fish_config = home.join(".config/fish/config.fish");
111    if fish_config.exists() {
112        let fish_block = format!(
113            r#"{PROXY_ENV_START}
114set -gx GEMINI_API_BASE_URL "{base}"
115{PROXY_ENV_END}"#
116        );
117        marked_block::upsert(
118            &fish_config,
119            PROXY_ENV_START,
120            PROXY_ENV_END,
121            &fish_block,
122            quiet,
123            "proxy env in ~/.config/fish/config.fish",
124        );
125    }
126
127    let ps_profile =
128        dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
129    if let Some(ref ps) = ps_profile {
130        if ps.exists() {
131            let ps_block = format!(
132                r#"{PROXY_ENV_START}
133$env:GEMINI_API_BASE_URL = "{base}"
134{PROXY_ENV_END}"#
135            );
136            marked_block::upsert(
137                ps,
138                PROXY_ENV_START,
139                PROXY_ENV_END,
140                &ps_block,
141                quiet,
142                "proxy env in PowerShell profile",
143            );
144        }
145    }
146}
147
148fn uninstall_claude_env(home: &Path, quiet: bool) {
149    use crate::core::config::Config;
150
151    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
152    let settings_path = settings_dir.join("settings.json");
153    let existing = match std::fs::read_to_string(&settings_path) {
154        Ok(s) if !s.trim().is_empty() => s,
155        _ => return,
156    };
157    let mut doc: serde_json::Value = match serde_json::from_str(&existing) {
158        Ok(v) => v,
159        Err(_) => return,
160    };
161
162    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
163        return;
164    };
165
166    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
167        return;
168    }
169
170    let cfg = Config::load();
171    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
172        env_obj.insert(
173            "ANTHROPIC_BASE_URL".to_string(),
174            serde_json::Value::String(upstream.clone()),
175        );
176        if !quiet {
177            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
178        }
179    } else {
180        env_obj.remove("ANTHROPIC_BASE_URL");
181        if env_obj.is_empty() {
182            doc.as_object_mut().map(|o| o.remove("env"));
183        }
184        if !quiet {
185            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
186        }
187    }
188
189    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
190    let _ = std::fs::write(&settings_path, content + "\n");
191}
192
193fn uninstall_codex_env(home: &Path, quiet: bool) {
194    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
195    let config_path = codex_dir.join("config.toml");
196    let existing = match std::fs::read_to_string(&config_path) {
197        Ok(s) if !s.trim().is_empty() => s,
198        _ => return,
199    };
200
201    if !existing.contains("OPENAI_BASE_URL") {
202        return;
203    }
204
205    let cleaned: String = existing
206        .lines()
207        .filter(|line| {
208            let trimmed = line.trim();
209            !trimmed.starts_with("OPENAI_BASE_URL")
210        })
211        .collect::<Vec<_>>()
212        .join("\n");
213
214    let cleaned = cleaned
215        .replace("\n[env]\n\n", "\n")
216        .replace("[env]\n\n", "");
217    let cleaned = if cleaned.trim() == "[env]" {
218        String::new()
219    } else {
220        cleaned
221    };
222
223    let _ = std::fs::write(&config_path, &cleaned);
224    if !quiet {
225        println!("  ✓ Removed OPENAI_BASE_URL from Codex CLI config");
226    }
227}
228
229fn install_claude_env(home: &Path, port: u16, quiet: bool) {
230    use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
231
232    let base = format!("http://127.0.0.1:{port}");
233
234    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
235    let settings_path = settings_dir.join("settings.json");
236    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
237    let mut doc: serde_json::Value = if existing.trim().is_empty() {
238        serde_json::json!({})
239    } else {
240        match serde_json::from_str(&existing) {
241            Ok(v) => v,
242            Err(_) => return,
243        }
244    };
245
246    let current_url = doc
247        .get("env")
248        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
249        .and_then(|v| v.as_str())
250        .unwrap_or("");
251
252    if current_url == base {
253        if !quiet {
254            println!("  Claude Code proxy env already configured");
255        }
256        return;
257    }
258
259    if let Some(upstream) = normalize_url_opt(current_url) {
260        if !is_local_proxy_url(&upstream) {
261            let mut cfg = Config::load();
262            if cfg.proxy.anthropic_upstream.is_none() {
263                cfg.proxy.anthropic_upstream = Some(upstream.clone());
264                let _ = cfg.save();
265                if !quiet {
266                    println!("  Preserved Claude Code upstream: {upstream}");
267                    println!("    → saved as proxy.anthropic_upstream in config");
268                }
269            }
270        }
271    }
272
273    if !is_proxy_reachable(port) {
274        if !quiet {
275            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
276        }
277        return;
278    }
279
280    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
281        o.entry("env")
282            .or_insert(serde_json::json!({}))
283            .as_object_mut()
284    }) {
285        env_obj.insert(
286            "ANTHROPIC_BASE_URL".to_string(),
287            serde_json::Value::String(base),
288        );
289    }
290
291    let _ = std::fs::create_dir_all(&settings_dir);
292    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
293    let _ = std::fs::write(&settings_path, content + "\n");
294    if !quiet {
295        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
296    }
297}
298
299fn is_proxy_reachable(port: u16) -> bool {
300    use std::net::TcpStream;
301    use std::time::Duration;
302    TcpStream::connect_timeout(
303        &format!("127.0.0.1:{port}")
304            .parse()
305            .expect("BUG: invalid hardcoded socket address"),
306        Duration::from_millis(200),
307    )
308    .is_ok()
309}
310
311fn install_codex_env(home: &Path, port: u16, quiet: bool) {
312    let base = format!("http://127.0.0.1:{port}");
313
314    if !is_proxy_reachable(port) {
315        if !quiet {
316            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
317        }
318        return;
319    }
320
321    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
322    let config_path = config_dir.join("config.toml");
323
324    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
325
326    if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
327        if !quiet {
328            println!("  Codex CLI proxy env already configured");
329        }
330        return;
331    }
332
333    if !config_dir.exists() {
334        return;
335    }
336
337    let mut content = existing;
338
339    if content.contains("[env]") {
340        if !content.contains("OPENAI_BASE_URL") {
341            content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
342        }
343    } else {
344        if !content.is_empty() && !content.ends_with('\n') {
345            content.push('\n');
346        }
347        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
348    }
349
350    let _ = std::fs::write(&config_path, &content);
351    if !quiet {
352        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
353    }
354}
355
356pub fn default_port() -> u16 {
357    DEFAULT_PROXY_PORT
358}