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 = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
185 if let Some(ref ps) = ps_profile {
186 if ps.exists() {
187 marked_block::remove_from_file(
188 ps,
189 PROXY_ENV_START,
190 PROXY_ENV_END,
191 quiet,
192 "proxy env from PowerShell profile",
193 );
194 }
195 }
196
197 uninstall_claude_env(home, quiet);
198 uninstall_codex_env(home, quiet);
199}
200
201fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
202 if !is_proxy_reachable(port) {
203 if !quiet {
204 println!(" Skipping shell proxy exports (proxy not running on port {port})");
205 }
206 return;
207 }
208
209 let base = format!("http://127.0.0.1:{port}");
210
211 let posix_block = format!(
212 r#"{PROXY_ENV_START}
213export ANTHROPIC_BASE_URL="{base}"
214export OPENAI_BASE_URL="{base}"
215export GEMINI_API_BASE_URL="{base}"
216{PROXY_ENV_END}"#
217 );
218
219 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
220 if rc.exists() {
221 let label = format!(
222 "proxy env in ~/{}",
223 rc.file_name().unwrap_or_default().to_string_lossy()
224 );
225 marked_block::upsert(
226 rc,
227 PROXY_ENV_START,
228 PROXY_ENV_END,
229 &posix_block,
230 quiet,
231 &label,
232 );
233 }
234 }
235
236 let fish_config = home.join(".config/fish/config.fish");
237 if fish_config.exists() {
238 let fish_block = format!(
239 r#"{PROXY_ENV_START}
240set -gx ANTHROPIC_BASE_URL "{base}"
241set -gx OPENAI_BASE_URL "{base}"
242set -gx GEMINI_API_BASE_URL "{base}"
243{PROXY_ENV_END}"#
244 );
245 marked_block::upsert(
246 &fish_config,
247 PROXY_ENV_START,
248 PROXY_ENV_END,
249 &fish_block,
250 quiet,
251 "proxy env in ~/.config/fish/config.fish",
252 );
253 }
254
255 let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
256 if let Some(ref ps) = ps_profile {
257 if ps.exists() {
258 let ps_block = format!(
259 r#"{PROXY_ENV_START}
260$env:ANTHROPIC_BASE_URL = "{base}"
261$env:OPENAI_BASE_URL = "{base}"
262$env:GEMINI_API_BASE_URL = "{base}"
263{PROXY_ENV_END}"#
264 );
265 marked_block::upsert(
266 ps,
267 PROXY_ENV_START,
268 PROXY_ENV_END,
269 &ps_block,
270 quiet,
271 "proxy env in PowerShell profile",
272 );
273 }
274 }
275}
276
277fn uninstall_claude_env(home: &Path, quiet: bool) {
278 use crate::core::config::Config;
279
280 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
281 let settings_path = settings_dir.join("settings.json");
282 let existing = match std::fs::read_to_string(&settings_path) {
283 Ok(s) if !s.trim().is_empty() => s,
284 _ => return,
285 };
286 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
287 Ok(v) => v,
288 Err(_) => return,
289 };
290
291 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
292 return;
293 };
294
295 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
296 return;
297 }
298
299 let cfg = Config::load();
300 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
301 env_obj.insert(
302 "ANTHROPIC_BASE_URL".to_string(),
303 serde_json::Value::String(upstream.clone()),
304 );
305 if !quiet {
306 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
307 }
308 } else {
309 env_obj.remove("ANTHROPIC_BASE_URL");
310 if env_obj.is_empty() {
311 doc.as_object_mut().map(|o| o.remove("env"));
312 }
313 if !quiet {
314 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
315 }
316 }
317
318 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
319 let _ = std::fs::write(&settings_path, content + "\n");
320}
321
322fn uninstall_codex_env(home: &Path, quiet: bool) {
323 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
324 let config_path = codex_dir.join("config.toml");
325 let existing = match std::fs::read_to_string(&config_path) {
326 Ok(s) if !s.trim().is_empty() => s,
327 _ => return,
328 };
329
330 if !existing.contains("OPENAI_BASE_URL") {
331 return;
332 }
333
334 let cleaned: String = existing
335 .lines()
336 .filter(|line| {
337 let trimmed = line.trim();
338 !trimmed.starts_with("OPENAI_BASE_URL")
339 })
340 .collect::<Vec<_>>()
341 .join("\n");
342
343 let cleaned = cleaned
344 .replace("\n[env]\n\n", "\n")
345 .replace("[env]\n\n", "");
346 let cleaned = if cleaned.trim() == "[env]" {
347 String::new()
348 } else {
349 cleaned
350 };
351
352 let _ = std::fs::write(&config_path, &cleaned);
353 if !quiet {
354 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
355 }
356}
357
358fn install_claude_env(home: &Path, port: u16, quiet: bool) {
359 install_claude_env_inner(home, port, quiet, false);
360}
361
362fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
363 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
364
365 let base = format!("http://127.0.0.1:{port}");
366
367 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
368 let settings_path = settings_dir.join("settings.json");
369 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
370 let mut doc: serde_json::Value = if existing.trim().is_empty() {
371 serde_json::json!({})
372 } else {
373 match crate::core::jsonc::parse_jsonc(&existing) {
374 Ok(v) => v,
375 Err(_) => return,
376 }
377 };
378
379 let current_url = doc
380 .get("env")
381 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
382 .and_then(|v| v.as_str())
383 .unwrap_or("");
384
385 if current_url == base {
386 if !quiet {
387 println!(" Claude Code proxy env already configured");
388 }
389 return;
390 }
391
392 if let Some(upstream) = normalize_url_opt(current_url) {
394 if !is_local_proxy_url(&upstream) {
395 let mut cfg = Config::load();
396 if cfg.proxy.anthropic_upstream.is_none() {
397 cfg.proxy.anthropic_upstream = Some(upstream.clone());
398 let _ = cfg.save();
399 }
400
401 if !force {
402 if !quiet {
403 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
404 eprintln!(
405 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
406 );
407 }
408 return;
409 }
410 if !quiet {
411 println!(" Overriding custom endpoint (--force): {upstream}");
412 }
413 }
414 }
415
416 if !is_proxy_reachable(port) {
417 if !quiet {
418 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
419 }
420 return;
421 }
422
423 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
424 o.entry("env")
425 .or_insert(serde_json::json!({}))
426 .as_object_mut()
427 }) {
428 env_obj.insert(
429 "ANTHROPIC_BASE_URL".to_string(),
430 serde_json::Value::String(base),
431 );
432 }
433
434 let _ = std::fs::create_dir_all(&settings_dir);
435 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
436 let _ = std::fs::write(&settings_path, content + "\n");
437 if !quiet {
438 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
439 }
440}
441
442pub fn proxy_timeout() -> std::time::Duration {
444 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
445 if let Ok(ms) = val.parse::<u64>() {
446 return std::time::Duration::from_millis(ms);
447 }
448 }
449 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
450 return std::time::Duration::from_millis(ms);
451 }
452 std::time::Duration::from_millis(200)
453}
454
455fn is_proxy_reachable(port: u16) -> bool {
456 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
457 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
458 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
459}
460
461fn install_codex_env(home: &Path, port: u16, quiet: bool) {
462 let base = format!("http://127.0.0.1:{port}");
463
464 if !is_proxy_reachable(port) {
465 if !quiet {
466 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
467 }
468 return;
469 }
470
471 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
472 let config_path = config_dir.join("config.toml");
473
474 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
475
476 if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
477 if !quiet {
478 println!(" Codex CLI proxy env already configured");
479 }
480 return;
481 }
482
483 if !config_dir.exists() {
484 return;
485 }
486
487 let mut content = existing;
488
489 if content.contains("[env]") {
490 if !content.contains("OPENAI_BASE_URL") {
491 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
492 }
493 } else {
494 if !content.is_empty() && !content.ends_with('\n') {
495 content.push('\n');
496 }
497 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
498 }
499
500 let _ = std::fs::write(&config_path, &content);
501 if !quiet {
502 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
503 }
504}
505
506pub fn default_port() -> u16 {
507 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
508 if let Ok(port) = val.parse::<u16>() {
509 return port;
510 }
511 }
512 let cfg = crate::core::config::Config::load();
513 if let Some(port) = cfg.proxy_port {
514 return port;
515 }
516 uid_based_port()
517}
518
519fn uid_based_port() -> u16 {
523 #[cfg(unix)]
524 {
525 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}