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,9 @@
---
"task-master-ai": minor
---
Add auto-detection for IDE profiles in rules command
- `tm rules add` now opens interactive setup with detected IDEs pre-selected
- `tm rules add -y` auto-detects and installs rules without prompting
- Detects 13 IDEs: Cursor, Claude Code, Windsurf, VS Code, Roo, Cline, Kiro, Zed, Kilo, Trae, Gemini, OpenCode, Codex

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "task-master-ai",
"version": "0.38.0",
"version": "0.39.0-rc.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "task-master-ai",
"version": "0.38.0",
"version": "0.39.0-rc.0",
"license": "MIT WITH Commons-Clause",
"workspaces": [
"apps/*",
@@ -1850,7 +1850,7 @@
"react-dom": "^19.0.0",
"tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11",
"task-master-ai": "*",
"task-master-ai": "0.39.0-rc.0",
"typescript": "^5.9.2"
},
"engines": {

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';

View File

@@ -145,8 +145,10 @@ import {
categorizeRemovalResults,
generateProfileRemovalSummary,
generateProfileSummary,
processRuleProfiles,
runInteractiveProfilesSetup
} from '../../src/utils/profiles.js';
import { detectInstalledIDEs } from '@tm/profiles';
import {
convertAllRulesToProfileRules,
getRulesProfile,
@@ -4393,14 +4395,19 @@ Examples:
'-m, --mode <mode>',
'Operating mode for filtering rules/commands (solo or team). Auto-detected from config if not specified.'
)
.option(
'-y, --yes',
'Non-interactive mode: auto-detect IDEs and install rules without prompting'
)
.addHelpText(
'after',
`
Examples:
$ task-master rules ${RULES_ACTIONS.ADD} windsurf roo # Add Windsurf and Roo rule sets
$ task-master rules ${RULES_ACTIONS.ADD} # Interactive setup with auto-detected IDEs pre-selected
$ task-master rules ${RULES_ACTIONS.ADD} -y # Auto-detect and install without prompting
$ task-master rules ${RULES_ACTIONS.ADD} windsurf roo # Add specific profiles
$ task-master rules ${RULES_ACTIONS.ADD} cursor --mode=team # Add Cursor rules for team mode only
$ task-master rules ${RULES_ACTIONS.REMOVE} windsurf # Remove Windsurf rule set
$ task-master rules --${RULES_SETUP_ACTION} # Interactive setup to select rule profiles`
$ task-master rules ${RULES_ACTIONS.REMOVE} windsurf # Remove Windsurf rule set`
)
.action(async (action, profiles, options) => {
const taskMaster = initTaskMaster({});
@@ -4425,7 +4432,8 @@ Examples:
*/
if (options[RULES_SETUP_ACTION]) {
// Run interactive rules setup ONLY (no project init)
const selectedRuleProfiles = await runInteractiveProfilesSetup();
const selectedRuleProfiles =
await runInteractiveProfilesSetup(projectRoot);
if (!selectedRuleProfiles || selectedRuleProfiles.length === 0) {
console.log(chalk.yellow('No profiles selected. Exiting.'));
@@ -4438,30 +4446,11 @@ Examples:
)
);
for (let i = 0; i < selectedRuleProfiles.length; i++) {
const profile = selectedRuleProfiles[i];
console.log(
chalk.blue(
`Processing profile ${i + 1}/${selectedRuleProfiles.length}: ${profile}...`
)
);
if (!isValidProfile(profile)) {
console.warn(
`Rule profile for "${profile}" not found. Valid profiles: ${RULE_PROFILES.join(', ')}. Skipping.`
);
continue;
}
const profileConfig = getRulesProfile(profile);
const mode = await getOperatingMode(options.mode);
const addResult = convertAllRulesToProfileRules(
projectRoot,
profileConfig,
{ mode }
);
console.log(chalk.green(generateProfileSummary(profile, addResult)));
}
await processRuleProfiles(
selectedRuleProfiles,
projectRoot,
options.mode
);
console.log(
chalk.green(
@@ -4486,6 +4475,98 @@ Examples:
process.exit(1);
}
/**
* 'task-master rules add' (no profiles):
*
* - Without -y: Opens interactive setup with auto-detected IDEs pre-selected
* - With -y: Non-interactive mode, installs rules for all detected IDEs
*
* Example usage:
* $ task-master rules add # Interactive setup with pre-selection
* $ task-master rules add -y # Auto-detect and install without prompting
*/
if (
action === RULES_ACTIONS.ADD &&
(!profiles || profiles.length === 0)
) {
let selectedRuleProfiles;
if (options.yes) {
// Non-interactive mode: auto-detect and install
console.log(chalk.blue('\n🔍 Auto-detecting installed IDEs...\n'));
const detected = detectInstalledIDEs({ projectRoot });
if (detected.length === 0) {
console.log(
chalk.yellow(
'No IDE markers detected in this project.\n' +
chalk.gray(
'Searched for: .cursor, .claude, .windsurf, .roo, .vscode, etc.\n'
)
)
);
console.log(
chalk.cyan(
'To manually select profiles, run without -y flag:\n' +
' task-master rules add\n\n' +
'Or specify profiles directly:\n' +
' task-master rules add cursor windsurf\n'
)
);
return;
}
// Show what was detected
console.log(chalk.green('✓ Detected the following IDEs:\n'));
for (const ide of detected) {
console.log(
`${ide.displayName} ${chalk.gray(`(${ide.markerPath})`)}`
);
}
console.log('');
selectedRuleProfiles = detected.map((d) => d.profileName);
} else {
// Interactive mode: open setup with pre-selection
selectedRuleProfiles = await runInteractiveProfilesSetup(projectRoot);
}
if (!selectedRuleProfiles || selectedRuleProfiles.length === 0) {
console.log(chalk.yellow('No profiles selected. Exiting.'));
return;
}
console.log(
chalk.blue(
`Installing ${selectedRuleProfiles.length} selected profile(s)...`
)
);
const addResults = await processRuleProfiles(
selectedRuleProfiles,
projectRoot,
options.mode
);
// Final summary
const { allSuccessfulProfiles, totalSuccess, totalFailed } =
categorizeProfileResults(addResults);
console.log(
chalk.green(
`\n✓ Successfully installed ${allSuccessfulProfiles.length} profile(s)`
)
);
if (totalSuccess > 0) {
console.log(
chalk.gray(
` ${totalSuccess} files processed, ${totalFailed} failed`
)
);
}
return;
}
if (!profiles || profiles.length === 0) {
console.error(
'Please specify at least one rule profile (e.g., windsurf, roo).'

View File

@@ -7,9 +7,14 @@ import path from 'path';
import boxen from 'boxen';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { log } from '../../scripts/modules/utils.js';
import { RULE_PROFILES } from '../constants/profiles.js';
import { getRulesProfile } from './rule-transformer.js';
import {
convertAllRulesToProfileRules,
getRulesProfile,
isValidProfile
} from './rule-transformer.js';
import { getPreSelectedProfiles } from '@tm/profiles';
import { getOperatingMode } from '../../scripts/modules/config-manager.js';
// =============================================================================
// PROFILE DETECTION
@@ -95,13 +100,28 @@ export function wouldRemovalLeaveNoProfiles(projectRoot, profilesToRemove) {
* Launches an interactive prompt for selecting which rule profiles to include in your project.
*
* This function dynamically lists all available profiles (from RULE_PROFILES) and presents them as checkboxes.
* The user must select at least one profile (no defaults are pre-selected). The result is an array of selected profile names.
* Detected IDE profiles (based on directory markers like .cursor, .claude, etc.) are pre-selected.
* The result is an array of selected profile names.
*
* Used by both project initialization (init) and the CLI 'task-master rules setup' command.
*
* @param {string} [projectRoot=process.cwd()] - Project root directory for IDE detection
* @returns {Promise<string[]>} Array of selected profile names (e.g., ['cursor', 'windsurf'])
*/
export async function runInteractiveProfilesSetup() {
export async function runInteractiveProfilesSetup(projectRoot = process.cwd()) {
// Auto-detect installed IDEs for pre-selection
const preSelected = getPreSelectedProfiles({ projectRoot });
if (preSelected.length > 0) {
const detectedNames = preSelected
.map((p) => getProfileDisplayName(p))
.join(', ');
console.log(
chalk.cyan(`\n🔍 Auto-detected IDEs: ${detectedNames}`) +
chalk.gray(' (pre-selected below)\n')
);
}
// Generate the profile list dynamically with proper display names, alphabetized
const profileDescriptions = RULE_PROFILES.map((profileName) => {
const displayName = getProfileDisplayName(profileName);
@@ -165,10 +185,14 @@ export async function runInteractiveProfilesSetup() {
);
// Generate choices in the same order as the display text above
// Pre-select profiles that were auto-detected
const sortedChoices = profileDescriptions.map(
({ profileName, displayName }) => ({
name: displayName,
value: profileName
name: preSelected.includes(profileName)
? `${displayName} ${chalk.dim('(detected)')}`
: displayName,
value: profileName,
checked: preSelected.includes(profileName)
})
);
@@ -185,6 +209,57 @@ export async function runInteractiveProfilesSetup() {
return ruleProfiles;
}
// =============================================================================
// PROFILE PROCESSING
// =============================================================================
/**
* Processes rule profiles by validating, resolving mode, and installing rules.
* @param {string[]} profiles - Array of profile names to process
* @param {string} projectRoot - Project root directory path
* @param {string|undefined} modeOption - Operating mode option from CLI
* @returns {Promise<Array<{profileName: string, success: number, failed: number}>>} Results for each profile
*/
export async function processRuleProfiles(profiles, projectRoot, modeOption) {
const results = [];
const mode = await getOperatingMode(modeOption);
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i];
console.log(
chalk.blue(
`Processing profile ${i + 1}/${profiles.length}: ${profile}...`
)
);
if (!isValidProfile(profile)) {
console.warn(
`Rule profile for "${profile}" not found. Valid profiles: ${RULE_PROFILES.join(', ')}. Skipping.`
);
continue;
}
const profileConfig = getRulesProfile(profile);
const addResult = convertAllRulesToProfileRules(
projectRoot,
profileConfig,
{
mode
}
);
results.push({
profileName: profile,
success: addResult.success,
failed: addResult.failed
});
console.log(chalk.green(generateProfileSummary(profile, addResult)));
}
return results;
}
// =============================================================================
// PROFILE SUMMARY
// =============================================================================