Skip to main content

lean_ctx/
proxy_setup.rs

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
10/// Comment written in place of the `ANTHROPIC_BASE_URL` export when no Anthropic API
11/// key is detectable. A Claude Pro/Max subscription authenticates via OAuth against
12/// `api.anthropic.com` directly and is rejected by any custom base URL, so we must not
13/// route it through the proxy.
14const 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
30/// Install proxy env without config guard (used by `lean-ctx proxy enable` which has already set the flag).
31/// `force_endpoint`: if true, overrides even non-local custom endpoints.
32pub 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
66/// Removes stale proxy URLs from Claude Code / Codex settings when the proxy is not enabled.
67/// Returns the number of stale URLs cleaned up.
68pub 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
137/// Returns true if Claude Code settings contain a local ANTHROPIC_BASE_URL
138/// while the proxy is not enabled (stale configuration).
139pub 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
163/// Returns true when an Anthropic **API key** is available for the proxy to forward
164/// upstream.
165///
166/// The proxy never injects credentials (see `proxy/forward.rs` — only
167/// `ALLOWED_REQUEST_HEADERS` are forwarded), so it can only help Claude Code when the
168/// user runs in API-key (pay-as-you-go) mode. A Claude **Pro/Max subscription**
169/// authenticates via OAuth directly against `api.anthropic.com`; that token is rejected
170/// by any custom `ANTHROPIC_BASE_URL`, so redirecting subscription traffic through the
171/// proxy only breaks auth (login loop / 401). When this returns `false`, callers must
172/// NOT point Claude Code at the proxy.
173pub fn anthropic_api_key_available(home: &Path) -> bool {
174    // 1) Process environment — covers shells and Claude Code launched from them.
175    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    // 2) Claude Code settings.json — an explicit key, an auth token, or a dynamic
182    //    key helper all indicate API-key mode.
183    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
209/// Explains why Claude Code was left pointing at `api.anthropic.com` instead of the
210/// proxy: a Pro/Max subscription (OAuth) cannot authenticate through a custom base URL.
211fn 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    // OpenAI SDK convention: the base URL INCLUDES the `/v1` prefix (default is
269    // `https://api.openai.com/v1`); clients append bare endpoints like `/responses`.
270    // Without `/v1`, OpenCode's ChatGPT-OAuth plugin fails to recognize Responses-API
271    // requests (it matches on `/v1/responses`) and OAuth traffic leaks to the platform
272    // API with the wrong credential ("Missing scopes: api.responses.write", #366).
273    // Anthropic and Gemini SDKs expect a bare origin instead — they append `/v1/...`
274    // / `/v1beta/...` themselves.
275    let openai_base = format!("{base}/v1");
276
277    // Only route Claude through the proxy when an API key is available; a Pro/Max
278    // subscription must keep talking to api.anthropic.com directly (see
279    // `anthropic_api_key_available`).
280    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
444/// Pi / forge resolve their provider endpoint from `~/.pi/agent/models.json`
445/// (`providers.<name>.baseUrl`) + OAuth, *not* from `ANTHROPIC_BASE_URL` /
446/// `OPENAI_BASE_URL`, so the shell and Claude/Codex wiring never reaches them
447/// (an independent benchmark found `proxy enable` silently bypassed for forge,
448/// #361). Point Pi's providers at the proxy directly instead. Unlike a Claude
449/// Code Pro/Max subscription — which a custom base URL breaks — Pi's OAuth works
450/// through the proxy, because the proxy forwards the credential verbatim to the
451/// real upstream (verified field-for-field in #361), so no API-key guard applies.
452fn 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
460/// Testable core of [`install_pi_env`]: operates on an explicit `~/.pi/agent`
461/// directory. Wires both providers using the same per-SDK conventions as the
462/// shell exports — Anthropic gets the bare origin (it appends `/v1` itself),
463/// OpenAI gets the `/v1`-suffixed URL (#366). A custom *remote* endpoint is
464/// preserved unless `force`, and only the providers we actually rewrite are
465/// touched, so the file round-trips cleanly on `disable`.
466fn 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    // Only wire Pi when it is actually configured on this machine.
470    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        // Never silently clobber a user's custom remote gateway; --force overrides.
503        if !force
504            && let Some(custom) = normalize_url_opt(&current)
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
531/// Testable core of [`uninstall_pi_env`]. Reverts only the providers whose
532/// `baseUrl` still points at the local proxy (i.e. the ones we set), so a custom
533/// remote endpoint the user configured themselves is never removed.
534fn 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
565/// `providers.<name>.baseUrl` from a Pi `models.json` document (`""` if absent).
566fn 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
574/// Sets `providers.<name>.baseUrl`, creating the nested objects as needed.
575fn 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
596/// Removes `providers.<name>.baseUrl` and prunes now-empty parent objects.
597/// Returns whether anything was removed.
598fn 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    // SUBSCRIPTION GUARD: the proxy never injects credentials, so redirecting Claude
649    // Code only works in API-key mode. A Claude Pro/Max subscription (OAuth) is rejected
650    // by a custom ANTHROPIC_BASE_URL → login loop / 401. When no API key is detectable we
651    // must not point Claude Code at the proxy. `--force` overrides for power users whose
652    // key lives somewhere we cannot probe (e.g. a keychain or apiKeyHelper we missed).
653    if !force && !anthropic_api_key_available(home) {
654        // Repair an existing stale local redirect so Claude Code reaches Anthropic again.
655        if is_local_lean_ctx_url(&current_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    // HARD GUARD: never overwrite non-local endpoints unless --force
687    if let Some(upstream) = normalize_url_opt(&current_url)
688        && !is_local_proxy_url(&upstream)
689    {
690        if Config::load_global().proxy.anthropic_upstream.is_none()
691            && let Err(e) =
692                Config::update_global(|c| c.proxy.anthropic_upstream = Some(upstream.clone()))
693        {
694            tracing::warn!("could not persist proxy upstream: {e}");
695        }
696
697        if !force {
698            if !quiet {
699                eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
700                eprintln!(
701                    "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
702                );
703            }
704            return;
705        }
706        if !quiet {
707            println!("  Overriding custom endpoint (--force): {upstream}");
708        }
709    }
710
711    if !is_proxy_reachable(port) {
712        if !quiet {
713            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
714        }
715        return;
716    }
717
718    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
719        o.entry("env")
720            .or_insert(serde_json::json!({}))
721            .as_object_mut()
722    }) {
723        env_obj.insert(
724            "ANTHROPIC_BASE_URL".to_string(),
725            serde_json::Value::String(base),
726        );
727    }
728
729    let _ = std::fs::create_dir_all(&settings_dir);
730    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
731    let _ = std::fs::write(&settings_path, content + "\n");
732    if !quiet {
733        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
734    }
735}
736
737/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
738pub fn proxy_timeout() -> std::time::Duration {
739    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS")
740        && let Ok(ms) = val.parse::<u64>()
741    {
742        return std::time::Duration::from_millis(ms);
743    }
744    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
745        return std::time::Duration::from_millis(ms);
746    }
747    std::time::Duration::from_millis(200)
748}
749
750fn is_proxy_reachable(port: u16) -> bool {
751    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
752    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
753    TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
754}
755
756fn install_codex_env(home: &Path, port: u16, quiet: bool) {
757    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
758    install_codex_env_at(&config_dir, port, quiet);
759}
760
761/// Testable core of `install_codex_env`: operates on an explicit Codex config
762/// directory instead of resolving it from `CODEX_HOME` / the real home.
763fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
764    // Codex CLI follows the OpenAI convention: base URL includes `/v1` (#366).
765    let base = format!("http://127.0.0.1:{port}");
766    let value = format!("{base}/v1");
767
768    if !is_proxy_reachable(port) {
769        if !quiet {
770            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
771        }
772        return;
773    }
774
775    let config_path = config_dir.join("config.toml");
776
777    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
778
779    if existing.contains("OPENAI_BASE_URL") && existing.contains(&value) {
780        if !quiet {
781            println!("  Codex CLI proxy env already configured");
782        }
783        return;
784    }
785
786    if !config_dir.exists() {
787        return;
788    }
789
790    let mut content = existing;
791
792    if content.contains("OPENAI_BASE_URL") {
793        // Migrate stale local entries written without `/v1` by older versions.
794        content = content
795            .lines()
796            .map(|line| {
797                let trimmed = line.trim();
798                if trimmed.starts_with("OPENAI_BASE_URL")
799                    && (trimmed.contains("127.0.0.1") || trimmed.contains("localhost"))
800                {
801                    format!("OPENAI_BASE_URL = \"{value}\"")
802                } else {
803                    line.to_string()
804                }
805            })
806            .collect::<Vec<_>>()
807            .join("\n");
808        if !content.ends_with('\n') {
809            content.push('\n');
810        }
811    } else if content.contains("[env]") {
812        content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{value}\""));
813    } else {
814        if !content.is_empty() && !content.ends_with('\n') {
815            content.push('\n');
816        }
817        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{value}\"\n"));
818    }
819
820    let _ = std::fs::write(&config_path, &content);
821    if !quiet {
822        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
823    }
824}
825
826pub fn default_port() -> u16 {
827    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
828        && let Ok(port) = val.parse::<u16>()
829    {
830        return port;
831    }
832    let cfg = crate::core::config::Config::load();
833    if let Some(port) = cfg.proxy_port {
834        return port;
835    }
836    uid_based_port()
837}
838
839/// Derives a deterministic port from the user's UID to avoid collisions
840/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
841/// System accounts (uid < 1000) and root always get the base port 4444.
842fn uid_based_port() -> u16 {
843    #[cfg(unix)]
844    {
845        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
846        // the calling process's real UID — no preconditions, no UB.
847        let uid = unsafe { libc::getuid() } as u16;
848        let offset = uid.saturating_sub(1000) % 1000;
849        DEFAULT_PROXY_PORT + offset
850    }
851    #[cfg(not(unix))]
852    {
853        DEFAULT_PROXY_PORT
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    #[test]
862    fn uid_port_first_regular_user() {
863        // uid 1000 (first regular user on most Linux) → base port
864        assert_eq!(DEFAULT_PROXY_PORT, 4444);
865    }
866
867    #[test]
868    fn uid_port_no_overflow() {
869        // Ensure port stays in valid range even with high UIDs
870        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
871        let port = DEFAULT_PROXY_PORT + 999;
872        assert_eq!(port, 5443);
873        assert!(port < u16::MAX);
874    }
875
876    #[test]
877    fn uid_port_system_accounts_get_base() {
878        // uid < 1000 → saturating_sub gives 0 → base port
879        let uid: u16 = 500;
880        let offset = uid.saturating_sub(1000) % 1000;
881        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
882    }
883
884    #[test]
885    fn proxy_timeout_default_200ms() {
886        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
887            return;
888        }
889        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
890    }
891
892    #[test]
893    fn proxy_timeout_is_non_zero() {
894        let t = proxy_timeout();
895        assert!(t.as_millis() > 0);
896    }
897
898    #[test]
899    fn is_proxy_reachable_returns_false_on_unused_port() {
900        assert!(!is_proxy_reachable(19999));
901    }
902
903    #[test]
904    fn posix_block_contains_all_provider_env_vars() {
905        let base = "http://127.0.0.1:4444";
906        let block = format!(
907            r#"{PROXY_ENV_START}
908export ANTHROPIC_BASE_URL="{base}"
909export OPENAI_BASE_URL="{base}/v1"
910export GEMINI_API_BASE_URL="{base}"
911{PROXY_ENV_END}"#
912        );
913        assert!(
914            block.contains("ANTHROPIC_BASE_URL"),
915            "shell exports must include ANTHROPIC_BASE_URL"
916        );
917        assert!(
918            block.contains("OPENAI_BASE_URL"),
919            "shell exports must include OPENAI_BASE_URL"
920        );
921        assert!(
922            block.contains("GEMINI_API_BASE_URL"),
923            "shell exports must include GEMINI_API_BASE_URL"
924        );
925    }
926
927    #[test]
928    fn fish_block_contains_all_provider_env_vars() {
929        let base = "http://127.0.0.1:4444";
930        let block = format!(
931            r#"{PROXY_ENV_START}
932set -gx ANTHROPIC_BASE_URL "{base}"
933set -gx OPENAI_BASE_URL "{base}/v1"
934set -gx GEMINI_API_BASE_URL "{base}"
935{PROXY_ENV_END}"#
936        );
937        assert!(block.contains("ANTHROPIC_BASE_URL"));
938        assert!(block.contains("OPENAI_BASE_URL"));
939        assert!(block.contains("GEMINI_API_BASE_URL"));
940    }
941
942    #[test]
943    fn powershell_block_contains_all_provider_env_vars() {
944        let base = "http://127.0.0.1:4444";
945        let block = format!(
946            r#"{PROXY_ENV_START}
947$env:ANTHROPIC_BASE_URL = "{base}"
948$env:OPENAI_BASE_URL = "{base}/v1"
949$env:GEMINI_API_BASE_URL = "{base}"
950{PROXY_ENV_END}"#
951        );
952        assert!(block.contains("ANTHROPIC_BASE_URL"));
953        assert!(block.contains("OPENAI_BASE_URL"));
954        assert!(block.contains("GEMINI_API_BASE_URL"));
955    }
956
957    /// The subscription guard reads the process environment; these tests are only
958    /// meaningful when the test runner itself does not provide an Anthropic key.
959    fn env_provides_anthropic_key() -> bool {
960        std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
961            || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
962    }
963
964    /// `claude_state_dir` honours `CLAUDE_CONFIG_DIR`; when set it would escape the
965    /// temp HOME and read the real settings file, so skip in that case.
966    fn claude_dir_overridden() -> bool {
967        std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
968    }
969
970    fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
971        let dir = home.join(".claude");
972        std::fs::create_dir_all(&dir).unwrap();
973        let path = dir.join("settings.json");
974        std::fs::write(&path, json).unwrap();
975        path
976    }
977
978    #[test]
979    fn api_key_available_true_with_api_key_helper() {
980        if claude_dir_overridden() {
981            return;
982        }
983        let home = tempfile::tempdir().unwrap();
984        write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
985        assert!(anthropic_api_key_available(home.path()));
986    }
987
988    #[test]
989    fn api_key_available_true_with_settings_env_key() {
990        if claude_dir_overridden() {
991            return;
992        }
993        let home = tempfile::tempdir().unwrap();
994        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
995        assert!(anthropic_api_key_available(home.path()));
996    }
997
998    #[test]
999    fn api_key_available_false_without_key() {
1000        if env_provides_anthropic_key() || claude_dir_overridden() {
1001            return;
1002        }
1003        let home = tempfile::tempdir().unwrap();
1004        write_claude_settings(home.path(), r#"{"env": {}}"#);
1005        assert!(!anthropic_api_key_available(home.path()));
1006    }
1007
1008    #[test]
1009    fn api_key_available_false_when_no_settings_file() {
1010        if env_provides_anthropic_key() || claude_dir_overridden() {
1011            return;
1012        }
1013        let home = tempfile::tempdir().unwrap();
1014        assert!(!anthropic_api_key_available(home.path()));
1015    }
1016
1017    #[test]
1018    fn subscription_guard_skips_redirect_without_key() {
1019        if env_provides_anthropic_key() || claude_dir_overridden() {
1020            return;
1021        }
1022        let home = tempfile::tempdir().unwrap();
1023        // No settings file → subscription mode, empty current URL → nothing to repair.
1024        install_claude_env_inner(home.path(), 4444, true, false);
1025        let settings = home.path().join(".claude/settings.json");
1026        assert!(
1027            !settings.exists(),
1028            "subscription mode must not write a proxy redirect"
1029        );
1030    }
1031
1032    #[test]
1033    fn subscription_guard_repairs_stale_local_redirect() {
1034        if env_provides_anthropic_key() || claude_dir_overridden() {
1035            return;
1036        }
1037        let home = tempfile::tempdir().unwrap();
1038        let path = write_claude_settings(
1039            home.path(),
1040            r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
1041        );
1042        install_claude_env_inner(home.path(), 4444, true, false);
1043        let after = std::fs::read_to_string(&path).unwrap();
1044        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1045        let base = doc
1046            .get("env")
1047            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1048            .and_then(|v| v.as_str())
1049            .unwrap_or("");
1050        assert!(
1051            !is_local_lean_ctx_url(base),
1052            "stale local redirect must be repaired in subscription mode, got {base:?}"
1053        );
1054    }
1055
1056    /// API-key mode must STILL route Claude through the proxy (we only protect
1057    /// subscriptions; pay-as-you-go users keep their compression). Uses a real bound
1058    /// port so `is_proxy_reachable` passes, exercising the full production path.
1059    #[test]
1060    fn install_redirects_claude_when_api_key_present() {
1061        if claude_dir_overridden() {
1062            return;
1063        }
1064        let home = tempfile::tempdir().unwrap();
1065        // API-key mode declared in settings.json → deterministic regardless of env.
1066        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1067        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1068        let port = listener.local_addr().unwrap().port();
1069
1070        install_claude_env_inner(home.path(), port, true, false);
1071
1072        let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
1073        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1074        let base = doc
1075            .get("env")
1076            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1077            .and_then(|v| v.as_str())
1078            .unwrap_or("");
1079        assert_eq!(
1080            base,
1081            format!("http://127.0.0.1:{port}"),
1082            "API-key mode must route Claude through the proxy"
1083        );
1084    }
1085
1086    /// Shell export: subscription mode keeps OpenAI/Gemini but omits the ANTHROPIC line
1087    /// (replaced by an explanatory comment), so a shell-launched Claude stays on
1088    /// api.anthropic.com.
1089    #[test]
1090    fn shell_export_omits_anthropic_without_key() {
1091        if env_provides_anthropic_key() || claude_dir_overridden() {
1092            return;
1093        }
1094        let home = tempfile::tempdir().unwrap();
1095        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1096        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1097        let port = listener.local_addr().unwrap().port();
1098
1099        install_shell_exports(home.path(), port, true);
1100
1101        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1102        assert!(
1103            rc.contains(&format!(
1104                "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
1105            )),
1106            "OpenAI export must remain and carry the /v1 suffix (#366)"
1107        );
1108        assert!(
1109            rc.contains(&format!(
1110                "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
1111            )),
1112            "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
1113        );
1114        assert!(
1115            !rc.contains("export ANTHROPIC_BASE_URL="),
1116            "ANTHROPIC export must be omitted in subscription mode"
1117        );
1118        assert!(
1119            rc.contains(ANTHROPIC_OMITTED_NOTE),
1120            "omission must be explained in the RC block"
1121        );
1122    }
1123
1124    /// Codex CLI config: a fresh install writes the `/v1`-suffixed proxy URL (#366).
1125    #[test]
1126    fn codex_env_writes_v1_suffixed_url() {
1127        let dir = tempfile::tempdir().unwrap();
1128        let codex_dir = dir.path().join(".codex");
1129        std::fs::create_dir_all(&codex_dir).unwrap();
1130        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1131        let port = listener.local_addr().unwrap().port();
1132
1133        install_codex_env_at(&codex_dir, port, true);
1134
1135        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1136        assert!(
1137            cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
1138            "Codex config must carry the /v1 suffix, got:\n{cfg}"
1139        );
1140    }
1141
1142    /// Codex CLI config: a stale local entry without `/v1` (written by older
1143    /// versions) is migrated in place instead of being treated as configured.
1144    #[test]
1145    fn codex_env_migrates_stale_entry_without_v1() {
1146        let dir = tempfile::tempdir().unwrap();
1147        let codex_dir = dir.path().join(".codex");
1148        std::fs::create_dir_all(&codex_dir).unwrap();
1149        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1150        let port = listener.local_addr().unwrap().port();
1151        std::fs::write(
1152            codex_dir.join("config.toml"),
1153            format!(
1154                "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
1155            ),
1156        )
1157        .unwrap();
1158
1159        install_codex_env_at(&codex_dir, port, true);
1160
1161        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1162        assert!(
1163            cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
1164            "stale entry must be migrated to the /v1 form, got:\n{cfg}"
1165        );
1166        assert!(
1167            cfg.contains("model = \"gpt-5.2\""),
1168            "unrelated config must be preserved"
1169        );
1170    }
1171
1172    /// Codex CLI config: a custom non-local endpoint is never rewritten.
1173    #[test]
1174    fn codex_env_preserves_custom_remote_endpoint() {
1175        let dir = tempfile::tempdir().unwrap();
1176        let codex_dir = dir.path().join(".codex");
1177        std::fs::create_dir_all(&codex_dir).unwrap();
1178        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1179        let port = listener.local_addr().unwrap().port();
1180        let original = "[env]\nOPENAI_BASE_URL = \"https://my-gateway.example.com/v1\"\n";
1181        std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1182
1183        install_codex_env_at(&codex_dir, port, true);
1184
1185        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1186        assert!(
1187            cfg.contains("https://my-gateway.example.com/v1"),
1188            "custom remote endpoint must be preserved, got:\n{cfg}"
1189        );
1190        assert!(
1191            !cfg.contains("127.0.0.1"),
1192            "proxy URL must not be injected over a custom endpoint"
1193        );
1194    }
1195
1196    /// Shell export: API-key mode includes the ANTHROPIC export (symmetry check).
1197    #[test]
1198    fn shell_export_includes_anthropic_with_key() {
1199        if claude_dir_overridden() {
1200            return;
1201        }
1202        let home = tempfile::tempdir().unwrap();
1203        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1204        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1205        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1206        let port = listener.local_addr().unwrap().port();
1207
1208        install_shell_exports(home.path(), port, true);
1209
1210        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1211        assert!(
1212            rc.contains(&format!(
1213                "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1214            )),
1215            "API-key mode must export ANTHROPIC_BASE_URL"
1216        );
1217    }
1218
1219    fn read_pi_models(agent_dir: &Path) -> serde_json::Value {
1220        let raw = std::fs::read_to_string(agent_dir.join("models.json")).unwrap();
1221        crate::core::jsonc::parse_jsonc(&raw).unwrap()
1222    }
1223
1224    /// #361: `proxy enable` must reach Pi/forge, which read `providers.*.baseUrl`
1225    /// from models.json (not ANTHROPIC_BASE_URL). Fresh install wires both
1226    /// providers with the per-SDK URL convention (anthropic bare, openai `/v1`).
1227    #[test]
1228    fn pi_env_fresh_install_writes_both_providers() {
1229        let dir = tempfile::tempdir().unwrap();
1230        let agent_dir = dir.path().join(".pi/agent");
1231        std::fs::create_dir_all(&agent_dir).unwrap();
1232        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1233        let port = listener.local_addr().unwrap().port();
1234
1235        install_pi_env_at(&agent_dir, port, true, false);
1236
1237        let doc = read_pi_models(&agent_dir);
1238        assert_eq!(
1239            pi_provider_base_url(&doc, "anthropic"),
1240            format!("http://127.0.0.1:{port}"),
1241            "Anthropic gets the bare origin (SDK appends /v1 itself)"
1242        );
1243        assert_eq!(
1244            pi_provider_base_url(&doc, "openai"),
1245            format!("http://127.0.0.1:{port}/v1"),
1246            "OpenAI gets the /v1-suffixed URL (#366)"
1247        );
1248    }
1249
1250    /// A user's custom remote gateway must survive `proxy enable` (no --force):
1251    /// only the untouched provider is pointed at the proxy.
1252    #[test]
1253    fn pi_env_preserves_custom_remote_endpoint_without_force() {
1254        let dir = tempfile::tempdir().unwrap();
1255        let agent_dir = dir.path().join(".pi/agent");
1256        std::fs::create_dir_all(&agent_dir).unwrap();
1257        std::fs::write(
1258            agent_dir.join("models.json"),
1259            r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1260        )
1261        .unwrap();
1262        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1263        let port = listener.local_addr().unwrap().port();
1264
1265        install_pi_env_at(&agent_dir, port, true, false);
1266
1267        let doc = read_pi_models(&agent_dir);
1268        assert_eq!(
1269            pi_provider_base_url(&doc, "anthropic"),
1270            "https://gw.example.com",
1271            "custom remote endpoint must be preserved without --force"
1272        );
1273        assert_eq!(
1274            pi_provider_base_url(&doc, "openai"),
1275            format!("http://127.0.0.1:{port}/v1"),
1276            "the untouched provider still gets the proxy"
1277        );
1278    }
1279
1280    /// `--force` (the `proxy enable --force` path) overrides a custom endpoint.
1281    #[test]
1282    fn pi_env_force_overrides_custom_endpoint() {
1283        let dir = tempfile::tempdir().unwrap();
1284        let agent_dir = dir.path().join(".pi/agent");
1285        std::fs::create_dir_all(&agent_dir).unwrap();
1286        std::fs::write(
1287            agent_dir.join("models.json"),
1288            r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1289        )
1290        .unwrap();
1291        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1292        let port = listener.local_addr().unwrap().port();
1293
1294        install_pi_env_at(&agent_dir, port, true, true);
1295
1296        let doc = read_pi_models(&agent_dir);
1297        assert_eq!(
1298            pi_provider_base_url(&doc, "anthropic"),
1299            format!("http://127.0.0.1:{port}"),
1300            "--force must override the custom endpoint"
1301        );
1302    }
1303
1304    /// A user without Pi installed must not get a Pi config materialized.
1305    #[test]
1306    fn pi_env_skips_when_agent_dir_absent() {
1307        let dir = tempfile::tempdir().unwrap();
1308        let agent_dir = dir.path().join(".pi/agent");
1309
1310        install_pi_env_at(&agent_dir, 19999, true, false);
1311
1312        assert!(
1313            !agent_dir.join("models.json").exists(),
1314            "no Pi config must be created when Pi is not configured"
1315        );
1316    }
1317
1318    /// `disable` reverts only the providers pointing at the local proxy; a
1319    /// user-owned custom endpoint is left untouched.
1320    #[test]
1321    fn pi_uninstall_removes_only_local_endpoints() {
1322        let dir = tempfile::tempdir().unwrap();
1323        let agent_dir = dir.path().join(".pi/agent");
1324        std::fs::create_dir_all(&agent_dir).unwrap();
1325        std::fs::write(
1326            agent_dir.join("models.json"),
1327            r#"{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:4444"},"openai":{"baseUrl":"https://api.openai.com/v1"}}}"#,
1328        )
1329        .unwrap();
1330
1331        uninstall_pi_env_at(&agent_dir, true);
1332
1333        let doc = read_pi_models(&agent_dir);
1334        assert_eq!(
1335            pi_provider_base_url(&doc, "anthropic"),
1336            "",
1337            "the local proxy endpoint we set must be removed"
1338        );
1339        assert_eq!(
1340            pi_provider_base_url(&doc, "openai"),
1341            "https://api.openai.com/v1",
1342            "a custom endpoint must be preserved on disable"
1343        );
1344    }
1345}