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 cleanup_stale_proxy_env(home: &Path) -> usize {
61 let cfg = crate::core::config::Config::load();
62 if cfg.proxy_enabled == Some(true) {
63 return 0;
64 }
65
66 let mut cleaned = 0;
67
68 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
69 let settings_path = settings_dir.join("settings.json");
70 if let Ok(content) = std::fs::read_to_string(&settings_path) {
71 if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&content) {
72 if let Some(base_url) = doc
73 .get("env")
74 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
75 .and_then(|v| v.as_str())
76 .map(String::from)
77 {
78 if is_local_lean_ctx_url(&base_url) {
79 if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
80 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
81 env_obj.insert(
82 "ANTHROPIC_BASE_URL".to_string(),
83 serde_json::Value::String(upstream.clone()),
84 );
85 println!(
86 " ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings"
87 );
88 } else {
89 env_obj.remove("ANTHROPIC_BASE_URL");
90 if env_obj.is_empty() {
91 doc.as_object_mut().map(|o| o.remove("env"));
92 }
93 println!(
94 " ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings"
95 );
96 }
97 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
98 let _ = std::fs::write(&settings_path, out + "\n");
99 cleaned += 1;
100 }
101 }
102 }
103 }
104 }
105
106 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
107 let codex_path = codex_dir.join("config.toml");
108 if let Ok(content) = std::fs::read_to_string(&codex_path) {
109 if content.contains("OPENAI_BASE_URL")
110 && (content.contains("127.0.0.1") || content.contains("localhost"))
111 {
112 let filtered: String = content
113 .lines()
114 .filter(|line| !line.trim().starts_with("OPENAI_BASE_URL"))
115 .collect::<Vec<_>>()
116 .join("\n");
117 let filtered = filtered
118 .replace("\n[env]\n\n", "\n")
119 .replace("[env]\n\n", "");
120 let filtered = if filtered.trim() == "[env]" {
121 String::new()
122 } else {
123 filtered
124 };
125 let _ = std::fs::write(&codex_path, &filtered);
126 println!(" ✓ Removed stale OPENAI_BASE_URL from Codex CLI config");
127 cleaned += 1;
128 }
129 }
130
131 cleaned
132}
133
134pub fn is_local_lean_ctx_url(url: &str) -> bool {
135 url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
136}
137
138pub fn has_stale_proxy_url(home: &Path) -> bool {
141 let cfg = crate::core::config::Config::load();
142 if cfg.proxy_enabled == Some(true) {
143 return false;
144 }
145
146 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
147 let settings_path = settings_dir.join("settings.json");
148 let Ok(content) = std::fs::read_to_string(&settings_path) else {
149 return false;
150 };
151 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&content) else {
152 return false;
153 };
154
155 let base_url = doc
156 .get("env")
157 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
158 .and_then(|v| v.as_str())
159 .unwrap_or("");
160
161 is_local_lean_ctx_url(base_url)
162}
163
164pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
165 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
166 let label = format!(
167 "proxy env from ~/{}",
168 rc.file_name().unwrap_or_default().to_string_lossy()
169 );
170 marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
171 }
172
173 let fish_config = home.join(".config/fish/config.fish");
174 if fish_config.exists() {
175 marked_block::remove_from_file(
176 &fish_config,
177 PROXY_ENV_START,
178 PROXY_ENV_END,
179 quiet,
180 "proxy env from ~/.config/fish/config.fish",
181 );
182 }
183
184 let ps_profile =
185 dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
186 if let Some(ref ps) = ps_profile {
187 if ps.exists() {
188 marked_block::remove_from_file(
189 ps,
190 PROXY_ENV_START,
191 PROXY_ENV_END,
192 quiet,
193 "proxy env from PowerShell profile",
194 );
195 }
196 }
197
198 uninstall_claude_env(home, quiet);
199 uninstall_codex_env(home, quiet);
200}
201
202fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
203 if !is_proxy_reachable(port) {
204 if !quiet {
205 println!(" Skipping shell proxy exports (proxy not running on port {port})");
206 }
207 return;
208 }
209
210 let base = format!("http://127.0.0.1:{port}");
211
212 let posix_block = format!(
213 r#"{PROXY_ENV_START}
214export GEMINI_API_BASE_URL="{base}"
215{PROXY_ENV_END}"#
216 );
217
218 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
219 if rc.exists() {
220 let label = format!(
221 "proxy env in ~/{}",
222 rc.file_name().unwrap_or_default().to_string_lossy()
223 );
224 marked_block::upsert(
225 rc,
226 PROXY_ENV_START,
227 PROXY_ENV_END,
228 &posix_block,
229 quiet,
230 &label,
231 );
232 }
233 }
234
235 let fish_config = home.join(".config/fish/config.fish");
236 if fish_config.exists() {
237 let fish_block = format!(
238 r#"{PROXY_ENV_START}
239set -gx GEMINI_API_BASE_URL "{base}"
240{PROXY_ENV_END}"#
241 );
242 marked_block::upsert(
243 &fish_config,
244 PROXY_ENV_START,
245 PROXY_ENV_END,
246 &fish_block,
247 quiet,
248 "proxy env in ~/.config/fish/config.fish",
249 );
250 }
251
252 let ps_profile =
253 dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
254 if let Some(ref ps) = ps_profile {
255 if ps.exists() {
256 let ps_block = format!(
257 r#"{PROXY_ENV_START}
258$env:GEMINI_API_BASE_URL = "{base}"
259{PROXY_ENV_END}"#
260 );
261 marked_block::upsert(
262 ps,
263 PROXY_ENV_START,
264 PROXY_ENV_END,
265 &ps_block,
266 quiet,
267 "proxy env in PowerShell profile",
268 );
269 }
270 }
271}
272
273fn uninstall_claude_env(home: &Path, quiet: bool) {
274 use crate::core::config::Config;
275
276 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
277 let settings_path = settings_dir.join("settings.json");
278 let existing = match std::fs::read_to_string(&settings_path) {
279 Ok(s) if !s.trim().is_empty() => s,
280 _ => return,
281 };
282 let mut doc: serde_json::Value = match serde_json::from_str(&existing) {
283 Ok(v) => v,
284 Err(_) => return,
285 };
286
287 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
288 return;
289 };
290
291 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
292 return;
293 }
294
295 let cfg = Config::load();
296 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
297 env_obj.insert(
298 "ANTHROPIC_BASE_URL".to_string(),
299 serde_json::Value::String(upstream.clone()),
300 );
301 if !quiet {
302 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
303 }
304 } else {
305 env_obj.remove("ANTHROPIC_BASE_URL");
306 if env_obj.is_empty() {
307 doc.as_object_mut().map(|o| o.remove("env"));
308 }
309 if !quiet {
310 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
311 }
312 }
313
314 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
315 let _ = std::fs::write(&settings_path, content + "\n");
316}
317
318fn uninstall_codex_env(home: &Path, quiet: bool) {
319 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
320 let config_path = codex_dir.join("config.toml");
321 let existing = match std::fs::read_to_string(&config_path) {
322 Ok(s) if !s.trim().is_empty() => s,
323 _ => return,
324 };
325
326 if !existing.contains("OPENAI_BASE_URL") {
327 return;
328 }
329
330 let cleaned: String = existing
331 .lines()
332 .filter(|line| {
333 let trimmed = line.trim();
334 !trimmed.starts_with("OPENAI_BASE_URL")
335 })
336 .collect::<Vec<_>>()
337 .join("\n");
338
339 let cleaned = cleaned
340 .replace("\n[env]\n\n", "\n")
341 .replace("[env]\n\n", "");
342 let cleaned = if cleaned.trim() == "[env]" {
343 String::new()
344 } else {
345 cleaned
346 };
347
348 let _ = std::fs::write(&config_path, &cleaned);
349 if !quiet {
350 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
351 }
352}
353
354fn install_claude_env(home: &Path, port: u16, quiet: bool) {
355 install_claude_env_inner(home, port, quiet, false);
356}
357
358fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
359 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
360
361 let base = format!("http://127.0.0.1:{port}");
362
363 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
364 let settings_path = settings_dir.join("settings.json");
365 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
366 let mut doc: serde_json::Value = if existing.trim().is_empty() {
367 serde_json::json!({})
368 } else {
369 match serde_json::from_str(&existing) {
370 Ok(v) => v,
371 Err(_) => return,
372 }
373 };
374
375 let current_url = doc
376 .get("env")
377 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
378 .and_then(|v| v.as_str())
379 .unwrap_or("");
380
381 if current_url == base {
382 if !quiet {
383 println!(" Claude Code proxy env already configured");
384 }
385 return;
386 }
387
388 if let Some(upstream) = normalize_url_opt(current_url) {
390 if !is_local_proxy_url(&upstream) {
391 let mut cfg = Config::load();
392 if cfg.proxy.anthropic_upstream.is_none() {
393 cfg.proxy.anthropic_upstream = Some(upstream.clone());
394 let _ = cfg.save();
395 }
396
397 if !force {
398 if !quiet {
399 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
400 eprintln!(
401 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
402 );
403 }
404 return;
405 }
406 if !quiet {
407 println!(" Overriding custom endpoint (--force): {upstream}");
408 }
409 }
410 }
411
412 if !is_proxy_reachable(port) {
413 if !quiet {
414 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
415 }
416 return;
417 }
418
419 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
420 o.entry("env")
421 .or_insert(serde_json::json!({}))
422 .as_object_mut()
423 }) {
424 env_obj.insert(
425 "ANTHROPIC_BASE_URL".to_string(),
426 serde_json::Value::String(base),
427 );
428 }
429
430 let _ = std::fs::create_dir_all(&settings_dir);
431 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
432 let _ = std::fs::write(&settings_path, content + "\n");
433 if !quiet {
434 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
435 }
436}
437
438fn is_proxy_reachable(port: u16) -> bool {
439 use std::net::TcpStream;
440 use std::time::Duration;
441 TcpStream::connect_timeout(
442 &format!("127.0.0.1:{port}")
443 .parse()
444 .expect("BUG: invalid hardcoded socket address"),
445 Duration::from_millis(200),
446 )
447 .is_ok()
448}
449
450fn install_codex_env(home: &Path, port: u16, quiet: bool) {
451 let base = format!("http://127.0.0.1:{port}");
452
453 if !is_proxy_reachable(port) {
454 if !quiet {
455 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
456 }
457 return;
458 }
459
460 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
461 let config_path = config_dir.join("config.toml");
462
463 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
464
465 if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
466 if !quiet {
467 println!(" Codex CLI proxy env already configured");
468 }
469 return;
470 }
471
472 if !config_dir.exists() {
473 return;
474 }
475
476 let mut content = existing;
477
478 if content.contains("[env]") {
479 if !content.contains("OPENAI_BASE_URL") {
480 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
481 }
482 } else {
483 if !content.is_empty() && !content.ends_with('\n') {
484 content.push('\n');
485 }
486 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
487 }
488
489 let _ = std::fs::write(&config_path, &content);
490 if !quiet {
491 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
492 }
493}
494
495pub fn default_port() -> u16 {
496 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
497 if let Ok(port) = val.parse::<u16>() {
498 return port;
499 }
500 }
501 let cfg = crate::core::config::Config::load();
502 if let Some(port) = cfg.proxy_port {
503 return port;
504 }
505 uid_based_port()
506}
507
508fn uid_based_port() -> u16 {
512 #[cfg(unix)]
513 {
514 let uid = unsafe { libc::getuid() } as u16;
515 let offset = uid.saturating_sub(1000) % 1000;
516 DEFAULT_PROXY_PORT + offset
517 }
518 #[cfg(not(unix))]
519 {
520 DEFAULT_PROXY_PORT
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 #[test]
529 fn uid_port_first_regular_user() {
530 assert_eq!(DEFAULT_PROXY_PORT, 4444);
532 }
533
534 #[test]
535 fn uid_port_no_overflow() {
536 let port = DEFAULT_PROXY_PORT + 999;
539 assert_eq!(port, 5443);
540 assert!(port < u16::MAX);
541 }
542
543 #[test]
544 fn uid_port_system_accounts_get_base() {
545 let uid: u16 = 500;
547 let offset = uid.saturating_sub(1000) % 1000;
548 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
549 }
550}