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) = crate::core::jsonc::parse_jsonc(&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) = crate::core::jsonc::parse_jsonc(&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 ANTHROPIC_BASE_URL="{base}"
215export OPENAI_BASE_URL="{base}"
216export GEMINI_API_BASE_URL="{base}"
217{PROXY_ENV_END}"#
218 );
219
220 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
221 if rc.exists() {
222 let label = format!(
223 "proxy env in ~/{}",
224 rc.file_name().unwrap_or_default().to_string_lossy()
225 );
226 marked_block::upsert(
227 rc,
228 PROXY_ENV_START,
229 PROXY_ENV_END,
230 &posix_block,
231 quiet,
232 &label,
233 );
234 }
235 }
236
237 let fish_config = home.join(".config/fish/config.fish");
238 if fish_config.exists() {
239 let fish_block = format!(
240 r#"{PROXY_ENV_START}
241set -gx ANTHROPIC_BASE_URL "{base}"
242set -gx OPENAI_BASE_URL "{base}"
243set -gx GEMINI_API_BASE_URL "{base}"
244{PROXY_ENV_END}"#
245 );
246 marked_block::upsert(
247 &fish_config,
248 PROXY_ENV_START,
249 PROXY_ENV_END,
250 &fish_block,
251 quiet,
252 "proxy env in ~/.config/fish/config.fish",
253 );
254 }
255
256 let ps_profile =
257 dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
258 if let Some(ref ps) = ps_profile {
259 if ps.exists() {
260 let ps_block = format!(
261 r#"{PROXY_ENV_START}
262$env:ANTHROPIC_BASE_URL = "{base}"
263$env:OPENAI_BASE_URL = "{base}"
264$env:GEMINI_API_BASE_URL = "{base}"
265{PROXY_ENV_END}"#
266 );
267 marked_block::upsert(
268 ps,
269 PROXY_ENV_START,
270 PROXY_ENV_END,
271 &ps_block,
272 quiet,
273 "proxy env in PowerShell profile",
274 );
275 }
276 }
277}
278
279fn uninstall_claude_env(home: &Path, quiet: bool) {
280 use crate::core::config::Config;
281
282 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
283 let settings_path = settings_dir.join("settings.json");
284 let existing = match std::fs::read_to_string(&settings_path) {
285 Ok(s) if !s.trim().is_empty() => s,
286 _ => return,
287 };
288 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
289 Ok(v) => v,
290 Err(_) => return,
291 };
292
293 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
294 return;
295 };
296
297 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
298 return;
299 }
300
301 let cfg = Config::load();
302 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
303 env_obj.insert(
304 "ANTHROPIC_BASE_URL".to_string(),
305 serde_json::Value::String(upstream.clone()),
306 );
307 if !quiet {
308 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
309 }
310 } else {
311 env_obj.remove("ANTHROPIC_BASE_URL");
312 if env_obj.is_empty() {
313 doc.as_object_mut().map(|o| o.remove("env"));
314 }
315 if !quiet {
316 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
317 }
318 }
319
320 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
321 let _ = std::fs::write(&settings_path, content + "\n");
322}
323
324fn uninstall_codex_env(home: &Path, quiet: bool) {
325 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
326 let config_path = codex_dir.join("config.toml");
327 let existing = match std::fs::read_to_string(&config_path) {
328 Ok(s) if !s.trim().is_empty() => s,
329 _ => return,
330 };
331
332 if !existing.contains("OPENAI_BASE_URL") {
333 return;
334 }
335
336 let cleaned: String = existing
337 .lines()
338 .filter(|line| {
339 let trimmed = line.trim();
340 !trimmed.starts_with("OPENAI_BASE_URL")
341 })
342 .collect::<Vec<_>>()
343 .join("\n");
344
345 let cleaned = cleaned
346 .replace("\n[env]\n\n", "\n")
347 .replace("[env]\n\n", "");
348 let cleaned = if cleaned.trim() == "[env]" {
349 String::new()
350 } else {
351 cleaned
352 };
353
354 let _ = std::fs::write(&config_path, &cleaned);
355 if !quiet {
356 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
357 }
358}
359
360fn install_claude_env(home: &Path, port: u16, quiet: bool) {
361 install_claude_env_inner(home, port, quiet, false);
362}
363
364fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
365 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
366
367 let base = format!("http://127.0.0.1:{port}");
368
369 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
370 let settings_path = settings_dir.join("settings.json");
371 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
372 let mut doc: serde_json::Value = if existing.trim().is_empty() {
373 serde_json::json!({})
374 } else {
375 match crate::core::jsonc::parse_jsonc(&existing) {
376 Ok(v) => v,
377 Err(_) => return,
378 }
379 };
380
381 let current_url = doc
382 .get("env")
383 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
384 .and_then(|v| v.as_str())
385 .unwrap_or("");
386
387 if current_url == base {
388 if !quiet {
389 println!(" Claude Code proxy env already configured");
390 }
391 return;
392 }
393
394 if let Some(upstream) = normalize_url_opt(current_url) {
396 if !is_local_proxy_url(&upstream) {
397 let mut cfg = Config::load();
398 if cfg.proxy.anthropic_upstream.is_none() {
399 cfg.proxy.anthropic_upstream = Some(upstream.clone());
400 let _ = cfg.save();
401 }
402
403 if !force {
404 if !quiet {
405 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
406 eprintln!(
407 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
408 );
409 }
410 return;
411 }
412 if !quiet {
413 println!(" Overriding custom endpoint (--force): {upstream}");
414 }
415 }
416 }
417
418 if !is_proxy_reachable(port) {
419 if !quiet {
420 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
421 }
422 return;
423 }
424
425 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
426 o.entry("env")
427 .or_insert(serde_json::json!({}))
428 .as_object_mut()
429 }) {
430 env_obj.insert(
431 "ANTHROPIC_BASE_URL".to_string(),
432 serde_json::Value::String(base),
433 );
434 }
435
436 let _ = std::fs::create_dir_all(&settings_dir);
437 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
438 let _ = std::fs::write(&settings_path, content + "\n");
439 if !quiet {
440 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
441 }
442}
443
444pub fn proxy_timeout() -> std::time::Duration {
446 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
447 if let Ok(ms) = val.parse::<u64>() {
448 return std::time::Duration::from_millis(ms);
449 }
450 }
451 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
452 return std::time::Duration::from_millis(ms);
453 }
454 std::time::Duration::from_millis(200)
455}
456
457fn is_proxy_reachable(port: u16) -> bool {
458 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
459 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
460 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
461}
462
463fn install_codex_env(home: &Path, port: u16, quiet: bool) {
464 let base = format!("http://127.0.0.1:{port}");
465
466 if !is_proxy_reachable(port) {
467 if !quiet {
468 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
469 }
470 return;
471 }
472
473 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
474 let config_path = config_dir.join("config.toml");
475
476 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
477
478 if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
479 if !quiet {
480 println!(" Codex CLI proxy env already configured");
481 }
482 return;
483 }
484
485 if !config_dir.exists() {
486 return;
487 }
488
489 let mut content = existing;
490
491 if content.contains("[env]") {
492 if !content.contains("OPENAI_BASE_URL") {
493 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
494 }
495 } else {
496 if !content.is_empty() && !content.ends_with('\n') {
497 content.push('\n');
498 }
499 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
500 }
501
502 let _ = std::fs::write(&config_path, &content);
503 if !quiet {
504 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
505 }
506}
507
508pub fn default_port() -> u16 {
509 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
510 if let Ok(port) = val.parse::<u16>() {
511 return port;
512 }
513 }
514 let cfg = crate::core::config::Config::load();
515 if let Some(port) = cfg.proxy_port {
516 return port;
517 }
518 uid_based_port()
519}
520
521fn uid_based_port() -> u16 {
525 #[cfg(unix)]
526 {
527 let uid = unsafe { libc::getuid() } as u16;
528 let offset = uid.saturating_sub(1000) % 1000;
529 DEFAULT_PROXY_PORT + offset
530 }
531 #[cfg(not(unix))]
532 {
533 DEFAULT_PROXY_PORT
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540
541 #[test]
542 fn uid_port_first_regular_user() {
543 assert_eq!(DEFAULT_PROXY_PORT, 4444);
545 }
546
547 #[test]
548 fn uid_port_no_overflow() {
549 let port = DEFAULT_PROXY_PORT + 999;
552 assert_eq!(port, 5443);
553 assert!(port < u16::MAX);
554 }
555
556 #[test]
557 fn uid_port_system_accounts_get_base() {
558 let uid: u16 = 500;
560 let offset = uid.saturating_sub(1000) % 1000;
561 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
562 }
563
564 #[test]
565 fn proxy_timeout_default_200ms() {
566 if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
567 return;
568 }
569 assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
570 }
571
572 #[test]
573 fn proxy_timeout_is_non_zero() {
574 let t = proxy_timeout();
575 assert!(t.as_millis() > 0);
576 }
577
578 #[test]
579 fn is_proxy_reachable_returns_false_on_unused_port() {
580 assert!(!is_proxy_reachable(19999));
581 }
582
583 #[test]
584 fn posix_block_contains_all_provider_env_vars() {
585 let base = "http://127.0.0.1:4444";
586 let block = format!(
587 r#"{PROXY_ENV_START}
588export ANTHROPIC_BASE_URL="{base}"
589export OPENAI_BASE_URL="{base}"
590export GEMINI_API_BASE_URL="{base}"
591{PROXY_ENV_END}"#
592 );
593 assert!(
594 block.contains("ANTHROPIC_BASE_URL"),
595 "shell exports must include ANTHROPIC_BASE_URL"
596 );
597 assert!(
598 block.contains("OPENAI_BASE_URL"),
599 "shell exports must include OPENAI_BASE_URL"
600 );
601 assert!(
602 block.contains("GEMINI_API_BASE_URL"),
603 "shell exports must include GEMINI_API_BASE_URL"
604 );
605 }
606
607 #[test]
608 fn fish_block_contains_all_provider_env_vars() {
609 let base = "http://127.0.0.1:4444";
610 let block = format!(
611 r#"{PROXY_ENV_START}
612set -gx ANTHROPIC_BASE_URL "{base}"
613set -gx OPENAI_BASE_URL "{base}"
614set -gx GEMINI_API_BASE_URL "{base}"
615{PROXY_ENV_END}"#
616 );
617 assert!(block.contains("ANTHROPIC_BASE_URL"));
618 assert!(block.contains("OPENAI_BASE_URL"));
619 assert!(block.contains("GEMINI_API_BASE_URL"));
620 }
621
622 #[test]
623 fn powershell_block_contains_all_provider_env_vars() {
624 let base = "http://127.0.0.1:4444";
625 let block = format!(
626 r#"{PROXY_ENV_START}
627$env:ANTHROPIC_BASE_URL = "{base}"
628$env:OPENAI_BASE_URL = "{base}"
629$env:GEMINI_API_BASE_URL = "{base}"
630{PROXY_ENV_END}"#
631 );
632 assert!(block.contains("ANTHROPIC_BASE_URL"));
633 assert!(block.contains("OPENAI_BASE_URL"));
634 assert!(block.contains("GEMINI_API_BASE_URL"));
635 }
636}