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