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: 1 addition & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ program
console.log(" mex check --quiet One-liner drift score");
console.log(" mex check --json Full drift report as JSON");
console.log(" mex check --fix Check and fix any errors found");
console.log(" mex sync Fix drift — Claude updates only what's broken");
console.log(" mex sync Fix drift — AI updates only what's broken");
console.log(" mex sync --dry-run Preview fix prompts without running them");
console.log(" mex sync --warnings Include warning-only files in sync");
console.log(" mex init Pre-scan codebase, build brief for AI");
Expand Down
48 changes: 45 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import type { MexConfig } from "./types.js";
import type { MexConfig, AiTool } from "./types.js";

/**
* Walk up from startDir looking for .git to find project root,
Expand Down Expand Up @@ -35,7 +35,8 @@ export function findConfig(startDir?: string): MexConfig {
);
}

return { projectRoot, scaffoldRoot };
const aiTools = loadAiTools(scaffoldRoot);
return { projectRoot, scaffoldRoot, aiTools };
}

function findProjectRoot(dir: string): string | null {
Expand All @@ -50,6 +51,47 @@ function findProjectRoot(dir: string): string | null {
}
}

// ── AI Tool persistence ──

const CONFIG_FILE = "config.json";

interface MexPersistedConfig {
aiTools?: unknown;
[key: string]: unknown;
}

const VALID_AI_TOOLS = new Set<string>(["claude", "cursor", "windsurf", "copilot", "opencode", "codex"]);

function loadAiTools(scaffoldRoot: string): AiTool[] {
const configPath = resolve(scaffoldRoot, CONFIG_FILE);
if (!existsSync(configPath)) return [];
try {
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return [];
const arr = (raw as MexPersistedConfig).aiTools;
if (!Array.isArray(arr)) return [];
return arr.filter((v): v is AiTool => typeof v === "string" && VALID_AI_TOOLS.has(v));
} catch {
return [];
}
}

export function saveAiTools(scaffoldRoot: string, tools: AiTool[]): void {
const configPath = resolve(scaffoldRoot, CONFIG_FILE);
let existing: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
existing = raw as Record<string, unknown>;
}
} catch { /* start fresh */ }
}
existing.aiTools = [...new Set(tools)];
mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n");
Comment on lines +79 to +92

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

saveAiTools() assumes JSON.parse() returns an object. If config.json is valid JSON but not an object (e.g. an array/number/string), existing.aiTools = tools will throw at runtime. Consider guarding existing to a plain object (non-null, typeof object, not Array) before assigning, and also consider de-duplicating tools to avoid repeated entries from multi-select input.

Copilot uses AI. Check for mistakes.
}

function findScaffoldRoot(projectRoot: string): string | null {
// Prefer .mex/ directory
const mexDir = resolve(projectRoot, ".mex");
Expand Down
22 changes: 21 additions & 1 deletion src/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
buildExistingWithBriefPrompt,
buildExistingNoBriefPrompt,
} from "./prompts.js";
import { saveAiTools } from "../config.js";
import type { AiTool } from "../types.js";

// ── Constants ──

Expand Down Expand Up @@ -197,7 +199,7 @@ export async function runSetup(opts: { dryRun?: boolean } = {}): Promise<void> {
try {
info("Scanning codebase...");
const { runScan } = await import("../scanner/index.js");
const config = { projectRoot, scaffoldRoot: mexDir };
const config = { projectRoot, scaffoldRoot: mexDir, aiTools: [] as AiTool[] };
const result = await runScan(config, { jsonOnly: true });
scannerBrief = JSON.stringify(result, null, 2);
ok("Pre-analysis complete — AI will reason from brief instead of exploring");
Expand Down Expand Up @@ -302,6 +304,15 @@ function detectProjectState(projectRoot: string, mexDir: string): ProjectState {
}
}

const TOOL_CHOICE_MAP: Record<string, AiTool> = {
"1": "claude",
"2": "cursor",
"3": "windsurf",
"4": "copilot",
"5": "opencode",
"6": "codex",
};

async function selectToolConfig(
rl: ReturnType<typeof createInterface>,
projectRoot: string,
Expand All @@ -322,12 +333,15 @@ async function selectToolConfig(
const choice = (await rl.question("Choice [1-8] (default: 1): ")).trim() || "1";

let selectedClaude = false;
const selectedTools: AiTool[] = [];

const copyConfig = (key: string) => {
const config = TOOL_CONFIGS[key];
if (!config) return;

if (key === "1") selectedClaude = true;
const tool = TOOL_CHOICE_MAP[key];
if (tool) selectedTools.push(tool);

const src = resolve(TEMPLATES_DIR, config.src);
const dest = resolve(projectRoot, config.dest);
Expand Down Expand Up @@ -377,6 +391,12 @@ async function selectToolConfig(
break;
}

// Persist tool selection
if (selectedTools.length > 0 && !dryRun) {
const mexDir = resolve(projectRoot, ".mex");
saveAiTools(mexDir, selectedTools);
}

return selectedClaude;
Comment on lines +394 to 400

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MexConfig now requires aiTools, but later in this file the config object passed into runScan is still created as { projectRoot, scaffoldRoot: mexDir } (missing aiTools), which will fail npm run typecheck and makes the types inconsistent. Consider including aiTools there (e.g. selectedTools/[]) or changing runScan to accept a narrower config type if it only needs projectRoot.

Copilot uses AI. Check for mistakes.
}

Expand Down
74 changes: 65 additions & 9 deletions src/sync/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import chalk from "chalk";
import { spawnSync } from "node:child_process";
import { spawnSync, execSync } from "node:child_process";
import { createInterface } from "node:readline";
import type { MexConfig, SyncTarget, DriftIssue } from "../types.js";
import type { MexConfig, SyncTarget, DriftIssue, AiTool } from "../types.js";
import { AI_TOOLS } from "../types.js";
import { runDriftCheck } from "../drift/index.js";
import { buildSyncBrief, buildCombinedBrief } from "./brief-builder.js";

Expand All @@ -15,15 +16,52 @@ function askUser(question: string): Promise<string> {
});
}

function runClaudeInteractive(brief: string, cwd: string): boolean {
const result = spawnSync("claude", [brief], {
function hasCliTool(cmd: string): boolean {
try {
execSync(`which ${cmd}`, { stdio: "ignore" });
return true;
} catch {
return false;
}
}
Comment on lines +19 to +26

Copilot AI Apr 13, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasCliTool() uses execSync(which ${cmd}), which is shell-based and not portable (e.g. Windows typically uses where). Even though cmd currently comes from constants, using execFileSync/spawnSync with shell:false (and selecting which vs where by platform) would make this safer and more reliable across environments.

Copilot uses AI. Check for mistakes.

function runToolInteractive(tool: AiTool, brief: string, cwd: string): boolean {
const meta = AI_TOOLS[tool];
if (!meta.cli) return false;

const args = [...meta.promptFlag, brief];
const result = spawnSync(meta.cli, args, {
cwd,
stdio: "inherit",
timeout: 300_000,
});
return result.status === 0 || result.status === null;
}

/** Pick which AI tool to use for interactive sync */
async function pickSyncTool(configuredTools: AiTool[]): Promise<AiTool | null> {
// Filter to tools that have a CLI and are installed
const available = configuredTools.filter((t) => {
const meta = AI_TOOLS[t];
return meta.cli && hasCliTool(meta.cli);
});

if (available.length === 0) return null;
if (available.length === 1) return available[0];

// Multiple CLI tools available — ask user
console.log(chalk.bold("\nWhich tool should fix these?"));
console.log();
available.forEach((t, i) => {
console.log(` ${i + 1}) ${AI_TOOLS[t].name}`);
});
console.log();

const choice = await askUser(`Choice [1-${available.length}] (default: 1): `);
const idx = parseInt(choice || "1", 10) - 1;
return available[idx] ?? available[0];
}

type SyncMode = "interactive" | "prompts";

/** Run targeted sync: detect → brief → AI → verify → ask → loop */
Expand All @@ -33,6 +71,7 @@ export async function runSync(
): Promise<void> {
let cycle = 0;
let mode: SyncMode | null = null;
let activeTool: AiTool | null = null;

while (true) {
cycle++;
Expand Down Expand Up @@ -107,9 +146,17 @@ export async function runSync(

// Ask user for mode (only on first cycle)
if (mode === null) {
// Determine if any configured tool has a usable CLI
const syncTool = await pickSyncTool(config.aiTools);
const toolName = syncTool ? AI_TOOLS[syncTool].name : null;

console.log(chalk.bold("\nHow should we fix these?"));
console.log();
console.log(" 1) Interactive — Claude fixes with you watching (default)");
if (toolName) {
console.log(` 1) Interactive — ${toolName} fixes with you watching (default)`);
} else {
console.log(" 1) Interactive — AI fixes with you watching (default)");
}
console.log(" 2) Show prompts — I'll paste manually");
console.log(" 3) Exit");
console.log();
Expand All @@ -119,7 +166,15 @@ export async function runSync(

switch (picked) {
case "1":
mode = "interactive";
if (!syncTool) {
console.log(chalk.yellow("No supported AI CLI detected. Falling back to prompts mode."));
console.log(chalk.dim("Supported CLIs: claude, opencode, codex"));
console.log();
mode = "prompts";
} else {
activeTool = syncTool;
mode = "interactive";
}
break;
case "2":
mode = "prompts";
Expand All @@ -143,13 +198,14 @@ export async function runSync(

// Step 3: Fix all files in one interactive session
console.log();
console.log(chalk.bold(`\nSending all ${targets.length} file(s) to Claude in one session...\n`));
const toolLabel = activeTool ? AI_TOOLS[activeTool].name : "AI";
console.log(chalk.bold(`\nSending all ${targets.length} file(s) to ${toolLabel} in one session...\n`));

const brief = await buildCombinedBrief(targets, config.projectRoot);
const ok = runClaudeInteractive(brief, config.projectRoot);
const ok = runToolInteractive(activeTool!, brief, config.projectRoot);

if (!ok) {
console.log(chalk.red("Claude session failed"));
console.log(chalk.red(`${toolLabel} session failed`));
}

// Step 4: Verify
Expand Down
22 changes: 22 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
// ── Shared Types ──

// ── AI Tool ──

export type AiTool = "claude" | "cursor" | "windsurf" | "copilot" | "opencode" | "codex";

export interface AiToolMeta {
name: string;
cli: string | null;
/** CLI flag to pass a prompt string directly */
promptFlag: string[];
}

export const AI_TOOLS: Record<AiTool, AiToolMeta> = {
claude: { name: "Claude Code", cli: "claude", promptFlag: [] },
cursor: { name: "Cursor", cli: null, promptFlag: [] },
windsurf: { name: "Windsurf", cli: null, promptFlag: [] },
copilot: { name: "Copilot", cli: null, promptFlag: [] },
opencode: { name: "OpenCode", cli: "opencode", promptFlag: ["run"] },
codex: { name: "Codex", cli: "codex", promptFlag: [] },
};

// ── Config ──

export interface MexConfig {
/** Absolute path to project root (where .git lives) */
projectRoot: string;
/** Absolute path to scaffold root (.mex/ directory) */
scaffoldRoot: string;
/** Which AI tool(s) the user selected during setup */
aiTools: AiTool[];
}

// ── Claims (extracted from markdown) ──
Expand Down
52 changes: 50 additions & 2 deletions test/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { findConfig } from "../src/config.js";
import { findConfig, saveAiTools } from "../src/config.js";

let tmpDir: string;

Expand Down Expand Up @@ -63,4 +63,52 @@ describe("findConfig", () => {
const config = findConfig(tmpDir);
expect(config.scaffoldRoot).toBe(mexPath);
});

it("returns empty aiTools when no config.json exists", () => {
mkdirSync(join(tmpDir, ".git"));
const mexPath = join(tmpDir, ".mex");
mkdirSync(mexPath);
writeFileSync(join(mexPath, "ROUTER.md"), "");
const config = findConfig(tmpDir);
expect(config.aiTools).toEqual([]);
});

it("loads aiTools from config.json when present", () => {
mkdirSync(join(tmpDir, ".git"));
const mexPath = join(tmpDir, ".mex");
mkdirSync(mexPath);
writeFileSync(join(mexPath, "ROUTER.md"), "");
writeFileSync(join(mexPath, "config.json"), JSON.stringify({ aiTools: ["opencode", "claude"] }));
const config = findConfig(tmpDir);
expect(config.aiTools).toEqual(["opencode", "claude"]);
});
});

describe("saveAiTools", () => {
it("creates config.json with aiTools", () => {
const mexPath = join(tmpDir, ".mex");
mkdirSync(mexPath, { recursive: true });
saveAiTools(mexPath, ["opencode"]);
const raw = JSON.parse(readFileSync(join(mexPath, "config.json"), "utf-8"));
expect(raw.aiTools).toEqual(["opencode"]);
});

it("preserves existing config keys when saving", () => {
const mexPath = join(tmpDir, ".mex");
mkdirSync(mexPath, { recursive: true });
writeFileSync(join(mexPath, "config.json"), JSON.stringify({ someOther: true }));
saveAiTools(mexPath, ["codex"]);
const raw = JSON.parse(readFileSync(join(mexPath, "config.json"), "utf-8"));
expect(raw.aiTools).toEqual(["codex"]);
expect(raw.someOther).toBe(true);
});

it("overwrites previous aiTools value", () => {
const mexPath = join(tmpDir, ".mex");
mkdirSync(mexPath, { recursive: true });
saveAiTools(mexPath, ["claude"]);
saveAiTools(mexPath, ["opencode", "codex"]);
const raw = JSON.parse(readFileSync(join(mexPath, "config.json"), "utf-8"));
expect(raw.aiTools).toEqual(["opencode", "codex"]);
});
});
Loading