Skip to content

Commit 312bb99

Browse files
committed
feat: Move test runner to Stop hook and add IDEA formatter
- Moves run-tests.py from PostToolUse to Stop hook for better timing - Tests now run after Claude finishes responding, not after each edit - Adds format-idea.py to automatically format Groovy files with IntelliJ IDEA - Keeps format-editorconfig.py for general file formatting
1 parent deb8c52 commit 312bb99

File tree

2 files changed

+273
-0
lines changed

2 files changed

+273
-0
lines changed

.claude/hooks/format-idea.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
#!/usr/bin/env python3
2+
"""
3+
IntelliJ IDEA formatter hook for Nextflow development.
4+
This hook applies IDEA code formatting to Groovy files after edits.
5+
"""
6+
7+
import hashlib
8+
import json
9+
import os
10+
import subprocess
11+
import sys
12+
import time
13+
from pathlib import Path
14+
15+
16+
def is_groovy_file(file_path):
17+
"""Check if the file is a Groovy source file"""
18+
if not file_path:
19+
return False
20+
21+
path = Path(file_path)
22+
23+
# Skip build directories, .git, etc.
24+
if any(part.startswith('.') or part == 'build' for part in path.parts):
25+
return False
26+
27+
# Only process Groovy files
28+
return path.suffix.lower() == '.groovy'
29+
30+
31+
def get_file_hash(file_path):
32+
"""Get SHA256 hash of file contents"""
33+
try:
34+
with open(file_path, 'rb') as f:
35+
return hashlib.sha256(f.read()).hexdigest()
36+
except Exception:
37+
return None
38+
39+
40+
def find_idea_sh():
41+
"""Locate idea.sh command"""
42+
# Check environment variable first
43+
idea_sh = os.environ.get('IDEA_SH')
44+
if idea_sh and os.path.exists(idea_sh):
45+
return idea_sh
46+
47+
# Common installation paths for macOS
48+
common_paths = [
49+
'/Applications/IntelliJ IDEA.app/Contents/MacOS/idea',
50+
'/Applications/IntelliJ IDEA CE.app/Contents/MacOS/idea',
51+
'/Applications/IntelliJ IDEA Ultimate.app/Contents/MacOS/idea',
52+
# Common paths for Linux
53+
'/usr/local/bin/idea.sh',
54+
'/opt/idea/bin/idea.sh',
55+
'/snap/intellij-idea-community/current/bin/idea.sh',
56+
'/snap/intellij-idea-ultimate/current/bin/idea.sh',
57+
# Check user's home for JetBrains Toolbox installations
58+
os.path.expanduser('~/.local/share/JetBrains/Toolbox/apps/IDEA-U/bin/idea.sh'),
59+
os.path.expanduser('~/.local/share/JetBrains/Toolbox/apps/IDEA-C/bin/idea.sh'),
60+
]
61+
62+
for path in common_paths:
63+
if os.path.exists(path):
64+
return path
65+
66+
# Try to find in PATH
67+
try:
68+
result = subprocess.run(['which', 'idea'], capture_output=True, text=True)
69+
if result.returncode == 0:
70+
idea_path = result.stdout.strip()
71+
if os.path.exists(idea_path):
72+
return idea_path
73+
except Exception:
74+
pass
75+
76+
return None
77+
78+
79+
def format_with_idea(file_path, idea_sh_path):
80+
"""Apply IDEA formatting to a Groovy file"""
81+
start_time = time.time()
82+
83+
try:
84+
# Get file hash before formatting
85+
before_hash = get_file_hash(file_path)
86+
87+
# Run IDEA formatter
88+
# Using format.sh script which is typically alongside idea.sh
89+
idea_dir = os.path.dirname(idea_sh_path)
90+
format_script = None
91+
92+
# Try to find format.sh in the same directory
93+
possible_format_scripts = [
94+
os.path.join(idea_dir, 'format.sh'),
95+
os.path.join(os.path.dirname(idea_dir), 'bin', 'format.sh'),
96+
]
97+
98+
for script in possible_format_scripts:
99+
if os.path.exists(script):
100+
format_script = script
101+
break
102+
103+
# If no format.sh found, use idea.sh with format command
104+
if format_script:
105+
command = [format_script, '-allowDefaults', file_path]
106+
else:
107+
command = [idea_sh_path, 'format', '-allowDefaults', file_path]
108+
109+
format_result = subprocess.run(
110+
command,
111+
capture_output=True,
112+
text=True,
113+
timeout=30
114+
)
115+
116+
# Get file hash after formatting
117+
after_hash = get_file_hash(file_path)
118+
execution_time = time.time() - start_time
119+
120+
# Check if formatting succeeded
121+
# IDEA formatter typically returns 0 on success
122+
if format_result.returncode == 0:
123+
return {
124+
'success': True,
125+
'changes_made': before_hash != after_hash,
126+
'execution_time': execution_time,
127+
'file_changed': before_hash != after_hash,
128+
'idea_path': idea_sh_path,
129+
'command': ' '.join(command)
130+
}
131+
else:
132+
return {
133+
'success': False,
134+
'error': 'IDEA formatting failed',
135+
'error_details': format_result.stderr or format_result.stdout,
136+
'execution_time': execution_time,
137+
'command': ' '.join(command),
138+
'suggestions': [
139+
'Verify IDEA installation and configuration',
140+
'Check file permissions',
141+
'Ensure IDEA is not already running the formatter'
142+
]
143+
}
144+
145+
except subprocess.TimeoutExpired:
146+
execution_time = time.time() - start_time
147+
return {
148+
'success': False,
149+
'error': 'IDEA formatting timed out after 30 seconds',
150+
'execution_time': execution_time,
151+
'suggestions': [
152+
'Check if IDEA is responsive',
153+
'Try closing and reopening IDEA',
154+
'Check system resources'
155+
]
156+
}
157+
except Exception as e:
158+
execution_time = time.time() - start_time
159+
return {
160+
'success': False,
161+
'error': f'Exception during formatting: {str(e)}',
162+
'execution_time': execution_time,
163+
'suggestions': [
164+
'Check file exists and is readable',
165+
'Verify system permissions',
166+
'Review hook configuration'
167+
]
168+
}
169+
170+
171+
def main():
172+
try:
173+
input_data = json.load(sys.stdin)
174+
except json.JSONDecodeError as e:
175+
error_output = {
176+
"decision": "block",
177+
"reason": f"IDEA formatter hook received invalid JSON input: {e}",
178+
"suggestions": ["Check Claude Code hook configuration"]
179+
}
180+
print(json.dumps(error_output))
181+
sys.exit(0)
182+
183+
hook_event = input_data.get("hook_event_name", "")
184+
tool_name = input_data.get("tool_name", "")
185+
tool_input = input_data.get("tool_input", {})
186+
187+
# Only process Edit, Write, MultiEdit tools
188+
if tool_name not in ["Edit", "Write", "MultiEdit"]:
189+
sys.exit(0)
190+
191+
file_path = tool_input.get("file_path", "")
192+
if not file_path or not is_groovy_file(file_path):
193+
sys.exit(0)
194+
195+
# Check if file exists after the edit
196+
if not os.path.exists(file_path):
197+
sys.exit(0)
198+
199+
# Find IDEA installation
200+
idea_sh_path = find_idea_sh()
201+
if not idea_sh_path:
202+
# If IDEA is not found, silently skip (don't block the workflow)
203+
# This allows the hook to be optional
204+
output = {
205+
"suppressOutput": True,
206+
"systemMessage": "⚠ IDEA formatter: idea.sh not found (set IDEA_SH environment variable)",
207+
"formattingResults": {
208+
"skipped": True,
209+
"reason": "IDEA not found"
210+
}
211+
}
212+
print(json.dumps(output))
213+
sys.exit(0)
214+
215+
# Apply IDEA formatting
216+
result = format_with_idea(file_path, idea_sh_path)
217+
filename = os.path.basename(file_path)
218+
219+
if result['success']:
220+
# Generate success message
221+
if result['changes_made']:
222+
time_text = f" ({result['execution_time']:.1f}s)"
223+
system_message = f"✓ IDEA formatter: applied to {filename}{time_text}"
224+
else:
225+
time_text = f" ({result['execution_time']:.1f}s)"
226+
system_message = f"✓ IDEA formatter: no changes needed for {filename}{time_text}"
227+
228+
output = {
229+
"suppressOutput": True,
230+
"systemMessage": system_message,
231+
"formattingResults": {
232+
"changesMade": result['changes_made'],
233+
"executionTime": result['execution_time'],
234+
"ideaPath": result['idea_path'],
235+
"fileChanged": result['file_changed']
236+
}
237+
}
238+
print(json.dumps(output))
239+
sys.exit(0)
240+
else:
241+
# Enhanced error output
242+
error_message = f"IDEA formatting failed for {filename}: {result['error']}"
243+
244+
output = {
245+
"decision": "block",
246+
"reason": error_message,
247+
"stopReason": f"Code formatting issues in {filename}",
248+
"formattingError": {
249+
"errorType": result['error'],
250+
"errorDetails": result.get('error_details', ''),
251+
"executionTime": result['execution_time'],
252+
"suggestions": result.get('suggestions', []),
253+
"filename": filename,
254+
"command": result.get('command', '')
255+
}
256+
}
257+
print(json.dumps(output))
258+
sys.exit(0)
259+
260+
261+
if __name__ == "__main__":
262+
main()

.claude/settings.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@
99
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-editorconfig.py",
1010
"timeout": 30
1111
},
12+
{
13+
"type": "command",
14+
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/format-idea.py",
15+
"timeout": 60
16+
}
17+
]
18+
}
19+
],
20+
"Stop": [
21+
{
22+
"hooks": [
1223
{
1324
"type": "command",
1425
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-tests.py",

0 commit comments

Comments
 (0)