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
276 let include_anthropic = anthropic_api_key_available(home);
280
281 let posix_anthropic = if include_anthropic {
282 format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
283 } else {
284 format!("# {ANTHROPIC_OMITTED_NOTE}")
285 };
286 let posix_block = format!(
287 r#"{PROXY_ENV_START}
288{posix_anthropic}
289export OPENAI_BASE_URL="{base}"
290export GEMINI_API_BASE_URL="{base}"
291{PROXY_ENV_END}"#
292 );
293
294 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
295 if rc.exists() {
296 let label = format!(
297 "proxy env in ~/{}",
298 rc.file_name().unwrap_or_default().to_string_lossy()
299 );
300 marked_block::upsert(
301 rc,
302 PROXY_ENV_START,
303 PROXY_ENV_END,
304 &posix_block,
305 quiet,
306 &label,
307 );
308 }
309 }
310
311 let fish_config = home.join(".config/fish/config.fish");
312 if fish_config.exists() {
313 let fish_anthropic = if include_anthropic {
314 format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
315 } else {
316 format!("# {ANTHROPIC_OMITTED_NOTE}")
317 };
318 let fish_block = format!(
319 r#"{PROXY_ENV_START}
320{fish_anthropic}
321set -gx OPENAI_BASE_URL "{base}"
322set -gx GEMINI_API_BASE_URL "{base}"
323{PROXY_ENV_END}"#
324 );
325 marked_block::upsert(
326 &fish_config,
327 PROXY_ENV_START,
328 PROXY_ENV_END,
329 &fish_block,
330 quiet,
331 "proxy env in ~/.config/fish/config.fish",
332 );
333 }
334
335 let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
336 if let Some(ref ps) = ps_profile {
337 if ps.exists() {
338 let ps_anthropic = if include_anthropic {
339 format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
340 } else {
341 format!("# {ANTHROPIC_OMITTED_NOTE}")
342 };
343 let ps_block = format!(
344 r#"{PROXY_ENV_START}
345{ps_anthropic}
346$env:OPENAI_BASE_URL = "{base}"
347$env:GEMINI_API_BASE_URL = "{base}"
348{PROXY_ENV_END}"#
349 );
350 marked_block::upsert(
351 ps,
352 PROXY_ENV_START,
353 PROXY_ENV_END,
354 &ps_block,
355 quiet,
356 "proxy env in PowerShell profile",
357 );
358 }
359 }
360}
361
362fn uninstall_claude_env(home: &Path, quiet: bool) {
363 use crate::core::config::Config;
364
365 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
366 let settings_path = settings_dir.join("settings.json");
367 let existing = match std::fs::read_to_string(&settings_path) {
368 Ok(s) if !s.trim().is_empty() => s,
369 _ => return,
370 };
371 let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
372 Ok(v) => v,
373 Err(_) => return,
374 };
375
376 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
377 return;
378 };
379
380 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
381 return;
382 }
383
384 let cfg = Config::load();
385 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
386 env_obj.insert(
387 "ANTHROPIC_BASE_URL".to_string(),
388 serde_json::Value::String(upstream.clone()),
389 );
390 if !quiet {
391 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
392 }
393 } else {
394 env_obj.remove("ANTHROPIC_BASE_URL");
395 if env_obj.is_empty() {
396 doc.as_object_mut().map(|o| o.remove("env"));
397 }
398 if !quiet {
399 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
400 }
401 }
402
403 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
404 let _ = std::fs::write(&settings_path, content + "\n");
405}
406
407fn uninstall_codex_env(home: &Path, quiet: bool) {
408 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
409 let config_path = codex_dir.join("config.toml");
410 let existing = match std::fs::read_to_string(&config_path) {
411 Ok(s) if !s.trim().is_empty() => s,
412 _ => return,
413 };
414
415 if !existing.contains("OPENAI_BASE_URL") {
416 return;
417 }
418
419 let cleaned: String = existing
420 .lines()
421 .filter(|line| {
422 let trimmed = line.trim();
423 !trimmed.starts_with("OPENAI_BASE_URL")
424 })
425 .collect::<Vec<_>>()
426 .join("\n");
427
428 let cleaned = cleaned
429 .replace("\n[env]\n\n", "\n")
430 .replace("[env]\n\n", "");
431 let cleaned = if cleaned.trim() == "[env]" {
432 String::new()
433 } else {
434 cleaned
435 };
436
437 let _ = std::fs::write(&config_path, &cleaned);
438 if !quiet {
439 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
440 }
441}
442
443fn install_claude_env(home: &Path, port: u16, quiet: bool) {
444 install_claude_env_inner(home, port, quiet, false);
445}
446
447fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
448 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
449
450 let base = format!("http://127.0.0.1:{port}");
451
452 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
453 let settings_path = settings_dir.join("settings.json");
454 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
455 let mut doc: serde_json::Value = if existing.trim().is_empty() {
456 serde_json::json!({})
457 } else {
458 match crate::core::jsonc::parse_jsonc(&existing) {
459 Ok(v) => v,
460 Err(_) => return,
461 }
462 };
463
464 let current_url = doc
465 .get("env")
466 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
467 .and_then(|v| v.as_str())
468 .unwrap_or("")
469 .to_string();
470
471 if !force && !anthropic_api_key_available(home) {
477 if is_local_lean_ctx_url(¤t_url) {
479 let cfg = Config::load();
480 if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
481 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
482 env_obj.insert(
483 "ANTHROPIC_BASE_URL".to_string(),
484 serde_json::Value::String(upstream.clone()),
485 );
486 } else {
487 env_obj.remove("ANTHROPIC_BASE_URL");
488 if env_obj.is_empty() {
489 doc.as_object_mut().map(|o| o.remove("env"));
490 }
491 }
492 let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
493 let _ = std::fs::write(&settings_path, out + "\n");
494 }
495 }
496 if !quiet {
497 warn_claude_subscription_skip();
498 }
499 return;
500 }
501
502 if current_url == base {
503 if !quiet {
504 println!(" Claude Code proxy env already configured");
505 }
506 return;
507 }
508
509 if let Some(upstream) = normalize_url_opt(¤t_url) {
511 if !is_local_proxy_url(&upstream) {
512 let mut cfg = Config::load();
513 if cfg.proxy.anthropic_upstream.is_none() {
514 cfg.proxy.anthropic_upstream = Some(upstream.clone());
515 let _ = cfg.save();
516 }
517
518 if !force {
519 if !quiet {
520 eprintln!(" \u{26a0} Custom endpoint detected: {upstream}");
521 eprintln!(
522 " Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
523 );
524 }
525 return;
526 }
527 if !quiet {
528 println!(" Overriding custom endpoint (--force): {upstream}");
529 }
530 }
531 }
532
533 if !is_proxy_reachable(port) {
534 if !quiet {
535 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
536 }
537 return;
538 }
539
540 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
541 o.entry("env")
542 .or_insert(serde_json::json!({}))
543 .as_object_mut()
544 }) {
545 env_obj.insert(
546 "ANTHROPIC_BASE_URL".to_string(),
547 serde_json::Value::String(base),
548 );
549 }
550
551 let _ = std::fs::create_dir_all(&settings_dir);
552 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
553 let _ = std::fs::write(&settings_path, content + "\n");
554 if !quiet {
555 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
556 }
557}
558
559pub fn proxy_timeout() -> std::time::Duration {
561 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
562 if let Ok(ms) = val.parse::<u64>() {
563 return std::time::Duration::from_millis(ms);
564 }
565 }
566 if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
567 return std::time::Duration::from_millis(ms);
568 }
569 std::time::Duration::from_millis(200)
570}
571
572fn is_proxy_reachable(port: u16) -> bool {
573 use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
574 let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
575 TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
576}
577
578fn install_codex_env(home: &Path, port: u16, quiet: bool) {
579 let base = format!("http://127.0.0.1:{port}");
580
581 if !is_proxy_reachable(port) {
582 if !quiet {
583 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
584 }
585 return;
586 }
587
588 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
589 let config_path = config_dir.join("config.toml");
590
591 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
592
593 if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
594 if !quiet {
595 println!(" Codex CLI proxy env already configured");
596 }
597 return;
598 }
599
600 if !config_dir.exists() {
601 return;
602 }
603
604 let mut content = existing;
605
606 if content.contains("[env]") {
607 if !content.contains("OPENAI_BASE_URL") {
608 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
609 }
610 } else {
611 if !content.is_empty() && !content.ends_with('\n') {
612 content.push('\n');
613 }
614 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
615 }
616
617 let _ = std::fs::write(&config_path, &content);
618 if !quiet {
619 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
620 }
621}
622
623pub fn default_port() -> u16 {
624 if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
625 if let Ok(port) = val.parse::<u16>() {
626 return port;
627 }
628 }
629 let cfg = crate::core::config::Config::load();
630 if let Some(port) = cfg.proxy_port {
631 return port;
632 }
633 uid_based_port()
634}
635
636fn uid_based_port() -> u16 {
640 #[cfg(unix)]
641 {
642 let uid = unsafe { libc::getuid() } as u16;
645 let offset = uid.saturating_sub(1000) % 1000;
646 DEFAULT_PROXY_PORT + offset
647 }
648 #[cfg(not(unix))]
649 {
650 DEFAULT_PROXY_PORT
651 }
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn uid_port_first_regular_user() {
660 assert_eq!(DEFAULT_PROXY_PORT, 4444);
662 }
663
664 #[test]
665 fn uid_port_no_overflow() {
666 let port = DEFAULT_PROXY_PORT + 999;
669 assert_eq!(port, 5443);
670 assert!(port < u16::MAX);
671 }
672
673 #[test]
674 fn uid_port_system_accounts_get_base() {
675 let uid: u16 = 500;
677 let offset = uid.saturating_sub(1000) % 1000;
678 assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
679 }
680
681 #[test]
682 fn proxy_timeout_default_200ms() {
683 if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
684 return;
685 }
686 assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
687 }
688
689 #[test]
690 fn proxy_timeout_is_non_zero() {
691 let t = proxy_timeout();
692 assert!(t.as_millis() > 0);
693 }
694
695 #[test]
696 fn is_proxy_reachable_returns_false_on_unused_port() {
697 assert!(!is_proxy_reachable(19999));
698 }
699
700 #[test]
701 fn posix_block_contains_all_provider_env_vars() {
702 let base = "http://127.0.0.1:4444";
703 let block = format!(
704 r#"{PROXY_ENV_START}
705export ANTHROPIC_BASE_URL="{base}"
706export OPENAI_BASE_URL="{base}"
707export GEMINI_API_BASE_URL="{base}"
708{PROXY_ENV_END}"#
709 );
710 assert!(
711 block.contains("ANTHROPIC_BASE_URL"),
712 "shell exports must include ANTHROPIC_BASE_URL"
713 );
714 assert!(
715 block.contains("OPENAI_BASE_URL"),
716 "shell exports must include OPENAI_BASE_URL"
717 );
718 assert!(
719 block.contains("GEMINI_API_BASE_URL"),
720 "shell exports must include GEMINI_API_BASE_URL"
721 );
722 }
723
724 #[test]
725 fn fish_block_contains_all_provider_env_vars() {
726 let base = "http://127.0.0.1:4444";
727 let block = format!(
728 r#"{PROXY_ENV_START}
729set -gx ANTHROPIC_BASE_URL "{base}"
730set -gx OPENAI_BASE_URL "{base}"
731set -gx GEMINI_API_BASE_URL "{base}"
732{PROXY_ENV_END}"#
733 );
734 assert!(block.contains("ANTHROPIC_BASE_URL"));
735 assert!(block.contains("OPENAI_BASE_URL"));
736 assert!(block.contains("GEMINI_API_BASE_URL"));
737 }
738
739 #[test]
740 fn powershell_block_contains_all_provider_env_vars() {
741 let base = "http://127.0.0.1:4444";
742 let block = format!(
743 r#"{PROXY_ENV_START}
744$env:ANTHROPIC_BASE_URL = "{base}"
745$env:OPENAI_BASE_URL = "{base}"
746$env:GEMINI_API_BASE_URL = "{base}"
747{PROXY_ENV_END}"#
748 );
749 assert!(block.contains("ANTHROPIC_BASE_URL"));
750 assert!(block.contains("OPENAI_BASE_URL"));
751 assert!(block.contains("GEMINI_API_BASE_URL"));
752 }
753
754 fn env_provides_anthropic_key() -> bool {
757 std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
758 || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
759 }
760
761 fn claude_dir_overridden() -> bool {
764 std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
765 }
766
767 fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
768 let dir = home.join(".claude");
769 std::fs::create_dir_all(&dir).unwrap();
770 let path = dir.join("settings.json");
771 std::fs::write(&path, json).unwrap();
772 path
773 }
774
775 #[test]
776 fn api_key_available_true_with_api_key_helper() {
777 if claude_dir_overridden() {
778 return;
779 }
780 let home = tempfile::tempdir().unwrap();
781 write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
782 assert!(anthropic_api_key_available(home.path()));
783 }
784
785 #[test]
786 fn api_key_available_true_with_settings_env_key() {
787 if claude_dir_overridden() {
788 return;
789 }
790 let home = tempfile::tempdir().unwrap();
791 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
792 assert!(anthropic_api_key_available(home.path()));
793 }
794
795 #[test]
796 fn api_key_available_false_without_key() {
797 if env_provides_anthropic_key() || claude_dir_overridden() {
798 return;
799 }
800 let home = tempfile::tempdir().unwrap();
801 write_claude_settings(home.path(), r#"{"env": {}}"#);
802 assert!(!anthropic_api_key_available(home.path()));
803 }
804
805 #[test]
806 fn api_key_available_false_when_no_settings_file() {
807 if env_provides_anthropic_key() || claude_dir_overridden() {
808 return;
809 }
810 let home = tempfile::tempdir().unwrap();
811 assert!(!anthropic_api_key_available(home.path()));
812 }
813
814 #[test]
815 fn subscription_guard_skips_redirect_without_key() {
816 if env_provides_anthropic_key() || claude_dir_overridden() {
817 return;
818 }
819 let home = tempfile::tempdir().unwrap();
820 install_claude_env_inner(home.path(), 4444, true, false);
822 let settings = home.path().join(".claude/settings.json");
823 assert!(
824 !settings.exists(),
825 "subscription mode must not write a proxy redirect"
826 );
827 }
828
829 #[test]
830 fn subscription_guard_repairs_stale_local_redirect() {
831 if env_provides_anthropic_key() || claude_dir_overridden() {
832 return;
833 }
834 let home = tempfile::tempdir().unwrap();
835 let path = write_claude_settings(
836 home.path(),
837 r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
838 );
839 install_claude_env_inner(home.path(), 4444, true, false);
840 let after = std::fs::read_to_string(&path).unwrap();
841 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
842 let base = doc
843 .get("env")
844 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
845 .and_then(|v| v.as_str())
846 .unwrap_or("");
847 assert!(
848 !is_local_lean_ctx_url(base),
849 "stale local redirect must be repaired in subscription mode, got {base:?}"
850 );
851 }
852
853 #[test]
857 fn install_redirects_claude_when_api_key_present() {
858 if claude_dir_overridden() {
859 return;
860 }
861 let home = tempfile::tempdir().unwrap();
862 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
864 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
865 let port = listener.local_addr().unwrap().port();
866
867 install_claude_env_inner(home.path(), port, true, false);
868
869 let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
870 let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
871 let base = doc
872 .get("env")
873 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
874 .and_then(|v| v.as_str())
875 .unwrap_or("");
876 assert_eq!(
877 base,
878 format!("http://127.0.0.1:{port}"),
879 "API-key mode must route Claude through the proxy"
880 );
881 }
882
883 #[test]
887 fn shell_export_omits_anthropic_without_key() {
888 if env_provides_anthropic_key() || claude_dir_overridden() {
889 return;
890 }
891 let home = tempfile::tempdir().unwrap();
892 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
893 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
894 let port = listener.local_addr().unwrap().port();
895
896 install_shell_exports(home.path(), port, true);
897
898 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
899 assert!(rc.contains("OPENAI_BASE_URL"), "OpenAI export must remain");
900 assert!(
901 rc.contains("GEMINI_API_BASE_URL"),
902 "Gemini export must remain"
903 );
904 assert!(
905 !rc.contains("export ANTHROPIC_BASE_URL="),
906 "ANTHROPIC export must be omitted in subscription mode"
907 );
908 assert!(
909 rc.contains(ANTHROPIC_OMITTED_NOTE),
910 "omission must be explained in the RC block"
911 );
912 }
913
914 #[test]
916 fn shell_export_includes_anthropic_with_key() {
917 if claude_dir_overridden() {
918 return;
919 }
920 let home = tempfile::tempdir().unwrap();
921 std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
922 write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
923 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
924 let port = listener.local_addr().unwrap().port();
925
926 install_shell_exports(home.path(), port, true);
927
928 let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
929 assert!(
930 rc.contains(&format!(
931 "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
932 )),
933 "API-key mode must export ANTHROPIC_BASE_URL"
934 );
935 }
936}