From deb8c5257ca6cadf15fd194ce6703a3c76af5dba Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 24 Sep 2025 10:10:43 +0200 Subject: [PATCH 1/2] Add Claude Code hooks for development workflow - Add EditorConfig enforcement hook using eclint - Add smart test runner that maps source files to test classes - Support all modules (nextflow, nf-commons, nf-lang, etc.) and plugins - Configure hooks to run on Edit/Write/MultiEdit operations - Add comprehensive documentation in .claude/README.md - Hooks provide immediate feedback on code formatting and test results Signed-off-by: Edmund Miller --- .claude/README.md | 100 ++++++++++++++++ .claude/hooks/format-editorconfig.py | 95 +++++++++++++++ .claude/hooks/run-tests.py | 172 +++++++++++++++++++++++++++ .claude/settings.json | 21 ++++ 4 files changed, 388 insertions(+) create mode 100644 .claude/README.md create mode 100755 .claude/hooks/format-editorconfig.py create mode 100755 .claude/hooks/run-tests.py create mode 100644 .claude/settings.json diff --git a/.claude/README.md b/.claude/README.md new file mode 100644 index 0000000000..a2ce61728d --- /dev/null +++ b/.claude/README.md @@ -0,0 +1,100 @@ +# Claude Code Hooks for Nextflow Development + +This directory contains Claude Code hooks configured to improve the Nextflow development experience. + +## Features + +### 1. EditorConfig Enforcement +- **Trigger**: After editing any source file (`.groovy`, `.java`, `.gradle`, `.md`, `.txt`, `.yml`, `.yaml`, `.json`) +- **Action**: Applies editorconfig formatting rules using `eclint` +- **Files**: `hooks/format-editorconfig.py` + +### 2. Automatic Test Running +- **Trigger**: After editing source files or test files in modules or plugins +- **Action**: + - For source files: Runs corresponding test class (e.g., `CacheDB.groovy` → runs `CacheDBTest`) + - For test files: Runs the specific test class +- **Files**: `hooks/run-tests.py` + +## Hook Configuration + +The hooks are configured in `.claude/settings.json` with: +- **30-second timeout** for editorconfig formatting +- **5-minute timeout** for test execution +- **Smart file filtering** to only process relevant files +- **Parallel execution** of both hooks after file edits + +## Supported File Structure + +The hooks understand Nextflow's module structure: + +``` +modules/ +├── nextflow/src/main/groovy/nextflow/cache/CacheDB.groovy +├── nextflow/src/test/groovy/nextflow/cache/CacheDBTest.groovy +├── nf-commons/src/main/groovy/... +├── nf-lang/src/main/java/... +└── ... + +plugins/ +├── nf-amazon/src/main/nextflow/cloud/aws/... +├── nf-azure/src/main/nextflow/cloud/azure/... +└── ... +``` + +## Test Commands Generated + +The hooks generate appropriate Gradle test commands: + +- **Source file**: `modules/nextflow/src/main/groovy/nextflow/cache/CacheDB.groovy` + - Runs: `./gradlew :nextflow:test --tests "*CacheDBTest"` + +- **Test file**: `modules/nextflow/src/test/groovy/nextflow/cache/CacheDBTest.groovy` + - Runs: `./gradlew :nextflow:test --tests "*CacheDBTest"` + +- **Plugin file**: `plugins/nf-amazon/src/main/nextflow/cloud/aws/AwsPlugin.groovy` + - Runs: `./gradlew :plugins:nf-amazon:test --tests "*AwsPluginTest"` + +## Error Handling + +- **EditorConfig failures**: Show warnings but don't block Claude +- **Test failures**: Provide detailed feedback to Claude for potential fixes +- **Missing tests**: Silently skip if no corresponding test exists +- **Timeouts**: Cancel long-running operations gracefully + +## Dependencies + +The hooks may automatically install: +- `eclint` via npm for editorconfig enforcement + +## Customization + +You can modify the hooks by: +1. Editing the Python scripts in `hooks/` +2. Adjusting timeouts in `settings.json` +3. Adding or removing file extensions in the filter logic + +## Troubleshooting + +If hooks aren't working: +1. Check that scripts are executable: `chmod +x .claude/hooks/*.py` +2. Verify Python 3 is available +3. Check Claude Code's debug output with `claude --debug` +4. Review hook execution in the transcript (Ctrl-R) + +## Example Output + +When editing a file, you'll see: +``` +✓ EditorConfig formatting applied to CacheDB.groovy +✓ Tests passed for CacheDB.groovy +BUILD SUCCESSFUL in 2s +``` + +If tests fail: +``` +Tests failed for CacheDB.groovy: +Error output: +CacheDBTest > testCacheCreation FAILED + AssertionError: Expected true but was false +``` \ No newline at end of file diff --git a/.claude/hooks/format-editorconfig.py b/.claude/hooks/format-editorconfig.py new file mode 100755 index 0000000000..8c0714c8b3 --- /dev/null +++ b/.claude/hooks/format-editorconfig.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +EditorConfig enforcement hook for Nextflow development. +This hook applies editorconfig formatting rules after file edits. +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + + +def is_source_file(file_path): + """Check if the file should be formatted""" + if not file_path: + return False + + # Only format source code files + extensions = {'.groovy', '.java', '.gradle', '.md', '.txt', '.yml', '.yaml', '.json'} + path = Path(file_path) + + # Skip build directories, .git, etc. + if any(part.startswith('.') or part == 'build' for part in path.parts): + return False + + return path.suffix.lower() in extensions + + +def format_with_editorconfig(file_path): + """Apply editorconfig formatting to a file""" + try: + # Check if eclint is available + result = subprocess.run(['which', 'eclint'], capture_output=True, text=True) + if result.returncode != 0: + print("eclint not found. Installing via npm...", file=sys.stderr) + install_result = subprocess.run(['npm', 'install', '-g', 'eclint'], + capture_output=True, text=True) + if install_result.returncode != 0: + return False, "Failed to install eclint" + + # Apply editorconfig formatting + format_result = subprocess.run(['eclint', 'fix', file_path], + capture_output=True, text=True) + + if format_result.returncode == 0: + return True, f"Applied editorconfig formatting to {file_path}" + else: + return False, f"eclint failed: {format_result.stderr}" + + except Exception as e: + return False, f"Error formatting file: {str(e)}" + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON input: {e}", file=sys.stderr) + sys.exit(1) + + hook_event = input_data.get("hook_event_name", "") + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only process Edit, Write, MultiEdit tools + if tool_name not in ["Edit", "Write", "MultiEdit"]: + sys.exit(0) + + file_path = tool_input.get("file_path", "") + if not file_path or not is_source_file(file_path): + sys.exit(0) + + # Check if file exists after the edit + if not os.path.exists(file_path): + sys.exit(0) + + success, message = format_with_editorconfig(file_path) + + if success: + # Use JSON output to suppress the normal stdout display + output = { + "suppressOutput": True, + "systemMessage": f"✓ EditorConfig formatting applied to {os.path.basename(file_path)}" + } + print(json.dumps(output)) + sys.exit(0) + else: + # Non-blocking error - show message but don't fail + print(f"Warning: {message}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.claude/hooks/run-tests.py b/.claude/hooks/run-tests.py new file mode 100755 index 0000000000..94076440b6 --- /dev/null +++ b/.claude/hooks/run-tests.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Test runner hook for Nextflow development. +This hook runs appropriate tests when source files or test files are edited. +""" + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +def extract_test_info(file_path): + """Extract module and test class info from file path""" + path = Path(file_path) + + # Check if it's in a module directory + module_match = None + for part in path.parts: + if part in ['nextflow', 'nf-commons', 'nf-httpfs', 'nf-lang', 'nf-lineage']: + module_match = part + break + # Handle plugin modules + if part.startswith('nf-') and 'plugins' in path.parts: + module_match = f"plugins:{part}" + break + + if not module_match: + return None, None, None + + # Extract the class/package info + src_parts = list(path.parts) + + # Find where the package structure starts + package_start_idx = -1 + for i, part in enumerate(src_parts): + if part in ['groovy', 'java'] and i > 0 and src_parts[i-1] in ['main', 'test']: + package_start_idx = i + 1 + break + + if package_start_idx == -1: + return module_match, None, None + + # Get package and class name + package_parts = src_parts[package_start_idx:-1] + package = '.'.join(package_parts) if package_parts else None + + class_name = path.stem + + return module_match, package, class_name + + +def determine_test_command(file_path): + """Determine the appropriate test command based on the file being edited""" + path = Path(file_path) + + # Only process Groovy and Java files in modules + if path.suffix not in ['.groovy', '.java']: + return None + + # Must be in modules or plugins directory + if 'modules' not in path.parts and 'plugins' not in path.parts: + return None + + module, package, class_name = extract_test_info(file_path) + if not module or not class_name: + return None + + # If it's already a test file, run it directly + if class_name.endswith('Test'): + test_pattern = f"*{class_name}" + return f"./gradlew :{module}:test --tests \"{test_pattern}\"" + + # If it's a source file, look for corresponding test + test_class = f"{class_name}Test" + test_pattern = f"*{test_class}" + + return f"./gradlew :{module}:test --tests \"{test_pattern}\"" + + +def run_test_command(command): + """Execute the test command""" + try: + print(f"Running: {command}") + + result = subprocess.run(command, shell=True, capture_output=True, + text=True, timeout=180) # 3 minute timeout + + return result.returncode, result.stdout, result.stderr + + except subprocess.TimeoutExpired: + return 1, "", "Test execution timed out after 3 minutes" + except Exception as e: + return 1, "", f"Error running tests: {str(e)}" + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON input: {e}", file=sys.stderr) + sys.exit(1) + + hook_event = input_data.get("hook_event_name", "") + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only process Edit, Write, MultiEdit tools + if tool_name not in ["Edit", "Write", "MultiEdit"]: + sys.exit(0) + + file_path = tool_input.get("file_path", "") + if not file_path: + sys.exit(0) + + # Determine test command + test_command = determine_test_command(file_path) + if not test_command: + # Not a file we can test + sys.exit(0) + + # Run the tests + returncode, stdout, stderr = run_test_command(test_command) + + if returncode == 0: + # Tests passed + lines = stdout.split('\n') + test_results = [line for line in lines if 'test' in line.lower() and ('passed' in line.lower() or 'success' in line.lower())] + + message = f"✓ Tests passed for {os.path.basename(file_path)}" + if test_results: + # Show a summary of the last few relevant lines + summary = '\n'.join(test_results[-3:]) if len(test_results) > 3 else '\n'.join(test_results) + message += f"\n{summary}" + + output = { + "suppressOutput": True, + "systemMessage": message + } + print(json.dumps(output)) + sys.exit(0) + else: + # Tests failed - show error to Claude for potential fixing + error_msg = f"Tests failed for {os.path.basename(file_path)}:\n" + + # Extract useful error information + if stderr: + error_msg += f"Error output:\n{stderr[:500]}\n" + + if stdout: + # Look for test failure information in stdout + lines = stdout.split('\n') + failure_lines = [line for line in lines + if any(keyword in line.lower() + for keyword in ['failed', 'error', 'exception', 'assertion'])] + + if failure_lines: + error_msg += f"Test failures:\n" + '\n'.join(failure_lines[-5:]) + + # Use JSON output to provide feedback to Claude + output = { + "decision": "block", + "reason": error_msg[:1000] + ("..." if len(error_msg) > 1000 else "") + } + print(json.dumps(output)) + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000000..7a01f01cd4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,21 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-editorconfig.py", + "timeout": 30 + }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.py", + "timeout": 300 + } + ] + } + ] + } +} \ No newline at end of file From 87253b396b88cb08330c8776e9bde2428129a159 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 8 Oct 2025 15:47:45 -0500 Subject: [PATCH 2/2] feat: Add comprehensive Claude Code hooks for development workflow This commit adds a complete set of Claude Code hooks to improve the development experience for Nextflow: **Immediate Formatting (PostToolUse):** - format-editorconfig.py: Applies editorconfig rules to all source files - format-idea.py: Formats Groovy files with IntelliJ IDEA code style **Build & Test Validation (Stop):** - check-build.py: Runs 'make compile' to catch compilation errors - run-tests.py: Runs corresponding test class for edited files **Subagent Validation (SubagentStop):** - check-build.py: Verifies subagent changes compile successfully **Benefits:** - Instant code formatting after each edit - Build errors caught before tests run - Tests only run once when agent finishes (not after each edit) - Subagents validate compilation but skip slower test runs - Non-blocking when optional tools (IDEA) not available **Configuration:** All hooks configured in .claude/settings.json with appropriate timeouts and proper event triggers for optimal development workflow. Signed-off-by: Edmund Miller --- .claude/README.md | 81 +++++++---- .claude/hooks/check-build.py | 124 +++++++++++++++++ .claude/hooks/format-idea.py | 262 +++++++++++++++++++++++++++++++++++ .claude/settings.json | 27 ++++ 4 files changed, 469 insertions(+), 25 deletions(-) create mode 100755 .claude/hooks/check-build.py create mode 100755 .claude/hooks/format-idea.py diff --git a/.claude/README.md b/.claude/README.md index a2ce61728d..b21449e76a 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -4,13 +4,25 @@ This directory contains Claude Code hooks configured to improve the Nextflow dev ## Features -### 1. EditorConfig Enforcement -- **Trigger**: After editing any source file (`.groovy`, `.java`, `.gradle`, `.md`, `.txt`, `.yml`, `.yaml`, `.json`) +### 1. EditorConfig Enforcement (PostToolUse) +- **Trigger**: Immediately after editing any source file (`.groovy`, `.java`, `.gradle`, `.md`, `.txt`, `.yml`, `.yaml`, `.json`) - **Action**: Applies editorconfig formatting rules using `eclint` - **Files**: `hooks/format-editorconfig.py` -### 2. Automatic Test Running -- **Trigger**: After editing source files or test files in modules or plugins +### 2. IntelliJ IDEA Formatter (PostToolUse) +- **Trigger**: Immediately after editing Groovy files +- **Action**: Applies IntelliJ IDEA code formatting to match project style +- **Files**: `hooks/format-idea.py` +- **Requirements**: IntelliJ IDEA installed (Community or Ultimate Edition) + +### 3. Build Check (Stop + SubagentStop) +- **Trigger**: When Claude finishes responding or when a subagent completes +- **Action**: Runs `make compile` to verify code compiles without errors +- **Files**: `hooks/check-build.py` +- **Purpose**: Catch syntax and compilation errors immediately + +### 4. Automatic Test Running (Stop) +- **Trigger**: When Claude finishes responding (main agent only) - **Action**: - For source files: Runs corresponding test class (e.g., `CacheDB.groovy` → runs `CacheDBTest`) - For test files: Runs the specific test class @@ -19,10 +31,17 @@ This directory contains Claude Code hooks configured to improve the Nextflow dev ## Hook Configuration The hooks are configured in `.claude/settings.json` with: -- **30-second timeout** for editorconfig formatting -- **5-minute timeout** for test execution -- **Smart file filtering** to only process relevant files -- **Parallel execution** of both hooks after file edits + +### PostToolUse (runs after each edit) +- **format-editorconfig.py**: 30-second timeout +- **format-idea.py**: 60-second timeout + +### Stop (runs when main agent finishes) +- **check-build.py**: 120-second timeout (runs first) +- **run-tests.py**: 300-second timeout (runs after build succeeds) + +### SubagentStop (runs when subagent finishes) +- **check-build.py**: 120-second timeout ## Supported File Structure @@ -67,12 +86,40 @@ The hooks generate appropriate Gradle test commands: The hooks may automatically install: - `eclint` via npm for editorconfig enforcement +Optional dependencies: +- IntelliJ IDEA (Community or Ultimate Edition) for Groovy formatting + - Set `IDEA_SH` environment variable if not in standard location + - Falls back gracefully if not found + +## Hook Execution Flow + +1. **During editing** (PostToolUse): + ``` + ✓ EditorConfig formatting applied to CacheDB.groovy + ✓ IDEA formatter: applied to CacheDB.groovy (5.2s) + ``` + +2. **When finishing** (Stop): + ``` + ✓ Build check passed (12.3s) + ✓ Tests passed for CacheDB.groovy + ``` + +3. **If errors occur**: + ``` + Build check failed: + error: cannot find symbol + symbol: variable foo + location: class CacheDB + ``` + ## Customization You can modify the hooks by: 1. Editing the Python scripts in `hooks/` 2. Adjusting timeouts in `settings.json` 3. Adding or removing file extensions in the filter logic +4. Disabling specific hooks by removing them from `settings.json` ## Troubleshooting @@ -81,20 +128,4 @@ If hooks aren't working: 2. Verify Python 3 is available 3. Check Claude Code's debug output with `claude --debug` 4. Review hook execution in the transcript (Ctrl-R) - -## Example Output - -When editing a file, you'll see: -``` -✓ EditorConfig formatting applied to CacheDB.groovy -✓ Tests passed for CacheDB.groovy -BUILD SUCCESSFUL in 2s -``` - -If tests fail: -``` -Tests failed for CacheDB.groovy: -Error output: -CacheDBTest > testCacheCreation FAILED - AssertionError: Expected true but was false -``` \ No newline at end of file +5. For IDEA formatter: verify IDEA installation or set `IDEA_SH` environment variable \ No newline at end of file diff --git a/.claude/hooks/check-build.py b/.claude/hooks/check-build.py new file mode 100755 index 0000000000..6d4f4949a3 --- /dev/null +++ b/.claude/hooks/check-build.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Build check hook for Nextflow development. +This hook runs a quick compilation check to catch syntax errors. +""" + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + + +def run_build_check(): + """Run a quick build check""" + start_time = time.time() + + try: + # Run make compile for quick syntax checking + result = subprocess.run( + ['make', 'compile'], + capture_output=True, + text=True, + timeout=120 # 2 minute timeout + ) + + execution_time = time.time() - start_time + + if result.returncode == 0: + return { + 'success': True, + 'execution_time': execution_time, + 'message': f"✓ Build check passed ({execution_time:.1f}s)" + } + else: + # Extract error information + error_lines = [] + for line in result.stdout.split('\n') + result.stderr.split('\n'): + if any(keyword in line.lower() for keyword in ['error', 'failed', 'exception']): + error_lines.append(line) + + error_summary = '\n'.join(error_lines[-10:]) if error_lines else result.stderr[-500:] + + return { + 'success': False, + 'execution_time': execution_time, + 'error': f"Build check failed:\n{error_summary}", + 'suggestions': [ + 'Check for syntax errors in modified files', + 'Review compilation errors above', + 'Run `make compile` manually for full output' + ] + } + + except subprocess.TimeoutExpired: + execution_time = time.time() - start_time + return { + 'success': False, + 'execution_time': execution_time, + 'error': 'Build check timed out after 2 minutes', + 'suggestions': [ + 'Check if build is hung', + 'Try running `make compile` manually' + ] + } + except Exception as e: + execution_time = time.time() - start_time + return { + 'success': False, + 'execution_time': execution_time, + 'error': f'Exception during build check: {str(e)}', + 'suggestions': [ + 'Verify make is installed', + 'Check build system configuration' + ] + } + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + error_output = { + "decision": "block", + "reason": f"Build check hook received invalid JSON input: {e}", + "suggestions": ["Check Claude Code hook configuration"] + } + print(json.dumps(error_output)) + sys.exit(0) + + hook_event = input_data.get("hook_event_name", "") + + # Only run on Stop and SubagentStop events + if hook_event not in ["stop-hook", "subagent-stop-hook"]: + sys.exit(0) + + # Run build check + result = run_build_check() + + if result['success']: + output = { + "suppressOutput": True, + "systemMessage": result['message'] + } + print(json.dumps(output)) + sys.exit(0) + else: + # Block with error information + output = { + "decision": "block", + "reason": result['error'], + "stopReason": "Build compilation failed", + "buildError": { + "executionTime": result['execution_time'], + "suggestions": result.get('suggestions', []) + } + } + print(json.dumps(output)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/format-idea.py b/.claude/hooks/format-idea.py new file mode 100755 index 0000000000..75ea8ea0e7 --- /dev/null +++ b/.claude/hooks/format-idea.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +IntelliJ IDEA formatter hook for Nextflow development. +This hook applies IDEA code formatting to Groovy files after edits. +""" + +import hashlib +import json +import os +import subprocess +import sys +import time +from pathlib import Path + + +def is_groovy_file(file_path): + """Check if the file is a Groovy source file""" + if not file_path: + return False + + path = Path(file_path) + + # Skip build directories, .git, etc. + if any(part.startswith('.') or part == 'build' for part in path.parts): + return False + + # Only process Groovy files + return path.suffix.lower() == '.groovy' + + +def get_file_hash(file_path): + """Get SHA256 hash of file contents""" + try: + with open(file_path, 'rb') as f: + return hashlib.sha256(f.read()).hexdigest() + except Exception: + return None + + +def find_idea_sh(): + """Locate idea.sh command""" + # Check environment variable first + idea_sh = os.environ.get('IDEA_SH') + if idea_sh and os.path.exists(idea_sh): + return idea_sh + + # Common installation paths for macOS + common_paths = [ + '/Applications/IntelliJ IDEA.app/Contents/MacOS/idea', + '/Applications/IntelliJ IDEA CE.app/Contents/MacOS/idea', + '/Applications/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea', + # Common paths for Linux + '/usr/local/bin/idea.sh', + '/opt/idea/bin/idea.sh', + '/snap/intellij-idea-community/current/bin/idea.sh', + '/snap/intellij-idea-ultimate/current/bin/idea.sh', + # Check user's home for JetBrains Toolbox installations + os.path.expanduser('~/.local/share/JetBrains/Toolbox/apps/IDEA-U/bin/idea.sh'), + os.path.expanduser('~/.local/share/JetBrains/Toolbox/apps/IDEA-C/bin/idea.sh'), + ] + + for path in common_paths: + if os.path.exists(path): + return path + + # Try to find in PATH + try: + result = subprocess.run(['which', 'idea'], capture_output=True, text=True) + if result.returncode == 0: + idea_path = result.stdout.strip() + if os.path.exists(idea_path): + return idea_path + except Exception: + pass + + return None + + +def format_with_idea(file_path, idea_sh_path): + """Apply IDEA formatting to a Groovy file""" + start_time = time.time() + + try: + # Get file hash before formatting + before_hash = get_file_hash(file_path) + + # Run IDEA formatter + # Using format.sh script which is typically alongside idea.sh + idea_dir = os.path.dirname(idea_sh_path) + format_script = None + + # Try to find format.sh in the same directory + possible_format_scripts = [ + os.path.join(idea_dir, 'format.sh'), + os.path.join(os.path.dirname(idea_dir), 'bin', 'format.sh'), + ] + + for script in possible_format_scripts: + if os.path.exists(script): + format_script = script + break + + # If no format.sh found, use idea.sh with format command + if format_script: + command = [format_script, '-allowDefaults', file_path] + else: + command = [idea_sh_path, 'format', '-allowDefaults', file_path] + + format_result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=30 + ) + + # Get file hash after formatting + after_hash = get_file_hash(file_path) + execution_time = time.time() - start_time + + # Check if formatting succeeded + # IDEA formatter typically returns 0 on success + if format_result.returncode == 0: + return { + 'success': True, + 'changes_made': before_hash != after_hash, + 'execution_time': execution_time, + 'file_changed': before_hash != after_hash, + 'idea_path': idea_sh_path, + 'command': ' '.join(command) + } + else: + return { + 'success': False, + 'error': 'IDEA formatting failed', + 'error_details': format_result.stderr or format_result.stdout, + 'execution_time': execution_time, + 'command': ' '.join(command), + 'suggestions': [ + 'Verify IDEA installation and configuration', + 'Check file permissions', + 'Ensure IDEA is not already running the formatter' + ] + } + + except subprocess.TimeoutExpired: + execution_time = time.time() - start_time + return { + 'success': False, + 'error': 'IDEA formatting timed out after 30 seconds', + 'execution_time': execution_time, + 'suggestions': [ + 'Check if IDEA is responsive', + 'Try closing and reopening IDEA', + 'Check system resources' + ] + } + except Exception as e: + execution_time = time.time() - start_time + return { + 'success': False, + 'error': f'Exception during formatting: {str(e)}', + 'execution_time': execution_time, + 'suggestions': [ + 'Check file exists and is readable', + 'Verify system permissions', + 'Review hook configuration' + ] + } + + +def main(): + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + error_output = { + "decision": "block", + "reason": f"IDEA formatter hook received invalid JSON input: {e}", + "suggestions": ["Check Claude Code hook configuration"] + } + print(json.dumps(error_output)) + sys.exit(0) + + hook_event = input_data.get("hook_event_name", "") + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only process Edit, Write, MultiEdit tools + if tool_name not in ["Edit", "Write", "MultiEdit"]: + sys.exit(0) + + file_path = tool_input.get("file_path", "") + if not file_path or not is_groovy_file(file_path): + sys.exit(0) + + # Check if file exists after the edit + if not os.path.exists(file_path): + sys.exit(0) + + # Find IDEA installation + idea_sh_path = find_idea_sh() + if not idea_sh_path: + # If IDEA is not found, silently skip (don't block the workflow) + # This allows the hook to be optional + output = { + "suppressOutput": True, + "systemMessage": "⚠ IDEA formatter: idea.sh not found (set IDEA_SH environment variable)", + "formattingResults": { + "skipped": True, + "reason": "IDEA not found" + } + } + print(json.dumps(output)) + sys.exit(0) + + # Apply IDEA formatting + result = format_with_idea(file_path, idea_sh_path) + filename = os.path.basename(file_path) + + if result['success']: + # Generate success message + if result['changes_made']: + time_text = f" ({result['execution_time']:.1f}s)" + system_message = f"✓ IDEA formatter: applied to {filename}{time_text}" + else: + time_text = f" ({result['execution_time']:.1f}s)" + system_message = f"✓ IDEA formatter: no changes needed for {filename}{time_text}" + + output = { + "suppressOutput": True, + "systemMessage": system_message, + "formattingResults": { + "changesMade": result['changes_made'], + "executionTime": result['execution_time'], + "ideaPath": result['idea_path'], + "fileChanged": result['file_changed'] + } + } + print(json.dumps(output)) + sys.exit(0) + else: + # Enhanced error output + error_message = f"IDEA formatting failed for {filename}: {result['error']}" + + output = { + "decision": "block", + "reason": error_message, + "stopReason": f"Code formatting issues in {filename}", + "formattingError": { + "errorType": result['error'], + "errorDetails": result.get('error_details', ''), + "executionTime": result['execution_time'], + "suggestions": result.get('suggestions', []), + "filename": filename, + "command": result.get('command', '') + } + } + print(json.dumps(output)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.claude/settings.json b/.claude/settings.json index 7a01f01cd4..9c46ad04ca 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,22 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-editorconfig.py", "timeout": 30 }, + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-idea.py", + "timeout": 60 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-build.py", + "timeout": 120 + }, { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.py", @@ -16,6 +32,17 @@ } ] } + ], + "SubagentStop": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/check-build.py", + "timeout": 120 + } + ] + } ] } } \ No newline at end of file