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 =
15 "ANTHROPIC_BASE_URL omitted: Claude Pro/Max subscription authenticates against api.anthropic.com directly (set ANTHROPIC_API_KEY to route Claude through the proxy)";
16
17pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
18 let cfg = crate::core::config::Config::load();
19 if cfg.proxy_enabled != Some(true) {
20 if !quiet {
21 println!(" Proxy env skipped (not enabled in config)");
22 }
23 return;
24 }
25 install_shell_exports(home, port, quiet);
26 install_claude_env(home, port, quiet);
27 install_codex_env(home, port, quiet);
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}
41
42pub fn preview_proxy_cleanup(home: &Path) {
43 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
44 let settings_path = settings_dir.join("settings.json");
45 if let Ok(content) = std::fs::read_to_string(&settings_path) {
46 if content.contains("ANTHROPIC_BASE_URL") {
47 let cfg = crate::core::config::Config::load();
48 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
49 println!(" Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
50 } else {
51 println!(" Would remove ANTHROPIC_BASE_URL from Claude Code settings");
52 }
53 }
54 }
55
56 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
57 let codex_path = codex_dir.join("config.toml");
58 if let Ok(content) = std::fs::read_to_string(codex_path) {
59 if content.contains("OPENAI_BASE_URL") {
60 println!(" Would remove OPENAI_BASE_URL from Codex CLI config");
61 }
62 }
63}
64
65pub fn cleanup_stale_proxy_env(home: &Path) -> usize {
68 let cfg = crate::core::config::Config::load();
69 if cfg.proxy_enabled == Some(true) {
70 return 0;
71 }
72
73 let mut cleaned = 0;
74
75 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
76 let settings_path = settings_dir.join("settings.json");
77 if let Ok(content) = std::fs::read_to_string(&settings_path) {
78 if let Ok(mut doc) = crate::core::jsonc::parse_jsonc(&content) {
79 if let Some(base_url) = doc
80 .get("env")
81 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
82 .and_then(|v| v.as_str())
83 .map(String::from)
84 {
85 if is_local_lean_ctx_url(&base_url) {
86 if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
87 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
88 env_obj.insert(
89 "ANTHROPIC_BASE_URL".to_string(),
90 serde_json::Value::String(upstream.clone()),
91 );
92 println!(
93 " ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings"
94 );
95 } else {
96 env_obj.remove("ANTHROPIC_BASE_URL");
97 if env_obj.is_empty() {
98 doc.as_object_mut().map(|o| o.remove("env"));
99 }
100 println!(
101 " ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings"
102 );
103 }
104 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
105 let _ = std::fs::write(&settings_path, out + "\n");
106 cleaned += 1;
107 }
108 }
109 }
110 }
111 }
112
113 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
114 let codex_path = codex_dir.join("config.toml");
115 if let Ok(content) = std::fs::read_to_string(&codex_path) {
116 if content.contains("OPENAI_BASE_URL")
117 && (content.contains("127.0.0.1") || content.contains("localhost"))
118 {
119 let filtered: String = content
120 .lines()
121 .filter(|line| !line.trim().starts_with("OPENAI_BASE_URL"))
122 .collect::<Vec<_>>()
123 .join("\n");
124 let filtered = filtered
125 .replace("\n[env]\n\n", "\n")
126 .replace("[env]\n\n", "");
127 let filtered = if filtered.trim() == "[env]" {
128 String::new()
129 } else {
130 filtered
131 };
132 let _ = std::fs::write(&codex_path, &filtered);
133 println!(" ✓ Removed stale OPENAI_BASE_URL from Codex CLI config");
134 cleaned += 1;
135 }
136 }
137
138 cleaned
139}
140
141pub fn is_local_lean_ctx_url(url: &str) -> bool {
142 url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
143}
144
145pub fn has_stale_proxy_url(home: &Path) -> bool {
148 let cfg = crate::core::config::Config::load();
149 if cfg.proxy_enabled == Some(true) {
150 return false;
151 }
152
153 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
154 let settings_path = settings_dir.join("settings.json");
155 let Ok(content) = std::fs::read_to_string(&settings_path) else {
156 return false;
157 };
158 let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
159 return false;
160 };
161
162 let base_url = doc
163 .get("env")
164 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
165 .and_then(|v| v.as_str())
166 .unwrap_or("");
167
168 is_local_lean_ctx_url(base_url)
169}
170
171pub fn anthropic_api_key_available(home: &Path) -> bool {
182 for var in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] {
184 if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) {
185 return true;
186 }
187 }
188
189 let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
192 let Ok(content) = std::fs::read_to_string(&settings_path) else {
193 return false;
194 };
195 let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
196 return false;
197 };
198
199 if doc
200 .get("apiKeyHelper")
201 .and_then(|v| v.as_str())
202 .is_some_and(|v| !v.trim().is_empty())
203 {
204 return true;
205 }
206
207 let env = doc.get("env");
208 ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
209 .iter()
210 .any(|key| {
211 env.and_then(|e| e.get(*key))
212 .and_then(|v| v.as_str())
213 .is_some_and(|v| !v.trim().is_empty())
214 })
215}
216
217fn warn_claude_subscription_skip() {
220 eprintln!(" \u{26a0} Claude Code: no ANTHROPIC_API_KEY detected (Pro/Max subscription?).");
221 eprintln!(" The proxy forwards your credential upstream but never injects one, and a");
222 eprintln!(" subscription token only authenticates against api.anthropic.com directly.");
223 eprintln!(" Leaving ANTHROPIC_BASE_URL untouched so Claude Code keeps working.");
224 eprintln!(" Savings on a subscription: use the lean-ctx MCP tools (ctx_read /");
225 eprintln!(" ctx_search / ctx_shell). Pay-as-you-go? Set ANTHROPIC_API_KEY, then run:");
226 eprintln!(" lean-ctx proxy enable");
227}
228
229pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
230 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
231 let label = format!(
232 "proxy env from ~/{}",
233 rc.file_name().unwrap_or_default().to_string_lossy()
234 );
235 marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
236 }
237
238 let fish_config = home.join(".config/fish/config.fish");
239 if fish_config.exists() {
240 marked_block::remove_from_file(
241 &fish_config,
242 PROXY_ENV_START,
243 PROXY_ENV_END,
244 quiet,
245 "proxy env from ~/.config/fish/config.fish",
246 );
247 }
248
249 let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
250 if let Some(ref ps) = ps_profile {
251 if ps.exists() {
252 marked_block::remove_from_file(
253 ps,
254 PROXY_ENV_START,
255 PROXY_ENV_END,
256 quiet,
257 "proxy env from PowerShell profile",
258 );
259 }
260 }
261
262 uninstall_claude_env(home, quiet);
263 uninstall_codex_env(home, quiet);
264}
265
266fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
267 if !is_proxy_reachable(port) {
268 if !quiet {
269 println!(" Skipping shell proxy exports (proxy not running on port {port})");
270 }
271 return;
272 }
273
274 let base = format!("http://127.0.0.1:{port}");
275 let openai_base = format!("{base}/v1");
283
284 let include_anthropic = anthropic_api_key_available(home);
288
289 let posix_anthropic = if include_anthropic {
290 format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
291 } else {
292 format!("# {ANTHROPIC_OMITTED_NOTE}")
293 };
294 let posix_block = format!(
295 r#"{PROXY_ENV_START}
296{posix_anthropic}
297export OPENAI_BASE_URL="{openai_base}"
298export GEMINI_API_BASE_URL="{base}"
299{PROXY_ENV_END}"#
300 );
301
302 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
303 if rc.exists() {
304 let label = format!(
305 "proxy env in ~/{}",
306 rc.file_name().unwrap_or_default().to_string_lossy()
307 );
308 marked_block::upsert(
309 rc,
310 PROXY_ENV_START,
311 PROXY_ENV_END,
312 &posix_block,
313 quiet,
314 &label,
315 );
316 }
317 }
318
319 let fish_config = home.join(".config/fish/config.fish");
320 if fish_config.exists() {
321 let fish_anthropic = if include_anthropic {
322 format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
323 } else {
324 format!("# {ANTHROPIC_OMITTED_NOTE}")
325 };
326 let fish_block = format!(
327 r#"{PROXY_ENV_START}
328{fish_anthropic}
329set -gx OPENAI_BASE_URL "{openai_base}"
330set -gx GEMINI_API_BASE_URL "{base}"
331{PROXY_ENV_END}"#
332 );
333 marked_block::upsert(
334 &fish_config,
335 PROXY_ENV_START,
336 PROXY_ENV_END,
337 &fish_block,
338 quiet,
339 "proxy env in ~/.config/fish/config.fish",
340 );
341 }
342
343 let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
344 if let Some(ref ps) = ps_profile {
345 if ps.exists() {
346 let ps_anthropic = if include_anthropic {
347 format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
348 } else {
349 format!("# {ANTHROPIC_OMITTED_NOTE}")
350 };
351 let ps_block = format!(
352 r#"{PROXY_ENV_START}
353{ps_anthropic}
354$env:OPENAI_BASE_URL = "{openai_base}"
355$env:GEMINI_API_BASE_URL = "{base}"
356{PROXY_ENV_END}"#
357 );
358 marked_block::upsert(
359 ps,
360 PROXY_ENV_START,
361 PROXY_ENV_END,
362 &ps_block,
363 quiet,
364 "proxy env in PowerShell profile",
365 );
366 }
367 }
368}
369
370fn uninstall_claude_env(home: &Path, quiet: bool) {
371 use crate::core::config::Config;
372
373 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
374 let settings_path = settings_dir.join("settings.json");
375 let existing = match std::fs::read_to_string(&settings_path) {
376 Ok(s) if !s.trim().is_empty() => s,
377 _ => return,
378 };
379 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
380 Ok(v) => v,
381 Err(_) => return,
382 };
383
384 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
385 return;
386 };
387
388 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
389 return;
390 }
391
392 let cfg = Config::load();
393 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
394 env_obj.insert(
395 "ANTHROPIC_BASE_URL".to_string(),
396 serde_json::Value::String(upstream.clone()),
397 );
398 if !quiet {
399 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
400 }
401 } else {
402 env_obj.remove("ANTHROPIC_BASE_URL");
403 if env_obj.is_empty() {
404 doc.as_object_mut().map(|o| o.remove("env"));
405 }
406 if !quiet {
407 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
408 }
409 }
410
411 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
412 let _ = std::fs::write(&settings_path, content + "\n");
413}
414
415fn uninstall_codex_env(home: &Path, quiet: bool) {
416 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
417 let config_path = codex_dir.join("config.toml");
418 let existing = match std::fs::read_to_string(&config_path) {
419 Ok(s) if !s.trim().is_empty() => s,
420 _ => return,
421 };
422
423 if !existing.contains("OPENAI_BASE_URL") {
424 return;
425 }
426
427 let cleaned: String = existing
428 .lines()
429 .filter(|line| {
430 let trimmed = line.trim();
431 !trimmed.starts_with("OPENAI_BASE_URL")
432 })
433 .collect::<Vec<_>>()
434 .join("\n");
435
436 let cleaned = cleaned
437 .replace("\n[env]\n\n", "\n")
438 .replace("[env]\n\n", "");
439 let cleaned = if cleaned.trim() == "[env]" {
440 String::new()
441 } else {
442 cleaned
443 };
444
445 let _ = std::fs::write(&config_path, &cleaned);
446 if !quiet {
447 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
448 }
449}
450
451fn install_claude_env(home: &Path, port: u16, quiet: bool) {
452 install_claude_env_inner(home, port, quiet, false);
453}
454
455fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
456 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
457
458 let base = format!("http://127.0.0.1:{port}");
459
460 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
461 let settings_path = settings_dir.join("settings.json");
462 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
463 let mut doc: serde_json::Value = if existing.trim().is_empty() {
464 serde_json::json!({})
465 } else {
466 match crate::core::jsonc::parse_jsonc(&existing) {
467 Ok(v) => v,
468 Err(_) => return,
469 }
470 };
471
472 let current_url = doc
473 .get("env")
474 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
475 .and_then(|v| v.as_str())
476 .unwrap_or("")
477 .to_string();
478
479 if !force && !anthropic_api_key_available(home) {
485 if is_local_lean_ctx_url(¤t_url) {
487 let cfg = Config::load();
488 if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
489 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
490 env_obj.insert(
491 "ANTHROPIC_BASE_URL".to_string(),
492 serde_json::Value::String(upstream.clone()),
493 );
494 } else {
495 env_obj.remove("ANTHROPIC_BASE_URL");
496 if env_obj.is_empty() {
497 doc.as_object_mut().map(|o| o.remove("env"));
498 }
499 }
500 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
501 let _ = std::fs::write(&settings_path, out + "\n");
502 }
503 }
504 if !quiet {
505 warn_claude_subscription_skip();
506 }
507 return;
508 }
509
510 if current_url == base {
511 if !quiet {
512 println!(" Claude Code proxy env already configured");
513 }
514 return;
515 }
516
517 if let Some(upstream) = normalize_url_opt(¤t_url) {
519 if !is_local_proxy_url(&upstream) {
520 let mut cfg = Config::load();
521 if cfg.proxy.anthropic_upstream.is_none() {
522 cfg.proxy.anthropic_upstream = Some(upstream.clone());
523 let _ = cfg.save();
524 }
525
526 if !force {
527 if !quiet {
528 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
529 eprintln!(
530 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
531 );
532 }
533 return;
534 }
535 if !quiet {
536 println!(" Overriding custom endpoint (--force): {upstream}");
537 }
538 }
539 }
540
541 if !is_proxy_reachable(port) {
542 if !quiet {
543 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
544 }
545 return;
546 }
547
548 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
549 o.entry("env")
550 .or_insert(serde_json::json!({}))
551 .as_object_mut()
552 }) {
553 env_obj.insert(
554 "ANTHROPIC_BASE_URL".to_string(),
555 serde_json::Value::String(base),
556 );
557 }
558
559 let _ = std::fs::create_dir_all(&settings_dir);
560 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
561 let _ = std::fs::write(&settings_path, content + "\n");
562 if !quiet {
563 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
564 }
565}
566
567pub fn proxy_timeout() -> std::time::Duration {
569 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
570 if let Ok(ms) = val.parse::<u64>() {
571 return std::time::Duration::from_millis(ms);
572 }
573 }
574 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
575 return std::time::Duration::from_millis(ms);
576 }
577 std::time::Duration::from_millis(200)
578}
579
580fn is_proxy_reachable(port: u16) -> bool {
581 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
582 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
583 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
584}
585
586fn install_codex_env(home: &Path, port: u16, quiet: bool) {
587 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
588 install_codex_env_at(&config_dir, port, quiet);
589}
590
591fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
594 let base = format!("http://127.0.0.1:{port}");
596 let value = format!("{base}/v1");
597
598 if !is_proxy_reachable(port) {
599 if !quiet {
600 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
601 }
602 return;
603 }
604
605 let config_path = config_dir.join("config.toml");
606
607 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
608
609 if existing.contains("OPENAI_BASE_URL") && existing.contains(&value) {
610 if !quiet {
611 println!(" Codex CLI proxy env already configured");
612 }
613 return;
614 }
615
616 if !config_dir.exists() {
617 return;
618 }
619
620 let mut content = existing;
621
622 if content.contains("OPENAI_BASE_URL") {
623 content = content
625 .lines()
626 .map(|line| {
627 let trimmed = line.trim();
628 if trimmed.starts_with("OPENAI_BASE_URL")
629 && (trimmed.contains("127.0.0.1") || trimmed.contains("localhost"))
630 {
631 format!("OPENAI_BASE_URL = \"{value}\"")
632 } else {
633 line.to_string()
634 }
635 })
636 .collect::<Vec<_>>()
637 .join("\n");
638 if !content.ends_with('\n') {
639 content.push('\n');
640 }
641 } else if content.contains("[env]") {
642 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{value}\""));
643 } else {
644 if !content.is_empty() && !content.ends_with('\n') {
645 content.push('\n');
646 }
647 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{value}\"\n"));
648 }
649
650 let _ = std::fs::write(&config_path, &content);
651 if !quiet {
652 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
653 }
654}
655
656pub fn default_port() -> u16 {
657 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
658 if let Ok(port) = val.parse::<u16>() {
659 return port;
660 }
661 }
662 let cfg = crate::core::config::Config::load();
663 if let Some(port) = cfg.proxy_port {
664 return port;
665 }
666 uid_based_port()
667}
668
669fn uid_based_port() -> u16 {
673 #[cfg(unix)]
674 {
675 let uid = unsafe { libc::getuid() } as u16;
678 let offset = uid.saturating_sub(1000) % 1000;
679 DEFAULT_PROXY_PORT + offset
680 }
681 #[cfg(not(unix))]
682 {
683 DEFAULT_PROXY_PORT
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 #[test]
692 fn uid_port_first_regular_user() {
693 assert_eq!(DEFAULT_PROXY_PORT, 4444);
695 }
696
697 #[test]
698 fn uid_port_no_overflow() {
699 let port = DEFAULT_PROXY_PORT + 999;
702 assert_eq!(port, 5443);
703 assert!(port < u16::MAX);
704 }
705
706 #[test]
707 fn uid_port_system_accounts_get_base() {
708 let uid: u16 = 500;
710 let offset = uid.saturating_sub(1000) % 1000;
711 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
712 }
713
714 #[test]
715 fn proxy_timeout_default_200ms() {
716 if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
717 return;
718 }
719 assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
720 }
721
722 #[test]
723 fn proxy_timeout_is_non_zero() {
724 let t = proxy_timeout();
725 assert!(t.as_millis() > 0);
726 }
727
728 #[test]
729 fn is_proxy_reachable_returns_false_on_unused_port() {
730 assert!(!is_proxy_reachable(19999));
731 }
732
733 #[test]
734 fn posix_block_contains_all_provider_env_vars() {
735 let base = "http://127.0.0.1:4444";
736 let block = format!(
737 r#"{PROXY_ENV_START}
738export ANTHROPIC_BASE_URL="{base}"
739export OPENAI_BASE_URL="{base}/v1"
740export GEMINI_API_BASE_URL="{base}"
741{PROXY_ENV_END}"#
742 );
743 assert!(
744 block.contains("ANTHROPIC_BASE_URL"),
745 "shell exports must include ANTHROPIC_BASE_URL"
746 );
747 assert!(
748 block.contains("OPENAI_BASE_URL"),
749 "shell exports must include OPENAI_BASE_URL"
750 );
751 assert!(
752 block.contains("GEMINI_API_BASE_URL"),
753 "shell exports must include GEMINI_API_BASE_URL"
754 );
755 }
756
757 #[test]
758 fn fish_block_contains_all_provider_env_vars() {
759 let base = "http://127.0.0.1:4444";
760 let block = format!(
761 r#"{PROXY_ENV_START}
762set -gx ANTHROPIC_BASE_URL "{base}"
763set -gx OPENAI_BASE_URL "{base}/v1"
764set -gx GEMINI_API_BASE_URL "{base}"
765{PROXY_ENV_END}"#
766 );
767 assert!(block.contains("ANTHROPIC_BASE_URL"));
768 assert!(block.contains("OPENAI_BASE_URL"));
769 assert!(block.contains("GEMINI_API_BASE_URL"));
770 }
771
772 #[test]
773 fn powershell_block_contains_all_provider_env_vars() {
774 let base = "http://127.0.0.1:4444";
775 let block = format!(
776 r#"{PROXY_ENV_START}
777$env:ANTHROPIC_BASE_URL = "{base}"
778$env:OPENAI_BASE_URL = "{base}/v1"
779$env:GEMINI_API_BASE_URL = "{base}"
780{PROXY_ENV_END}"#
781 );
782 assert!(block.contains("ANTHROPIC_BASE_URL"));
783 assert!(block.contains("OPENAI_BASE_URL"));
784 assert!(block.contains("GEMINI_API_BASE_URL"));
785 }
786
787 fn env_provides_anthropic_key() -> bool {
790 std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
791 || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
792 }
793
794 fn claude_dir_overridden() -> bool {
797 std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
798 }
799
800 fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
801 let dir = home.join(".claude");
802 std::fs::create_dir_all(&dir).unwrap();
803 let path = dir.join("settings.json");
804 std::fs::write(&path, json).unwrap();
805 path
806 }
807
808 #[test]
809 fn api_key_available_true_with_api_key_helper() {
810 if claude_dir_overridden() {
811 return;
812 }
813 let home = tempfile::tempdir().unwrap();
814 write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
815 assert!(anthropic_api_key_available(home.path()));
816 }
817
818 #[test]
819 fn api_key_available_true_with_settings_env_key() {
820 if claude_dir_overridden() {
821 return;
822 }
823 let home = tempfile::tempdir().unwrap();
824 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
825 assert!(anthropic_api_key_available(home.path()));
826 }
827
828 #[test]
829 fn api_key_available_false_without_key() {
830 if env_provides_anthropic_key() || claude_dir_overridden() {
831 return;
832 }
833 let home = tempfile::tempdir().unwrap();
834 write_claude_settings(home.path(), r#"{"env": {}}"#);
835 assert!(!anthropic_api_key_available(home.path()));
836 }
837
838 #[test]
839 fn api_key_available_false_when_no_settings_file() {
840 if env_provides_anthropic_key() || claude_dir_overridden() {
841 return;
842 }
843 let home = tempfile::tempdir().unwrap();
844 assert!(!anthropic_api_key_available(home.path()));
845 }
846
847 #[test]
848 fn subscription_guard_skips_redirect_without_key() {
849 if env_provides_anthropic_key() || claude_dir_overridden() {
850 return;
851 }
852 let home = tempfile::tempdir().unwrap();
853 install_claude_env_inner(home.path(), 4444, true, false);
855 let settings = home.path().join(".claude/settings.json");
856 assert!(
857 !settings.exists(),
858 "subscription mode must not write a proxy redirect"
859 );
860 }
861
862 #[test]
863 fn subscription_guard_repairs_stale_local_redirect() {
864 if env_provides_anthropic_key() || claude_dir_overridden() {
865 return;
866 }
867 let home = tempfile::tempdir().unwrap();
868 let path = write_claude_settings(
869 home.path(),
870 r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
871 );
872 install_claude_env_inner(home.path(), 4444, true, false);
873 let after = std::fs::read_to_string(&path).unwrap();
874 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
875 let base = doc
876 .get("env")
877 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
878 .and_then(|v| v.as_str())
879 .unwrap_or("");
880 assert!(
881 !is_local_lean_ctx_url(base),
882 "stale local redirect must be repaired in subscription mode, got {base:?}"
883 );
884 }
885
886 #[test]
890 fn install_redirects_claude_when_api_key_present() {
891 if claude_dir_overridden() {
892 return;
893 }
894 let home = tempfile::tempdir().unwrap();
895 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
897 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
898 let port = listener.local_addr().unwrap().port();
899
900 install_claude_env_inner(home.path(), port, true, false);
901
902 let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
903 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
904 let base = doc
905 .get("env")
906 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
907 .and_then(|v| v.as_str())
908 .unwrap_or("");
909 assert_eq!(
910 base,
911 format!("http://127.0.0.1:{port}"),
912 "API-key mode must route Claude through the proxy"
913 );
914 }
915
916 #[test]
920 fn shell_export_omits_anthropic_without_key() {
921 if env_provides_anthropic_key() || claude_dir_overridden() {
922 return;
923 }
924 let home = tempfile::tempdir().unwrap();
925 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
926 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
927 let port = listener.local_addr().unwrap().port();
928
929 install_shell_exports(home.path(), port, true);
930
931 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
932 assert!(
933 rc.contains(&format!(
934 "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
935 )),
936 "OpenAI export must remain and carry the /v1 suffix (#366)"
937 );
938 assert!(
939 rc.contains(&format!(
940 "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
941 )),
942 "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
943 );
944 assert!(
945 !rc.contains("export ANTHROPIC_BASE_URL="),
946 "ANTHROPIC export must be omitted in subscription mode"
947 );
948 assert!(
949 rc.contains(ANTHROPIC_OMITTED_NOTE),
950 "omission must be explained in the RC block"
951 );
952 }
953
954 #[test]
956 fn codex_env_writes_v1_suffixed_url() {
957 let dir = tempfile::tempdir().unwrap();
958 let codex_dir = dir.path().join(".codex");
959 std::fs::create_dir_all(&codex_dir).unwrap();
960 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
961 let port = listener.local_addr().unwrap().port();
962
963 install_codex_env_at(&codex_dir, port, true);
964
965 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
966 assert!(
967 cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
968 "Codex config must carry the /v1 suffix, got:\n{cfg}"
969 );
970 }
971
972 #[test]
975 fn codex_env_migrates_stale_entry_without_v1() {
976 let dir = tempfile::tempdir().unwrap();
977 let codex_dir = dir.path().join(".codex");
978 std::fs::create_dir_all(&codex_dir).unwrap();
979 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
980 let port = listener.local_addr().unwrap().port();
981 std::fs::write(
982 codex_dir.join("config.toml"),
983 format!(
984 "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
985 ),
986 )
987 .unwrap();
988
989 install_codex_env_at(&codex_dir, port, true);
990
991 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
992 assert!(
993 cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
994 "stale entry must be migrated to the /v1 form, got:\n{cfg}"
995 );
996 assert!(
997 cfg.contains("model = \"gpt-5.2\""),
998 "unrelated config must be preserved"
999 );
1000 }
1001
1002 #[test]
1004 fn codex_env_preserves_custom_remote_endpoint() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let codex_dir = dir.path().join(".codex");
1007 std::fs::create_dir_all(&codex_dir).unwrap();
1008 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1009 let port = listener.local_addr().unwrap().port();
1010 let original = "[env]\nOPENAI_BASE_URL = \"https://my-gateway.example.com/v1\"\n";
1011 std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1012
1013 install_codex_env_at(&codex_dir, port, true);
1014
1015 let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1016 assert!(
1017 cfg.contains("https://my-gateway.example.com/v1"),
1018 "custom remote endpoint must be preserved, got:\n{cfg}"
1019 );
1020 assert!(
1021 !cfg.contains("127.0.0.1"),
1022 "proxy URL must not be injected over a custom endpoint"
1023 );
1024 }
1025
1026 #[test]
1028 fn shell_export_includes_anthropic_with_key() {
1029 if claude_dir_overridden() {
1030 return;
1031 }
1032 let home = tempfile::tempdir().unwrap();
1033 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1034 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1035 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1036 let port = listener.local_addr().unwrap().port();
1037
1038 install_shell_exports(home.path(), port, true);
1039
1040 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1041 assert!(
1042 rc.contains(&format!(
1043 "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1044 )),
1045 "API-key mode must export ANTHROPIC_BASE_URL"
1046 );
1047 }
1048}