diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bea52ed5d..32cd3ab349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features +- **feat(providers): add Grok Build (xAI) provider with OAuth import-token flow** — `grok-cli` (alias `gc`) routes through Grok's CLI chat proxy; users paste their `~/.grok/auth.json` (or the JWT), with automatic `refresh_token` rotation. The public xAI client_id is embedded via `resolvePublicCred("grok_id")` (Hard Rule #11), never a literal. ([#5020](https://github.com/diegosouzapw/OmniRoute/pull/5020) — thanks @fulorgnas) - **feat(dashboard): click-to-edit model alias in the provider page** — click an alias to edit it inline (Enter/blur saves, Escape cancels), instead of only being able to delete and re-add it. ([#5119](https://github.com/diegosouzapw/OmniRoute/pull/5119) — thanks @waguriagentic) - **feat(providers): add ZenMux Free (session-cookie free-tier) provider** — `zenmux-free` (alias `zmf`) with a dedicated executor translating ZenMux's Anthropic-style SSE to OpenAI format; ships 12 free-tier models (DeepSeek V3.2, GLM 4.7 Flash Free, etc.). ([#5105](https://github.com/diegosouzapw/OmniRoute/pull/5105) — thanks @mrnasil) - **feat(providers): allow local/private provider URLs by default (`Allow Local Provider URLs` flag)** — adding/validating an OpenAI-compatible provider on a loopback/LAN address (e.g. `http://127.0.0.1:3264/api`) was rejected by the SSRF guard with "Blocked private or local provider URL", even though OmniRoute is local-first. A new `OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS` feature flag (default **ON**, toggle in Settings → Feature Flags) now scopes the provider-validation guard to allow local/private hosts while still blocking cloud-metadata endpoints (169.254.169.254, metadata.google.internal). Disable it to restore strict public-only blocking. Webhook/remote-image SSRF defaults are unchanged. ([#5066](https://github.com/diegosouzapw/OmniRoute/issues/5066), thanks @daniij) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 992aa92ef6..3bf36c79ff 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -167,6 +167,7 @@ import { kiroProvider } from "./registry/kiro/index.ts"; import { openadapterProvider } from "./registry/openadapter/index.ts"; import { ditProvider } from "./registry/dit/index.ts"; import { tokenrouterProvider } from "./registry/tokenrouter/index.ts"; +import { grok_cliProvider } from "./registry/grok-cli/index.ts"; import { codebuddy_cnProvider } from "./registry/codebuddy-cn/index.ts"; import { pioneerProvider } from "./registry/pioneer/index.ts"; import { zenmux_freeProvider } from "./registry/zenmux-free/index.ts"; @@ -339,6 +340,7 @@ export const REGISTRY: Record = { openadapter: openadapterProvider, dit: ditProvider, tokenrouter: tokenrouterProvider, + "grok-cli": grok_cliProvider, "codebuddy-cn": codebuddy_cnProvider, pioneer: pioneerProvider, "zenmux-free": zenmux_freeProvider, diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts new file mode 100644 index 0000000000..a255e68bbf --- /dev/null +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -0,0 +1,22 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { resolvePublicCred } from "../../shared.ts"; + +export const grok_cliProvider: RegistryEntry = { + id: "grok-cli", + alias: "gc", + format: "openai", + executor: "grok-cli", + baseUrl: "https://cli-chat-proxy.grok.com/v1/chat/completions", + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + models: [ + { id: "grok-build", name: "Grok Build", contextLength: 128000 }, + { id: "grok-composer-2.5-fast", name: "Grok Composer 2.5 Fast", contextLength: 128000 }, + ], + oauth: { + clientIdEnv: "GROK_OAUTH_CLIENT_ID", + clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + tokenUrl: "https://auth.x.ai/oauth2/token", + }, +}; diff --git a/open-sse/executors/grok-cli.ts b/open-sse/executors/grok-cli.ts new file mode 100644 index 0000000000..5f5f870251 --- /dev/null +++ b/open-sse/executors/grok-cli.ts @@ -0,0 +1,244 @@ +/** + * GrokCliExecutor — Grok Build Provider + * + * Routes requests through Grok's chat proxy endpoint using OAuth authentication. + * Uses Node.js https module directly with IPv4 forced to bypass Cloudflare blocking. + * Supports automatic token refresh via refresh_token. + */ + +import { + BaseExecutor, + type ExecuteInput, + type ExecutorLog, + type ProviderCredentials, +} from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; +import https from "node:https"; + +const GROK_TOKEN_URL = "https://auth.x.ai/oauth2/token"; +const REQUEST_TIMEOUT_MS = 60_000; + +export class GrokCliExecutor extends BaseExecutor { + constructor() { + super("grok-cli", PROVIDERS["grok-cli"]); + } + + async execute(input: ExecuteInput) { + const { model, body, stream, credentials, signal } = input; + + const url = this.buildUrl(model, stream, 0, credentials); + const headers = this.buildHeaders(credentials, stream); + const transformedBody = this.transformRequest(model, body, stream, credentials); + const bodyStr = JSON.stringify(transformedBody); + + const response = await this.nativePost(url, headers, bodyStr, signal); + return { response, url, headers, transformedBody }; + } + + async refreshCredentials( + credentials: ProviderCredentials, + log?: ExecutorLog | null + ): Promise | null> { + if (!credentials?.refreshToken) { + log?.warn?.("TOKEN_REFRESH", "Grok Build: no refresh token available"); + return null; + } + + const clientId = resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"); + + try { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: credentials.refreshToken, + }); + + const result = await this.nativeHttpsPost( + GROK_TOKEN_URL, + { + "Content-Type": "application/x-www-form-urlencoded", + }, + body.toString(), + 10_000 + ); + + if (result.status !== 200) { + log?.warn?.("TOKEN_REFRESH", `Grok Build: refresh failed with status ${result.status}`); + return null; + } + + const data = JSON.parse(result.body); + if (!data.access_token) { + log?.warn?.("TOKEN_REFRESH", "Grok Build: no access_token in refresh response"); + return null; + } + + const expiresIn = data.expires_in || 21600; + const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); + + log?.info?.("TOKEN_REFRESH", `Grok Build: token refreshed, expires ${expiresAt}`); + + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || credentials.refreshToken, + expiresAt, + }; + } catch (error) { + log?.warn?.( + "TOKEN_REFRESH", + `Grok Build: refresh error: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } + } + + private nativeHttpsPost( + url: string, + headers: Record, + bodyStr: string, + timeoutMs = 10_000 + ): Promise<{ status: number; body: string }> { + const urlObj = new URL(url); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => req.destroy(new Error("Timeout")), timeoutMs); + + const req = https.request( + { + hostname: urlObj.hostname, + port: 443, + path: urlObj.pathname + urlObj.search, + method: "POST", + family: 4, + headers: { + ...headers, + "Content-Length": Buffer.byteLength(bodyStr), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + clearTimeout(timer); + resolve({ + status: res.statusCode ?? 500, + body: Buffer.concat(chunks).toString("utf-8"), + }); + }); + } + ); + + req.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + req.write(bodyStr); + req.end(); + }); + } + + private nativePost( + url: string, + headers: Record, + bodyStr: string, + signal?: AbortSignal | null + ): Promise { + const urlObj = new URL(url); + + if (signal?.aborted) { + return Promise.reject(new Error("Aborted")); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => req.destroy(new Error("Timeout")), REQUEST_TIMEOUT_MS); + let settled = false; + + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const req = https.request( + { + hostname: urlObj.hostname, + port: 443, + path: urlObj.pathname + urlObj.search, + method: "POST", + family: 4, + headers: { + ...headers, + "Content-Length": Buffer.byteLength(bodyStr), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + settle(() => { + const responseBody = Buffer.concat(chunks).toString("utf-8"); + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(res.headers)) { + if (typeof value === "string") responseHeaders[key] = value; + else if (Array.isArray(value)) responseHeaders[key] = value.join(", "); + } + resolve( + new Response(responseBody, { + status: res.statusCode ?? 500, + headers: responseHeaders, + }) + ); + }); + }); + } + ); + + if (signal) { + const onAbort = () => settle(() => reject(new Error("Aborted"))); + signal.addEventListener("abort", onAbort, { once: true }); + // Clean up listener when request finishes naturally + req.on("close", () => signal.removeEventListener("abort", onAbort)); + } + + req.on("error", (err) => settle(() => reject(err))); + req.write(bodyStr); + req.end(); + }); + } + + buildHeaders(credentials: ProviderCredentials, stream = true) { + const headers: Record = { + "Content-Type": "application/json", + }; + + if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } else if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } + + headers["Accept"] = stream ? "text/event-stream" : "application/json"; + headers["x-grok-client-version"] = "0.2.64"; + headers["x-grok-client-identifier"] = "grok_cli_rs"; + headers["User-Agent"] = "grok-cli/0.2.64 (Windows 10.0.26200; x64)"; + + return headers; + } + + transformRequest( + model: string, + body: unknown, + stream: boolean, + _credentials: ProviderCredentials + ) { + const transformed = + body && typeof body === "object" ? { ...(body as Record) } : {}; + if (!transformed.model) { + transformed.model = model || "grok-composer-2.5-fast"; + } + transformed.stream = !!stream; + return transformed; + } +} diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 65e518b7fc..e01751ade6 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -47,14 +47,16 @@ import { V0VercelWebExecutor } from "./v0-vercel-web.ts"; import { KimiWebExecutor } from "./kimi-web.ts"; import { DoubaoWebExecutor } from "./doubao-web.ts"; import { QwenWebExecutor } from "./qwen-web.ts"; -import { KimiExecutor } from "./kimi.ts" +import { KimiExecutor } from "./kimi.ts"; import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; import { LMArenaExecutor } from "./lmarena.ts"; import { MimocodeExecutor } from "./mimocode.ts"; +import { GrokCliExecutor } from "./grok-cli.ts"; import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; import { ZenmuxFreeExecutor } from "./zenmux-free.ts"; + const executors = { antigravity: new AntigravityExecutor(), agy: new AntigravityExecutor(), @@ -150,6 +152,8 @@ const executors = { lma: new LMArenaExecutor(), // Alias mimocode: new MimocodeExecutor(), mcode: new MimocodeExecutor(), // Alias + "grok-cli": new GrokCliExecutor(), + gc: new GrokCliExecutor(), // Alias "codebuddy-cn": new CodeBuddyCnExecutor(), cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn "zenmux-free": new ZenmuxFreeExecutor(), @@ -214,5 +218,6 @@ export { TheOldLlmExecutor } from "./theoldllm.ts"; export { ChipotleExecutor } from "./chipotle.ts"; export { LMArenaExecutor } from "./lmarena.ts"; export { MimocodeExecutor } from "./mimocode.ts"; +export { GrokCliExecutor } from "./grok-cli.ts"; export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; export { ZenmuxFreeExecutor } from "./zenmux-free.ts"; diff --git a/open-sse/utils/publicCreds.ts b/open-sse/utils/publicCreds.ts index 821c9f7538..4862f34c81 100644 --- a/open-sse/utils/publicCreds.ts +++ b/open-sse/utils/publicCreds.ts @@ -179,6 +179,11 @@ const EMBEDDED_DEFAULTS = { github_copilot_id: [ 38, 27, 95, 71, 16, 90, 69, 67, 4, 29, 72, 22, 90, 91, 12, 0, 75, 19, 8, 87, ], + // Grok Build CLI (xAI) — public oauth client id (import-token flow) + grok_id: [ + 13, 92, 15, 89, 66, 91, 76, 70, 72, 29, 71, 70, 3, 65, 93, 84, 72, 23, 28, 87, 92, 88, 15, 95, + 91, 22, 71, 87, 20, 66, 67, 86, 13, 81, 81, 21, + ], } as const; export type EmbeddedDefaultKey = keyof typeof EMBEDDED_DEFAULTS; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 6fe6837e04..1fa5504c8a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -71,6 +71,7 @@ export default function ProviderDetailPageClient() { const [codexCliGuideOpen, setCodexCliGuideOpen] = useState(false); const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false); const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false); + const [importGrokCliModalOpen, setImportGrokCliModalOpen] = useState(false); const isOpenAICompatible = isOpenAICompatibleProvider(providerId); const isCcCompatible = isClaudeCodeCompatibleProvider(providerId); const isCommandCode = providerId === "command-code"; @@ -520,6 +521,7 @@ export default function ProviderDetailPageClient() { onOpenImportCodex={() => setImportCodexModalOpen(true)} onOpenImportClaude={() => setImportClaudeModalOpen(true)} onOpenImportGemini={() => setImportGeminiModalOpen(true)} + onOpenImportGrokCli={() => setImportGrokCliModalOpen(true)} t={t} /> @@ -539,6 +541,7 @@ export default function ProviderDetailPageClient() { onOpenImportCodex={() => setImportCodexModalOpen(true)} onOpenImportClaude={() => setImportClaudeModalOpen(true)} onOpenImportGemini={() => setImportGeminiModalOpen(true)} + onOpenImportGrokCli={() => setImportGrokCliModalOpen(true)} t={t} /> ) : ( @@ -759,6 +762,8 @@ export default function ProviderDetailPageClient() { handleApplyGeminiAuthLocal={handleApplyGeminiAuthLocal} importGeminiModalOpen={importGeminiModalOpen} setImportGeminiModalOpen={setImportGeminiModalOpen} + importGrokCliModalOpen={importGrokCliModalOpen} + setImportGrokCliModalOpen={setImportGrokCliModalOpen} batchTestResults={batchTestResults} setBatchTestResults={setBatchTestResults} emailsVisible={emailsVisible} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx index eddd3a24fa..e2be6eb3ac 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsHeaderToolbar.tsx @@ -45,6 +45,7 @@ type ConnectionsHeaderToolbarProps = { onOpenImportCodex: () => void; onOpenImportClaude: () => void; onOpenImportGemini: () => void; + onOpenImportGrokCli: () => void; t: ProviderMessageTranslator; }; @@ -87,6 +88,7 @@ export default function ConnectionsHeaderToolbar({ onOpenImportCodex, onOpenImportClaude, onOpenImportGemini, + onOpenImportGrokCli, t, }: ConnectionsHeaderToolbarProps) { return ( @@ -338,6 +340,16 @@ export default function ConnectionsHeaderToolbar({ : "Import auth"} )} + {providerId === "grok-cli" && ( + + )} )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx index 79d073ecf3..ef6cbf2526 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx @@ -24,6 +24,7 @@ interface EmptyConnectionsPlaceholderProps { onOpenImportCodex: () => void; onOpenImportClaude: () => void; onOpenImportGemini: () => void; + onOpenImportGrokCli: () => void; t: ProviderMessageTranslator; } @@ -42,14 +43,13 @@ export default function EmptyConnectionsPlaceholder({ onOpenImportCodex, onOpenImportClaude, onOpenImportGemini, + onOpenImportGrokCli, t, }: EmptyConnectionsPlaceholderProps) { return (
- - {isOAuth ? "lock" : "key"} - + {isOAuth ? "lock" : "key"}

{t("noConnectionsYet")}

{t("addFirstConnectionHint")}

@@ -82,10 +82,7 @@ export default function EmptyConnectionsPlaceholder({ {providerSupportsPat ? "Add PAT" : t("addConnection")} {providerId === "qoder" && ( - )} @@ -122,6 +119,15 @@ export default function EmptyConnectionsPlaceholder({ : "Import auth"} )} + {providerId === "grok-cli" && ( + + )} )}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 55880e7692..fea42183b5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -23,6 +23,7 @@ import { AdaptaTutorialModal } from "./AdaptaTutorialModal"; import { ImportCodexAuthModal, ApplyCodexAuthModal } from "./modals/ImportCodexAuthModal"; import { ImportClaudeAuthModal, ApplyClaudeAuthModal } from "./modals/ImportClaudeAuthModal"; import { ImportGeminiAuthModal, ApplyGeminiAuthModal } from "./modals/ImportGeminiAuthModal"; +import ImportGrokCliAuthModal from "./modals/ImportGrokCliAuthModal"; import { type ConnectionRowConnection } from "./ConnectionRow"; import { type BatchTestResults } from "../hooks/useProviderConnections"; import { type ImportProgress } from "../hooks/useModelImportHandlers"; @@ -123,6 +124,9 @@ interface ProviderModalsPanelProps { handleApplyGeminiAuthLocal: (id: string) => Promise; importGeminiModalOpen: boolean; setImportGeminiModalOpen: (open: boolean) => void; + // Grok Build auth + importGrokCliModalOpen: boolean; + setImportGrokCliModalOpen: (open: boolean) => void; // Batch test results batchTestResults: BatchTestResults | null; setBatchTestResults: (r: BatchTestResults | null) => void; @@ -209,6 +213,8 @@ export default function ProviderModalsPanel({ handleApplyGeminiAuthLocal, importGeminiModalOpen, setImportGeminiModalOpen, + importGrokCliModalOpen, + setImportGrokCliModalOpen, batchTestResults, setBatchTestResults, emailsVisible, @@ -395,6 +401,16 @@ export default function ProviderModalsPanel({ }} /> )} + {providerId === "grok-cli" && importGrokCliModalOpen && ( + setImportGrokCliModalOpen(false)} + onSuccess={() => { + setImportGrokCliModalOpen(false); + void fetchConnections(); + }} + /> + )} void; + onSuccess: () => void; +} + +function parseGrokJson(json: unknown): { + valid: boolean; + email: string | null; + hasRefreshToken: boolean; +} { + try { + const doc = json && typeof json === "object" ? (json as Record) : null; + if (!doc) return { valid: false, email: null, hasRefreshToken: false }; + + const entries = Object.values(doc); + for (const entry of entries) { + if (entry && typeof entry === "object" && "key" in entry) { + const obj = entry as Record; + const key = typeof obj.key === "string" ? obj.key : null; + const email = typeof obj.email === "string" ? obj.email : null; + const hasRefreshToken = + typeof obj.refresh_token === "string" && obj.refresh_token.length > 0; + if (key && key.startsWith("eyJ")) { + return { valid: true, email, hasRefreshToken }; + } + } + } + return { valid: false, email: null, hasRefreshToken: false }; + } catch { + return { valid: false, email: null, hasRefreshToken: false }; + } +} + +export default function ImportGrokCliAuthModal({ + onClose, + onSuccess, +}: ImportGrokCliAuthModalProps) { + const notify = useNotificationStore(); + const [tab, setTab] = useState<"upload" | "paste">("upload"); + const [parsedJson, setParsedJson] = useState(null); + const [detectedEmail, setDetectedEmail] = useState(null); + const [hasRefreshToken, setHasRefreshToken] = useState(false); + const [parseError, setParseError] = useState(null); + const [pasteText, setPasteText] = useState(""); + const [name, setName] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + function handlePreview(json: unknown) { + setParseError(null); + setDetectedEmail(null); + setParsedJson(null); + setHasRefreshToken(false); + const result = parseGrokJson(json); + if (!result.valid) { + setParseError( + "Not a valid Grok Build auth.json. Expected an object with a key containing a JWT." + ); + return; + } + setDetectedEmail(result.email); + setHasRefreshToken(result.hasRefreshToken); + setParsedJson(json); + } + + function handleFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + try { + handlePreview(JSON.parse(ev.target?.result as string)); + } catch { + setParseError("Could not parse JSON"); + } + }; + reader.readAsText(file); + } + + function handlePasteChange(text: string) { + setPasteText(text); + if (!text.trim()) { + setParsedJson(null); + setParseError(null); + setDetectedEmail(null); + setHasRefreshToken(false); + return; + } + try { + handlePreview(JSON.parse(text)); + } catch { + setParseError("Could not parse JSON"); + setParsedJson(null); + } + } + + async function handleSubmit() { + if (!parsedJson) return; + setLoading(true); + setError(null); + try { + const res = await fetch("/api/oauth/grok-cli/import-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: parsedJson }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || "Failed to import"); + return; + } + notify.success("Grok Build connection imported successfully"); + onSuccess(); + } catch { + setError("Failed to import Grok Build auth"); + } finally { + setLoading(false); + } + } + + return ( + +
+

+ Import your Grok Build ~/.grok/auth.json file. You can get it by running{" "} + grok login in your terminal. +

+ + {/* Tab toggle */} +
+ + +
+ + {/* Upload tab */} + {tab === "upload" && ( +
+ +
+ )} + + {/* Paste tab */} + {tab === "paste" && ( +
+