Files
gh-epieczko-betty/skills/code.format/code_format.py
2025-11-29 18:26:08 +08:00

435 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
code.format - Format code using Prettier
This skill formats code using Prettier, supporting multiple languages and file types.
It can format individual files or entire directories, check formatting without making
changes, and respect custom Prettier configurations.
Generated by meta.skill with Betty Framework certification
"""
import os
import sys
import json
import yaml
import subprocess
import shutil
from pathlib import Path
from typing import Dict, List, Any, Optional
from betty.config import BASE_DIR
from betty.logging_utils import setup_logger
from betty.certification import certified_skill
logger = setup_logger(__name__)
class CodeFormat:
"""
Format code using Prettier, supporting multiple languages and file types.
"""
# Supported file extensions
SUPPORTED_EXTENSIONS = {
'.js', '.jsx', '.mjs', '.cjs', # JavaScript
'.ts', '.tsx', # TypeScript
'.css', '.scss', '.less', # Styles
'.html', '.htm', # HTML
'.json', # JSON
'.yaml', '.yml', # YAML
'.md', '.mdx', # Markdown
'.graphql', '.gql', # GraphQL
'.vue', # Vue
}
# Prettier config file names
CONFIG_FILES = [
'.prettierrc',
'.prettierrc.json',
'.prettierrc.yml',
'.prettierrc.yaml',
'.prettierrc.json5',
'.prettierrc.js',
'.prettierrc.cjs',
'prettier.config.js',
'prettier.config.cjs',
]
def __init__(self, base_dir: str = BASE_DIR):
"""Initialize skill"""
self.base_dir = Path(base_dir)
def _check_prettier_installed(self) -> tuple[bool, Optional[str]]:
"""
Check if Prettier is installed and return the command to use.
Returns:
Tuple of (is_installed, command_path)
"""
# Check for npx prettier (local installation)
try:
result = subprocess.run(
['npx', 'prettier', '--version'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logger.info(f"Found Prettier via npx: {result.stdout.strip()}")
return True, 'npx prettier'
except (subprocess.SubprocessError, FileNotFoundError):
pass
# Check for global prettier installation
prettier_path = shutil.which('prettier')
if prettier_path:
try:
result = subprocess.run(
['prettier', '--version'],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
logger.info(f"Found Prettier globally: {result.stdout.strip()}")
return True, 'prettier'
except subprocess.SubprocessError:
pass
return False, None
def _find_config_file(self, start_path: Path, custom_config: Optional[str] = None) -> Optional[Path]:
"""
Find Prettier configuration file.
Args:
start_path: Path to start searching from
custom_config: Optional custom config file path
Returns:
Path to config file or None
"""
if custom_config:
config_path = Path(custom_config)
if config_path.exists():
logger.info(f"Using custom config: {config_path}")
return config_path
else:
logger.warning(f"Custom config not found: {config_path}")
# Search upwards from start_path
current = start_path if start_path.is_dir() else start_path.parent
while current != current.parent: # Stop at root
for config_name in self.CONFIG_FILES:
config_path = current / config_name
if config_path.exists():
logger.info(f"Found config: {config_path}")
return config_path
current = current.parent
logger.info("No Prettier config found, will use defaults")
return None
def _discover_files(self, path: Path, patterns: Optional[List[str]] = None) -> List[Path]:
"""
Discover files to format.
Args:
path: File or directory path
patterns: Optional glob patterns to filter files
Returns:
List of file paths to format
"""
if path.is_file():
return [path]
files = []
if patterns:
for pattern in patterns:
files.extend(path.rglob(pattern))
else:
# Find all files with supported extensions
for ext in self.SUPPORTED_EXTENSIONS:
files.extend(path.rglob(f'*{ext}'))
# Filter out common ignore patterns
ignored_dirs = {'node_modules', '.git', 'dist', 'build', '.next', 'coverage', '__pycache__'}
filtered_files = [
f for f in files
if f.is_file() and not any(ignored in f.parts for ignored in ignored_dirs)
]
logger.info(f"Discovered {len(filtered_files)} files to format")
return filtered_files
def _format_file(self, file_path: Path, prettier_cmd: str, check_only: bool = False,
config_path: Optional[Path] = None) -> Dict[str, Any]:
"""
Format a single file using Prettier.
Args:
file_path: Path to file to format
prettier_cmd: Prettier command to use
check_only: Only check formatting without modifying
config_path: Optional path to config file
Returns:
Dict with formatting result
"""
cmd = prettier_cmd.split()
cmd.append(str(file_path))
if check_only:
cmd.append('--check')
else:
cmd.append('--write')
if config_path:
cmd.extend(['--config', str(config_path)])
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0:
if check_only and result.stdout:
# File is already formatted
return {
'file': str(file_path),
'status': 'already_formatted',
'ok': True
}
else:
# File was formatted
return {
'file': str(file_path),
'status': 'formatted',
'ok': True
}
else:
# Formatting failed or file needs formatting (in check mode)
if check_only and 'Code style issues' in result.stderr:
return {
'file': str(file_path),
'status': 'needs_formatting',
'ok': True
}
else:
return {
'file': str(file_path),
'status': 'error',
'ok': False,
'error': result.stderr or result.stdout
}
except subprocess.TimeoutExpired:
logger.error(f"Timeout formatting {file_path}")
return {
'file': str(file_path),
'status': 'error',
'ok': False,
'error': 'Timeout after 30 seconds'
}
except Exception as e:
logger.error(f"Error formatting {file_path}: {e}")
return {
'file': str(file_path),
'status': 'error',
'ok': False,
'error': str(e)
}
@certified_skill("code.format")
def execute(
self,
path: str,
config_path: Optional[str] = None,
check_only: bool = False,
file_patterns: Optional[str] = None,
write: bool = True
) -> Dict[str, Any]:
"""
Execute the code formatting skill.
Args:
path: File or directory path to format
config_path: Path to custom Prettier configuration file
check_only: Only check formatting without modifying files
file_patterns: Comma-separated glob patterns to filter files
write: Write formatted output to files (default: True)
Returns:
Dict with execution results including:
- ok: Overall success status
- status: Status message
- formatted_count: Number of files formatted
- checked_count: Number of files checked
- error_count: Number of files with errors
- files_formatted: List of formatted file paths
- files_already_formatted: List of already formatted file paths
- files_with_errors: List of files that had errors
"""
try:
logger.info(f"Executing code.format on: {path}")
# Check if Prettier is installed
is_installed, prettier_cmd = self._check_prettier_installed()
if not is_installed:
return {
"ok": False,
"status": "failed",
"error": "Prettier is not installed. Install it with: npm install -g prettier or npm install --save-dev prettier"
}
# Validate path
target_path = Path(path)
if not target_path.exists():
return {
"ok": False,
"status": "failed",
"error": f"Path does not exist: {path}"
}
# Find config file
config_file = self._find_config_file(target_path, config_path)
# Parse file patterns
patterns = None
if file_patterns:
patterns = [p.strip() for p in file_patterns.split(',')]
# Discover files
files = self._discover_files(target_path, patterns)
if not files:
return {
"ok": True,
"status": "success",
"message": "No files found to format",
"formatted_count": 0,
"checked_count": 0,
"error_count": 0
}
# Format files
results = []
for file_path in files:
result = self._format_file(
file_path,
prettier_cmd,
check_only=check_only or not write,
config_path=config_file
)
results.append(result)
# Aggregate results
files_formatted = [r['file'] for r in results if r['status'] == 'formatted']
files_already_formatted = [r['file'] for r in results if r['status'] == 'already_formatted']
files_need_formatting = [r['file'] for r in results if r['status'] == 'needs_formatting']
files_with_errors = [
{'file': r['file'], 'error': r.get('error', 'Unknown error')}
for r in results if r['status'] == 'error'
]
response = {
"ok": True,
"status": "success",
"formatted_count": len(files_formatted),
"already_formatted_count": len(files_already_formatted),
"needs_formatting_count": len(files_need_formatting),
"checked_count": len(files),
"error_count": len(files_with_errors),
"files_formatted": files_formatted,
"files_already_formatted": files_already_formatted,
"files_need_formatting": files_need_formatting,
"files_with_errors": files_with_errors
}
if check_only:
response["message"] = f"Checked {len(files)} files. {len(files_need_formatting)} need formatting."
else:
response["message"] = f"Formatted {len(files_formatted)} files. {len(files_already_formatted)} already formatted."
logger.info(f"Skill completed: {response['message']}")
return response
except Exception as e:
logger.error(f"Error executing skill: {e}")
return {
"ok": False,
"status": "failed",
"error": str(e)
}
def main():
"""CLI entry point"""
import argparse
parser = argparse.ArgumentParser(
description="Format code using Prettier, supporting multiple languages and file types."
)
parser.add_argument(
"--path",
required=True,
help="File or directory path to format"
)
parser.add_argument(
"--config-path",
help="Path to custom Prettier configuration file"
)
parser.add_argument(
"--check",
action="store_true",
help="Only check formatting without modifying files"
)
parser.add_argument(
"--patterns",
help="Comma-separated glob patterns to filter files (e.g., '**/*.js,**/*.ts')"
)
parser.add_argument(
"--no-write",
action="store_true",
help="Don't write changes (dry run)"
)
parser.add_argument(
"--output-format",
choices=["json", "yaml"],
default="json",
help="Output format"
)
args = parser.parse_args()
# Create skill instance
skill = CodeFormat()
# Execute skill
result = skill.execute(
path=args.path,
config_path=args.config_path,
check_only=args.check,
file_patterns=args.patterns,
write=not args.no_write
)
# Output result
if args.output_format == "json":
print(json.dumps(result, indent=2))
else:
print(yaml.dump(result, default_flow_style=False))
# Exit with appropriate code
sys.exit(0 if result.get("ok") else 1)
if __name__ == "__main__":
main()