From 3c6041ac5dea013947ebf7b4283be672a4556740 Mon Sep 17 00:00:00 2001 From: Leihan Chen Date: Thu, 16 Jul 2026 21:44:48 -0400 Subject: [PATCH] feat(installer): add Grok Build target Add Grok Build (xAI) as a supported installer target so codegraph install auto-wires the CodeGraph MCP server into Grok's config. - New src/installer/targets/grok.ts implementing AgentTarget for Grok - Config: ~/.grok/config.toml (global) or .grok/config.toml (local) - MCP format: [mcp_servers.codegraph] TOML table (same as Codex) - Supports both global and project-scoped installs - Respects GROK_HOME env var override - Writes marker-fenced CodeGraph block to ~/.grok/AGENTS.md (global) - No permissions concept (Grok gates through its own UI) - Idempotent: byte-equal re-runs return action='unchanged' - Uninstall preserves sibling MCP servers and other TOML sections - Register grokTarget in registry.ts, add 'grok' to TargetId union - 13 new tests in installer-targets.test.ts covering install, sibling preservation, top-level section preservation, idempotent re-run, uninstall, local install, detect transitions, and GROK_HOME override - Update README.md with Grok Build in badges and agent lists --- README.md | 12 +- __tests__/installer-targets.test.ts | 209 ++++++++++++++++++++++++++++ src/installer/targets/grok.ts | 199 ++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 5 files changed, 418 insertions(+), 6 deletions(-) create mode 100644 src/installer/targets/grok.ts diff --git a/README.md b/README.md index dbaa2ad69..3c64c607f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Already installed? Run `codegraph upgrade` Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and Grok Build with Semantic Code Intelligence **Surgical context · fewer tool calls · faster answers · 100% local** @@ -32,6 +32,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents) [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) +[![Grok Build](https://img.shields.io/badge/Grok_Build-supported-blueviolet.svg)](#supported-agents)
@@ -102,7 +103,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you codegraph install ``` -Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) +Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and Grok Build — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) ### 3. Initialize each project @@ -415,7 +416,7 @@ npx @colbymchenry/codegraph ``` The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **Grok Build** - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one - Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. @@ -442,7 +443,7 @@ codegraph install --print-config codex # print snippet, no file wr ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / Grok Build) for the MCP server to load. ### 3. Initialize Projects @@ -800,6 +801,7 @@ is written): - **Gemini CLI** - **Antigravity IDE** - **Kiro** +- **Grok Build** ## Supported Languages @@ -908,7 +910,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro** +**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and Grok Build** [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index e11efd3cc..891b95d1d 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -1187,6 +1187,7 @@ describe('Installer targets — registry', () => { expect(getTarget('gemini')?.id).toBe('gemini'); expect(getTarget('antigravity')?.id).toBe('antigravity'); expect(getTarget('kiro')?.id).toBe('kiro'); + expect(getTarget('grok')?.id).toBe('grok'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); @@ -1571,6 +1572,214 @@ function listAllFiles(dir: string): string[] { // SAME directory (which is exactly how this bug stayed invisible), so these // tests deliberately split them. // --------------------------------------------------------------------------- +describe('Installer targets — Grok Build', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('grok: install writes [mcp_servers.codegraph] to ~/.grok/config.toml AND the AGENTS.md codegraph block', () => { + const grok = getTarget('grok')!; + const first = grok.install('global', { autoAllow: false }); + const tomlPath = path.join(tmpHome, '.grok', 'config.toml'); + const agentsMd = path.join(tmpHome, '.grok', 'AGENTS.md'); + expect(first.files.some((f) => f.path.endsWith('config.toml'))).toBe(true); + expect(fs.existsSync(tomlPath)).toBe(true); + const toml = fs.readFileSync(tomlPath, 'utf-8'); + expect(toml).toContain('[mcp_servers.codegraph]'); + expect(toml).toContain('command = "codegraph"'); + expect(toml).toContain('args = ["serve", "--mcp"]'); + // The short instructions block IS written (subagents / non-MCP + // harnesses read AGENTS.md but never the MCP initialize instructions). + expect(fs.existsSync(agentsMd)).toBe(true); + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('## CodeGraph'); + expect(body).toContain('codegraph explore'); + // Re-install is fully unchanged (byte-equal block → idempotent). + const second = grok.install('global', { autoAllow: false }); + for (const f of second.files) expect(f.action).toBe('unchanged'); + }); + + it('grok: install preserves a pre-existing sibling MCP server in config.toml', () => { + const grok = getTarget('grok')!; + const dir = path.join(tmpHome, '.grok'); + fs.mkdirSync(dir, { recursive: true }); + const tomlPath = path.join(dir, 'config.toml'); + fs.writeFileSync(tomlPath, [ + '[mcp_servers.github]', + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + '', + ].join('\n')); + + grok.install('global', { autoAllow: false }); + + const after = fs.readFileSync(tomlPath, 'utf-8'); + expect(after).toContain('[mcp_servers.github]'); + expect(after).toContain('server-github'); + expect(after).toContain('[mcp_servers.codegraph]'); + }); + + it('grok: install preserves other top-level TOML sections (cli, models, features)', () => { + const grok = getTarget('grok')!; + const dir = path.join(tmpHome, '.grok'); + fs.mkdirSync(dir, { recursive: true }); + const tomlPath = path.join(dir, 'config.toml'); + fs.writeFileSync(tomlPath, [ + '[cli]', + 'auto_update = true', + '', + '[models]', + 'default = "grok-build"', + '', + ].join('\n')); + + grok.install('global', { autoAllow: false }); + + const after = fs.readFileSync(tomlPath, 'utf-8'); + expect(after).toContain('auto_update = true'); + expect(after).toContain('default = "grok-build"'); + expect(after).toContain('[mcp_servers.codegraph]'); + }); + + it('grok: install replaces a legacy AGENTS.md codegraph block with the current one, keeping user content', () => { + const grok = getTarget('grok')!; + const dir = path.join(tmpHome, '.grok'); + fs.mkdirSync(dir, { recursive: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `# My grok notes\n\nBe terse.\n\n${LEGACY_BLOCK}\n`); + + const result = grok.install('global', { autoAllow: false }); + + const body = fs.readFileSync(agentsMd, 'utf-8'); + expect(body).toContain('# My grok notes'); + expect(body).toContain('Be terse.'); + // Self-heal: the stale pre-#529 body is gone, the current block is in. + expect(body).not.toContain('Prefer `codegraph_search`'); + expect(body).toContain('codegraph explore'); + const mdEntry = result.files.find((f) => f.path.endsWith('AGENTS.md')); + expect(mdEntry?.action).toBe('updated'); + }); + + it('grok: uninstall strips [mcp_servers.codegraph] but leaves siblings intact', () => { + const grok = getTarget('grok')!; + const dir = path.join(tmpHome, '.grok'); + fs.mkdirSync(dir, { recursive: true }); + const tomlPath = path.join(dir, 'config.toml'); + // Pre-seed with a sibling MCP server. + fs.writeFileSync(tomlPath, [ + '[mcp_servers.github]', + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + '', + '[mcp_servers.codegraph]', + 'command = "codegraph"', + 'args = ["serve", "--mcp"]', + '', + ].join('\n')); + + grok.uninstall('global'); + + const after = fs.readFileSync(tomlPath, 'utf-8'); + expect(after).not.toContain('[mcp_servers.codegraph]'); + expect(after).toContain('[mcp_servers.github]'); + }); + + it('grok: local install writes ./.grok/config.toml (project scope)', () => { + const grok = getTarget('grok')!; + const result = grok.install('local', { autoAllow: false }); + const tomlPath = path.join(tmpCwd, '.grok', 'config.toml'); + // macOS symlinks /var → /private/var; fs.realpathSync normalizes both. + expect(result.files.some((f) => fs.realpathSync(f.path) === fs.realpathSync(tomlPath))).toBe(true); + expect(fs.existsSync(tomlPath)).toBe(true); + const toml = fs.readFileSync(tomlPath, 'utf-8'); + expect(toml).toContain('[mcp_servers.codegraph]'); + // Local install does NOT write AGENTS.md (that's global only). + expect(result.files.some((f) => f.path.endsWith('AGENTS.md'))).toBe(false); + }); + + it('grok: local uninstall removes config.toml when it becomes empty', () => { + const grok = getTarget('grok')!; + grok.install('local', { autoAllow: false }); + const tomlPath = path.join(tmpCwd, '.grok', 'config.toml'); + expect(fs.existsSync(tomlPath)).toBe(true); + + grok.uninstall('local'); + + // The only entry was codegraph, so the file is removed entirely. + expect(fs.existsSync(tomlPath)).toBe(false); + // The .grok/ dir should also be cleaned up. + expect(fs.existsSync(path.join(tmpCwd, '.grok'))).toBe(false); + }); + + it('grok: local uninstall keeps config.toml when sibling servers exist', () => { + const grok = getTarget('grok')!; + grok.install('local', { autoAllow: false }); + const tomlPath = path.join(tmpCwd, '.grok', 'config.toml'); + // Add a sibling server. + const content = fs.readFileSync(tomlPath, 'utf-8'); + const withSibling = content.replace( + /\n*$/, + '\n\n[mcp_servers.github]\ncommand = "npx"\nargs = ["-y", "@modelcontextprotocol/server-github"]\n', + ); + fs.writeFileSync(tomlPath, withSibling); + + grok.uninstall('local'); + + expect(fs.existsSync(tomlPath)).toBe(true); + const after = fs.readFileSync(tomlPath, 'utf-8'); + expect(after).not.toContain('[mcp_servers.codegraph]'); + expect(after).toContain('[mcp_servers.github]'); + }); + + it('grok: detect before install shows alreadyConfigured=false', () => { + const grok = getTarget('grok')!; + expect(grok.detect('global').alreadyConfigured).toBe(false); + expect(grok.detect('local').alreadyConfigured).toBe(false); + }); + + it('grok: detect after install shows alreadyConfigured=true', () => { + const grok = getTarget('grok')!; + grok.install('global', { autoAllow: false }); + expect(grok.detect('global').alreadyConfigured).toBe(true); + // Local was not installed, so it should still be false. + expect(grok.detect('local').alreadyConfigured).toBe(false); + }); + + it('grok: GROK_HOME env var overrides default config dir', () => { + const grok = getTarget('grok')!; + const customHome = mkTmpDir('grok-home'); + process.env.GROK_HOME = customHome; + try { + grok.install('global', { autoAllow: false }); + const tomlPath = path.join(customHome, 'config.toml'); + expect(fs.existsSync(tomlPath)).toBe(true); + const toml = fs.readFileSync(tomlPath, 'utf-8'); + expect(toml).toContain('[mcp_servers.codegraph]'); + // Default location should NOT have been written. + expect(fs.existsSync(path.join(tmpHome, '.grok', 'config.toml'))).toBe(false); + } finally { + delete process.env.GROK_HOME; + fs.rmSync(customHome, { recursive: true, force: true }); + } + }); +}); + describe('Installer targets — opencode XDG config path (#535)', () => { let tmpHome: string; let tmpCwd: string; diff --git a/src/installer/targets/grok.ts b/src/installer/targets/grok.ts new file mode 100644 index 000000000..01256b514 --- /dev/null +++ b/src/installer/targets/grok.ts @@ -0,0 +1,199 @@ +/** + * Grok Build (xAI) target. Writes: + * + * - MCP server entry to `~/.grok/config.toml` (global) or + * `./.grok/config.toml` (local) as the dotted-key table + * `[mcp_servers.codegraph]`. TOML — not JSON — handled by + * the narrow serializer in `./toml.ts` (same as Codex). + * + * Grok reads project rules from `AGENTS.md` files natively, so no + * separate instructions file is needed — the MCP `initialize` + * response is the single source of truth for agent-facing guidance + * (issue #529). A prior install that wrote a `` + * block into an AGENTS.md is self-healed on upgrade (stripped by + * install, same as other targets). + * + * No permissions concept — Grok gates tool invocations through its + * own UI prompts rather than an external allowlist. `autoAllow` is + * silently ignored. + * + * Config dir resolution: `~/.grok/` (preferred) or `$GROK_HOME` + * (env override). Project-scoped config lives at `.grok/config.toml` + * relative to the project root. + * + * Docs: https://docs.x.ai/docs/mcp + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + getMcpServerConfig, + removeMarkedSection, + upsertInstructionsEntry, +} from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, +} from '../instructions-template'; +import { buildTomlTable, removeTomlTable, upsertTomlTable } from './toml'; + +const TOML_HEADER = 'mcp_servers.codegraph'; + +function globalConfigDir(): string { + const grokHome = process.env.GROK_HOME; + return grokHome || path.join(os.homedir(), '.grok'); +} +function tomlConfigPath(loc: Location): string { + return loc === 'global' + ? path.join(globalConfigDir(), 'config.toml') + : path.join(process.cwd(), '.grok', 'config.toml'); +} +/** + * Grok reads AGENTS.md natively from the project root and from + * `~/.grok/`. We write the marker-fenced block to the global + * AGENTS.md so subagents and non-MCP harnesses see it (#704). + * Project-local AGENTS.md at the cwd is left to the user. + */ +function instructionsPath(): string { + return path.join(globalConfigDir(), 'AGENTS.md'); +} + +class GrokTarget implements AgentTarget { + readonly id = 'grok' as const; + readonly displayName = 'Grok Build'; + readonly docsUrl = 'https://docs.x.ai/docs/mcp'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const tomlPath = tomlConfigPath(loc); + let alreadyConfigured = false; + if (fs.existsSync(tomlPath)) { + try { + const content = fs.readFileSync(tomlPath, 'utf-8'); + alreadyConfigured = content.includes(`[${TOML_HEADER}]`); + } catch { /* ignore */ } + } + const configDir = loc === 'global' ? globalConfigDir() : path.join(process.cwd(), '.grok'); + const installed = fs.existsSync(configDir) || fs.existsSync(tomlPath); + return { installed, alreadyConfigured, configPath: tomlPath }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + + files.push(writeMcpEntry(loc)); + + // Global AGENTS.md gets the short marker-fenced CodeGraph block (#704): + // subagents and non-MCP harnesses read AGENTS.md but never the MCP + // initialize instructions. Upsert self-heals a stale pre-#529 block. + // Only write for global — project-local AGENTS.md is the user's domain. + if (loc === 'global') { + files.push(upsertInstructionsEntry(instructionsPath())); + } + + return { + files, + notes: ['Restart Grok for MCP changes to take effect.'], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + const tomlPath = tomlConfigPath(loc); + if (fs.existsSync(tomlPath)) { + const content = fs.readFileSync(tomlPath, 'utf-8'); + const { content: nextContent, action } = removeTomlTable(content, TOML_HEADER); + if (action === 'removed') { + if (nextContent.trim() === '') { + try { fs.unlinkSync(tomlPath); } catch { /* ignore */ } + // Also remove the .grok/ dir if it's now empty (project-local only; + // never remove the global ~/.grok/ — it has other files). + if (loc === 'local') { + const dir = path.dirname(tomlPath); + try { + if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir); + } catch { /* ignore */ } + } + } else { + atomicWriteFileSync(tomlPath, nextContent.trimEnd() + '\n'); + } + files.push({ path: tomlPath, action: 'removed' }); + } else { + files.push({ path: tomlPath, action: 'not-found' }); + } + } else { + files.push({ path: tomlPath, action: 'not-found' }); + } + + // Strip the marker-delimited block from global AGENTS.md if a prior + // install wrote one. Only for global — local AGENTS.md is the user's. + if (loc === 'global') { + files.push(removeInstructionsEntry()); + } + + return { files }; + } + + printConfig(loc: Location): string { + const block = buildCodegraphBlock(); + const target = tomlConfigPath(loc); + return `# Add to ${target}\n\n${block}\n`; + } + + describePaths(loc: Location): string[] { + const paths = [tomlConfigPath(loc)]; + if (loc === 'global') paths.push(instructionsPath()); + return paths; + } +} + +function buildCodegraphBlock(): string { + const mcp = getMcpServerConfig(); + return buildTomlTable(TOML_HEADER, { + command: mcp.command, + args: mcp.args, + }); +} + +function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = tomlConfigPath(loc); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const block = buildCodegraphBlock(); + const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const created = existing.length === 0; + const { content: nextContent, action } = upsertTomlTable(existing, TOML_HEADER, block); + + if (action === 'unchanged') { + return { path: file, action: 'unchanged' }; + } + atomicWriteFileSync(file, nextContent); + return { path: file, action: created ? 'created' : 'updated' }; +} + +/** + * Strip the marker-delimited CodeGraph block from `~/.grok/AGENTS.md` + * if a prior install wrote one. Used by both install (self-heal on + * upgrade) and uninstall — see issue #529. + */ +function removeInstructionsEntry(): WriteResult['files'][number] { + const file = instructionsPath(); + const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + return { path: file, action }; +} + +export const grokTarget: AgentTarget = new GrokTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..6346d66bc 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { grokTarget } from './grok'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + grokTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..3e0c9d6c5 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'grok'; /** * Result of `target.detect(location)`.