1use std::path::Path;
2
3use crate::marked_block;
4
5const PROXY_ENV_START: &str = "# >>> lean-ctx proxy env >>>";
6const PROXY_ENV_END: &str = "# <<< lean-ctx proxy env <<<";
7
8const DEFAULT_PROXY_PORT: u16 = 4444;
9
10pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
11 install_shell_exports(home, port, quiet);
12 install_claude_env(home, port, quiet);
13 install_codex_env(home, port, quiet);
14}
15
16pub fn preview_proxy_cleanup(home: &Path) {
17 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
18 let settings_path = settings_dir.join("settings.json");
19 if let Ok(content) = std::fs::read_to_string(&settings_path) {
20 if content.contains("ANTHROPIC_BASE_URL") {
21 let cfg = crate::core::config::Config::load();
22 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
23 println!(" Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
24 } else {
25 println!(" Would remove ANTHROPIC_BASE_URL from Claude Code settings");
26 }
27 }
28 }
29
30 let codex_path = home.join(".codex").join("config.toml");
31 if let Ok(content) = std::fs::read_to_string(codex_path) {
32 if content.contains("OPENAI_BASE_URL") {
33 println!(" Would remove OPENAI_BASE_URL from Codex CLI config");
34 }
35 }
36}
37
38pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
39 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
40 let label = format!(
41 "proxy env from ~/{}",
42 rc.file_name().unwrap_or_default().to_string_lossy()
43 );
44 marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
45 }
46 uninstall_claude_env(home, quiet);
47 uninstall_codex_env(home, quiet);
48}
49
50fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
51 if !is_proxy_reachable(port) {
52 if !quiet {
53 println!(" Skipping shell proxy exports (proxy not running on port {port})");
54 }
55 return;
56 }
57
58 let base = format!("http://127.0.0.1:{port}");
59
60 let block = format!(
61 r#"{PROXY_ENV_START}
62export GEMINI_API_BASE_URL="{base}"
63{PROXY_ENV_END}"#
64 );
65
66 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
67 if rc.exists() {
68 let label = format!(
69 "proxy env in ~/{}",
70 rc.file_name().unwrap_or_default().to_string_lossy()
71 );
72 marked_block::upsert(rc, PROXY_ENV_START, PROXY_ENV_END, &block, quiet, &label);
73 }
74 }
75}
76
77fn uninstall_claude_env(home: &Path, quiet: bool) {
78 use crate::core::config::Config;
79
80 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
81 let settings_path = settings_dir.join("settings.json");
82 let existing = match std::fs::read_to_string(&settings_path) {
83 Ok(s) if !s.trim().is_empty() => s,
84 _ => return,
85 };
86 let mut doc: serde_json::Value = match serde_json::from_str(&existing) {
87 Ok(v) => v,
88 Err(_) => return,
89 };
90
91 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
92 return;
93 };
94
95 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
96 return;
97 }
98
99 let cfg = Config::load();
100 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
101 env_obj.insert(
102 "ANTHROPIC_BASE_URL".to_string(),
103 serde_json::Value::String(upstream.clone()),
104 );
105 if !quiet {
106 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
107 }
108 } else {
109 env_obj.remove("ANTHROPIC_BASE_URL");
110 if env_obj.is_empty() {
111 doc.as_object_mut().map(|o| o.remove("env"));
112 }
113 if !quiet {
114 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
115 }
116 }
117
118 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
119 let _ = std::fs::write(&settings_path, content + "\n");
120}
121
122fn uninstall_codex_env(home: &Path, quiet: bool) {
123 let config_path = home.join(".codex").join("config.toml");
124 let existing = match std::fs::read_to_string(&config_path) {
125 Ok(s) if !s.trim().is_empty() => s,
126 _ => return,
127 };
128
129 if !existing.contains("OPENAI_BASE_URL") {
130 return;
131 }
132
133 let cleaned: String = existing
134 .lines()
135 .filter(|line| {
136 let trimmed = line.trim();
137 !trimmed.starts_with("OPENAI_BASE_URL")
138 })
139 .collect::<Vec<_>>()
140 .join("\n");
141
142 let cleaned = cleaned
143 .replace("\n[env]\n\n", "\n")
144 .replace("[env]\n\n", "");
145 let cleaned = if cleaned.trim() == "[env]" {
146 String::new()
147 } else {
148 cleaned
149 };
150
151 let _ = std::fs::write(&config_path, &cleaned);
152 if !quiet {
153 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
154 }
155}
156
157fn install_claude_env(home: &Path, port: u16, quiet: bool) {
158 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
159
160 let base = format!("http://127.0.0.1:{port}");
161
162 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
163 let settings_path = settings_dir.join("settings.json");
164 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
165 let mut doc: serde_json::Value = if existing.trim().is_empty() {
166 serde_json::json!({})
167 } else {
168 match serde_json::from_str(&existing) {
169 Ok(v) => v,
170 Err(_) => return,
171 }
172 };
173
174 let current_url = doc
175 .get("env")
176 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
177 .and_then(|v| v.as_str())
178 .unwrap_or("");
179
180 if current_url == base {
181 if !quiet {
182 println!(" Claude Code proxy env already configured");
183 }
184 return;
185 }
186
187 if let Some(upstream) = normalize_url_opt(current_url) {
188 if !is_local_proxy_url(&upstream) {
189 let mut cfg = Config::load();
190 if cfg.proxy.anthropic_upstream.is_none() {
191 cfg.proxy.anthropic_upstream = Some(upstream.clone());
192 let _ = cfg.save();
193 if !quiet {
194 println!(" Preserved Claude Code upstream: {upstream}");
195 println!(" → saved as proxy.anthropic_upstream in config");
196 }
197 }
198 }
199 }
200
201 if !is_proxy_reachable(port) {
202 if !quiet {
203 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
204 }
205 return;
206 }
207
208 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
209 o.entry("env")
210 .or_insert(serde_json::json!({}))
211 .as_object_mut()
212 }) {
213 env_obj.insert(
214 "ANTHROPIC_BASE_URL".to_string(),
215 serde_json::Value::String(base),
216 );
217 }
218
219 let _ = std::fs::create_dir_all(&settings_dir);
220 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
221 let _ = std::fs::write(&settings_path, content + "\n");
222 if !quiet {
223 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
224 }
225}
226
227fn is_proxy_reachable(port: u16) -> bool {
228 use std::net::TcpStream;
229 use std::time::Duration;
230 TcpStream::connect_timeout(
231 &format!("127.0.0.1:{port}")
232 .parse()
233 .expect("BUG: invalid hardcoded socket address"),
234 Duration::from_millis(200),
235 )
236 .is_ok()
237}
238
239fn install_codex_env(home: &Path, port: u16, quiet: bool) {
240 let base = format!("http://127.0.0.1:{port}");
241
242 if !is_proxy_reachable(port) {
243 if !quiet {
244 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
245 }
246 return;
247 }
248
249 let config_dir = home.join(".codex");
250 let config_path = config_dir.join("config.toml");
251
252 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
253
254 if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
255 if !quiet {
256 println!(" Codex CLI proxy env already configured");
257 }
258 return;
259 }
260
261 if !config_dir.exists() {
262 return;
263 }
264
265 let mut content = existing;
266
267 if content.contains("[env]") {
268 if !content.contains("OPENAI_BASE_URL") {
269 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
270 }
271 } else {
272 if !content.is_empty() && !content.ends_with('\n') {
273 content.push('\n');
274 }
275 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
276 }
277
278 let _ = std::fs::write(&config_path, &content);
279 if !quiet {
280 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
281 }
282}
283
284pub fn default_port() -> u16 {
285 DEFAULT_PROXY_PORT
286}