#!/bin/bash # hooks/enforce-uv.sh # PreToolUse hook - enforces uv over pip / bare tools inside uv-managed projects # Matcher: Bash # # Turns the "modern-tools" guidance (a should-do prompt) into a deterministic # must-do guard. Redirects: # pip install -> uv add (or `uv pip ...` for unmanaged envs) # pytest / ruff / mypy ... -> uv run # # Configuration in .claude/settings.json: # { # "hooks": { # "PreToolUse": [{ # "matcher": "Bash", # "hooks": ["bash hooks/enforce-uv.sh $TOOL_INPUT"] # }] # } # } # # Exit codes: # 0 = allow (not a Python project, already uv, or no violation) # 2 = block with guidance # # Scope guards: # - Only activates when a pyproject.toml exists in the working directory # (i.e. a uv-managed project). Outside one, pip/bare tools pass through. # - Honors ENFORCE_UV=0 to disable for a single command or session. INPUT="$1" [[ -z "$INPUT" ]] && exit 0 [[ "$ENFORCE_UV" == "0" ]] && exit 0 # Only enforce inside a uv-managed project [[ -f "pyproject.toml" ]] || exit 0 block() { echo "BLOCKED (enforce-uv): $1" echo "Use instead: $2" echo "" echo "This project has a pyproject.toml — prefer the uv workflow." echo "To bypass for one command, prefix it with ENFORCE_UV=0." exit 2 } # --- pip install (mask the allowed `uv pip` compatibility layer first) ------- MASKED=$(printf '%s' "$INPUT" | sed -E 's/\buv pip\b/UV_PIP/g') if printf '%s' "$MASKED" | grep -qE '\bpip[0-9.]*[[:space:]]+install\b'; then block "bare 'pip install'" "uv add (or 'uv pip install ...' for an unmanaged venv)" fi # --- bare dev tools that should run inside the project env ------------------- # Skip if the command already routes through uv (uv run / uvx). if ! printf '%s' "$INPUT" | grep -qE '\b(uv run|uvx)\b'; then if printf '%s' "$INPUT" | grep -qE '(^|[;&|][[:space:]]*)(pytest|ruff|mypy|pyright|black|isort|flake8)\b'; then TOOL=$(printf '%s' "$INPUT" | grep -oE '(pytest|ruff|mypy|pyright|black|isort|flake8)' | head -1) block "bare '$TOOL' in a uv project" "uv run $TOOL ..." fi fi exit 0