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
10const ANTHROPIC_OMITTED_NOTE: &str = "ANTHROPIC_BASE_URL omitted: Claude Pro/Max subscription authenticates against api.anthropic.com directly (set ANTHROPIC_API_KEY to route Claude through the proxy)";
15
16pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
17 let cfg = crate::core::config::Config::load();
18 if cfg.proxy_enabled != Some(true) {
19 if !quiet {
20 println!(" Proxy env skipped (not enabled in config)");
21 }
22 return;
23 }
24 install_shell_exports(home, port, quiet);
25 install_claude_env(home, port, quiet);
26 install_codex_env(home, port, quiet);
27 install_pi_env(home, port, quiet, false);
28}
29
30pub fn install_proxy_env_unchecked(home: &Path, port: u16, quiet: bool, force_endpoint: bool) {
33 install_shell_exports(home, port, quiet);
34 if force_endpoint {
35 install_claude_env_inner(home, port, quiet, true);
36 } else {
37 install_claude_env(home, port, quiet);
38 }
39 install_codex_env(home, port, quiet);
40 install_pi_env(home, port, quiet, force_endpoint);
41}
42
43pub fn preview_proxy_cleanup(home: &Path) {
44 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
45 let settings_path = settings_dir.join("settings.json");
46 if let Ok(content) = std::fs::read_to_string(&settings_path)
47 && content.contains("ANTHROPIC_BASE_URL")
48 {
49 let cfg = crate::core::config::Config::load();
50 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
51 println!(" Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
52 } else {
53 println!(" Would remove ANTHROPIC_BASE_URL from Claude Code settings");
54 }
55 }
56
57 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
58 let codex_path = codex_dir.join("config.toml");
59 if let Ok(content) = std::fs::read_to_string(codex_path)
60 && content.contains("OPENAI_BASE_URL")
61 {
62 println!(" Would remove OPENAI_BASE_URL from Codex CLI config");
63 }
64}
65
66pub fn cleanup_stale_proxy_env(home: &Path) -> usize {
69 let cfg = crate::core::config::Config::load();
70 if cfg.proxy_enabled == Some(true) {
71 return 0;
72 }
73
74 let mut cleaned = 0;
75
76 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
77 let settings_path = settings_dir.join("settings.json");
78 if let Ok(content) = std::fs::read_to_string(&settings_path)
79 && let Ok(mut doc) = crate::core::jsonc::parse_jsonc(&content)
80 && let Some(base_url) = doc
81 .get("env")
82 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
83 .and_then(|v| v.as_str())
84 .map(String::from)
85 && is_local_lean_ctx_url(&base_url)
86 && let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut())
87 {
88 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
89 env_obj.insert(
90 "ANTHROPIC_BASE_URL".to_string(),
91 serde_json::Value::String(upstream.clone()),
92 );
93 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
94 } else {
95 env_obj.remove("ANTHROPIC_BASE_URL");
96 if env_obj.is_empty() {
97 doc.as_object_mut().map(|o| o.remove("env"));
98 }
99 println!(" ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings");
100 }
101 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
102 let _ = std::fs::write(&settings_path, out + "\n");
103 cleaned += 1;
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 && 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 cleaned
131}
132
133pub fn is_local_lean_ctx_url(url: &str) -> bool {
134 url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
135}
136
137pub fn has_stale_proxy_url(home: &Path) -> bool {
140 let cfg = crate::core::config::Config::load();
141 if cfg.proxy_enabled == Some(true) {
142 return false;
143 }
144
145 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
146 let settings_path = settings_dir.join("settings.json");
147 let Ok(content) = std::fs::read_to_string(&settings_path) else {
148 return false;
149 };
150 let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
151 return false;
152 };
153
154 let base_url = doc
155 .get("env")
156 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
157 .and_then(|v| v.as_str())
158 .unwrap_or("");
159
160 is_local_lean_ctx_url(base_url)
161}
162
163pub fn anthropic_api_key_available(home: &Path) -> bool {
174 for var in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] {
176 if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) {
177 return true;
178 }
179 }
180
181 let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
184 let Ok(content) = std::fs::read_to_string(&settings_path) else {
185 return false;
186 };
187 let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
188 return false;
189 };
190
191 if doc
192 .get("apiKeyHelper")
193 .and_then(|v| v.as_str())
194 .is_some_and(|v| !v.trim().is_empty())
195 {
196 return true;
197 }
198
199 let env = doc.get("env");
200 ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
201 .iter()
202 .any(|key| {
203 env.and_then(|e| e.get(*key))
204 .and_then(|v| v.as_str())
205 .is_some_and(|v| !v.trim().is_empty())
206 })
207}
208
209fn warn_claude_subscription_skip() {
212 eprintln!(" \u{26a0} Claude Code: no ANTHROPIC_API_KEY detected (Pro/Max subscription?).");
213 eprintln!(" The proxy forwards your credential upstream but never injects one, and a");
214 eprintln!(" subscription token only authenticates against api.anthropic.com directly.");
215 eprintln!(" Leaving ANTHROPIC_BASE_URL untouched so Claude Code keeps working.");
216 eprintln!(" Savings on a subscription: use the lean-ctx MCP tools (ctx_read /");
217 eprintln!(" ctx_search / ctx_shell). Pay-as-you-go? Set ANTHROPIC_API_KEY, then run:");
218 eprintln!(" lean-ctx proxy enable");
219}
220
221pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
222 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
223 let label = format!(
224 "proxy env from ~/{}",
225 rc.file_name().unwrap_or_default().to_string_lossy()
226 );
227 marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
228 }
229
230 let fish_config = home.join(".config/fish/config.fish");
231 if fish_config.exists() {
232 marked_block::remove_from_file(
233 &fish_config,
234 PROXY_ENV_START,
235 PROXY_ENV_END,
236 quiet,
237 "proxy env from ~/.config/fish/config.fish",
238 );
239 }
240
241 let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
242 if let Some(ref ps) = ps_profile
243 && ps.exists()
244 {
245 marked_block::remove_from_file(
246 ps,
247 PROXY_ENV_START,
248 PROXY_ENV_END,
249 quiet,
250 "proxy env from PowerShell profile",
251 );
252 }
253
254 uninstall_claude_env(home, quiet);
255 uninstall_codex_env(home, quiet);
256 uninstall_pi_env(home, quiet);
257}
258
259fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
260 if !is_proxy_reachable(port) {
261 if !quiet {
262 println!(" Skipping shell proxy exports (proxy not running on port {port})");
263 }
264 return;
265 }
266
267 let base = format!("http://127.0.0.1:{port}");
268 let openai_base = format!("{base}/v1");
276
277 let include_anthropic = anthropic_api_key_available(home);
281
282 let posix_anthropic = if include_anthropic {
283 format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
284 } else {
285 format!("# {ANTHROPIC_OMITTED_NOTE}")
286 };
287 let posix_block = format!(
288 r#"{PROXY_ENV_START}
289{posix_anthropic}
290export OPENAI_BASE_URL="{openai_base}"
291export GEMINI_API_BASE_URL="{base}"
292{PROXY_ENV_END}"#
293 );
294
295 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
296 if rc.exists() {
297 let label = format!(
298 "proxy env in ~/{}",
299 rc.file_name().unwrap_or_default().to_string_lossy()
300 );
301 marked_block::upsert(
302 rc,
303 PROXY_ENV_START,
304 PROXY_ENV_END,
305 &posix_block,
306 quiet,
307 &label,
308 );
309 }
310 }
311
312 let fish_config = home.join(".config/fish/config.fish");
313 if fish_config.exists() {
314 let fish_anthropic = if include_anthropic {
315 format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
316 } else {
317 format!("# {ANTHROPIC_OMITTED_NOTE}")
318 };
319 let fish_block = format!(
320 r#"{PROXY_ENV_START}
321{fish_anthropic}
322set -gx OPENAI_BASE_URL "{openai_base}"
323set -gx GEMINI_API_BASE_URL "{base}"
324{PROXY_ENV_END}"#
325 );
326 marked_block::upsert(
327 &fish_config,
328 PROXY_ENV_START,
329 PROXY_ENV_END,
330 &fish_block,
331 quiet,
332 "proxy env in ~/.config/fish/config.fish",
333 );
334 }
335
336 let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
337 if let Some(ref ps) = ps_profile
338 && ps.exists()
339 {
340 let ps_anthropic = if include_anthropic {
341 format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
342 } else {
343 format!("# {ANTHROPIC_OMITTED_NOTE}")
344 };
345 let ps_block = format!(
346 r#"{PROXY_ENV_START}
347{ps_anthropic}
348$env:OPENAI_BASE_URL = "{openai_base}"
349$env:GEMINI_API_BASE_URL = "{base}"
350{PROXY_ENV_END}"#
351 );
352 marked_block::upsert(
353 ps,
354 PROXY_ENV_START,
355 PROXY_ENV_END,
356 &ps_block,
357 quiet,
358 "proxy env in PowerShell profile",
359 );
360 }
361}
362
363fn uninstall_claude_env(home: &Path, quiet: bool) {
364 use crate::core::config::Config;
365
366 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
367 let settings_path = settings_dir.join("settings.json");
368 let existing = match std::fs::read_to_string(&settings_path) {
369 Ok(s) if !s.trim().is_empty() => s,
370 _ => return,
371 };
372 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
373 Ok(v) => v,
374 Err(_) => return,
375 };
376
377 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
378 return;
379 };
380
381 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
382 return;
383 }
384
385 let cfg = Config::load();
386 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
387 env_obj.insert(
388 "ANTHROPIC_BASE_URL".to_string(),
389 serde_json::Value::String(upstream.clone()),
390 );
391 if !quiet {
392 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
393 }
394 } else {
395 env_obj.remove("ANTHROPIC_BASE_URL");
396 if env_obj.is_empty() {
397 doc.as_object_mut().map(|o| o.remove("env"));
398 }
399 if !quiet {
400 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
401 }
402 }
403
404 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
405 let _ = std::fs::write(&settings_path, content + "\n");
406}
407
408fn uninstall_codex_env(home: &Path, quiet: bool) {
409 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
410 let config_path = codex_dir.join("config.toml");
411 let existing = match std::fs::read_to_string(&config_path) {
412 Ok(s) if !s.trim().is_empty() => s,
413 _ => return,
414 };
415
416 if !existing.contains("OPENAI_BASE_URL") {
417 return;
418 }
419
420 let cleaned: String = existing
421 .lines()
422 .filter(|line| {
423 let trimmed = line.trim();
424 !trimmed.starts_with("OPENAI_BASE_URL")
425 })
426 .collect::<Vec<_>>()
427 .join("\n");
428
429 let cleaned = cleaned
430 .replace("\n[env]\n\n", "\n")
431 .replace("[env]\n\n", "");
432 let cleaned = if cleaned.trim() == "[env]" {
433 String::new()
434 } else {
435 cleaned
436 };
437
438 let _ = std::fs::write(&config_path, &cleaned);
439 if !quiet {
440 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
441 }
442}
443
444fn install_pi_env(home: &Path, port: u16, quiet: bool, force: bool) {
453 install_pi_env_at(&home.join(".pi/agent"), port, quiet, force);
454}
455
456fn uninstall_pi_env(home: &Path, quiet: bool) {
457 uninstall_pi_env_at(&home.join(".pi/agent"), quiet);
458}
459
460fn install_pi_env_at(agent_dir: &Path, port: u16, quiet: bool, force: bool) {
467 use crate::core::config::{is_local_proxy_url, normalize_url_opt};
468
469 if !agent_dir.exists() {
471 return;
472 }
473 if !is_proxy_reachable(port) {
474 if !quiet {
475 println!(" Skipping Pi proxy env (proxy not running on port {port})");
476 }
477 return;
478 }
479
480 let base = format!("http://127.0.0.1:{port}");
481 let models_path = agent_dir.join("models.json");
482 let existing = std::fs::read_to_string(&models_path).unwrap_or_default();
483 let mut doc: serde_json::Value = if existing.trim().is_empty() {
484 serde_json::json!({})
485 } else {
486 match crate::core::jsonc::parse_jsonc(&existing) {
487 Ok(v) => v,
488 Err(_) => return,
489 }
490 };
491
492 let mut changed = false;
493 let mut kept_custom: Vec<String> = Vec::new();
494 for (provider, proxy_url) in [
495 ("anthropic", base.clone()),
496 ("openai", format!("{base}/v1")),
497 ] {
498 let current = pi_provider_base_url(&doc, provider).to_string();
499 if current == proxy_url {
500 continue;
501 }
502 if !force
504 && let Some(custom) = normalize_url_opt(¤t)
505 && !is_local_proxy_url(&custom)
506 {
507 kept_custom.push(format!("{provider} → {custom}"));
508 continue;
509 }
510 set_pi_provider_base_url(&mut doc, provider, &proxy_url);
511 changed = true;
512 }
513
514 if changed {
515 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
516 let _ = std::fs::write(&models_path, out + "\n");
517 if !quiet {
518 println!(
519 " Configured Pi providers (anthropic/openai) → proxy in ~/.pi/agent/models.json"
520 );
521 }
522 }
523 if !quiet && !kept_custom.is_empty() {
524 eprintln!(
525 " \u{26a0} Pi: kept custom endpoint(s) {}; use `lean-ctx proxy enable --force` to override.",
526 kept_custom.join(", ")
527 );
528 }
529}
530
531fn uninstall_pi_env_at(agent_dir: &Path, quiet: bool) {
535 use crate::core::config::is_local_proxy_url;
536
537 let models_path = agent_dir.join("models.json");
538 let existing = match std::fs::read_to_string(&models_path) {
539 Ok(s) if !s.trim().is_empty() => s,
540 _ => return,
541 };
542 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
543 Ok(v) => v,
544 Err(_) => return,
545 };
546
547 let mut changed = false;
548 for provider in ["anthropic", "openai"] {
549 if is_local_proxy_url(pi_provider_base_url(&doc, provider))
550 && remove_pi_provider_base_url(&mut doc, provider)
551 {
552 changed = true;
553 }
554 }
555
556 if changed {
557 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
558 let _ = std::fs::write(&models_path, out + "\n");
559 if !quiet {
560 println!(" \u{2713} Removed Pi proxy endpoints from ~/.pi/agent/models.json");
561 }
562 }
563}
564
565fn pi_provider_base_url<'a>(doc: &'a serde_json::Value, provider: &str) -> &'a str {
567 doc.get("providers")
568 .and_then(|p| p.get(provider))
569 .and_then(|p| p.get("baseUrl"))
570 .and_then(serde_json::Value::as_str)
571 .unwrap_or("")
572}
573
574fn set_pi_provider_base_url(doc: &mut serde_json::Value, provider: &str, url: &str) {
576 let Some(root) = doc.as_object_mut() else {
577 return;
578 };
579 let providers = root
580 .entry("providers")
581 .or_insert_with(|| serde_json::json!({}));
582 let Some(providers) = providers.as_object_mut() else {
583 return;
584 };
585 let entry = providers
586 .entry(provider.to_string())
587 .or_insert_with(|| serde_json::json!({}));
588 if let Some(entry) = entry.as_object_mut() {
589 entry.insert(
590 "baseUrl".to_string(),
591 serde_json::Value::String(url.to_string()),
592 );
593 }
594}
595
596fn remove_pi_provider_base_url(doc: &mut serde_json::Value, provider: &str) -> bool {
599 let Some(root) = doc.as_object_mut() else {
600 return false;
601 };
602 let Some(providers) = root.get_mut("providers").and_then(|p| p.as_object_mut()) else {
603 return false;
604 };
605 let Some(entry) = providers.get_mut(provider).and_then(|p| p.as_object_mut()) else {
606 return false;
607 };
608 if entry.remove("baseUrl").is_none() {
609 return false;
610 }
611 if entry.is_empty() {
612 providers.remove(provider);
613 }
614 if providers.is_empty() {
615 root.remove("providers");
616 }
617 true
618}
619
620fn install_claude_env(home: &Path, port: u16, quiet: bool) {
621 install_claude_env_inner(home, port, quiet, false);
622}
623
624fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
625 use crate::core::config::{Config, is_local_proxy_url, normalize_url_opt};
626
627 let base = format!("http://127.0.0.1:{port}");
628
629 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
630 let settings_path = settings_dir.join("settings.json");
631 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
632 let mut doc: serde_json::Value = if existing.trim().is_empty() {
633 serde_json::json!({})
634 } else {
635 match crate::core::jsonc::parse_jsonc(&existing) {
636 Ok(v) => v,
637 Err(_) => return,
638 }
639 };
640
641 let current_url = doc
642 .get("env")
643 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
644 .and_then(|v| v.as_str())
645 .unwrap_or("")
646 .to_string();
647
648 if !force && !anthropic_api_key_available(home) {
654 if is_local_lean_ctx_url(¤t_url) {
656 let cfg = Config::load();
657 if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
658 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
659 env_obj.insert(
660 "ANTHROPIC_BASE_URL".to_string(),
661 serde_json::Value::String(upstream.clone()),
662 );
663 } else {
664 env_obj.remove("ANTHROPIC_BASE_URL");
665 if env_obj.is_empty() {
666 doc.as_object_mut().map(|o| o.remove("env"));
667 }
668 }
669 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
670 let _ = std::fs::write(&settings_path, out + "\n");
671 }
672 }
673 if !quiet {
674 warn_claude_subscription_skip();
675 }
676 return;
677 }
678
679 if current_url == base {
680 if !quiet {
681 println!(" Claude Code proxy env already configured");
682 }
683 return;
684 }
685
686 if let Some(upstream) = normalize_url_opt(¤t_url)
688 && !is_local_proxy_url(&upstream)
689 {
690 if Config::load_global().proxy.anthropic_upstream.is_none()
691 && let Err(e) =
692 Config::update_global(|c| c.proxy.anthropic_upstream = Some(upstream.clone()))
693 {
694 tracing::warn!("could not persist proxy upstream: {e}");
695 }
696
697 if !force {
698 if !quiet {
699 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
700 eprintln!(
701 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
702 );
703 }
704 return;
705 }
706 if !quiet {
707 println!(" Overriding custom endpoint (--force): {upstream}");
708 }
709 }
710
711 if !is_proxy_reachable(port) {
712 if !quiet {
713 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
714 }
715 return;
716 }
717
718 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
719 o.entry("env")
720 .or_insert(serde_json::json!({}))
721 .as_object_mut()
722 }) {
723 env_obj.insert(
724 "ANTHROPIC_BASE_URL".to_string(),
725 serde_json::Value::String(base),
726 );
727 }
728
729 let _ = std::fs::create_dir_all(&settings_dir);
730 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
731 let _ = std::fs::write(&settings_path, content + "\n");
732 if !quiet {
733 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
734 }
735}
736
737pub fn proxy_timeout() -> std::time::Duration {
739 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS")
740 && let Ok(ms) = val.parse::<u64>()
741 {
742 return std::time::Duration::from_millis(ms);
743 }
744 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
745 return std::time::Duration::from_millis(ms);
746 }
747 std::time::Duration::from_millis(200)
748}
749
750fn is_proxy_reachable(port: u16) -> bool {
751 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
752 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
753 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
754}
755
756fn install_codex_env(home: &Path, port: u16, quiet: bool) {
757 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
758 install_codex_env_at(&config_dir, port, quiet);
759}
760
761fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
764 let base = format!("http://127.0.0.1:{port}");
766 let value = format!("{base}/v1");
767
768 if !is_proxy_reachable(port) {
769 if !quiet {
770 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
771 }
772 return;
773 }
774
775 let config_path = config_dir.join("config.toml");
776
777 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
778
779 if existing.contains("OPENAI_BASE_URL") && existing.contains(&value) {
780 if !quiet {
781 println!(" Codex CLI proxy env already configured");
782 }
783 return;
784 }
785
786 if !config_dir.exists() {
787 return;
788 }
789
790 let mut content = existing;
791
792 if content.contains("OPENAI_BASE_URL") {
793 content = content
795 .lines()
796 .map(|line| {
797 let trimmed = line.trim();
798 if trimmed.starts_with("OPENAI_BASE_URL")
799 && (trimmed.contains("127.0.0.1") || trimmed.contains("localhost"))
800 {
801 format!("OPENAI_BASE_URL = \"{value}\"")
802 } else {
803 line.to_string()
804 }
805 })
806 .collect::<Vec<_>>()
807 .join("\n");
808 if !content.ends_with('\n') {
809 content.push('\n');
810 }
811 } else if content.contains("[env]") {
812 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{value}\""));
813 } else {
814 if !content.is_empty() && !content.ends_with('\n') {
815 content.push('\n');
816 }
817 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{value}\"\n"));
818 }
819
820 let _ = std::fs::write(&config_path, &content);
821 if !quiet {
822 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
823 }
824}
825
826pub fn default_port() -> u16 {
827 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
828 && let Ok(port) = val.parse::<u16>()
829 {
830 return port;
831 }
832 let cfg = crate::core::config::Config::load();
833 if let Some(port) = cfg.proxy_port {
834 return port;
835 }
836 uid_based_port()
837}
838
839fn uid_based_port() -> u16 {
843 #[cfg(unix)]
844 {
845 let uid = unsafe { libc::getuid() } as u16;
848 let offset = uid.saturating_sub(1000) % 1000;
849 DEFAULT_PROXY_PORT + offset
850 }
851 #[cfg(not(unix))]
852 {
853 DEFAULT_PROXY_PORT
854 }
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860
861 #[test]
862 fn uid_port_first_regular_user() {
863 assert_eq!(DEFAULT_PROXY_PORT, 4444);
865 }
866
867 #[test]
868 fn uid_port_no_overflow() {
869 let port = DEFAULT_PROXY_PORT + 999;
872 assert_eq!(port, 5443);
873 assert!(port < u16::MAX);
874 }
875
876 #[test]
877 fn uid_port_system_accounts_get_base() {
878 let uid: u16 = 500;
880 let offset = uid.saturating_sub(1000) % 1000;
881 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
882 }
883
884 #[test]
885 fn proxy_timeout_default_200ms() {
886 if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
887 return;
888 }
889 assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
890 }
891
892 #[test]
893 fn proxy_timeout_is_non_zero() {
894 let t = proxy_timeout();
895 assert!(t.as_millis() > 0);
896 }
897
898 #[test]
899 fn is_proxy_reachable_returns_false_on_unused_port() {
900 assert!(!is_proxy_reachable(19999));
901 }
902
903 #[test]
904 fn posix_block_contains_all_provider_env_vars() {
905 let base = "http://127.0.0.1:4444";
906 let block = format!(
907 r#"{PROXY_ENV_START}
908export ANTHROPIC_BASE_URL="{base}"
909export OPENAI_BASE_URL="{base}/v1"
910export GEMINI_API_BASE_URL="{base}"
911{PROXY_ENV_END}"#
912 );
913 assert!(
914 block.contains("ANTHROPIC_BASE_URL"),
915 "shell exports must include ANTHROPIC_BASE_URL"
916 );
917 assert!(
918 block.contains("OPENAI_BASE_URL"),
919 "shell exports must include OPENAI_BASE_URL"
920 );
921 assert!(
922 block.contains("GEMINI_API_BASE_URL"),
923 "shell exports must include GEMINI_API_BASE_URL"
924 );
925 }
926
927 #[test]
928 fn fish_block_contains_all_provider_env_vars() {
929 let base = "http://127.0.0.1:4444";
930 let block = format!(
931 r#"{PROXY_ENV_START}
932set -gx ANTHROPIC_BASE_URL "{base}"
933set -gx OPENAI_BASE_URL "{base}/v1"
934set -gx GEMINI_API_BASE_URL "{base}"
935{PROXY_ENV_END}"#
936 );
937 assert!(block.contains("ANTHROPIC_BASE_URL"));
938 assert!(block.contains("OPENAI_BASE_URL"));
939 assert!(block.contains("GEMINI_API_BASE_URL"));
940 }
941
942 #[test]
943 fn powershell_block_contains_all_provider_env_vars() {
944 let base = "http://127.0.0.1:4444";
945 let block = format!(
946 r#"{PROXY_ENV_START}
947$env:ANTHROPIC_BASE_URL = "{base}"
948$env:OPENAI_BASE_URL = "{base}/v1"
949$env:GEMINI_API_BASE_URL = "{base}"
950{PROXY_ENV_END}"#
951 );
952 assert!(block.contains("ANTHROPIC_BASE_URL"));
953 assert!(block.contains("OPENAI_BASE_URL"));
954 assert!(block.contains("GEMINI_API_BASE_URL"));
955 }
956
957 fn env_provides_anthropic_key() -> bool {
960 std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
961 || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
962 }
963
964 fn claude_dir_overridden() -> bool {
967 std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
968 }
969
970 fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
971 let dir = home.join(".claude");
972 std::fs::create_dir_all(&dir).unwrap();
973 let path = dir.join("settings.json");
974 std::fs::write(&path, json).unwrap();
975 path
976 }
977
978 #[test]
979 fn api_key_available_true_with_api_key_helper() {
980 if claude_dir_overridden() {
981 return;
982 }
983 let home = tempfile::tempdir().unwrap();
984 write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
985 assert!(anthropic_api_key_available(home.path()));
986 }
987
988 #[test]
989 fn api_key_available_true_with_settings_env_key() {
990 if claude_dir_overridden() {
991 return;
992 }
993 let home = tempfile::tempdir().unwrap();
994 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
995 assert!(anthropic_api_key_available(home.path()));
996 }
997
998 #[test]
999 fn api_key_available_false_without_key() {
1000 if env_provides_anthropic_key() || claude_dir_overridden() {
1001 return;
1002 }
1003 let home = tempfile::tempdir().unwrap();
1004 write_claude_settings(home.path(), r#"{"env": {}}"#);
1005 assert!(!anthropic_api_key_available(home.path()));
1006 }
1007
1008 #[test]
1009 fn api_key_available_false_when_no_settings_file() {
1010 if env_provides_anthropic_key() || claude_dir_overridden() {
1011 return;
1012 }
1013 let home = tempfile::tempdir().unwrap();
1014 assert!(!anthropic_api_key_available(home.path()));
1015 }
1016
1017 #[test]
1018 fn subscription_guard_skips_redirect_without_key() {
1019 if env_provides_anthropic_key() || claude_dir_overridden() {
1020 return;
1021 }
1022 let home = tempfile::tempdir().unwrap();
1023 install_claude_env_inner(home.path(), 4444, true, false);
1025 let settings = home.path().join(".claude/settings.json");
1026 assert!(
1027 !settings.exists(),
1028 "subscription mode must not write a proxy redirect"
1029 );
1030 }
1031
1032 #[test]
1033 fn subscription_guard_repairs_stale_local_redirect() {
1034 if env_provides_anthropic_key() || claude_dir_overridden() {
1035 return;
1036 }
1037 let home = tempfile::tempdir().unwrap();
1038 let path = write_claude_settings(
1039 home.path(),
1040 r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
1041 );
1042 install_claude_env_inner(home.path(), 4444, true, false);
1043 let after = std::fs::read_to_string(&path).unwrap();
1044 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1045 let base = doc
1046 .get("env")
1047 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1048 .and_then(|v| v.as_str())
1049 .unwrap_or("");
1050 assert!(
1051 !is_local_lean_ctx_url(base),
1052 "stale local redirect must be repaired in subscription mode, got {base:?}"
1053 );
1054 }
1055
1056 #[test]
1060 fn install_redirects_claude_when_api_key_present() {
1061 if claude_dir_overridden() {
1062 return;
1063 }
1064 let home = tempfile::tempdir().unwrap();
1065 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1067 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1068 let port = listener.local_addr().unwrap().port();
1069
1070 install_claude_env_inner(home.path(), port, true, false);
1071
1072 let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
1073 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1074 let base = doc
1075 .get("env")
1076 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1077 .and_then(|v| v.as_str())
1078 .unwrap_or("");
1079 assert_eq!(
1080 base,
1081 format!("http://127.0.0.1:{port}"),
1082 "API-key mode must route Claude through the proxy"
1083 );
1084 }
1085
1086 #[test]
1090 fn shell_export_omits_anthropic_without_key() {
1091 if env_provides_anthropic_key() || claude_dir_overridden() {
1092 return;
1093 }
1094 let home = tempfile::tempdir().unwrap();
1095 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1096 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1097 let port = listener.local_addr().unwrap().port();
1098
1099 install_shell_exports(home.path(), port, true);
1100
1101 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1102 assert!(
1103 rc.contains(&format!(
1104 "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
1105 )),
1106 "OpenAI export must remain and carry the /v1 suffix (#366)"
1107 );
1108 assert!(
1109 rc.contains(&format!(
1110 "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
1111 )),
1112 "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
1113 );
1114 assert!(
1115 !rc.contains("export ANTHROPIC_BASE_URL="),
1116 "ANTHROPIC export must be omitted in subscription mode"
1117 );
1118 assert!(
1119 rc.contains(ANTHROPIC_OMITTED_NOTE),
1120 "omission must be explained in the RC block"
1121 );
1122 }
1123
1124 #[test]
1126 fn codex_env_writes_v1_suffixed_url() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let codex_dir = dir.path().join(".codex");
1129 std::fs::create_dir_all(&codex_dir).unwrap();
1130 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1131 let port = listener.local_addr().unwrap().port();
1132
1133 install_codex_env_at(&codex_dir, port, true);
1134
1135 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1136 assert!(
1137 cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
1138 "Codex config must carry the /v1 suffix, got:\n{cfg}"
1139 );
1140 }
1141
1142 #[test]
1145 fn codex_env_migrates_stale_entry_without_v1() {
1146 let dir = tempfile::tempdir().unwrap();
1147 let codex_dir = dir.path().join(".codex");
1148 std::fs::create_dir_all(&codex_dir).unwrap();
1149 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1150 let port = listener.local_addr().unwrap().port();
1151 std::fs::write(
1152 codex_dir.join("config.toml"),
1153 format!(
1154 "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
1155 ),
1156 )
1157 .unwrap();
1158
1159 install_codex_env_at(&codex_dir, port, true);
1160
1161 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1162 assert!(
1163 cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
1164 "stale entry must be migrated to the /v1 form, got:\n{cfg}"
1165 );
1166 assert!(
1167 cfg.contains("model = \"gpt-5.2\""),
1168 "unrelated config must be preserved"
1169 );
1170 }
1171
1172 #[test]
1174 fn codex_env_preserves_custom_remote_endpoint() {
1175 let dir = tempfile::tempdir().unwrap();
1176 let codex_dir = dir.path().join(".codex");
1177 std::fs::create_dir_all(&codex_dir).unwrap();
1178 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1179 let port = listener.local_addr().unwrap().port();
1180 let original = "[env]\nOPENAI_BASE_URL = \"https://my-gateway.example.com/v1\"\n";
1181 std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1182
1183 install_codex_env_at(&codex_dir, port, true);
1184
1185 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1186 assert!(
1187 cfg.contains("https://my-gateway.example.com/v1"),
1188 "custom remote endpoint must be preserved, got:\n{cfg}"
1189 );
1190 assert!(
1191 !cfg.contains("127.0.0.1"),
1192 "proxy URL must not be injected over a custom endpoint"
1193 );
1194 }
1195
1196 #[test]
1198 fn shell_export_includes_anthropic_with_key() {
1199 if claude_dir_overridden() {
1200 return;
1201 }
1202 let home = tempfile::tempdir().unwrap();
1203 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1204 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1205 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1206 let port = listener.local_addr().unwrap().port();
1207
1208 install_shell_exports(home.path(), port, true);
1209
1210 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1211 assert!(
1212 rc.contains(&format!(
1213 "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1214 )),
1215 "API-key mode must export ANTHROPIC_BASE_URL"
1216 );
1217 }
1218
1219 fn read_pi_models(agent_dir: &Path) -> serde_json::Value {
1220 let raw = std::fs::read_to_string(agent_dir.join("models.json")).unwrap();
1221 crate::core::jsonc::parse_jsonc(&raw).unwrap()
1222 }
1223
1224 #[test]
1228 fn pi_env_fresh_install_writes_both_providers() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let agent_dir = dir.path().join(".pi/agent");
1231 std::fs::create_dir_all(&agent_dir).unwrap();
1232 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1233 let port = listener.local_addr().unwrap().port();
1234
1235 install_pi_env_at(&agent_dir, port, true, false);
1236
1237 let doc = read_pi_models(&agent_dir);
1238 assert_eq!(
1239 pi_provider_base_url(&doc, "anthropic"),
1240 format!("http://127.0.0.1:{port}"),
1241 "Anthropic gets the bare origin (SDK appends /v1 itself)"
1242 );
1243 assert_eq!(
1244 pi_provider_base_url(&doc, "openai"),
1245 format!("http://127.0.0.1:{port}/v1"),
1246 "OpenAI gets the /v1-suffixed URL (#366)"
1247 );
1248 }
1249
1250 #[test]
1253 fn pi_env_preserves_custom_remote_endpoint_without_force() {
1254 let dir = tempfile::tempdir().unwrap();
1255 let agent_dir = dir.path().join(".pi/agent");
1256 std::fs::create_dir_all(&agent_dir).unwrap();
1257 std::fs::write(
1258 agent_dir.join("models.json"),
1259 r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1260 )
1261 .unwrap();
1262 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1263 let port = listener.local_addr().unwrap().port();
1264
1265 install_pi_env_at(&agent_dir, port, true, false);
1266
1267 let doc = read_pi_models(&agent_dir);
1268 assert_eq!(
1269 pi_provider_base_url(&doc, "anthropic"),
1270 "https://gw.example.com",
1271 "custom remote endpoint must be preserved without --force"
1272 );
1273 assert_eq!(
1274 pi_provider_base_url(&doc, "openai"),
1275 format!("http://127.0.0.1:{port}/v1"),
1276 "the untouched provider still gets the proxy"
1277 );
1278 }
1279
1280 #[test]
1282 fn pi_env_force_overrides_custom_endpoint() {
1283 let dir = tempfile::tempdir().unwrap();
1284 let agent_dir = dir.path().join(".pi/agent");
1285 std::fs::create_dir_all(&agent_dir).unwrap();
1286 std::fs::write(
1287 agent_dir.join("models.json"),
1288 r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1289 )
1290 .unwrap();
1291 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1292 let port = listener.local_addr().unwrap().port();
1293
1294 install_pi_env_at(&agent_dir, port, true, true);
1295
1296 let doc = read_pi_models(&agent_dir);
1297 assert_eq!(
1298 pi_provider_base_url(&doc, "anthropic"),
1299 format!("http://127.0.0.1:{port}"),
1300 "--force must override the custom endpoint"
1301 );
1302 }
1303
1304 #[test]
1306 fn pi_env_skips_when_agent_dir_absent() {
1307 let dir = tempfile::tempdir().unwrap();
1308 let agent_dir = dir.path().join(".pi/agent");
1309
1310 install_pi_env_at(&agent_dir, 19999, true, false);
1311
1312 assert!(
1313 !agent_dir.join("models.json").exists(),
1314 "no Pi config must be created when Pi is not configured"
1315 );
1316 }
1317
1318 #[test]
1321 fn pi_uninstall_removes_only_local_endpoints() {
1322 let dir = tempfile::tempdir().unwrap();
1323 let agent_dir = dir.path().join(".pi/agent");
1324 std::fs::create_dir_all(&agent_dir).unwrap();
1325 std::fs::write(
1326 agent_dir.join("models.json"),
1327 r#"{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:4444"},"openai":{"baseUrl":"https://api.openai.com/v1"}}}"#,
1328 )
1329 .unwrap();
1330
1331 uninstall_pi_env_at(&agent_dir, true);
1332
1333 let doc = read_pi_models(&agent_dir);
1334 assert_eq!(
1335 pi_provider_base_url(&doc, "anthropic"),
1336 "",
1337 "the local proxy endpoint we set must be removed"
1338 );
1339 assert_eq!(
1340 pi_provider_base_url(&doc, "openai"),
1341 "https://api.openai.com/v1",
1342 "a custom endpoint must be preserved on disable"
1343 );
1344 }
1345}