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 uv for 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.

Signature