diff --git a/src/cli.ts b/src/cli.ts
index fb056a57..cc7abae8 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -112,6 +112,7 @@ program
.option('--agents', 'Generate AGENTS.md')
.option('--cursor', 'Generate .cursorrules')
.option('--gemini', 'Generate GEMINI.md')
+ .option('--grok', 'Wire grok-faf-mcp into .grok/config.toml')
.option('--conductor', 'Generate conductor config')
.option('--html', 'Generate project.html (visual render of project.faf)')
.option('--all', 'Generate all formats')
@@ -238,6 +239,7 @@ program.command('status', { hidden: true }).action(() => scoreCommand(undefined,
program.command('agents', { hidden: true }).action(() => exportCommand({ agents: true }));
program.command('cursor', { hidden: true }).action(() => exportCommand({ cursor: true }));
program.command('gemini', { hidden: true }).action(() => exportCommand({ gemini: true }));
+program.command('grok', { hidden: true }).action(() => exportCommand({ grok: true }));
program.command('validate', { hidden: true }).action((file: string) => checkCommand(file));
program.command('yolo', { hidden: true }).action(() => initCommand({ yolo: true }));
diff --git a/src/commands/export.ts b/src/commands/export.ts
index 700280e9..94450283 100644
--- a/src/commands/export.ts
+++ b/src/commands/export.ts
@@ -2,6 +2,7 @@ import { findFafFile, readFaf, readFafRaw } from '../interop/faf.js';
import { writeAgentsMd } from '../interop/agents.js';
import { writeCursorrules } from '../interop/cursorrules.js';
import { writeGeminiMd } from '../interop/gemini.js';
+import { writeGrokConfig } from '../interop/grok.js';
import { writeProjectHtml } from '../interop/projecthtml.js';
import { scoreFafYaml } from '../core/scorer.js';
import { dim, fafCyan } from '../ui/colors.js';
@@ -10,6 +11,7 @@ export interface ExportOptions {
agents?: boolean;
cursor?: boolean;
gemini?: boolean;
+ grok?: boolean;
conductor?: boolean;
html?: boolean;
all?: boolean;
@@ -29,6 +31,7 @@ export function exportCommand(options: ExportOptions = {}): void {
(!options.agents &&
!options.cursor &&
!options.gemini &&
+ !options.grok &&
!options.conductor &&
!options.html);
@@ -47,6 +50,13 @@ export function exportCommand(options: ExportOptions = {}): void {
console.log(` GEMINI.md`);
}
+ // Opt-in only: wires an MCP server into the user's .grok/ config, so it
+ // never fires on a bare `faf export` or `--all` — only on explicit --grok.
+ if (options.grok) {
+ const status = writeGrokConfig(dir, data);
+ console.log(` .grok/config.toml (${status})`);
+ }
+
if (exportAll || options.html) {
// Render from the CURRENT project.faf — scored via the real scorer,
// never a reimplementation. project.html is a view, not a format.
diff --git a/src/interop/grok.ts b/src/interop/grok.ts
new file mode 100644
index 00000000..f4d35929
--- /dev/null
+++ b/src/interop/grok.ts
@@ -0,0 +1,55 @@
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
+import { join } from 'path';
+import type { FafData } from '../core/types.js';
+
+/**
+ * Canonical hosted endpoint for grok-faf-mcp — the URL form Grok CLI chose
+ * (hosted SSE over local subprocess: "zero local subprocess, always current").
+ * Source of truth: the grok-faf-mcp README install card.
+ */
+export const GROK_FAF_MCP_URL = 'https://mcpaas.live/grok/mcp/v1';
+
+/** TOML table key for the grok-faf-mcp server entry. */
+export const GROK_MCP_TABLE = 'mcp_servers.grok-faf-mcp';
+
+/**
+ * The `[mcp_servers.grok-faf-mcp]` block — the "section" wired into
+ * `.grok/config.toml`. This is machine-readable wiring, not a rule file:
+ * CLAUDE.md *instructs* an AI to read context; this *connects* the FAF MCP.
+ */
+export function generateGrokConfig(_data?: FafData): string {
+ return [`[${GROK_MCP_TABLE}]`, `url = "${GROK_FAF_MCP_URL}"`, ''].join('\n');
+}
+
+export type GrokWriteStatus = 'created' | 'merged' | 'unchanged';
+
+/**
+ * Wire grok-faf-mcp into `
/.grok/config.toml` — non-destructively.
+ * - absent file → create `.grok/` + write a one-line header + the block ('created')
+ * - section missing → append the block, preserving all existing config ('merged')
+ * - already wired → leave the user's file exactly as-is ('unchanged')
+ *
+ * Never overwrites or deletes existing config. A stale url in an existing
+ * entry is left untouched (idempotent) — re-point by editing the file.
+ */
+export function writeGrokConfig(dir: string, data?: FafData): GrokWriteStatus {
+ const grokDir = join(dir, '.grok');
+ const configPath = join(grokDir, 'config.toml');
+ const block = generateGrokConfig(data);
+
+ if (!existsSync(configPath)) {
+ mkdirSync(grokDir, { recursive: true });
+ const header = '# grok-faf-mcp — wired by FAF from project.faf\n\n';
+ writeFileSync(configPath, header + block, 'utf-8');
+ return 'created';
+ }
+
+ const existing = readFileSync(configPath, 'utf-8');
+ if (existing.includes(`[${GROK_MCP_TABLE}]`)) {
+ return 'unchanged';
+ }
+
+ const sep = existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
+ writeFileSync(configPath, existing + sep + block, 'utf-8');
+ return 'merged';
+}
diff --git a/tests/interop/grok.test.ts b/tests/interop/grok.test.ts
new file mode 100644
index 00000000..038eec49
--- /dev/null
+++ b/tests/interop/grok.test.ts
@@ -0,0 +1,97 @@
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import {
+ GROK_FAF_MCP_URL,
+ GROK_MCP_TABLE,
+ generateGrokConfig,
+ writeGrokConfig,
+} from '../../src/interop/grok.js';
+
+describe('interop/grok — .grok/config.toml MCP wiring', () => {
+ let testDir: string;
+ const configPath = () => join(testDir, '.grok', 'config.toml');
+
+ beforeEach(() => {
+ testDir = join(tmpdir(), `faf-test-grok-${Date.now()}-${Math.random().toString(36).slice(2)}`);
+ mkdirSync(testDir, { recursive: true });
+ });
+
+ afterEach(() => {
+ rmSync(testDir, { recursive: true, force: true });
+ });
+
+ // --- generate ---
+
+ test('generateGrokConfig emits the canonical [mcp_servers.grok-faf-mcp] block', () => {
+ const block = generateGrokConfig();
+ expect(block).toContain(`[${GROK_MCP_TABLE}]`);
+ expect(block).toContain(`url = "${GROK_FAF_MCP_URL}"`);
+ });
+
+ test('canonical URL is the hosted form Grok chose', () => {
+ expect(GROK_FAF_MCP_URL).toBe('https://mcpaas.live/grok/mcp/v1');
+ expect(GROK_MCP_TABLE).toBe('mcp_servers.grok-faf-mcp');
+ });
+
+ // --- write: created ---
+
+ test('writeGrokConfig creates .grok/config.toml when absent', () => {
+ const status = writeGrokConfig(testDir);
+ expect(status).toBe('created');
+ expect(existsSync(configPath())).toBe(true);
+
+ const written = readFileSync(configPath(), 'utf-8');
+ expect(written).toContain(`[${GROK_MCP_TABLE}]`);
+ expect(written).toContain(`url = "${GROK_FAF_MCP_URL}"`);
+ // quiet one-line header, not a banner
+ expect(written.startsWith('# grok-faf-mcp')).toBe(true);
+ });
+
+ // --- write: idempotent (unchanged) ---
+
+ test('writeGrokConfig is idempotent — second run is unchanged + byte-identical', () => {
+ expect(writeGrokConfig(testDir)).toBe('created');
+ const first = readFileSync(configPath(), 'utf-8');
+
+ expect(writeGrokConfig(testDir)).toBe('unchanged');
+ const second = readFileSync(configPath(), 'utf-8');
+
+ expect(second).toBe(first);
+ });
+
+ // --- write: merged (non-destructive) ---
+
+ test('writeGrokConfig appends to an existing config without clobbering it', () => {
+ mkdirSync(join(testDir, '.grok'), { recursive: true });
+ const existing = '[mcp_servers.some-other-server]\nurl = "https://example.com/mcp"\n';
+ writeFileSync(configPath(), existing, 'utf-8');
+
+ const status = writeGrokConfig(testDir);
+ expect(status).toBe('merged');
+
+ const merged = readFileSync(configPath(), 'utf-8');
+ // existing config survives in full
+ expect(merged).toContain('[mcp_servers.some-other-server]');
+ expect(merged).toContain('https://example.com/mcp');
+ // and ours is added
+ expect(merged).toContain(`[${GROK_MCP_TABLE}]`);
+ expect(merged).toContain(GROK_FAF_MCP_URL);
+ });
+
+ test('merge then re-run leaves the other server + ours intact (unchanged)', () => {
+ mkdirSync(join(testDir, '.grok'), { recursive: true });
+ writeFileSync(configPath(), '[mcp_servers.other]\nurl = "https://x.dev"\n', 'utf-8');
+
+ expect(writeGrokConfig(testDir)).toBe('merged');
+ expect(writeGrokConfig(testDir)).toBe('unchanged');
+
+ const final = readFileSync(configPath(), 'utf-8');
+ expect(final).toContain('[mcp_servers.other]');
+ expect(final).toContain('https://x.dev');
+ // exactly one grok-faf-mcp entry (no duplicate appended on re-run)
+ const occurrences = final.split(`[${GROK_MCP_TABLE}]`).length - 1;
+ expect(occurrences).toBe(1);
+ });
+});
diff --git a/tests/wjttc/grok-config.test.ts b/tests/wjttc/grok-config.test.ts
new file mode 100644
index 00000000..4f32a5f5
--- /dev/null
+++ b/tests/wjttc/grok-config.test.ts
@@ -0,0 +1,122 @@
+/**
+ * WJTTC — .grok/config.toml MCP wiring (faf export --grok). Grounded baseline.
+ *
+ * project.faf → .grok/config.toml [mcp_servers.grok-faf-mcp] is the machine-
+ * readable projection (the cable, not a billboard). Because it ACTS on the
+ * user's config, the championship gates that matter are SAFETY (never clobber)
+ * and the LIVE road (the real CLI path is opt-in, never a side effect).
+ *
+ * BRAKE — non-destructive invariants (don't wreck the user's config)
+ * ENGINE — the canonical block is emitted + the file is created correctly
+ * AERO — deterministic output (no drift between runs)
+ * TYRE — live: the real `faf export --grok` path + the opt-in guard
+ */
+
+import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { execSync } from 'child_process';
+import {
+ GROK_FAF_MCP_URL,
+ GROK_MCP_TABLE,
+ generateGrokConfig,
+ writeGrokConfig,
+} from '../../src/interop/grok.js';
+
+let dir: string;
+const cfg = () => join(dir, '.grok', 'config.toml');
+const TABLE = `[${GROK_MCP_TABLE}]`;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'faf-grok-'));
+});
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+});
+
+describe('WJTTC BRAKE: .grok/config.toml is non-destructive', () => {
+ test('an existing config (other server) survives the merge', () => {
+ mkdirSync(join(dir, '.grok'), { recursive: true });
+ writeFileSync(cfg(), '[mcp_servers.other]\nurl = "https://other.dev/mcp"\n');
+
+ expect(writeGrokConfig(dir)).toBe('merged');
+
+ const out = readFileSync(cfg(), 'utf-8');
+ expect(out).toContain('[mcp_servers.other]'); // theirs preserved
+ expect(out).toContain('https://other.dev/mcp');
+ expect(out).toContain(TABLE); // ours added
+ });
+
+ test('re-running never duplicates the grok-faf-mcp entry', () => {
+ expect(writeGrokConfig(dir)).toBe('created');
+ expect(writeGrokConfig(dir)).toBe('unchanged');
+ expect(writeGrokConfig(dir)).toBe('unchanged');
+ const occurrences = readFileSync(cfg(), 'utf-8').split(TABLE).length - 1;
+ expect(occurrences).toBe(1);
+ });
+
+ test('an already-wired file is left byte-identical', () => {
+ writeGrokConfig(dir);
+ const before = readFileSync(cfg(), 'utf-8');
+ expect(writeGrokConfig(dir)).toBe('unchanged');
+ expect(readFileSync(cfg(), 'utf-8')).toBe(before);
+ });
+});
+
+describe('WJTTC ENGINE: canonical block + file creation', () => {
+ test('generateGrokConfig emits the canonical [mcp_servers.grok-faf-mcp] block', () => {
+ const block = generateGrokConfig();
+ expect(block).toContain(TABLE);
+ expect(block).toContain(`url = "${GROK_FAF_MCP_URL}"`);
+ });
+
+ test('writeGrokConfig creates .grok/config.toml with a quiet one-line header', () => {
+ expect(writeGrokConfig(dir)).toBe('created');
+ expect(existsSync(cfg())).toBe(true);
+ const out = readFileSync(cfg(), 'utf-8');
+ expect(out).toContain(TABLE);
+ expect(out).toContain(GROK_FAF_MCP_URL);
+ expect(out.startsWith('# grok-faf-mcp')).toBe(true); // header, not a banner
+ });
+});
+
+describe('WJTTC AERO: deterministic output (no drift between runs)', () => {
+ test('two independent fresh dirs produce byte-identical config', () => {
+ const a = mkdtempSync(join(tmpdir(), 'faf-grok-a-'));
+ const b = mkdtempSync(join(tmpdir(), 'faf-grok-b-'));
+ try {
+ writeGrokConfig(a);
+ writeGrokConfig(b);
+ expect(readFileSync(join(a, '.grok', 'config.toml'), 'utf-8'))
+ .toBe(readFileSync(join(b, '.grok', 'config.toml'), 'utf-8'));
+ } finally {
+ rmSync(a, { recursive: true, force: true });
+ rmSync(b, { recursive: true, force: true });
+ }
+ });
+});
+
+describe('WJTTC TYRE: live CLI — the real road', () => {
+ const cli = join(__dirname, '../../src/cli.ts');
+ const run = (cmd: string) =>
+ execSync(`bun ${cli} ${cmd}`, { cwd: dir, encoding: 'utf-8', timeout: 30000 });
+
+ beforeEach(() => {
+ writeFileSync(
+ join(dir, 'project.faf'),
+ 'faf_version: 3.0.0\nproject:\n name: wjttc-grok\n goal: live wiring grip\n main_language: TypeScript\n',
+ );
+ });
+
+ test('`faf export --grok` wires the hosted endpoint end-to-end', () => {
+ run('export --grok');
+ expect(existsSync(cfg())).toBe(true);
+ expect(readFileSync(cfg(), 'utf-8')).toContain(GROK_FAF_MCP_URL);
+ });
+
+ test('bare `faf export` does NOT touch .grok/ (opt-in guard)', () => {
+ run('export');
+ expect(existsSync(join(dir, '.grok'))).toBe(false);
+ });
+});