Ralph/feat/add.auto.rules.add (#1535)

This commit is contained in:
Ralph Khreish
2025-12-18 20:03:40 +01:00
committed by GitHub
parent 73e3fe8dd3
commit 4d1ed20345
10 changed files with 684 additions and 37 deletions

View File

@@ -0,0 +1,242 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
detectInstalledIDEs,
detectProfile,
getPreSelectedProfiles
} from './detector.js';
import { IDE_MARKERS } from './profiles-map.js';
// Use real fs operations with temp directories for accurate testing
describe('IDE Detection', () => {
let tempDir: string;
beforeEach(() => {
// Create a real temp directory for each test
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tm-detection-test-'));
});
afterEach(() => {
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
describe('detectInstalledIDEs', () => {
it('should detect a single IDE directory', () => {
// Arrange - create .cursor directory
fs.mkdirSync(path.join(tempDir, '.cursor'));
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert
expect(results).toHaveLength(1);
expect(results[0]).toEqual({
profileName: 'cursor',
markerPath: '.cursor',
displayName: 'Cursor',
exists: true
});
});
it('should detect multiple IDE directories', () => {
// Arrange - create multiple IDE directories
fs.mkdirSync(path.join(tempDir, '.cursor'));
fs.mkdirSync(path.join(tempDir, '.claude'));
fs.mkdirSync(path.join(tempDir, '.windsurf'));
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert
expect(results).toHaveLength(3);
const profileNames = results.map((r) => r.profileName);
expect(profileNames).toContain('cursor');
expect(profileNames).toContain('claude');
expect(profileNames).toContain('windsurf');
});
it('should return empty array when no IDEs are detected', () => {
// Act - tempDir has no IDE directories
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert
expect(results).toHaveLength(0);
});
it('should detect directory markers for all directory-based profiles', () => {
// Arrange - create all directory-based markers
const directoryMarkers = [
'.cursor',
'.claude',
'.windsurf',
'.roo',
'.cline',
'.vscode',
'.kiro',
'.zed',
'.kilo',
'.trae',
'.gemini',
'.opencode',
'.codex'
];
for (const marker of directoryMarkers) {
fs.mkdirSync(path.join(tempDir, marker));
}
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert
expect(results.length).toBe(directoryMarkers.length);
});
it('should detect file-based markers (GEMINI.md)', () => {
// Arrange - create GEMINI.md file (but no .gemini directory)
fs.writeFileSync(path.join(tempDir, 'GEMINI.md'), '# Gemini config');
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert
expect(results).toHaveLength(1);
expect(results[0]).toEqual({
profileName: 'gemini',
markerPath: 'GEMINI.md',
displayName: 'Gemini',
exists: true
});
});
it('should prefer directory marker over file marker for Gemini', () => {
// Arrange - create both .gemini directory and GEMINI.md file
fs.mkdirSync(path.join(tempDir, '.gemini'));
fs.writeFileSync(path.join(tempDir, 'GEMINI.md'), '# Gemini config');
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert - should only detect once, with directory marker
const geminiResults = results.filter((r) => r.profileName === 'gemini');
expect(geminiResults).toHaveLength(1);
expect(geminiResults[0].markerPath).toBe('.gemini');
});
it('should not detect files as directories', () => {
// Arrange - create a file named .cursor instead of directory
fs.writeFileSync(path.join(tempDir, '.cursor'), 'not a directory');
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert - should not detect because it's a file, not a directory
expect(results.filter((r) => r.profileName === 'cursor')).toHaveLength(0);
});
it('should not detect directories as files', () => {
// Arrange - create a directory named GEMINI.md instead of file
fs.mkdirSync(path.join(tempDir, 'GEMINI.md'));
// Act
const results = detectInstalledIDEs({ projectRoot: tempDir });
// Assert - should not detect GEMINI.md as it's a directory, not a file
const geminiResults = results.filter((r) => r.profileName === 'gemini');
expect(geminiResults).toHaveLength(0);
});
});
describe('getPreSelectedProfiles', () => {
it('should return profile names only', () => {
// Arrange
fs.mkdirSync(path.join(tempDir, '.cursor'));
fs.mkdirSync(path.join(tempDir, '.claude'));
// Act
const profiles = getPreSelectedProfiles({ projectRoot: tempDir });
// Assert
expect(profiles).toEqual(expect.arrayContaining(['cursor', 'claude']));
expect(profiles).toHaveLength(2);
});
it('should return empty array when no IDEs detected', () => {
// Act
const profiles = getPreSelectedProfiles({ projectRoot: tempDir });
// Assert
expect(profiles).toEqual([]);
});
});
describe('detectProfile', () => {
it('should return detection result for existing profile', () => {
// Arrange
fs.mkdirSync(path.join(tempDir, '.cursor'));
// Act
const result = detectProfile('cursor', tempDir);
// Assert
expect(result).toEqual({
profileName: 'cursor',
markerPath: '.cursor',
displayName: 'Cursor',
exists: true
});
});
it('should return null for non-existing profile', () => {
// Act - no .cursor directory exists
const result = detectProfile('cursor', tempDir);
// Assert
expect(result).toBeNull();
});
it('should return null for non-detectable profile (amp)', () => {
// Act
const result = detectProfile('amp', tempDir);
// Assert
expect(result).toBeNull();
});
});
});
describe('IDE_MARKERS', () => {
it('should have 13 detectable profiles', () => {
// amp is not detectable
expect(IDE_MARKERS).toHaveLength(13);
});
it('should have valid marker definitions for all profiles', () => {
for (const marker of IDE_MARKERS) {
expect(marker.profileName).toBeTruthy();
expect(marker.displayName).toBeTruthy();
expect(marker.markers).toBeInstanceOf(Array);
expect(marker.markers.length).toBeGreaterThan(0);
for (const m of marker.markers) {
expect(m.path).toBeTruthy();
expect(['directory', 'file']).toContain(m.type);
}
}
});
it('should include cursor, claude, and windsurf profiles', () => {
const profileNames = IDE_MARKERS.map((m) => m.profileName);
expect(profileNames).toContain('cursor');
expect(profileNames).toContain('claude');
expect(profileNames).toContain('windsurf');
});
it('should not include amp (no known marker)', () => {
const profileNames = IDE_MARKERS.map((m) => m.profileName);
expect(profileNames).not.toContain('amp');
});
});

View File

@@ -0,0 +1,84 @@
/**
* IDE detection logic for auto-detecting installed IDEs in project directories.
*/
import fs from 'fs';
import path from 'path';
import { IDE_MARKERS } from './profiles-map.js';
import type { DetectionResult, DetectionOptions } from './types.js';
/**
* Detect installed IDEs by checking for marker directories/files.
*
* @param options - Detection options including project root
* @returns Array of detected IDE profiles
*
* @example
* ```typescript
* const detected = detectInstalledIDEs({ projectRoot: '/path/to/project' });
* // Returns: [{ profileName: 'cursor', markerPath: '.cursor', displayName: 'Cursor', exists: true }]
* ```
*/
export function detectInstalledIDEs(
options: DetectionOptions
): DetectionResult[] {
const { projectRoot } = options;
const results: DetectionResult[] = [];
for (const ideMarker of IDE_MARKERS) {
// Check each marker (directory or file) - first match wins
for (const marker of ideMarker.markers) {
const fullPath = path.join(projectRoot, marker.path);
try {
const stat = fs.statSync(fullPath);
const exists =
marker.type === 'directory' ? stat.isDirectory() : stat.isFile();
if (exists) {
results.push({
profileName: ideMarker.profileName,
markerPath: marker.path,
displayName: ideMarker.displayName,
exists: true
});
break; // Found one marker, no need to check others for this IDE
}
} catch {
// File/directory doesn't exist or can't be accessed - continue to next marker
}
}
}
return results;
}
/**
* Get profile names that should be pre-selected based on detected IDEs.
*
* @param options - Detection options including project root
* @returns Array of profile names (e.g., ['cursor', 'claude'])
*
* @example
* ```typescript
* const profiles = getPreSelectedProfiles({ projectRoot: '/path/to/project' });
* // Returns: ['cursor', 'claude']
* ```
*/
export function getPreSelectedProfiles(options: DetectionOptions): string[] {
return detectInstalledIDEs(options).map((r) => r.profileName);
}
/**
* Detect a specific profile's IDE marker.
*
* @param profileName - Profile to check (e.g., 'cursor')
* @param projectRoot - Project root directory
* @returns Detection result if found, null otherwise
*/
export function detectProfile(
profileName: string,
projectRoot: string
): DetectionResult | null {
const detected = detectInstalledIDEs({ projectRoot });
return detected.find((r) => r.profileName === profileName) ?? null;
}

View File

@@ -0,0 +1,20 @@
/**
* IDE Detection Module
*
* Provides functions to auto-detect installed IDEs based on project markers.
* Used for pre-selecting profiles in `tm rules add` and
* non-interactive mode with `tm rules add -y`.
*/
export type { DetectionResult, DetectionOptions } from './types.js';
export type { IDEMarker } from './profiles-map.js';
export {
IDE_MARKERS,
getIDEMarker,
isDetectableProfile
} from './profiles-map.js';
export {
detectInstalledIDEs,
getPreSelectedProfiles,
detectProfile
} from './detector.js';

View File

@@ -0,0 +1,108 @@
/**
* IDE marker definitions for auto-detection.
* Maps profile names to their filesystem markers.
*/
/**
* Represents an IDE marker configuration
*/
export interface IDEMarker {
/** Profile name matching RULE_PROFILES */
profileName: string;
/** Markers to check (first match wins) */
markers: Array<{ path: string; type: 'directory' | 'file' }>;
/** Human-readable display name */
displayName: string;
}
/**
* IDE marker definitions - directory or file-based detection.
* Order matches typical usage frequency for slightly faster detection.
*/
export const IDE_MARKERS: IDEMarker[] = [
// Most common IDE profiles
{
profileName: 'cursor',
markers: [{ path: '.cursor', type: 'directory' }],
displayName: 'Cursor'
},
{
profileName: 'claude',
markers: [{ path: '.claude', type: 'directory' }],
displayName: 'Claude Code'
},
{
profileName: 'windsurf',
markers: [{ path: '.windsurf', type: 'directory' }],
displayName: 'Windsurf'
},
{
profileName: 'vscode',
markers: [{ path: '.vscode', type: 'directory' }],
displayName: 'VS Code'
},
{
profileName: 'roo',
markers: [{ path: '.roo', type: 'directory' }],
displayName: 'Roo Code'
},
{
profileName: 'cline',
markers: [{ path: '.cline', type: 'directory' }],
displayName: 'Cline'
},
{
profileName: 'kiro',
markers: [{ path: '.kiro', type: 'directory' }],
displayName: 'Kiro'
},
{
profileName: 'zed',
markers: [{ path: '.zed', type: 'directory' }],
displayName: 'Zed'
},
{
profileName: 'kilo',
markers: [{ path: '.kilo', type: 'directory' }],
displayName: 'Kilo Code'
},
{
profileName: 'trae',
markers: [{ path: '.trae', type: 'directory' }],
displayName: 'Trae'
},
// Integration guides with detectable markers
{
profileName: 'gemini',
markers: [
{ path: '.gemini', type: 'directory' },
{ path: 'GEMINI.md', type: 'file' }
],
displayName: 'Gemini'
},
{
profileName: 'opencode',
markers: [{ path: '.opencode', type: 'directory' }],
displayName: 'OpenCode'
},
{
profileName: 'codex',
markers: [{ path: '.codex', type: 'directory' }],
displayName: 'Codex'
}
// Note: 'amp' has no known project-local marker
];
/**
* Get IDE marker config for a specific profile
*/
export function getIDEMarker(profileName: string): IDEMarker | undefined {
return IDE_MARKERS.find((m) => m.profileName === profileName);
}
/**
* Check if a profile has detectable markers
*/
export function isDetectableProfile(profileName: string): boolean {
return IDE_MARKERS.some((m) => m.profileName === profileName);
}

View File

@@ -0,0 +1,25 @@
/**
* Detection types for auto-detecting IDE markers in project directories.
*/
/**
* Result of detecting an IDE marker in the project
*/
export interface DetectionResult {
/** Profile name (e.g., 'cursor', 'claude') */
profileName: string;
/** The marker path that was found (e.g., '.cursor') */
markerPath: string;
/** Human-readable display name (e.g., 'Cursor', 'Claude Code') */
displayName: string;
/** Whether the marker exists */
exists: boolean;
}
/**
* Options for IDE detection
*/
export interface DetectionOptions {
/** Project root directory to search in */
projectRoot: string;
}

View File

@@ -8,3 +8,6 @@ export * from './slash-commands/index.js';
// Re-export shell utilities
export * from './shell-utils.js';
// Re-export IDE detection utilities
export * from './detection/index.js';