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_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
31 let codex_path = codex_dir.join("config.toml");
32 if let Ok(content) = std::fs::read_to_string(codex_path) {
33 if content.contains("OPENAI_BASE_URL") {
34 println!(" Would remove OPENAI_BASE_URL from Codex CLI config");
35 }
36 }
37}
38
39pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
40 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
41 let label = format!(
42 "proxy env from ~/{}",
43 rc.file_name().unwrap_or_default().to_string_lossy()
44 );
45 marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
46 }
47
48 let fish_config = home.join(".config/fish/config.fish");
49 if fish_config.exists() {
50 marked_block::remove_from_file(
51 &fish_config,
52 PROXY_ENV_START,
53 PROXY_ENV_END,
54 quiet,
55 "proxy env from ~/.config/fish/config.fish",
56 );
57 }
58
59 let ps_profile =
60 dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
61 if let Some(ref ps) = ps_profile {
62 if ps.exists() {
63 marked_block::remove_from_file(
64 ps,
65 PROXY_ENV_START,
66 PROXY_ENV_END,
67 quiet,
68 "proxy env from PowerShell profile",
69 );
70 }
71 }
72
73 uninstall_claude_env(home, quiet);
74 uninstall_codex_env(home, quiet);
75}
76
77fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
78 if !is_proxy_reachable(port) {
79 if !quiet {
80 println!(" Skipping shell proxy exports (proxy not running on port {port})");
81 }
82 return;
83 }
84
85 let base = format!("http://127.0.0.1:{port}");
86
87 let posix_block = format!(
88 r#"{PROXY_ENV_START}
89export GEMINI_API_BASE_URL="{base}"
90{PROXY_ENV_END}"#
91 );
92
93 for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
94 if rc.exists() {
95 let label = format!(
96 "proxy env in ~/{}",
97 rc.file_name().unwrap_or_default().to_string_lossy()
98 );
99 marked_block::upsert(
100 rc,
101 PROXY_ENV_START,
102 PROXY_ENV_END,
103 &posix_block,
104 quiet,
105 &label,
106 );
107 }
108 }
109
110 let fish_config = home.join(".config/fish/config.fish");
111 if fish_config.exists() {
112 let fish_block = format!(
113 r#"{PROXY_ENV_START}
114set -gx GEMINI_API_BASE_URL "{base}"
115{PROXY_ENV_END}"#
116 );
117 marked_block::upsert(
118 &fish_config,
119 PROXY_ENV_START,
120 PROXY_ENV_END,
121 &fish_block,
122 quiet,
123 "proxy env in ~/.config/fish/config.fish",
124 );
125 }
126
127 let ps_profile =
128 dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
129 if let Some(ref ps) = ps_profile {
130 if ps.exists() {
131 let ps_block = format!(
132 r#"{PROXY_ENV_START}
133$env:GEMINI_API_BASE_URL = "{base}"
134{PROXY_ENV_END}"#
135 );
136 marked_block::upsert(
137 ps,
138 PROXY_ENV_START,
139 PROXY_ENV_END,
140 &ps_block,
141 quiet,
142 "proxy env in PowerShell profile",
143 );
144 }
145 }
146}
147
148fn uninstall_claude_env(home: &Path, quiet: bool) {
149 use crate::core::config::Config;
150
151 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
152 let settings_path = settings_dir.join("settings.json");
153 let existing = match std::fs::read_to_string(&settings_path) {
154 Ok(s) if !s.trim().is_empty() => s,
155 _ => return,
156 };
157 let mut doc: serde_json::Value = match serde_json::from_str(&existing) {
158 Ok(v) => v,
159 Err(_) => return,
160 };
161
162 let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
163 return;
164 };
165
166 if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
167 return;
168 }
169
170 let cfg = Config::load();
171 if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
172 env_obj.insert(
173 "ANTHROPIC_BASE_URL".to_string(),
174 serde_json::Value::String(upstream.clone()),
175 );
176 if !quiet {
177 println!(" ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
178 }
179 } else {
180 env_obj.remove("ANTHROPIC_BASE_URL");
181 if env_obj.is_empty() {
182 doc.as_object_mut().map(|o| o.remove("env"));
183 }
184 if !quiet {
185 println!(" ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
186 }
187 }
188
189 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
190 let _ = std::fs::write(&settings_path, content + "\n");
191}
192
193fn uninstall_codex_env(home: &Path, quiet: bool) {
194 let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
195 let config_path = codex_dir.join("config.toml");
196 let existing = match std::fs::read_to_string(&config_path) {
197 Ok(s) if !s.trim().is_empty() => s,
198 _ => return,
199 };
200
201 if !existing.contains("OPENAI_BASE_URL") {
202 return;
203 }
204
205 let cleaned: String = existing
206 .lines()
207 .filter(|line| {
208 let trimmed = line.trim();
209 !trimmed.starts_with("OPENAI_BASE_URL")
210 })
211 .collect::<Vec<_>>()
212 .join("\n");
213
214 let cleaned = cleaned
215 .replace("\n[env]\n\n", "\n")
216 .replace("[env]\n\n", "");
217 let cleaned = if cleaned.trim() == "[env]" {
218 String::new()
219 } else {
220 cleaned
221 };
222
223 let _ = std::fs::write(&config_path, &cleaned);
224 if !quiet {
225 println!(" ✓ Removed OPENAI_BASE_URL from Codex CLI config");
226 }
227}
228
229fn install_claude_env(home: &Path, port: u16, quiet: bool) {
230 use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
231
232 let base = format!("http://127.0.0.1:{port}");
233
234 let settings_dir = crate::core::editor_registry::claude_state_dir(home);
235 let settings_path = settings_dir.join("settings.json");
236 let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
237 let mut doc: serde_json::Value = if existing.trim().is_empty() {
238 serde_json::json!({})
239 } else {
240 match serde_json::from_str(&existing) {
241 Ok(v) => v,
242 Err(_) => return,
243 }
244 };
245
246 let current_url = doc
247 .get("env")
248 .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
249 .and_then(|v| v.as_str())
250 .unwrap_or("");
251
252 if current_url == base {
253 if !quiet {
254 println!(" Claude Code proxy env already configured");
255 }
256 return;
257 }
258
259 if let Some(upstream) = normalize_url_opt(current_url) {
260 if !is_local_proxy_url(&upstream) {
261 let mut cfg = Config::load();
262 if cfg.proxy.anthropic_upstream.is_none() {
263 cfg.proxy.anthropic_upstream = Some(upstream.clone());
264 let _ = cfg.save();
265 if !quiet {
266 println!(" Preserved Claude Code upstream: {upstream}");
267 println!(" → saved as proxy.anthropic_upstream in config");
268 }
269 }
270 }
271 }
272
273 if !is_proxy_reachable(port) {
274 if !quiet {
275 println!(" Skipping Claude Code proxy env (proxy not running on port {port})");
276 }
277 return;
278 }
279
280 if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
281 o.entry("env")
282 .or_insert(serde_json::json!({}))
283 .as_object_mut()
284 }) {
285 env_obj.insert(
286 "ANTHROPIC_BASE_URL".to_string(),
287 serde_json::Value::String(base),
288 );
289 }
290
291 let _ = std::fs::create_dir_all(&settings_dir);
292 let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
293 let _ = std::fs::write(&settings_path, content + "\n");
294 if !quiet {
295 println!(" Configured ANTHROPIC_BASE_URL in Claude Code settings");
296 }
297}
298
299fn is_proxy_reachable(port: u16) -> bool {
300 use std::net::TcpStream;
301 use std::time::Duration;
302 TcpStream::connect_timeout(
303 &format!("127.0.0.1:{port}")
304 .parse()
305 .expect("BUG: invalid hardcoded socket address"),
306 Duration::from_millis(200),
307 )
308 .is_ok()
309}
310
311fn install_codex_env(home: &Path, port: u16, quiet: bool) {
312 let base = format!("http://127.0.0.1:{port}");
313
314 if !is_proxy_reachable(port) {
315 if !quiet {
316 println!(" Skipping Codex CLI proxy env (proxy not running on port {port})");
317 }
318 return;
319 }
320
321 let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
322 let config_path = config_dir.join("config.toml");
323
324 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
325
326 if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
327 if !quiet {
328 println!(" Codex CLI proxy env already configured");
329 }
330 return;
331 }
332
333 if !config_dir.exists() {
334 return;
335 }
336
337 let mut content = existing;
338
339 if content.contains("[env]") {
340 if !content.contains("OPENAI_BASE_URL") {
341 content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
342 }
343 } else {
344 if !content.is_empty() && !content.ends_with('\n') {
345 content.push('\n');
346 }
347 content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
348 }
349
350 let _ = std::fs::write(&config_path, &content);
351 if !quiet {
352 println!(" Configured OPENAI_BASE_URL in Codex CLI config");
353 }
354}
355
356pub fn default_port() -> u16 {
357 DEFAULT_PROXY_PORT
358}