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    uninstall_claude_env(home, quiet);
48    uninstall_codex_env(home, quiet);
49}
50
51fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
52    if !is_proxy_reachable(port) {
53        if !quiet {
54            println!("  Skipping shell proxy exports (proxy not running on port {port})");
55        }
56        return;
57    }
58
59    let base = format!("http://127.0.0.1:{port}");
60
61    let block = format!(
62        r#"{PROXY_ENV_START}
63export GEMINI_API_BASE_URL="{base}"
64{PROXY_ENV_END}"#
65    );
66
67    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
68        if rc.exists() {
69            let label = format!(
70                "proxy env in ~/{}",
71                rc.file_name().unwrap_or_default().to_string_lossy()
72            );
73            marked_block::upsert(rc, PROXY_ENV_START, PROXY_ENV_END, &block, quiet, &label);
74        }
75    }
76}
77
78fn uninstall_claude_env(home: &Path, quiet: bool) {
79    use crate::core::config::Config;
80
81    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
82    let settings_path = settings_dir.join("settings.json");
83    let existing = match std::fs::read_to_string(&settings_path) {
84        Ok(s) if !s.trim().is_empty() => s,
85        _ => return,
86    };
87    let mut doc: serde_json::Value = match serde_json::from_str(&existing) {
88        Ok(v) => v,
89        Err(_) => return,
90    };
91
92    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
93        return;
94    };
95
96    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
97        return;
98    }
99
100    let cfg = Config::load();
101    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
102        env_obj.insert(
103            "ANTHROPIC_BASE_URL".to_string(),
104            serde_json::Value::String(upstream.clone()),
105        );
106        if !quiet {
107            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
108        }
109    } else {
110        env_obj.remove("ANTHROPIC_BASE_URL");
111        if env_obj.is_empty() {
112            doc.as_object_mut().map(|o| o.remove("env"));
113        }
114        if !quiet {
115            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
116        }
117    }
118
119    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
120    let _ = std::fs::write(&settings_path, content + "\n");
121}
122
123fn uninstall_codex_env(home: &Path, quiet: bool) {
124    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
125    let config_path = codex_dir.join("config.toml");
126    let existing = match std::fs::read_to_string(&config_path) {
127        Ok(s) if !s.trim().is_empty() => s,
128        _ => return,
129    };
130
131    if !existing.contains("OPENAI_BASE_URL") {
132        return;
133    }
134
135    let cleaned: String = existing
136        .lines()
137        .filter(|line| {
138            let trimmed = line.trim();
139            !trimmed.starts_with("OPENAI_BASE_URL")
140        })
141        .collect::<Vec<_>>()
142        .join("\n");
143
144    let cleaned = cleaned
145        .replace("\n[env]\n\n", "\n")
146        .replace("[env]\n\n", "");
147    let cleaned = if cleaned.trim() == "[env]" {
148        String::new()
149    } else {
150        cleaned
151    };
152
153    let _ = std::fs::write(&config_path, &cleaned);
154    if !quiet {
155        println!("  ✓ Removed OPENAI_BASE_URL from Codex CLI config");
156    }
157}
158
159fn install_claude_env(home: &Path, port: u16, quiet: bool) {
160    use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
161
162    let base = format!("http://127.0.0.1:{port}");
163
164    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
165    let settings_path = settings_dir.join("settings.json");
166    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
167    let mut doc: serde_json::Value = if existing.trim().is_empty() {
168        serde_json::json!({})
169    } else {
170        match serde_json::from_str(&existing) {
171            Ok(v) => v,
172            Err(_) => return,
173        }
174    };
175
176    let current_url = doc
177        .get("env")
178        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
179        .and_then(|v| v.as_str())
180        .unwrap_or("");
181
182    if current_url == base {
183        if !quiet {
184            println!("  Claude Code proxy env already configured");
185        }
186        return;
187    }
188
189    if let Some(upstream) = normalize_url_opt(current_url) {
190        if !is_local_proxy_url(&upstream) {
191            let mut cfg = Config::load();
192            if cfg.proxy.anthropic_upstream.is_none() {
193                cfg.proxy.anthropic_upstream = Some(upstream.clone());
194                let _ = cfg.save();
195                if !quiet {
196                    println!("  Preserved Claude Code upstream: {upstream}");
197                    println!("    → saved as proxy.anthropic_upstream in config");
198                }
199            }
200        }
201    }
202
203    if !is_proxy_reachable(port) {
204        if !quiet {
205            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
206        }
207        return;
208    }
209
210    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
211        o.entry("env")
212            .or_insert(serde_json::json!({}))
213            .as_object_mut()
214    }) {
215        env_obj.insert(
216            "ANTHROPIC_BASE_URL".to_string(),
217            serde_json::Value::String(base),
218        );
219    }
220
221    let _ = std::fs::create_dir_all(&settings_dir);
222    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
223    let _ = std::fs::write(&settings_path, content + "\n");
224    if !quiet {
225        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
226    }
227}
228
229fn is_proxy_reachable(port: u16) -> bool {
230    use std::net::TcpStream;
231    use std::time::Duration;
232    TcpStream::connect_timeout(
233        &format!("127.0.0.1:{port}")
234            .parse()
235            .expect("BUG: invalid hardcoded socket address"),
236        Duration::from_millis(200),
237    )
238    .is_ok()
239}
240
241fn install_codex_env(home: &Path, port: u16, quiet: bool) {
242    let base = format!("http://127.0.0.1:{port}");
243
244    if !is_proxy_reachable(port) {
245        if !quiet {
246            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
247        }
248        return;
249    }
250
251    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
252    let config_path = config_dir.join("config.toml");
253
254    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
255
256    if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
257        if !quiet {
258            println!("  Codex CLI proxy env already configured");
259        }
260        return;
261    }
262
263    if !config_dir.exists() {
264        return;
265    }
266
267    let mut content = existing;
268
269    if content.contains("[env]") {
270        if !content.contains("OPENAI_BASE_URL") {
271            content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
272        }
273    } else {
274        if !content.is_empty() && !content.ends_with('\n') {
275            content.push('\n');
276        }
277        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
278    }
279
280    let _ = std::fs::write(&config_path, &content);
281    if !quiet {
282        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
283    }
284}
285
286pub fn default_port() -> u16 {
287    DEFAULT_PROXY_PORT
288}