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 let mut cfg = Config::load();
691 if cfg.proxy.anthropic_upstream.is_none() {
692 cfg.proxy.anthropic_upstream = Some(upstream.clone());
693 let _ = cfg.save();
694 }
695
696 if !force {
697 if !quiet {
698 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
699 eprintln!(
700 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
701 );
702 }
703 return;
704 }
705 if !quiet {
706 println!(" Overriding custom endpoint (--force): {upstream}");
707 }
708 }
709
710 if !is_proxy_reachable(port) {
711 if !quiet {
712 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
713 }
714 return;
715 }
716
717 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
718 o.entry("env")
719 .or_insert(serde_json::json!({}))
720 .as_object_mut()
721 }) {
722 env_obj.insert(
723 "ANTHROPIC_BASE_URL".to_string(),
724 serde_json::Value::String(base),
725 );
726 }
727
728 let _ = std::fs::create_dir_all(&settings_dir);
729 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
730 let _ = std::fs::write(&settings_path, content + "\n");
731 if !quiet {
732 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
733 }
734}
735
736pub fn proxy_timeout() -> std::time::Duration {
738 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS")
739 && let Ok(ms) = val.parse::<u64>()
740 {
741 return std::time::Duration::from_millis(ms);
742 }
743 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
744 return std::time::Duration::from_millis(ms);
745 }
746 std::time::Duration::from_millis(200)
747}
748
749fn is_proxy_reachable(port: u16) -> bool {
750 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
751 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
752 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
753}
754
755fn install_codex_env(home: &Path, port: u16, quiet: bool) {
756 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
757 install_codex_env_at(&config_dir, port, quiet);
758}
759
760fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
763 let base = format!("http://127.0.0.1:{port}");
765 let value = format!("{base}/v1");
766
767 if !is_proxy_reachable(port) {
768 if !quiet {
769 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
770 }
771 return;
772 }
773
774 let config_path = config_dir.join("config.toml");
775
776 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
777
778 if existing.contains("OPENAI_BASE_URL") && existing.contains(&value) {
779 if !quiet {
780 println!(" Codex CLI proxy env already configured");
781 }
782 return;
783 }
784
785 if !config_dir.exists() {
786 return;
787 }
788
789 let mut content = existing;
790
791 if content.contains("OPENAI_BASE_URL") {
792 content = content
794 .lines()
795 .map(|line| {
796 let trimmed = line.trim();
797 if trimmed.starts_with("OPENAI_BASE_URL")
798 && (trimmed.contains("127.0.0.1") || trimmed.contains("localhost"))
799 {
800 format!("OPENAI_BASE_URL = \"{value}\"")
801 } else {
802 line.to_string()
803 }
804 })
805 .collect::<Vec<_>>()
806 .join("\n");
807 if !content.ends_with('\n') {
808 content.push('\n');
809 }
810 } else if content.contains("[env]") {
811 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{value}\""));
812 } else {
813 if !content.is_empty() && !content.ends_with('\n') {
814 content.push('\n');
815 }
816 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{value}\"\n"));
817 }
818
819 let _ = std::fs::write(&config_path, &content);
820 if !quiet {
821 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
822 }
823}
824
825pub fn default_port() -> u16 {
826 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
827 && let Ok(port) = val.parse::<u16>()
828 {
829 return port;
830 }
831 let cfg = crate::core::config::Config::load();
832 if let Some(port) = cfg.proxy_port {
833 return port;
834 }
835 uid_based_port()
836}
837
838fn uid_based_port() -> u16 {
842 #[cfg(unix)]
843 {
844 let uid = unsafe { libc::getuid() } as u16;
847 let offset = uid.saturating_sub(1000) % 1000;
848 DEFAULT_PROXY_PORT + offset
849 }
850 #[cfg(not(unix))]
851 {
852 DEFAULT_PROXY_PORT
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859
860 #[test]
861 fn uid_port_first_regular_user() {
862 assert_eq!(DEFAULT_PROXY_PORT, 4444);
864 }
865
866 #[test]
867 fn uid_port_no_overflow() {
868 let port = DEFAULT_PROXY_PORT + 999;
871 assert_eq!(port, 5443);
872 assert!(port < u16::MAX);
873 }
874
875 #[test]
876 fn uid_port_system_accounts_get_base() {
877 let uid: u16 = 500;
879 let offset = uid.saturating_sub(1000) % 1000;
880 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
881 }
882
883 #[test]
884 fn proxy_timeout_default_200ms() {
885 if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
886 return;
887 }
888 assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
889 }
890
891 #[test]
892 fn proxy_timeout_is_non_zero() {
893 let t = proxy_timeout();
894 assert!(t.as_millis() > 0);
895 }
896
897 #[test]
898 fn is_proxy_reachable_returns_false_on_unused_port() {
899 assert!(!is_proxy_reachable(19999));
900 }
901
902 #[test]
903 fn posix_block_contains_all_provider_env_vars() {
904 let base = "http://127.0.0.1:4444";
905 let block = format!(
906 r#"{PROXY_ENV_START}
907export ANTHROPIC_BASE_URL="{base}"
908export OPENAI_BASE_URL="{base}/v1"
909export GEMINI_API_BASE_URL="{base}"
910{PROXY_ENV_END}"#
911 );
912 assert!(
913 block.contains("ANTHROPIC_BASE_URL"),
914 "shell exports must include ANTHROPIC_BASE_URL"
915 );
916 assert!(
917 block.contains("OPENAI_BASE_URL"),
918 "shell exports must include OPENAI_BASE_URL"
919 );
920 assert!(
921 block.contains("GEMINI_API_BASE_URL"),
922 "shell exports must include GEMINI_API_BASE_URL"
923 );
924 }
925
926 #[test]
927 fn fish_block_contains_all_provider_env_vars() {
928 let base = "http://127.0.0.1:4444";
929 let block = format!(
930 r#"{PROXY_ENV_START}
931set -gx ANTHROPIC_BASE_URL "{base}"
932set -gx OPENAI_BASE_URL "{base}/v1"
933set -gx GEMINI_API_BASE_URL "{base}"
934{PROXY_ENV_END}"#
935 );
936 assert!(block.contains("ANTHROPIC_BASE_URL"));
937 assert!(block.contains("OPENAI_BASE_URL"));
938 assert!(block.contains("GEMINI_API_BASE_URL"));
939 }
940
941 #[test]
942 fn powershell_block_contains_all_provider_env_vars() {
943 let base = "http://127.0.0.1:4444";
944 let block = format!(
945 r#"{PROXY_ENV_START}
946$env:ANTHROPIC_BASE_URL = "{base}"
947$env:OPENAI_BASE_URL = "{base}/v1"
948$env:GEMINI_API_BASE_URL = "{base}"
949{PROXY_ENV_END}"#
950 );
951 assert!(block.contains("ANTHROPIC_BASE_URL"));
952 assert!(block.contains("OPENAI_BASE_URL"));
953 assert!(block.contains("GEMINI_API_BASE_URL"));
954 }
955
956 fn env_provides_anthropic_key() -> bool {
959 std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
960 || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
961 }
962
963 fn claude_dir_overridden() -> bool {
966 std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
967 }
968
969 fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
970 let dir = home.join(".claude");
971 std::fs::create_dir_all(&dir).unwrap();
972 let path = dir.join("settings.json");
973 std::fs::write(&path, json).unwrap();
974 path
975 }
976
977 #[test]
978 fn api_key_available_true_with_api_key_helper() {
979 if claude_dir_overridden() {
980 return;
981 }
982 let home = tempfile::tempdir().unwrap();
983 write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
984 assert!(anthropic_api_key_available(home.path()));
985 }
986
987 #[test]
988 fn api_key_available_true_with_settings_env_key() {
989 if claude_dir_overridden() {
990 return;
991 }
992 let home = tempfile::tempdir().unwrap();
993 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
994 assert!(anthropic_api_key_available(home.path()));
995 }
996
997 #[test]
998 fn api_key_available_false_without_key() {
999 if env_provides_anthropic_key() || claude_dir_overridden() {
1000 return;
1001 }
1002 let home = tempfile::tempdir().unwrap();
1003 write_claude_settings(home.path(), r#"{"env": {}}"#);
1004 assert!(!anthropic_api_key_available(home.path()));
1005 }
1006
1007 #[test]
1008 fn api_key_available_false_when_no_settings_file() {
1009 if env_provides_anthropic_key() || claude_dir_overridden() {
1010 return;
1011 }
1012 let home = tempfile::tempdir().unwrap();
1013 assert!(!anthropic_api_key_available(home.path()));
1014 }
1015
1016 #[test]
1017 fn subscription_guard_skips_redirect_without_key() {
1018 if env_provides_anthropic_key() || claude_dir_overridden() {
1019 return;
1020 }
1021 let home = tempfile::tempdir().unwrap();
1022 install_claude_env_inner(home.path(), 4444, true, false);
1024 let settings = home.path().join(".claude/settings.json");
1025 assert!(
1026 !settings.exists(),
1027 "subscription mode must not write a proxy redirect"
1028 );
1029 }
1030
1031 #[test]
1032 fn subscription_guard_repairs_stale_local_redirect() {
1033 if env_provides_anthropic_key() || claude_dir_overridden() {
1034 return;
1035 }
1036 let home = tempfile::tempdir().unwrap();
1037 let path = write_claude_settings(
1038 home.path(),
1039 r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
1040 );
1041 install_claude_env_inner(home.path(), 4444, true, false);
1042 let after = std::fs::read_to_string(&path).unwrap();
1043 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1044 let base = doc
1045 .get("env")
1046 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1047 .and_then(|v| v.as_str())
1048 .unwrap_or("");
1049 assert!(
1050 !is_local_lean_ctx_url(base),
1051 "stale local redirect must be repaired in subscription mode, got {base:?}"
1052 );
1053 }
1054
1055 #[test]
1059 fn install_redirects_claude_when_api_key_present() {
1060 if claude_dir_overridden() {
1061 return;
1062 }
1063 let home = tempfile::tempdir().unwrap();
1064 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1066 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1067 let port = listener.local_addr().unwrap().port();
1068
1069 install_claude_env_inner(home.path(), port, true, false);
1070
1071 let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
1072 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1073 let base = doc
1074 .get("env")
1075 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1076 .and_then(|v| v.as_str())
1077 .unwrap_or("");
1078 assert_eq!(
1079 base,
1080 format!("http://127.0.0.1:{port}"),
1081 "API-key mode must route Claude through the proxy"
1082 );
1083 }
1084
1085 #[test]
1089 fn shell_export_omits_anthropic_without_key() {
1090 if env_provides_anthropic_key() || claude_dir_overridden() {
1091 return;
1092 }
1093 let home = tempfile::tempdir().unwrap();
1094 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1095 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1096 let port = listener.local_addr().unwrap().port();
1097
1098 install_shell_exports(home.path(), port, true);
1099
1100 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1101 assert!(
1102 rc.contains(&format!(
1103 "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
1104 )),
1105 "OpenAI export must remain and carry the /v1 suffix (#366)"
1106 );
1107 assert!(
1108 rc.contains(&format!(
1109 "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
1110 )),
1111 "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
1112 );
1113 assert!(
1114 !rc.contains("export ANTHROPIC_BASE_URL="),
1115 "ANTHROPIC export must be omitted in subscription mode"
1116 );
1117 assert!(
1118 rc.contains(ANTHROPIC_OMITTED_NOTE),
1119 "omission must be explained in the RC block"
1120 );
1121 }
1122
1123 #[test]
1125 fn codex_env_writes_v1_suffixed_url() {
1126 let dir = tempfile::tempdir().unwrap();
1127 let codex_dir = dir.path().join(".codex");
1128 std::fs::create_dir_all(&codex_dir).unwrap();
1129 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1130 let port = listener.local_addr().unwrap().port();
1131
1132 install_codex_env_at(&codex_dir, port, true);
1133
1134 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1135 assert!(
1136 cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
1137 "Codex config must carry the /v1 suffix, got:\n{cfg}"
1138 );
1139 }
1140
1141 #[test]
1144 fn codex_env_migrates_stale_entry_without_v1() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let codex_dir = dir.path().join(".codex");
1147 std::fs::create_dir_all(&codex_dir).unwrap();
1148 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1149 let port = listener.local_addr().unwrap().port();
1150 std::fs::write(
1151 codex_dir.join("config.toml"),
1152 format!(
1153 "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
1154 ),
1155 )
1156 .unwrap();
1157
1158 install_codex_env_at(&codex_dir, port, true);
1159
1160 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1161 assert!(
1162 cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
1163 "stale entry must be migrated to the /v1 form, got:\n{cfg}"
1164 );
1165 assert!(
1166 cfg.contains("model = \"gpt-5.2\""),
1167 "unrelated config must be preserved"
1168 );
1169 }
1170
1171 #[test]
1173 fn codex_env_preserves_custom_remote_endpoint() {
1174 let dir = tempfile::tempdir().unwrap();
1175 let codex_dir = dir.path().join(".codex");
1176 std::fs::create_dir_all(&codex_dir).unwrap();
1177 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1178 let port = listener.local_addr().unwrap().port();
1179 let original = "[env]\nOPENAI_BASE_URL = \"https://my-gateway.example.com/v1\"\n";
1180 std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1181
1182 install_codex_env_at(&codex_dir, port, true);
1183
1184 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1185 assert!(
1186 cfg.contains("https://my-gateway.example.com/v1"),
1187 "custom remote endpoint must be preserved, got:\n{cfg}"
1188 );
1189 assert!(
1190 !cfg.contains("127.0.0.1"),
1191 "proxy URL must not be injected over a custom endpoint"
1192 );
1193 }
1194
1195 #[test]
1197 fn shell_export_includes_anthropic_with_key() {
1198 if claude_dir_overridden() {
1199 return;
1200 }
1201 let home = tempfile::tempdir().unwrap();
1202 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1203 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1204 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1205 let port = listener.local_addr().unwrap().port();
1206
1207 install_shell_exports(home.path(), port, true);
1208
1209 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1210 assert!(
1211 rc.contains(&format!(
1212 "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1213 )),
1214 "API-key mode must export ANTHROPIC_BASE_URL"
1215 );
1216 }
1217
1218 fn read_pi_models(agent_dir: &Path) -> serde_json::Value {
1219 let raw = std::fs::read_to_string(agent_dir.join("models.json")).unwrap();
1220 crate::core::jsonc::parse_jsonc(&raw).unwrap()
1221 }
1222
1223 #[test]
1227 fn pi_env_fresh_install_writes_both_providers() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let agent_dir = dir.path().join(".pi/agent");
1230 std::fs::create_dir_all(&agent_dir).unwrap();
1231 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1232 let port = listener.local_addr().unwrap().port();
1233
1234 install_pi_env_at(&agent_dir, port, true, false);
1235
1236 let doc = read_pi_models(&agent_dir);
1237 assert_eq!(
1238 pi_provider_base_url(&doc, "anthropic"),
1239 format!("http://127.0.0.1:{port}"),
1240 "Anthropic gets the bare origin (SDK appends /v1 itself)"
1241 );
1242 assert_eq!(
1243 pi_provider_base_url(&doc, "openai"),
1244 format!("http://127.0.0.1:{port}/v1"),
1245 "OpenAI gets the /v1-suffixed URL (#366)"
1246 );
1247 }
1248
1249 #[test]
1252 fn pi_env_preserves_custom_remote_endpoint_without_force() {
1253 let dir = tempfile::tempdir().unwrap();
1254 let agent_dir = dir.path().join(".pi/agent");
1255 std::fs::create_dir_all(&agent_dir).unwrap();
1256 std::fs::write(
1257 agent_dir.join("models.json"),
1258 r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1259 )
1260 .unwrap();
1261 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1262 let port = listener.local_addr().unwrap().port();
1263
1264 install_pi_env_at(&agent_dir, port, true, false);
1265
1266 let doc = read_pi_models(&agent_dir);
1267 assert_eq!(
1268 pi_provider_base_url(&doc, "anthropic"),
1269 "https://gw.example.com",
1270 "custom remote endpoint must be preserved without --force"
1271 );
1272 assert_eq!(
1273 pi_provider_base_url(&doc, "openai"),
1274 format!("http://127.0.0.1:{port}/v1"),
1275 "the untouched provider still gets the proxy"
1276 );
1277 }
1278
1279 #[test]
1281 fn pi_env_force_overrides_custom_endpoint() {
1282 let dir = tempfile::tempdir().unwrap();
1283 let agent_dir = dir.path().join(".pi/agent");
1284 std::fs::create_dir_all(&agent_dir).unwrap();
1285 std::fs::write(
1286 agent_dir.join("models.json"),
1287 r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1288 )
1289 .unwrap();
1290 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1291 let port = listener.local_addr().unwrap().port();
1292
1293 install_pi_env_at(&agent_dir, port, true, true);
1294
1295 let doc = read_pi_models(&agent_dir);
1296 assert_eq!(
1297 pi_provider_base_url(&doc, "anthropic"),
1298 format!("http://127.0.0.1:{port}"),
1299 "--force must override the custom endpoint"
1300 );
1301 }
1302
1303 #[test]
1305 fn pi_env_skips_when_agent_dir_absent() {
1306 let dir = tempfile::tempdir().unwrap();
1307 let agent_dir = dir.path().join(".pi/agent");
1308
1309 install_pi_env_at(&agent_dir, 19999, true, false);
1310
1311 assert!(
1312 !agent_dir.join("models.json").exists(),
1313 "no Pi config must be created when Pi is not configured"
1314 );
1315 }
1316
1317 #[test]
1320 fn pi_uninstall_removes_only_local_endpoints() {
1321 let dir = tempfile::tempdir().unwrap();
1322 let agent_dir = dir.path().join(".pi/agent");
1323 std::fs::create_dir_all(&agent_dir).unwrap();
1324 std::fs::write(
1325 agent_dir.join("models.json"),
1326 r#"{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:4444"},"openai":{"baseUrl":"https://api.openai.com/v1"}}}"#,
1327 )
1328 .unwrap();
1329
1330 uninstall_pi_env_at(&agent_dir, true);
1331
1332 let doc = read_pi_models(&agent_dir);
1333 assert_eq!(
1334 pi_provider_base_url(&doc, "anthropic"),
1335 "",
1336 "the local proxy endpoint we set must be removed"
1337 );
1338 assert_eq!(
1339 pi_provider_base_url(&doc, "openai"),
1340 "https://api.openai.com/v1",
1341 "a custom endpoint must be preserved on disable"
1342 );
1343 }
1344}