diff --git a/apps/desktop/public/icons/ai/grok.svg b/apps/desktop/public/icons/ai/grok.svg
new file mode 100644
index 000000000..74ad7ac2e
--- /dev/null
+++ b/apps/desktop/public/icons/ai/grok.svg
@@ -0,0 +1 @@
+
diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
index 921b4f6d2..374b095a3 100644
--- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue
+++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue
@@ -2635,7 +2635,7 @@ function normalizeMaxRetries(value: number | undefined): number {
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref(null);
-const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"]);
+const CLI_AI_PROVIDERS = new Set(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli"]);
const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]);
const CURSOR_CONTROL_ENV = new Set(["CURSOR_CONFIG_DIR", "CURSOR_DATA_DIR"]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
@@ -2663,6 +2663,8 @@ const aiEditOpenCodeCliPath = ref("");
const aiEditOpenCodeCliEnvRows = ref([]);
const aiEditCursorCliPath = ref("");
const aiEditCursorCliEnvRows = ref([]);
+const aiEditGrokCliPath = ref("");
+const aiEditGrokCliEnvRows = ref([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
@@ -2695,6 +2697,7 @@ const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-c
const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli");
const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli");
const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-cli");
+const aiIsGrokCli = computed(() => aiEditProvider.value === "grok-cli");
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => {
@@ -2702,6 +2705,7 @@ const aiCliCommandName = computed(() => {
if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode";
if (aiIsCursorCli.value) return "agent";
+ if (aiIsGrokCli.value) return "grok";
return "codex";
});
const aiCliLoginCommand = computed(() => {
@@ -2709,6 +2713,7 @@ const aiCliLoginCommand = computed(() => {
if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode auth login";
if (aiIsCursorCli.value) return "agent login";
+ if (aiIsGrokCli.value) return "grok login";
return "codex login";
});
const aiEditCliPath = computed({
@@ -2717,6 +2722,7 @@ const aiEditCliPath = computed({
if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value;
if (aiIsCursorCli.value) return aiEditCursorCliPath.value;
+ if (aiIsGrokCli.value) return aiEditGrokCliPath.value;
return aiEditCodexCliPath.value;
},
set: (value: string) => {
@@ -2728,6 +2734,8 @@ const aiEditCliPath = computed({
aiEditOpenCodeCliPath.value = value;
} else if (aiIsCursorCli.value) {
aiEditCursorCliPath.value = value;
+ } else if (aiIsGrokCli.value) {
+ aiEditGrokCliPath.value = value;
} else {
aiEditCodexCliPath.value = value;
}
@@ -2738,6 +2746,7 @@ const aiEditCliEnvRows = computed(() => {
if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value;
if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value;
+ if (aiIsGrokCli.value) return aiEditGrokCliEnvRows.value;
return aiEditCodexCliEnvRows.value;
});
watch(aiIsCliProvider, (isCliProvider) => {
@@ -2834,6 +2843,8 @@ function removeCliEnvRow(id: string) {
aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsCursorCli.value) {
aiEditCursorCliEnvRows.value = aiEditCursorCliEnvRows.value.filter((row) => row.id !== id);
+ } else if (aiIsGrokCli.value) {
+ aiEditGrokCliEnvRows.value = aiEditGrokCliEnvRows.value.filter((row) => row.id !== id);
} else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
}
@@ -2866,6 +2877,8 @@ function currentAiEditConfig() {
opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {},
cursorCliPath: aiEditCursorCliPath.value.trim() || undefined,
cursorCliEnv: aiIsCursorCli.value ? cliEnvFromRows(aiEditCursorCliEnvRows.value) : {},
+ grokCliPath: aiEditGrokCliPath.value.trim() || undefined,
+ grokCliEnv: aiIsGrokCli.value ? cliEnvFromRows(aiEditGrokCliEnvRows.value) : {},
};
}
@@ -2939,6 +2952,8 @@ function aiEnterEditMode(configId?: string) {
aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv);
aiEditCursorCliPath.value = config.cursorCliPath ?? "";
aiEditCursorCliEnvRows.value = aiEnvRowsFromConfig(config.cursorCliEnv);
+ aiEditGrokCliPath.value = config.grokCliPath ?? "";
+ aiEditGrokCliEnvRows.value = aiEnvRowsFromConfig(config.grokCliEnv);
}
} else {
aiEditConfigName.value = "";
@@ -2964,6 +2979,8 @@ function aiEnterEditMode(configId?: string) {
aiEditOpenCodeCliEnvRows.value = [];
aiEditCursorCliPath.value = "";
aiEditCursorCliEnvRows.value = [];
+ aiEditGrokCliPath.value = "";
+ aiEditGrokCliEnvRows.value = [];
}
}
diff --git a/apps/desktop/src/components/icons/AiProviderLogo.vue b/apps/desktop/src/components/icons/AiProviderLogo.vue
index d18418772..7fecbca2c 100644
--- a/apps/desktop/src/components/icons/AiProviderLogo.vue
+++ b/apps/desktop/src/components/icons/AiProviderLogo.vue
@@ -21,7 +21,7 @@ watch(
},
);
-const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli");
+const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli" || props.provider === "cursor-cli" || props.provider === "grok-cli");
const localIconUrl = computed(() => {
if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg");
return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : "";
diff --git a/apps/desktop/src/i18n/backend-errors.ts b/apps/desktop/src/i18n/backend-errors.ts
index f457f58d8..340aff57a 100644
--- a/apps/desktop/src/i18n/backend-errors.ts
+++ b/apps/desktop/src/i18n/backend-errors.ts
@@ -44,6 +44,14 @@ const taggedAiCliErrorKeys: Record = {
openCodeRunFailed: "ai.cliErrors.openCodeRunFailed",
cursorNotInstalled: "ai.cliErrors.cursorNotInstalled",
cursorCliPathInvalid: "ai.cliErrors.cursorCliPathInvalid",
+ grokCliNotInstalled: "ai.cliErrors.grokCliNotInstalled",
+ grokCliPathInvalid: "ai.cliErrors.grokCliPathInvalid",
+ grokCliEnvInvalid: "ai.cliErrors.grokCliEnvInvalid",
+ grokCliEnvReserved: "ai.cliErrors.grokCliEnvReserved",
+ grokCliNotAuthenticated: "ai.cliErrors.grokCliNotAuthenticated",
+ grokCliMcpStartupFailed: "ai.cliErrors.grokCliMcpStartupFailed",
+ grokCliCommandLineTooLong: "ai.cliErrors.grokCliCommandLineTooLong",
+ grokCliRunFailed: "ai.cliErrors.grokCliRunFailed",
cursorEnvInvalid: "ai.cliErrors.cursorEnvInvalid",
cursorEnvReserved: "ai.cliErrors.cursorEnvReserved",
cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated",
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 3221971de..a8a7b9e83 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2248,6 +2248,14 @@ export default {
openCodeRunFailed: "OpenCode CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
cursorNotInstalled: "Cursor CLI was not found. Install Cursor CLI or set its executable path in Settings > AI.",
cursorCliPathInvalid: "The Cursor CLI path is invalid. Select only the agent executable and configure environment variables separately.",
+ grokCliNotInstalled: "Grok CLI was not found. Install Grok CLI or set its executable path in Settings > AI.",
+ grokCliPathInvalid: "The Grok CLI path is invalid. Select only the grok executable and configure environment variables separately.",
+ grokCliEnvInvalid: "A Grok CLI environment variable name is invalid. Use names such as HTTPS_PROXY.",
+ grokCliEnvReserved: "A DBX-managed MCP environment variable was overridden. Remove DBX_MCP_* variables from the provider configuration.",
+ grokCliNotAuthenticated: "Grok CLI is not authenticated. Run `grok login` in a terminal and try again.",
+ grokCliMcpStartupFailed: "Grok loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.",
+ grokCliCommandLineTooLong: "Windows rejected the Grok CLI command because it was too long. Update DBX and retry using the generated prompt file.",
+ grokCliRunFailed: "Grok CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
cursorEnvInvalid: "A Cursor CLI environment variable name is invalid. Use names such as HTTPS_PROXY.",
cursorEnvReserved: "A Cursor or DBX-managed environment variable was overridden. Remove CURSOR_CONFIG_DIR, CURSOR_DATA_DIR, and DBX_MCP_* variables from the provider configuration.",
cursorNotAuthenticated: "Cursor CLI is not authenticated. Run `agent login` in a terminal and try again.",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 1b62d11c2..03f249969 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -2094,6 +2094,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI no respondió antes de que finalizara el tiempo de espera.",
cursorProtocolError: "Cursor CLI devolvió un flujo de eventos JSON no válido. Revisa la versión de Cursor CLI y los detalles siguientes.",
cursorRunFailed: "Cursor CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.",
+ grokCliNotInstalled: "No se encontró Grok CLI. Instala Grok CLI o configura la ruta del ejecutable en Ajustes > AI.",
+ grokCliPathInvalid: "La ruta de Grok CLI no es válida. Selecciona solo el ejecutable grok y configura las variables de entorno por separado.",
+ grokCliEnvInvalid: "El nombre de una variable de entorno de Grok CLI no es válido. Usa nombres como HTTPS_PROXY.",
+ grokCliEnvReserved: "Se sobrescribió una variable MCP gestionada por DBX. Elimina las variables DBX_MCP_* de la configuración del proveedor.",
+ grokCliNotAuthenticated: "Grok CLI no está autenticado. Ejecuta `grok login` en una terminal e inténtalo de nuevo.",
+ grokCliMcpStartupFailed: "Grok cargó la configuración MCP pero no pudo iniciar DBX MCP Server. Revisa Ajustes > MCP y los diagnósticos siguientes.",
+ grokCliCommandLineTooLong: "Windows rechazó el comando de Grok CLI porque era demasiado largo. Actualiza DBX y vuelve a intentarlo con el archivo de prompt generado.",
+ grokCliRunFailed: "Grok CLI terminó de forma inesperada. Usa el código de error y los diagnósticos siguientes para identificar el ejecutable o la salida fallida.",
},
run: "Ejecutar",
readingSchema: "Leyendo esquema",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index 6f7044b20..c127312e9 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2234,6 +2234,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI non ha risposto prima della scadenza dell'operazione.",
cursorProtocolError: "Cursor CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di Cursor CLI e i dettagli seguenti.",
cursorRunFailed: "Cursor CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.",
+ grokCliNotInstalled: "Grok CLI non è stato trovato. Installa Grok CLI o imposta il percorso dell'eseguibile in Impostazioni > AI.",
+ grokCliPathInvalid: "Il percorso di Grok CLI non è valido. Seleziona solo l'eseguibile grok e configura separatamente le variabili d'ambiente.",
+ grokCliEnvInvalid: "Il nome di una variabile d'ambiente di Grok CLI non è valido. Usa nomi come HTTPS_PROXY.",
+ grokCliEnvReserved: "È stata sovrascritta una variabile MCP gestita da DBX. Rimuovi le variabili DBX_MCP_* dalla configurazione del provider.",
+ grokCliNotAuthenticated: "Grok CLI non è autenticato. Esegui `grok login` in un terminale e riprova.",
+ grokCliMcpStartupFailed: "Grok ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.",
+ grokCliCommandLineTooLong: "Windows ha rifiutato il comando Grok CLI perché troppo lungo. Aggiorna DBX e riprova usando il file di prompt generato.",
+ grokCliRunFailed: "Grok CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.",
},
actions: {
general: "Generale",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index bc5b96d8b..2048f6a69 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -2128,6 +2128,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLIはタイムアウトまでに応答しませんでした。",
cursorProtocolError: "Cursor CLIが無効なJSONイベントストリームを返しました。Cursor CLIのバージョンと以下の診断詳細を確認してください。",
cursorRunFailed: "Cursor CLIが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。",
+ grokCliNotInstalled: "Grok CLIが見つかりません。Grok CLIをインストールするか、設定 > AIで実行ファイルのパスを指定してください。",
+ grokCliPathInvalid: "Grok CLIのパスが無効です。grokの実行ファイルのみを選択し、環境変数は別に設定してください。",
+ grokCliEnvInvalid: "Grok CLIの環境変数名が無効です。HTTPS_PROXYなどの有効な名前を使用してください。",
+ grokCliEnvReserved: "DBXが管理するMCP環境変数が上書きされています。プロバイダー設定からDBX_MCP_*変数を削除してください。",
+ grokCliNotAuthenticated: "Grok CLIにログインしていません。ターミナルで`grok login`を実行してから再試行してください。",
+ grokCliMcpStartupFailed: "GrokはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断詳細を確認してください。",
+ grokCliCommandLineTooLong: "Windowsが長すぎるGrok CLIコマンドを拒否しました。DBXを更新し、生成されたプロンプトファイルで再試行してください。",
+ grokCliRunFailed: "Grok CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。",
},
run: "実行",
readingSchema: "スキーマを読み取り中",
diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts
index 35d3d34ae..2b4d8fb23 100644
--- a/apps/desktop/src/i18n/locales/ko.ts
+++ b/apps/desktop/src/i18n/locales/ko.ts
@@ -2112,6 +2112,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI가 제한 시간 안에 응답하지 않았습니다.",
cursorProtocolError: "Cursor CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. Cursor CLI 버전과 아래 진단을 확인하세요.",
cursorRunFailed: "Cursor CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패 원인을 확인하세요.",
+ grokCliNotInstalled: "Grok CLI를 찾을 수 없습니다. Grok CLI를 설치하거나 설정 > AI에서 실행 파일 경로를 설정하세요.",
+ grokCliPathInvalid: "Grok CLI 경로가 잘못되었습니다. grok 실행 파일만 선택하고 환경 변수는 별도로 구성하세요.",
+ grokCliEnvInvalid: "Grok CLI 환경 변수 이름이 잘못되었습니다. HTTPS_PROXY 같은 이름을 사용하세요.",
+ grokCliEnvReserved: "DBX가 관리하는 MCP 환경 변수가 재정의되었습니다. 공급자 구성에서 DBX_MCP_* 변수를 제거하세요.",
+ grokCliNotAuthenticated: "Grok CLI가 인증되지 않았습니다. 터미널에서 `grok login`을 실행한 후 다시 시도하세요.",
+ grokCliMcpStartupFailed: "Grok이 MCP 구성을 불러왔지만 DBX MCP 서버를 시작할 수 없습니다. 설정 > MCP와 아래 진단을 확인하세요.",
+ grokCliCommandLineTooLong: "명령이 너무 길어 Windows가 Grok CLI 명령을 거부했습니다. DBX를 업데이트하고 생성된 프롬프트 파일을 사용하여 다시 시도하세요.",
+ grokCliRunFailed: "Grok CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.",
},
actions: {
general: "일반",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index 2066c68a2..e8acd2325 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -2096,6 +2096,14 @@ export default withEnglishFallback({
cursorTimeout: "O Cursor CLI não respondeu antes do tempo limite da operação.",
cursorProtocolError: "O Cursor CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do Cursor CLI e os detalhes abaixo.",
cursorRunFailed: "O Cursor CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.",
+ grokCliNotInstalled: "Grok CLI não foi encontrado. Instale o Grok CLI ou defina o caminho do executável em Configurações > AI.",
+ grokCliPathInvalid: "O caminho do Grok CLI é inválido. Selecione apenas o executável grok e configure as variáveis de ambiente separadamente.",
+ grokCliEnvInvalid: "O nome de uma variável de ambiente do Grok CLI é inválido. Use nomes como HTTPS_PROXY.",
+ grokCliEnvReserved: "Uma variável MCP gerenciada pelo DBX foi sobrescrita. Remova as variáveis DBX_MCP_* da configuração do provedor.",
+ grokCliNotAuthenticated: "Grok CLI não está autenticado. Execute `grok login` em um terminal e tente novamente.",
+ grokCliMcpStartupFailed: "O Grok carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.",
+ grokCliCommandLineTooLong: "O Windows rejeitou o comando do Grok CLI porque era muito longo. Atualize o DBX e tente novamente com o arquivo de prompt gerado.",
+ grokCliRunFailed: "Grok CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.",
},
run: "Executar",
readingSchema: "Lendo schema",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index 98b6682cf..b132cd921 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2248,6 +2248,14 @@ export default withEnglishFallback({
openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
cursorNotInstalled: "未找到 Cursor CLI。请安装 Cursor CLI,或在 设置 > AI 中填写其可执行文件路径。",
cursorCliPathInvalid: "Cursor CLI 路径无效。请只选择 agent 可执行文件,环境变量需单独配置。",
+ grokCliNotInstalled: "未找到 Grok CLI。请安装 Grok CLI,或在 设置 > AI 中指定可执行文件路径。",
+ grokCliPathInvalid: "Grok CLI 路径无效。请只选择 grok 可执行文件,并单独配置环境变量。",
+ grokCliEnvInvalid: "Grok CLI 环境变量名称无效。请使用类似 HTTPS_PROXY 的名称。",
+ grokCliEnvReserved: "覆盖了 DBX 管理的 MCP 环境变量。请从提供商配置中移除 DBX_MCP_* 变量。",
+ grokCliNotAuthenticated: "Grok CLI 尚未登录。请在终端执行 `grok login` 后重试。",
+ grokCliMcpStartupFailed: "Grok 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 与下方诊断信息。",
+ grokCliCommandLineTooLong: "命令行过长,Windows 拒绝执行 Grok CLI。请更新 DBX 并使用生成的 prompt 文件重试。",
+ grokCliRunFailed: "Grok CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
cursorEnvInvalid: "Cursor CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。",
cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。",
cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 9eb39b765..5f2dc5d08 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2237,6 +2237,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI 未能在操作逾時前回應。",
cursorProtocolError: "Cursor CLI 傳回了無效的 JSON 事件串流。請檢查 Cursor CLI 版本和下方診斷詳情。",
cursorRunFailed: "Cursor CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。",
+ grokCliNotInstalled: "找不到 Grok CLI。請安裝 Grok CLI,或在 設定 > AI 中指定可執行檔路徑。",
+ grokCliPathInvalid: "Grok CLI 路徑無效。請只選擇 grok 可執行檔,並單獨設定環境變數。",
+ grokCliEnvInvalid: "Grok CLI 環境變數名稱無效。請使用類似 HTTPS_PROXY 的名稱。",
+ grokCliEnvReserved: "覆寫了 DBX 管理的 MCP 環境變數。請從供應商設定中移除 DBX_MCP_* 變數。",
+ grokCliNotAuthenticated: "Grok CLI 尚未登入。請在終端機執行 `grok login` 後重試。",
+ grokCliMcpStartupFailed: "Grok 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 與下方診斷資訊。",
+ grokCliCommandLineTooLong: "命令列過長,Windows 拒絕執行 Grok CLI。請更新 DBX 並使用產生的 prompt 檔案重試。",
+ grokCliRunFailed: "Grok CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。",
},
actions: {
general: "通用問答",
diff --git a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts
index 975fa0e56..34947d95d 100644
--- a/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts
+++ b/apps/desktop/src/lib/__tests__/ai/aiConfigCandidates.spec.ts
@@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => {
expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false);
});
- it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
+ it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
expect(
isAiConfigModelCandidate(
config({
diff --git a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts
index 8a63faeb9..36a0a7e3c 100644
--- a/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts
+++ b/apps/desktop/src/lib/ai/__tests__/aiConfigOrdering.spec.ts
@@ -23,11 +23,12 @@ describe("orderAiConfigsForDisplay", () => {
{ id: "codex", provider: "codex-cli" },
{ id: "opencode", provider: "opencode-cli" },
{ id: "cursor", provider: "cursor-cli" },
+ { id: "grok", provider: "grok-cli" },
{ id: "pi", provider: "pi-agent-cli" },
{ id: "custom", provider: "custom" },
];
- expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "pi", "custom"]);
+ expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "cursor", "grok", "pi", "custom"]);
});
it("preserves creation order for configs from the same provider", () => {
diff --git a/apps/desktop/src/lib/ai/aiConfigCandidates.ts b/apps/desktop/src/lib/ai/aiConfigCandidates.ts
index 0b2540c7b..c1af7bbde 100644
--- a/apps/desktop/src/lib/ai/aiConfigCandidates.ts
+++ b/apps/desktop/src/lib/ai/aiConfigCandidates.ts
@@ -1,6 +1,6 @@
import type { AiConfig } from "@/types/ai";
-const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"]);
+const CLI_PROVIDERS = new Set(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli"]);
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.
diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts
index 227e6b83c..feeecaa07 100644
--- a/apps/desktop/src/stores/settingsStore.ts
+++ b/apps/desktop/src/stores/settingsStore.ts
@@ -253,6 +253,16 @@ export const AI_PROVIDER_PRESETS: Record = {
authMethod: "bearer",
requiresApiKey: false,
},
+ "grok-cli": {
+ label: "Grok CLI",
+ iconSlug: "grok",
+ provider: "grok-cli",
+ endpoint: "",
+ model: "default",
+ apiStyle: "completions",
+ authMethod: "bearer",
+ requiresApiKey: false,
+ },
"pi-agent-cli": {
label: "Pi Coding Agent",
iconSlug: "pi",
@@ -323,6 +333,8 @@ export function normalizeAiConfig(config: Partial | null | undefined):
opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv),
cursorCliPath: config?.cursorCliPath?.trim() || undefined,
cursorCliEnv: normalizeAiEnv(config?.cursorCliEnv),
+ grokCliPath: config?.grokCliPath?.trim() || undefined,
+ grokCliEnv: normalizeAiEnv(config?.grokCliEnv),
};
}
@@ -1461,7 +1473,7 @@ export const useSettingsStore = defineStore("settings", () => {
const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
if (!config) return false;
const preset = AI_PROVIDER_PRESETS[config.provider];
- if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli" || config.provider === "cursor-cli") return true;
+ if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli" || config.provider === "cursor-cli" || config.provider === "grok-cli") return true;
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
});
diff --git a/apps/desktop/src/types/ai.ts b/apps/desktop/src/types/ai.ts
index 9a017f2d1..ff5e0f3d8 100644
--- a/apps/desktop/src/types/ai.ts
+++ b/apps/desktop/src/types/ai.ts
@@ -1,4 +1,4 @@
-export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "custom";
+export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "cursor-cli" | "grok-cli" | "custom";
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
@@ -51,6 +51,8 @@ export interface AiConfig {
opencodeCliEnv?: Record;
cursorCliPath?: string | null;
cursorCliEnv?: Record;
+ grokCliPath?: string | null;
+ grokCliEnv?: Record;
runtimeEffort?: AiEffortSelection | null;
}
diff --git a/crates/dbx-core/src/agent_loop.rs b/crates/dbx-core/src/agent_loop.rs
index bf6279c4d..96ffdcf4f 100644
--- a/crates/dbx-core/src/agent_loop.rs
+++ b/crates/dbx-core/src/agent_loop.rs
@@ -191,6 +191,11 @@ pub async fn run_agent_loop(
);
return crate::ai_cursor_cli::run_cursor_agent(config, &prompt, options, cancelled, on_event).await;
}
+ if matches!(config.provider, AiProvider::GrokCli) {
+ let prompt =
+ crate::ai_grok_cli::build_grok_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
+ return crate::ai_grok_cli::run_grok_agent(config, &prompt, options, cancelled, on_event).await;
+ }
let prompt =
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs
index d6de78696..cace8ba0a 100644
--- a/crates/dbx-core/src/ai.rs
+++ b/crates/dbx-core/src/ai.rs
@@ -83,6 +83,8 @@ pub enum AiProvider {
OpenCodeCli,
#[serde(rename = "cursor-cli")]
CursorCli,
+ #[serde(rename = "grok-cli")]
+ GrokCli,
Custom,
}
@@ -102,6 +104,7 @@ impl AiProvider {
AiProvider::PiAgentCli => "pi-agent-cli",
AiProvider::OpenCodeCli => "opencode-cli",
AiProvider::CursorCli => "cursor-cli",
+ AiProvider::GrokCli => "grok-cli",
AiProvider::CodexCli => "codex-cli",
AiProvider::Custom => "custom",
}
@@ -401,6 +404,10 @@ pub struct AiConfig {
pub cursor_cli_path: Option,
#[serde(default)]
pub cursor_cli_env: HashMap,
+ #[serde(default)]
+ pub grok_cli_path: Option,
+ #[serde(default)]
+ pub grok_cli_env: HashMap,
}
fn default_enable_thinking() -> bool {
@@ -418,6 +425,7 @@ pub fn is_cli_provider(provider: &AiProvider) -> bool {
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::CursorCli
+ | AiProvider::GrokCli
)
}
@@ -649,6 +657,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::CursorCli
+ | AiProvider::GrokCli
| AiProvider::Gemini => unreachable!(),
}
}
@@ -1498,6 +1507,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str
AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?,
AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?,
AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_models(config).await?,
+ AiProvider::GrokCli => crate::ai_grok_cli::list_grok_models(config).await?,
_ => {
validate_model_list_config(config)?;
let client = build_ai_http_client(config, 30)?;
@@ -1524,7 +1534,8 @@ pub async fn list_models_core(config: &AiConfig) -> Result, Str
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
- | AiProvider::CursorCli => {
+ | AiProvider::CursorCli
+ | AiProvider::GrokCli => {
unreachable!()
}
}
@@ -1553,7 +1564,7 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
return Ok(AiEffortCapability::Unsupported);
}
- if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
+ if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::GrokCli) {
let models = list_models_core(config).await?;
return Ok(models
.into_iter()
@@ -2087,6 +2098,10 @@ pub async fn test_connection_core(config: &AiConfig) -> Result Result {
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
- | AiProvider::CursorCli => {
+ | AiProvider::CursorCli
+ | AiProvider::GrokCli => {
unreachable!()
}
AiProvider::Openai
@@ -2521,7 +2537,8 @@ pub async fn stream(
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
- | AiProvider::CursorCli => {
+ | AiProvider::CursorCli
+ | AiProvider::GrokCli => {
unreachable!()
}
AiProvider::Openai
@@ -4181,6 +4198,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
},
system_prompt: "Be concise.".to_string(),
messages: vec![AiMessage {
@@ -4795,6 +4814,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let err = build_ai_http_client(&config, 1).unwrap_err();
@@ -4829,6 +4850,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@@ -4861,6 +4884,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@@ -4893,6 +4918,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(
@@ -4929,6 +4956,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
@@ -4962,6 +4991,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
for provider in
@@ -5014,6 +5045,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
@@ -5042,6 +5075,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
}
@@ -5073,6 +5108,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert!(uses_anthropic_messages_api(&config));
@@ -5156,6 +5193,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert!(!uses_anthropic_messages_api(&config));
@@ -5199,6 +5238,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
@@ -5266,6 +5307,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
@@ -5305,6 +5348,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let api_key_headers = claude_headers(&config).unwrap();
@@ -5424,6 +5469,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
@@ -5460,6 +5507,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
@@ -5545,6 +5594,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let models = vec![
AiModelInfo::new("qwen3:0.6b", None),
@@ -5810,6 +5861,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({
@@ -6025,6 +6078,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@@ -6066,6 +6121,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -6113,6 +6170,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -6154,6 +6213,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -6191,6 +6252,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@@ -6259,6 +6322,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let request = AiCompletionRequest {
config: config.clone(),
@@ -6321,6 +6386,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@@ -6853,6 +6920,7 @@ mod tests {
AiProvider::PiAgentCli,
AiProvider::OpenCodeCli,
AiProvider::CursorCli,
+ AiProvider::GrokCli,
] {
let mut config = test_config(provider.clone());
config.max_retries = None;
diff --git a/crates/dbx-core/src/ai_claude_code_cli.rs b/crates/dbx-core/src/ai_claude_code_cli.rs
index 3ae0b5867..4e937e860 100644
--- a/crates/dbx-core/src/ai_claude_code_cli.rs
+++ b/crates/dbx-core/src/ai_claude_code_cli.rs
@@ -585,6 +585,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs
index e5ffea3d8..a47a43c5a 100644
--- a/crates/dbx-core/src/ai_cli_agent.rs
+++ b/crates/dbx-core/src/ai_cli_agent.rs
@@ -36,6 +36,8 @@ pub enum CliAgentJsonlDialect {
ClaudeCodePrint,
OpenCodeRun,
CursorPrint,
+ /// Grok Build headless `--output-format streaming-json` (ACP-derived NDJSON).
+ GrokStreamingJson,
}
pub struct CliAgentProcessSpec {
@@ -238,6 +240,7 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA
CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line),
CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line),
CliAgentJsonlDialect::CursorPrint => parse_cursor_jsonl_line(line),
+ CliAgentJsonlDialect::GrokStreamingJson => parse_grok_streaming_json_line(line),
}
}
@@ -792,6 +795,139 @@ fn cursor_error_message(value: &Value) -> String {
.to_string()
}
+/// Parse Grok Build `--output-format streaming-json` NDJSON events.
+///
+/// Documented event types: `text`, `thought`, `tool_call`, `tool_call_update`,
+/// `usage`, `plan`, `available_commands`, `end`, `error` (list is non-exhaustive).
+fn parse_grok_streaming_json_line(line: &str) -> ParsedCliAgentEvent {
+ let Ok(value) = serde_json::from_str::(line) else {
+ return ParsedCliAgentEvent::default();
+ };
+
+ match value.get("type").and_then(Value::as_str).unwrap_or_default() {
+ "text" => {
+ let Some(text) = value.get("data").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
+ return ParsedCliAgentEvent::default();
+ };
+ ParsedCliAgentEvent {
+ final_text: Some(text.to_string()),
+ events: vec![AgentEvent::TextDelta { delta: text.to_string() }],
+ ..Default::default()
+ }
+ }
+ "thought" => {
+ let Some(text) = value.get("data").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
+ return ParsedCliAgentEvent::default();
+ };
+ ParsedCliAgentEvent {
+ events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }],
+ ..Default::default()
+ }
+ }
+ "tool_call" => {
+ let status = value.get("status").and_then(Value::as_str).unwrap_or("in_progress");
+ if status == "failed" || status == "error" {
+ return parse_grok_tool_call_end(&value, true);
+ }
+ ParsedCliAgentEvent {
+ events: vec![AgentEvent::ToolCallStart {
+ tool_call_id: grok_tool_call_id(&value),
+ tool_name: grok_tool_name(&value),
+ args: value
+ .get("rawInput")
+ .or_else(|| value.get("raw_input"))
+ .or_else(|| value.get("input"))
+ .cloned()
+ .unwrap_or_else(|| Value::Object(Default::default())),
+ }],
+ ..Default::default()
+ }
+ }
+ "tool_call_update" => {
+ let status = value.get("status").and_then(Value::as_str).unwrap_or("completed");
+ let is_error = matches!(status, "failed" | "error" | "cancelled" | "rejected");
+ if matches!(status, "in_progress" | "pending" | "running") {
+ return ParsedCliAgentEvent::default();
+ }
+ parse_grok_tool_call_end(&value, is_error)
+ }
+ "end" => {
+ let usage = value.get("usage").and_then(grok_usage_tokens);
+ ParsedCliAgentEvent {
+ events: vec![AgentEvent::AgentEnd {
+ input_tokens: usage.as_ref().and_then(|u| (u.input_tokens > 0).then_some(u.input_tokens)),
+ output_tokens: usage.as_ref().and_then(|u| (u.output_tokens > 0).then_some(u.output_tokens)),
+ }],
+ ..Default::default()
+ }
+ }
+ "error" => {
+ let message = value
+ .get("message")
+ .and_then(Value::as_str)
+ .or_else(|| value.get("error").and_then(Value::as_str))
+ .or_else(|| value.get("data").and_then(Value::as_str))
+ .unwrap_or("Grok CLI failed")
+ .to_string();
+ ParsedCliAgentEvent {
+ error: Some(message.clone()),
+ events: vec![AgentEvent::Error { message }],
+ ..Default::default()
+ }
+ }
+ _ => ParsedCliAgentEvent::default(),
+ }
+}
+
+fn parse_grok_tool_call_end(value: &Value, is_error: bool) -> ParsedCliAgentEvent {
+ let result = value
+ .get("rawOutput")
+ .or_else(|| value.get("raw_output"))
+ .or_else(|| value.get("content"))
+ .or_else(|| value.get("error"))
+ .cloned()
+ .unwrap_or(Value::Null);
+ ParsedCliAgentEvent {
+ events: vec![AgentEvent::ToolCallEnd {
+ tool_call_id: grok_tool_call_id(value),
+ tool_name: grok_tool_name(value),
+ result,
+ is_error,
+ }],
+ ..Default::default()
+ }
+}
+
+fn grok_tool_call_id(value: &Value) -> String {
+ value
+ .get("toolCallId")
+ .and_then(Value::as_str)
+ .or_else(|| value.get("tool_call_id").and_then(Value::as_str))
+ .or_else(|| value.get("id").and_then(Value::as_str))
+ .unwrap_or("grok-tool-call")
+ .to_string()
+}
+
+fn grok_tool_name(value: &Value) -> String {
+ value
+ .get("toolName")
+ .and_then(Value::as_str)
+ .or_else(|| value.get("tool_name").and_then(Value::as_str))
+ .or_else(|| value.get("title").and_then(Value::as_str))
+ .or_else(|| value.get("name").and_then(Value::as_str))
+ .unwrap_or("mcp_tool")
+ .to_string()
+}
+
+fn grok_usage_tokens(usage: &Value) -> Option {
+ let input =
+ usage.get("input_tokens").or_else(|| usage.get("prompt_tokens")).and_then(Value::as_u64).unwrap_or(0) as u32;
+ let output =
+ usage.get("output_tokens").or_else(|| usage.get("completion_tokens")).and_then(Value::as_u64).unwrap_or(0)
+ as u32;
+ (input > 0 || output > 0).then_some(TokenUsage { input_tokens: input, output_tokens: output })
+}
+
pub async fn run_cli_jsonl_agent(
spec: CliAgentProcessSpec,
cancelled: &Notify,
@@ -895,6 +1031,74 @@ pub async fn run_cli_jsonl_agent(
Ok(final_text)
}
+#[cfg(test)]
+mod grok_streaming_json_tests {
+ use super::*;
+
+ #[test]
+ fn parses_text_thought_and_end_with_usage() {
+ let text = parse_cli_jsonl_event(r#"{"type":"text","data":"hello"}"#, CliAgentJsonlDialect::GrokStreamingJson)
+ .unwrap();
+ assert!(matches!(&text[0], AgentEvent::TextDelta { delta } if delta == "hello"));
+
+ let thought = parse_cli_jsonl_event(
+ r#"{"type":"thought","data":"thinking..."}"#,
+ CliAgentJsonlDialect::GrokStreamingJson,
+ )
+ .unwrap();
+ assert!(matches!(&thought[0], AgentEvent::ReasoningDelta { delta } if delta == "thinking..."));
+
+ let end = parse_cli_jsonl_event(
+ r#"{"type":"end","stopReason":"end_turn","usage":{"input_tokens":10,"output_tokens":4}}"#,
+ CliAgentJsonlDialect::GrokStreamingJson,
+ )
+ .unwrap();
+ assert!(matches!(&end[0], AgentEvent::AgentEnd { input_tokens: Some(10), output_tokens: Some(4) }));
+ }
+
+ #[test]
+ fn parses_tool_call_lifecycle() {
+ let start = parse_cli_jsonl_event(
+ r#"{"type":"tool_call","toolCallId":"call_1","toolName":"dbx__dbx_list_tables","status":"in_progress","rawInput":{"schema":"public"}}"#,
+ CliAgentJsonlDialect::GrokStreamingJson,
+ )
+ .unwrap();
+ assert!(matches!(
+ &start[0],
+ AgentEvent::ToolCallStart { tool_call_id, tool_name, args }
+ if tool_call_id == "call_1"
+ && tool_name == "dbx__dbx_list_tables"
+ && args.get("schema").and_then(Value::as_str) == Some("public")
+ ));
+
+ let end = parse_cli_jsonl_event(
+ r#"{"type":"tool_call_update","toolCallId":"call_1","toolName":"dbx__dbx_list_tables","status":"completed","rawOutput":{"tables":["users"]}}"#,
+ CliAgentJsonlDialect::GrokStreamingJson,
+ )
+ .unwrap();
+ assert!(matches!(
+ &end[0],
+ AgentEvent::ToolCallEnd { tool_call_id, is_error: false, .. } if tool_call_id == "call_1"
+ ));
+
+ assert!(parse_cli_jsonl_event(
+ r#"{"type":"tool_call_update","toolCallId":"call_1","status":"in_progress"}"#,
+ CliAgentJsonlDialect::GrokStreamingJson,
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn parses_error_event() {
+ let parsed = parse_cli_jsonl_line(
+ r#"{"type":"error","message":"auth failed"}"#,
+ CliAgentJsonlDialect::GrokStreamingJson,
+ );
+ assert_eq!(parsed.error.as_deref(), Some("auth failed"));
+ assert!(matches!(&parsed.events[0], AgentEvent::Error { message } if message == "auth failed"));
+ }
+}
+
#[cfg(all(test, unix))]
mod tests {
use super::*;
diff --git a/crates/dbx-core/src/ai_codex_cli.rs b/crates/dbx-core/src/ai_codex_cli.rs
index 8fa5e3479..44896a1ab 100644
--- a/crates/dbx-core/src/ai_codex_cli.rs
+++ b/crates/dbx-core/src/ai_codex_cli.rs
@@ -1042,6 +1042,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_cursor_cli.rs b/crates/dbx-core/src/ai_cursor_cli.rs
index dea8c0b4f..e7057d28d 100644
--- a/crates/dbx-core/src/ai_cursor_cli.rs
+++ b/crates/dbx-core/src/ai_cursor_cli.rs
@@ -422,6 +422,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_effort.rs b/crates/dbx-core/src/ai_effort.rs
index 72ab30721..783d3af2c 100644
--- a/crates/dbx-core/src/ai_effort.rs
+++ b/crates/dbx-core/src/ai_effort.rs
@@ -87,7 +87,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option None,
+ | AiProvider::CursorCli
+ | AiProvider::GrokCli => None,
}
}
@@ -194,6 +195,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::CursorCli
+ | AiProvider::GrokCli
| AiProvider::Custom => None,
}
}
@@ -214,6 +216,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::CursorCli
+ | AiProvider::GrokCli
) {
return match selection {
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
@@ -270,7 +273,8 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
- | AiProvider::CursorCli => {}
+ | AiProvider::CursorCli
+ | AiProvider::GrokCli => {}
}
}
@@ -428,6 +432,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_grok_cli.rs b/crates/dbx-core/src/ai_grok_cli.rs
new file mode 100644
index 000000000..aee02921a
--- /dev/null
+++ b/crates/dbx-core/src/ai_grok_cli.rs
@@ -0,0 +1,695 @@
+use crate::agent_events::AgentEvent;
+use crate::ai::{
+ AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortLevel, AiEffortOption, AiEffortSelection, AiModelInfo,
+ AiTestConnectionResult,
+};
+use crate::ai_cli_agent::{
+ build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, run_cli_jsonl_agent, toml_string,
+ toml_string_array, CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
+};
+use std::collections::{BTreeMap, BTreeSet};
+use std::env;
+use std::path::{Path, PathBuf};
+use std::process::Stdio;
+use std::time::{Duration, Instant};
+use tokio::sync::Notify;
+
+const DEFAULT_GROK_MODELS: &[&str] = &["default", "grok-4.5"];
+/// Matches Grok CLI model metadata (`supports_reasoning_effort` + `reasoning_efforts`).
+const DEFAULT_GROK_EFFORTS: &[&str] = &["low", "medium", "high"];
+const DEFAULT_GROK_EFFORT: &str = "high";
+const GROK_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
+const DISALLOWED_BUILTIN_TOOLS: &str = "run_terminal_command,read_file,search_replace,list_dir,grep,kill_command_or_subagent,todo_write,get_command_or_subagent_output,spawn_subagent,scheduler_create,scheduler_delete,scheduler_list,monitor,search_tool,use_tool,workflow,enter_plan_mode,exit_plan_mode,ask_user_question,image_gen,image_edit,image_to_video,reference_to_video,write,bash,shell,edit,Glob,Grep";
+
+pub type GrokRunOptions = CliAgentRunOptions;
+pub type GrokCommandSpec = CliAgentCommandSpec;
+
+struct GrokIsolatedHome {
+ path: PathBuf,
+}
+
+impl GrokIsolatedHome {
+ fn create(options: &GrokRunOptions) -> Result {
+ let path = env::temp_dir().join(format!("dbx-grok-cli-{}", uuid::Uuid::new_v4()));
+ let grok_dir = path.join(".grok");
+ std::fs::create_dir_all(&grok_dir)
+ .map_err(|error| format!("[grokCliRunFailed] Failed to create isolated Grok home: {error}"))?;
+
+ if let Some(auth_source) = real_grok_auth_path() {
+ let auth_dest = grok_dir.join("auth.json");
+ std::fs::copy(&auth_source, &auth_dest).map_err(|error| {
+ format!(
+ "[grokCliNotAuthenticated] Failed to copy Grok auth credentials into the isolated home: {error}"
+ )
+ })?;
+ }
+
+ let config_path = grok_dir.join("config.toml");
+ std::fs::write(&config_path, grok_mcp_config_toml(options))
+ .map_err(|error| format!("[grokCliRunFailed] Failed to write isolated Grok MCP configuration: {error}"))?;
+
+ Ok(Self { path })
+ }
+
+ fn write_prompt(&self, prompt: &str) -> Result {
+ let path = self.path.join("dbx-prompt.txt");
+ std::fs::write(&path, prompt)
+ .map_err(|error| format!("[grokCliRunFailed] Failed to write Grok prompt file: {error}"))?;
+ Ok(path)
+ }
+}
+
+impl Drop for GrokIsolatedHome {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.path);
+ }
+}
+
+fn real_grok_home() -> Option {
+ env::var_os("HOME").map(PathBuf::from).map(|home| home.join(".grok")).filter(|path| path.is_dir())
+}
+
+fn real_grok_auth_path() -> Option {
+ real_grok_home().map(|home| home.join("auth.json")).filter(|path| path.is_file())
+}
+
+fn grok_program(config: &AiConfig) -> String {
+ config.grok_cli_path.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or("grok").to_string()
+}
+
+fn grok_process_env(
+ config: &AiConfig,
+ command: &GrokCommandSpec,
+ home: Option<&Path>,
+) -> Result, String> {
+ let mut env = BTreeMap::from_iter(grok_cli_env(config)?);
+ if let Some(home) = home {
+ env.insert("HOME".to_string(), home.to_string_lossy().to_string());
+ }
+ if let Some(dir) = command_parent_dir(command) {
+ let user_path = env.get("PATH").map(String::as_str);
+ env.insert("PATH".to_string(), merged_path_with_dir(&dir, user_path));
+ }
+ Ok(env.into_iter().collect())
+}
+
+fn command_parent_dir(command: &GrokCommandSpec) -> Option {
+ Path::new(&command.program)
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .map(|parent| parent.to_string_lossy().to_string())
+}
+
+fn merged_path_with_dir(dir: &str, user_path: Option<&str>) -> String {
+ let mut seen = BTreeSet::new();
+ let mut dirs = vec![PathBuf::from(dir)];
+ if let Some(path) = user_path {
+ dirs.extend(env::split_paths(path));
+ }
+ dirs.extend(common_executable_dirs());
+ let paths = dirs.into_iter().filter(|path| seen.insert(path.clone())).collect::>();
+ env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string()
+}
+
+fn common_executable_dirs() -> Vec {
+ let mut dirs = Vec::new();
+ if let Ok(path) = env::var("PATH") {
+ dirs.extend(env::split_paths(&path));
+ }
+ #[cfg(windows)]
+ {
+ if let Ok(app_data) = env::var("APPDATA") {
+ dirs.push(PathBuf::from(app_data).join("npm"));
+ }
+ }
+ #[cfg(not(windows))]
+ {
+ dirs.extend([
+ PathBuf::from("/opt/homebrew/bin"),
+ PathBuf::from("/usr/local/bin"),
+ PathBuf::from("/usr/bin"),
+ PathBuf::from("/bin"),
+ PathBuf::from("/usr/sbin"),
+ PathBuf::from("/sbin"),
+ ]);
+ if let Some(home) = env::var_os("HOME") {
+ dirs.push(PathBuf::from(home).join(".grok").join("bin"));
+ }
+ }
+ dirs
+}
+
+fn validate_grok_program(config: &AiConfig) -> Result {
+ let program = grok_program(config);
+ if starts_with_env_assignment(&program) {
+ return Err("[grokCliPathInvalid] Grok CLI path should contain only the executable path. Add environment variables in the Grok CLI environment variables section.".to_string());
+ }
+ if is_path_like_program(&program) {
+ let expanded = crate::path_utils::expand_tilde(&program);
+ let path = Path::new(&expanded);
+ if path.is_dir() {
+ return launchable_program_in_dir(path, "grok").ok_or_else(|| {
+ "[grokCliPathInvalid] Grok CLI path should point to the grok executable or a directory containing grok."
+ .to_string()
+ });
+ }
+ return Ok(expanded);
+ }
+ Ok(program)
+}
+
+fn launchable_program_in_dir(dir: &Path, program: &str) -> Option {
+ program_path_candidates(dir, program)
+ .into_iter()
+ .find(|candidate| is_launchable_program_path(candidate) && candidate.is_file())
+ .map(|path| path.to_string_lossy().to_string())
+}
+
+#[cfg(not(windows))]
+fn program_path_candidates(dir: &Path, program: &str) -> Vec {
+ vec![dir.join(program)]
+}
+
+#[cfg(windows)]
+fn program_path_candidates(dir: &Path, program: &str) -> Vec {
+ let path = Path::new(program);
+ if path.extension().is_some() {
+ return vec![dir.join(program)];
+ }
+ [".cmd", ".exe", ".bat", ".com", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect()
+}
+
+#[cfg(not(windows))]
+fn is_launchable_program_path(_path: &Path) -> bool {
+ true
+}
+
+#[cfg(windows)]
+fn is_launchable_program_path(path: &Path) -> bool {
+ matches!(
+ path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref(),
+ Some("exe" | "cmd" | "bat" | "com")
+ )
+}
+
+fn is_path_like_program(program: &str) -> bool {
+ program.contains('/') || program.contains('\\') || program.starts_with('~')
+}
+
+fn starts_with_env_assignment(program: &str) -> bool {
+ let Some(first_token) = program.split_whitespace().next() else {
+ return false;
+ };
+ let Some((key, _)) = first_token.split_once('=') else {
+ return false;
+ };
+ is_env_var_name(key)
+}
+
+fn is_env_var_name(name: &str) -> bool {
+ let mut chars = name.chars();
+ let Some(first) = chars.next() else {
+ return false;
+ };
+ (first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
+}
+
+fn is_reserved_dbx_mcp_env_name(name: &str) -> bool {
+ name.to_ascii_uppercase().starts_with("DBX_MCP_")
+}
+
+pub fn grok_cli_env(config: &AiConfig) -> Result, String> {
+ let mut env = BTreeMap::new();
+ for (key, value) in &config.grok_cli_env {
+ let key = key.trim();
+ if key.is_empty() {
+ continue;
+ }
+ if !is_env_var_name(key) {
+ return Err(format!(
+ "[grokCliEnvInvalid] Invalid Grok CLI environment variable name `{key}`. Use names like HTTPS_PROXY."
+ ));
+ }
+ if is_reserved_dbx_mcp_env_name(key) {
+ return Err(format!(
+ "[grokCliEnvReserved] `{key}` is managed by DBX for the scoped MCP server and cannot be set here."
+ ));
+ }
+ env.insert(key.to_string(), value.clone());
+ }
+ Ok(env.into_iter().collect())
+}
+
+/// Grok MCP tool names are `server__tool` (no `mcp__` prefix). Permission rules
+/// must use `MCPTool(server__tool)` form — `mcp__server__tool` never matches.
+fn grok_mcp_permission_rules(options: &GrokRunOptions) -> Vec {
+ let mut rules = vec!["MCPTool(dbx__*)".to_string()];
+ for tool in dbx_mcp_enabled_tools(options.agent_mode) {
+ rules.push(format!("MCPTool(dbx__{tool})"));
+ }
+ rules
+}
+
+fn grok_mcp_config_toml(options: &GrokRunOptions) -> String {
+ let mcp_command =
+ options.mcp_server_command.as_ref().map(|command| command.program.as_str()).unwrap_or("dbx-mcp-server");
+ let permission_rules = grok_mcp_permission_rules(options);
+ let permission_rule_refs = permission_rules.iter().map(String::as_str).collect::>();
+ let mut lines = vec![
+ "[cli]".to_string(),
+ "auto_update = false".to_string(),
+ String::new(),
+ // Tool auto-approval is driven by the `--always-approve` CLI flag, not a
+ // config key: grok's permission_mode enum has no "always-approve" value
+ // (valid values: default/acceptEdits/auto/dontAsk/bypassPermissions/plan).
+ "[permission]".to_string(),
+ format!("allow = {}", toml_string_array(&permission_rule_refs)),
+ String::new(),
+ // Only command/args/env are part of the documented mcp_servers schema
+ // (per `grok mcp add`); startup_timeout_sec/tool_timeout_sec/enabled_tools
+ // are not recognized fields and would be silently ignored.
+ "[mcp_servers.dbx]".to_string(),
+ format!("command = {}", toml_string(mcp_command)),
+ ];
+ if let Some(command) = options.mcp_server_command.as_ref().filter(|command| !command.args.is_empty()) {
+ let args = command.args.iter().map(String::as_str).collect::>();
+ lines.push(format!("args = {}", toml_string_array(&args)));
+ }
+ lines.push(String::new());
+ lines.push("[mcp_servers.dbx.env]".to_string());
+ for (name, value) in dbx_mcp_scope_env(options) {
+ lines.push(format!("{name} = {}", toml_string(&value)));
+ }
+ lines.push(String::new());
+ lines.join("\n")
+}
+
+pub fn build_grok_command(config: &AiConfig, prompt_file: &Path, options: &GrokRunOptions) -> GrokCommandSpec {
+ // Align with Grok Build headless docs (`--prompt-file`, `streaming-json`,
+ // `--always-approve`, `--effort`). Prompt is written to a temp file so long
+ // DBX system+conversation prompts stay under OS argv limits.
+ let mut args = vec![
+ "--prompt-file".to_string(),
+ prompt_file.to_string_lossy().to_string(),
+ "--output-format".to_string(),
+ "streaming-json".to_string(),
+ "--always-approve".to_string(),
+ "--disable-web-search".to_string(),
+ "--no-subagents".to_string(),
+ "--no-plan".to_string(),
+ "--disallowed-tools".to_string(),
+ DISALLOWED_BUILTIN_TOOLS.to_string(),
+ "--verbatim".to_string(),
+ ];
+
+ // Grok permission rules: `MCPTool(server__tool)`, not Claude-style `mcp__server__tool`.
+ for rule in grok_mcp_permission_rules(options) {
+ args.push("--allow".to_string());
+ args.push(rule);
+ }
+
+ let model = config.model.trim();
+ if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
+ args.push("--model".to_string());
+ args.push(model.to_string());
+ }
+
+ let effort =
+ config.runtime_effort.as_ref().and_then(|effort| effort.cli_value()).or_else(|| match config.reasoning_level {
+ crate::ai::AiReasoningLevel::Default | crate::ai::AiReasoningLevel::Minimal => None,
+ crate::ai::AiReasoningLevel::Low => Some("low".to_string()),
+ crate::ai::AiReasoningLevel::Medium => Some("medium".to_string()),
+ crate::ai::AiReasoningLevel::High => Some("high".to_string()),
+ crate::ai::AiReasoningLevel::Xhigh | crate::ai::AiReasoningLevel::Max => Some("high".to_string()),
+ });
+ if let Some(effort) = effort {
+ // `--effort` is the documented alias of `--reasoning-effort`.
+ args.push("--effort".to_string());
+ args.push(effort);
+ }
+
+ GrokCommandSpec { program: grok_program(config), args }
+}
+
+pub fn build_grok_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String {
+ build_cli_agent_prompt("Grok", system_prompt, messages, allow_write_sql)
+}
+
+fn grok_effort_option(id: &str, label: &str, description: &str) -> AiEffortOption {
+ AiEffortOption {
+ id: id.to_string(),
+ label: label.to_string(),
+ description: Some(description.to_string()),
+ selection: AiEffortSelection::Enum(id.to_string()),
+ }
+}
+
+/// Grok CLI reasoning effort levels (same surface as Codex model effort in the assistant).
+pub fn grok_effort_capability() -> AiEffortCapability {
+ AiEffortCapability::Enum {
+ options: vec![
+ grok_effort_option("low", "Low", "Quick, fast implementations"),
+ grok_effort_option("medium", "Medium", "Balanced effort with standard implementation and testing"),
+ grok_effort_option("high", "High", "Highest implementation quality with extensive reasoning"),
+ ],
+ default: AiEffortSelection::Enum(DEFAULT_GROK_EFFORT.to_string()),
+ source: AiCapabilitySource::LocalCli,
+ }
+}
+
+fn with_grok_effort(mut model: AiModelInfo) -> AiModelInfo {
+ model.effort_capability = Some(grok_effort_capability());
+ model.supported_effort_levels =
+ DEFAULT_GROK_EFFORTS.iter().filter_map(|level| level.parse::().ok()).collect();
+ model
+}
+
+fn default_grok_models() -> Vec {
+ DEFAULT_GROK_MODELS
+ .iter()
+ .map(|id| {
+ let display = if *id == "default" { Some("Default".to_string()) } else { None };
+ with_grok_effort(AiModelInfo::new(*id, display))
+ })
+ .collect()
+}
+
+pub async fn list_grok_models(config: &AiConfig) -> Result, String> {
+ let program = validate_grok_program(config)?;
+ Ok(discover_grok_models(config, program).await.unwrap_or_else(default_grok_models))
+}
+
+async fn discover_grok_models(config: &AiConfig, program: String) -> Option> {
+ let command = GrokCommandSpec { program, args: vec!["models".to_string()] };
+ let env = grok_process_env(config, &command, None).ok()?;
+ let mut process = cli_command(&command.program);
+ process
+ .args(command.args.iter().map(String::as_str))
+ .envs(env.iter().map(|(key, value)| (key.as_str(), value.as_str())))
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .kill_on_drop(true);
+ let child = process.spawn().ok()?;
+ let output = tokio::time::timeout(GROK_MODEL_DISCOVERY_TIMEOUT, child.wait_with_output()).await.ok()?.ok()?;
+ if !output.status.success() {
+ return None;
+ }
+ parse_grok_models(&String::from_utf8_lossy(&output.stdout))
+}
+
+fn parse_grok_models(stdout: &str) -> Option> {
+ let mut seen = BTreeSet::new();
+ let mut models = Vec::new();
+ for line in stdout.lines() {
+ let trimmed = line.trim();
+ let Some(id) = trimmed.strip_prefix('*').map(str::trim).filter(|id| !id.is_empty()) else {
+ continue;
+ };
+ let id = id.split_whitespace().next().unwrap_or(id).trim_matches(|ch| ch == '(' || ch == ')' || ch == ',');
+ if id.is_empty() || !seen.insert(id.to_string()) {
+ continue;
+ }
+ models.push(with_grok_effort(AiModelInfo::new(id, None)));
+ }
+ if models.is_empty() {
+ return None;
+ }
+ if seen.insert("default".to_string()) {
+ models.insert(0, with_grok_effort(AiModelInfo::new("default", Some("Default".to_string()))));
+ }
+ Some(models)
+}
+
+pub async fn test_grok_connection(config: &AiConfig) -> Result {
+ let start = Instant::now();
+ let program = validate_grok_program(config)?;
+ let command = GrokCommandSpec { program, args: vec!["models".to_string()] };
+ let mut process = cli_command(&command.program);
+ process.args(command.args.iter().map(String::as_str));
+ process.envs(grok_process_env(config, &command, None)?.iter().map(|(key, value)| (key.as_str(), value.as_str())));
+
+ let output = process.output().await.map_err(|e| classify_grok_spawn_error(&e.to_string()))?;
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let combined =
+ [stdout.trim(), stderr.trim()].into_iter().filter(|part| !part.is_empty()).collect::>().join("\n");
+
+ if output.status.success() {
+ // `grok models` can exit 0 yet print an auth warning; catch the same
+ // "Not signed in" wording the headless run emits (see classify_grok_run_error).
+ let combined_lower = combined.to_ascii_lowercase();
+ if combined_lower.contains("not signed in")
+ || combined_lower.contains("not logged")
+ || combined_lower.contains("not authenticated")
+ {
+ return Err(classify_grok_run_error(&combined));
+ }
+ Ok(AiTestConnectionResult {
+ success: true,
+ message: format!("OK - {}ms", start.elapsed().as_millis()),
+ latency_ms: Some(start.elapsed().as_millis() as u64),
+ model_used: config.model.trim().to_string(),
+ error_category: None,
+ })
+ } else {
+ Err(classify_grok_run_error(&combined))
+ }
+}
+
+fn classify_grok_spawn_error(message: &str) -> String {
+ if message.contains("No such file") || message.contains("not found") {
+ "[grokCliNotInstalled] Grok CLI was not found. Install Grok CLI or set the Grok CLI path in DBX AI settings."
+ .to_string()
+ } else if is_command_line_too_long_error(message) {
+ "[grokCliCommandLineTooLong] Grok CLI command line is too long. Update DBX so Grok prompts are sent through a temporary prompt file."
+ .to_string()
+ } else {
+ format!("[grokCliRunFailed] Failed to start Grok CLI: {message}")
+ }
+}
+
+fn is_command_line_too_long_error(message: &str) -> bool {
+ let lower = message.to_ascii_lowercase();
+ message.contains("os error 206")
+ || message.contains("文件名或扩展名太长")
+ || lower.contains("filename or extension is too long")
+ || lower.contains("the filename or extension is too long")
+}
+
+fn classify_grok_run_error(stderr: &str) -> String {
+ let lower = stderr.to_ascii_lowercase();
+ // Grok's real headless auth error is "Not signed in. ... run `grok login` ..."
+ // (verified against @xai-official/grok 1.0.0). Older wording used
+ // "not authenticated"/"not logged", so cover both for robustness.
+ if lower.contains("not signed in")
+ || lower.contains("not authenticated")
+ || lower.contains("not logged")
+ || lower.contains("please login")
+ || lower.contains("login required")
+ || lower.contains("authentication required")
+ {
+ format!("[grokCliNotAuthenticated] Grok CLI is not authenticated. Run `grok login` and try again. {stderr}")
+ } else if lower.contains("dbx-mcp-server") || lower.contains("enoent") {
+ format!("[dbxMcpMissing] DBX MCP server was not found. Install @dbx-app/mcp-server and try again. {stderr}")
+ } else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) {
+ format!("[grokCliMcpStartupFailed] Grok could not start the DBX MCP server. {stderr}")
+ } else {
+ format!("[grokCliRunFailed] Grok CLI failed. {stderr}")
+ }
+}
+
+pub async fn run_grok_agent(
+ config: &AiConfig,
+ prompt: &str,
+ options: GrokRunOptions,
+ cancelled: &Notify,
+ on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
+) -> Result {
+ let program = validate_grok_program(config)?;
+ let isolated_home = GrokIsolatedHome::create(&options)?;
+ let prompt_path = isolated_home.write_prompt(prompt)?;
+ let mut command = build_grok_command(config, &prompt_path, &options);
+ command.program = program;
+ let env = grok_process_env(config, &command, Some(&isolated_home.path))?;
+ run_cli_jsonl_agent(
+ CliAgentProcessSpec {
+ command,
+ env,
+ env_remove: Vec::new(),
+ current_dir: Some(isolated_home.path.clone()),
+ stdin: None,
+ // Grok headless `streaming-json`: text/thought/tool_call/tool_call_update/end/error.
+ dialect: CliAgentJsonlDialect::GrokStreamingJson,
+ classify_spawn_error: classify_grok_spawn_error,
+ classify_run_error: classify_grok_run_error,
+ },
+ cancelled,
+ on_event,
+ )
+ .await
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ build_grok_command, classify_grok_run_error, default_grok_models, grok_cli_env, grok_effort_capability,
+ grok_mcp_config_toml, parse_grok_models, validate_grok_program, GrokRunOptions,
+ };
+ use crate::ai::{
+ AiApiStyle, AiAuthMethod, AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortLevel, AiEffortSelection,
+ AiProvider, AiReasoningLevel,
+ };
+ use crate::ai_cli_agent::CliAgentCommandSpec;
+ use std::path::PathBuf;
+
+ fn base_config() -> AiConfig {
+ AiConfig {
+ provider: AiProvider::GrokCli,
+ api_key: String::new(),
+ auth_method: AiAuthMethod::Bearer,
+ endpoint: String::new(),
+ model: "default".to_string(),
+ models: Vec::new(),
+ api_style: AiApiStyle::Completions,
+ proxy_enabled: false,
+ proxy_url: String::new(),
+ enable_thinking: true,
+ reasoning_level: AiReasoningLevel::Default,
+ runtime_effort: None,
+ context_window: None,
+ max_retries: None,
+ codex_cli_path: None,
+ codex_cli_env: Default::default(),
+ claude_code_cli_path: None,
+ claude_code_cli_env: Default::default(),
+ pi_agent_cli_path: None,
+ pi_agent_cli_env: Default::default(),
+ opencode_cli_path: None,
+ opencode_cli_env: Default::default(),
+ cursor_cli_path: None,
+ cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
+ }
+ }
+
+ fn run_options() -> GrokRunOptions {
+ GrokRunOptions {
+ connection_id: "conn-1".to_string(),
+ connection_name: "Demo".to_string(),
+ database: "app".to_string(),
+ schema: None,
+ agent_mode: true,
+ allow_writes: false,
+ allow_dangerous: false,
+ confirmed_write_sql: None,
+ mcp_server_command: Some(CliAgentCommandSpec {
+ program: "dbx-mcp-server".to_string(),
+ args: vec!["--stdio".to_string()],
+ }),
+ }
+ }
+
+ #[test]
+ fn validates_path_and_env() {
+ let mut config = base_config();
+ assert_eq!(validate_grok_program(&config).unwrap(), "grok");
+
+ config.grok_cli_path = Some("HTTPS_PROXY=http://proxy:1 /usr/bin/grok".to_string());
+ assert!(validate_grok_program(&config).unwrap_err().contains("[grokCliPathInvalid]"));
+
+ config.grok_cli_path = None;
+ config.grok_cli_env.insert("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string());
+ assert_eq!(grok_cli_env(&config).unwrap(), vec![("HTTPS_PROXY".to_string(), "http://proxy:9800".to_string())]);
+
+ config.grok_cli_env.insert("DBX_MCP_ALLOW_WRITES".to_string(), "1".to_string());
+ assert!(grok_cli_env(&config).unwrap_err().contains("[grokCliEnvReserved]"));
+ }
+
+ #[test]
+ fn builds_headless_command_with_prompt_file_and_model() {
+ let mut config = base_config();
+ config.model = "grok-4.5".to_string();
+ let prompt = PathBuf::from("/tmp/dbx-prompt.txt");
+ let command = build_grok_command(&config, &prompt, &run_options());
+ assert_eq!(command.program, "grok");
+ assert!(command.args.windows(2).any(|pair| pair == ["--prompt-file", "/tmp/dbx-prompt.txt"]));
+ assert!(command.args.windows(2).any(|pair| pair == ["--output-format", "streaming-json"]));
+ assert!(command.args.iter().any(|arg| arg == "--always-approve"));
+ assert!(command.args.windows(2).any(|pair| pair == ["--model", "grok-4.5"]));
+ assert!(command.args.iter().any(|arg| arg == "--disable-web-search"));
+ // Grok permission rule form — not Claude-style mcp__server__tool.
+ assert!(command.args.windows(2).any(|pair| pair == ["--allow", "MCPTool(dbx__*)"]));
+ assert!(command.args.windows(2).any(|pair| pair == ["--allow", "MCPTool(dbx__dbx_list_tables)"]));
+ assert!(!command.args.iter().any(|arg| arg.contains("mcp__dbx__")));
+ }
+
+ #[test]
+ fn builds_effort_flag_from_runtime_effort() {
+ let mut config = base_config();
+ config.model = "grok-4.5".to_string();
+ config.runtime_effort = Some(AiEffortSelection::Enum("medium".to_string()));
+ let prompt = PathBuf::from("/tmp/dbx-prompt.txt");
+ let command = build_grok_command(&config, &prompt, &run_options());
+ assert!(command.args.windows(2).any(|pair| pair == ["--effort", "medium"]));
+ }
+
+ #[test]
+ fn models_expose_low_medium_high_effort_like_codex() {
+ let capability = grok_effort_capability();
+ let AiEffortCapability::Enum { options, default, source } = capability else {
+ panic!("expected enum effort capability");
+ };
+ assert_eq!(source, AiCapabilitySource::LocalCli);
+ assert_eq!(default, AiEffortSelection::Enum("high".to_string()));
+ assert_eq!(options.iter().map(|option| option.id.as_str()).collect::>(), vec!["low", "medium", "high"]);
+
+ let stdout = "You are logged in with grok.com.\n\nDefault model: grok-4.5\n\nAvailable models:\n * grok-4.5 (default)\n";
+ let models = parse_grok_models(stdout).unwrap();
+ let grok = models.iter().find(|model| model.id == "grok-4.5").unwrap();
+ assert!(matches!(grok.effort_capability, Some(AiEffortCapability::Enum { .. })));
+ assert_eq!(grok.supported_effort_levels, vec![AiEffortLevel::Low, AiEffortLevel::Medium, AiEffortLevel::High]);
+ }
+
+ #[test]
+ fn mcp_config_includes_scoped_dbx_server() {
+ let toml = grok_mcp_config_toml(&run_options());
+ assert!(toml.contains("[mcp_servers.dbx]"));
+ assert!(toml.contains("command = \"dbx-mcp-server\""));
+ assert!(toml.contains("args = [\"--stdio\"]"));
+ assert!(toml.contains("DBX_MCP_SCOPE_CONNECTION_ID = \"conn-1\""));
+ assert!(toml.contains("DBX_MCP_ALLOW_WRITES = \"0\""));
+ assert!(toml.contains("[permission]"));
+ assert!(toml.contains("MCPTool(dbx__*)"));
+ // Invalid fields removed: grok has no "always-approve" permission_mode,
+ // and startup_timeout_sec/tool_timeout_sec/enabled_tools are not mcp_servers keys.
+ assert!(!toml.contains("permission_mode"));
+ assert!(!toml.contains("always-approve"));
+ assert!(!toml.contains("startup_timeout_sec"));
+ assert!(!toml.contains("tool_timeout_sec"));
+ assert!(!toml.contains("enabled_tools"));
+ }
+
+ #[test]
+ fn parses_models_listing() {
+ let stdout = "You are logged in with grok.com.\n\nDefault model: grok-4.5\n\nAvailable models:\n * grok-4.5 (default)\n * grok-4\n";
+ let models = parse_grok_models(stdout).unwrap();
+ assert_eq!(models[0].id, "default");
+ assert!(models.iter().any(|model| model.id == "grok-4.5"));
+ assert!(models.iter().any(|model| model.id == "grok-4"));
+ assert_eq!(default_grok_models()[1].id, "grok-4.5");
+ }
+
+ #[test]
+ fn classifies_auth_errors() {
+ let err = classify_grok_run_error("not authenticated; please login");
+ assert!(err.contains("[grokCliNotAuthenticated]"));
+
+ // Real @xai-official/grok 1.0.0 headless auth failure emits this exact wording.
+ let real = classify_grok_run_error(
+ "Not signed in. To authenticate without a browser, run:\n grok login --device-code",
+ );
+ assert!(real.contains("[grokCliNotAuthenticated]"));
+ }
+}
diff --git a/crates/dbx-core/src/ai_model_filter.rs b/crates/dbx-core/src/ai_model_filter.rs
index f84d67e58..7010c0373 100644
--- a/crates/dbx-core/src/ai_model_filter.rs
+++ b/crates/dbx-core/src/ai_model_filter.rs
@@ -98,6 +98,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::CursorCli
+ | AiProvider::GrokCli
| AiProvider::MiniMax
| AiProvider::Custom => true,
}
diff --git a/crates/dbx-core/src/ai_opencode_cli.rs b/crates/dbx-core/src/ai_opencode_cli.rs
index 3e6c7e822..acab37563 100644
--- a/crates/dbx-core/src/ai_opencode_cli.rs
+++ b/crates/dbx-core/src/ai_opencode_cli.rs
@@ -468,6 +468,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}
diff --git a/crates/dbx-core/src/ai_pi_agent_cli.rs b/crates/dbx-core/src/ai_pi_agent_cli.rs
index 0313b08fa..1fa4cd098 100644
--- a/crates/dbx-core/src/ai_pi_agent_cli.rs
+++ b/crates/dbx-core/src/ai_pi_agent_cli.rs
@@ -876,6 +876,8 @@ mod tests {
opencode_cli_env: HashMap::new(),
cursor_cli_path: None,
cursor_cli_env: HashMap::new(),
+ grok_cli_path: None,
+ grok_cli_env: HashMap::new(),
}
}
diff --git a/crates/dbx-core/src/cloud_sync.rs b/crates/dbx-core/src/cloud_sync.rs
index 55cf35a64..9228d3834 100644
--- a/crates/dbx-core/src/cloud_sync.rs
+++ b/crates/dbx-core/src/cloud_sync.rs
@@ -1592,6 +1592,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
},
}
}
diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs
index 5915bc559..14b91eb4d 100644
--- a/crates/dbx-core/src/lib.rs
+++ b/crates/dbx-core/src/lib.rs
@@ -15,6 +15,7 @@ pub mod ai_cli_agent;
pub mod ai_codex_cli;
pub mod ai_cursor_cli;
pub mod ai_effort;
+pub mod ai_grok_cli;
mod ai_model_filter;
pub mod ai_opencode_cli;
pub mod ai_pi_agent_cli;
diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs
index 132cd40a5..db70ad49a 100644
--- a/crates/dbx-core/src/storage.rs
+++ b/crates/dbx-core/src/storage.rs
@@ -5476,6 +5476,8 @@ mod tests {
opencode_cli_env: std::collections::HashMap::new(),
cursor_cli_path: None,
cursor_cli_env: std::collections::HashMap::new(),
+ grok_cli_path: None,
+ grok_cli_env: std::collections::HashMap::new(),
},
}
}
@@ -5567,6 +5569,30 @@ mod tests {
std::fs::remove_file(&db).ok();
}
+ #[tokio::test]
+ async fn grok_cli_ai_config_roundtrip() {
+ let db = temp_db_path("grok-cli-ai-roundtrip");
+ let storage = Storage::open(&db).await.unwrap();
+
+ let mut cfg = make_ai_config("grok-cli", true);
+ cfg.config.provider = AiProvider::GrokCli;
+ cfg.config.api_key = String::new();
+ cfg.config.auth_method = AiAuthMethod::Bearer;
+ cfg.config.endpoint = String::new();
+ cfg.config.model = "default".to_string();
+ cfg.config.api_style = AiApiStyle::Completions;
+ cfg.config.grok_cli_path = Some("/Users/me/.grok/bin/grok".to_string());
+ storage.save_ai_config_item(&cfg).await.unwrap();
+
+ let loaded = storage.load_ai_configs().await.unwrap();
+ assert_eq!(loaded.len(), 1);
+ assert!(matches!(loaded[0].config.provider, AiProvider::GrokCli));
+ assert_eq!(loaded[0].config.model, "default");
+ assert_eq!(loaded[0].config.grok_cli_path.as_deref(), Some("/Users/me/.grok/bin/grok"));
+
+ std::fs::remove_file(&db).ok();
+ }
+
#[tokio::test]
async fn anthropic_compatible_ai_config_roundtrip() {
let db = temp_db_path("anthropic-compatible-ai-roundtrip");
diff --git a/crates/dbx-web/src/routes/ai.rs b/crates/dbx-web/src/routes/ai.rs
index 4bd10f986..9120fbabe 100644
--- a/crates/dbx-web/src/routes/ai.rs
+++ b/crates/dbx-web/src/routes/ai.rs
@@ -531,6 +531,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}
@@ -541,7 +543,7 @@ mod tests {
AiProvider::ClaudeCodeCli,
AiProvider::PiAgentCli,
AiProvider::OpenCodeCli,
- AiProvider::CursorCli,
+ AiProvider::CursorCli | AiProvider::GrokCli,
] {
let config = make_config(provider);
assert!(reject_web_unsupported_ai_provider(&config).is_err());
@@ -628,6 +630,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
};
let body = super::AiTestConnectionRequest { config };
diff --git a/packages/app-tests/settingsStore.test.ts b/packages/app-tests/settingsStore.test.ts
index db12ad188..8898077d0 100644
--- a/packages/app-tests/settingsStore.test.ts
+++ b/packages/app-tests/settingsStore.test.ts
@@ -544,6 +544,9 @@ test("AI provider presets include common hosted and local providers", () => {
assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].iconSlug, "cursor");
assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].requiresApiKey, false);
+ assert.equal(AI_PROVIDER_PRESETS["grok-cli"].model, "default");
+ assert.equal(AI_PROVIDER_PRESETS["grok-cli"].iconSlug, "grok");
+ assert.equal(AI_PROVIDER_PRESETS["grok-cli"].requiresApiKey, false);
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi");
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false);
@@ -556,6 +559,7 @@ test("AI provider presets include common hosted and local providers", () => {
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("cursor-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("cursor-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
+ assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("grok-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
});
@@ -633,6 +637,14 @@ test("normalizes legacy AI config and fills provider defaults", () => {
assert.equal(openCode.opencodeCliPath, "/opt/homebrew/bin/opencode");
assert.deepEqual(openCode.opencodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
assert.equal(openCode.model, "default");
+ const grokCli = normalizeAiConfig({
+ provider: "grok-cli",
+ grokCliPath: " /Users/me/.grok/bin/grok ",
+ grokCliEnv: { HTTPS_PROXY: "http://proxy:9800" },
+ });
+ assert.equal(grokCli.grokCliPath, "/Users/me/.grok/bin/grok");
+ assert.deepEqual(grokCli.grokCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
+ assert.equal(grokCli.model, "default");
});
test("infers legacy AI provider from saved endpoint and model", () => {
diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs
index 7ce0f4f7f..1e1d846eb 100644
--- a/src-tauri/src/commands/ai.rs
+++ b/src-tauri/src/commands/ai.rs
@@ -241,6 +241,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig {
AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"),
AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"),
AiProvider::CursorCli => (&mut config.cursor_cli_path, "agent"),
+ AiProvider::GrokCli => (&mut config.grok_cli_path, "grok"),
_ => return config,
};
let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command);
@@ -330,6 +331,8 @@ mod tests {
opencode_cli_env: Default::default(),
cursor_cli_path: None,
cursor_cli_env: Default::default(),
+ grok_cli_path: None,
+ grok_cli_env: Default::default(),
}
}