Auto-formatting Python with Ruff in Gemini CLI
I like the convenience of having Gemini CLI write code for me, but I'm picky about formatting. I'll show you how to set up an automated hook that runs Ruff to format your code and sort your imports every time Gemini CLI modifies a Python file.
What is a hook?
In Gemini CLI, a hook is a script that runs at specific points in the agent's lifecycle. Think of it like middleware. Hooks allow you to modify how Gemini CLI behaves.
I'm using the AfterTool hook, which triggers immediately after a tool
finishes. This lets me run Ruff after Gemini CLI wrote a file using the
write_file tool, or made edits using the replace tool.
Prerequisites
To follow along, make sure you have:
- Gemini CLI installed.
-
A project using
uvfor dependency management.
1. Add Ruff to your project
Add Ruff to your development dependencies so the hook has access to it.
uv add --dev ruff
2. Configure Gemini CLI
Update .gemini/settings.json in your project root to register the hook for
both write_file and replace.
{
"hooks": {
"AfterTool": [
{
"matcher": "write_file|replace",
"hooks": [
{
"name": "ruff-format",
"type": "command",
"command": "$GEMINI_PROJECT_DIR/.gemini/hooks/format_python.py",
"description": "Auto-format Python files after edits."
}
]
}
]
}
}
3. Create the hook script
Create a directory for your hooks and a Python script to handle the formatting logic.
mkdir -p .gemini/hooks
touch .gemini/hooks/format_python.py
chmod +x .gemini/hooks/format_python.py
Paste the following code into .gemini/hooks/format_python.py. This script
uses Ruff to format Python files after edits.
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
def main():
try:
# Read the hook input from stdin
raw_input = sys.stdin.read()
if not raw_input:
return
hook_input = json.loads(raw_input)
tool_name = hook_input.get("tool_name")
file_path = hook_input.get("tool_input", {}).get("file_path")
event_name = hook_input.get("hook_event_name")
# Explicitly filter for relevant tools as a best practice
if tool_name not in ["write_file", "replace"]:
print(json.dumps({"decision": "allow"}))
return
# Ignore non-python files
if not (file_path and file_path.endswith(".py")):
print(json.dumps({"decision": "allow"}))
return
# Handle AfterTool to detect changes and notify the agent
if event_name == "AfterTool" and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as f:
before_content = f.read()
# Sort imports using Ruff's isort rules
subprocess.run(
["uv", "run", "ruff", "check", "--select", "I", "--fix", file_path],
capture_output=True,
text=True,
)
# Format the code using Ruff's formatter
result = subprocess.run(
["uv", "run", "ruff", "format", file_path],
capture_output=True,
text=True,
)
if result.returncode == 0:
with open(file_path, "r", encoding="utf-8") as f:
after_content = f.read()
# Check if Ruff made changes
if before_content != after_content:
# Notify only via additionalContext (silent in terminal)
print(json.dumps({
"decision": "allow",
"hookSpecificOutput": {
"additionalContext": f"\n\nNOTE: Ruff formatted {file_path}."
}
}))
else:
# Silent success
print(json.dumps({"decision": "allow"}))
else:
# Fail: Block and provide error feedback
print(json.dumps({
"decision": "deny",
"reason": f"Ruff formatting failed for {file_path}:\n{result.stderr}",
"systemMessage": f"❌ Ruff formatting failed for {file_path}",
}))
return
print(json.dumps({"decision": "allow"}))
except Exception as e:
print(f"Hook script error: {str(e)}", file=sys.stderr)
print(json.dumps({"decision": "allow"}))
if __name__ == "__main__":
main()
This script reads the content of the newly written or edited file and runs Ruff to first sort imports and then format the code. It adds a note to the agent's context if Ruff made changes. If no changes were needed, the script is silent.
Handling Ruff failures
If the code generated by the agent has syntax errors, Ruff will fail. In this case, the script returns a deny decision and includes the specific error, making Gemini CLI realize the code is broken and needs fixing.
4. Verification
First, verify that the hook is correctly registered by running the following command in Gemini CLI:
/hooks panel
You should see ruff-format listed in the active hooks. Now, to verify it
works in practice, copy and paste this prompt into Gemini CLI:
Create a file named `verification_test.py` with unsorted
imports for `sys` and `os`, and a function `hello` that
has inconsistent spacing like `x=1+2`.
The ruff-format hook should have automatically formatted the
file. Prove to me that it happened.
You should see the task complete silently in your terminal. If you inspect the file, you'll see that Ruff has already sorted the imports and fixed the spacing.
That's it! I hope it's useful to you.