diff --git a/CHANGELOG.md b/CHANGELOG.md
index e398b5c..c3d91d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
+### Added
+
+- **`grok` backend — SuperGrok / X Premium+ OAuth.** New cloud backend that
+ authenticates with xAI subscription OAuth (Authorization Code + PKCE browser
+ login, plus RFC 8628 device-code for headless/SSH) via `/login grok`. Access
+ and refresh tokens are stored in the existing `0600` `auth.json` under the
+ `grok` provider, refreshed automatically, and never injected into environment
+ variables. Requests use OpenAI-compatible chat completions at
+ `https://api.x.ai/v1` (override with `XAI_BASE_URL` / `GROK_BASE_URL`).
+ Default model is `grok-4.5`. Model selection uses a static agent-ready catalog
+ matching pi (`grok-4.5`, `grok-4.3`, `grok-build-0.1`) instead of live
+ `GET /models`. `/login grok` can import credentials from the official Grok CLI
+ (`~/.grok/auth.json`) when present. Setup wizard, `/backend`, `/doctor`, and
+ `/auth` list the new provider.
+
## [1.2.3] - 2026-07-16
### Fixed
diff --git a/README.md b/README.md
index 5fd480d..b7678f1 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,8 @@
+
+
@@ -146,6 +148,22 @@ This is intentionally separate from `/auth set openai`: `openai` uses an
refreshable ChatGPT OAuth token in `auth.json` and talks to the Codex Responses
backend.
+### Path A3 — Grok / SuperGrok subscription login
+
+Use a SuperGrok or X Premium+ subscription the same way OpenCode / Hermes do —
+browser or headless device-code OAuth, no `XAI_API_KEY`:
+
+```text
+/login grok
+/backend grok
+```
+
+Pick **1) Browser login** (opens the system browser + local callback) or
+**2) Device-code login** for SSH/headless. Tokens land in `auth.json` under
+`grok` and refresh automatically. Default model is `grok-4.5`. `/model` exposes
+the static agent-ready catalog (`grok-4.5`, `grok-4.3`, `grok-build-0.1`), same
+as pi — it does not scrape xAI's full `/models` list.
+
### Path B — Local model
*Private, free, offline — runs entirely on your machine.*
@@ -238,12 +256,14 @@ A handful of moves worth knowing right away:
| `openrouter` | `https://openrouter.ai/api/v1` | Cloud A/B with `/compare`; access to frontier models and Fusion |
| `openai` | `https://api.openai.com/v1` | Direct provider access with your own key |
| `openai-codex` | `https://chatgpt.com/backend-api/codex/responses` | ChatGPT/Codex subscription OAuth via `/login openai-codex` |
+| `grok` | `https://api.x.ai/v1` | SuperGrok / X Premium+ OAuth via `/login grok` (browser or device-code) |
Switch at runtime with `/backend `. Endpoint overrides:
`OLLAMA_BASE_URL`, `LM_STUDIO_BASE_URL`, `MLX_BASE_URL`, `LLAMACPP_BASE_URL`,
-`OPENAI_BASE_URL`, `OPENAI_CODEX_BASE_URL`. API backends require an API key
-(set via [`/auth`](#cost-and-credentials) or env var); `openai-codex` requires
-`/login openai-codex`.
+`OPENAI_BASE_URL`, `OPENAI_CODEX_BASE_URL`, `XAI_BASE_URL` / `GROK_BASE_URL`.
+API backends require an API key (set via [`/auth`](#cost-and-credentials) or
+env var); `openai-codex` requires `/login openai-codex`; `grok` requires
+`/login grok`.
### Default model per backend
@@ -260,6 +280,7 @@ or `modelOverride` in your config.
| `openrouter` | `qwen/qwen-2.5-coder-32b-instruct` |
| `openai` | `gpt-4o-mini` |
| `openai-codex` | `gpt-5.5` |
+| `grok` | `grok-4.5` |
### Recommend the right model for your box
@@ -360,6 +381,8 @@ this exact call`. The session cache resets on `/new`.
/auth manage API keys and OAuth credentials
/login openai-codex sign in with ChatGPT/Codex subscription OAuth
/logout openai-codex clear the stored ChatGPT/Codex login
+/login grok sign in with SuperGrok / X Premium+ OAuth
+/logout grok clear the stored Grok login
/image attach an image to the next user turn
/reasoning on|off toggle the streaming reasoning panel
/verbose on|off show every tool call with its full args + result
@@ -407,6 +430,8 @@ no change in behavior.
/auth clear openai remove from the file (env stays for this session)
/login openai-codex browser/device-code login with ChatGPT/Codex
/logout openai-codex remove the stored OAuth credential
+/login grok browser/device-code login with SuperGrok / X Premium+
+/logout grok remove the stored Grok OAuth credential
```
`openai-codex` is not an `OPENAI_API_KEY` replacement. It uses browser/device
@@ -414,6 +439,13 @@ OAuth, stores `{access, refresh, expires, accountId}` in the same `auth.json`,
refreshes the access token before use, and sends model traffic to the Codex
Responses backend.
+`grok` is not an `XAI_API_KEY` replacement either. It uses the same OAuth
+shape as the official Grok CLI (browser PKCE on localhost, or RFC 8628
+device-code for SSH/headless), stores tokens under the `grok` key in
+`auth.json`, refreshes automatically, and calls `https://api.x.ai/v1`
+chat completions. If `~/.grok/auth.json` already has a Grok CLI login,
+`/login grok` can import and refresh those credentials.
+
### Per-turn and session cost
When you're on a cloud backend with known pricing or provider-reported usage
@@ -538,8 +570,8 @@ unattended local execution.
To mix subscription and API usage, put subscription-backed models on tiers where
Small Harness has a real login backend, such as `openai-codex` after
-`/login openai-codex`, and keep usage-billed automation on `openai`,
-`openrouter`, or local backends. For Claude/Fable subscriptions, track usage
+`/login openai-codex` or `grok` after `/login grok`, and keep usage-billed
+automation on `openai`, `openrouter`, or local backends. For Claude/Fable subscriptions, track usage
with `/fable`; direct unattended execution should stay on an API-compatible
backend unless you add an explicit Claude CLI adapter.
@@ -916,7 +948,7 @@ Resolution order (later overrides earlier):
### Environment variables (the useful ones)
```bash
-BACKEND=ollama # ollama|lm-studio|mlx|llamacpp|openrouter|openai|openai-codex
+BACKEND=ollama # ollama|lm-studio|mlx|llamacpp|openrouter|openai|openai-codex|grok
AGENT_MODEL=qwen2.5-coder:14b # overrides the backend default model
OPENAI_API_KEY=sk-... # required for openai
@@ -1106,6 +1138,7 @@ runtime.
- **OpenRouter** — set `OPENROUTER_API_KEY` (or use `/auth set openrouter`).
- **OpenAI** — set `OPENAI_API_KEY` (or use `/auth set openai`). Use `OPENAI_BASE_URL` for a compatible proxy.
- **OpenAI Codex** — run `/login openai-codex`, then `/backend openai-codex`.
+- **Grok** — run `/login grok` (browser or device-code), then `/backend grok`.
Run `/doctor --deep` for a fuller capability probe (streaming, usage chunks,
native tool calls, inline JSON fallback). Reports land under `.sessions/doctor/`.
diff --git a/src/auth.rs b/src/auth.rs
index a084325..0921a10 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -7,9 +7,9 @@ use std::path::PathBuf;
/// Persistent on-disk credential store.
///
/// Legacy files used `{"": ""}`. New files may also store
-/// typed entries so OAuth providers (notably `openai-codex`, which uses a
-/// ChatGPT/Codex subscription login rather than an API key) can live beside API
-/// keys without changing existing user config.
+/// typed entries so OAuth providers (`openai-codex`, `grok`, …) that use a
+/// subscription login rather than an API key can live beside API keys without
+/// changing existing user config.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AuthStore {
#[serde(flatten)]
diff --git a/src/backends.rs b/src/backends.rs
index fef3026..b0ebb39 100644
--- a/src/backends.rs
+++ b/src/backends.rs
@@ -11,6 +11,7 @@ pub enum BackendName {
Openrouter,
OpenAi,
OpenAiCodex,
+ Grok,
}
impl BackendName {
@@ -23,6 +24,7 @@ impl BackendName {
BackendName::Openrouter => "openrouter",
BackendName::OpenAi => "openai",
BackendName::OpenAiCodex => "openai-codex",
+ BackendName::Grok => "grok",
}
}
pub fn parse(s: &str) -> Option {
@@ -34,6 +36,7 @@ impl BackendName {
"openrouter" => Some(Self::Openrouter),
"openai" | "open-ai" => Some(Self::OpenAi),
"openai-codex" | "open-ai-codex" | "codex" | "chatgpt" => Some(Self::OpenAiCodex),
+ "grok" | "xai" | "xai-oauth" | "x-ai" | "supergrok" | "grok-oauth" => Some(Self::Grok),
_ => None,
}
}
@@ -46,6 +49,7 @@ impl BackendName {
Self::Openrouter,
Self::OpenAi,
Self::OpenAiCodex,
+ Self::Grok,
]
}
/// True for backends that talk to a process on the user's machine, false
@@ -55,9 +59,13 @@ impl BackendName {
pub fn is_local(&self) -> bool {
match self {
Self::Ollama | Self::LmStudio | Self::Mlx | Self::LlamaCpp => true,
- Self::Openrouter | Self::OpenAi | Self::OpenAiCodex => false,
+ Self::Openrouter | Self::OpenAi | Self::OpenAiCodex | Self::Grok => false,
}
}
+
+ pub fn is_oauth_login(&self) -> bool {
+ matches!(self, Self::OpenAiCodex | Self::Grok)
+ }
}
#[derive(Debug, Clone)]
@@ -151,6 +159,15 @@ pub fn backend(name: BackendName) -> BackendDescriptor {
is_local: false,
openrouter: OpenRouterConfig::default(),
},
+ BackendName::Grok => BackendDescriptor {
+ name,
+ base_url: std::env::var("XAI_BASE_URL")
+ .or_else(|_| std::env::var("GROK_BASE_URL"))
+ .unwrap_or_else(|_| "https://api.x.ai/v1".into()),
+ api_key: String::new(),
+ is_local: false,
+ openrouter: OpenRouterConfig::default(),
+ },
}
}
@@ -165,6 +182,11 @@ pub fn default_model(b: &BackendDescriptor, override_: Option<&str>) -> String {
.unwrap_or("gpt-5.5")
.to_string();
}
+ if matches!(b.name, BackendName::Grok) {
+ return crate::xai_oauth::canonical_grok_model(m)
+ .unwrap_or("grok-4.5")
+ .to_string();
+ }
return m.to_string();
}
match b.name {
@@ -180,6 +202,7 @@ pub fn default_model(b: &BackendDescriptor, override_: Option<&str>) -> String {
BackendName::Openrouter => "qwen/qwen-2.5-coder-32b-instruct",
BackendName::OpenAi => "gpt-4o-mini",
BackendName::OpenAiCodex => "gpt-5.5",
+ BackendName::Grok => "grok-4.5",
}
.to_string()
}
@@ -202,6 +225,15 @@ pub fn validate(b: &BackendDescriptor) -> Result<()> {
"ChatGPT/Codex login is required when BACKEND=openai-codex. Run `/login openai-codex`."
));
}
+ if matches!(b.name, BackendName::Grok)
+ && crate::auth::AuthStore::load()
+ .get_oauth(crate::xai_oauth::PROVIDER)
+ .is_none()
+ {
+ return Err(anyhow!(
+ "Grok login is required when BACKEND=grok. Run `/login grok`."
+ ));
+ }
Ok(())
}
@@ -258,6 +290,28 @@ mod tests {
assert!(!BackendName::Openrouter.is_local());
assert!(!BackendName::OpenAi.is_local());
assert!(!BackendName::OpenAiCodex.is_local());
+ assert!(!BackendName::Grok.is_local());
+ assert!(BackendName::Grok.is_oauth_login());
+ }
+
+ #[test]
+ fn parses_grok_aliases() {
+ assert_eq!(BackendName::parse("grok"), Some(BackendName::Grok));
+ assert_eq!(BackendName::parse("xai"), Some(BackendName::Grok));
+ assert_eq!(BackendName::parse("xai-oauth"), Some(BackendName::Grok));
+ assert_eq!(BackendName::parse("supergrok"), Some(BackendName::Grok));
+ assert_eq!(BackendName::Grok.as_str(), "grok");
+ }
+
+ #[test]
+ fn lists_grok_as_switchable_backend() {
+ assert!(BackendName::all().contains(&BackendName::Grok));
+ }
+
+ #[test]
+ fn defaults_grok_to_grok_4_5() {
+ let model = default_model(&descriptor(BackendName::Grok), None);
+ assert_eq!(model, "grok-4.5");
}
#[test]
diff --git a/src/commands/config_cmds.rs b/src/commands/config_cmds.rs
index b5b89a5..3c5d6cf 100644
--- a/src/commands/config_cmds.rs
+++ b/src/commands/config_cmds.rs
@@ -212,35 +212,49 @@ pub(super) async fn cmd_auth(args: &str) -> Result<()> {
struct AppStateLoginOnly;
+fn normalize_login_provider(raw: &str) -> Option<&'static str> {
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "" | "openai-codex" | "codex" | "chatgpt" => Some("openai-codex"),
+ "grok" | "xai" | "xai-oauth" | "x-ai" | "supergrok" | "grok-oauth" => Some("grok"),
+ _ => None,
+ }
+}
+
pub(super) async fn cmd_login(args: &str, state: &mut impl LoginState) -> Result<()> {
- let provider = if args.trim().is_empty() {
- "openai-codex"
- } else {
- args.trim()
- };
- if !matches!(provider, "openai-codex" | "codex" | "chatgpt") {
+ let Some(provider) = normalize_login_provider(args) else {
println!(
- " {RED}✗{RESET} {DIM}unknown login provider: {provider} (try: openai-codex){RESET}"
+ " {RED}✗{RESET} {DIM}unknown login provider: {} (try: openai-codex, grok){RESET}",
+ args.trim()
);
return Ok(());
- }
+ };
- println!(" {BOLD}ChatGPT / Codex login{RESET}");
- println!(
- " {DIM}This uses your ChatGPT/Codex subscription OAuth token, not OPENAI_API_KEY.{RESET}"
- );
+ let (title, note) = match provider {
+ "grok" => (
+ "Grok / SuperGrok login",
+ "This uses your SuperGrok or X Premium+ subscription OAuth, not XAI_API_KEY.",
+ ),
+ _ => (
+ "ChatGPT / Codex login",
+ "This uses your ChatGPT/Codex subscription OAuth token, not OPENAI_API_KEY.",
+ ),
+ };
+ println!(" {BOLD}{title}{RESET}");
+ println!(" {DIM}{note}{RESET}");
println!(" {DIM}1) Browser login (default){RESET}");
println!(" {DIM}2) Device-code login (headless/SSH){RESET}");
let pick = plain_read_line(format!(" {DIM}Select [1]: {RESET}")).await?;
- let result = if pick.trim() == "2" || pick.trim().eq_ignore_ascii_case("device") {
- crate::codex_oauth::login_and_save_device_code(state.http()).await
- } else {
- crate::codex_oauth::login_and_save_browser(state.http()).await
+ let device = pick.trim() == "2" || pick.trim().eq_ignore_ascii_case("device");
+ let result = match (provider, device) {
+ ("grok", true) => crate::xai_oauth::login_and_save_device_code(state.http()).await,
+ ("grok", false) => crate::xai_oauth::login_and_save_browser(state.http()).await,
+ (_, true) => crate::codex_oauth::login_and_save_device_code(state.http()).await,
+ (_, false) => crate::codex_oauth::login_and_save_browser(state.http()).await,
};
match result {
Ok(path) => {
println!(
- " {GREEN}✓{RESET} {DIM}logged in to openai-codex; saved to {}{RESET}",
+ " {GREEN}✓{RESET} {DIM}logged in to {provider}; saved to {}{RESET}",
path.display()
);
state.after_login()?;
@@ -260,7 +274,7 @@ impl LoginState for AppState {
&self.http
}
fn after_login(&mut self) -> Result<()> {
- if matches!(self.config.backend, BackendName::OpenAiCodex) {
+ if self.config.backend.is_oauth_login() {
self.rebuild_client()?;
self.resolve_model();
}
@@ -279,43 +293,25 @@ impl LoginState for AppStateLoginOnly {
}
pub(super) fn cmd_logout(args: &str) -> Result<()> {
- let provider = if args.trim().is_empty() {
- "openai-codex"
- } else {
- args.trim()
- };
- if !matches!(provider, "openai-codex" | "codex" | "chatgpt") {
+ let Some(provider) = normalize_login_provider(args) else {
println!(
- " {RED}✗{RESET} {DIM}unknown logout provider: {provider} (try: openai-codex){RESET}"
+ " {RED}✗{RESET} {DIM}unknown logout provider: {} (try: openai-codex, grok){RESET}",
+ args.trim()
);
return Ok(());
- }
+ };
let mut store = crate::auth::AuthStore::load();
- if store.clear("openai-codex") {
+ if store.clear(provider) {
store.save()?;
- println!(" {GREEN}✓{RESET} {DIM}cleared openai-codex login{RESET}");
+ println!(" {GREEN}✓{RESET} {DIM}cleared {provider} login{RESET}");
} else {
- println!(" {DIM}no stored openai-codex login{RESET}");
+ println!(" {DIM}no stored {provider} login{RESET}");
}
Ok(())
}
-fn print_auth_status() {
- use crate::auth::{auth_file_path, mask_key, AuthStore, KNOWN_PROVIDERS};
- let store = AuthStore::load();
- println!(" {DIM}provider status source{RESET}");
- for (provider, env_name) in KNOWN_PROVIDERS {
- let env_val = std::env::var(env_name).unwrap_or_default();
- let (display, source) = if !env_val.is_empty() {
- (mask_key(&env_val), format!("env: {env_name}"))
- } else if let Some(k) = store.get(provider) {
- (mask_key(k), "auth file".into())
- } else {
- ("(not set)".into(), "—".into())
- };
- println!(" {:<12} {:<22} {DIM}{}{RESET}", provider, display, source);
- }
- if let Some(oauth) = store.get_oauth("openai-codex") {
+fn print_oauth_status(store: &crate::auth::AuthStore, provider: &str, login_hint: &str) {
+ if let Some(oauth) = store.get_oauth(provider) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
@@ -330,16 +326,32 @@ fn print_auth_status() {
.as_ref()
.map(|id| format!("auth file · account {id}"))
.unwrap_or_else(|| "auth file".into());
- println!(
- " {:<12} {:<22} {DIM}{}{RESET}",
- "openai-codex", status, source
- );
+ println!(" {:<12} {:<22} {DIM}{}{RESET}", provider, status, source);
} else {
println!(
- " {:<12} {:<22} {DIM}/login openai-codex{RESET}",
- "openai-codex", "(not logged in)"
+ " {:<12} {:<22} {DIM}{login_hint}{RESET}",
+ provider, "(not logged in)"
);
}
+}
+
+fn print_auth_status() {
+ use crate::auth::{auth_file_path, mask_key, AuthStore, KNOWN_PROVIDERS};
+ let store = AuthStore::load();
+ println!(" {DIM}provider status source{RESET}");
+ for (provider, env_name) in KNOWN_PROVIDERS {
+ let env_val = std::env::var(env_name).unwrap_or_default();
+ let (display, source) = if !env_val.is_empty() {
+ (mask_key(&env_val), format!("env: {env_name}"))
+ } else if let Some(k) = store.get(provider) {
+ (mask_key(k), "auth file".into())
+ } else {
+ ("(not set)".into(), "—".into())
+ };
+ println!(" {:<12} {:<22} {DIM}{}{RESET}", provider, display, source);
+ }
+ print_oauth_status(&store, "openai-codex", "/login openai-codex");
+ print_oauth_status(&store, "grok", "/login grok");
if let Some(path) = auth_file_path() {
println!(" {DIM}file{RESET} {}", path.display());
}
@@ -531,14 +543,22 @@ pub(super) async fn cmd_backend(args: &str, state: &mut AppState) -> Result<()>
);
return Ok(());
}
- if !chosen.is_local()
- && !matches!(chosen, BackendName::OpenAiCodex)
- && backend(chosen).api_key.is_empty()
+ if matches!(chosen, BackendName::Grok)
+ && crate::auth::AuthStore::load()
+ .get_oauth(crate::xai_oauth::PROVIDER)
+ .is_none()
{
+ println!(
+ " {RED}✗{RESET} {DIM}not logged in for grok. Run /login grok to sign in with SuperGrok / X Premium+.{RESET}"
+ );
+ return Ok(());
+ }
+ if !chosen.is_local() && !chosen.is_oauth_login() && backend(chosen).api_key.is_empty() {
let env_name = match chosen {
BackendName::Openrouter => "OPENROUTER_API_KEY",
BackendName::OpenAi => "OPENAI_API_KEY",
BackendName::OpenAiCodex => "ChatGPT login",
+ BackendName::Grok => "Grok login",
_ => "API key",
};
println!(" {RED}✗{RESET} {DIM}{env_name} not set in environment.{RESET}");
@@ -569,6 +589,15 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> {
return Ok(());
};
canonical.to_string()
+ } else if matches!(state.config.backend, BackendName::Grok) {
+ let Some(canonical) = crate::xai_oauth::canonical_grok_model(args) else {
+ println!(
+ " {RED}✗{RESET} {DIM}{args} is not supported with Grok login. Try one of: {}{RESET}",
+ crate::xai_oauth::grok_model_list().join(", ")
+ );
+ return Ok(());
+ };
+ canonical.to_string()
} else {
args.to_string()
};
@@ -602,14 +631,23 @@ pub(super) async fn cmd_model(args: &str, state: &mut AppState) -> Result<()> {
println!(" {DIM}No models available.{RESET}");
return Ok(());
}
- let prompt = format!(" {DIM}Filter (blank for all):{RESET} ");
- let filter = plain_read_line(prompt).await?.trim().to_lowercase();
- let matches: Vec = if filter.is_empty() {
- ids
+ println!(
+ " {DIM}{} model(s) from {}{RESET}",
+ ids.len(),
+ state.config.backend.as_str()
+ );
+ let matches: Vec = if ids.len() > 20 {
+ let prompt = format!(" {DIM}Filter (blank for all):{RESET} ");
+ let filter = plain_read_line(prompt).await?.trim().to_lowercase();
+ if filter.is_empty() {
+ ids
+ } else {
+ ids.into_iter()
+ .filter(|m| m.to_lowercase().contains(&filter))
+ .collect()
+ }
} else {
- ids.into_iter()
- .filter(|m| m.to_lowercase().contains(&filter))
- .collect()
+ ids
};
let total = matches.len();
let shown: Vec = matches.into_iter().take(20).collect();
diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs
index 0266a01..598b91d 100644
--- a/src/commands/doctor.rs
+++ b/src/commands/doctor.rs
@@ -69,11 +69,19 @@ async fn cmd_doctor_probe(args: &str, state: &AppState) -> Result<()> {
" {RED}✗{RESET} {DIM}openai-codex login missing; run /login openai-codex{RESET}"
);
}
+ } else if matches!(state.config.backend, BackendName::Grok) {
+ if crate::auth::AuthStore::load()
+ .get_oauth(crate::xai_oauth::PROVIDER)
+ .is_none()
+ {
+ println!(" {RED}✗{RESET} {DIM}grok login missing; run /login grok{RESET}");
+ }
} else if !state.config.backend.is_local() && state.backend.api_key.is_empty() {
let env_name = match state.config.backend {
BackendName::Openrouter => "OPENROUTER_API_KEY",
BackendName::OpenAi => "OPENAI_API_KEY",
BackendName::OpenAiCodex => "ChatGPT login",
+ BackendName::Grok => "Grok login",
_ => "API key",
};
println!(" {RED}✗{RESET} {DIM}{env_name} missing{RESET}");
@@ -961,6 +969,7 @@ fn backend_model_hint(backend_name: BackendName, model: &str) -> String {
BackendName::Openrouter => "set OPENROUTER_API_KEY before using OpenRouter".into(),
BackendName::OpenAi => "set OPENAI_API_KEY before using OpenAI".into(),
BackendName::OpenAiCodex => "run /login openai-codex before using ChatGPT/Codex".into(),
+ BackendName::Grok => "run /login grok before using SuperGrok / X Premium+".into(),
}
}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index 2607a2f..6b60cea 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -164,9 +164,9 @@ pub const COMMANDS: &[(&str, &str)] = &[
),
(
"/login",
- "Browser/device-code login for subscription providers (openai-codex)",
+ "Browser/device-code login for subscription providers (openai-codex, grok)",
),
- ("/logout", "Clear an OAuth login (openai-codex)"),
+ ("/logout", "Clear an OAuth login (openai-codex, grok)"),
(
"/image",
"Attach an image to the next user prompt (vision-capable models only)",
@@ -189,7 +189,7 @@ pub const COMMANDS: &[(&str, &str)] = &[
),
(
"/backend",
- "Switch backend (ollama, lm-studio, mlx, llamacpp, openrouter, openai, openai-codex)",
+ "Switch backend (ollama, lm-studio, mlx, llamacpp, openrouter, openai, openai-codex, grok)",
),
(
"/model",
diff --git a/src/config.rs b/src/config.rs
index 6124492..e28e0d0 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -863,6 +863,10 @@ impl AgentConfig {
self.render_system_prompt_for_tools(&self.tools)
}
+ pub fn system_prompt_template(&self) -> &str {
+ self.system_prompt.as_str()
+ }
+
pub fn render_system_prompt_for_tools(&self, tools: &[String]) -> String {
let cwd = std::env::current_dir()
.map(|p| p.display().to_string())
@@ -873,10 +877,10 @@ impl AgentConfig {
tools.join(", ")
};
let mut prompt = self
- .system_prompt
+ .system_prompt_template()
.replace("{cwd}", &cwd)
.replace("{tools}", &tool_list);
- if self.mode == OperatorMode::Ship {
+ if self.mode == OperatorMode::Ship && !prompt.is_empty() {
prompt.push_str(
"\n\nShip mode:\n\
- Prefer run_tests over raw shell for test execution.\n\
@@ -1306,6 +1310,55 @@ pub fn load_config() -> AgentConfig {
mod tests {
use super::*;
+ #[test]
+ fn all_backends_share_the_same_stock_system_prompt() {
+ for backend in [
+ BackendName::Ollama,
+ BackendName::LmStudio,
+ BackendName::Mlx,
+ BackendName::LlamaCpp,
+ BackendName::Openrouter,
+ BackendName::OpenAi,
+ BackendName::OpenAiCodex,
+ BackendName::Grok,
+ ] {
+ let config = AgentConfig {
+ backend,
+ ..Default::default()
+ };
+ assert_eq!(config.system_prompt_template(), SYSTEM_PROMPT);
+ let rendered = config.render_system_prompt_for_tools(&["file_read".into()]);
+ assert!(rendered.contains("small open-weight LLM"));
+ assert!(rendered.contains("file_read"));
+ assert!(rendered.contains("Current working directory:"));
+ }
+ }
+
+ #[test]
+ fn custom_system_prompt_is_used_as_is() {
+ let config = AgentConfig {
+ system_prompt: "You are a custom assistant.".into(),
+ ..Default::default()
+ };
+ assert_eq!(
+ config.system_prompt_template(),
+ "You are a custom assistant."
+ );
+ assert!(config
+ .render_system_prompt()
+ .starts_with("You are a custom assistant."));
+ }
+
+ #[test]
+ fn empty_system_prompt_sends_nothing() {
+ let config = AgentConfig {
+ system_prompt: String::new(),
+ ..Default::default()
+ };
+ assert_eq!(config.system_prompt_template(), "");
+ assert_eq!(config.render_system_prompt(), "");
+ }
+
#[test]
fn parses_dotenv_quotes_and_comments() {
let dir = tempfile::tempdir().unwrap();
diff --git a/src/main.rs b/src/main.rs
index 3733d16..6c053e5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -53,6 +53,7 @@ mod turn_checkpoint;
mod turn_trace;
mod update_check;
mod warmup;
+mod xai_oauth;
use std::io::{IsTerminal, Read, Write};
@@ -653,6 +654,9 @@ async fn probe_backend(
crate::backends::BackendName::OpenAiCodex => {
"Run `/login openai-codex` to sign in with ChatGPT/Codex."
}
+ crate::backends::BackendName::Grok => {
+ "Run `/login grok` to sign in with SuperGrok / X Premium+."
+ }
};
Err(format!("{e}. {hint}"))
}
@@ -697,15 +701,25 @@ async fn main() -> anyhow::Result<()> {
crate::theme::init(config.display.color, config.display.ascii);
let http = crate::openai::build_http_client();
let backend_desc = config.backend_descriptor();
- let missing_codex_login = matches!(config.backend, BackendName::OpenAiCodex)
- && crate::auth::AuthStore::load()
- .get_oauth("openai-codex")
- .is_none();
+ let missing_oauth_login = config.backend.is_oauth_login()
+ && match config.backend {
+ BackendName::OpenAiCodex => crate::auth::AuthStore::load()
+ .get_oauth("openai-codex")
+ .is_none(),
+ BackendName::Grok => crate::auth::AuthStore::load()
+ .get_oauth(crate::xai_oauth::PROVIDER)
+ .is_none(),
+ _ => false,
+ };
if let Err(e) = validate(&backend_desc) {
- if missing_codex_login {
+ if missing_oauth_login {
+ let login_cmd = match config.backend {
+ BackendName::Grok => "/login grok",
+ _ => "/login openai-codex",
+ };
println!(" {YELLOW}!{RESET} {DIM}{e}{RESET}");
println!(
- " {DIM}Starting anyway so you can run /login openai-codex, or /backend to switch.{RESET}"
+ " {DIM}Starting anyway so you can run {login_cmd}, or /backend to switch.{RESET}"
);
} else {
eprintln!("{e}");
@@ -736,8 +750,11 @@ async fn main() -> anyhow::Result<()> {
}
let mut warmed_fingerprint = None;
- let probe = if missing_codex_login {
- Err("Run /login openai-codex to sign in with ChatGPT/Codex.".to_string())
+ let probe = if missing_oauth_login {
+ Err(match config.backend {
+ BackendName::Grok => "Run /login grok to sign in with SuperGrok / X Premium+.".into(),
+ _ => "Run /login openai-codex to sign in with ChatGPT/Codex.".into(),
+ })
} else {
probe_backend(&http, &backend_desc).await
};
diff --git a/src/openai.rs b/src/openai.rs
index 8deff62..80bc57e 100644
--- a/src/openai.rs
+++ b/src/openai.rs
@@ -205,6 +205,13 @@ pub fn build_http_client() -> reqwest::Client {
.expect("failed to build HTTP client")
}
+async fn resolve_bearer(client: &reqwest::Client, backend: &BackendDescriptor) -> Result {
+ if matches!(backend.name, BackendName::Grok) {
+ return crate::xai_oauth::access_token(client).await;
+ }
+ Ok(backend.api_key.clone())
+}
+
pub async fn list_models(
client: &reqwest::Client,
backend: &BackendDescriptor,
@@ -212,6 +219,11 @@ pub async fn list_models(
if matches!(backend.name, BackendName::OpenAiCodex) {
return Ok(crate::codex_responses::codex_model_list());
}
+ if matches!(backend.name, BackendName::Grok) {
+ // Static catalog (same shape as openai-codex): avoid GET /models on
+ // every `/model` open and only expose agent-ready Grok ids.
+ return Ok(crate::xai_oauth::grok_model_list());
+ }
let url = format!("{}/models", backend.base_url.trim_end_matches('/'));
let resp = client.get(url).bearer_auth(&backend.api_key).send().await?;
if !resp.status().is_success() {
@@ -243,9 +255,10 @@ pub async fn chat_oneshot(
backend.base_url.trim_end_matches('/')
);
let body = request_body(backend, req)?;
+ let bearer = resolve_bearer(client, backend).await?;
let resp = client
.post(url)
- .bearer_auth(&backend.api_key)
+ .bearer_auth(bearer)
.json(&body)
.send()
.await?;
@@ -343,9 +356,10 @@ where
backend.base_url.trim_end_matches('/')
);
let body = request_body(backend, req)?;
+ let bearer = resolve_bearer(client, backend).await?;
let resp = client
.post(url)
- .bearer_auth(&backend.api_key)
+ .bearer_auth(bearer)
.json(&body)
.send()
.await?;
@@ -413,7 +427,7 @@ fn apply_effort_to_request(
serde_json::json!({ "effort": effort.openrouter_reasoning_effort() }),
);
}
- BackendName::OpenAi => {
+ BackendName::OpenAi | BackendName::Grok => {
if let Some(value) = effort.openai_reasoning_effort() {
obj.insert("reasoning_effort".into(), Value::String(value.into()));
}
diff --git a/src/session_turn.rs b/src/session_turn.rs
index d53f1c6..14ff79f 100644
--- a/src/session_turn.rs
+++ b/src/session_turn.rs
@@ -186,6 +186,8 @@ fn format_footer(
path_suffix: &str,
scorecard_suffix: &str,
fable_suffix: &str,
+ model: &str,
+ endpoint: &str,
) -> String {
let mut parts = vec![
format!("{} in", format_tokens(input_tokens)),
@@ -217,6 +219,12 @@ fn format_footer(
if !fable_suffix.is_empty() {
parts.push(fable_suffix.to_string());
}
+ if !model.is_empty() {
+ parts.push(model.to_string());
+ }
+ if !endpoint.is_empty() {
+ parts.push(endpoint.trim_end_matches('/').to_string());
+ }
format!("{GRAY} {}{RESET}", parts.join(" · "))
}
@@ -931,6 +939,8 @@ pub async fn run_user_turn(state: &mut AppState, opts: TurnOptions) -> Result &'static str {
BackendName::OpenAiCodex => {
"run `/login openai-codex` to sign in with ChatGPT/Codex subscription OAuth."
}
+ BackendName::Grok => {
+ "run `/login grok` to sign in with SuperGrok / X Premium+ (browser or device-code)."
+ }
}
}
diff --git a/src/xai_oauth.rs b/src/xai_oauth.rs
new file mode 100644
index 0000000..91f121e
--- /dev/null
+++ b/src/xai_oauth.rs
@@ -0,0 +1,834 @@
+use anyhow::{anyhow, Context, Result};
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
+use serde::Deserialize;
+use serde_json::Value;
+use sha2::{Digest, Sha256};
+use std::io::{Read, Write};
+use std::net::{TcpListener, TcpStream};
+use std::path::PathBuf;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
+
+use crate::auth::{auth_file_path, AuthStore, OAuthCredential};
+use crate::input::plain_read_line;
+
+pub const PROVIDER: &str = "grok";
+const CLIENT_ID: &str = "b1a00492-073a-47ea-816f-4c329264a828";
+const ISSUER: &str = "https://auth.x.ai";
+const DISCOVERY_URL: &str = "https://auth.x.ai/.well-known/openid-configuration";
+const SCOPE: &str = "openid profile email offline_access grok-cli:access api:access";
+const REDIRECT_HOST: &str = "127.0.0.1";
+const PREFERRED_REDIRECT_PORT: u16 = 56121;
+const REDIRECT_PATH: &str = "/callback";
+const GROK_CLI_AUTH_SCOPE_KEY: &str = "https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828";
+const GROK_CLI_LEGACY_SCOPE_KEY: &str = "https://accounts.x.ai/sign-in";
+/// Curated agent-ready Grok models, matching pi's built-in xAI catalog.
+/// Kept static so `/model` does not hit `GET /models` on every open.
+pub const GROK_MODEL_LIST: &[&str] = &["grok-4.5", "grok-4.3", "grok-build-0.1"];
+
+fn now_secs() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+fn random_bytes() -> [u8; N] {
+ let mut out = [0u8; N];
+ getrandom::getrandom(&mut out).expect("OS CSPRNG (getrandom) unavailable");
+ out
+}
+
+fn random_hex(bytes: usize) -> String {
+ random_bytes::<32>()[..bytes]
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect()
+}
+
+fn pkce_pair() -> (String, String) {
+ let verifier = URL_SAFE_NO_PAD.encode(random_bytes::<32>());
+ let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
+ (verifier, challenge)
+}
+
+fn percent_encode(input: &str) -> String {
+ let mut out = String::new();
+ for b in input.bytes() {
+ match b {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ out.push(b as char)
+ }
+ _ => out.push_str(&format!("%{b:02X}")),
+ }
+ }
+ out
+}
+
+fn form_urlencoded(params: &[(&str, &str)]) -> String {
+ params
+ .iter()
+ .map(|(k, v)| format!("{}={}", percent_encode(k), percent_encode(v)))
+ .collect::>()
+ .join("&")
+}
+
+fn percent_decode(input: &str) -> String {
+ let mut out = Vec::new();
+ let bytes = input.as_bytes();
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ if let Ok(hex) = std::str::from_utf8(&bytes[i + 1..i + 3]) {
+ if let Ok(value) = u8::from_str_radix(hex, 16) {
+ out.push(value);
+ i += 3;
+ continue;
+ }
+ }
+ }
+ out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+fn query_param(path: &str, name: &str) -> Option {
+ let raw_query = path.split_once('?').map(|(_, q)| q).unwrap_or(path);
+ let query = raw_query
+ .split_once('#')
+ .map(|(q, _)| q)
+ .unwrap_or(raw_query);
+ for pair in query.split('&') {
+ let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
+ if k == name {
+ return Some(percent_decode(v));
+ }
+ }
+ None
+}
+
+fn parse_authorization_input(input: &str) -> (Option, Option) {
+ let value = input.trim();
+ if value.contains("code=") || value.contains("state=") {
+ (query_param(value, "code"), query_param(value, "state"))
+ } else if let Some((code, state)) = value.split_once('#') {
+ (Some(code.to_string()), Some(state.to_string()))
+ } else if value.is_empty() {
+ (None, None)
+ } else {
+ (Some(value.to_string()), None)
+ }
+}
+
+#[derive(Debug, Clone)]
+struct Discovery {
+ authorization_endpoint: String,
+ token_endpoint: String,
+ device_authorization_endpoint: String,
+}
+
+fn validate_xai_endpoint(url: &str) -> Result {
+ let parsed = reqwest::Url::parse(url).context("parsing xAI OAuth endpoint")?;
+ if parsed.scheme() != "https" {
+ return Err(anyhow!(
+ "xAI OAuth discovery returned non-https endpoint: {url}"
+ ));
+ }
+ let host = parsed.host_str().unwrap_or_default().to_ascii_lowercase();
+ if host != "x.ai" && !host.ends_with(".x.ai") {
+ return Err(anyhow!(
+ "xAI OAuth discovery returned unexpected host: {url}"
+ ));
+ }
+ Ok(url.to_string())
+}
+
+async fn discover(client: &reqwest::Client) -> Result {
+ let resp = client
+ .get(DISCOVERY_URL)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .context("fetching xAI OIDC discovery")?;
+ if !resp.status().is_success() {
+ let status = resp.status();
+ let body = resp.text().await.unwrap_or_default();
+ return Err(anyhow!(
+ "xAI OAuth discovery failed ({status}): {}",
+ body.trim()
+ ));
+ }
+ let data: Value = resp.json().await?;
+ let auth = data
+ .get("authorization_endpoint")
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| anyhow!("xAI discovery missing authorization_endpoint"))?;
+ let token = data
+ .get("token_endpoint")
+ .and_then(|v| v.as_str())
+ .ok_or_else(|| anyhow!("xAI discovery missing token_endpoint"))?;
+ let device = data
+ .get("device_authorization_endpoint")
+ .and_then(|v| v.as_str())
+ .unwrap_or("https://auth.x.ai/oauth2/device/code");
+ Ok(Discovery {
+ authorization_endpoint: validate_xai_endpoint(auth)?,
+ token_endpoint: validate_xai_endpoint(token)?,
+ device_authorization_endpoint: validate_xai_endpoint(device)?,
+ })
+}
+
+fn authorization_url(
+ discovery: &Discovery,
+ redirect_uri: &str,
+ challenge: &str,
+ state: &str,
+ nonce: &str,
+) -> String {
+ let params = [
+ ("response_type", "code"),
+ ("client_id", CLIENT_ID),
+ ("redirect_uri", redirect_uri),
+ ("scope", SCOPE),
+ ("code_challenge", challenge),
+ ("code_challenge_method", "S256"),
+ ("state", state),
+ ("nonce", nonce),
+ ];
+ format!(
+ "{}?{}",
+ discovery.authorization_endpoint,
+ form_urlencoded(¶ms)
+ )
+}
+
+fn write_callback_response(mut stream: TcpStream, ok: bool, message: &str, origin: Option<&str>) {
+ let title = if ok {
+ "Grok login complete"
+ } else {
+ "Grok login failed"
+ };
+ let body = format!(
+ "{}
{}
{}
",
+ title, title, message
+ );
+ let status = if ok { "200 OK" } else { "400 Bad Request" };
+ let mut headers = format!(
+ "HTTP/1.1 {status}\r\ncontent-type: text/html; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n",
+ body.len()
+ );
+ if let Some(origin) = origin {
+ headers.push_str(&format!(
+ "access-control-allow-origin: {origin}\r\naccess-control-allow-methods: GET, OPTIONS\r\naccess-control-allow-headers: Content-Type\r\naccess-control-allow-private-network: true\r\nvary: Origin\r\n"
+ ));
+ }
+ headers.push_str("\r\n");
+ headers.push_str(&body);
+ let _ = stream.write_all(headers.as_bytes());
+}
+
+fn cors_origin(headers: &str) -> Option {
+ for line in headers.lines() {
+ let line = line.trim();
+ if let Some(rest) = line
+ .strip_prefix("Origin:")
+ .or_else(|| line.strip_prefix("origin:"))
+ {
+ let origin = rest.trim();
+ if origin == "https://accounts.x.ai" || origin == "https://auth.x.ai" {
+ return Some(origin.to_string());
+ }
+ }
+ }
+ None
+}
+
+fn open_browser(url: &str) -> Result<()> {
+ #[cfg(target_os = "macos")]
+ let mut cmd = std::process::Command::new("open");
+ #[cfg(target_os = "linux")]
+ let mut cmd = std::process::Command::new("xdg-open");
+ #[cfg(target_os = "windows")]
+ let mut cmd = {
+ let mut c = std::process::Command::new("cmd");
+ c.args(["/C", "start", ""]);
+ c
+ };
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ let _ = url;
+ return Err(anyhow!("no browser open helper on this platform"));
+ }
+ #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
+ {
+ cmd.arg(url);
+ cmd.spawn().context("opening browser")?;
+ Ok(())
+ }
+}
+
+#[derive(Debug, Deserialize)]
+struct TokenResponse {
+ access_token: String,
+ #[serde(default)]
+ refresh_token: Option,
+ #[serde(default)]
+ expires_in: Option,
+}
+
+async fn read_token_response(
+ resp: reqwest::Response,
+ operation: &str,
+ fallback_refresh: Option<&str>,
+) -> Result {
+ if !resp.status().is_success() {
+ let status = resp.status();
+ let body = resp.text().await.unwrap_or_default();
+ return Err(anyhow!(
+ "xAI Grok token {operation} failed ({status}): {}",
+ body.trim()
+ ));
+ }
+ let token: TokenResponse = resp.json().await?;
+ let refresh = token
+ .refresh_token
+ .filter(|s| !s.is_empty())
+ .or_else(|| fallback_refresh.map(str::to_string))
+ .ok_or_else(|| anyhow!("xAI token response missing refresh_token"))?;
+ Ok(OAuthCredential {
+ credential_type: "oauth".into(),
+ access: token.access_token,
+ refresh,
+ expires: now_secs() + token.expires_in.unwrap_or(3600),
+ account_id: None,
+ })
+}
+
+async fn exchange_authorization_code(
+ client: &reqwest::Client,
+ token_endpoint: &str,
+ code: &str,
+ verifier: &str,
+ redirect_uri: &str,
+) -> Result {
+ let body = form_urlencoded(&[
+ ("grant_type", "authorization_code"),
+ ("client_id", CLIENT_ID),
+ ("code", code),
+ ("code_verifier", verifier),
+ ("redirect_uri", redirect_uri),
+ ]);
+ let resp = client
+ .post(token_endpoint)
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .header("Accept", "application/json")
+ .body(body)
+ .send()
+ .await?;
+ read_token_response(resp, "exchange", None).await
+}
+
+pub async fn refresh_oauth(
+ client: &reqwest::Client,
+ refresh: &str,
+ token_endpoint: Option<&str>,
+) -> Result {
+ let endpoint = match token_endpoint {
+ Some(url) => validate_xai_endpoint(url)?,
+ None => discover(client).await?.token_endpoint,
+ };
+ let body = form_urlencoded(&[
+ ("grant_type", "refresh_token"),
+ ("refresh_token", refresh),
+ ("client_id", CLIENT_ID),
+ ]);
+ let resp = client
+ .post(endpoint)
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .header("Accept", "application/json")
+ .body(body)
+ .send()
+ .await?;
+ read_token_response(resp, "refresh", Some(refresh)).await
+}
+
+fn save_oauth(credential: OAuthCredential) -> Result {
+ let mut store = AuthStore::load();
+ store.set_oauth(PROVIDER, credential);
+ store.save()?;
+ auth_file_path().context("no auth file path")
+}
+
+fn bind_callback_port() -> Result<(TcpListener, u16)> {
+ match TcpListener::bind((REDIRECT_HOST, PREFERRED_REDIRECT_PORT)) {
+ Ok(listener) => Ok((listener, PREFERRED_REDIRECT_PORT)),
+ Err(_) => {
+ let listener = TcpListener::bind((REDIRECT_HOST, 0))
+ .context("binding ephemeral OAuth callback port")?;
+ let port = listener.local_addr()?.port();
+ Ok((listener, port))
+ }
+ }
+}
+
+fn wait_for_browser_callback_with_listener(listener: TcpListener, state: String) -> Result {
+ loop {
+ let (mut stream, _) = listener.accept().context("waiting for OAuth callback")?;
+ let mut buf = [0u8; 8192];
+ let n = stream.read(&mut buf).unwrap_or(0);
+ let request = String::from_utf8_lossy(&buf[..n]);
+ let first = request.lines().next().unwrap_or_default();
+ let method = first.split_whitespace().next().unwrap_or_default();
+ let path = first.split_whitespace().nth(1).unwrap_or_default();
+ let origin = cors_origin(&request);
+
+ if method.eq_ignore_ascii_case("OPTIONS") {
+ let mut headers = "HTTP/1.1 204 No Content\r\nconnection: close\r\n".to_string();
+ if let Some(ref o) = origin {
+ headers.push_str(&format!(
+ "access-control-allow-origin: {o}\r\naccess-control-allow-methods: GET, OPTIONS\r\naccess-control-allow-headers: Content-Type\r\naccess-control-allow-private-network: true\r\nvary: Origin\r\n"
+ ));
+ }
+ headers.push_str("\r\n");
+ let _ = stream.write_all(headers.as_bytes());
+ continue;
+ }
+
+ if !path.starts_with(REDIRECT_PATH) {
+ write_callback_response(
+ stream,
+ false,
+ "Callback route not found.",
+ origin.as_deref(),
+ );
+ return Err(anyhow!("unexpected OAuth callback path"));
+ }
+ if let Some(err) = query_param(path, "error") {
+ let desc = query_param(path, "error_description").unwrap_or_default();
+ write_callback_response(
+ stream,
+ false,
+ &format!("Authorization failed: {err} {desc}"),
+ origin.as_deref(),
+ );
+ return Err(anyhow!("xAI authorization failed: {err} {desc}"));
+ }
+ let got_state = query_param(path, "state");
+ if got_state.as_deref() != Some(state.as_str()) {
+ write_callback_response(stream, false, "State mismatch.", origin.as_deref());
+ return Err(anyhow!("OAuth state mismatch"));
+ }
+ let code =
+ query_param(path, "code").ok_or_else(|| anyhow!("OAuth callback missing code"))?;
+ write_callback_response(
+ stream,
+ true,
+ "Grok authentication completed. You can close this window.",
+ origin.as_deref(),
+ );
+ return Ok(code);
+ }
+}
+
+fn parse_expiry_ms(value: &Value) -> Option {
+ if let Some(n) = value.as_u64() {
+ return Some(if n > 10_000_000_000 { n / 1000 } else { n });
+ }
+ if let Some(s) = value.as_str() {
+ if let Ok(n) = s.parse::() {
+ return Some(if n > 10_000_000_000 { n / 1000 } else { n });
+ }
+ if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
+ return Some(dt.timestamp().max(0) as u64);
+ }
+ }
+ None
+}
+
+pub fn load_grok_cli_credentials() -> Option {
+ let home = std::env::var_os("HOME")?;
+ let path = PathBuf::from(home).join(".grok").join("auth.json");
+ let text = std::fs::read_to_string(path).ok()?;
+ let data: Value = serde_json::from_str(&text).ok()?;
+
+ let try_entry = |entry: &Value| -> Option {
+ let access = entry
+ .get("key")
+ .or_else(|| entry.get("access_token"))
+ .or_else(|| entry.get("token"))
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())?
+ .to_string();
+ let refresh = entry
+ .get("refresh_token")
+ .or_else(|| entry.get("refresh"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ let expires = entry
+ .get("expires_at")
+ .or_else(|| entry.get("expires"))
+ .and_then(parse_expiry_ms)
+ .unwrap_or_else(|| now_secs() + 3600);
+ Some(OAuthCredential {
+ credential_type: "oauth".into(),
+ access,
+ refresh,
+ expires,
+ account_id: entry
+ .get("user_id")
+ .or_else(|| entry.get("principal_id"))
+ .and_then(|v| v.as_str())
+ .map(str::to_string),
+ })
+ };
+
+ if let Some(entry) = data.get(GROK_CLI_AUTH_SCOPE_KEY) {
+ if let Some(cred) = try_entry(entry) {
+ return Some(cred);
+ }
+ }
+ if let Some(entry) = data.get(GROK_CLI_LEGACY_SCOPE_KEY) {
+ if let Some(cred) = try_entry(entry) {
+ return Some(cred);
+ }
+ }
+ try_entry(&data)
+}
+
+async fn maybe_import_grok_cli(client: &reqwest::Client) -> Result