mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
Ralph/feat/add.auto.rules.add (#1535)
This commit is contained in:
242
packages/tm-profiles/src/detection/detector.spec.ts
Normal file
242
packages/tm-profiles/src/detection/detector.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
84
packages/tm-profiles/src/detection/detector.ts
Normal file
84
packages/tm-profiles/src/detection/detector.ts
Normal 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;
|
||||
}
|
||||
20
packages/tm-profiles/src/detection/index.ts
Normal file
20
packages/tm-profiles/src/detection/index.ts
Normal 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';
|
||||
108
packages/tm-profiles/src/detection/profiles-map.ts
Normal file
108
packages/tm-profiles/src/detection/profiles-map.ts
Normal 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);
|
||||
}
|
||||
25
packages/tm-profiles/src/detection/types.ts
Normal file
25
packages/tm-profiles/src/detection/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user