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
23pub 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 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}