lean_ctx/proxy_setup/
grok.rs1use std::path::Path;
4
5use super::util::{GROK_OMITTED_NOTE, is_local_lean_ctx_url, is_proxy_reachable};
6
7pub(crate) fn install_grok_env(home: &Path, port: u16, quiet: bool, force: bool) {
19 let grok_dir = home.join(".grok");
20 let mode = effective_grok_auth_mode(home, force);
21 if grok_dir.exists() && mode != GrokAuthMode::None {
22 match mode {
26 GrokAuthMode::Subscription => {
27 ensure_proxy_provider(GROK_CHAT_PROVIDER_ID, GROK_CHAT_UPSTREAM, quiet);
28 }
29 GrokAuthMode::ApiKey => ensure_proxy_provider(XAI_PROVIDER_ID, XAI_UPSTREAM, quiet),
30 GrokAuthMode::None => {}
31 }
32 }
33 install_grok_env_at(&grok_dir, port, quiet, force, mode);
34}
35
36pub(crate) fn effective_grok_auth_mode(home: &Path, force: bool) -> GrokAuthMode {
42 match grok_auth_mode(home) {
43 GrokAuthMode::None if force => GrokAuthMode::Subscription,
44 other => other,
45 }
46}
47
48pub(crate) fn uninstall_grok_env(home: &Path, quiet: bool) {
49 uninstall_grok_env_at(&home.join(".grok"), quiet);
50}
51
52pub fn xai_api_key_available() -> bool {
54 for var in ["XAI_API_KEY", "GROK_CODE_XAI_API_KEY"] {
55 if let Ok(v) = std::env::var(var)
56 && !v.trim().is_empty()
57 {
58 return true;
59 }
60 }
61 false
62}
63
64pub fn grok_session_auth_available(home: &Path) -> bool {
66 let path = home.join(".grok/auth.json");
67 let Ok(raw) = std::fs::read_to_string(path) else {
68 return false;
69 };
70 let Ok(doc) = serde_json::from_str::<serde_json::Value>(&raw) else {
71 return false;
72 };
73 doc.as_object().is_some_and(|entries| {
75 entries.values().any(|v| {
76 v.get("key")
77 .and_then(serde_json::Value::as_str)
78 .is_some_and(|k| !k.trim().is_empty())
79 })
80 })
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub(crate) enum GrokAuthMode {
85 Subscription,
87 ApiKey,
89 None,
90}
91
92pub(crate) fn grok_auth_mode(home: &Path) -> GrokAuthMode {
95 if grok_session_auth_available(home) {
96 GrokAuthMode::Subscription
97 } else if xai_api_key_available() {
98 GrokAuthMode::ApiKey
99 } else {
100 GrokAuthMode::None
101 }
102}
103
104pub(crate) const XAI_PROVIDER_ID: &str = "xai";
105pub(crate) const XAI_UPSTREAM: &str = "https://api.x.ai";
106pub(crate) const GROK_CHAT_PROVIDER_ID: &str = "grok-chat";
107pub(crate) const GROK_CHAT_UPSTREAM: &str = "https://cli-chat-proxy.grok.com";
108
109#[derive(Debug, Clone, Copy)]
110pub(crate) enum ShellFlavor {
111 Posix,
112 Fish,
113 PowerShell,
114}
115
116pub(crate) fn grok_proxy_base_url(port: u16, mode: GrokAuthMode) -> Option<String> {
117 let base = format!("http://127.0.0.1:{port}");
118 match mode {
119 GrokAuthMode::Subscription => Some(format!("{base}/providers/{GROK_CHAT_PROVIDER_ID}/v1")),
120 GrokAuthMode::ApiKey => Some(format!("{base}/providers/{XAI_PROVIDER_ID}/v1")),
121 GrokAuthMode::None => None,
122 }
123}
124
125pub(crate) fn render_grok_shell_exports(
126 base: &str,
127 mode: GrokAuthMode,
128 flavor: ShellFlavor,
129) -> String {
130 match mode {
131 GrokAuthMode::None => format!("# {GROK_OMITTED_NOTE}"),
132 GrokAuthMode::Subscription => {
133 let url = format!("{base}/providers/{GROK_CHAT_PROVIDER_ID}/v1");
136 match flavor {
137 ShellFlavor::Posix => {
138 format!(r#"export GROK_CLI_CHAT_PROXY_BASE_URL="{url}""#)
139 }
140 ShellFlavor::Fish => {
141 format!(r#"set -gx GROK_CLI_CHAT_PROXY_BASE_URL "{url}""#)
142 }
143 ShellFlavor::PowerShell => {
144 format!(r#"$env:GROK_CLI_CHAT_PROXY_BASE_URL = "{url}""#)
145 }
146 }
147 }
148 GrokAuthMode::ApiKey => {
149 let url = format!("{base}/providers/{XAI_PROVIDER_ID}/v1");
150 match flavor {
151 ShellFlavor::Posix => format!(
152 r#"export GROK_MODELS_BASE_URL="{url}"
153export GROK_CLI_CHAT_PROXY_BASE_URL="{url}""#
154 ),
155 ShellFlavor::Fish => format!(
156 r#"set -gx GROK_MODELS_BASE_URL "{url}"
157set -gx GROK_CLI_CHAT_PROXY_BASE_URL "{url}""#
158 ),
159 ShellFlavor::PowerShell => format!(
160 r#"$env:GROK_MODELS_BASE_URL = "{url}"
161$env:GROK_CLI_CHAT_PROXY_BASE_URL = "{url}""#
162 ),
163 }
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
171pub(super) enum ProviderEnsureAction {
172 Unchanged,
174 Seeded,
176 Updated { previous: String },
178}
179
180pub(super) fn reconcile_proxy_provider(
186 providers: &mut Vec<crate::core::config::ProviderEntry>,
187 id: &str,
188 base_url: &str,
189) -> ProviderEnsureAction {
190 use crate::core::config::{ProviderEntry, WireShape, normalize_url};
191
192 let desired = normalize_url(base_url);
193 if let Some(existing) = providers
194 .iter_mut()
195 .find(|p| p.id.trim().eq_ignore_ascii_case(id))
196 {
197 if normalize_url(&existing.base_url) == desired {
198 return ProviderEnsureAction::Unchanged;
199 }
200 let previous = existing.base_url.clone();
201 existing.base_url = desired;
202 return ProviderEnsureAction::Updated { previous };
203 }
204
205 providers.push(ProviderEntry {
206 id: id.to_string(),
207 shape: WireShape::OpenAi,
208 base_url: desired,
209 api_key_env: None, aws_region: None,
211 enabled: None,
212 local: None,
213 });
214 ProviderEnsureAction::Seeded
215}
216
217pub(crate) fn ensure_proxy_provider(id: &str, base_url: &str, quiet: bool) {
222 use crate::core::config::normalize_url;
223
224 let desired = normalize_url(base_url);
225 let cfg = crate::core::config::Config::load();
226 if cfg
227 .proxy
228 .providers
229 .iter()
230 .any(|p| p.id.trim().eq_ignore_ascii_case(id) && normalize_url(&p.base_url) == desired)
231 {
232 return;
233 }
234
235 let mut action = ProviderEnsureAction::Unchanged;
236 match crate::core::config::Config::update_global(|c| {
237 action = reconcile_proxy_provider(&mut c.proxy.providers, id, &desired);
238 }) {
239 Ok(_) => {
240 if quiet {
241 return;
242 }
243 match action {
244 ProviderEnsureAction::Unchanged => {}
245 ProviderEnsureAction::Seeded => {
246 println!(" \x1b[32m✓\x1b[0m Seeded [[proxy.providers]] id={id} → {desired}");
247 }
248 ProviderEnsureAction::Updated { previous } => {
249 println!(
250 " \x1b[33m!\x1b[0m Updated [[proxy.providers]] id={id} base_url\n was: {previous}\n now: {desired}"
251 );
252 }
253 }
254 }
255 Err(e) => {
256 tracing::warn!("could not ensure {id} proxy provider: {e}");
257 if !quiet {
258 eprintln!(
259 " \u{26a0} Could not ensure {id} provider in config.toml: {e}\n \
260 Fix manually:\n [[proxy.providers]]\n id = \"{id}\"\n \
261 shape = \"openai\"\n base_url = \"{desired}\""
262 );
263 }
264 }
265 }
266}
267
268pub(crate) fn install_grok_env_at(
270 grok_dir: &Path,
271 port: u16,
272 quiet: bool,
273 force: bool,
274 mode: GrokAuthMode,
275) {
276 use crate::core::config::{is_local_proxy_url, normalize_url_opt};
277
278 if !grok_dir.exists() {
279 return;
280 }
281 if mode == GrokAuthMode::None && !force {
282 if !quiet {
283 eprintln!(" \u{26a0} Grok: no session token and no XAI_API_KEY.");
284 eprintln!(" Subscription: run `grok login`, then `lean-ctx proxy enable`.");
285 eprintln!(" API key: export XAI_API_KEY=…, then re-run proxy enable.");
286 }
287 return;
288 }
289 let mode = if mode == GrokAuthMode::None && force {
291 GrokAuthMode::Subscription
292 } else {
293 mode
294 };
295
296 if !is_proxy_reachable(port) {
297 if !quiet {
298 println!(" Skipping Grok proxy env (proxy not running on port {port})");
299 }
300 return;
301 }
302
303 let Some(proxy_url) = grok_proxy_base_url(port, mode) else {
304 return;
305 };
306 let config_path = grok_dir.join("config.toml");
307 let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
308
309 match mode {
310 GrokAuthMode::Subscription => {
311 if grok_config_has_local_proxy_entry(&existing) {
314 let cleaned = strip_grok_proxy_entries(&existing);
315 if cleaned != existing {
316 let _ = std::fs::write(&config_path, cleaned);
317 if !quiet {
318 println!(
319 " \x1b[32m✓\x1b[0m Grok subscription: removed [endpoints].models_base_url \
320 (would force API-key auth)"
321 );
322 }
323 }
324 }
325 if !quiet {
326 println!(
327 " Configured Grok subscription rail: GROK_CLI_CHAT_PROXY_BASE_URL → {proxy_url}"
328 );
329 println!(
330 " (session Bearer forwarded to {GROK_CHAT_UPSTREAM}; shell export applied)"
331 );
332 }
333 }
334 GrokAuthMode::ApiKey => {
335 if let Some(current) = grok_models_base_url(&existing) {
337 if current == proxy_url {
338 if !quiet {
339 println!(" Grok API-key rail already configured");
340 }
341 return;
342 }
343 if !force
344 && let Some(custom) = normalize_url_opt(¤t)
345 && !is_local_proxy_url(&custom)
346 && !custom.contains("/providers/xai/")
347 {
348 if !quiet {
349 eprintln!(
350 " \u{26a0} Grok: kept custom models_base_url ({current}); \
351 use `lean-ctx proxy enable --force` to override."
352 );
353 }
354 return;
355 }
356 }
357
358 let updated = upsert_grok_models_base_url(&existing, &proxy_url);
359 if updated != existing {
360 if let Some(parent) = config_path.parent() {
361 let _ = std::fs::create_dir_all(parent);
362 }
363 let _ = std::fs::write(&config_path, updated);
364 if !quiet {
365 println!(" Configured Grok [endpoints].models_base_url → proxy ({proxy_url})");
366 }
367 }
368 }
369 GrokAuthMode::None => {}
370 }
371}
372
373pub(crate) fn uninstall_grok_env_at(grok_dir: &Path, quiet: bool) {
374 let config_path = grok_dir.join("config.toml");
375 let existing = match std::fs::read_to_string(&config_path) {
376 Ok(s) if !s.trim().is_empty() => s,
377 _ => return,
378 };
379 if !grok_config_has_local_proxy_entry(&existing) {
380 return;
381 }
382 let cleaned = strip_grok_proxy_entries(&existing);
383 let _ = std::fs::write(&config_path, cleaned);
384 if !quiet {
385 println!(" \x1b[32m✓\x1b[0m Removed Grok proxy models_base_url from ~/.grok/config.toml");
386 }
387}
388
389pub(crate) fn grok_models_base_url(content: &str) -> Option<String> {
391 let doc = content.parse::<toml_edit::DocumentMut>().ok()?;
392 doc.get("endpoints")?
393 .get("models_base_url")?
394 .as_str()
395 .map(String::from)
396}
397
398pub(crate) fn grok_config_has_local_proxy_entry(content: &str) -> bool {
399 grok_models_base_url(content).is_some_and(|u| {
400 is_local_lean_ctx_url(&u) && (u.contains("/providers/xai") || u.contains("127.0.0.1"))
401 })
402}
403
404pub(crate) fn upsert_grok_models_base_url(existing: &str, proxy_url: &str) -> String {
410 let Ok(mut doc) = existing.parse::<toml_edit::DocumentMut>() else {
411 return existing.to_string();
412 };
413 if doc
415 .get("endpoints")
416 .is_some_and(|item| !item.is_table() && !item.is_inline_table() && !item.is_none())
417 {
418 doc["endpoints"] = toml_edit::table();
419 }
420 let endpoints = doc["endpoints"].or_insert(toml_edit::table());
421 endpoints["models_base_url"] = toml_edit::value(proxy_url);
422 doc.to_string()
423}
424
425pub(crate) fn strip_grok_proxy_entries(content: &str) -> String {
430 let Ok(mut doc) = content.parse::<toml_edit::DocumentMut>() else {
431 return content.to_string();
432 };
433 let should_strip = doc
434 .get("endpoints")
435 .and_then(|e| e.get("models_base_url"))
436 .and_then(|v| v.as_str())
437 .is_some_and(is_local_lean_ctx_url);
438 if !should_strip {
439 return content.to_string();
440 }
441 let empty = if let Some(tbl) = doc["endpoints"].as_table_mut() {
442 tbl.remove("models_base_url");
443 tbl.is_empty()
444 } else if let Some(tbl) = doc["endpoints"].as_inline_table_mut() {
445 tbl.remove("models_base_url");
446 tbl.is_empty()
447 } else {
448 return content.to_string();
449 };
450 if empty {
451 doc.remove("endpoints");
452 }
453 doc.to_string()
454}