Skip to main content

lean_ctx/proxy_setup/
claude.rs

1//! Claude Code settings proxy env wiring.
2
3use std::path::Path;
4
5use super::util::{is_local_lean_ctx_url, is_proxy_reachable};
6
7/// Returns true when an Anthropic **API key** is available for the proxy to forward
8/// upstream.
9///
10/// The proxy never injects credentials (see `proxy/forward.rs` — only
11/// `ALLOWED_REQUEST_HEADERS` are forwarded), so it can only help Claude Code when the
12/// user runs in API-key (pay-as-you-go) mode. A Claude **Pro/Max subscription**
13/// authenticates via OAuth directly against `api.anthropic.com`; that token is rejected
14/// by any custom `ANTHROPIC_BASE_URL`, so redirecting subscription traffic through the
15/// proxy only breaks auth (login loop / 401). When this returns `false`, callers must
16/// NOT point Claude Code at the proxy.
17pub fn anthropic_api_key_available(home: &Path) -> bool {
18    // 1) Process environment — covers shells and Claude Code launched from them.
19    for var in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] {
20        if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) {
21            return true;
22        }
23    }
24
25    // 2) Claude Code settings.json — an explicit key, an auth token, or a dynamic
26    //    key helper all indicate API-key mode.
27    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
28    let Ok(content) = std::fs::read_to_string(&settings_path) else {
29        return false;
30    };
31    let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
32        return false;
33    };
34
35    if doc
36        .get("apiKeyHelper")
37        .and_then(|v| v.as_str())
38        .is_some_and(|v| !v.trim().is_empty())
39    {
40        return true;
41    }
42
43    let env = doc.get("env");
44    ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
45        .iter()
46        .any(|key| {
47            env.and_then(|e| e.get(*key))
48                .and_then(|v| v.as_str())
49                .is_some_and(|v| !v.trim().is_empty())
50        })
51}
52
53/// Explains why Claude Code was left pointing at `api.anthropic.com` instead of the
54/// proxy: a Pro/Max subscription (OAuth) cannot authenticate through a custom base URL.
55fn warn_claude_subscription_skip() {
56    eprintln!("  \u{26a0} Claude Code: no ANTHROPIC_API_KEY detected (Pro/Max subscription?).");
57    eprintln!("    The proxy forwards your credential upstream but never injects one, and a");
58    eprintln!("    subscription token only authenticates against api.anthropic.com directly.");
59    eprintln!("    Leaving ANTHROPIC_BASE_URL untouched so Claude Code keeps working.");
60    eprintln!("    Savings on a subscription: use the lean-ctx MCP tools (ctx_read /");
61    eprintln!("    ctx_search / ctx_shell). Pay-as-you-go? Set ANTHROPIC_API_KEY, then run:");
62    eprintln!("      lean-ctx proxy enable");
63}
64
65pub(crate) fn uninstall_claude_env(home: &Path, quiet: bool) {
66    use crate::core::config::Config;
67
68    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
69    let settings_path = settings_dir.join("settings.json");
70    let existing = match std::fs::read_to_string(&settings_path) {
71        Ok(s) if !s.trim().is_empty() => s,
72        _ => return,
73    };
74    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
75        Ok(v) => v,
76        Err(_) => return,
77    };
78
79    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
80        return;
81    };
82
83    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
84        return;
85    }
86
87    let cfg = Config::load();
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        if !quiet {
94            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
95        }
96    } else {
97        env_obj.remove("ANTHROPIC_BASE_URL");
98        if env_obj.is_empty() {
99            doc.as_object_mut().map(|o| o.remove("env"));
100        }
101        if !quiet {
102            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
103        }
104    }
105
106    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
107    let _ = std::fs::write(&settings_path, content + "\n");
108}
109
110pub(crate) fn install_claude_env(home: &Path, port: u16, quiet: bool) {
111    install_claude_env_inner(home, port, quiet, false);
112}
113
114pub(crate) fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
115    use crate::core::config::{Config, is_local_proxy_url, normalize_url_opt};
116
117    let base = format!("http://127.0.0.1:{port}");
118
119    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
120    let settings_path = settings_dir.join("settings.json");
121    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
122    let mut doc: serde_json::Value = if existing.trim().is_empty() {
123        serde_json::json!({})
124    } else {
125        match crate::core::jsonc::parse_jsonc(&existing) {
126            Ok(v) => v,
127            Err(_) => return,
128        }
129    };
130
131    let current_url = doc
132        .get("env")
133        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
134        .and_then(|v| v.as_str())
135        .unwrap_or("")
136        .to_string();
137
138    // SUBSCRIPTION GUARD: the proxy never injects credentials, so redirecting Claude
139    // Code only works in API-key mode. A Claude Pro/Max subscription (OAuth) is rejected
140    // by a custom ANTHROPIC_BASE_URL → login loop / 401. When no API key is detectable we
141    // must not point Claude Code at the proxy. `--force` overrides for power users whose
142    // key lives somewhere we cannot probe (e.g. a keychain or apiKeyHelper we missed).
143    if !force && !anthropic_api_key_available(home) {
144        // Repair an existing stale local redirect so Claude Code reaches Anthropic again.
145        if is_local_lean_ctx_url(&current_url) {
146            let cfg = Config::load();
147            if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
148                if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
149                    env_obj.insert(
150                        "ANTHROPIC_BASE_URL".to_string(),
151                        serde_json::Value::String(upstream.clone()),
152                    );
153                } else {
154                    env_obj.remove("ANTHROPIC_BASE_URL");
155                    if env_obj.is_empty() {
156                        doc.as_object_mut().map(|o| o.remove("env"));
157                    }
158                }
159                let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
160                let _ = std::fs::write(&settings_path, out + "\n");
161            }
162        }
163        if !quiet {
164            warn_claude_subscription_skip();
165        }
166        return;
167    }
168
169    if current_url == base {
170        if !quiet {
171            println!("  Claude Code proxy env already configured");
172        }
173        return;
174    }
175
176    // HARD GUARD: never overwrite non-local endpoints unless --force
177    if let Some(upstream) = normalize_url_opt(&current_url)
178        && !is_local_proxy_url(&upstream)
179    {
180        if Config::load_global().proxy.anthropic_upstream.is_none()
181            && let Err(e) =
182                Config::update_global(|c| c.proxy.anthropic_upstream = Some(upstream.clone()))
183        {
184            tracing::warn!("could not persist proxy upstream: {e}");
185        }
186
187        if !force {
188            if !quiet {
189                eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
190                eprintln!(
191                    "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
192                );
193            }
194            return;
195        }
196        if !quiet {
197            println!("  Overriding custom endpoint (--force): {upstream}");
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}