From 70e8dc49748e96a2461c2240a55a9684d7f8436b Mon Sep 17 00:00:00 2001 From: rixzkiye Date: Thu, 16 Jul 2026 14:38:13 +0700 Subject: [PATCH] feat(cli-tools): add Grok Build setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Grok Build to Dashboard → CLI Tools. Apply writes a [model.9router] custom model to ~/.grok/config.toml and sets [models].default, routing the xAI Grok TUI through 9Router. Reset removes the slot and restores the previous default. --- CHANGELOG.md | 1 + .../cli-tools/[toolId]/ToolDetailClient.js | 4 +- .../cli-tools/components/GrokBuildToolCard.js | 387 ++++++++++++++++++ .../dashboard/cli-tools/components/index.js | 1 + src/app/api/cli-tools/all-statuses/route.js | 2 + .../cli-tools/grok-build-settings/route.js | 242 +++++++++++ src/shared/constants/cliTools.js | 24 ++ 7 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js create mode 100644 src/app/api/cli-tools/grok-build-settings/route.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ba2133..5cdd2373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Features - **Perplexity**: add Agent API provider (#2492) - **Grok CLI**: add Grok CLI / Grok Build provider with OAuth device-code flow (#2502) +- **CLI tools**: add Grok Build setup — writes `[model.9router]` custom model to `~/.grok/config.toml` - **Featherless**: add OpenAI-compatible provider presets - **SearXNG**: configure endpoint via SEARXNG_URL env (#2499) - **Providers**: add max thinking level for gpt-5.6-sol (#2500) diff --git a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js index fa8d7111..2e735647 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/[toolId]/ToolDetailClient.js @@ -9,7 +9,7 @@ import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, ClineToolCard, KiloToolCard, DeepSeekTuiToolCard, - JcodeToolCard, + JcodeToolCard, GrokBuildToolCard, } from "../components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; @@ -139,6 +139,8 @@ export default function ToolDetailClient({ toolId, machineId }) { return ; case "jcode": return ; + case "grok-build": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js new file mode 100644 index 00000000..cc72ea4a --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/GrokBuildToolCard.js @@ -0,0 +1,387 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; +import Image from "next/image"; +import BaseUrlSelect from "./BaseUrlSelect"; +import ApiKeySelect from "./ApiKeySelect"; +import { matchKnownEndpoint } from "./cliEndpointMatch"; + +const ENDPOINT = "/api/cli-tools/grok-build-settings"; +const MODEL_SLOT = "9router"; + +export default function GrokBuildToolCard({ + tool, + isExpanded, + onToggle, + baseUrl, + hasActiveProviders, + apiKeys, + activeProviders, + cloudEnabled, + initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, +}) { + const [grokStatus, setGrokStatus] = useState(initialStatus || null); + const [checking, setChecking] = useState(false); + const [applying, setApplying] = useState(false); + const [restoring, setRestoring] = useState(false); + const [message, setMessage] = useState(null); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [modelAliases, setModelAliases] = useState({}); + const [showManualConfigModal, setShowManualConfigModal] = useState(false); + const [customBaseUrl, setCustomBaseUrl] = useState(""); + const hasInitializedModel = useRef(false); + + const getConfigStatus = () => { + if (!grokStatus?.installed) return null; + const cfg = grokStatus.settings?.model; + if (!cfg?.base_url) return "not_configured"; + if (matchKnownEndpoint(cfg.base_url, { tunnelPublicUrl, tailscaleUrl })) return "configured"; + return "other"; + }; + + const configStatus = getConfigStatus(); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + if (initialStatus) setGrokStatus(initialStatus); + }, [initialStatus]); + + useEffect(() => { + if (isExpanded && !grokStatus) { + checkStatus(); + fetchModelAliases(); + } + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); + + const fetchModelAliases = async () => { + try { + const res = await fetch("/api/models/alias"); + const data = await res.json(); + if (res.ok) setModelAliases(data.aliases || {}); + } catch (error) { + console.log("Error fetching model aliases:", error); + } + }; + + useEffect(() => { + if (grokStatus?.installed && !hasInitializedModel.current) { + hasInitializedModel.current = true; + const cfg = grokStatus.settings?.model; + if (cfg?.model) setSelectedModel(cfg.model); + } + }, [grokStatus]); + + const checkStatus = async () => { + setChecking(true); + try { + const res = await fetch(ENDPOINT); + const data = await res.json(); + setGrokStatus(data); + } catch (error) { + setGrokStatus({ installed: false, error: error.message }); + } finally { + setChecking(false); + } + }; + + const normalizeLocalhost = (url) => url.replace("://localhost", "://127.0.0.1"); + + const getLocalBaseUrl = () => { + if (typeof window !== "undefined") { + return normalizeLocalhost(window.location.origin); + } + return "http://127.0.0.1:20128"; + }; + + const getEffectiveBaseUrl = () => { + const url = customBaseUrl || getLocalBaseUrl(); + return url.endsWith("/v1") ? url : `${url}/v1`; + }; + + const handleApply = async () => { + setApplying(true); + setMessage(null); + try { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: getEffectiveBaseUrl(), + apiKey: keyToUse, + model: selectedModel, + }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings applied successfully!" }); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to apply settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setApplying(false); + } + }; + + const handleReset = async () => { + setRestoring(true); + setMessage(null); + try { + const res = await fetch(ENDPOINT, { method: "DELETE" }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Settings reset successfully!" }); + setSelectedModel(""); + checkStatus(); + } else { + setMessage({ type: "error", text: data.error || "Failed to reset settings" }); + } + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setRestoring(false); + } + }; + + const handleModelSelect = (model) => { + setSelectedModel(model.value); + setModalOpen(false); + }; + + const getManualConfigs = () => { + const keyToUse = (selectedApiKey && selectedApiKey.trim()) + ? selectedApiKey + : (!cloudEnabled ? "sk_9router" : ""); + + const modelId = selectedModel || "provider/model-id"; + const tomlContent = `[models] +default = "${MODEL_SLOT}" + +[model.${MODEL_SLOT}] +model = "${modelId}" +base_url = "${getEffectiveBaseUrl()}" +name = "9Router" +description = "Routed via 9Router gateway" +api_backend = "chat_completions" +api_key = "${keyToUse}" +`; + + return [ + { filename: "~/.grok/config.toml", content: tomlContent }, + ]; + }; + + return ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checking && ( +
+ progress_activity + Checking Grok Build... +
+ )} + + {!checking && grokStatus && !grokStatus.installed && ( +
+
+
+ warning +
+

Grok Build not detected locally

+

Install:

+ curl -fsSL https://x.ai/cli/install.sh | bash +

Manual configuration is still available if 9router is deployed on a remote server.

+
+
+
+ +
+
+
+ )} + + {!checking && grokStatus?.installed && ( + <> +
+ {tool.notes && tool.notes.length > 0 && ( +
+ {tool.notes.map((note, idx) => ( +
+ + {note.type === "warning" ? "warning" : note.type === "error" ? "error" : "info"} + + {note.text} +
+ ))} +
+ )} + +
+ Select Endpoint + arrow_forward + +
+ + {grokStatus?.settings?.model?.base_url && ( +
+ Current + arrow_forward + + {grokStatus.settings.model.base_url} + {grokStatus.settings.model.model ? ` · ${grokStatus.settings.model.model}` : ""} + +
+ )} + +
+ API Key + arrow_forward + +
+ +
+ Default Model + arrow_forward +
+ setSelectedModel(e.target.value)} + placeholder="provider/model-id" + className="w-full min-w-0 pl-2 pr-7 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5" + /> + {selectedModel && ( + + )} +
+ +
+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} +
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Grok Build" + /> + + setShowManualConfigModal(false)} + title="Grok Build - Manual Configuration" + configs={getManualConfigs()} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index aeca8700..e1399677 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -12,6 +12,7 @@ export { default as ClineToolCard } from "./ClineToolCard"; export { default as KiloToolCard } from "./KiloToolCard"; export { default as DeepSeekTuiToolCard } from "./DeepSeekTuiToolCard"; export { default as JcodeToolCard } from "./JcodeToolCard"; +export { default as GrokBuildToolCard } from "./GrokBuildToolCard"; export { default as MitmServerCard } from "./MitmServerCard"; export { default as MitmToolCard } from "./MitmToolCard"; export { default as MitmLinkCard } from "./MitmLinkCard"; diff --git a/src/app/api/cli-tools/all-statuses/route.js b/src/app/api/cli-tools/all-statuses/route.js index 4d5174f5..c3ac832b 100644 --- a/src/app/api/cli-tools/all-statuses/route.js +++ b/src/app/api/cli-tools/all-statuses/route.js @@ -13,6 +13,7 @@ import { GET as clineGet } from "../cline-settings/route"; import { GET as kiloGet } from "../kilo-settings/route"; import { GET as deepseekTuiGet } from "../deepseek-tui-settings/route"; import { GET as jcodeGet } from "../jcode-settings/route"; +import { GET as grokBuildGet } from "../grok-build-settings/route"; const STATUS_GETTERS = { claude: claudeGet, @@ -27,6 +28,7 @@ const STATUS_GETTERS = { kilo: kiloGet, "deepseek-tui": deepseekTuiGet, jcode: jcodeGet, + "grok-build": grokBuildGet, }; // Batch endpoint: gather all CLI tool statuses in one round-trip diff --git a/src/app/api/cli-tools/grok-build-settings/route.js b/src/app/api/cli-tools/grok-build-settings/route.js new file mode 100644 index 00000000..afc747ef --- /dev/null +++ b/src/app/api/cli-tools/grok-build-settings/route.js @@ -0,0 +1,242 @@ +"use server"; + +import { NextResponse } from "next/server"; +import { exec } from "child_process"; +import { promisify } from "util"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const execAsync = promisify(exec); + +const PROVIDER_NAME = "9router"; +const MODEL_SLOT = "9router"; +const BUILTIN_DEFAULT = "grok-build"; + +// [model.9router] ... until next [section] header or EOF +const MODEL_SECTION_RE = new RegExp( + `^\\[model\\.${MODEL_SLOT}\\][ \\t]*\\r?\\n(?:(?!\\[)[^\\r\\n]*\\r?\\n?)*`, + "m" +); + +const MODELS_SECTION_RE = /^\[models\][ \t]*\r?\n((?:(?!\[)[^\r\n]*\r?\n?)*)/m; + +// Marker written on Apply so Reset can restore the previous [models].default +const PREV_DEFAULT_RE = /^# 9router-prev-default = "([^"]*)"[ \t]*\r?\n?/m; + +const getGrokDir = () => path.join(os.homedir(), ".grok"); +const getGrokConfigPath = () => path.join(getGrokDir(), "config.toml"); +const getGrokBinPath = () => path.join(getGrokDir(), "bin", "grok"); + +const checkGrokInstalled = async () => { + try { + const isWindows = os.platform() === "win32"; + const command = isWindows ? "where grok" : "which grok"; + await execAsync(command, { windowsHide: true }); + return true; + } catch { + try { + await fs.access(getGrokBinPath()); + return true; + } catch { + try { + await fs.access(getGrokConfigPath()); + return true; + } catch { + return false; + } + } + } +}; + +const readConfigToml = async () => { + try { + return await fs.readFile(getGrokConfigPath(), "utf-8"); + } catch (error) { + if (error.code === "ENOENT") return ""; + throw error; + } +}; + +const getTomlField = (body, key) => { + const m = body.match(new RegExp(`^[ \\t]*${key}[ \\t]*=[ \\t]*"([^"]*)"`, "m")); + return m ? m[1] : null; +}; + +const parseModelSection = (toml) => { + const match = toml.match(MODEL_SECTION_RE); + if (!match) return null; + const body = match[0].replace(/^\[model\.[^\]]+\][ \t]*\r?\n/, ""); + return { + model: getTomlField(body, "model"), + base_url: getTomlField(body, "base_url"), + name: getTomlField(body, "name"), + api_key: getTomlField(body, "api_key"), + api_backend: getTomlField(body, "api_backend"), + }; +}; + +const parseModelsDefault = (toml) => { + const match = toml.match(MODELS_SECTION_RE); + if (!match) return null; + return getTomlField(match[1] || "", "default"); +}; + +const buildModelSection = (model, baseUrl, apiKey) => { + const lines = [ + `[model.${MODEL_SLOT}]`, + `model = "${model}"`, + `base_url = "${baseUrl}"`, + `name = "9Router"`, + `description = "Routed via 9Router gateway"`, + `api_backend = "chat_completions"`, + ]; + if (apiKey) lines.push(`api_key = "${apiKey}"`); + return `${lines.join("\n")}\n`; +}; + +const upsertModelSection = (toml, section) => { + if (MODEL_SECTION_RE.test(toml)) return toml.replace(MODEL_SECTION_RE, section); + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}\n${section}`; +}; + +const removeModelSection = (toml) => + toml.replace(MODEL_SECTION_RE, "").replace(/\n{3,}/g, "\n\n"); + +// Set or insert default = "..." inside existing [models], or create the section +const setModelsDefault = (toml, value) => { + const match = toml.match(MODELS_SECTION_RE); + if (match) { + const body = match[1] || ""; + let newBody; + if (/^[ \t]*default[ \t]*=/m.test(body)) { + newBody = body.replace(/^[ \t]*default[ \t]*=[ \t]*"[^"]*"/m, `default = "${value}"`); + } else { + newBody = `default = "${value}"\n${body}`; + } + return toml.replace(match[0], `[models]\n${newBody}`); + } + const block = `[models]\ndefault = "${value}"\n\n`; + return toml.length > 0 ? block + toml : block; +}; + +// Remember the previous default once (so re-Apply does not overwrite it with "9router") +const rememberPrevDefault = (toml) => { + if (PREV_DEFAULT_RE.test(toml)) return toml; + const current = parseModelsDefault(toml); + if (!current || current === MODEL_SLOT) return toml; + const marker = `# 9router-prev-default = "${current}"\n`; + // Prefer placing the marker just above [model.9router] if present, else at EOF + if (MODEL_SECTION_RE.test(toml)) { + return toml.replace(MODEL_SECTION_RE, (section) => marker + section); + } + const needsNl = toml.length > 0 && !toml.endsWith("\n"); + return `${toml}${needsNl ? "\n" : ""}${marker}`; +}; + +// If default points at our slot, restore previous (or built-in) default and drop marker +const clearModelsDefaultIfOurs = (toml) => { + const prevMatch = toml.match(PREV_DEFAULT_RE); + const restoreTo = prevMatch?.[1] || BUILTIN_DEFAULT; + let next = toml.replace(PREV_DEFAULT_RE, ""); + const current = parseModelsDefault(next); + if (current === MODEL_SLOT) { + next = setModelsDefault(next, restoreTo); + } + return next; +}; + +const has9RouterConfig = (modelCfg) => { + if (!modelCfg?.base_url) return false; + return true; +}; + +export async function GET() { + try { + const installed = await checkGrokInstalled(); + if (!installed) { + return NextResponse.json({ + installed: false, + settings: null, + message: "Grok Build is not installed", + }); + } + + const toml = await readConfigToml(); + const model = parseModelSection(toml); + const defaultModel = parseModelsDefault(toml); + + return NextResponse.json({ + installed: true, + settings: { + model, + default: defaultModel, + }, + has9Router: has9RouterConfig(model), + configPath: getGrokConfigPath(), + }); + } catch (error) { + console.log("Error checking grok-build settings:", error); + return NextResponse.json({ error: "Failed to check grok-build settings" }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const { baseUrl, apiKey, model } = await request.json(); + if (!baseUrl || !model) { + return NextResponse.json({ error: "baseUrl and model are required" }, { status: 400 }); + } + + const dir = getGrokDir(); + await fs.mkdir(dir, { recursive: true }); + + const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`; + const keyToWrite = apiKey || "sk_9router"; + + let toml = await readConfigToml(); + toml = rememberPrevDefault(toml); + toml = upsertModelSection(toml, buildModelSection(model, normalizedBaseUrl, keyToWrite)); + toml = setModelsDefault(toml, MODEL_SLOT); + + await fs.writeFile(getGrokConfigPath(), toml); + + return NextResponse.json({ + success: true, + message: "Grok Build settings applied successfully!", + configPath: getGrokConfigPath(), + modelSlot: MODEL_SLOT, + }); + } catch (error) { + console.log("Error updating grok-build settings:", error); + return NextResponse.json({ error: "Failed to update grok-build settings" }, { status: 500 }); + } +} + +export async function DELETE() { + try { + const configPath = getGrokConfigPath(); + let toml = ""; + try { + toml = await fs.readFile(configPath, "utf-8"); + } catch (error) { + if (error.code === "ENOENT") { + return NextResponse.json({ success: true, message: "No config file to reset" }); + } + throw error; + } + + toml = removeModelSection(toml); + toml = clearModelsDefaultIfOurs(toml); + await fs.writeFile(configPath, toml); + + return NextResponse.json({ + success: true, + message: `${PROVIDER_NAME} model slot removed from Grok Build`, + }); + } catch (error) { + console.log("Error resetting grok-build settings:", error); + return NextResponse.json({ error: "Failed to reset grok-build settings" }, { status: 500 }); + } +} diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js index 685d2bc6..b501e884 100644 --- a/src/shared/constants/cliTools.js +++ b/src/shared/constants/cliTools.js @@ -363,6 +363,30 @@ amp --model "{{model}}" { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro", alias: "gemini", defaultValue: "gemini/gemini-3.1-pro" }, ], }, + "grok-build": { + id: "grok-build", + name: "Grok Build", + image: "/providers/grok-cli.png", + color: "#1DA1F2", + description: "xAI Grok Build TUI coding agent", + configType: "custom", + docsUrl: "https://x.ai/cli", + defaultCommand: "grok", + notes: [ + { + type: "info", + text: "Grok Build uses ~/.grok/config.toml. 9Router writes a [model.9router] custom model and sets it as the default.", + }, + { + type: "info", + text: "After Apply, run grok (or /model 9router) to use the routed model. Switch back anytime with /model grok-build.", + }, + { + type: "warning", + text: "Config path: Linux/macOS ~/.grok/config.toml • Windows %USERPROFILE%\\.grok\\config.toml", + }, + ], + }, // HIDDEN: gemini-cli // "gemini-cli": { // id: "gemini-cli",