Skip to content

Instantly share code, notes, and snippets.

@glennmatlin
Last active February 4, 2026 03:44
Show Gist options
  • Select an option

  • Save glennmatlin/fadc41edc3bb9ff68ff9cfa5d6b8aca7 to your computer and use it in GitHub Desktop.

Select an option

Save glennmatlin/fadc41edc3bb9ff68ff9cfa5d6b8aca7 to your computer and use it in GitHub Desktop.
Claude Code hooks for working with `uv`

Claude Code Hooks for working with uv

by Glenn Matlin / glennmatlin on all socials

What This Does

Prevents Claude Code from using pip, python, pytest, etc. directly in projects that use uv for Python environment management. Instead, Claude is guided to use uv run, uv add, etc.

Smart detection:

  • Only activates in directories with pyproject.toml
  • Only activates if uv is installed
  • Allows non-Python commands (git, npm, etc.) to pass through

Files

File Purpose
settings.json Hook configuration (merge into your ~/.claude/settings.json)
pre_tool_use_uv.py Blocks direct Python commands before execution
notification_uv.py Provides helpful reminders about uv usage

Installation

  1. Create hooks directory: mkdir -p ~/.claude/hooks
  2. Copy pre_tool_use_uv.py and notification_uv.py to ~/.claude/hooks/
  3. Merge settings.json into your ~/.claude/settings.json
  4. Restart Claude Code

How It Works

The PreToolUse hook intercepts Bash commands and:

  • Allows: uv run python, uv add, git, npm, etc.
  • Blocks: python script.py, pip install, pytest, etc.
  • Suggests: The equivalent uv command

Example

Command blocked: pip install requests
Reason: This project uses UV for Python management. Try: uv add requests

Covered Commands

Blocked Suggested Replacement
python script.py uv run python script.py
pip install pkg uv add pkg
pip list uv pip list
pytest uv run pytest
ruff check . uv run ruff check .
mypy src/ uv run mypy src/
black . uv run black .
flake8 uv run flake8
isort . uv run isort .
pylint uv run pylint
bandit uv run bandit
safety uv run safety

API Notes

Uses the current Claude Code hooks API:

  • exit 0 = allow command
  • exit 2 + stderr message = block command with feedback to Claude

License

MIT

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.8"
# dependencies = []
# ///
"""
Notification hook for UV-related reminders.
"""
import json
import sys
import re
def main():
"""Main hook function."""
try:
# Read input
input_data = json.loads(sys.stdin.read())
message = input_data.get('message', '')
# Check for Python-related permission requests
python_keywords = ['python', 'pip', 'install', 'package', 'dependency']
if any(keyword in message.lower() for keyword in python_keywords):
reminder = "\\n💡 Reminder: Use UV commands (uv run, uv add) instead of pip/python directly."
# Provide context-specific suggestions
if 'install' in message.lower():
reminder += "\\n To add packages: uv add <package_name>"
if 'python' in message.lower() and 'run' in message.lower():
reminder += "\\n To run Python: uv run python or uv run script.py"
print(reminder, file=sys.stderr)
sys.exit(0)
except Exception as e:
print(f"Notification hook error: {str(e)}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.8"
# dependencies = []
# ///
"""
Pre-tool use hook for Claude Code to guide UV usage in Python projects.
Uses exit code 2 + stderr to block commands (current Claude Code API).
Exit 0 allows the command to proceed.
"""
import json
import shutil
import sys
import re
from pathlib import Path
from typing import Dict, Any
class UVCommandHandler:
"""Handle Python commands with UV awareness."""
def __init__(self):
self.project_root = Path.cwd()
self.has_uv = self.check_uv_available()
self.in_project = self.check_in_project()
def check_uv_available(self) -> bool:
"""Check if UV is available in PATH."""
return shutil.which("uv") is not None
def check_in_project(self) -> bool:
"""Check if we're in a Python project with pyproject.toml."""
return (self.project_root / "pyproject.toml").exists()
def analyze_command(self, command: str) -> Dict[str, Any]:
"""Analyze command to determine how to handle it."""
# Check if command already uses uv
if command.strip().startswith("uv"):
return {"action": "allow", "reason": "Already using uv"}
# Skip non-Python commands entirely
skip_prefixes = [
"git ",
"cd ",
"ls ",
"cat ",
"echo ",
"grep ",
"find ",
"mkdir ",
"rm ",
"cp ",
"mv ",
"curl ",
"wget ",
"which ",
"touch ",
"chmod ",
"chown ",
"ln ",
"tar ",
"zip ",
"unzip ",
"head ",
"tail ",
"less ",
"more ",
"wc ",
"sort ",
"diff ",
"sed ",
"awk ",
"cut ",
"tr ",
"xargs ",
"tee ",
"date ",
"pwd ",
"whoami ",
"env ",
"export ",
"source ",
".",
"npm ",
"npx ",
"node ",
"yarn ",
"pnpm ",
"cargo ",
"rustc ",
"go ",
"make ",
"cmake ",
"docker ",
"kubectl ",
"brew ",
"apt ",
"apt-get ",
"yum ",
"dnf ",
"pacman ",
]
if any(command.strip().startswith(prefix) for prefix in skip_prefixes):
return {"action": "allow", "reason": "Not a Python command"}
# Check for actual Python command execution
python_exec_patterns = [
r"^python3?\s+",
r"^python3?\s*$",
r"\|\s*python3?\s+",
r";\s*python3?\s+",
r"&&\s*python3?\s+",
r"^pip3?\s+",
r"\|\s*pip3?\s+",
r";\s*pip3?\s+",
r"&&\s*pip3?\s+",
r"^(pytest|ruff|mypy|black|flake8|isort|pylint|bandit|safety)\s*",
r";\s*(pytest|ruff|mypy|black|flake8|isort|pylint|bandit|safety)\s*",
r"&&\s*(pytest|ruff|mypy|black|flake8|isort|pylint|bandit|safety)\s*",
]
is_python_exec = any(
re.search(pattern, command) for pattern in python_exec_patterns
)
if not is_python_exec:
return {"action": "allow", "reason": "Not a Python execution command"}
# If we're in a UV project, block and provide guidance
if self.has_uv and self.in_project:
suggestion = self.suggest_uv_command(command)
return {
"action": "block",
"reason": f"This project uses UV for Python management. {suggestion}",
}
return {"action": "allow", "reason": "UV not required"}
def suggest_uv_command(self, command: str) -> str:
"""Provide UV command suggestions."""
if "&&" in command:
parts = command.split("&&")
transformed_parts = []
for part in parts:
part = part.strip()
if re.search(
r"\b(python3?|pip3?|pytest|ruff|mypy|black|flake8|isort|pylint|bandit|safety)\b",
part,
):
transformed_parts.append(self._transform_single_command(part))
else:
transformed_parts.append(part)
return f"Try: {' && '.join(transformed_parts)}"
return f"Try: {self._transform_single_command(command)}"
def _transform_single_command(self, command: str) -> str:
"""Transform a single Python command to use UV."""
if re.match(r"^python3?\s+", command):
return re.sub(r"^python3?\s+", "uv run python ", command)
elif re.match(r"^pip3?\s+install\s+", command):
return re.sub(r"^pip3?\s+install\s+", "uv add ", command)
elif re.match(r"^pip3?\s+", command):
return re.sub(r"^pip3?\s+", "uv pip ", command)
elif re.match(
r"^(pytest|ruff|mypy|black|flake8|isort|pylint|bandit|safety)", command
):
return f"uv run {command}"
return command
def main():
"""Main hook function."""
try:
input_data = json.loads(sys.stdin.read())
tool_name = input_data.get("tool_name", "")
if tool_name not in ["Bash", "Run"]:
sys.exit(0) # Allow non-Bash tools
tool_input = input_data.get("tool_input", {})
command = tool_input.get("command", "")
if not command:
sys.exit(0) # Allow empty commands
handler = UVCommandHandler()
result = handler.analyze_command(command)
if result["action"] == "block":
# Exit 2 + stderr message blocks the command (current API)
print(result["reason"], file=sys.stderr)
sys.exit(2)
sys.exit(0) # Allow
except Exception as e:
# On error, allow to avoid blocking workflow
print(f"Hook error (allowing): {e!s}", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Run",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/pre_tool_use_uv.py",
"timeout": 10
}
]
}
],
"Notification": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "python3 ~/.claude/hooks/notification_uv.py"
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment