enforce-uv.sh 2.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
  1. #!/bin/bash
  2. # hooks/enforce-uv.sh
  3. # PreToolUse hook - enforces uv over pip / bare tools inside uv-managed projects
  4. # Matcher: Bash
  5. #
  6. # Turns the "modern-tools" guidance (a should-do prompt) into a deterministic
  7. # must-do guard. Redirects:
  8. # pip install <pkg> -> uv add <pkg> (or `uv pip ...` for unmanaged envs)
  9. # pytest / ruff / mypy ... -> uv run <tool>
  10. #
  11. # Configuration in .claude/settings.json:
  12. # {
  13. # "hooks": {
  14. # "PreToolUse": [{
  15. # "matcher": "Bash",
  16. # "hooks": ["bash hooks/enforce-uv.sh $TOOL_INPUT"]
  17. # }]
  18. # }
  19. # }
  20. #
  21. # Exit codes:
  22. # 0 = allow (not a Python project, already uv, or no violation)
  23. # 2 = block with guidance
  24. #
  25. # Scope guards:
  26. # - Only activates when a pyproject.toml exists in the working directory
  27. # (i.e. a uv-managed project). Outside one, pip/bare tools pass through.
  28. # - Honors ENFORCE_UV=0 to disable for a single command or session.
  29. INPUT="$1"
  30. [[ -z "$INPUT" ]] && exit 0
  31. [[ "$ENFORCE_UV" == "0" ]] && exit 0
  32. # Only enforce inside a uv-managed project
  33. [[ -f "pyproject.toml" ]] || exit 0
  34. block() {
  35. echo "BLOCKED (enforce-uv): $1"
  36. echo "Use instead: $2"
  37. echo ""
  38. echo "This project has a pyproject.toml — prefer the uv workflow."
  39. echo "To bypass for one command, prefix it with ENFORCE_UV=0."
  40. exit 2
  41. }
  42. # --- pip install (mask the allowed `uv pip` compatibility layer first) -------
  43. MASKED=$(printf '%s' "$INPUT" | sed -E 's/\buv pip\b/UV_PIP/g')
  44. if printf '%s' "$MASKED" | grep -qE '\bpip[0-9.]*[[:space:]]+install\b'; then
  45. block "bare 'pip install'" "uv add <pkg> (or 'uv pip install ...' for an unmanaged venv)"
  46. fi
  47. # --- bare dev tools that should run inside the project env -------------------
  48. # Skip if the command already routes through uv (uv run / uvx).
  49. if ! printf '%s' "$INPUT" | grep -qE '\b(uv run|uvx)\b'; then
  50. if printf '%s' "$INPUT" | grep -qE '(^|[;&|][[:space:]]*)(pytest|ruff|mypy|pyright|black|isort|flake8)\b'; then
  51. TOOL=$(printf '%s' "$INPUT" | grep -oE '(pytest|ruff|mypy|pyright|black|isort|flake8)' | head -1)
  52. block "bare '$TOOL' in a uv project" "uv run $TOOL ..."
  53. fi
  54. fi
  55. exit 0