Skip to content

Commit 0d034d7

Browse files
fix: make sync tool-agnostic instead of hardcoding Claude
Closes #29 - Add AiTool type and AI_TOOLS registry with CLI metadata per tool - Persist tool selection from setup to .mex/config.json - Load saved aiTools in findConfig and pass through MexConfig - Sync now dispatches to the correct CLI (claude, opencode run, codex) - Tools without a CLI (Cursor, Windsurf, Copilot) fall back to prompts mode - Replace hardcoded "Claude" strings in sync UI and cli help text - Add tests for config persistence (save/load aiTools)
1 parent ad0cc3d commit 0d034d7

6 files changed

Lines changed: 194 additions & 15 deletions

File tree

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ program
149149
console.log(" mex check --quiet One-liner drift score");
150150
console.log(" mex check --json Full drift report as JSON");
151151
console.log(" mex check --fix Check and fix any errors found");
152-
console.log(" mex sync Fix drift — Claude updates only what's broken");
152+
console.log(" mex sync Fix drift — AI updates only what's broken");
153153
console.log(" mex sync --dry-run Preview fix prompts without running them");
154154
console.log(" mex sync --warnings Include warning-only files in sync");
155155
console.log(" mex init Pre-scan codebase, build brief for AI");

src/config.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { existsSync } from "node:fs";
1+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
22
import { resolve, dirname } from "node:path";
3-
import type { MexConfig } from "./types.js";
3+
import type { MexConfig, AiTool } from "./types.js";
44

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

38-
return { projectRoot, scaffoldRoot };
38+
const aiTools = loadAiTools(scaffoldRoot);
39+
return { projectRoot, scaffoldRoot, aiTools };
3940
}
4041

4142
function findProjectRoot(dir: string): string | null {
@@ -50,6 +51,38 @@ function findProjectRoot(dir: string): string | null {
5051
}
5152
}
5253

54+
// ── AI Tool persistence ──
55+
56+
const CONFIG_FILE = "config.json";
57+
58+
interface MexPersistedConfig {
59+
aiTools?: AiTool[];
60+
}
61+
62+
function loadAiTools(scaffoldRoot: string): AiTool[] {
63+
const configPath = resolve(scaffoldRoot, CONFIG_FILE);
64+
if (!existsSync(configPath)) return [];
65+
try {
66+
const raw = JSON.parse(readFileSync(configPath, "utf-8")) as MexPersistedConfig;
67+
return Array.isArray(raw.aiTools) ? raw.aiTools : [];
68+
} catch {
69+
return [];
70+
}
71+
}
72+
73+
export function saveAiTools(scaffoldRoot: string, tools: AiTool[]): void {
74+
const configPath = resolve(scaffoldRoot, CONFIG_FILE);
75+
let existing: MexPersistedConfig = {};
76+
if (existsSync(configPath)) {
77+
try {
78+
existing = JSON.parse(readFileSync(configPath, "utf-8")) as MexPersistedConfig;
79+
} catch { /* start fresh */ }
80+
}
81+
existing.aiTools = tools;
82+
mkdirSync(dirname(configPath), { recursive: true });
83+
writeFileSync(configPath, JSON.stringify(existing, null, 2) + "\n");
84+
}
85+
5386
function findScaffoldRoot(projectRoot: string): string | null {
5487
// Prefer .mex/ directory
5588
const mexDir = resolve(projectRoot, ".mex");

src/setup/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
buildExistingWithBriefPrompt,
1212
buildExistingNoBriefPrompt,
1313
} from "./prompts.js";
14+
import { saveAiTools } from "../config.js";
15+
import type { AiTool } from "../types.js";
1416

1517
// ── Constants ──
1618

@@ -302,6 +304,15 @@ function detectProjectState(projectRoot: string, mexDir: string): ProjectState {
302304
}
303305
}
304306

307+
const TOOL_CHOICE_MAP: Record<string, AiTool> = {
308+
"1": "claude",
309+
"2": "cursor",
310+
"3": "windsurf",
311+
"4": "copilot",
312+
"5": "opencode",
313+
"6": "codex",
314+
};
315+
305316
async function selectToolConfig(
306317
rl: ReturnType<typeof createInterface>,
307318
projectRoot: string,
@@ -322,12 +333,15 @@ async function selectToolConfig(
322333
const choice = (await rl.question("Choice [1-8] (default: 1): ")).trim() || "1";
323334

324335
let selectedClaude = false;
336+
const selectedTools: AiTool[] = [];
325337

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

330342
if (key === "1") selectedClaude = true;
343+
const tool = TOOL_CHOICE_MAP[key];
344+
if (tool) selectedTools.push(tool);
331345

332346
const src = resolve(TEMPLATES_DIR, config.src);
333347
const dest = resolve(projectRoot, config.dest);
@@ -377,6 +391,12 @@ async function selectToolConfig(
377391
break;
378392
}
379393

394+
// Persist tool selection
395+
if (selectedTools.length > 0 && !dryRun) {
396+
const mexDir = resolve(projectRoot, ".mex");
397+
saveAiTools(mexDir, selectedTools);
398+
}
399+
380400
return selectedClaude;
381401
}
382402

src/sync/index.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import chalk from "chalk";
2-
import { spawnSync } from "node:child_process";
2+
import { spawnSync, execSync } from "node:child_process";
33
import { createInterface } from "node:readline";
4-
import type { MexConfig, SyncTarget, DriftIssue } from "../types.js";
4+
import type { MexConfig, SyncTarget, DriftIssue, AiTool } from "../types.js";
5+
import { AI_TOOLS } from "../types.js";
56
import { runDriftCheck } from "../drift/index.js";
67
import { buildSyncBrief, buildCombinedBrief } from "./brief-builder.js";
78

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

18-
function runClaudeInteractive(brief: string, cwd: string): boolean {
19-
const result = spawnSync("claude", [brief], {
19+
function hasCliTool(cmd: string): boolean {
20+
try {
21+
execSync(`which ${cmd}`, { stdio: "ignore" });
22+
return true;
23+
} catch {
24+
return false;
25+
}
26+
}
27+
28+
function runToolInteractive(tool: AiTool, brief: string, cwd: string): boolean {
29+
const meta = AI_TOOLS[tool];
30+
if (!meta.cli) return false;
31+
32+
const args = [...meta.promptFlag, brief];
33+
const result = spawnSync(meta.cli, args, {
2034
cwd,
2135
stdio: "inherit",
2236
timeout: 300_000,
2337
});
2438
return result.status === 0 || result.status === null;
2539
}
2640

41+
/** Pick which AI tool to use for interactive sync */
42+
async function pickSyncTool(configuredTools: AiTool[]): Promise<AiTool | null> {
43+
// Filter to tools that have a CLI and are installed
44+
const available = configuredTools.filter((t) => {
45+
const meta = AI_TOOLS[t];
46+
return meta.cli && hasCliTool(meta.cli);
47+
});
48+
49+
if (available.length === 0) return null;
50+
if (available.length === 1) return available[0];
51+
52+
// Multiple CLI tools available — ask user
53+
console.log(chalk.bold("\nWhich tool should fix these?"));
54+
console.log();
55+
available.forEach((t, i) => {
56+
console.log(` ${i + 1}) ${AI_TOOLS[t].name}`);
57+
});
58+
console.log();
59+
60+
const choice = await askUser(`Choice [1-${available.length}] (default: 1): `);
61+
const idx = parseInt(choice || "1", 10) - 1;
62+
return available[idx] ?? available[0];
63+
}
64+
2765
type SyncMode = "interactive" | "prompts";
2866

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

3776
while (true) {
3877
cycle++;
@@ -107,9 +146,17 @@ export async function runSync(
107146

108147
// Ask user for mode (only on first cycle)
109148
if (mode === null) {
149+
// Determine if any configured tool has a usable CLI
150+
const syncTool = await pickSyncTool(config.aiTools);
151+
const toolName = syncTool ? AI_TOOLS[syncTool].name : null;
152+
110153
console.log(chalk.bold("\nHow should we fix these?"));
111154
console.log();
112-
console.log(" 1) Interactive — Claude fixes with you watching (default)");
155+
if (toolName) {
156+
console.log(` 1) Interactive — ${toolName} fixes with you watching (default)`);
157+
} else {
158+
console.log(" 1) Interactive — AI fixes with you watching (default)");
159+
}
113160
console.log(" 2) Show prompts — I'll paste manually");
114161
console.log(" 3) Exit");
115162
console.log();
@@ -119,7 +166,15 @@ export async function runSync(
119166

120167
switch (picked) {
121168
case "1":
122-
mode = "interactive";
169+
if (!syncTool) {
170+
console.log(chalk.yellow("No supported AI CLI detected. Falling back to prompts mode."));
171+
console.log(chalk.dim("Supported CLIs: claude, opencode, codex"));
172+
console.log();
173+
mode = "prompts";
174+
} else {
175+
activeTool = syncTool;
176+
mode = "interactive";
177+
}
123178
break;
124179
case "2":
125180
mode = "prompts";
@@ -143,13 +198,14 @@ export async function runSync(
143198

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

148204
const brief = await buildCombinedBrief(targets, config.projectRoot);
149-
const ok = runClaudeInteractive(brief, config.projectRoot);
205+
const ok = runToolInteractive(activeTool!, brief, config.projectRoot);
150206

151207
if (!ok) {
152-
console.log(chalk.red("Claude session failed"));
208+
console.log(chalk.red(`${toolLabel} session failed`));
153209
}
154210

155211
// Step 4: Verify

src/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
11
// ── Shared Types ──
22

3+
// ── AI Tool ──
4+
5+
export type AiTool = "claude" | "cursor" | "windsurf" | "copilot" | "opencode" | "codex";
6+
7+
export interface AiToolMeta {
8+
name: string;
9+
cli: string | null;
10+
/** CLI flag to pass a prompt string directly */
11+
promptFlag: string[];
12+
}
13+
14+
export const AI_TOOLS: Record<AiTool, AiToolMeta> = {
15+
claude: { name: "Claude Code", cli: "claude", promptFlag: [] },
16+
cursor: { name: "Cursor", cli: null, promptFlag: [] },
17+
windsurf: { name: "Windsurf", cli: null, promptFlag: [] },
18+
copilot: { name: "Copilot", cli: null, promptFlag: [] },
19+
opencode: { name: "OpenCode", cli: "opencode", promptFlag: ["run"] },
20+
codex: { name: "Codex", cli: "codex", promptFlag: [] },
21+
};
22+
323
// ── Config ──
424

525
export interface MexConfig {
626
/** Absolute path to project root (where .git lives) */
727
projectRoot: string;
828
/** Absolute path to scaffold root (.mex/ directory) */
929
scaffoldRoot: string;
30+
/** Which AI tool(s) the user selected during setup */
31+
aiTools: AiTool[];
1032
}
1133

1234
// ── Claims (extracted from markdown) ──

test/config.test.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, it, expect, beforeEach, afterEach } from "vitest";
2-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
33
import { join } from "node:path";
44
import { tmpdir } from "node:os";
5-
import { findConfig } from "../src/config.js";
5+
import { findConfig, saveAiTools } from "../src/config.js";
66

77
let tmpDir: string;
88

@@ -63,4 +63,52 @@ describe("findConfig", () => {
6363
const config = findConfig(tmpDir);
6464
expect(config.scaffoldRoot).toBe(mexPath);
6565
});
66+
67+
it("returns empty aiTools when no config.json exists", () => {
68+
mkdirSync(join(tmpDir, ".git"));
69+
const mexPath = join(tmpDir, ".mex");
70+
mkdirSync(mexPath);
71+
writeFileSync(join(mexPath, "ROUTER.md"), "");
72+
const config = findConfig(tmpDir);
73+
expect(config.aiTools).toEqual([]);
74+
});
75+
76+
it("loads aiTools from config.json when present", () => {
77+
mkdirSync(join(tmpDir, ".git"));
78+
const mexPath = join(tmpDir, ".mex");
79+
mkdirSync(mexPath);
80+
writeFileSync(join(mexPath, "ROUTER.md"), "");
81+
writeFileSync(join(mexPath, "config.json"), JSON.stringify({ aiTools: ["opencode", "claude"] }));
82+
const config = findConfig(tmpDir);
83+
expect(config.aiTools).toEqual(["opencode", "claude"]);
84+
});
85+
});
86+
87+
describe("saveAiTools", () => {
88+
it("creates config.json with aiTools", () => {
89+
const mexPath = join(tmpDir, ".mex");
90+
mkdirSync(mexPath, { recursive: true });
91+
saveAiTools(mexPath, ["opencode"]);
92+
const raw = JSON.parse(readFileSync(join(mexPath, "config.json"), "utf-8"));
93+
expect(raw.aiTools).toEqual(["opencode"]);
94+
});
95+
96+
it("preserves existing config keys when saving", () => {
97+
const mexPath = join(tmpDir, ".mex");
98+
mkdirSync(mexPath, { recursive: true });
99+
writeFileSync(join(mexPath, "config.json"), JSON.stringify({ someOther: true }));
100+
saveAiTools(mexPath, ["codex"]);
101+
const raw = JSON.parse(readFileSync(join(mexPath, "config.json"), "utf-8"));
102+
expect(raw.aiTools).toEqual(["codex"]);
103+
expect(raw.someOther).toBe(true);
104+
});
105+
106+
it("overwrites previous aiTools value", () => {
107+
const mexPath = join(tmpDir, ".mex");
108+
mkdirSync(mexPath, { recursive: true });
109+
saveAiTools(mexPath, ["claude"]);
110+
saveAiTools(mexPath, ["opencode", "codex"]);
111+
const raw = JSON.parse(readFileSync(join(mexPath, "config.json"), "utf-8"));
112+
expect(raw.aiTools).toEqual(["opencode", "codex"]);
113+
});
66114
});

0 commit comments

Comments
 (0)