Browse Source

feat(hooks): add enforce-uv PreToolUse hook

Turns the modern-tools guidance into a deterministic guard. Inside a
uv-managed project (pyproject.toml present) it blocks:

  pip install <pkg>         -> suggests uv add <pkg>
  bare pytest/ruff/mypy/... -> suggests uv run <tool>

Masks the allowed `uv pip` compatibility layer, skips when the command
already routes through `uv run`/`uvx`, no-ops outside a project, and honors
ENFORCE_UV=0 to bypass. Verified with 13 allow/block cases (incl. no false
positive when a tool name only appears inside a commit message).

Registered in plugin.json (4 -> 5 hooks), hooks/README.md, and README.md;
also added the previously-missing check-mail.sh row to both tables.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
0xDarkMatter 1 week ago
parent
commit
304934d5fa
4 changed files with 71 additions and 4 deletions
  1. 3 2
      .claude-plugin/plugin.json
  2. 3 2
      README.md
  3. 3 0
      hooks/README.md
  4. 62 0
      hooks/enforce-uv.sh

+ 3 - 2
.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "claude-mods",
   "version": "2.8.0",
-  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 78 skills, 2 commands, 6 rules, 4 hooks, 13 output styles, modern CLI tools",
+  "description": "Custom commands, skills, and agents for Claude Code - session continuity, 23 expert agents, 78 skills, 2 commands, 6 rules, 5 hooks, 13 output styles, modern CLI tools",
   "author": "0xDarkMatter",
   "repository": "https://github.com/0xDarkMatter/claude-mods",
   "license": "MIT",
@@ -136,7 +136,8 @@
       "hooks/pre-commit-lint.sh",
       "hooks/post-edit-format.sh",
       "hooks/dangerous-cmd-warn.sh",
-      "hooks/check-mail.sh"
+      "hooks/check-mail.sh",
+      "hooks/enforce-uv.sh"
     ],
     "output-styles": [
       "output-styles/vesper.md",

File diff suppressed because it is too large
+ 3 - 2
README.md


+ 3 - 0
hooks/README.md

@@ -9,6 +9,8 @@ Claude Code hooks allow you to run custom scripts at key workflow points.
 | `pre-commit-lint.sh` | PreToolUse | Auto-lint staged files before commit (JS/TS, Python, Go, Rust, PHP) |
 | `post-edit-format.sh` | PostToolUse | Auto-format files after Write/Edit (Prettier, Ruff, gofmt, rustfmt) |
 | `dangerous-cmd-warn.sh` | PreToolUse | Block destructive commands (force push, rm -rf, DROP TABLE, etc.) |
+| `enforce-uv.sh` | PreToolUse | Enforce uv over pip/bare tools in uv-managed projects (`pip install` → `uv add`, bare `pytest`/`ruff`/`mypy` → `uv run`) |
+| `check-mail.sh` | PreToolUse | Check for unread pigeon pmail via signal file (zero-cost when empty) |
 
 ## Configuration
 
@@ -22,6 +24,7 @@ Add hooks to `.claude/settings.json` or `.claude/settings.local.json`:
         "matcher": "Bash",
         "hooks": [
           "bash hooks/dangerous-cmd-warn.sh $TOOL_INPUT",
+          "bash hooks/enforce-uv.sh $TOOL_INPUT",
           "bash hooks/pre-commit-lint.sh $TOOL_INPUT"
         ]
       }

+ 62 - 0
hooks/enforce-uv.sh

@@ -0,0 +1,62 @@
+#!/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 <pkg>        -> uv add <pkg>   (or `uv pip ...` for unmanaged envs)
+#   pytest / ruff / mypy ... -> uv run <tool>
+#
+# 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 <pkg>   (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