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); + }); +});