feat(ai): add Grok CLI as a Desktop no-API-key provider

* feat(ai): add Grok CLI as a Desktop no-API-key provider

Add grok-cli alongside Codex/Claude Code/Pi, with path/env settings,
isolated MCP-scoped headless runs, model listing, and connection test.

* feat(ai): expose Grok CLI reasoning effort like Codex

Attach low/medium/high effort capabilities to Grok models for the
assistant effort menu, wire resolve_model_effort for grok-cli, and
use the monochrome Grok mark icon.

* i18n: add Grok CLI error messages for remaining locales

Fill grokCli* cliErrors strings in es, it, ja, ko, pt-BR, and zh-TW
to match en/zh-CN and satisfy i18n autofill CI.

* fix(ai): align Grok CLI headless with official Build flags

Address review feedback on PR #5494:
- Use streaming-json + CliAgentJsonlDialect::GrokStreamingJson
- Switch permission to --always-approve and MCPTool(dbx__*) rules
- Prefer --effort over --reasoning-effort; keep --prompt-file

Verified with unit tests and a local headless e2e against grok 1.0.

* style(ai): fix rustfmt for Grok CLI provider files

* test(ai): fix Grok CLI entry in provider ordering spec

* fix(ai): harden grok CLI error wording and config schema

Add 'not signed in' to classify_grok_run_error and test_grok_connection
auth detection — real @xai-official/grok 1.0.0 emits this exact wording
on headless auth failure (verified against the shipped binary).

Remove invalid config.toml fields: permission_mode='always-approve'
(not a valid enum; auto-approve is driven by --always-approve flag) and
startup_timeout_sec/tool_timeout_sec/enabled_tools (not mcp_servers
schema fields per 'grok mcp add').

---------

Co-authored-by: t8y2 <1156263951@qq.com>
This commit is contained in:
chenow9 2026-08-10 10:54:27 +08:00 committed by GitHub
parent fff07ada83
commit d08189ba7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1156 additions and 14 deletions

View File

@ -0,0 +1 @@
<svg fill="#000000" fill-rule="evenodd" role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><path d="M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"/></svg>

After

Width:  |  Height:  |  Size: 700 B

View File

@ -2635,7 +2635,7 @@ function normalizeMaxRetries(value: number | undefined): number {
const aiDeleteConfirmOpen = ref(false); const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null); const aiDeleteConfigId = ref<string | null>(null);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"]); const CLI_AI_PROVIDERS = new Set<AiProvider>(["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 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 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))); 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<AiEnvRow[]>([]); const aiEditOpenCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditCursorCliPath = ref(""); const aiEditCursorCliPath = ref("");
const aiEditCursorCliEnvRows = ref<AiEnvRow[]>([]); const aiEditCursorCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditGrokCliPath = ref("");
const aiEditGrokCliEnvRows = ref<AiEnvRow[]>([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages"); 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 aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli");
const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli"); const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli");
const aiIsCursorCli = computed(() => aiEditProvider.value === "cursor-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 aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label); const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => { const aiCliCommandName = computed(() => {
@ -2702,6 +2705,7 @@ const aiCliCommandName = computed(() => {
if (aiIsPiAgentCli.value) return "pi"; if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode"; if (aiIsOpenCodeCli.value) return "opencode";
if (aiIsCursorCli.value) return "agent"; if (aiIsCursorCli.value) return "agent";
if (aiIsGrokCli.value) return "grok";
return "codex"; return "codex";
}); });
const aiCliLoginCommand = computed(() => { const aiCliLoginCommand = computed(() => {
@ -2709,6 +2713,7 @@ const aiCliLoginCommand = computed(() => {
if (aiIsPiAgentCli.value) return "pi"; if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode auth login"; if (aiIsOpenCodeCli.value) return "opencode auth login";
if (aiIsCursorCli.value) return "agent login"; if (aiIsCursorCli.value) return "agent login";
if (aiIsGrokCli.value) return "grok login";
return "codex login"; return "codex login";
}); });
const aiEditCliPath = computed({ const aiEditCliPath = computed({
@ -2717,6 +2722,7 @@ const aiEditCliPath = computed({
if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value; if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value; if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value;
if (aiIsCursorCli.value) return aiEditCursorCliPath.value; if (aiIsCursorCli.value) return aiEditCursorCliPath.value;
if (aiIsGrokCli.value) return aiEditGrokCliPath.value;
return aiEditCodexCliPath.value; return aiEditCodexCliPath.value;
}, },
set: (value: string) => { set: (value: string) => {
@ -2728,6 +2734,8 @@ const aiEditCliPath = computed({
aiEditOpenCodeCliPath.value = value; aiEditOpenCodeCliPath.value = value;
} else if (aiIsCursorCli.value) { } else if (aiIsCursorCli.value) {
aiEditCursorCliPath.value = value; aiEditCursorCliPath.value = value;
} else if (aiIsGrokCli.value) {
aiEditGrokCliPath.value = value;
} else { } else {
aiEditCodexCliPath.value = value; aiEditCodexCliPath.value = value;
} }
@ -2738,6 +2746,7 @@ const aiEditCliEnvRows = computed(() => {
if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value; if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value; if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value;
if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value; if (aiIsCursorCli.value) return aiEditCursorCliEnvRows.value;
if (aiIsGrokCli.value) return aiEditGrokCliEnvRows.value;
return aiEditCodexCliEnvRows.value; return aiEditCodexCliEnvRows.value;
}); });
watch(aiIsCliProvider, (isCliProvider) => { watch(aiIsCliProvider, (isCliProvider) => {
@ -2834,6 +2843,8 @@ function removeCliEnvRow(id: string) {
aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id); aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsCursorCli.value) { } else if (aiIsCursorCli.value) {
aiEditCursorCliEnvRows.value = aiEditCursorCliEnvRows.value.filter((row) => row.id !== id); aiEditCursorCliEnvRows.value = aiEditCursorCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsGrokCli.value) {
aiEditGrokCliEnvRows.value = aiEditGrokCliEnvRows.value.filter((row) => row.id !== id);
} else { } else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id); aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
} }
@ -2866,6 +2877,8 @@ function currentAiEditConfig() {
opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {}, opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {},
cursorCliPath: aiEditCursorCliPath.value.trim() || undefined, cursorCliPath: aiEditCursorCliPath.value.trim() || undefined,
cursorCliEnv: aiIsCursorCli.value ? cliEnvFromRows(aiEditCursorCliEnvRows.value) : {}, 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); aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv);
aiEditCursorCliPath.value = config.cursorCliPath ?? ""; aiEditCursorCliPath.value = config.cursorCliPath ?? "";
aiEditCursorCliEnvRows.value = aiEnvRowsFromConfig(config.cursorCliEnv); aiEditCursorCliEnvRows.value = aiEnvRowsFromConfig(config.cursorCliEnv);
aiEditGrokCliPath.value = config.grokCliPath ?? "";
aiEditGrokCliEnvRows.value = aiEnvRowsFromConfig(config.grokCliEnv);
} }
} else { } else {
aiEditConfigName.value = ""; aiEditConfigName.value = "";
@ -2964,6 +2979,8 @@ function aiEnterEditMode(configId?: string) {
aiEditOpenCodeCliEnvRows.value = []; aiEditOpenCodeCliEnvRows.value = [];
aiEditCursorCliPath.value = ""; aiEditCursorCliPath.value = "";
aiEditCursorCliEnvRows.value = []; aiEditCursorCliEnvRows.value = [];
aiEditGrokCliPath.value = "";
aiEditGrokCliEnvRows.value = [];
} }
} }

View File

@ -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(() => { const localIconUrl = computed(() => {
if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg"); if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg");
return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : ""; return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : "";

View File

@ -44,6 +44,14 @@ const taggedAiCliErrorKeys: Record<string, string> = {
openCodeRunFailed: "ai.cliErrors.openCodeRunFailed", openCodeRunFailed: "ai.cliErrors.openCodeRunFailed",
cursorNotInstalled: "ai.cliErrors.cursorNotInstalled", cursorNotInstalled: "ai.cliErrors.cursorNotInstalled",
cursorCliPathInvalid: "ai.cliErrors.cursorCliPathInvalid", 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", cursorEnvInvalid: "ai.cliErrors.cursorEnvInvalid",
cursorEnvReserved: "ai.cliErrors.cursorEnvReserved", cursorEnvReserved: "ai.cliErrors.cursorEnvReserved",
cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated", cursorNotAuthenticated: "ai.cliErrors.cursorNotAuthenticated",

View File

@ -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.", 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.", 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.", 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.", 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.", 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.", cursorNotAuthenticated: "Cursor CLI is not authenticated. Run `agent login` in a terminal and try again.",

View File

@ -2094,6 +2094,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI no respondió antes de que finalizara el tiempo de espera.", 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.", 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.", 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", run: "Ejecutar",
readingSchema: "Leyendo esquema", readingSchema: "Leyendo esquema",

View File

@ -2234,6 +2234,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI non ha risposto prima della scadenza dell'operazione.", 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.", 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.", 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: { actions: {
general: "Generale", general: "Generale",

View File

@ -2128,6 +2128,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLIはタイムアウトまでに応答しませんでした。", cursorTimeout: "Cursor CLIはタイムアウトまでに応答しませんでした。",
cursorProtocolError: "Cursor CLIが無効なJSONイベントストリームを返しました。Cursor CLIのバージョンと以下の診断詳細を確認してください。", cursorProtocolError: "Cursor CLIが無効なJSONイベントストリームを返しました。Cursor CLIのバージョンと以下の診断詳細を確認してください。",
cursorRunFailed: "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: "実行", run: "実行",
readingSchema: "スキーマを読み取り中", readingSchema: "スキーマを読み取り中",

View File

@ -2112,6 +2112,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI가 제한 시간 안에 응답하지 않았습니다.", cursorTimeout: "Cursor CLI가 제한 시간 안에 응답하지 않았습니다.",
cursorProtocolError: "Cursor CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. Cursor CLI 버전과 아래 진단을 확인하세요.", cursorProtocolError: "Cursor CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. Cursor CLI 버전과 아래 진단을 확인하세요.",
cursorRunFailed: "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: { actions: {
general: "일반", general: "일반",

View File

@ -2096,6 +2096,14 @@ export default withEnglishFallback({
cursorTimeout: "O Cursor CLI não respondeu antes do tempo limite da operação.", 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.", 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.", 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", run: "Executar",
readingSchema: "Lendo schema", readingSchema: "Lendo schema",

View File

@ -2248,6 +2248,14 @@ export default withEnglishFallback({
openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。", openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
cursorNotInstalled: "未找到 Cursor CLI。请安装 Cursor CLI或在 设置 > AI 中填写其可执行文件路径。", cursorNotInstalled: "未找到 Cursor CLI。请安装 Cursor CLI或在 设置 > AI 中填写其可执行文件路径。",
cursorCliPathInvalid: "Cursor CLI 路径无效。请只选择 agent 可执行文件,环境变量需单独配置。", 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 这类合法名称。", cursorEnvInvalid: "Cursor CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。",
cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。", cursorEnvReserved: "配置覆盖了由 Cursor 或 DBX 管理的环境变量。请移除 CURSOR_CONFIG_DIR、CURSOR_DATA_DIR 和 DBX_MCP_* 变量。",
cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。", cursorNotAuthenticated: "Cursor CLI 尚未登录。请在终端执行 `agent login` 后重试。",

View File

@ -2237,6 +2237,14 @@ export default withEnglishFallback({
cursorTimeout: "Cursor CLI 未能在操作逾時前回應。", cursorTimeout: "Cursor CLI 未能在操作逾時前回應。",
cursorProtocolError: "Cursor CLI 傳回了無效的 JSON 事件串流。請檢查 Cursor CLI 版本和下方診斷詳情。", cursorProtocolError: "Cursor CLI 傳回了無效的 JSON 事件串流。請檢查 Cursor CLI 版本和下方診斷詳情。",
cursorRunFailed: "Cursor CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 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: { actions: {
general: "通用問答", general: "通用問答",

View File

@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => {
expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false); 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( expect(
isAiConfigModelCandidate( isAiConfigModelCandidate(
config({ config({

View File

@ -23,11 +23,12 @@ describe("orderAiConfigsForDisplay", () => {
{ id: "codex", provider: "codex-cli" }, { id: "codex", provider: "codex-cli" },
{ id: "opencode", provider: "opencode-cli" }, { id: "opencode", provider: "opencode-cli" },
{ id: "cursor", provider: "cursor-cli" }, { id: "cursor", provider: "cursor-cli" },
{ id: "grok", provider: "grok-cli" },
{ id: "pi", provider: "pi-agent-cli" }, { id: "pi", provider: "pi-agent-cli" },
{ id: "custom", provider: "custom" }, { 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", () => { it("preserves creation order for configs from the same provider", () => {

View File

@ -1,6 +1,6 @@
import type { AiConfig } from "@/types/ai"; import type { AiConfig } from "@/types/ai";
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli"]); const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli", "cursor-cli", "grok-cli"]);
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean { export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass. // CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.

View File

@ -253,6 +253,16 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
authMethod: "bearer", authMethod: "bearer",
requiresApiKey: false, requiresApiKey: false,
}, },
"grok-cli": {
label: "Grok CLI",
iconSlug: "grok",
provider: "grok-cli",
endpoint: "",
model: "default",
apiStyle: "completions",
authMethod: "bearer",
requiresApiKey: false,
},
"pi-agent-cli": { "pi-agent-cli": {
label: "Pi Coding Agent", label: "Pi Coding Agent",
iconSlug: "pi", iconSlug: "pi",
@ -323,6 +333,8 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv), opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv),
cursorCliPath: config?.cursorCliPath?.trim() || undefined, cursorCliPath: config?.cursorCliPath?.trim() || undefined,
cursorCliEnv: normalizeAiEnv(config?.cursorCliEnv), 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); const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
if (!config) return false; if (!config) return false;
const preset = AI_PROVIDER_PRESETS[config.provider]; 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); return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
}); });

View File

@ -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 AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer"; export type AiAuthMethod = "api-key" | "bearer";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
@ -51,6 +51,8 @@ export interface AiConfig {
opencodeCliEnv?: Record<string, string>; opencodeCliEnv?: Record<string, string>;
cursorCliPath?: string | null; cursorCliPath?: string | null;
cursorCliEnv?: Record<string, string>; cursorCliEnv?: Record<string, string>;
grokCliPath?: string | null;
grokCliEnv?: Record<string, string>;
runtimeEffort?: AiEffortSelection | null; runtimeEffort?: AiEffortSelection | null;
} }

View File

@ -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; 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 = let prompt =
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes); 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; return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;

View File

@ -83,6 +83,8 @@ pub enum AiProvider {
OpenCodeCli, OpenCodeCli,
#[serde(rename = "cursor-cli")] #[serde(rename = "cursor-cli")]
CursorCli, CursorCli,
#[serde(rename = "grok-cli")]
GrokCli,
Custom, Custom,
} }
@ -102,6 +104,7 @@ impl AiProvider {
AiProvider::PiAgentCli => "pi-agent-cli", AiProvider::PiAgentCli => "pi-agent-cli",
AiProvider::OpenCodeCli => "opencode-cli", AiProvider::OpenCodeCli => "opencode-cli",
AiProvider::CursorCli => "cursor-cli", AiProvider::CursorCli => "cursor-cli",
AiProvider::GrokCli => "grok-cli",
AiProvider::CodexCli => "codex-cli", AiProvider::CodexCli => "codex-cli",
AiProvider::Custom => "custom", AiProvider::Custom => "custom",
} }
@ -401,6 +404,10 @@ pub struct AiConfig {
pub cursor_cli_path: Option<String>, pub cursor_cli_path: Option<String>,
#[serde(default)] #[serde(default)]
pub cursor_cli_env: HashMap<String, String>, pub cursor_cli_env: HashMap<String, String>,
#[serde(default)]
pub grok_cli_path: Option<String>,
#[serde(default)]
pub grok_cli_env: HashMap<String, String>,
} }
fn default_enable_thinking() -> bool { fn default_enable_thinking() -> bool {
@ -418,6 +425,7 @@ pub fn is_cli_provider(provider: &AiProvider) -> bool {
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli | AiProvider::CursorCli
| AiProvider::GrokCli
) )
} }
@ -649,6 +657,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli | AiProvider::CursorCli
| AiProvider::GrokCli
| AiProvider::Gemini => unreachable!(), | AiProvider::Gemini => unreachable!(),
} }
} }
@ -1498,6 +1507,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?, 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::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?,
AiProvider::CursorCli => crate::ai_cursor_cli::list_cursor_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)?; validate_model_list_config(config)?;
let client = build_ai_http_client(config, 30)?; let client = build_ai_http_client(config, 30)?;
@ -1524,7 +1534,8 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
| AiProvider::ClaudeCodeCli | AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli => { | AiProvider::CursorCli
| AiProvider::GrokCli => {
unreachable!() unreachable!()
} }
} }
@ -1553,7 +1564,7 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
return Ok(AiEffortCapability::Unsupported); 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?; let models = list_models_core(config).await?;
return Ok(models return Ok(models
.into_iter() .into_iter()
@ -2087,6 +2098,10 @@ pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionR
if matches!(config.provider, AiProvider::CursorCli) { if matches!(config.provider, AiProvider::CursorCli) {
return crate::ai_cursor_cli::test_cursor_connection(config).await; return crate::ai_cursor_cli::test_cursor_connection(config).await;
} }
if matches!(config.provider, AiProvider::GrokCli) {
return crate::ai_grok_cli::test_grok_connection(config).await;
}
let mut resolved_config = config.clone(); let mut resolved_config = config.clone();
if resolved_config.model.trim().is_empty() { if resolved_config.model.trim().is_empty() {
let model = list_models_core(&resolved_config) let model = list_models_core(&resolved_config)
@ -2463,7 +2478,8 @@ pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
| AiProvider::ClaudeCodeCli | AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli => { | AiProvider::CursorCli
| AiProvider::GrokCli => {
unreachable!() unreachable!()
} }
AiProvider::Openai AiProvider::Openai
@ -2521,7 +2537,8 @@ pub async fn stream(
| AiProvider::ClaudeCodeCli | AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli => { | AiProvider::CursorCli
| AiProvider::GrokCli => {
unreachable!() unreachable!()
} }
AiProvider::Openai AiProvider::Openai
@ -4181,6 +4198,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}, },
system_prompt: "Be concise.".to_string(), system_prompt: "Be concise.".to_string(),
messages: vec![AiMessage { messages: vec![AiMessage {
@ -4795,6 +4814,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let err = build_ai_http_client(&config, 1).unwrap_err(); let err = build_ai_http_client(&config, 1).unwrap_err();
@ -4829,6 +4850,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
build_ai_http_client(&config, 1).unwrap(); build_ai_http_client(&config, 1).unwrap();
@ -4861,6 +4884,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
build_ai_http_client(&config, 1).unwrap(); build_ai_http_client(&config, 1).unwrap();
@ -4893,6 +4918,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
assert_eq!( assert_eq!(
@ -4929,6 +4956,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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"); assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
@ -4962,6 +4991,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
for provider in for provider in
@ -5014,6 +5045,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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"); 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(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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"); 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(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
assert!(uses_anthropic_messages_api(&config)); assert!(uses_anthropic_messages_api(&config));
@ -5156,6 +5193,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
assert!(!uses_anthropic_messages_api(&config)); assert!(!uses_anthropic_messages_api(&config));
@ -5199,6 +5238,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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_endpoint(&config), "https://api.example.com/v1/chat/completions");
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models"); 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(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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"); assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
@ -5305,6 +5348,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let api_key_headers = claude_headers(&config).unwrap(); let api_key_headers = claude_headers(&config).unwrap();
@ -5424,6 +5469,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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"); assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
@ -5460,6 +5507,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), 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)); assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
@ -5545,6 +5594,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let models = vec![ let models = vec![
AiModelInfo::new("qwen3:0.6b", None), AiModelInfo::new("qwen3:0.6b", None),
@ -5810,6 +5861,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ let mut body = serde_json::json!({
@ -6025,6 +6078,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": &config.model, "model": &config.model,
@ -6066,6 +6121,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ "model": &config.model }); let mut body = serde_json::json!({ "model": &config.model });
@ -6113,6 +6170,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ "model": &config.model }); let mut body = serde_json::json!({ "model": &config.model });
@ -6154,6 +6213,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ "model": &config.model }); let mut body = serde_json::json!({ "model": &config.model });
@ -6191,6 +6252,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ "model": &config.model }); let mut body = serde_json::json!({ "model": &config.model });
@ -6259,6 +6322,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let request = AiCompletionRequest { let request = AiCompletionRequest {
config: config.clone(), config: config.clone(),
@ -6321,6 +6386,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let mut body = serde_json::json!({ let mut body = serde_json::json!({
"model": &config.model, "model": &config.model,
@ -6853,6 +6920,7 @@ mod tests {
AiProvider::PiAgentCli, AiProvider::PiAgentCli,
AiProvider::OpenCodeCli, AiProvider::OpenCodeCli,
AiProvider::CursorCli, AiProvider::CursorCli,
AiProvider::GrokCli,
] { ] {
let mut config = test_config(provider.clone()); let mut config = test_config(provider.clone());
config.max_retries = None; config.max_retries = None;

View File

@ -585,6 +585,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }

View File

@ -36,6 +36,8 @@ pub enum CliAgentJsonlDialect {
ClaudeCodePrint, ClaudeCodePrint,
OpenCodeRun, OpenCodeRun,
CursorPrint, CursorPrint,
/// Grok Build headless `--output-format streaming-json` (ACP-derived NDJSON).
GrokStreamingJson,
} }
pub struct CliAgentProcessSpec { 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::ClaudeCodePrint => parse_claude_code_jsonl_line(line),
CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line), CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line),
CliAgentJsonlDialect::CursorPrint => parse_cursor_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() .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::<Value>(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<TokenUsage> {
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( pub async fn run_cli_jsonl_agent(
spec: CliAgentProcessSpec, spec: CliAgentProcessSpec,
cancelled: &Notify, cancelled: &Notify,
@ -895,6 +1031,74 @@ pub async fn run_cli_jsonl_agent(
Ok(final_text) 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))] #[cfg(all(test, unix))]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -1042,6 +1042,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }

View File

@ -422,6 +422,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }

View File

@ -87,7 +87,8 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option<AiE
| AiProvider::ClaudeCodeCli | AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli => None, | AiProvider::CursorCli
| AiProvider::GrokCli => None,
} }
} }
@ -194,6 +195,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli | AiProvider::CursorCli
| AiProvider::GrokCli
| AiProvider::Custom => None, | AiProvider::Custom => None,
} }
} }
@ -214,6 +216,7 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli | AiProvider::CursorCli
| AiProvider::GrokCli
) { ) {
return match selection { return match selection {
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()), 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::ClaudeCodeCli
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli => {} | AiProvider::CursorCli
| AiProvider::GrokCli => {}
} }
} }
@ -428,6 +432,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }

View File

@ -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<Self, String> {
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<PathBuf, String> {
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<PathBuf> {
env::var_os("HOME").map(PathBuf::from).map(|home| home.join(".grok")).filter(|path| path.is_dir())
}
fn real_grok_auth_path() -> Option<PathBuf> {
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<Vec<(String, String)>, 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<String> {
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::<Vec<_>>();
env::join_paths(paths).unwrap_or_default().to_string_lossy().to_string()
}
fn common_executable_dirs() -> Vec<PathBuf> {
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<String, String> {
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<String> {
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<PathBuf> {
vec![dir.join(program)]
}
#[cfg(windows)]
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
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<Vec<(String, String)>, 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<String> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<AiEffortLevel>().ok()).collect();
model
}
fn default_grok_models() -> Vec<AiModelInfo> {
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<Vec<AiModelInfo>, 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<Vec<AiModelInfo>> {
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<Vec<AiModelInfo>> {
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<AiTestConnectionResult, String> {
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::<Vec<_>>().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<String, String> {
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<_>>(), 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]"));
}
}

View File

@ -98,6 +98,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
| AiProvider::PiAgentCli | AiProvider::PiAgentCli
| AiProvider::OpenCodeCli | AiProvider::OpenCodeCli
| AiProvider::CursorCli | AiProvider::CursorCli
| AiProvider::GrokCli
| AiProvider::MiniMax | AiProvider::MiniMax
| AiProvider::Custom => true, | AiProvider::Custom => true,
} }

View File

@ -468,6 +468,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }

View File

@ -876,6 +876,8 @@ mod tests {
opencode_cli_env: HashMap::new(), opencode_cli_env: HashMap::new(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: HashMap::new(), cursor_cli_env: HashMap::new(),
grok_cli_path: None,
grok_cli_env: HashMap::new(),
} }
} }

View File

@ -1592,6 +1592,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}, },
} }
} }

View File

@ -15,6 +15,7 @@ pub mod ai_cli_agent;
pub mod ai_codex_cli; pub mod ai_codex_cli;
pub mod ai_cursor_cli; pub mod ai_cursor_cli;
pub mod ai_effort; pub mod ai_effort;
pub mod ai_grok_cli;
mod ai_model_filter; mod ai_model_filter;
pub mod ai_opencode_cli; pub mod ai_opencode_cli;
pub mod ai_pi_agent_cli; pub mod ai_pi_agent_cli;

View File

@ -5476,6 +5476,8 @@ mod tests {
opencode_cli_env: std::collections::HashMap::new(), opencode_cli_env: std::collections::HashMap::new(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: std::collections::HashMap::new(), 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(); 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] #[tokio::test]
async fn anthropic_compatible_ai_config_roundtrip() { async fn anthropic_compatible_ai_config_roundtrip() {
let db = temp_db_path("anthropic-compatible-ai-roundtrip"); let db = temp_db_path("anthropic-compatible-ai-roundtrip");

View File

@ -531,6 +531,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }
@ -541,7 +543,7 @@ mod tests {
AiProvider::ClaudeCodeCli, AiProvider::ClaudeCodeCli,
AiProvider::PiAgentCli, AiProvider::PiAgentCli,
AiProvider::OpenCodeCli, AiProvider::OpenCodeCli,
AiProvider::CursorCli, AiProvider::CursorCli | AiProvider::GrokCli,
] { ] {
let config = make_config(provider); let config = make_config(provider);
assert!(reject_web_unsupported_ai_provider(&config).is_err()); assert!(reject_web_unsupported_ai_provider(&config).is_err());
@ -628,6 +630,8 @@ mod tests {
opencode_cli_env: Default::default(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
}; };
let body = super::AiTestConnectionRequest { config }; let body = super::AiTestConnectionRequest { config };

View File

@ -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"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].iconSlug, "cursor"); assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].iconSlug, "cursor");
assert.equal(AI_PROVIDER_PRESETS["cursor-cli"].requiresApiKey, false); 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"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi"); assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi");
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false); 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("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("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("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")); 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.equal(openCode.opencodeCliPath, "/opt/homebrew/bin/opencode");
assert.deepEqual(openCode.opencodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" }); assert.deepEqual(openCode.opencodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
assert.equal(openCode.model, "default"); 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", () => { test("infers legacy AI provider from saved endpoint and model", () => {

View File

@ -241,6 +241,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig {
AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"), AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"),
AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"), AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"),
AiProvider::CursorCli => (&mut config.cursor_cli_path, "agent"), AiProvider::CursorCli => (&mut config.cursor_cli_path, "agent"),
AiProvider::GrokCli => (&mut config.grok_cli_path, "grok"),
_ => return config, _ => return config,
}; };
let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command); 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(), opencode_cli_env: Default::default(),
cursor_cli_path: None, cursor_cli_path: None,
cursor_cli_env: Default::default(), cursor_cli_env: Default::default(),
grok_cli_path: None,
grok_cli_env: Default::default(),
} }
} }