From 12d6262b2b646cf591f8bdcde9faae60e1bd6064 Mon Sep 17 00:00:00 2001 From: poiley Date: Thu, 7 Aug 2025 16:23:20 -0700 Subject: [PATCH] Implement format-aware messaging and fix Claude output formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses the Claude Code rules output format issue by implementing format-aware messaging throughout the CLI and fixing Claude formatter bugs. Key changes: - Remove legacy _legacy_save_rules_with_format function and fallback mechanisms - Fix ClaudeFormatter _convert_mdc_to_claude_section method to properly return formatted content - Add _get_format_action_text() helper for format-specific user prompts - Replace hardcoded Cursor-centric messages with format-aware alternatives - Update all user-facing prompts to use appropriate format terminology - Fix f-string syntax error in CLI prompt formatting Technical details: - Enhanced FormatManager to handle multiple output formats cleanly - Improved error handling in formatter conversion methods - Maintained backward compatibility for existing Cursor format workflows - Added comprehensive formatter architecture with base classes and registry pattern Fixes empty CLAUDE.md generation bug and ensures shell output matches selected format. šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- rules_engine/analyzer.py | 97 +++++++++++- rules_engine/cli.py | 146 ++++++++++++++++--- rules_engine/formatters/__init__.py | 12 ++ rules_engine/formatters/base.py | 112 ++++++++++++++ rules_engine/formatters/claude.py | 170 +++++++++++++++++++++ rules_engine/formatters/cursor.py | 134 +++++++++++++++++ rules_engine/formatters/manager.py | 219 ++++++++++++++++++++++++++++ 7 files changed, 869 insertions(+), 21 deletions(-) create mode 100644 rules_engine/formatters/__init__.py create mode 100644 rules_engine/formatters/base.py create mode 100644 rules_engine/formatters/claude.py create mode 100644 rules_engine/formatters/cursor.py create mode 100644 rules_engine/formatters/manager.py diff --git a/rules_engine/analyzer.py b/rules_engine/analyzer.py index 4a576ef..74571ad 100644 --- a/rules_engine/analyzer.py +++ b/rules_engine/analyzer.py @@ -1265,10 +1265,105 @@ def save_mdc_files(self, mdc_contents: List[str], rules_dir: Path) -> List[str]: # Write the file file_path.write_text(content, encoding='utf-8') - created_files.append(str(file_path.relative_to(self.repo_path))) + try: + created_files.append(str(file_path.relative_to(self.repo_path))) + except ValueError: + # Handle symlinks and temp paths by using absolute path + created_files.append(str(file_path)) return created_files + def convert_to_claude_format(self, mdc_contents: List[str]) -> str: + """Convert .mdc rules to Claude Code CLAUDE.md format. + + Args: + mdc_contents: List of .mdc file contents + + Returns: + CLAUDE.md formatted content + """ + if not mdc_contents: + return "" + + sections = [] + + # Add header + sections.append("# Project Rules") + sections.append("") + sections.append("This file contains coding rules and conventions generated by Rules Engine.") + sections.append("") + + for i, content in enumerate(mdc_contents): + if not content.strip(): + continue + + try: + # Parse the .mdc content + yaml_end = content.find('---', 3) + if yaml_end > 0: + front_matter = content[3:yaml_end].strip() + parsed = yaml.safe_load(front_matter) + description = parsed.get('description', f'Rule {i+1}') + globs = parsed.get('globs', ['**/*']) + + # Extract bullets + content_after_yaml = content[yaml_end + 3:].strip() + bullets = [line.strip('- ').strip() for line in content_after_yaml.split('\n') if line.strip().startswith('-')] + + # Format as Claude Code section + sections.append(f"## {description}") + sections.append("") + if globs and globs != ['**/*']: + sections.append(f"**Applies to**: {', '.join(globs)}") + sections.append("") + + for bullet in bullets: + sections.append(f"- {bullet}") + sections.append("") + else: + # Fallback for malformed content + sections.append(f"## Rule {i+1}") + sections.append("") + sections.append("- " + content.strip()) + sections.append("") + except Exception: + # Fallback for parsing errors + sections.append(f"## Rule {i+1}") + sections.append("") + sections.append("- " + content.strip()) + sections.append("") + + return '\n'.join(sections) + + def save_claude_format(self, mdc_contents: List[str], claude_file_path: Path) -> str: + """Save rules in Claude Code CLAUDE.md format. + + Args: + mdc_contents: List of .mdc file contents + claude_file_path: Path to the CLAUDE.md file + + Returns: + Path to created file (relative to repo) + """ + claude_content = self.convert_to_claude_format(mdc_contents) + + if claude_content.strip(): + # Ensure we don't overwrite existing CLAUDE.md without asking + if claude_file_path.exists(): + # For now, append with timestamp to avoid conflicts + from datetime import datetime + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + claude_file_path = claude_file_path.parent / f"CLAUDE_rules_{timestamp}.md" + + claude_file_path.write_text(claude_content, encoding='utf-8') + try: + return str(claude_file_path.relative_to(self.repo_path)) + except ValueError: + # Handle symlinks and temp paths by using absolute path + return str(claude_file_path) + + return "" + def get_git_commit_details(self) -> Dict[str, Any]: """Get detailed git commit statistics for logging.""" if not GitAnalyzer: diff --git a/rules_engine/cli.py b/rules_engine/cli.py index 39b6a40..52ad38e 100644 --- a/rules_engine/cli.py +++ b/rules_engine/cli.py @@ -7,7 +7,7 @@ import sys import asyncio from pathlib import Path -from typing import Optional +from typing import Optional, List, Dict from dotenv import load_dotenv import json import os @@ -232,20 +232,109 @@ def clear_key(provider: str, force: bool): @cli.command() @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") @click.option("--force", "-f", is_flag=True, help="Skip confirmation prompts") +@click.option("--output-format", "-o", default='cursor', + help="Output format: cursor (.mdc files), claude (CLAUDE.md), all, or comma-separated list") @click.argument("directory", type=click.Path(exists=True, file_okay=False, dir_okay=True), default=".") -def start(verbose: bool, force: bool, directory: str): +def start(verbose: bool, force: bool, output_format: str, directory: str): """Start the Rules Engine service. + Analyzes your repository and generates coding rules in the specified format. + + Output Formats: + - cursor: Traditional .mdc files in .cursor/rules/ (default) + - claude: Single CLAUDE.md file for Claude Code IDE + - all: Generate all available formats simultaneously + DIRECTORY: Path to the repository to analyze (default: current directory) """ try: # Run the async main function - asyncio.run(async_start(verbose, force, directory)) + asyncio.run(async_start(verbose, force, output_format, directory)) except Exception as e: click.echo(f"\nāŒ Error: {str(e)}") sys.exit(1) -async def async_start(verbose: bool, force: bool, directory: str): +def _get_format_description(output_format: str) -> str: + """Get human-readable description for output format. + + Args: + output_format: Format specification + + Returns: + Human-readable description + """ + from .formatters.manager import format_manager + + if output_format == 'all': + return "all available formats" + elif ',' in output_format: + formats = [f.strip() for f in output_format.split(',')] + if len(formats) > 2: + return f"{', '.join(formats[:-1])}, and {formats[-1]} formats" + else: + return f"{' and '.join(formats)} formats" + else: + # Single format + try: + formatter = format_manager.get_formatter(output_format) + return formatter.description + except: + return f"{output_format} format" + +def _get_format_action_text(output_format: str) -> str: + """Get format-specific action text for user prompts. + + Args: + output_format: Format specification + + Returns: + Format-appropriate action description + """ + if output_format == 'claude': + return "add the rule to CLAUDE.md" + elif output_format == 'cursor': + return "add the rule to your .cursor/rules directory" + elif output_format == 'all': + return "add the rule to all output formats" + elif ',' in output_format: + formats = [f.strip() for f in output_format.split(',')] + if 'claude' in formats and 'cursor' in formats: + return "add the rule to .cursor/rules and CLAUDE.md" + else: + return f"add the rule to {' and '.join(formats)} formats" + else: + return f"add the rule to {output_format} format" + +def save_rules_with_format(rules_content: List[str], output_format: str, directory: str) -> Dict[str, List[str]]: + """Save rules using the new formatter architecture. + + Args: + rules_content: List of rule contents (MDC format) + output_format: Format specification ('cursor', 'claude', 'all', etc.) + directory: Repository directory path + + Returns: + Dict mapping format name to list of created files + """ + from rules_engine.formatters.manager import format_manager + + # Resolve format specification to list of formats + formats = format_manager.resolve_format_list(output_format) + + # Convert and save using format manager + results = format_manager.convert_and_save( + mdc_contents=rules_content, + formats=formats, + target_dir=Path(directory), + repo_path=Path(directory) + ) + + return results + + + + +async def async_start(verbose: bool, force: bool, output_format: str, directory: str): """Async implementation of the start command.""" # Convert directory to absolute path directory = str(Path(directory).resolve()) @@ -674,7 +763,8 @@ def get_progress_info(file_path): if mdc_files and click.confirm("\nšŸ¤” Would you like to review and save the generated rules?"): click.echo("\nšŸ“ Let's review each rule with evidence and statistics.") click.echo("For each rule, you can:") - click.echo("- Accept it (y): Add the rule to your .cursor/rules directory") + action_text = _get_format_action_text(output_format) + click.echo(f"- Accept it (y): {action_text.capitalize()}") click.echo("- Skip it (n): Don't add this rule") click.echo("- Accept all remaining (a): Accept this rule and all remaining rules") click.echo("- Accept high-confidence (h): Accept this rule and all remaining high-confidence rules") @@ -901,7 +991,7 @@ def get_rule_confidence_score(mdc_content): # Get user decision choice = click.prompt( - f"\nšŸ¤” Add this rule to your .cursor/rules directory? (y/n/q/a/h)", + f"šŸ¤” {_get_format_action_text(output_format).capitalize()}? (y/n/q/a/h)", type=click.Choice(['y', 'n', 'q', 'a', 'h'], case_sensitive=False), default='n' ) @@ -1108,16 +1198,20 @@ def get_rule_confidence_score(mdc_content): created_files.append(str(category_file.relative_to(Path(directory)))) click.echo("\nāœ… Category files created successfully!") - click.echo(f"šŸ“ Files created in {rules_dir}:") + click.echo(f"šŸ“ Files created:") for file_path in created_files: click.echo(f" • {file_path}") else: # Fall back to individual files with improved names click.echo(f"\nšŸ’¾ Saving {len(accepted_rules)} rules as individual files...") - created_files = analyzer.save_mdc_files(accepted_rules, rules_dir) + format_results = save_rules_with_format(accepted_rules, output_format, directory) + created_files = [] + for fmt_name, files in format_results.items(): + created_files.extend(files) - click.echo("\nāœ… Individual rule files created!") - click.echo(f"šŸ“ Files created in {rules_dir}:") + format_desc = _get_format_description(output_format) + click.echo(f"\nāœ… Individual rule files created in {format_desc}!") + click.echo(f"šŸ“ Files created:") for file_path in created_files[:5]: # Show first 5 click.echo(f" • {file_path}") if len(created_files) > 5: @@ -1125,10 +1219,14 @@ def get_rule_confidence_score(mdc_content): else: # Fallback if categorization parsing fails click.echo(f"\nšŸ’¾ Saving {len(accepted_rules)} rules...") - created_files = analyzer.save_mdc_files(accepted_rules, rules_dir) + format_results = save_rules_with_format(accepted_rules, output_format, directory) + created_files = [] + for fmt_name, files in format_results.items(): + created_files.extend(files) - click.echo("\nāœ… Rules saved!") - click.echo(f"šŸ“ Files created in {rules_dir}:") + format_desc = _get_format_description(output_format) + click.echo(f"\nāœ… Rules saved in {format_desc}!") + click.echo(f"šŸ“ Files created:") for file_path in created_files[:5]: click.echo(f" • {file_path}") if len(created_files) > 5: @@ -1137,10 +1235,14 @@ def get_rule_confidence_score(mdc_content): except Exception as e: click.echo(f"āš ļø Categorization failed: {e}") click.echo("šŸ“ Falling back to individual rule files...") - created_files = analyzer.save_mdc_files(accepted_rules, rules_dir) + format_results = save_rules_with_format(accepted_rules, output_format, directory) + created_files = [] + for fmt_name, files in format_results.items(): + created_files.extend(files) - click.echo("\nāœ… Rules saved as individual files!") - click.echo(f"šŸ“ Files created in {rules_dir}:") + format_desc = _get_format_description(output_format) + click.echo(f"\nāœ… Rules saved as individual files in {format_desc}!") + click.echo(f"šŸ“ Files created:") for file_path in created_files[:5]: click.echo(f" • {file_path}") if len(created_files) > 5: @@ -1151,10 +1253,14 @@ def get_rule_confidence_score(mdc_content): elif mdc_files: # Auto-save all rules if not reviewing click.echo(f"\nšŸ’¾ Saving all {len(mdc_files)} rules...") - created_files = analyzer.save_mdc_files(mdc_files, rules_dir) + format_results = save_rules_with_format(mdc_files, output_format, directory) + created_files = [] + for fmt_name, files in format_results.items(): + created_files.extend(files) - click.echo("\nāœ… All rules saved!") - click.echo(f"šŸ“ Files created in {rules_dir}:") + format_desc = _get_format_description(output_format) + click.echo(f"\nāœ… All rules saved in {format_desc}!") + click.echo(f"šŸ“ Files created:") for file_path in created_files[:5]: # Show first 5 click.echo(f" • {file_path}") if len(created_files) > 5: @@ -1177,4 +1283,4 @@ def main(): cli() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/rules_engine/formatters/__init__.py b/rules_engine/formatters/__init__.py new file mode 100644 index 0000000..5b20747 --- /dev/null +++ b/rules_engine/formatters/__init__.py @@ -0,0 +1,12 @@ +""" +Rules Engine output formatters package. + +This package contains all the output formatters for different IDE/editor formats. +""" + +from .base import BaseFormatter, FormatError +from .cursor import CursorFormatter +from .claude import ClaudeFormatter +from .manager import FormatManager + +__all__ = ['BaseFormatter', 'FormatError', 'CursorFormatter', 'ClaudeFormatter', 'FormatManager'] \ No newline at end of file diff --git a/rules_engine/formatters/base.py b/rules_engine/formatters/base.py new file mode 100644 index 0000000..a4ea143 --- /dev/null +++ b/rules_engine/formatters/base.py @@ -0,0 +1,112 @@ +""" +Base formatter interface for Rules Engine output formats. +""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import List, Dict, Any, Optional + + +class FormatError(Exception): + """Exception raised when format operations fail.""" + pass + + +class BaseFormatter(ABC): + """Base class for all output formatters.""" + + def __init__(self, name: str, description: str, file_extensions: List[str]): + """Initialize the formatter. + + Args: + name: Short name for the format (e.g., 'cursor', 'claude') + description: Human-readable description + file_extensions: List of file extensions this format uses (e.g., ['.mdc']) + """ + self.name = name + self.description = description + self.file_extensions = file_extensions + + @abstractmethod + def convert(self, mdc_contents: List[str]) -> Dict[str, str]: + """Convert .mdc contents to this format. + + Args: + mdc_contents: List of .mdc file contents + + Returns: + Dict mapping filename to content + + Raises: + FormatError: If conversion fails + """ + pass + + @abstractmethod + def save(self, content_map: Dict[str, str], target_dir: Path, repo_path: Path) -> List[str]: + """Save converted content to target directory. + + Args: + content_map: Dict from convert() mapping filename to content + target_dir: Directory to save files in + repo_path: Repository root path for relative path calculation + + Returns: + List of created file paths (relative to repo_path when possible) + + Raises: + FormatError: If saving fails + """ + pass + + def validate_content(self, mdc_contents: List[str]) -> None: + """Validate that content can be converted to this format. + + Args: + mdc_contents: List of .mdc file contents + + Raises: + FormatError: If content is invalid for this format + """ + if not mdc_contents: + raise FormatError(f"No content provided for {self.name} format") + + for i, content in enumerate(mdc_contents): + if not content.strip(): + raise FormatError(f"Empty content at index {i} for {self.name} format") + + def get_info(self) -> Dict[str, Any]: + """Get formatter information. + + Returns: + Dict with formatter metadata + """ + return { + 'name': self.name, + 'description': self.description, + 'file_extensions': self.file_extensions + } + + def supports_merge(self) -> bool: + """Whether this formatter supports merging multiple rules into single files. + + Returns: + True if formatter can merge rules, False otherwise + """ + return False + + def _safe_relative_path(self, file_path: Path, repo_path: Path) -> str: + """Safely get relative path, fallback to absolute on error. + + Args: + file_path: File path to make relative + repo_path: Repository root path + + Returns: + Relative path string, or absolute path if relative fails + """ + try: + return str(file_path.relative_to(repo_path)) + except (ValueError, OSError): + # Handle symlinks, temp paths, cross-platform issues + return str(file_path) \ No newline at end of file diff --git a/rules_engine/formatters/claude.py b/rules_engine/formatters/claude.py new file mode 100644 index 0000000..db86d6b --- /dev/null +++ b/rules_engine/formatters/claude.py @@ -0,0 +1,170 @@ +""" +Claude Code IDE formatter for Rules Engine. + +Generates CLAUDE.md file for Claude Code IDE integration. +""" + +import yaml +from pathlib import Path +from typing import List, Dict +from datetime import datetime +from .base import BaseFormatter, FormatError + + +class ClaudeFormatter(BaseFormatter): + """Formatter for Claude Code IDE CLAUDE.md format.""" + + def __init__(self): + super().__init__( + name='claude', + description='Single CLAUDE.md file for Claude Code IDE', + file_extensions=['.md'] + ) + + def convert(self, mdc_contents: List[str]) -> Dict[str, str]: + """Convert .mdc contents to Claude Code CLAUDE.md format. + + Args: + mdc_contents: List of .mdc file contents + + Returns: + Dict with single 'CLAUDE.md' key mapping to content + """ + self.validate_content(mdc_contents) + + sections = [] + + # Add header + sections.append("# Project Rules") + sections.append("") + sections.append("This file contains coding rules and conventions generated by Rules Engine.") + sections.append("") + + for i, content in enumerate(mdc_contents): + if not content.strip(): + continue + + try: + rule_section = self._convert_mdc_to_claude_section(content, i) + if rule_section: + sections.append(rule_section) + sections.append("") # Add spacing between rules + except Exception as e: + # Log warning but continue processing other rules + print(f"Warning: Failed to convert rule {i+1}: {e}") + continue + + if len(sections) <= 4: # Only header sections, no actual rules + raise FormatError("No valid rules could be converted to Claude format") + + claude_content = '\n'.join(sections).strip() + return {'CLAUDE.md': claude_content} + + def save(self, content_map: Dict[str, str], target_dir: Path, repo_path: Path) -> List[str]: + """Save CLAUDE.md file to repository root. + + Args: + content_map: Dict with 'CLAUDE.md' key + target_dir: Directory to save in (repository root) + repo_path: Repository root path + + Returns: + List with single CLAUDE.md file path + """ + if not content_map or 'CLAUDE.md' not in content_map: + return [] + + claude_content = content_map['CLAUDE.md'] + if not claude_content.strip(): + return [] + + claude_file_path = target_dir / 'CLAUDE.md' + + # Handle existing CLAUDE.md files + if claude_file_path.exists(): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + claude_file_path = target_dir / f"CLAUDE_rules_{timestamp}.md" + + try: + claude_file_path.write_text(claude_content, encoding='utf-8') + return [self._safe_relative_path(claude_file_path, repo_path)] + except (OSError, UnicodeError) as e: + raise FormatError(f"Failed to write Claude file {claude_file_path}: {e}") + + def supports_merge(self) -> bool: + """Claude format merges all rules into single file.""" + return True + + def _convert_mdc_to_claude_section(self, mdc_content: str, index: int) -> str: + """Convert single .mdc content to Claude markdown section. + + Args: + mdc_content: Single .mdc file content + index: Rule index for fallback naming + + Returns: + Markdown section for Claude format + """ + try: + # Parse the .mdc content + yaml_end = mdc_content.find('---', 3) + if yaml_end > 0: + front_matter = mdc_content[3:yaml_end].strip() + parsed = yaml.safe_load(front_matter) + description = parsed.get('description', f'Rule {index+1}') + globs = parsed.get('globs', []) + + # Extract bullets + content_after_yaml = mdc_content[yaml_end + 3:].strip() + bullets = [ + line.strip('- ').strip() + for line in content_after_yaml.split('\n') + if line.strip().startswith('-') + ] + + # Build Claude section + section_lines = [f"## {description}"] + + # Add scope information if specific globs are provided + if globs and globs != ['**/*']: + section_lines.append("") + section_lines.append(f"**Applies to**: {', '.join(globs)}") + + # Add bullets + section_lines.append("") + for bullet in bullets: + section_lines.append(f"- {bullet}") + + return '\n'.join(section_lines) + else: + # Fallback for malformed content + return f"## Rule {index+1}\n\n- {mdc_content.strip()}" + + except (yaml.YAMLError, AttributeError) as e: + # Fallback for parsing errors + return f"## Rule {index+1}\n\n- {mdc_content.strip()}" + + def validate_content(self, mdc_contents: List[str]) -> None: + """Validate content for Claude format conversion. + + Args: + mdc_contents: List of .mdc file contents + + Raises: + FormatError: If content is invalid + """ + super().validate_content(mdc_contents) + + # Additional Claude-specific validation + valid_rules = 0 + for content in mdc_contents: + if content.strip(): + # Check if it has some structure (either YAML frontmatter or bullet points) + has_yaml = '---' in content + has_bullets = any(line.strip().startswith('-') for line in content.split('\n')) + + if has_yaml or has_bullets: + valid_rules += 1 + + if valid_rules == 0: + raise FormatError("No valid rules found for Claude format conversion") \ No newline at end of file diff --git a/rules_engine/formatters/cursor.py b/rules_engine/formatters/cursor.py new file mode 100644 index 0000000..8f734ee --- /dev/null +++ b/rules_engine/formatters/cursor.py @@ -0,0 +1,134 @@ +""" +Cursor IDE formatter for Rules Engine. + +Generates .mdc files in .cursor/rules/ directory. +""" + +import yaml +from pathlib import Path +from typing import List, Dict +from .base import BaseFormatter, FormatError + + +class CursorFormatter(BaseFormatter): + """Formatter for Cursor IDE .mdc files.""" + + def __init__(self): + super().__init__( + name='cursor', + description='Traditional .mdc files in .cursor/rules/ directory', + file_extensions=['.mdc'] + ) + + def convert(self, mdc_contents: List[str]) -> Dict[str, str]: + """Convert .mdc contents to Cursor format (pass-through since already in .mdc format). + + Args: + mdc_contents: List of .mdc file contents + + Returns: + Dict mapping filename to content + """ + self.validate_content(mdc_contents) + + content_map = {} + + for i, content in enumerate(mdc_contents): + if not content.strip(): + continue + + # Extract filename from YAML front matter + filename = self._extract_filename(content, i) + content_map[filename] = content + + return content_map + + def save(self, content_map: Dict[str, str], target_dir: Path, repo_path: Path) -> List[str]: + """Save .mdc files to .cursor/rules/ directory. + + Args: + content_map: Dict mapping filename to content + target_dir: Directory to save in (should be repository root) + repo_path: Repository root path + + Returns: + List of created file paths + """ + if not content_map: + return [] + + # Create .cursor/rules directory + rules_dir = target_dir / '.cursor' / 'rules' + rules_dir.mkdir(parents=True, exist_ok=True) + + created_files = [] + + for filename, content in content_map.items(): + file_path = rules_dir / filename + + # Ensure we don't overwrite files by adding numbers + counter = 1 + original_path = file_path + while file_path.exists(): + base_name = original_path.stem + file_path = rules_dir / f'{base_name}-{counter}.mdc' + counter += 1 + + try: + file_path.write_text(content, encoding='utf-8') + created_files.append(self._safe_relative_path(file_path, repo_path)) + except (OSError, UnicodeError) as e: + raise FormatError(f"Failed to write Cursor file {file_path}: {e}") + + return created_files + + def supports_merge(self) -> bool: + """Cursor format supports individual files, not merging.""" + return False + + def _extract_filename(self, content: str, index: int) -> str: + """Extract filename from .mdc content. + + Args: + content: .mdc file content + index: Index for fallback naming + + Returns: + Filename with .mdc extension + """ + try: + # Parse the YAML front matter to get description + yaml_end = content.find('---', 3) # Find second --- + if yaml_end > 0: + front_matter = content[3:yaml_end].strip() + parsed = yaml.safe_load(front_matter) + description = parsed.get('description', f'rule-{index}') + filename = self._slugify(description) + '.mdc' + else: + filename = f'rule-{index}.mdc' + except (yaml.YAMLError, AttributeError): + filename = f'rule-{index}.mdc' + + return filename + + def _slugify(self, text: str) -> str: + """Convert text to a filename-safe slug. + + Args: + text: Text to slugify + + Returns: + Filename-safe slug + """ + import re + + # Convert to lowercase and replace spaces/special chars with hyphens + slug = re.sub(r'[^\w\s-]', '', text.lower()) + slug = re.sub(r'[\s_]+', '-', slug) + slug = slug.strip('-') + + # Truncate if too long + if len(slug) > 50: + slug = slug[:50].rstrip('-') + + return slug or 'rule' \ No newline at end of file diff --git a/rules_engine/formatters/manager.py b/rules_engine/formatters/manager.py new file mode 100644 index 0000000..c56bf7d --- /dev/null +++ b/rules_engine/formatters/manager.py @@ -0,0 +1,219 @@ +""" +Format manager for Rules Engine output formatters. + +Provides registry and discovery of available formatters. +""" + +from typing import Dict, List, Set, Optional +from pathlib import Path +from .base import BaseFormatter, FormatError +from .cursor import CursorFormatter +from .claude import ClaudeFormatter + + +class FormatManager: + """Manages and dispatches to different output formatters.""" + + def __init__(self): + """Initialize the format manager with built-in formatters.""" + self.formatters: Dict[str, BaseFormatter] = {} + self._register_builtin_formatters() + + def _register_builtin_formatters(self) -> None: + """Register the built-in formatters.""" + self.register_formatter(CursorFormatter()) + self.register_formatter(ClaudeFormatter()) + + def register_formatter(self, formatter: BaseFormatter) -> None: + """Register a new formatter. + + Args: + formatter: Formatter instance to register + + Raises: + FormatError: If formatter name conflicts with existing formatter + """ + if formatter.name in self.formatters: + raise FormatError(f"Formatter '{formatter.name}' is already registered") + + self.formatters[formatter.name] = formatter + + def get_formatter(self, name: str) -> BaseFormatter: + """Get a formatter by name. + + Args: + name: Formatter name + + Returns: + Formatter instance + + Raises: + FormatError: If formatter not found + """ + if name not in self.formatters: + available = ', '.join(self.get_available_formats()) + raise FormatError(f"Formatter '{name}' not found. Available: {available}") + + return self.formatters[name] + + def get_available_formats(self) -> List[str]: + """Get list of available format names. + + Returns: + List of format names sorted alphabetically + """ + return sorted(self.formatters.keys()) + + def get_format_info(self, name: Optional[str] = None) -> Dict: + """Get information about formatters. + + Args: + name: Specific formatter name, or None for all formatters + + Returns: + Dict with formatter information + """ + if name: + return self.get_formatter(name).get_info() + + return { + fmt_name: formatter.get_info() + for fmt_name, formatter in self.formatters.items() + } + + def convert_and_save(self, mdc_contents: List[str], formats: List[str], + target_dir: Path, repo_path: Path) -> Dict[str, List[str]]: + """Convert and save rules in specified formats. + + Args: + mdc_contents: List of .mdc file contents + formats: List of format names to generate + target_dir: Directory to save files in + repo_path: Repository root path + + Returns: + Dict mapping format name to list of created files + + Raises: + FormatError: If any format operation fails + """ + if not mdc_contents: + raise FormatError("No content provided for conversion") + + if not formats: + raise FormatError("No formats specified") + + results = {} + errors = [] + + for format_name in formats: + try: + formatter = self.get_formatter(format_name) + + # Convert content + content_map = formatter.convert(mdc_contents) + + # Save files + created_files = formatter.save(content_map, target_dir, repo_path) + results[format_name] = created_files + + except Exception as e: + error_msg = f"Failed to process {format_name} format: {e}" + errors.append(error_msg) + results[format_name] = [] + + if errors and not any(results.values()): + # All formats failed + raise FormatError(f"All format operations failed: {'; '.join(errors)}") + elif errors: + # Some formats failed, log warnings + print(f"Warning: Some format operations failed: {'; '.join(errors)}") + + return results + + def validate_formats(self, format_names: List[str]) -> None: + """Validate that all format names are available. + + Args: + format_names: List of format names to validate + + Raises: + FormatError: If any format name is invalid + """ + available = set(self.get_available_formats()) + invalid = set(format_names) - available + + if invalid: + invalid_list = ', '.join(sorted(invalid)) + available_list = ', '.join(sorted(available)) + raise FormatError(f"Invalid formats: {invalid_list}. Available: {available_list}") + + def resolve_format_list(self, format_spec: str) -> List[str]: + """Resolve format specification to list of format names. + + Args: + format_spec: Format specification ('cursor', 'claude', 'all', or comma-separated) + + Returns: + List of format names to process + + Raises: + FormatError: If format specification is invalid + """ + if format_spec == 'all': + # All available formats + return self.get_available_formats() + else: + # Single format or comma-separated list + formats = [f.strip() for f in format_spec.split(',') if f.strip()] + self.validate_formats(formats) + return formats + + def supports_plugin_discovery(self) -> bool: + """Check if plugin discovery is supported. + + Returns: + True if plugin discovery is available + """ + try: + import pkg_resources + return True + except ImportError: + return False + + def discover_plugins(self) -> int: + """Discover and load formatter plugins. + + Returns: + Number of plugins loaded + + Raises: + FormatError: If plugin discovery fails + """ + if not self.supports_plugin_discovery(): + raise FormatError("Plugin discovery requires pkg_resources") + + import pkg_resources + + loaded_count = 0 + for entry_point in pkg_resources.iter_entry_points('rules_engine.formatters'): + try: + formatter_class = entry_point.load() + formatter = formatter_class() + + if not isinstance(formatter, BaseFormatter): + print(f"Warning: Plugin '{entry_point.name}' does not inherit from BaseFormatter") + continue + + self.register_formatter(formatter) + loaded_count += 1 + print(f"Loaded formatter plugin: {entry_point.name}") + + except Exception as e: + print(f"Warning: Failed to load formatter plugin '{entry_point.name}': {e}") + + return loaded_count + + +# Global format manager instance +format_manager = FormatManager() \ No newline at end of file