Skip to content

Commit f01f41f

Browse files
committed
Add conflict detection and marr doctor command (#56)
* Add conflict detection and marr doctor command (#13) Implements integration support for existing Claude Code configurations: - Add conflict detector for directive conflicts, duplicate standards, missing imports - Enhance marr validate with --conflicts and --json flags - Add marr doctor command for interactive conflict resolution - Add backup utility for safe file modifications * Add dynamic conflict detection from installed MARR standards - Create standards-reader.ts to parse standards and extract directives - Create config-scanner.ts to discover all config/prompt files - Refactor conflict-detector to use semantic matching instead of hardcoded patterns - Detect .cursorrules, copilot-instructions.md, and custom standards - Follow @import chains for transitive conflict detection
1 parent 557c165 commit f01f41f

12 files changed

Lines changed: 3415 additions & 32 deletions

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"lint": "eslint src/**/*.ts",
1414
"check-bin": "node scripts/check-binary-name.js",
1515
"prepack": "npm run check-bin && tsc",
16-
"prepublishOnly": "npm run check-bin && npm run build && npm test"
16+
"prepublishOnly": "npm run check-bin && npm run build && npm test",
17+
"reinstall": "npm run build && npm install -g ."
1718
},
1819
"keywords": [
1920
"ai",

src/commands/doctor.ts

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
/**
2+
* Doctor command - Interactive conflict resolution
3+
*
4+
* Walks through detected conflicts and helps resolve them
5+
*/
6+
7+
import { Command } from 'commander';
8+
import * as readline from 'readline';
9+
import * as logger from '../utils/logger.js';
10+
import * as fileOps from '../utils/file-ops.js';
11+
import * as backup from '../utils/backup.js';
12+
import {
13+
detectProjectConflicts,
14+
detectUserConflicts,
15+
} from '../utils/conflict-detector.js';
16+
import type {
17+
Conflict,
18+
Resolution,
19+
ResolutionAction,
20+
} from '../types/conflict.js';
21+
22+
interface DoctorOptions {
23+
auto?: boolean;
24+
user?: boolean;
25+
project?: boolean;
26+
dryRun?: boolean;
27+
}
28+
29+
export function doctorCommand(program: Command): void {
30+
program
31+
.command('doctor')
32+
.description('Interactive conflict resolution for MARR configuration')
33+
.option('--auto', 'Automatically apply recommended resolutions')
34+
.option('-u, --user', 'Only check user-level configuration')
35+
.option('-p, --project', 'Only check project-level configuration')
36+
.option('-n, --dry-run', 'Preview what would be changed without applying')
37+
.addHelpText('after', `
38+
What it does:
39+
1. Detects conflicts between your config and MARR standards
40+
2. Walks through each conflict interactively
41+
3. Lets you choose how to resolve each conflict
42+
4. Creates backups before any modifications
43+
44+
Workflow:
45+
$ marr validate # See what conflicts exist
46+
$ marr doctor # Resolve conflicts interactively
47+
48+
Examples:
49+
$ marr doctor Interactive resolution
50+
$ marr doctor --auto Apply recommended fixes automatically
51+
$ marr doctor --dry-run Preview without making changes
52+
$ marr doctor --user Only check user config (~/.claude/)
53+
$ marr doctor --project Only check project config`)
54+
.action(async (options: DoctorOptions) => {
55+
try {
56+
await executeDoctor(options);
57+
} catch (err) {
58+
logger.error((err as Error).message);
59+
process.exit(1);
60+
}
61+
});
62+
}
63+
64+
async function executeDoctor(options: DoctorOptions): Promise<void> {
65+
const { auto, user, project, dryRun } = options;
66+
67+
logger.section(dryRun ? 'MARR Doctor (Dry Run)' : 'MARR Doctor');
68+
logger.blank();
69+
70+
// Determine scope
71+
const checkUser = user || (!user && !project);
72+
const checkProject = project || (!user && !project);
73+
74+
// Detect conflicts
75+
logger.info('Scanning for conflicts...');
76+
const conflicts: Conflict[] = [];
77+
78+
if (checkUser) {
79+
conflicts.push(...detectUserConflicts());
80+
}
81+
82+
if (checkProject) {
83+
conflicts.push(...detectProjectConflicts());
84+
}
85+
86+
if (conflicts.length === 0) {
87+
logger.blank();
88+
logger.success('No conflicts detected! Your configuration is clean.');
89+
return;
90+
}
91+
92+
logger.blank();
93+
logger.log(`Found ${conflicts.length} conflict(s) to resolve.`);
94+
logger.blank();
95+
96+
// Process conflicts
97+
const actions: ResolutionAction[] = [];
98+
99+
if (auto) {
100+
// Auto mode: apply recommended resolutions
101+
for (const conflict of conflicts) {
102+
const recommended = conflict.resolutions.find(r => r.recommended);
103+
const resolution = recommended || conflict.resolutions[0];
104+
105+
const action = await applyResolution(conflict, resolution, dryRun);
106+
actions.push(action);
107+
}
108+
} else {
109+
// Interactive mode
110+
const rl = readline.createInterface({
111+
input: process.stdin,
112+
output: process.stdout,
113+
});
114+
115+
for (let i = 0; i < conflicts.length; i++) {
116+
const conflict = conflicts[i];
117+
const action = await resolveConflictInteractively(rl, conflict, i + 1, conflicts.length, dryRun);
118+
actions.push(action);
119+
}
120+
121+
rl.close();
122+
}
123+
124+
// Display summary
125+
displaySummary(actions, dryRun);
126+
}
127+
128+
/**
129+
* Interactively resolve a single conflict
130+
*/
131+
async function resolveConflictInteractively(
132+
rl: readline.Interface,
133+
conflict: Conflict,
134+
current: number,
135+
total: number,
136+
dryRun?: boolean
137+
): Promise<ResolutionAction> {
138+
logger.section(`[${current}/${total}] ${getCategoryLabel(conflict.category)}`);
139+
140+
// Display conflict details
141+
logger.log(` Location: ${conflict.location}${conflict.line ? `:${conflict.line}` : ''}`);
142+
logger.log(` Found: "${conflict.existing}"`);
143+
144+
if (conflict.marrExpects) {
145+
logger.log(` MARR expects: ${conflict.marrExpects}`);
146+
}
147+
148+
if (conflict.marrSource) {
149+
logger.log(` Source: ${conflict.marrSource}`);
150+
}
151+
152+
logger.blank();
153+
logger.log(' Options:');
154+
155+
// Display resolution options
156+
for (const resolution of conflict.resolutions) {
157+
const marker = resolution.recommended ? ' (recommended)' : '';
158+
logger.log(` [${resolution.key}] ${resolution.label}${marker}`);
159+
logger.log(` ${resolution.description}`);
160+
}
161+
162+
logger.blank();
163+
164+
// Get user choice
165+
const choice = await question(rl, ' Choice: ');
166+
const selectedResolution = conflict.resolutions.find(
167+
r => r.key.toLowerCase() === choice.toLowerCase()
168+
);
169+
170+
if (!selectedResolution) {
171+
logger.warning(' Invalid choice, skipping...');
172+
return {
173+
conflictId: conflict.id,
174+
resolution: 'skipped',
175+
success: false,
176+
error: 'Invalid choice',
177+
};
178+
}
179+
180+
return applyResolution(conflict, selectedResolution, dryRun);
181+
}
182+
183+
/**
184+
* Apply a resolution to a conflict
185+
*/
186+
async function applyResolution(
187+
conflict: Conflict,
188+
resolution: Resolution,
189+
dryRun?: boolean
190+
): Promise<ResolutionAction> {
191+
const action: ResolutionAction = {
192+
conflictId: conflict.id,
193+
resolution: resolution.label,
194+
success: true,
195+
};
196+
197+
// Skip resolution
198+
if (resolution.key === 's') {
199+
logger.info(` Skipped: ${conflict.description}`);
200+
return action;
201+
}
202+
203+
// For dry run, just report what would happen
204+
if (dryRun) {
205+
logger.info(` Would apply: ${resolution.label}`);
206+
return action;
207+
}
208+
209+
// Create backup before modifying
210+
if (fileOps.exists(conflict.location)) {
211+
const backupPath = backup.createBackup(conflict.location);
212+
if (backupPath) {
213+
action.backupPath = backupPath;
214+
logger.success(` Backup created: ${backupPath}`);
215+
}
216+
}
217+
218+
// Apply the resolution based on category and choice
219+
try {
220+
switch (conflict.category) {
221+
case 'missing_import':
222+
if (resolution.key === 'a') {
223+
await addMissingImport(conflict);
224+
logger.success(` Added MARR import to ${conflict.location}`);
225+
}
226+
break;
227+
228+
case 'directive_conflict':
229+
if (resolution.key === 'm') {
230+
await removeConflictingDirective(conflict);
231+
logger.success(` Removed conflicting directive from ${conflict.location}`);
232+
} else if (resolution.key === 'k') {
233+
logger.info(` Keeping existing directive`);
234+
}
235+
break;
236+
237+
case 'duplicate_standard':
238+
if (resolution.key === 'm') {
239+
// For now, just inform user - removing files is dangerous
240+
logger.info(` Consider removing: ${conflict.location}`);
241+
logger.info(` MARR provides this functionality via ${conflict.marrSource}`);
242+
} else if (resolution.key === 'k') {
243+
logger.info(` Keeping your custom standard`);
244+
} else if (resolution.key === 'b') {
245+
logger.warning(` Keeping both - may cause conflicts`);
246+
}
247+
break;
248+
249+
case 'override_after_import':
250+
if (resolution.key === 'r') {
251+
await removeConflictingDirective(conflict);
252+
logger.success(` Removed override from ${conflict.location}`);
253+
} else if (resolution.key === 'k') {
254+
logger.info(` Keeping intentional override`);
255+
}
256+
break;
257+
}
258+
} catch (err) {
259+
action.success = false;
260+
action.error = (err as Error).message;
261+
logger.error(` Failed: ${action.error}`);
262+
}
263+
264+
return action;
265+
}
266+
267+
/**
268+
* Add missing MARR import to a file
269+
*/
270+
async function addMissingImport(conflict: Conflict): Promise<void> {
271+
const content = fileOps.readFile(conflict.location);
272+
273+
// Determine which import to add based on location
274+
const isUserConfig = conflict.location.includes('.claude/CLAUDE.md') &&
275+
conflict.location.includes(fileOps.getHomeDir());
276+
277+
const importLine = isUserConfig
278+
? '@~/.claude/marr/MARR-USER-CLAUDE.md'
279+
: '@.claude/marr/MARR-PROJECT-CLAUDE.md';
280+
281+
const importComment = '<!-- MARR: Making Agents Really Reliable -->';
282+
const importBlock = `${importComment}\n${importLine}\n`;
283+
284+
// Add after first heading or at top
285+
const lines = content.split('\n');
286+
const firstHeadingIndex = lines.findIndex(line => line.startsWith('# '));
287+
288+
let newContent: string;
289+
if (firstHeadingIndex >= 0) {
290+
lines.splice(firstHeadingIndex + 1, 0, '', importBlock);
291+
newContent = lines.join('\n');
292+
} else {
293+
newContent = importBlock + '\n' + content;
294+
}
295+
296+
fileOps.writeFile(conflict.location, newContent);
297+
}
298+
299+
/**
300+
* Remove a conflicting directive from a file
301+
*/
302+
async function removeConflictingDirective(conflict: Conflict): Promise<void> {
303+
if (!conflict.line) {
304+
throw new Error('Cannot remove directive without line number');
305+
}
306+
307+
const content = fileOps.readFile(conflict.location);
308+
const lines = content.split('\n');
309+
310+
// Remove the conflicting line
311+
const lineIndex = conflict.line - 1;
312+
if (lineIndex >= 0 && lineIndex < lines.length) {
313+
lines.splice(lineIndex, 1);
314+
}
315+
316+
fileOps.writeFile(conflict.location, lines.join('\n'));
317+
}
318+
319+
/**
320+
* Get human-readable label for conflict category
321+
*/
322+
function getCategoryLabel(category: string): string {
323+
const labels: Record<string, string> = {
324+
directive_conflict: 'Directive Conflict',
325+
duplicate_standard: 'Duplicate Standard',
326+
missing_import: 'Missing MARR Import',
327+
override_after_import: 'Override After Import',
328+
};
329+
return labels[category] || category;
330+
}
331+
332+
/**
333+
* Display summary of actions taken
334+
*/
335+
function displaySummary(actions: ResolutionAction[], dryRun?: boolean): void {
336+
logger.blank();
337+
logger.section(dryRun ? 'Summary (Dry Run)' : 'Summary');
338+
339+
const resolved = actions.filter(a => a.success && a.resolution !== 'skipped').length;
340+
const skipped = actions.filter(a => a.resolution === 'skipped' || a.resolution.toLowerCase().includes('skip')).length;
341+
const failed = actions.filter(a => !a.success).length;
342+
343+
logger.log(` Resolved: ${resolved}`);
344+
logger.log(` Skipped: ${skipped}`);
345+
logger.log(` Failed: ${failed}`);
346+
347+
// List backups created
348+
const backups = actions.filter(a => a.backupPath);
349+
if (backups.length > 0) {
350+
logger.blank();
351+
logger.info('Backups created:');
352+
for (const action of backups) {
353+
logger.log(` ${action.backupPath}`);
354+
}
355+
}
356+
357+
logger.blank();
358+
359+
if (dryRun) {
360+
logger.info('Dry run complete. Run without --dry-run to apply changes.');
361+
} else if (failed === 0) {
362+
logger.success('Doctor complete!');
363+
} else {
364+
logger.warning(`Doctor complete with ${failed} failure(s).`);
365+
}
366+
}
367+
368+
/**
369+
* Promise-based readline question
370+
*/
371+
function question(rl: readline.Interface, prompt: string): Promise<string> {
372+
return new Promise((resolve) => {
373+
rl.question(prompt, (answer) => {
374+
resolve(answer.trim());
375+
});
376+
});
377+
}

0 commit comments

Comments
 (0)