Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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 }));

Expand Down
10 changes: 10 additions & 0 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -10,6 +11,7 @@ export interface ExportOptions {
agents?: boolean;
cursor?: boolean;
gemini?: boolean;
grok?: boolean;
conductor?: boolean;
html?: boolean;
all?: boolean;
Expand All @@ -29,6 +31,7 @@ export function exportCommand(options: ExportOptions = {}): void {
(!options.agents &&
!options.cursor &&
!options.gemini &&
!options.grok &&
!options.conductor &&
!options.html);

Expand All @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions src/interop/grok.ts
Original file line number Diff line number Diff line change
@@ -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 `<dir>/.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';
}
97 changes: 97 additions & 0 deletions tests/interop/grok.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
122 changes: 122 additions & 0 deletions tests/wjttc/grok-config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading