diff --git a/README.md b/README.md index 115d6cc6d3..51d21e4d61 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Choose your agents, review the proposed changes, and approve them. Setup adds REA's MCP server and matching workflow instructions, with backups of existing configuration. Restart your agent afterward. -Setup supports Claude Code, Codex, Cursor, Gemini CLI and +Setup supports Claude Code, Codex, Cursor, Gemini CLI, Grok Build and [other agents](docs/installation.md#supported-agents). See [installation and setup](docs/installation.md) for provider configuration and manual MCP registration. diff --git a/README_ar.md b/README_ar.md index 24c3e148cd..8e977c3260 100644 --- a/README_ar.md +++ b/README_ar.md @@ -68,7 +68,7 @@ npx rea-agents setup اختر الوكلاء، وراجع التغييرات المقترحة، ثم وافق عليها. يضيف الإعداد خادم MCP الخاص بـ REA وتعليمات سير العمل المطابقة، مع الاحتفاظ بنسخ احتياطية من الإعدادات الموجودة. أعد تشغيل وكيلك بعد ذلك. -يدعم الإعداد Claude Code وCodex وCursor وGemini CLI و[وكلاء آخرين](docs/installation.md#supported-agents). راجع [التثبيت والإعداد](docs/installation.md) لتهيئة موفّري التحليل وتسجيل MCP يدويًا. +يدعم الإعداد Claude Code وCodex وCursor وGemini CLI وGrok Build و[وكلاء آخرين](docs/installation.md#supported-agents). راجع [التثبيت والإعداد](docs/installation.md) لتهيئة موفّري التحليل وتسجيل MCP يدويًا. ### اطلب من وكيلك diff --git a/README_de.md b/README_de.md index dc2c2b8782..a6342dc0f6 100644 --- a/README_de.md +++ b/README_de.md @@ -68,7 +68,7 @@ npx rea-agents setup Wähle deine Agenten aus, prüfe die geplanten Änderungen und bestätige sie. Die Einrichtung fügt den MCP-Server von REA und passende Workflow-Anweisungen hinzu und sichert vorhandene Konfigurationen. Starte deinen Agenten anschließend neu. -Die Einrichtung unterstützt Claude Code, Codex, Cursor, Gemini CLI und [weitere Agenten](docs/installation.md#supported-agents). Informationen zur Provider-Konfiguration und manuellen MCP-Registrierung findest du unter [Installation und Einrichtung](docs/installation.md). +Die Einrichtung unterstützt Claude Code, Codex, Cursor, Gemini CLI, Grok Build und [weitere Agenten](docs/installation.md#supported-agents). Informationen zur Provider-Konfiguration und manuellen MCP-Registrierung findest du unter [Installation und Einrichtung](docs/installation.md). ### Deinen Agenten fragen diff --git a/README_es.md b/README_es.md index e9df0c5c7a..4d0a06d4f1 100644 --- a/README_es.md +++ b/README_es.md @@ -68,7 +68,7 @@ npx rea-agents setup Elige tus agentes, revisa los cambios propuestos y apruébalos. La configuración añade el servidor MCP de REA y las instrucciones de trabajo correspondientes, con copias de seguridad de la configuración existente. Reinicia tu agente al terminar. -La configuración admite Claude Code, Codex, Cursor, Gemini CLI y [otros agentes](docs/installation.md#supported-agents). Consulta [instalación y configuración](docs/installation.md) para configurar proveedores y registrar MCP manualmente. +La configuración admite Claude Code, Codex, Cursor, Gemini CLI, Grok Build y [otros agentes](docs/installation.md#supported-agents). Consulta [instalación y configuración](docs/installation.md) para configurar proveedores y registrar MCP manualmente. ### Pregunta a tu agente diff --git a/README_ja.md b/README_ja.md index f51f9d7c12..61efe98257 100644 --- a/README_ja.md +++ b/README_ja.md @@ -68,7 +68,7 @@ npx rea-agents setup 使うエージェントを選び、予定されている変更を確認して承認してください。セットアップは REA の MCP サーバーと対応するワークフローの手順を追加し、既存の設定をバックアップします。完了後にエージェントを再起動してください。 -セットアップは Claude Code、Codex、Cursor、Gemini CLI、および[その他のエージェント](docs/installation.md#supported-agents)に対応しています。プロバイダーの設定と MCP の手動登録については、[インストールとセットアップ](docs/installation.md)を参照してください。 +セットアップは Claude Code、Codex、Cursor、Gemini CLI、Grok Build、および[その他のエージェント](docs/installation.md#supported-agents)に対応しています。プロバイダーの設定と MCP の手動登録については、[インストールとセットアップ](docs/installation.md)を参照してください。 ### エージェントに依頼する diff --git a/README_ko.md b/README_ko.md index 4180c38e29..bc41bcc0c0 100644 --- a/README_ko.md +++ b/README_ko.md @@ -68,7 +68,7 @@ npx rea-agents setup 에이전트를 선택하고, 예정된 변경 사항을 검토한 뒤 승인하세요. 설정 과정은 기존 설정을 백업하고 REA의 MCP 서버와 버전에 맞는 워크플로 지침을 추가합니다. 완료 후 에이전트를 다시 시작하세요. -설정은 Claude Code, Codex, Cursor, Gemini CLI 및 [다른 에이전트](docs/installation.md#supported-agents)를 지원합니다. 제공자 설정과 수동 MCP 등록은 [설치 및 설정](docs/installation.md)을 참고하세요. +설정은 Claude Code, Codex, Cursor, Gemini CLI, Grok Build 및 [다른 에이전트](docs/installation.md#supported-agents)를 지원합니다. 제공자 설정과 수동 MCP 등록은 [설치 및 설정](docs/installation.md)을 참고하세요. ### 에이전트에게 요청 diff --git a/README_pl.md b/README_pl.md index baa8e3f1cc..404166822e 100644 --- a/README_pl.md +++ b/README_pl.md @@ -68,7 +68,7 @@ npx rea-agents setup Wybierz swoich agentów, sprawdź proponowane zmiany i zatwierdź je. Konfigurator dodaje serwer MCP REA i pasujące instrukcje pracy, tworząc kopie zapasowe istniejącej konfiguracji. Następnie uruchom agenta ponownie. -Konfigurator obsługuje Claude Code, Codex, Cursor, Gemini CLI i [innych agentów](docs/installation.md#supported-agents). Konfigurację dostawców analizy i ręczną rejestrację MCP opisuje przewodnik [instalacji i konfiguracji](docs/installation.md). +Konfigurator obsługuje Claude Code, Codex, Cursor, Gemini CLI, Grok Build i [innych agentów](docs/installation.md#supported-agents). Konfigurację dostawców analizy i ręczną rejestrację MCP opisuje przewodnik [instalacji i konfiguracji](docs/installation.md). ### Zapytaj agenta diff --git a/README_pt-BR.md b/README_pt-BR.md index bad0348639..a83a1b8330 100644 --- a/README_pt-BR.md +++ b/README_pt-BR.md @@ -68,7 +68,7 @@ npx rea-agents setup Escolha seus agentes, revise as alterações propostas e aprove-as. A configuração adiciona o servidor MCP do REA e as instruções de fluxo de trabalho correspondentes, com backups das configurações existentes. Reinicie seu agente depois. -A configuração oferece suporte ao Claude Code, Codex, Cursor, Gemini CLI e [outros agentes](docs/installation.md#supported-agents). Consulte [instalação e configuração](docs/installation.md) para configurar provedores e registrar o MCP manualmente. +A configuração oferece suporte ao Claude Code, Codex, Cursor, Gemini CLI, Grok Build e [outros agentes](docs/installation.md#supported-agents). Consulte [instalação e configuração](docs/installation.md) para configurar provedores e registrar o MCP manualmente. ### Peça ao seu agente diff --git a/README_ru.md b/README_ru.md index a20e077e1b..07051c98fd 100644 --- a/README_ru.md +++ b/README_ru.md @@ -68,7 +68,7 @@ npx rea-agents setup Выберите агентов, проверьте предлагаемые изменения и подтвердите их. Настройка добавляет MCP-сервер REA и соответствующие рабочие инструкции, сохраняя резервные копии существующих настроек. После этого перезапустите агента. -Настройка поддерживает Claude Code, Codex, Cursor, Gemini CLI и [других агентов](docs/installation.md#supported-agents). Настройка провайдеров и ручная регистрация MCP описаны в разделе [установка и настройка](docs/installation.md). +Настройка поддерживает Claude Code, Codex, Cursor, Gemini CLI, Grok Build и [других агентов](docs/installation.md#supported-agents). Настройка провайдеров и ручная регистрация MCP описаны в разделе [установка и настройка](docs/installation.md). ### Задайте вопрос агенту diff --git a/README_th.md b/README_th.md index da34fb80c7..e2f4d7454c 100644 --- a/README_th.md +++ b/README_th.md @@ -68,7 +68,7 @@ npx rea-agents setup เลือกเอเจนต์ ตรวจสอบการเปลี่ยนแปลงที่เสนอ แล้วอนุมัติ ขั้นตอนตั้งค่าจะเพิ่มเซิร์ฟเวอร์ MCP ของ REA และคำแนะนำการทำงานที่ตรงกัน พร้อมสำรองการตั้งค่าที่มีอยู่ จากนั้นเริ่มเอเจนต์ใหม่ -ขั้นตอนตั้งค่ารองรับ Claude Code, Codex, Cursor, Gemini CLI และ[เอเจนต์อื่น ๆ](docs/installation.md#supported-agents) ดู[การติดตั้งและตั้งค่า](docs/installation.md) สำหรับการตั้งค่าผู้ให้บริการวิเคราะห์และการลงทะเบียน MCP ด้วยตนเอง +ขั้นตอนตั้งค่ารองรับ Claude Code, Codex, Cursor, Gemini CLI, Grok Build และ[เอเจนต์อื่น ๆ](docs/installation.md#supported-agents) ดู[การติดตั้งและตั้งค่า](docs/installation.md) สำหรับการตั้งค่าผู้ให้บริการวิเคราะห์และการลงทะเบียน MCP ด้วยตนเอง ### ถามเอเจนต์ diff --git a/README_tr.md b/README_tr.md index cf449c2e72..8966e6a866 100644 --- a/README_tr.md +++ b/README_tr.md @@ -68,7 +68,7 @@ npx rea-agents setup Ajanlarınızı seçin, önerilen değişiklikleri inceleyin ve onaylayın. Kurulum, mevcut yapılandırmaları yedekleyerek REA'nın MCP sunucusunu ve uyumlu iş akışı talimatlarını ekler. Ardından ajanınızı yeniden başlatın. -Kurulum Claude Code, Codex, Cursor, Gemini CLI ve [diğer ajanları](docs/installation.md#supported-agents) destekler. Sağlayıcı yapılandırması ve elle MCP kaydı için [yükleme ve kurulum](docs/installation.md) belgesine bakın. +Kurulum Claude Code, Codex, Cursor, Gemini CLI, Grok Build ve [diğer ajanları](docs/installation.md#supported-agents) destekler. Sağlayıcı yapılandırması ve elle MCP kaydı için [yükleme ve kurulum](docs/installation.md) belgesine bakın. ### Ajanınıza sorun diff --git a/README_uk.md b/README_uk.md index 45e9c8265f..52bdd78a04 100644 --- a/README_uk.md +++ b/README_uk.md @@ -68,7 +68,7 @@ npx rea-agents setup Виберіть агентів, перевірте запропоновані зміни та схваліть їх. Налаштування додає MCP-сервер REA та відповідні інструкції робочих процесів, створюючи резервні копії наявної конфігурації. Після цього перезапустіть агента. -Налаштування підтримує Claude Code, Codex, Cursor, Gemini CLI та [інших агентів](docs/installation.md#supported-agents). Конфігурацію провайдерів і ручну реєстрацію MCP описано в розділі [встановлення й налаштування](docs/installation.md). +Налаштування підтримує Claude Code, Codex, Cursor, Gemini CLI, Grok Build та [інших агентів](docs/installation.md#supported-agents). Конфігурацію провайдерів і ручну реєстрацію MCP описано в розділі [встановлення й налаштування](docs/installation.md). ### Запитайте агента diff --git a/README_vi.md b/README_vi.md index 252faf85e1..0edf0b48a5 100644 --- a/README_vi.md +++ b/README_vi.md @@ -68,7 +68,7 @@ npx rea-agents setup Chọn các tác nhân, xem lại những thay đổi được đề xuất và chấp thuận. Quá trình thiết lập thêm máy chủ MCP của REA cùng hướng dẫn quy trình tương ứng, đồng thời sao lưu cấu hình hiện có. Sau đó khởi động lại tác nhân. -Quá trình thiết lập hỗ trợ Claude Code, Codex, Cursor, Gemini CLI và [các tác nhân khác](docs/installation.md#supported-agents). Xem [cài đặt và thiết lập](docs/installation.md) để cấu hình trình phân tích và đăng ký MCP thủ công. +Quá trình thiết lập hỗ trợ Claude Code, Codex, Cursor, Gemini CLI, Grok Build và [các tác nhân khác](docs/installation.md#supported-agents). Xem [cài đặt và thiết lập](docs/installation.md) để cấu hình trình phân tích và đăng ký MCP thủ công. ### Hỏi tác nhân diff --git a/README_zh-TW.md b/README_zh-TW.md index c8a8eeadc8..9b0915116d 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -68,7 +68,7 @@ npx rea-agents setup 選擇 AI 代理,檢查計劃中的變更並核准。設定流程會添加 REA 的 MCP 服務和對應的工作流程指引,並備份已有組態。完成後重新啟動 AI 代理。 -設定流程支援 Claude Code、Codex、Cursor、Gemini CLI 和[其他 AI 代理](docs/installation.md#supported-agents)。提供者組態和手動註冊 MCP 的方法見[安裝與設定](docs/installation.md)。 +設定流程支援 Claude Code、Codex、Cursor、Gemini CLI、Grok Build 和[其他 AI 代理](docs/installation.md#supported-agents)。提供者組態和手動註冊 MCP 的方法見[安裝與設定](docs/installation.md)。 ### 詢問 AI 代理 diff --git a/README_zh.md b/README_zh.md index 218ca5d809..0e7624e0d9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -68,7 +68,7 @@ npx rea-agents setup 选择智能体,检查计划中的变更并批准。设置流程会添加 REA 的 MCP 服务和匹配的工作流指引,并备份已有配置。完成后重启智能体。 -设置流程支持 Claude Code、Codex、Cursor、Gemini CLI 和[其他智能体](docs/installation.md#supported-agents)。提供方配置和手动注册 MCP 的方法见[安装与设置](docs/installation.md)。 +设置流程支持 Claude Code、Codex、Cursor、Gemini CLI、Grok Build 和[其他智能体](docs/installation.md#supported-agents)。提供方配置和手动注册 MCP 的方法见[安装与设置](docs/installation.md)。 ### 询问智能体 diff --git a/docs/installation.md b/docs/installation.md index 078b29013d..5dcf4d99a1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -119,7 +119,8 @@ readiness for one task instead of auditing every integration, see ## Supported agents -Setup can configure these clients for REA's local MCP server: +Setup can configure these clients for REA's local MCP server. Grok Bot is +listed after the table because its connector is not one of these files: | Client | `--client` value | | ------------------ | ---------------- | @@ -135,12 +136,37 @@ Setup can configure these clients for REA's local MCP server: | GitHub Copilot CLI | `copilot_cli` | | Command Code | `commandcode` | | VS Code | `vscode` | +| Grok Build | `grok_build` | For OpenCode, setup writes the V1 `mcp.rea` entry, which OpenCode V1 and V2 both load. If the configuration already uses OpenCode V2's native `mcp.servers` table, setup registers REA there instead and replaces any earlier `mcp.rea` entry from REA. +Grok Build loads `[mcp_servers.rea]` from `$GROK_HOME/config.toml`, or from +`~/.grok/config.toml` when `GROK_HOME` is unset. Setup edits that server +table, `[mcp_servers.rea.env]`, and a root `disabled_mcp_servers` entry that +names `rea`. It sets `startup_timeout_sec = 30` and leaves every other name +in that list. The shared skill installed under `~/.agents/skills` is already +on Grok Build's skill path. + +Grok Bot (`grok_bot`) is detected from `~/.grokbot`, or from `SAND_DATA_ROOT` +when that value is an absolute path. A relative or empty `SAND_DATA_ROOT` +stays on `~/.grokbot`. That directory is not the connector store. Grok Bot +keeps connectors in the signed-in account and runs them on its hosted +computer. It does not import `mcp.json` from the data directory, and it does +not attach a stdio server running on this machine. Setup does not call the +account connector API, does not write a registration file, and does not report +the data directory as aligned. Ask the Grok Bot chat to add a custom MCP +server named `rea` that runs on the Bot's computer: + +```bash +npx -y rea-agents@ mcp +``` + +Do not put credentials in that command or its arguments. `rea doctor --client grok_bot` +reports this manual step. `rea uninstall` does not remove the account connector. + ## Review setup changes `rea setup` first offers the supported agents in a multi-select. Existing REA @@ -209,7 +235,7 @@ rea setup --yes --all-detected --install-hopper --json Setup pins package-runner MCP registrations to the exact installed REA version, installs the matching skill and on-demand references in the same plan, and adds -`startup_timeout_sec = 30` for Codex. `rea update` installs the exact resolved +`startup_timeout_sec = 30` for Codex and Grok Build. `rea update` installs the exact resolved release into the npm prefix that owns the running package, then checks the new executable's version before reporting success. It does not reopen onboarding. Release lookup and installation both use npm's configured registry. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3832bf2388..62323d591c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,7 +67,7 @@ REA setup lets you select agent integration and optional Hopper installation. It installs the bundled workflow, configures detected agents, and can save verified paths for an existing Ghidra installation. It configures Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, -GitHub Copilot CLI, Command Code, and VS Code using each client's configuration format. +GitHub Copilot CLI, Command Code, VS Code, and Grok Build using each client's configuration format. Grok Bot is detected locally, but its connector is added from the Grok Bot chat because the signed-in account store is not a data-directory file. Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.x and the 64-bit full JDK that installation declares. Current 12.1 releases require JDK 21 diff --git a/scripts/verify-package-environment.mjs b/scripts/verify-package-environment.mjs index 73a3703983..90955d5932 100644 --- a/scripts/verify-package-environment.mjs +++ b/scripts/verify-package-environment.mjs @@ -79,6 +79,8 @@ export async function verifyPackageEnvironment({ CLAUDE_CONFIG_DIR: home, CODEX_HOME: codexDir, COPILOT_HOME: join(home, ".copilot"), + GROK_HOME: join(home, ".grok"), + SAND_DATA_ROOT: join(home, ".grokbot"), OPENCODE_CONFIG: undefined, XDG_CONFIG_HOME: join(home, ".config"), PATH: `${fakeBin}:${process.env.PATH ?? ""}`, diff --git a/scripts/verify-package-update.mjs b/scripts/verify-package-update.mjs index fc12965464..fcc27b0914 100644 --- a/scripts/verify-package-update.mjs +++ b/scripts/verify-package-update.mjs @@ -101,6 +101,8 @@ export async function verifyPackageUpdate({ CLAUDE_CONFIG_DIR: home, CODEX_HOME: join(home, ".codex"), COPILOT_HOME: join(home, ".copilot"), + GROK_HOME: join(home, ".grok"), + SAND_DATA_ROOT: join(home, ".grokbot"), XDG_CONFIG_HOME: join(home, ".config"), OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.json"), npm_config_prefix: prefix, diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs index aa1c05b386..07c28d76bd 100644 --- a/scripts/verify-published-package.mjs +++ b/scripts/verify-published-package.mjs @@ -99,6 +99,15 @@ async function verifyPublishedUpdate({ version: targetVersion, canaryRoot }) { const environment = { ...process.env, HOME: home, + USERPROFILE: home, + APPDATA: join(home, "AppData", "Roaming"), + CLAUDE_CONFIG_DIR: home, + CODEX_HOME: join(home, ".codex"), + COPILOT_HOME: join(home, ".copilot"), + GROK_HOME: join(home, ".grok"), + SAND_DATA_ROOT: join(home, ".grokbot"), + XDG_CONFIG_HOME: join(home, ".config"), + OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.json"), npm_config_cache: join(canaryRoot, "update-cache"), npm_config_prefix: prefix, PATH: `${join(prefix, "bin")}${delimiter}${process.env.PATH ?? ""}`, diff --git a/scripts/verify-windows-package.mjs b/scripts/verify-windows-package.mjs index 3d3fabf353..8ece0dde37 100644 --- a/scripts/verify-windows-package.mjs +++ b/scripts/verify-windows-package.mjs @@ -108,9 +108,13 @@ try { HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), + CLAUDE_CONFIG_DIR: home, + CODEX_HOME: join(home, ".codex"), XDG_CONFIG_HOME: join(home, ".config"), OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.jsonc"), COPILOT_HOME: join(home, ".copilot"), + GROK_HOME: join(home, ".grok"), + SAND_DATA_ROOT: join(home, ".grokbot"), }; await mkdir(join(home, ".config", "opencode"), { recursive: true }); await writeFile( diff --git a/skill-src/reverse-engineer-anything/SKILL.md b/skill-src/reverse-engineer-anything/SKILL.md index d1b3690605..404ad45ea8 100644 --- a/skill-src/reverse-engineer-anything/SKILL.md +++ b/skill-src/reverse-engineer-anything/SKILL.md @@ -31,7 +31,9 @@ When tools are absent or registration is stale: `npx -y rea-agents@latest doctor --client codex --json`. Substitute the current supported client: `claude_code`, `claude_desktop`, `codex`, `cursor`, `gemini_cli`, `windsurf`, `devin`, `opencode`, `antigravity`, `copilot_cli`, - `commandcode`, or `vscode`. If the client is unknown, use `doctor --json` and + `commandcode`, `vscode`, `grok_build`, or `grok_bot`. `grok_bot` has no local + file registration: doctor reports the Grok Bot chat step, and setup does not + write `mcp.json`. If the client is unknown, use `doctor --json` and inspect its registration results before choosing a setup scope. 2. Distinguish the reason. Missing, malformed, or stale registration needs a scoped configuration repair. An aligned registration with no tools in the diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index f12db600b6..0b96a79d37 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -2,13 +2,16 @@ import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { isDeepStrictEqual } from "node:util"; import { applyEdits, + findNodeAtLocation, modify, parse as parseJsonc, + parseTree, printParseErrorCode, type ParseError, } from "jsonc-parser"; import { z } from "zod"; +import { PRODUCT_IDENTITY } from "../identity.js"; import type { SetupClient } from "./SupportedClients.js"; export type ClientConfigurationFormat = NonNullable; @@ -66,6 +69,7 @@ export const clientConfigurationServersKey = ( ): ClientServersKey => { switch (format) { case "toml": + case "grok": return "mcp_servers"; case "opencode": return "mcp"; @@ -80,7 +84,8 @@ const parseDocument = ( text: string, format: ClientConfigurationFormat, ): Record => { - if (format === "toml") return objectSchema.parse(parseToml(text)); + if (format === "toml" || format === "grok") + return objectSchema.parse(parseToml(text)); const errors: ParseError[] = []; // Accept a UTF-8 BOM without shifting diagnostics or editing the original text. const jsonText = text.startsWith("\uFEFF") ? ` ${text.slice(1)}` : text; @@ -178,6 +183,959 @@ const valueAt = ( return current; }; +type TomlStringMode = "none" | "ml-basic" | "ml-literal"; + +const TOML_BASIC_ESCAPES: Readonly> = { + b: "\b", + t: "\t", + n: "\n", + f: "\f", + r: "\r", + e: "\u001b", + '"': '"', + "\\": "\\", +}; + +/** A `[table]` or `[[array]]` header outside strings, with decoded key segments. */ +interface TomlTableHeader { + readonly path: readonly string[]; +} + +const skipHorizontalSpace = (line: string, index: number): number => { + let next = index; + while (line[next] === " " || line[next] === "\t") next += 1; + return next; +}; + +const decodeBasicEscape = ( + line: string, + slash: number, +): { readonly value: string; readonly next: number } | undefined => { + const escape = line[slash + 1]; + if (escape === undefined) return undefined; + const simple = TOML_BASIC_ESCAPES[escape]; + if (simple !== undefined) return { value: simple, next: slash + 2 }; + // smol-toml accepts `\xHH` even though TOML 1.0 does not. + if (escape === "x") { + const hex = line.slice(slash + 2, slash + 4); + if (!/^[0-9A-Fa-f]{2}$/u.test(hex)) return undefined; + return { + value: String.fromCodePoint(Number.parseInt(hex, 16)), + next: slash + 4, + }; + } + if (escape !== "u" && escape !== "U") return undefined; + const length = escape === "u" ? 4 : 8; + const hex = line.slice(slash + 2, slash + 2 + length); + if (!new RegExp(`^[0-9A-Fa-f]{${String(length)}}$`, "u").test(hex)) + return undefined; + const code = Number.parseInt(hex, 16); + if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return undefined; + return { value: String.fromCodePoint(code), next: slash + 2 + length }; +}; + +/** Read one single-line quoted key. Multiline quotes are not a header key. */ +const parseQuotedKey = ( + line: string, + start: number, +): { readonly value: string; readonly next: number } | undefined => { + const quote = line[start]; + if ( + (quote !== '"' && quote !== "'") || + line.startsWith(quote === '"' ? '"""' : "'''", start) + ) + return undefined; + if (quote === "'") { + const end = line.indexOf("'", start + 1); + if (end < 0 || line.slice(start + 1, end).includes("\n")) return undefined; + return { value: line.slice(start + 1, end), next: end + 1 }; + } + let value = ""; + let index = start + 1; + while (index < line.length) { + const char = line[index]; + if (char === "\n" || char === "\r") return undefined; + if (char === '"') return { value, next: index + 1 }; + if (char !== "\\") { + value += char ?? ""; + index += 1; + continue; + } + const decoded = decodeBasicEscape(line, index); + if (decoded === undefined) return undefined; + value += decoded.value; + index = decoded.next; + } + return undefined; +}; + +const parseBareKey = ( + line: string, + start: number, +): { readonly value: string; readonly next: number } | undefined => { + let index = start; + while (/[A-Za-z0-9_-]/u.test(line[index] ?? "")) index += 1; + if (index === start) return undefined; + return { value: line.slice(start, index), next: index }; +}; + +/** + * Parse a table header. Quoted segments stay one key, so `mcp_servers."rea.env"` + * is not the dotted path `mcp_servers.rea.env`. + */ +const parseTomlTableHeader = (line: string): TomlTableHeader | undefined => { + let index = skipHorizontalSpace(line, 0); + const array = line.startsWith("[[", index); + if (!array && line[index] !== "[") return undefined; + index += array ? 2 : 1; + const path: string[] = []; + for (;;) { + index = skipHorizontalSpace(line, index); + const key = + line[index] === '"' || line[index] === "'" + ? parseQuotedKey(line, index) + : parseBareKey(line, index); + if (key === undefined) return undefined; + path.push(key.value); + index = skipHorizontalSpace(line, key.next); + if (line[index] !== ".") break; + index += 1; + } + const close = array ? "]]" : "]"; + if (!line.startsWith(close, index)) return undefined; + index = skipHorizontalSpace(line, index + close.length); + if (index < line.length && line[index] !== "#") return undefined; + return { path }; +}; + +/** + * String state after one physical line, starting at `start`. A multiline + * closer does not end the scan: `""", """` closes one string and opens another. + */ +const tomlStringModeAfter = ( + line: string, + mode: TomlStringMode, + start = 0, +): TomlStringMode => { + if (mode === "ml-basic" || mode === "ml-literal") { + const delimiter = mode === "ml-basic" ? '"""' : "'''"; + const closed = closeMultiline(line, start, delimiter, mode === "ml-basic"); + if (closed === undefined) return mode; + return tomlStringModeAfter(line, "none", closed); + } + let index = start; + while (index < line.length) { + if (line[index] === "#") return "none"; + if (line.startsWith('"""', index)) { + const closed = closeMultiline(line, index + 3, '"""', true); + if (closed === undefined) return "ml-basic"; + index = closed; + continue; + } + if (line.startsWith("'''", index)) { + const closed = closeMultiline(line, index + 3, "'''", false); + if (closed === undefined) return "ml-literal"; + index = closed; + continue; + } + const quote = line[index]; + if (quote === '"' || quote === "'") { + const quoted = parseQuotedKey(line, index); + if (quoted === undefined) return "none"; + index = quoted.next; + continue; + } + index += 1; + } + return "none"; +}; + +/** Index after a multiline delimiter, or undefined when this line does not close it. */ +const closeMultiline = ( + line: string, + start: number, + delimiter: '"""' | "'''", + escapes: boolean, +): number | undefined => { + let index = start; + while (index < line.length) { + if (escapes && line[index] === "\\") { + if (line[index + 1] === undefined) return undefined; + index += 2; + continue; + } + if (line.startsWith(delimiter, index)) { + // One or two quotes before the closer belong to the string: """keep"""" + let end = index + delimiter.length; + const quote = delimiter[0]; + let extra = 0; + while (extra < 2 && line[end] === quote) { + extra += 1; + end += 1; + } + return end; + } + index += 1; + } + return undefined; +}; + +const isBlankOrComment = (line: string): boolean => { + const trimmed = line.trimStart(); + return trimmed === "" || trimmed.startsWith("#"); +}; + +const looksLikeTomlHeader = (line: string): boolean => + line.trimStart().startsWith("["); + +/** `mcp_servers.` and tables nested under it, compared by key segment. */ +const isGrokServerTable = ( + path: readonly string[], + serverKey: string, +): boolean => path[0] === "mcp_servers" && path[1] === serverKey; + +const parseKeyPath = ( + text: string, + start: number, +): + | { readonly segments: readonly string[]; readonly next: number } + | undefined => { + const segments: string[] = []; + let index = start; + for (;;) { + index = skipHorizontalSpace(text, index); + const current = text[index]; + if (current === undefined || current === "\n" || current === "\r") + return undefined; + const key = + current === '"' || current === "'" + ? parseQuotedKey(text, index) + : parseBareKey(text, index); + if (key === undefined) return undefined; + segments.push(key.value); + index = skipHorizontalSpace(text, key.next); + if (text[index] !== ".") return { segments, next: index }; + index += 1; + } +}; + +const parseAssignment = ( + line: string, +): + | { readonly segments: readonly string[]; readonly equals: number } + | undefined => { + const key = parseKeyPath(line, 0); + if (key === undefined) return undefined; + const equals = skipHorizontalSpace(line, key.next); + if (line[equals] !== "=") return undefined; + return { segments: key.segments, equals }; +}; + +const skipWsAndComments = (text: string, start: number): number => { + let index = start; + while (index < text.length) { + const char = text[index]; + if (char === " " || char === "\t" || char === "\n" || char === "\r") { + index += 1; + continue; + } + if (char === "#") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + return index; + } + return index; +}; + +/** Index after one TOML value. Containers and multiline strings may cross lines. */ +const endOfTomlValue = (text: string, start: number): number => { + const index = skipWsAndComments(text, start); + if (index >= text.length) return index; + if (text.startsWith('"""', index)) { + const closed = closeMultiline(text, index + 3, '"""', true); + return closed ?? text.length; + } + if (text.startsWith("'''", index)) { + const closed = closeMultiline(text, index + 3, "'''", false); + return closed ?? text.length; + } + const quote = text[index]; + if (quote === '"' || quote === "'") { + const quoted = parseQuotedKey(text, index); + return quoted === undefined ? text.length : quoted.next; + } + if (quote === "{" || quote === "[") return endOfContainer(text, index); + let cursor = index; + while (cursor < text.length) { + const char = text[cursor]; + if ( + char === "\n" || + char === "#" || + char === "," || + char === "}" || + char === "]" + ) + break; + cursor += 1; + } + return cursor; +}; + +const endOfContainer = (text: string, open: number): number => { + const closers: string[] = [text[open] === "{" ? "}" : "]"]; + let index = open + 1; + while (index < text.length && closers.length > 0) { + if (text.startsWith('"""', index)) { + const closed = closeMultiline(text, index + 3, '"""', true); + if (closed === undefined) return text.length; + index = closed; + continue; + } + if (text.startsWith("'''", index)) { + const closed = closeMultiline(text, index + 3, "'''", false); + if (closed === undefined) return text.length; + index = closed; + continue; + } + const quote = text[index]; + if (quote === '"' || quote === "'") { + const quoted = parseQuotedKey(text, index); + if (quoted === undefined) return text.length; + index = quoted.next; + continue; + } + if (quote === "#") { + while (index < text.length && text[index] !== "\n") index += 1; + continue; + } + if (quote === "{" || quote === "[") { + closers.push(quote === "{" ? "}" : "]"); + index += 1; + continue; + } + if (quote === closers[closers.length - 1]) { + closers.pop(); + index += 1; + continue; + } + index += 1; + } + return index; +}; + +/** TOML basic string. JSON leaves U+007F raw, which TOML rejects. */ +const tomlBasicString = (value: string): string => + JSON.stringify(value).replaceAll("\u007f", "\\u007f"); + +const tomlInlineKey = (key: string): string => + /^[A-Za-z0-9_-]+$/u.test(key) ? key : tomlBasicString(key); + +const tomlInlineValue = (value: unknown): string => { + if (typeof value === "string") return tomlBasicString(value); + if (typeof value === "boolean") return value ? "true" : "false"; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (Array.isArray(value)) + return `[${value.map((item) => tomlInlineValue(item)).join(", ")}]`; + if (typeof value === "object" && value !== null) { + const body = Object.entries(value) + .map( + ([key, nested]) => `${tomlInlineKey(key)} = ${tomlInlineValue(nested)}`, + ) + .join(", "); + return `{ ${body} }`; + } + return '""'; +}; + +interface InlinePair { + readonly leading: string; + readonly text: string; + readonly drop: boolean; +} + +const readTrivia = ( + text: string, + start: number, + limit: number, +): { readonly text: string; readonly next: number } => { + let index = start; + while (index < limit) { + const char = text[index]; + if ( + char === " " || + char === "\t" || + char === "\n" || + char === "\r" || + char === "," + ) { + index += 1; + continue; + } + if (char === "#") { + while (index < limit && text[index] !== "\n") index += 1; + continue; + } + break; + } + return { text: text.slice(start, index), next: index }; +}; + +const commasOutsideComments = (trivia: string): string => + trivia + .split("\n") + .map((part) => { + const comment = part.indexOf("#"); + if (comment < 0) return part.replaceAll(",", ""); + return `${part.slice(0, comment).replaceAll(",", "")}${part.slice(comment)}`; + }) + .join("\n"); + +const hasSeparatorComma = (trivia: string): boolean => + trivia.split("\n").some((part) => { + const comment = part.indexOf("#"); + const code = comment < 0 ? part : part.slice(0, comment); + return code.includes(","); + }); + +/** + * Replace or remove `serverKey` inside one `{ ... }` table. A closed inline + * table cannot later grow a `[mcp_servers.rea]` header. + */ +const editGrokInlineTable = ( + table: string, + serverKey: string, + entry: unknown, +): string | undefined => { + if (!table.startsWith("{")) return undefined; + const end = endOfContainer(table, 0); + if (table[end - 1] !== "}") return undefined; + const pairs: InlinePair[] = []; + let index = 1; + let trailing = ""; + while (index < end - 1) { + const trivia = readTrivia(table, index, end - 1); + if (trivia.next >= end - 1) { + trailing = trivia.text; + break; + } + const key = parseKeyPath(table, trivia.next); + if (key === undefined) return undefined; + const equals = skipWsAndComments(table, key.next); + if (table[equals] !== "=") return undefined; + const valueEnd = endOfTomlValue(table, equals + 1); + if (valueEnd > end - 1) return undefined; + pairs.push({ + leading: trivia.text, + text: table.slice(trivia.next, valueEnd), + drop: key.segments[0] === serverKey, + }); + index = valueEnd; + } + const insertion = + entry === undefined + ? undefined + : `${tomlInlineKey(serverKey)} = ${tomlInlineValue(entry)}`; + if (insertion === undefined && pairs.every((pair) => !pair.drop)) + return table; + return rebuildInlineTable(pairs, trailing, insertion); +}; + +const rebuildInlineTable = ( + pairs: readonly InlinePair[], + trailing: string, + insertion: string | undefined, +): string => rebuildDelimitedEntries(pairs, trailing, insertion, "{", "}"); + +/** + * Rebuild `{ ... }` or `[ ... ]` after dropping entries. An empty container + * keeps comment trivia; a table with no comments stays `{}`. + */ +const rebuildDelimitedEntries = ( + pairs: readonly InlinePair[], + trailing: string, + insertion: string | undefined, + open: "{" | "[", + close: "}" | "]", +): string => { + const parts: string[] = [open]; + let emitted = false; + // A comma already emitted in a dropped pair's comment trivia. + let separatorPending = false; + for (const pair of pairs) { + if (pair.drop) { + if (!pair.leading.includes("#")) continue; + // A separator already emitted must not be repeated for the next field. + const dropped: string = + !emitted || separatorPending + ? commasOutsideComments(pair.leading) + : pair.leading; + parts.push(dropped); + separatorPending = separatorPending || hasSeparatorComma(dropped); + continue; + } + const kept = emitted ? pair.leading : commasOutsideComments(pair.leading); + parts.push(separatorPending ? commasOutsideComments(kept) : kept); + if (emitted && !separatorPending && !pair.leading.includes(",")) + parts.push(", "); + separatorPending = false; + parts.push(pair.text); + emitted = true; + } + if (insertion !== undefined) { + if (!(emitted && separatorPending)) parts.push(emitted ? ", " : " "); + parts.push(insertion); + emitted = true; + } + if (!emitted) { + const retained = `${parts.slice(1).join("")}${commasOutsideComments(trailing)}`; + return retained.includes("#") + ? `${open}${retained}${close}` + : `${open}${close}`; + } + const tail = commasOutsideComments(trailing); + parts.push(tail); + if (tail === "" && open === "{") parts.push(" "); + parts.push(close); + return parts.join(""); +}; + +/** + * Decoded TOML string, or undefined when the token is not a string. + * The document parser accepts multiline basic and literal strings. + */ +const tomlStringValue = (text: string): string | undefined => { + try { + const value = parseToml(`value = ${text}`)["value"]; + return typeof value === "string" ? value : undefined; + } catch (cause: unknown) { + void cause; + return undefined; + } +}; + +/** + * Array text with `serverKey` removed. `""` means the assignment itself can + * be deleted. Undefined when the array cannot be edited safely. + */ +const tomlArrayWithoutString = ( + array: string, + serverKey: string, +): string | undefined => { + if (!array.startsWith("[")) return undefined; + const end = endOfContainer(array, 0); + if (end !== array.length || array[end - 1] !== "]") return undefined; + const pairs: InlinePair[] = []; + let index = 1; + let trailing = ""; + while (index < end - 1) { + const trivia = readTrivia(array, index, end - 1); + if (trivia.next >= end - 1) { + trailing = trivia.text; + break; + } + const valueEnd = endOfTomlValue(array, trivia.next); + if (valueEnd > end - 1) return undefined; + const text = array.slice(trivia.next, valueEnd); + pairs.push({ + leading: trivia.text, + text, + drop: tomlStringValue(text) === serverKey, + }); + index = valueEnd; + } + if (pairs.every((pair) => !pair.drop)) return array; + const rebuilt = rebuildDelimitedEntries(pairs, trailing, undefined, "[", "]"); + return rebuilt === "[]" ? "" : rebuilt; +}; + +type DisabledServerListEdit = + | { readonly kind: "ignore" } + | { readonly kind: "keep" } + | { readonly kind: "delete"; readonly comment: string | undefined } + | { readonly kind: "replace"; readonly text: string }; + +interface DisabledServerListInput { + readonly text: string; + readonly lineStart: number; + readonly valueOffset: number; + readonly valueEnd: number; + readonly statementEnd: number; + readonly tablePath: readonly string[]; + readonly segments: readonly string[]; + readonly serverKey: string; + readonly entry: unknown; +} + +/** Drop `serverKey` from a root `disabled_mcp_servers` array while installing. */ +const disabledServerListEdit = ( + input: DisabledServerListInput, +): DisabledServerListEdit => { + if ( + input.entry === undefined || + input.tablePath.length !== 0 || + input.segments.length !== 1 || + input.segments[0] !== "disabled_mcp_servers" + ) + return { kind: "ignore" }; + const open = skipWsAndComments(input.text, input.valueOffset); + if (input.text[open] !== "[") return { kind: "ignore" }; + const arrayText = input.text.slice(open, input.valueEnd); + const edited = tomlArrayWithoutString(arrayText, input.serverKey); + if (edited === undefined || edited === arrayText) return { kind: "keep" }; + if (edited === "") { + const suffix = input.text.slice(input.valueEnd, input.statementEnd); + const hash = suffix.indexOf("#"); + return { + kind: "delete", + comment: hash < 0 ? undefined : suffix.slice(hash), + }; + } + return { + kind: "replace", + text: `${input.text.slice(input.lineStart, open)}${edited}${input.text.slice(input.valueEnd, input.statementEnd)}`, + }; +}; + +/** Lines that replace a disabled-server assignment, or undefined to leave it. */ +const disabledServerListLines = ( + edit: DisabledServerListEdit, + lines: readonly string[], + lineIndex: number, + endLine: number, +): readonly string[] | undefined => { + switch (edit.kind) { + case "ignore": + return undefined; + case "keep": + return lines.slice(lineIndex, endLine + 1); + case "replace": + return edit.text.split("\n"); + case "delete": + return edit.comment === undefined ? [] : [edit.comment]; + default: { + const unreachable: never = edit; + return unreachable; + } + } +}; + +/** + * Replace REA's Grok server tables and assignments. Comments that introduce a + * following table stay. Text inside strings is not treated as a header. + */ +const upsertGrokServerSection = ( + originalText: string, + serverKey: string, + entry: unknown, +): string => { + // smol-toml accepts a leading BOM. Leave it in place or the first header is missed. + const bom = originalText.startsWith("\uFEFF"); + const source = bom ? originalText.slice(1) : originalText; + // Keep a preceding \r on each line. Rewriting \r\n in the whole file would + // change string values. A file whose every break is CRLF gets a CRLF section. + const crlf = + source.includes("\r\n") && !source.replaceAll("\r\n", "").includes("\n"); + const text = source; + const lines = text.split("\n"); + const lineStarts: number[] = []; + let cursor = 0; + for (const line of lines) { + lineStarts.push(cursor); + cursor += line.length + 1; + } + const kept: string[] = []; + const pending: string[] = []; + let skipping = false; + let inlineServerTable = false; + let mode: TomlStringMode = "none"; + let tablePath: readonly string[] = []; + const discardPending = (): void => { + pending.length = 0; + }; + const flushPending = (): void => { + kept.push(...pending); + pending.length = 0; + }; + const lineIndexAt = (offset: number): number => { + let line = 0; + for (let index = 0; index < lineStarts.length; index += 1) + if ((lineStarts[index] ?? 0) <= offset) line = index; + return line; + }; + let lineIndex = 0; + while (lineIndex < lines.length) { + const physical = lines[lineIndex] ?? ""; + const line = physical.endsWith("\r") ? physical.slice(0, -1) : physical; + if (mode !== "none") { + mode = tomlStringModeAfter(line, mode); + if (skipping) discardPending(); + else kept.push(physical); + lineIndex += 1; + continue; + } + const header = parseTomlTableHeader(line); + if (header !== undefined) { + tablePath = header.path; + if (isGrokServerTable(header.path, serverKey)) { + discardPending(); + skipping = true; + lineIndex += 1; + continue; + } + skipping = false; + flushPending(); + kept.push(physical); + lineIndex += 1; + continue; + } + const assignment = parseAssignment(line); + if (assignment !== undefined) { + const lineStart = lineStarts[lineIndex] ?? 0; + const valueOffset = lineStart + assignment.equals + 1; + const valueEnd = endOfTomlValue(text, valueOffset); + let statementEnd = valueEnd; + while (statementEnd < text.length && text[statementEnd] !== "\n") + statementEnd += 1; + const endLine = lineIndexAt(Math.max(statementEnd - 1, lineStart)); + if (skipping) { + discardPending(); + lineIndex = endLine + 1; + mode = "none"; + continue; + } + const disabledLines = disabledServerListLines( + disabledServerListEdit({ + text, + lineStart, + valueOffset, + valueEnd, + statementEnd, + tablePath, + segments: assignment.segments, + serverKey, + entry, + }), + lines, + lineIndex, + endLine, + ); + if (disabledLines !== undefined) { + flushPending(); + kept.push(...disabledLines); + lineIndex = endLine + 1; + mode = "none"; + continue; + } + const absolute = [...tablePath, ...assignment.segments]; + const open = skipWsAndComments(text, valueOffset); + const closesServerTable = + absolute.length === 1 && + absolute[0] === "mcp_servers" && + text[open] === "{"; + if (closesServerTable || isGrokServerTable(absolute, serverKey)) { + if (closesServerTable) { + flushPending(); + const edited = editGrokInlineTable( + text.slice(open, valueEnd), + serverKey, + entry, + ); + const prefix = text.slice(lineStart, open); + const suffix = text.slice(valueEnd, statementEnd); + const replacement = + edited === undefined + ? text.slice(lineStart, statementEnd) + : `${prefix}${edited}${suffix}`; + kept.push(...replacement.split("\n")); + inlineServerTable = true; + } else discardPending(); + } else { + flushPending(); + for (let index = lineIndex; index <= endLine; index += 1) + kept.push(lines[index] ?? ""); + } + lineIndex = endLine + 1; + mode = "none"; + continue; + } + if (isBlankOrComment(line)) { + pending.push(physical); + lineIndex += 1; + continue; + } + mode = tomlStringModeAfter(line, "none"); + if (skipping) { + if (looksLikeTomlHeader(line)) { + flushPending(); + skipping = false; + tablePath = ["\0"]; + kept.push(physical); + } else discardPending(); + } else { + flushPending(); + kept.push(physical); + } + lineIndex += 1; + } + if (!skipping) flushPending(); + let body = kept.join("\n"); + // The last kept CRLF line has no following LF from the join. + if (body.endsWith("\r")) body += "\n"; + if (entry !== undefined && !inlineServerTable) { + let fragment = stringifyToml({ + mcp_servers: { [serverKey]: entry }, + }).replace(/\n+$/u, ""); + if (crlf) fragment = fragment.replaceAll("\n", "\r\n"); + const nl = crlf ? "\r\n" : "\n"; + const endedWithNewline = body.endsWith("\n"); + const base = body.replace(/\n+$/u, ""); + const trimmed = + endedWithNewline && base.endsWith("\r") ? base.slice(0, -1) : base; + body = + trimmed.length === 0 + ? `${fragment}${nl}` + : `${trimmed}${nl}${nl}${fragment}${nl}`; + } + return bom ? `\uFEFF${body}` : body; +}; + +const grokServerEntry = ( + document: Record, + serverKey: string, +): unknown => { + const servers = objectSchema.safeParse(document.mcp_servers); + if (!servers.success || !Object.hasOwn(servers.data, serverKey)) + return undefined; + return servers.data[serverKey]; +}; + +/** Whether Grok Build's root disable list names `serverKey`. */ +export const grokServerListedDisabled = ( + document: Record, + serverKey: string, +): boolean => + Array.isArray(document.disabled_mcp_servers) && + document.disabled_mcp_servers.includes(serverKey); + +/** Keep an existing Grok document intact aside from the REA server tables. */ +const serializeGrokConfiguration = ( + document: Record, + originalText: string | undefined, +): string => { + const entry = grokServerEntry(document, PRODUCT_IDENTITY.mcpServerKey); + if (originalText === undefined) return stringifyToml(document); + return upsertGrokServerSection( + originalText, + PRODUCT_IDENTITY.mcpServerKey, + entry, + ); +}; + +/** Comments in the trivia between two properties. `undefined` when there are none. */ +const commentsBetweenProperties = (gap: string): string | undefined => { + const pieces: string[] = []; + let index = 0; + while (index < gap.length) { + const line = gap.indexOf("//", index); + const block = gap.indexOf("/*", index); + if (line < 0 && block < 0) break; + const start = line < 0 ? block : block < 0 ? line : Math.min(line, block); + const lineComment = start === line; + const close = lineComment + ? gap.indexOf("\n", start) + : gap.indexOf("*/", start + 2); + if (!lineComment && close < 0) return undefined; + const end = lineComment ? (close < 0 ? gap.length : close) : close + 2; + const comment = gap.slice(start, end); + const lineBreak = gap.lastIndexOf("\n", start - 1); + if (lineBreak < 0) pieces.push(` ${comment}`); + else { + const prefix = gap.slice(lineBreak + 1, start); + let indentEnd = 0; + while (prefix[indentEnd] === " " || prefix[indentEnd] === "\t") + indentEnd += 1; + const newline = + lineBreak > 0 && gap[lineBreak - 1] === "\r" ? "\r\n" : "\n"; + pieces.push(`${newline}${prefix.slice(0, indentEnd)}${comment}`); + } + index = end; + } + return pieces.length === 0 ? undefined : pieces.join(""); +}; + +/** + * Delete an object property without taking a comment from the gap before it. + * jsonc-parser's removal span includes that gap, so a trailing comment on the + * previous property would disappear. Returns `undefined` to keep the stock edit. + */ +const deleteJsonPropertyPreservingComments = ( + text: string, + path: readonly string[], +): string | undefined => { + const root = parseTree(text, [], { allowTrailingComma: true }); + const value = + root === undefined ? undefined : findNodeAtLocation(root, [...path]); + const property = value?.parent; + const parent = property?.parent; + if ( + property === undefined || + parent?.type !== "object" || + property.type !== "property" || + parent.children === undefined + ) + return undefined; + const propertyIndex = parent.children.indexOf(property); + const previous = parent.children[propertyIndex - 1]; + if (propertyIndex <= 0 || previous === undefined) return undefined; + const comments = commentsBetweenProperties( + text.slice(previous.offset + previous.length, property.offset), + ); + if (comments === undefined) return undefined; + let removeEnd = property.offset + property.length; + const hasNext = parent.children[propertyIndex + 1] !== undefined; + if (hasNext) { + let comma = removeEnd; + while (comma < text.length) { + const char = text[comma]; + if (char === " " || char === "\t" || char === "\r" || char === "\n") { + comma += 1; + continue; + } + if (text.startsWith("/*", comma)) { + const close = text.indexOf("*/", comma + 2); + if (close < 0) return undefined; + comma = close + 2; + continue; + } + if (text.startsWith("//", comma)) { + while ( + comma < text.length && + text[comma] !== "\n" && + text[comma] !== "\r" + ) + comma += 1; + continue; + } + break; + } + if (text[comma] !== ",") return undefined; + removeEnd = comma + 1; + } + // A line comment runs to the next break. Keep the following token outside it. + const remainder = text.slice(removeEnd); + let suffix = `${hasNext ? "," : ""}${comments}`; + if ( + suffix.includes("//") && + !suffix.endsWith("\n") && + !suffix.endsWith("\r") && + !remainder.startsWith("\n") && + !remainder.startsWith("\r") + ) + suffix += "\n"; + return `${text.slice(0, previous.offset + previous.length)}${suffix}${remainder}`; +}; + /** Serialize a validated client document, retaining JSONC comments. */ export const serializeClientConfiguration = ( document: Record, @@ -188,17 +1146,22 @@ export const serializeClientConfiguration = ( if (format === undefined || format === "unsupported") throw new TypeError("client does not have a supported MCP config format"); if (format === "toml") return stringifyToml(document); + if (format === "grok") + return serializeGrokConfiguration(document, originalText); if (originalText !== undefined && editedPaths !== undefined) - return editedPaths.reduce( - (text, path) => - applyEdits( - text, - modify(text, [...path], valueAt(document, path), { - formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, - }), - ), - originalText, - ); + return editedPaths.reduce((text, path) => { + const value = valueAt(document, path); + if (value === undefined) { + const preserved = deleteJsonPropertyPreservingComments(text, path); + if (preserved !== undefined) return preserved; + } + return applyEdits( + text, + modify(text, [...path], value, { + formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, + }), + ); + }, originalText); return `${JSON.stringify(document, null, 2)}\n`; }; diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index 91acc42939..8c2194ac8c 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -1,5 +1,6 @@ import { effectiveClientServer, + grokServerListedDisabled, parseClientConfiguration, } from "./ClientConfigurationDocument.js"; import { access, readFile } from "node:fs/promises"; @@ -13,7 +14,10 @@ import { isOwnedClientRegistrationCommand, npxRegistrationCommand, } from "./ClientRegistrationIdentity.js"; -import { supportedClients } from "./SupportedClients.js"; +import { + manualRegistrationRemediation, + supportedClients, +} from "./SupportedClients.js"; import type { SetupClient } from "./SupportedClients.js"; interface ClientRegistrationStatusBase { @@ -37,7 +41,7 @@ export type ClientRegistrationStatus = ClientRegistrationStatusBase & } | { readonly command: readonly []; - readonly state: "missing" | "invalid"; + readonly state: "missing" | "invalid" | "manual"; readonly remediation: string; } ); @@ -80,21 +84,33 @@ export const readClientRegistrationStatuses = async ( CLAUDE_CONFIG_DIR: options.environment.CLAUDE_CONFIG_DIR, CODEX_HOME: options.environment.CODEX_HOME, COPILOT_HOME: options.environment.COPILOT_HOME, + GROK_HOME: options.environment.GROK_HOME, OPENCODE_CONFIG: options.environment.OPENCODE_CONFIG, + SAND_DATA_ROOT: options.environment.SAND_DATA_ROOT, XDG_CONFIG_HOME: options.environment.XDG_CONFIG_HOME, }, )) { if ( - client.format === "unsupported" || - (!(await exists(client.markerPath)) && !(await exists(client.configPath))) + !(await exists(client.markerPath)) && + !(await exists(client.configPath)) ) continue; + const manualRemediation = manualRegistrationRemediation(client.name); + if (client.format === "unsupported") { + if (manualRemediation !== undefined) + statuses.push({ + client: client.name, + config_path: client.markerPath ?? client.configPath, + command: [], + state: "manual", + remediation: manualRemediation, + }); + continue; + } try { const content = await readFile(client.configPath, "utf8"); - const raw = effectiveClientServer( - parseClientConfiguration(content, client.format), - PRODUCT_IDENTITY.mcpServerKey, - ); + const parsed = parseClientConfiguration(content, client.format); + const raw = effectiveClientServer(parsed, PRODUCT_IDENTITY.mcpServerKey); if (raw === undefined) { statuses.push( unavailableStatus(client.name, client.configPath, "missing"), @@ -116,7 +132,14 @@ export const readClientRegistrationStatuses = async ( client, currentCommandPath, options.platform ?? process.platform, - ) + ) && + !( + client.format === "grok" && + grokServerListedDisabled( + parsed.document, + PRODUCT_IDENTITY.mcpServerKey, + ) + ) ? "aligned" : "stale", ), @@ -148,7 +171,7 @@ const registrationAligned = ( if (!isOwnedClientRegistrationCommand(command, currentCommandPath)) return false; if ( - client.name === "codex" && + (client.name === "codex" || client.name === "grok_build") && registration.startup_timeout_sec !== MCP_STARTUP_POLICY.codexStartupTimeoutSeconds ) diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index d6845245a1..5c8dbc10ed 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -286,7 +286,58 @@ describe("doctor scoped readiness", () => { ); expect(staleSelected.healthy).toBe(false); }); +}); +describe("Grok Bot readiness", () => { + it("does not treat a Grok Bot data directory as an aligned registration", async () => { + const registrations: readonly ClientRegistrationStatus[] = [ + { + client: "grok_bot", + config_path: "/home/user/.grokbot", + command: [], + state: "manual", + remediation: "Add the connector from the Grok Bot chat.", + }, + ]; + const audit = await runDoctor( + undefined, + host({ clientRegistrations: () => Promise.resolve(registrations) }), + ); + expect(audit.checks.map(({ name }) => name)).not.toContain( + "registration:grok_bot", + ); + expect(audit.identity?.registrations).toEqual(registrations); + + const scoped = await runDoctor( + undefined, + host({ clientRegistrations: () => Promise.resolve(registrations) }), + { clients: ["grok_bot"] }, + ); + expect(scoped.healthy).toBe(false); + expect(scoped.scope_checks).toContainEqual( + expect.objectContaining({ + name: "registration:grok_bot", + ok: false, + remediation: "Add the connector from the Grok Bot chat.", + }), + ); + + const absent = await runDoctor( + undefined, + host({ clientRegistrations: () => Promise.resolve([]) }), + { clients: ["grok_bot"] }, + ); + expect(absent.scope_checks).toContainEqual( + expect.objectContaining({ + name: "registration:grok_bot", + ok: false, + remediation: expect.stringContaining("hosted computer"), + }), + ); + }); +}); + +describe("doctor scoped readiness", () => { it("keeps agent readiness independent of Hopper host requirements", async () => { const result = await runDoctor( undefined, diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index 078db0d73d..9ff752f3a1 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -215,7 +215,7 @@ const collectDoctorIdentity = async ( ...registrations .filter( (registration): registration is UnhealthyClientRegistrationStatus => - registration.state !== "aligned", + registration.state !== "aligned" && registration.state !== "manual", ) .map(registrationCheck), ], diff --git a/src/application/DoctorScope.ts b/src/application/DoctorScope.ts index bd9af069b9..a92cd2863a 100644 --- a/src/application/DoctorScope.ts +++ b/src/application/DoctorScope.ts @@ -3,6 +3,7 @@ import type { ClientRegistrationStatus, UnhealthyClientRegistrationStatus, } from "./ClientRegistrationStatus.js"; +import { manualRegistrationRemediation } from "./SupportedClients.js"; import type { DoctorCheck, DoctorProviderInspection, @@ -135,8 +136,17 @@ const selectedProviderChecksFor = ( const selectedRegistrationCheck = ( client: string, status: ClientRegistrationStatus | undefined, -): DoctorCheck => - status === undefined +): DoctorCheck => { + const manual = manualRegistrationRemediation(client); + if (status === undefined && manual !== undefined) + return { + name: `registration:${client}`, + ok: false, + classification: "config_drift", + detail: `${client} is not registered through a local configuration file.`, + remediation: manual, + }; + return status === undefined ? { name: `registration:${client}`, ok: false, @@ -152,6 +162,7 @@ const selectedRegistrationCheck = ( detail: status.config_path, } : registrationCheck(status); +}; const registrationCheck = ( status: UnhealthyClientRegistrationStatus, diff --git a/src/application/Setup.planning.test.ts b/src/application/Setup.planning.test.ts index dbcd96f6f3..41b2a71787 100644 --- a/src/application/Setup.planning.test.ts +++ b/src/application/Setup.planning.test.ts @@ -254,4 +254,36 @@ describe("setup workflow lifecycle and provider planning", () => { expect(host.configurations).toBe(0); expect(host.checkedHopperPaths).toEqual(["/manual/Hopper"]); }); + + it("does not write a file when Grok Bot is selected", async () => { + const host = new FakeSetupHost(); + host.doctorHealthy = true; + host.skill = "unchanged"; + host.clients = [ + { + name: "grok_bot", + displayName: "Grok Bot", + configPath: "/home/user/.grokbot", + format: "unsupported", + }, + ]; + + const planned = await runSetup( + { ...options(true), clientIds: ["grok_bot"], dryRun: true }, + host, + ); + expect(planned.status).toBe("planned"); + expect(planned.plannedActions).toEqual([]); + expect(planned.remediation).toContain("hosted computer"); + expect(host.configurations).toBe(0); + + const applied = await runSetup( + { ...options(true), clientIds: ["grok_bot"] }, + host, + ); + expect(applied.status).toBe("needs_human"); + expect(applied.appliedActions).toEqual([]); + expect(applied.remediation).toContain("hosted computer"); + expect(host.configurations).toBe(0); + }); }); diff --git a/src/application/Setup.ts b/src/application/Setup.ts index 9032d79e80..a22484efe3 100644 --- a/src/application/Setup.ts +++ b/src/application/Setup.ts @@ -11,7 +11,10 @@ import type { SetupProviderEnvironment, SetupResult, } from "./SetupTypes.js"; -import type { SetupClient } from "./SupportedClients.js"; +import { + manualRegistrationRemediation, + type SetupClient, +} from "./SupportedClients.js"; import type { DoctorReport, DoctorScope } from "./Doctor.js"; import { configureDetectedClients } from "./SetupClients.js"; import { discoverSetupState, planSetupActions } from "./SetupPlan.js"; @@ -163,6 +166,7 @@ export const runSetup = async ( planSelection, ); + const manualRemediation = manualClientsRemediation(planned.selectedClients); if (options.dryRun === true) return { status: "planned", @@ -171,6 +175,9 @@ export const runSetup = async ( clients, doctor: summarizeDoctor(discovery.initialDoctor), clientStates, + ...(manualRemediation === undefined + ? {} + : { remediation: manualRemediation }), }; let approved = options.approved || plannedActions.length === 0; @@ -250,11 +257,14 @@ export const runSetup = async ( "REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.", ); const doctor = summarizeDoctor(await host.doctor(readinessScope)); - const remediation = finalSetupRemediation( - host.platform, - appliedActions.includes("installed_hopper"), - doctor.healthy, - hopperPath, + const remediation = combineRemediation( + finalSetupRemediation( + host.platform, + appliedActions.includes("installed_hopper"), + doctor.healthy, + hopperPath, + ), + manualRemediation, ); return { status: remediation === undefined ? "ready" : "needs_human", @@ -383,6 +393,22 @@ const setupFailure = async ( const emitProgress = (options: SetupOptions, event: SetupProgressEvent): void => options.onProgress?.(event); +const manualClientsRemediation = ( + clients: readonly SetupClient[], +): string | undefined => + clients + .map((client) => manualRegistrationRemediation(client.name)) + .find((remediation) => remediation !== undefined); + +const combineRemediation = ( + ...parts: readonly (string | undefined)[] +): string | undefined => { + const present = parts.filter( + (part): part is string => part !== undefined && part !== "", + ); + return present.length === 0 ? undefined : present.join(" "); +}; + const finalSetupRemediation = ( platform: NodeJS.Platform, installedHopper: boolean, diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index 7487e46bdc..2d5343a58f 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -2,6 +2,7 @@ import { clientRegistrationEntry, clientConfigurationValuesEqual, clientServerPath, + grokServerListedDisabled, legacyClientServerPath, parseClientConfiguration, serializeClientConfiguration, @@ -58,7 +59,7 @@ const configureClientDocument = async ( let parsed: ClientConfigurationDocument; try { parsed = parseClientConfiguration( - original ?? (format === "toml" ? "" : "{}"), + original ?? (format === "toml" || format === "grok" ? "" : "{}"), format, ); } catch (cause: unknown) { @@ -234,7 +235,7 @@ const restoreConfig = async ( } }; -/** Whether REA's entry matches and no conflicting legacy entry remains. */ +/** Whether REA's entry matches, no legacy entry remains, and Grok is not suppressing it. */ const registrationCurrent = ( parsed: ClientConfigurationDocument, desired: unknown, @@ -242,7 +243,12 @@ const registrationCurrent = ( clientConfigurationValuesEqual( parsed.servers[PRODUCT_IDENTITY.mcpServerKey], desired, - ) && !Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey); + ) && + !Object.hasOwn(parsed.legacyServers, PRODUCT_IDENTITY.mcpServerKey) && + !( + parsed.dialect === "grok" && + grokServerListedDisabled(parsed.document, PRODUCT_IDENTITY.mcpServerKey) + ); const clientConfigurationDesired = ( client: SetupClient, @@ -262,7 +268,7 @@ const clientConfigurationDesired = ( ); return { ...registration, - ...(client.name === "codex" + ...(client.name === "codex" || client.name === "grok_build" ? { startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds, } diff --git a/src/application/SetupHost.ts b/src/application/SetupHost.ts index b4a8a49446..2941642867 100644 --- a/src/application/SetupHost.ts +++ b/src/application/SetupHost.ts @@ -140,9 +140,11 @@ export const systemSetupHost = ( ? Promise.resolve({ status: "skipped" }) : configureClientConfiguration(client, providerEnvironment, command), clientNeedsConfigure: (client, providerEnvironment, command) => - clientConfigurationAligned(client, providerEnvironment, command).then( - (aligned) => !aligned, - ), + client.format === "unsupported" + ? Promise.resolve(false) + : clientConfigurationAligned(client, providerEnvironment, command).then( + (aligned) => !aligned, + ), inspectClientConfiguration: inspectClientConfiguration, skillNeedsInstall: () => canonicalSkillNeedsInstall(homedir()), installSkill: () => installCanonicalSkill(homedir()), diff --git a/src/application/SetupPlan.ts b/src/application/SetupPlan.ts index fcea6271d9..9891cf322b 100644 --- a/src/application/SetupPlan.ts +++ b/src/application/SetupPlan.ts @@ -168,9 +168,11 @@ export const planSetupActions = async (input: { }; } const clientPlans = inspections.flatMap(({ client, inspection }) => - inspection.status === "create" || - inspection.status === "update" || - (input.discovery.installHopper && inspection.status === "already_current") + client.format !== "unsupported" && + (inspection.status === "create" || + inspection.status === "update" || + (input.discovery.installHopper && + inspection.status === "already_current")) ? [ { client, diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 399efb926b..7db4cd4256 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { isAbsolute, join } from "node:path"; import { lstatSync } from "node:fs"; /** One supported client configuration location. */ @@ -14,6 +14,7 @@ export interface SetupClient { | "copilot_cli" | "opencode" | "commandcode" + | "grok" | "unsupported"; } @@ -27,7 +28,9 @@ interface ClientPathContext { readonly CLAUDE_CONFIG_DIR?: string | undefined; readonly CODEX_HOME?: string | undefined; readonly COPILOT_HOME?: string | undefined; + readonly GROK_HOME?: string | undefined; readonly OPENCODE_CONFIG?: string | undefined; + readonly SAND_DATA_ROOT?: string | undefined; readonly XDG_CONFIG_HOME?: string | undefined; }; } @@ -77,6 +80,32 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string => const codexDirectory = ({ home, env }: ClientPathContext): string => env.CODEX_HOME ?? join(home, ".codex"); +const grokDirectory = ({ home, env }: ClientPathContext): string => + env.GROK_HOME ?? join(home, ".grok"); + +/** Grok Bot uses an absolute SAND_DATA_ROOT; anything else stays ~/.grokbot. */ +const grokBotDirectory = ({ home, env }: ClientPathContext): string => { + const root = env.SAND_DATA_ROOT; + return root !== undefined && root !== "" && isAbsolute(root) + ? root + : join(home, ".grokbot"); +}; + +/** + * Grok Bot stores connectors in the signed-in account and runs them on its + * hosted computer. The data directory is only a detection marker. + */ +export const GROK_BOT_MANUAL_REGISTRATION_REMEDIATION = + "Grok Bot keeps connectors in the signed-in account and runs them on its hosted computer. Setup does not write that account store, and a data-directory mcp.json is not a registration. In the Grok Bot chat, add a custom MCP server named rea that runs on the Bot's computer with `npx -y rea-agents@ mcp`. Do not put credentials in the command or arguments. A stdio server on this machine is not attached."; + +/** Remediation for a client whose connector is not a local configuration file. */ +export const manualRegistrationRemediation = ( + clientName: string, +): string | undefined => + clientName === "grok_bot" + ? GROK_BOT_MANUAL_REGISTRATION_REMEDIATION + : undefined; + const copilotDirectory = ({ home, env }: ClientPathContext): string => env.COPILOT_HOME ?? join(home, ".copilot"); @@ -212,6 +241,21 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [ markerPath: vscodeUserDirectory, format: "vscode", }, + { + name: "grok_build", + displayName: "Grok Build", + configPath: (context: ClientPathContext) => + join(grokDirectory(context), "config.toml"), + markerPath: grokDirectory, + format: "grok", + }, + { + name: "grok_bot", + displayName: "Grok Bot", + configPath: grokBotDirectory, + markerPath: grokBotDirectory, + format: "unsupported", + }, ] as const satisfies readonly ClientDefinition[]; const resolvePath = (path: ClientPath, context: ClientPathContext): string => @@ -226,7 +270,9 @@ export const supportedClients = ( CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, CODEX_HOME: process.env.CODEX_HOME, COPILOT_HOME: process.env.COPILOT_HOME, + GROK_HOME: process.env.GROK_HOME, OPENCODE_CONFIG: process.env.OPENCODE_CONFIG, + SAND_DATA_ROOT: process.env.SAND_DATA_ROOT, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, }, ): readonly SetupClient[] => { diff --git a/src/application/Uninstall.ts b/src/application/Uninstall.ts index 6482094886..d0203ae2f5 100644 --- a/src/application/Uninstall.ts +++ b/src/application/Uninstall.ts @@ -16,7 +16,11 @@ import { z } from "zod"; import { PRODUCT_IDENTITY } from "../identity.js"; import { isOwnedClientRegistrationCommand } from "./ClientRegistrationIdentity.js"; import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js"; -import { supportedClients, type SetupClient } from "./SupportedClients.js"; +import { + manualRegistrationRemediation, + supportedClients, + type SetupClient, +} from "./SupportedClients.js"; interface ManagedPathStats { readonly uid?: number; @@ -127,7 +131,8 @@ const removeClient = async ( return item( client.name, "skipped", - "This client has no documented local MCP configuration boundary.", + manualRegistrationRemediation(client.name) ?? + "This client has no documented local MCP configuration boundary.", ); const resolved = await resolveUninstallConfigPath(client, fileSystem); if (typeof resolved !== "string") return resolved; @@ -152,7 +157,7 @@ const removeClient = async ( return item( client.name, "failed", - `Configuration is not valid ${client.format === "toml" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, + `Configuration is not valid ${client.format === "toml" || client.format === "grok" ? "TOML" : client.format === "opencode" ? "JSONC" : "JSON"} and was not changed. Repair it, then rerun uninstall.`, ); } const name = PRODUCT_IDENTITY.mcpServerKey; diff --git a/tests/boundary/filesystem/clientConfigurationDialects.test.ts b/tests/boundary/filesystem/clientConfigurationDialects.test.ts index 32db9c7570..afea88e6f2 100644 --- a/tests/boundary/filesystem/clientConfigurationDialects.test.ts +++ b/tests/boundary/filesystem/clientConfigurationDialects.test.ts @@ -25,7 +25,9 @@ beforeEach(() => { "CLAUDE_CONFIG_DIR", "CODEX_HOME", "COPILOT_HOME", + "GROK_HOME", "OPENCODE_CONFIG", + "SAND_DATA_ROOT", "XDG_CONFIG_HOME", ]) vi.stubEnv(name, undefined); @@ -398,6 +400,40 @@ describe("platform-aware client config paths", () => { }, ); + it("honors Grok Build and Grok Bot configuration roots", () => { + expect( + supportedClients("/home/a", "linux", { + GROK_HOME: "/custom/grok", + SAND_DATA_ROOT: "/custom/grokbot", + }).filter(({ name }) => name === "grok_build" || name === "grok_bot"), + ).toEqual([ + expect.objectContaining({ + name: "grok_build", + displayName: "Grok Build", + configPath: "/custom/grok/config.toml", + markerPath: "/custom/grok", + format: "grok", + }), + expect.objectContaining({ + name: "grok_bot", + displayName: "Grok Bot", + configPath: "/custom/grokbot", + markerPath: "/custom/grokbot", + format: "unsupported", + }), + ]); + expect( + supportedClients("/home/a", "linux", { + SAND_DATA_ROOT: "relative/sand", + }).find(({ name }) => name === "grok_bot")?.configPath, + ).toBe("/home/a/.grokbot"); + expect( + supportedClients("/home/a", "darwin", {}).find( + ({ name }) => name === "grok_build", + )?.configPath, + ).toBe("/home/a/.grok/config.toml"); + }); + it("honors Copilot CLI and Devin platform configuration roots", () => { expect( supportedClients("/home/a", "linux", { diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 1a25e81008..8ea5a99454 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -9,13 +9,19 @@ import { writeFile, } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { parse as parseToml } from "smol-toml"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { parse as parseJsonc, type ParseError } from "jsonc-parser"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { supportedClients } from "../../../src/application/SupportedClients.js"; +import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; -import { detectClients } from "../../../src/application/SetupHost.js"; +import { + detectClients, + systemSetupHost, +} from "../../../src/application/SetupHost.js"; import { runUninstall, systemUninstallHost, @@ -29,7 +35,9 @@ beforeEach(() => { "CLAUDE_CONFIG_DIR", "CODEX_HOME", "COPILOT_HOME", + "GROK_HOME", "OPENCODE_CONFIG", + "SAND_DATA_ROOT", "XDG_CONFIG_HOME", ]) vi.stubEnv(name, undefined); @@ -62,6 +70,8 @@ describe("client configuration filesystem lifecycle", () => { "copilot_cli", "commandcode", "vscode", + "grok_build", + "grok_bot", ]); expect( detected.find(({ name }) => name === "claude_code")?.configPath, @@ -183,6 +193,1123 @@ describe("client configuration filesystem lifecycle", () => { }); }); +describe("Grok Build server table edits", () => { + it("edits only the Grok Build REA tables and removes them on uninstall", async () => { + const home = await createTestTempDirectory("rea-grok-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = [ + "# keep this comment", + 'disabled_mcp_servers = ["xapi"]', + "", + "[cli]", + 'default_model = "grok-4"', + "", + "[mcp_servers.blender]", + 'command = "blender"', + 'args = ["--mcp"]', + "", + "[mcp_servers.rea]", + 'command = "old"', + 'args = ["mcp"]', + "", + "[mcp_servers.rea.env]", + 'OLD = "1"', + "", + "[[marketplace.sources]]", + 'name = "official"', + 'url = "https://example.test/market"', + "", + "[privacy]", + "share = false", + "", + ].join("\r\n"); + await writeFile(configPath, original); + const environment = { + HOPPER_LAUNCHER_PATH: "/Hopper Path", + GHIDRA_INSTALL_DIR: "/opt/ghidra", + JAVA_HOME: "/opt/jdk-21", + }; + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + environment, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("\r\n"); + expect(configured).toContain("# keep this comment"); + expect(configured).toContain('disabled_mcp_servers = ["xapi"]'); + expect(configured).toContain('default_model = "grok-4"'); + expect(configured).toContain("[mcp_servers.blender]"); + expect(configured).toContain('command = "blender"'); + expect(configured).toContain("[[marketplace.sources]]"); + expect(configured).toContain('name = "official"'); + expect(configured).toContain('url = "https://example.test/market"'); + expect(configured).toContain("[privacy]"); + expect(configured).toContain("share = false"); + expect(configured).toContain("[mcp_servers.rea]"); + expect(configured).toContain("startup_timeout_sec = 30"); + expect(configured).not.toContain('OLD = "1"'); + expect(configured).not.toContain('command = "old"'); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + blender: { command: "blender", args: ["--mcp"] }, + rea: { + command: "rea", + args: ["mcp"], + startup_timeout_sec: 30, + env: { + JAVA_HOME: "/opt/jdk-21", + HOPPER_LAUNCHER_PATH: "/Hopper Path", + GHIDRA_INSTALL_DIR: "/opt/ghidra", + }, + }, + }, + marketplace: { sources: [{ name: "official" }] }, + privacy: { share: false }, + }); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + environment, + ["rea", "mcp"], + ), + ).toEqual({ status: "unchanged" }); + expect(await readFile(configPath, "utf8")).toBe(configured); + + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("# keep this comment"); + expect(removed).toContain("[mcp_servers.blender]"); + expect(removed).toContain("[[marketplace.sources]]"); + expect(removed).toContain('url = "https://example.test/market"'); + expect(removed).toContain("[privacy]"); + expect(removed).not.toContain("[mcp_servers.rea]"); + expect(removed).not.toContain("startup_timeout_sec"); + expect(removed).not.toContain("JAVA_HOME"); + }); +}); + +describe("Grok Build disabled server list", () => { + it("removes rea from disabled_mcp_servers and leaves other names", async () => { + const home = await createTestTempDirectory("rea-grok-disabled-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + "disabled_mcp_servers = [", + ' "other",', + ' "r\\u0065a",', + ' "rea", # was off', + ' "github",', + "]", + "[mcp_servers.rea]", + "enabled = false", + 'command = "rea"', + 'args = ["mcp"]', + "startup_timeout_sec = 30", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("# was off"); + expect(configured).not.toContain("enabled"); + expect(parseToml(configured)).toMatchObject({ + disabled_mcp_servers: ["other", "github"], + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toEqual({ status: "unchanged" }); + await writeFile(configPath, 'disabled_mcp_servers = ["rea"] # note\n'); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const created = await readFile(configPath, "utf8"); + expect(created).toContain("# note"); + expect(created).not.toContain("disabled_mcp_servers"); + expect(parseToml(created)).toMatchObject({ + mcp_servers: { rea: { command: "rea", args: ["mcp"] } }, + }); + }); + + it("removes multiline rea entries from disabled_mcp_servers", async () => { + const home = await createTestTempDirectory("rea-grok-disabled-multiline-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + "disabled_mcp_servers = [", + ' """rea""",', + " '''rea''',", + ' """', + 'rea""",', + ' """rea', + '""",', + ' "github",', + "]", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(parseToml(configured)).toMatchObject({ + disabled_mcp_servers: ["rea\n", "github"], + mcp_servers: { rea: { command: "rea", args: ["mcp"] } }, + }); + await writeFile(configPath, 'disabled_mcp_servers = ["""rea"""] # note\n'); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const created = await readFile(configPath, "utf8"); + expect(created).toContain("# note"); + expect(created).not.toContain("disabled_mcp_servers"); + }); +}); + +describe("Grok Build text that resembles a server table", () => { + it("preserves Grok text that only looks like an REA table", async () => { + const home = await createTestTempDirectory("rea-grok-preserve-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = [ + "banner = '''", + "welcome", + "[mcp_servers.rea]", + "keep this text", + "[privacy]", + "'''", + "", + "[mcp_servers.rea]", + 'command = "old"', + 'args = ["mcp"]', + "", + "# Production server: preserve this configuration", + '[mcp_servers."rea.env"]', + 'command = "other"', + 'args = ["serve"]', + "", + '[mcp_servers."other[prod]"]', + 'command = "other"', + 'args = ["serve"]', + "", + '[[marketplace."sources[prod]"]]', + 'name = "official"', + "", + ].join("\n"); + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("keep this text"); + expect(configured).toContain( + "# Production server: preserve this configuration", + ); + expect(configured).toContain('[mcp_servers."rea.env"]'); + expect(configured).toContain('[mcp_servers."other[prod]"]'); + expect(configured).toContain('[[marketplace."sources[prod]"]]'); + expect(configured).not.toContain('command = "old"'); + expect(parseToml(configured)).toMatchObject({ + banner: "welcome\n[mcp_servers.rea]\nkeep this text\n[privacy]\n", + mcp_servers: { + "rea.env": { command: "other", args: ["serve"] }, + "other[prod]": { command: "other", args: ["serve"] }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + marketplace: { "sources[prod]": [{ name: "official" }] }, + }); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toEqual({ status: "unchanged" }); + + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("keep this text"); + expect(removed).toContain( + "# Production server: preserve this configuration", + ); + expect(removed).toContain('[mcp_servers."other[prod]"]'); + expect(parseToml(removed)).toMatchObject({ + mcp_servers: { + "rea.env": { command: "other" }, + "other[prod]": { command: "other" }, + }, + }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + }); + + it("replaces spaced and escaped Grok REA headers without duplicating them", async () => { + const home = await createTestTempDirectory("rea-grok-alias-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + "[ mcp_servers . rea ]", + 'command = "old"', + 'args = ["mcp"]', + "", + '[mcp_servers."\\u0072ea".env]', + 'OLD = "1"', + "", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).not.toContain("mcp_servers . rea"); + expect(configured).not.toContain("\\u0072ea"); + expect(configured).not.toContain('OLD = "1"'); + expect( + configured + .split("\n") + .filter((line) => line.trim() === "[mcp_servers.rea]"), + ).toHaveLength(1); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); +}); + +describe("Grok Build multiline and escaped headers", () => { + it("preserves a multiline string opened on the line that closed the previous one", async () => { + const home = await createTestTempDirectory("rea-grok-multiline-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + 'banner = ["""', + "first", + '""", """', + "[mcp_servers.rea]", + 'command = "keep this text"', + "[privacy]", + "keep this too", + '"""]', + "", + "marker = ['''", + "first", + "''', '''", + "[mcp_servers.rea]", + 'command = "keep literal"', + "''']", + "", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain('command = "keep this text"'); + expect(configured).toContain("keep this too"); + expect(configured).toContain('command = "keep literal"'); + expect(parseToml(configured)).toMatchObject({ + banner: [ + "first\n", + '[mcp_servers.rea]\ncommand = "keep this text"\n[privacy]\nkeep this too\n', + ], + marker: ["first\n", '[mcp_servers.rea]\ncommand = "keep literal"\n'], + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toEqual({ status: "unchanged" }); + }); + + it("replaces a hex-escaped Grok REA header and keeps a hex-escaped foreign name", async () => { + const home = await createTestTempDirectory("rea-grok-hex-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = [ + '[mcp_servers."\\x72ea"]', + 'command = "rea"', + 'args = ["mcp"]', + "", + '[mcp_servers."rea\\x2Eenv"]', + 'command = "other"', + 'args = ["serve"]', + "", + ].join("\n"); + await writeFile(configPath, original); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain('[mcp_servers."rea\\x2Eenv"]'); + expect(removed).not.toContain("\\x72"); + expect(parseToml(removed)).toMatchObject({ + mcp_servers: { "rea.env": { command: "other", args: ["serve"] } }, + }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).not.toContain("\\x72"); + expect(configured).toContain('[mcp_servers."rea\\x2Eenv"]'); + expect( + configured + .split("\n") + .filter((line) => line.trim() === "[mcp_servers.rea]"), + ).toHaveLength(1); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + "rea.env": { command: "other", args: ["serve"] }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); +}); + +describe("Grok Build inline and dotted assignments", () => { + it("updates and removes inline and dotted Grok REA assignments", async () => { + const home = await createTestTempDirectory("rea-grok-assign-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const inline = [ + "[mcp_servers]", + 'rea = """', + "[mcp_servers.rea]", + 'command = "keep? no, this is the old value"', + '"""', + 'other = { command = "other" }', + "", + "# next table", + "[privacy]", + "share = false", + "", + ].join("\n"); + await writeFile(configPath, inline); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configuredInline = await readFile(configPath, "utf8"); + expect(configuredInline).not.toContain("keep? no, this is the old value"); + expect(configuredInline).toContain('other = { command = "other" }'); + expect(configuredInline).toContain("# next table"); + expect(configuredInline).toContain("[privacy]"); + expect(parseToml(configuredInline)).toMatchObject({ + mcp_servers: { + other: { command: "other" }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + privacy: { share: false }, + }); + expect(configuredInline).not.toContain("enabled"); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toEqual({ status: "unchanged" }); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removedInline = await readFile(configPath, "utf8"); + expect(removedInline).toContain('other = { command = "other" }'); + expect(removedInline).toContain("# next table"); + expect(parseToml(removedInline)).not.toHaveProperty("mcp_servers.rea"); + + const dotted = [ + 'mcp_servers.rea = { command = "rea", args = ["mcp"] }', + 'mcp_servers.other.command = "other"', + "", + "# next table", + "[privacy]", + "share = false", + "", + ].join("\n"); + await writeFile(configPath, dotted); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removedDotted = await readFile(configPath, "utf8"); + expect(removedDotted).toContain('mcp_servers.other.command = "other"'); + expect(removedDotted).toContain("# next table"); + expect(parseToml(removedDotted)).toMatchObject({ + mcp_servers: { other: { command: "other" } }, + privacy: { share: false }, + }); + expect(parseToml(removedDotted)).not.toHaveProperty("mcp_servers.rea"); + + await writeFile( + configPath, + [ + "[mcp_servers]", + 'rea.command = "old"', + 'rea.args = ["old"]', + 'other.command = "other"', + "", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configuredDotted = await readFile(configPath, "utf8"); + expect(configuredDotted).toContain('other.command = "other"'); + expect(configuredDotted).not.toContain('rea.command = "old"'); + expect(parseToml(configuredDotted)).toMatchObject({ + mcp_servers: { + other: { command: "other" }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); +}); + +describe("Grok Build closed inline tables", () => { + it("splices REA into a closed mcp_servers inline table", async () => { + const home = await createTestTempDirectory("rea-grok-inline-table-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = [ + "# preamble", + 'mcp_servers = { other = { command = "other" }, "\\x72ea" = { command = "old", args = ["old"] } }', + "", + "# next table", + "[privacy]", + "share = false", + "", + ].join("\n"); + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("# preamble"); + expect(configured).toContain("# next table"); + expect(configured).toContain("[privacy]"); + expect(configured).toContain("mcp_servers ="); + expect(configured).not.toMatch(/^\[mcp_servers\.rea\]$/mu); + expect(configured).not.toContain("\\x72"); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + other: { command: "other" }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + privacy: { share: false }, + }); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toEqual({ status: "unchanged" }); + + await writeFile( + configPath, + [ + 'mcp_servers = { other = { command = "other" }, rea = { command = "rea", args = ["mcp"] } }', + "", + ].join("\n"), + ); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain('other = { command = "other" }'); + expect(removed).toContain("mcp_servers ="); + expect(parseToml(removed)).toMatchObject({ + mcp_servers: { other: { command: "other" } }, + }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + + await writeFile(configPath, "mcp_servers = { }\n"); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const created = await readFile(configPath, "utf8"); + expect(created).toContain("mcp_servers ="); + expect(created).not.toMatch(/^\[mcp_servers\.rea\]$/mu); + expect(parseToml(created)).toMatchObject({ + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); + + it("keeps a quote that belongs to a multiline string closer", async () => { + const home = await createTestTempDirectory("rea-grok-quotes-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + 'banner = ["""keep""""]', + "marker = ['''keep'''']", + "[mcp_servers.rea]", + 'command = "rea"', + 'args = ["mcp"]', + "", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(parseToml(configured)).toMatchObject({ + banner: ['keep"'], + marker: ["keep'"], + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + expect( + configured + .split("\n") + .filter((line) => line.trim() === "[mcp_servers.rea]"), + ).toHaveLength(1); + }); +}); + +describe("Grok Build scalar and nested values", () => { + it("preserves a basic string that uses the escape smol-toml accepts", async () => { + const home = await createTestTempDirectory("rea-grok-escape-e-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + [ + 'banner = "\\e[31m"', + "[mcp_servers.rea]", + 'command = "rea"', + 'args = ["mcp"]', + "", + ].join("\n"), + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain('banner = "\\e[31m"'); + expect(parseToml(configured)).toMatchObject({ + banner: "\u001b[31m", + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); + + it("updates a closed inline table whose last value is a scalar", async () => { + const home = await createTestTempDirectory("rea-grok-scalar-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + 'mcp_servers = { rea.command = "rea", rea.args = ["mcp"], rea.startup_timeout_sec = 10 }\n', + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).not.toMatch(/^\[mcp_servers\.rea\]$/mu); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); + + it("removes a nested array inside a Grok REA table", async () => { + const home = await createTestTempDirectory("rea-grok-nested-array-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = [ + "[mcp_servers.rea]", + 'command = "rea"', + 'args = ["mcp"]', + "extra = [", + " [1, 2],", + "]", + "", + "[privacy]", + "share = false", + "", + ].join("\n"); + await writeFile(configPath, original); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("[privacy]"); + expect(removed).not.toContain("[1, 2]"); + expect(parseToml(removed)).toMatchObject({ privacy: { share: false } }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("[privacy]"); + expect(configured).not.toContain("[1, 2]"); + expect(parseToml(configured)).toMatchObject({ + privacy: { share: false }, + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); +}); + +describe("Grok Build newlines and leading marks", () => { + it("edits a BOM-prefixed Grok document without losing the first table", async () => { + const home = await createTestTempDirectory("rea-grok-bom-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + '\uFEFF[profile]\nmcp_servers.rea = "keep this setting"\n', + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured.startsWith("\uFEFF")).toBe(true); + expect(configured).toContain('mcp_servers.rea = "keep this setting"'); + expect(parseToml(configured)).toMatchObject({ + profile: { mcp_servers: { rea: "keep this setting" } }, + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + + await writeFile( + configPath, + '\uFEFF[mcp_servers.rea]\ncommand = "rea"\nargs = ["mcp"]\n', + ); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed.startsWith("\uFEFF")).toBe(true); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + }); + + it("keeps CRLF string values in a Grok file that also uses LF", async () => { + const home = await createTestTempDirectory("rea-grok-crlf-string-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = + 'banner = """a\r\nb"""\n[mcp_servers.rea]\ncommand = "rea"\nargs = ["mcp"]\n'; + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + expect(parseToml(await readFile(configPath, "utf8"))).toMatchObject({ + banner: "a\r\nb", + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + + await writeFile(configPath, original); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(parseToml(removed)).toMatchObject({ banner: "a\r\nb" }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + }); + + it("keeps LF string values in a Grok file that also uses CRLF", async () => { + const home = await createTestTempDirectory("rea-grok-mixed-newline-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + 'banner = """keep\nthis LF"""\n[mcp_servers.rea]\r\ncommand = "rea"\r\nargs = ["mcp"]\r\n', + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + expect(parseToml(await readFile(configPath, "utf8"))).toMatchObject({ + banner: "keep\nthis LF", + mcp_servers: { + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + }); +}); + +describe("Grok Build inline comments", () => { + it("keeps comments on dotted Grok fields without repeating a comma", async () => { + const home = await createTestTempDirectory("rea-grok-dotted-comment-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = [ + 'mcp_servers = { other = {command="other"}, # keep other note', + ' rea.command = "rea", # command note', + ' rea.args = ["mcp"] }', + "", + ].join("\n"); + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("# keep other note"); + expect(configured).toContain("# command note"); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + other: { command: "other" }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + + await writeFile(configPath, original); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("# keep other note"); + expect(removed).toContain("# command note"); + expect(parseToml(removed)).toMatchObject({ + mcp_servers: { other: { command: "other" } }, + }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + }); + + it("keeps an inline comment that sits before the Grok REA pair", async () => { + const home = await createTestTempDirectory("rea-grok-inline-before-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + const original = + 'mcp_servers = { other = {command="other"}, # important other-server note\n rea = {command="rea",args=["mcp"]} }\n'; + await writeFile(configPath, original); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + undefined, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("# important other-server note"); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + other: { command: "other" }, + rea: { command: "rea", args: ["mcp"], startup_timeout_sec: 30 }, + }, + }); + + await writeFile(configPath, original); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("# important other-server note"); + expect(parseToml(removed)).toMatchObject({ + mcp_servers: { other: { command: "other" } }, + }); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + }); + + it("keeps inline-table comments and encodes a DEL in an inline value", async () => { + const home = await createTestTempDirectory("rea-grok-inline-comment-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + 'mcp_servers = { rea = {command="rea",args=["mcp"]}, # other: one, two\n other = {command="other"} }\n', + ); + expect( + await configureClientConfiguration( + { name: "grok_build", configPath, format: "grok" }, + { HOPPER_LAUNCHER_PATH: "/tmp/path\u007fwith-del" }, + ["rea", "mcp"], + ), + ).toMatchObject({ status: "configured" }); + const configured = await readFile(configPath, "utf8"); + expect(configured).toContain("# other: one, two"); + expect(parseToml(configured)).toMatchObject({ + mcp_servers: { + other: { command: "other" }, + rea: { + command: "rea", + args: ["mcp"], + startup_timeout_sec: 30, + env: { HOPPER_LAUNCHER_PATH: "/tmp/path\u007fwith-del" }, + }, + }, + }); + }); + + it("keeps comments when uninstall removes the only inline Grok server", async () => { + const home = await createTestTempDirectory("rea-grok-inline-empty-"); + roots.push(home); + const configPath = join(home, ".grok/config.toml"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + 'mcp_servers = { # policy\n rea = { command = "rea", args = ["mcp"] } # owned\n}\n', + ); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("# policy"); + expect(removed).toContain("# owned"); + expect(parseToml(removed)).not.toHaveProperty("mcp_servers.rea"); + }); +}); + +describe("JSON client configuration comments", () => { + it("keeps a same-line JSONC comment when uninstall removes Cursor", async () => { + const home = await createTestTempDirectory("rea-cursor-jsonc-comment-"); + roots.push(home); + const configPath = join(home, ".cursor/mcp.json"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + '{\n "mcpServers": {\n "other": {"command":"other"}, // important other-server note\n "rea": {"command":"rea","args":["mcp"]}\n }\n}\n', + ); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("// important other-server note"); + const errors: ParseError[] = []; + const document = parseJsonc(removed, errors, { allowTrailingComma: true }); + expect(errors).toEqual([]); + expect(document).toEqual({ + mcpServers: { other: { command: "other" } }, + }); + }); + + it("keeps a JSONC comment when a block comment precedes the comma", async () => { + const home = await createTestTempDirectory("rea-cursor-jsonc-block-"); + roots.push(home); + const configPath = join(home, ".cursor/mcp.json"); + await mkdir(dirname(configPath), { recursive: true }); + await writeFile( + configPath, + '{"mcpServers":{\n "other":{"command":"other"}, // keep other note\n "rea":{"command":"rea","args":["mcp"]} /**/,\n "keep":{"command":"keep"}\n}}\n', + ); + expect((await runUninstall(false, systemUninstallHost(home))).status).toBe( + "complete", + ); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("// keep other note"); + const errors: ParseError[] = []; + const document = parseJsonc(removed, errors, { allowTrailingComma: true }); + expect(errors).toEqual([]); + expect(document).toEqual({ + mcpServers: { + other: { command: "other" }, + keep: { command: "keep" }, + }, + }); + }); + + it("keeps a JSONC comment without swallowing the next server or closers", async () => { + const home = await createTestTempDirectory("rea-cursor-jsonc-next-"); + roots.push(home); + const configPath = join(home, ".cursor/mcp.json"); + await mkdir(dirname(configPath), { recursive: true }); + const files = [ + '{\n "mcpServers": {\n "other": {"command":"other"}, // important other-server note\n "rea": {"command":"rea","args":["mcp"]}, "keep": {"command":"keep"}\n }\n}\n', + '{\n "mcpServers": {\n "other": {"command":"other"}, // important other-server note\n "rea": {"command":"rea","args":["mcp"]}}}\n', + ]; + for (const original of files) { + await writeFile(configPath, original); + expect( + (await runUninstall(false, systemUninstallHost(home))).status, + ).toBe("complete"); + const removed = await readFile(configPath, "utf8"); + expect(removed).toContain("// important other-server note"); + const errors: ParseError[] = []; + const document = parseJsonc(removed, errors, { + allowTrailingComma: true, + }); + expect(errors).toEqual([]); + expect(document).toMatchObject({ + mcpServers: { other: { command: "other" } }, + }); + if (original.includes('"keep"')) + expect(document).toMatchObject({ + mcpServers: { keep: { command: "keep" } }, + }); + } + }); +}); + +describe("Grok Bot registration", () => { + it("does not write or align a data-directory mcp.json", async () => { + const home = await createTestTempDirectory("rea-grokbot-"); + roots.push(home); + const directory = join(home, ".grokbot"); + const configPath = join(directory, "mcp.json"); + await mkdir(directory, { recursive: true }); + const original = + '{"mcpServers":{"other":{"command":"other"},"rea":{"command":"rea","args":["mcp"]}}}\n'; + await writeFile(configPath, original); + const client = supportedClients(home).find( + ({ name }) => name === "grok_bot", + ); + if (client === undefined) throw new Error("missing grok_bot"); + expect(client.format).toBe("unsupported"); + expect(client.configPath).toBe(directory); + expect( + await systemSetupHost().configureClient(client, {}, ["rea", "mcp"]), + ).toEqual({ status: "skipped" }); + expect(await readFile(configPath, "utf8")).toBe(original); + expect( + await readClientRegistrationStatuses(home, "/current/rea", { + environment: {}, + }), + ).toEqual([ + expect.objectContaining({ + client: "grok_bot", + config_path: directory, + state: "manual", + command: [], + }), + ]); + const removed = await runUninstall(false, systemUninstallHost(home)); + expect(removed.status).toBe("complete"); + expect(removed.items).toContainEqual( + expect.objectContaining({ name: "grok_bot", status: "skipped" }), + ); + expect(await readFile(configPath, "utf8")).toBe(original); + }); +}); + describe("client configuration filesystem removal", () => { it("uninstalls only owned entries and refuses purge symlinks", async () => { const home = await createTestTempDirectory("rea-uninstall-"); diff --git a/tests/boundary/filesystem/clientRegistrationStatus.test.ts b/tests/boundary/filesystem/clientRegistrationStatus.test.ts index ea9d046432..05279dae3c 100644 --- a/tests/boundary/filesystem/clientRegistrationStatus.test.ts +++ b/tests/boundary/filesystem/clientRegistrationStatus.test.ts @@ -1,13 +1,30 @@ import { mkdir, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; +beforeEach(() => { + for (const name of [ + "APPDATA", + "CLAUDE_CONFIG_DIR", + "CODEX_HOME", + "COPILOT_HOME", + "GROK_HOME", + "OPENCODE_CONFIG", + "SAND_DATA_ROOT", + "XDG_CONFIG_HOME", + ]) + vi.stubEnv(name, undefined); +}); +afterEach(() => { + vi.unstubAllEnvs(); +}); + describe("client registration status", () => { it("discovers Claude Code from its config file without a marker directory", async () => { const home = await createTestTempDirectory("rea-claude-config-only-"); @@ -206,6 +223,43 @@ describe("Node-wrapped registration policy", () => { }, ); + it.each([1, 30])( + "checks Grok Build startup timeout %d independently of launcher", + async (timeout) => { + const home = await createTestTempDirectory("rea-grok-registration-"); + await mkdir(join(home, ".grok")); + const entry = resolve("scripts/rea.mjs"); + await writeFile( + join(home, ".grok/config.toml"), + `[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entry)}, "mcp"]\nstartup_timeout_sec = ${String(timeout)}\n`, + ); + const statuses = await readClientRegistrationStatuses(home, entry, { + environment: {}, + }); + expect(statuses).toEqual([ + expect.objectContaining({ + client: "grok_build", + state: timeout === 30 ? "aligned" : "stale", + }), + ]); + }, + ); + + it("reports Grok Build stale when rea is listed in disabled_mcp_servers", async () => { + const home = await createTestTempDirectory("rea-grok-disabled-status-"); + await mkdir(join(home, ".grok")); + const entry = resolve("scripts/rea.mjs"); + await writeFile( + join(home, ".grok/config.toml"), + `disabled_mcp_servers = ["rea"]\n[mcp_servers.rea]\ncommand = ${JSON.stringify(process.execPath)}\nargs = [${JSON.stringify(entry)}, "mcp"]\nstartup_timeout_sec = 30\n`, + ); + expect( + await readClientRegistrationStatuses(home, entry, { environment: {} }), + ).toEqual([ + expect.objectContaining({ client: "grok_build", state: "stale" }), + ]); + }); + it("checks a direct-launcher Codex startup timeout without Node wrapping", async () => { const home = await createTestTempDirectory("rea-direct-registration-"); await mkdir(join(home, ".codex")); diff --git a/tests/process-global/updateMaintenance.test.ts b/tests/process-global/updateMaintenance.test.ts index a7c66dacd0..83a6c8929b 100644 --- a/tests/process-global/updateMaintenance.test.ts +++ b/tests/process-global/updateMaintenance.test.ts @@ -27,6 +27,8 @@ beforeEach(async () => { CLAUDE_CONFIG_DIR: home, CODEX_HOME: join(home, ".codex"), COPILOT_HOME: join(home, ".copilot"), + GROK_HOME: join(home, ".grok"), + SAND_DATA_ROOT: join(home, ".grokbot"), XDG_CONFIG_HOME: join(home, ".config"), OPENCODE_CONFIG: join(home, ".config", "opencode", "opencode.jsonc"), })) diff --git a/website/public/faq/index.html b/website/public/faq/index.html index 9ef107adc3..174f74c6ea 100644 --- a/website/public/faq/index.html +++ b/website/public/faq/index.html @@ -107,7 +107,8 @@ Any agent that supports local MCP servers. Setup includes Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin, OpenCode, Antigravity, GitHub Copilot CLI, - Command Code and VS Code. + Command Code, Grok Build, and VS Code. Grok Bot's connector is + added from its chat.

Connect your coding agent. diff --git a/website/public/get-started/index.html b/website/public/get-started/index.html index 25b3e57c60..a9ba773f06 100644 --- a/website/public/get-started/index.html +++ b/website/public/get-started/index.html @@ -75,8 +75,9 @@

Connect your coding agent

Run setup to connect REA to your coding agent. Client choices - include Claude Code, Codex, Cursor, Gemini CLI, Windsurf and other - supported MCP clients. + include Claude Code, Codex, Cursor, Gemini CLI, Windsurf, Grok + Build, and other supported MCP clients. Grok Bot's connector is + added from its chat.