Using Claude Code Hooks to Force uv
I wrote recently about why uv changed how I think about Python projects. The short version: I use uv for everything now. But Claude Code doesn’t always get the memo.
Even with instructions in your CLAUDE.md, you’ll occasionally watch Claude reach for pip install or bare python or pytest instead of routing through uv. It’s not that Claude ignores the instructions. It’s that these commands are deeply embedded in training data and sometimes the habit wins.
Claude Code hooks fix this at the infrastructure level. Instead of hoping the model reads your instructions, you intercept the command before it runs.
What Are Hooks?
Hooks are shell commands that Claude Code runs at specific points in its workflow. The one we care about is PreToolUse, which fires right before Claude executes a tool like Bash. Your hook script receives the command as JSON on stdin, and you control what happens:
- Exit 0: Allow the command to proceed.
- Exit 2 + stderr message: Block the command and show the message to Claude as feedback.
That’s the whole API. Exit 0 to allow, exit 2 to block with a reason.
The Hook
Here’s a hook that detects when Claude is about to run a Python command directly and blocks it with a suggestion to use uv instead. It only activates when you’re in a project that actually uses uv (has a pyproject.toml and uv is installed).
Create ~/.claude/hooks/force_uv.py:
#!/usr/bin/env python3
"""Pre-tool-use hook that redirects Python commands to uv."""
import json
import re
import shutil
import sys
from pathlib import Path
def should_enforce() -> bool:
"""Only enforce in projects that use uv."""
return (
shutil.which("uv") is not None
and (Path.cwd() / "pyproject.toml").exists()
)
def check_command(command: str) -> str | None:
"""Return a suggested replacement if the command should be blocked."""
cmd = command.strip()
# Already using uv, allow it
if cmd.startswith("uv"):
return None
# Not a Python command, allow it
safe_prefixes = [
"git", "cd", "ls", "cat", "echo", "grep", "find", "mkdir", "rm",
"cp", "mv", "curl", "make", "docker", "npm", "node", "cargo", "go",
"brew", "which", "touch", "chmod", "tar", "zip", "head", "tail",
"sed", "awk", "diff", "sort", "wc",
]
if any(cmd.startswith(p + " ") or cmd == p for p in safe_prefixes):
return None
# Direct Python/pip execution
if re.match(r"^python3?\s+", cmd):
return re.sub(r"^python3?\s+", "uv run python ", cmd)
if re.match(r"^pip3?\s+install\s+", cmd):
return re.sub(r"^pip3?\s+install\s+", "uv add ", cmd)
if re.match(r"^pip3?\s+", cmd):
return re.sub(r"^pip3?\s+", "uv pip ", cmd)
# Common Python tools that should go through uv run
tools = r"^(pytest|ruff|mypy|black|flake8|isort|pylint|bandit|safety)\b"
if re.match(tools, cmd):
return f"uv run {cmd}"
return None
def main():
try:
data = json.loads(sys.stdin.read())
if data.get("tool_name") not in ("Bash", "Run"):
sys.exit(0)
command = data.get("tool_input", {}).get("command", "")
if not command or not should_enforce():
sys.exit(0)
suggestion = check_command(command)
if suggestion:
print(
f"This project uses uv. Try: {suggestion}",
file=sys.stderr,
)
sys.exit(2)
sys.exit(0)
except Exception:
sys.exit(0) # Don't block on errors
if __name__ == "__main__":
main()
Then register it in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Run",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/force_uv.py",
"timeout": 10
}
]
}
]
}
}
What It Catches
The hook intercepts these patterns and suggests the uv equivalent:
| Blocked Command | Suggested Replacement |
|---|---|
python script.py | uv run python script.py |
pip install requests | uv add requests |
pip freeze | uv pip freeze |
pytest tests/ | uv run pytest tests/ |
ruff check . | uv run ruff check . |
mypy src/ | uv run mypy src/ |
It leaves everything else alone. Git commands, make targets, docker, node, cargo, anything that isn’t a Python command passes through without interference.
Why This Matters
The real value here isn’t saving yourself from a pip install once in a while. It’s that the hook teaches Claude through feedback. When the hook blocks a command with “This project uses uv. Try: uv add requests”, Claude sees that message and adjusts. After a few blocked attempts in a session, it starts reaching for uv on its own.
This is more reliable than CLAUDE.md instructions because it’s enforcement, not suggestion. The model can’t accidentally skip it. And it’s smarter than a blanket rule because it only activates in projects that actually have a pyproject.toml and uv installed. Your non-Python work is unaffected.
Extending It
The pattern is general. If you have strong opinions about how commands should be run in your projects, a PreToolUse hook is the right place to enforce them. Some ideas:
- Block
npmin favor ofpnpmorbun - Require
docker composeinstead ofdocker-compose - Prevent
rm -rfon certain directories - Force all database commands through a specific CLI wrapper
The hook is just a script that reads JSON and returns an exit code. Keep it simple and fast (note the 10-second timeout in the config), and it stays out of the way.
Stay in the loop
Get notified when I publish new posts. No spam, unsubscribe anytime.