diff --git a/src/cli.ts b/src/cli.ts index 337cbab1..c2bc0913 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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"); diff --git a/src/config.ts b/src/config.ts index 82147e73..c210f73a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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, @@ -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 { @@ -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(["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 = {}; + 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; + } + } catch { /* start fresh */ } + } + existing.aiTools = [...new Set(tools)]; + mkdirSync(dirname(configPath), { recursive: true }); + writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n"); +} + function findScaffoldRoot(projectRoot: string): string | null { // Prefer .mex/ directory const mexDir = resolve(projectRoot, ".mex"); diff --git a/src/setup/index.ts b/src/setup/index.ts index 96ee9614..7a168381 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -11,6 +11,8 @@ import { buildExistingWithBriefPrompt, buildExistingNoBriefPrompt, } from "./prompts.js"; +import { saveAiTools } from "../config.js"; +import type { AiTool } from "../types.js"; // ── Constants ── @@ -197,7 +199,7 @@ export async function runSetup(opts: { dryRun?: boolean } = {}): Promise { 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"); @@ -302,6 +304,15 @@ function detectProjectState(projectRoot: string, mexDir: string): ProjectState { } } +const TOOL_CHOICE_MAP: Record = { + "1": "claude", + "2": "cursor", + "3": "windsurf", + "4": "copilot", + "5": "opencode", + "6": "codex", +}; + async function selectToolConfig( rl: ReturnType, projectRoot: string, @@ -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); @@ -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; } diff --git a/src/sync/index.ts b/src/sync/index.ts index 276c187e..841cd9f8 100644 --- a/src/sync/index.ts +++ b/src/sync/index.ts @@ -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"; @@ -15,8 +16,21 @@ function askUser(question: string): Promise { }); } -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; + } +} + +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, @@ -24,6 +38,30 @@ function runClaudeInteractive(brief: string, cwd: string): boolean { return result.status === 0 || result.status === null; } +/** Pick which AI tool to use for interactive sync */ +async function pickSyncTool(configuredTools: AiTool[]): Promise { + // 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 */ @@ -33,6 +71,7 @@ export async function runSync( ): Promise { let cycle = 0; let mode: SyncMode | null = null; + let activeTool: AiTool | null = null; while (true) { cycle++; @@ -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(); @@ -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"; @@ -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 diff --git a/src/types.ts b/src/types.ts index 9efbc517..ac100c3a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,25 @@ // ── 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 = { + 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 { @@ -7,6 +27,8 @@ export interface MexConfig { 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) ── diff --git a/test/config.test.ts b/test/config.test.ts index 422a5cc9..d36b1176 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -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; @@ -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"]); + }); });