diff --git a/config_auditor/cli.py b/config_auditor/cli.py new file mode 100644 index 0000000..805114b --- /dev/null +++ b/config_auditor/cli.py @@ -0,0 +1,221 @@ +import sys +from pathlib import Path +from typing import Optional +import click + +from config_auditor.discovery import ConfigDiscovery +from config_auditor.parsers import ParserFactory +from config_auditor.rules import RuleRegistry, Issue +from config_auditor.report import ReportGenerator + + +@click.group() +@click.option("--path", "-p", default=".", help="Path to scan") +@click.option("--format", "-f", type=click.Choice(["json", "yaml", "text"]), default="text", help="Output format") +@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output") +@click.pass_context +def cli(ctx: click.Context, path: str, format: str, verbose: bool): + ctx.ensure_object(dict) + ctx.obj["path"] = Path(path) + ctx.obj["format"] = format + ctx.obj["verbose"] = verbose + + +@cli.command() +@click.pass_context +def scan(ctx: click.Context): + """Scan a directory for configuration files.""" + path = ctx.obj["path"] + verbose = ctx.obj.get("verbose", False) + + if not path.exists(): + click.echo(f"Error: Path does not exist: {path}", err=True) + sys.exit(2) + + if not path.is_dir(): + click.echo(f"Error: Path is not a directory: {path}", err=True) + sys.exit(2) + + discovery = ConfigDiscovery(max_depth=3) + config_files = discovery.discover(path) + + if verbose: + for cf in config_files: + click.echo(f"Found: {cf.path} ({cf.format})") + + click.echo(f"Found {len(config_files)} configuration files") + + if ctx.obj["format"] == "json": + import json + result = [{"path": str(cf.path), "format": cf.format} for cf in config_files] + click.echo(json.dumps(result, indent=2)) + elif ctx.obj["format"] == "yaml": + import yaml + result = [{"path": str(cf.path), "format": cf.format} for cf in config_files] + click.echo(yaml.dump(result)) + else: + for cf in config_files: + click.echo(f" {cf.path} ({cf.format})") + + return 0 + + +@cli.command() +@click.option("--format", "-f", type=click.Choice(["json", "yaml", "text"]), default="text", help="Output format") +@click.pass_context +def audit(ctx: click.Context, format: str): + """Audit configuration files for issues.""" + path = ctx.obj["path"] + format_output = format + verbose = ctx.obj.get("verbose", False) + + if not path.exists(): + click.echo(f"Error: Path does not exist: {path}", err=True) + sys.exit(2) + + discovery = ConfigDiscovery(max_depth=3) + config_files = discovery.discover(path) + + if not config_files: + click.echo("No configuration files found", err=True) + sys.exit(3) + + parser_factory = ParserFactory() + rule_registry = RuleRegistry() + issues: list[Issue] = [] + + for cf in config_files: + try: + content = cf.path.read_text() + data = parser_factory.parse(cf.format, content) + if data is None: + continue + + file_issues = rule_registry.evaluate(cf.format, data, cf.path) + issues.extend(file_issues) + except Exception as e: + if verbose: + click.echo(f"Warning: Failed to parse {cf.path}: {e}") + + report_gen = ReportGenerator() + if format_output == "json": + output = report_gen.to_json(issues) + click.echo(output) + elif format_output == "yaml": + output = report_gen.to_yaml(issues) + click.echo(output) + else: + report_gen.to_text(issues, click.echo) + + critical_count = sum(1 for i in issues if i.severity == "critical") + if critical_count > 0: + sys.exit(4) + elif issues: + sys.exit(4) + return 0 + + +@cli.command() +@click.option("--dry-run", is_flag=True, default=False, help="Preview changes without applying") +@click.option("--force", is_flag=True, default=False, help="Skip confirmation") +@click.pass_context +def fix(ctx: click.Context, dry_run: bool, force: bool): + """Automatically fix detected issues.""" + path = ctx.obj["path"] + verbose = ctx.obj.get("verbose", False) + + if not path.exists(): + click.echo(f"Error: Path does not exist: {path}", err=True) + sys.exit(2) + + discovery = ConfigDiscovery(max_depth=3) + config_files = discovery.discover(path) + + if not config_files: + click.echo("No configuration files found", err=True) + sys.exit(3) + + from config_auditor.fixes import Fixer + fixer = Fixer(dry_run=dry_run, force=force) + + fixed_count = 0 + for cf in config_files: + try: + content = cf.path.read_text() + fixes_applied = fixer.fix_config(cf.path, cf.format, content) + if fixes_applied: + fixed_count += fixes_applied + if verbose: + click.echo(f"Applied {fixes_applied} fixes to {cf.path}") + except Exception as e: + if verbose: + click.echo(f"Warning: Failed to fix {cf.path}: {e}") + + if fixed_count > 0: + click.echo(f"Applied {fixed_count} fixes") + return 0 + else: + click.echo("No fixes applied") + return 0 + + +@cli.command() +@click.option("--template", "-t", type=click.Choice(["node", "python", "typescript"]), help="Template type") +@click.pass_context +def generate(ctx: click.Context, template: Optional[str]): + """Generate optimal configurations.""" + path = ctx.obj["path"] + + if not path.exists(): + click.echo(f"Error: Path does not exist: {path}", err=True) + sys.exit(2) + + from config_auditor.generate import ConfigGenerator + + generator = ConfigGenerator() + + if template: + config = generator.generate_from_template(template, path) + output = ctx.obj["format"] + if output == "json": + import json + click.echo(json.dumps(config, indent=2)) + elif output == "yaml": + import yaml + click.echo(yaml.dump(config)) + else: + import json + click.echo(json.dumps(config, indent=2)) + else: + project_type = generator.detect_project_type(path) + if project_type: + config = generator.generate_from_template(project_type, path) + import json + click.echo(json.dumps(config, indent=2)) + else: + click.echo("Could not detect project type. Use --template to specify.", err=True) + sys.exit(2) + + return 0 + + +@cli.command() +@click.pass_context +def config(ctx: click Context): + """Show current configuration.""" + import yaml + + config_path = Path("config.yaml") + if config_path.exists(): + content = config_path.read_text() + click.echo(content) + else: + click.echo("No config.yaml found in current directory") + + +def main(): + cli(obj={}) + + +if __name__ == "__main__": + main()