245 lines
6.9 KiB
Ruby
Executable File
245 lines
6.9 KiB
Ruby
Executable File
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
|
|
require 'claude_hooks'
|
|
require 'open3'
|
|
require 'shellwords'
|
|
|
|
# Auto Format Handler
|
|
#
|
|
# PURPOSE: Automatically format/lint files after Write, Edit, or MultiEdit operations
|
|
# DESIGN: Lightweight, obvious, maintainable using claude_hooks DSL patterns
|
|
# ALIGNMENT: Mirrors pre-commit configuration for consistency
|
|
#
|
|
# SUPPORTED FORMATTERS:
|
|
# - Ruby: RuboCop with auto-correct (-A flag)
|
|
# - Markdown: markdownlint with --fix (matching pre-commit rules)
|
|
# - Shell: shfmt with 2-space indentation (matching pre-commit args)
|
|
# - Lua: stylua with custom configuration (160 column width, Unix line endings, etc.)
|
|
# - Rust: rustfmt with default configuration
|
|
# - Python: ruff format (automatically uses existing Black/isort/flake8 configs)
|
|
# - YAML: yamlfmt with default configuration, prettier as fallback
|
|
# - JavaScript/TypeScript: eslint with --fix, prettier as fallback
|
|
|
|
class AutoFormatHandler < ClaudeHooks::PostToolUse
|
|
DEFAULT_SKIP_PATTERNS = %w[
|
|
node_modules/
|
|
dist/
|
|
build/
|
|
.git/
|
|
*.min.js
|
|
*.min.css
|
|
vendor/
|
|
tmp/
|
|
.bundle/
|
|
].freeze
|
|
|
|
def call
|
|
log "Auto-format handler triggered for #{tool_name}"
|
|
|
|
return output_data unless should_process_tool?
|
|
return output_data unless file_path_available?
|
|
return output_data if should_skip_file?
|
|
|
|
perform_formatting
|
|
|
|
output_data
|
|
end
|
|
|
|
private
|
|
|
|
def should_process_tool?
|
|
file_modification_tools.include?(tool_name) && tool_successful?
|
|
end
|
|
|
|
def file_modification_tools
|
|
%w[Write Edit MultiEdit]
|
|
end
|
|
|
|
def tool_successful?
|
|
return false if tool_response&.dig('error')
|
|
return false if tool_response&.dig('isError')
|
|
return false if tool_response&.dig('success') == false
|
|
return false if tool_response&.dig('exitCode')&.nonzero?
|
|
|
|
true
|
|
end
|
|
|
|
def file_path_available?
|
|
return true if current_file_path && File.exist?(current_file_path)
|
|
|
|
log "File path not available or doesn't exist: #{current_file_path}", level: :warn
|
|
false
|
|
end
|
|
|
|
def current_file_path
|
|
@current_file_path ||= tool_input&.dig('file_path')
|
|
end
|
|
|
|
def should_skip_file?
|
|
return false unless current_file_path
|
|
|
|
relative_path = relative_file_path
|
|
|
|
# Check against simple, obvious skip patterns
|
|
if skip_patterns.any? { |pattern| matches_skip_pattern?(relative_path, pattern) }
|
|
log "Skipping #{relative_path} - matches ignore pattern"
|
|
return true
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def relative_file_path
|
|
# Use claude_hooks cwd helper instead of Dir.pwd
|
|
File.expand_path(current_file_path).sub("#{cwd}/", '')
|
|
end
|
|
|
|
def skip_patterns
|
|
DEFAULT_SKIP_PATTERNS
|
|
end
|
|
|
|
def matches_skip_pattern?(file_path, pattern)
|
|
if pattern.end_with?('/')
|
|
# Directory pattern
|
|
file_path.start_with?(pattern[0..-2])
|
|
elsif pattern.include?('*')
|
|
# Simple glob pattern using Ruby's built-in File.fnmatch
|
|
File.fnmatch(pattern, file_path)
|
|
else
|
|
# Exact match or basename match
|
|
file_path == pattern || File.basename(file_path) == pattern
|
|
end
|
|
end
|
|
|
|
def perform_formatting
|
|
formatter = detect_formatter
|
|
unless formatter
|
|
log "No formatter available for #{current_file_path}"
|
|
return
|
|
end
|
|
|
|
log "Formatting #{current_file_path} with #{formatter[:name]}"
|
|
|
|
result = run_formatter(formatter)
|
|
|
|
if result[:success]
|
|
log "Successfully formatted #{current_file_path}"
|
|
add_success_feedback(formatter[:name])
|
|
else
|
|
log "Formatting failed: #{result[:error]}", level: :error
|
|
add_error_feedback(formatter[:name], result[:error])
|
|
end
|
|
end
|
|
|
|
def detect_formatter
|
|
extension = File.extname(current_file_path).downcase
|
|
|
|
case extension
|
|
when '.rb'
|
|
command_available?('rubocop') ? { name: 'RuboCop', command: 'rubocop', args: ['-A'] } : nil
|
|
when '.md'
|
|
if command_available?('markdownlint')
|
|
{
|
|
name: 'markdownlint',
|
|
command: 'markdownlint',
|
|
args: [
|
|
'--fix',
|
|
'--disable',
|
|
'MD013,MD041,MD026,MD012,MD024'
|
|
]
|
|
}
|
|
end
|
|
when '.sh', '.bash'
|
|
if command_available?('shfmt')
|
|
{
|
|
name: 'shfmt',
|
|
command: 'shfmt',
|
|
args: ['-w', '-i', '2'] # 2-space indentation
|
|
}
|
|
end
|
|
when '.lua'
|
|
if command_available?('stylua')
|
|
{
|
|
name: 'stylua',
|
|
command: 'stylua',
|
|
args: [
|
|
'--column-width', '160',
|
|
'--line-endings', 'Unix',
|
|
'--indent-type', 'Spaces',
|
|
'--indent-width', '2',
|
|
'--quote-style', 'AutoPreferSingle',
|
|
'--call-parentheses', 'None'
|
|
]
|
|
}
|
|
end
|
|
when '.rs'
|
|
command_available?('rustfmt') ? { name: 'rustfmt', command: 'rustfmt', args: [] } : nil
|
|
when '.py'
|
|
command_available?('ruff') ? { name: 'ruff', command: 'ruff', args: ['format'] } : nil
|
|
when '.yml', '.yaml'
|
|
if command_available?('yamlfmt')
|
|
{ name: 'yamlfmt', command: 'yamlfmt', args: ['-w'] }
|
|
elsif command_available?('prettier')
|
|
{ name: 'prettier', command: 'prettier', args: ['--write', '--parser', 'yaml'] }
|
|
end
|
|
when '.js', '.jsx', '.ts', '.tsx', '.json'
|
|
if command_available?('eslint')
|
|
{ name: 'eslint', command: 'eslint', args: ['--fix'] }
|
|
elsif command_available?('prettier')
|
|
{ name: 'prettier', command: 'prettier', args: ['--write'] }
|
|
end
|
|
end
|
|
end
|
|
|
|
def command_available?(command)
|
|
# Cache command availability to avoid repeated system calls
|
|
@command_cache ||= {}
|
|
|
|
return @command_cache[command] if @command_cache.key?(command)
|
|
|
|
@command_cache[command] = system("which #{command} > /dev/null 2>&1")
|
|
end
|
|
|
|
def run_formatter(formatter)
|
|
command_parts = [formatter[:command]] + formatter[:args] + [current_file_path]
|
|
command = command_parts.map { |part| Shellwords.escape(part) }.join(' ')
|
|
|
|
log "Running: #{command}"
|
|
|
|
stdout_err, status = Open3.capture2e(command)
|
|
|
|
if status.success?
|
|
{ success: true, output: stdout_err }
|
|
else
|
|
{ success: false, error: stdout_err.strip }
|
|
end
|
|
rescue StandardError => e
|
|
{ success: false, error: e.message }
|
|
end
|
|
|
|
def add_success_feedback(formatter_name)
|
|
# Use output_data hash directly - obvious and DSL-idiomatic
|
|
output_data['feedback'] ||= []
|
|
output_data['feedback'] << "✓ Auto-formatted with #{formatter_name}"
|
|
end
|
|
|
|
def add_error_feedback(formatter_name, error)
|
|
output_data['feedback'] ||= []
|
|
output_data['feedback'] << "⚠ Auto-formatting failed (#{formatter_name}): #{error}"
|
|
end
|
|
end
|
|
|
|
# Testing support - claude_hooks DSL pattern
|
|
if __FILE__ == $PROGRAM_NAME
|
|
ClaudeHooks::CLI.test_runner(AutoFormatHandler) do |input_data|
|
|
input_data['tool_name'] = 'Write'
|
|
input_data['tool_input'] = { 'file_path' => '/tmp/test.rb' }
|
|
input_data['tool_response'] = { 'success' => true }
|
|
input_data['session_id'] = 'test-session-01'
|
|
|
|
# Create a test file for demonstration
|
|
File.write('/tmp/test.rb', "def hello\nputs 'world'\nend")
|
|
end
|
|
end
|