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 @@ +Grok 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(), } }