I’ve been working across several codebases that each use a different package manager — npm, pnpm, yarn — and agents keep reaching for npm by default even when the project clearly uses something else.
The obvious fix is to add an instruction to CLAUDE.md, agents.md, or whatever steering file the tool reads. But this has two problems. First, it’s probabilistic — the model may or may not honour the instruction, especially mid-session or after a long context. Second, it’s always-on: the instruction is loaded for every session regardless of whether you’re doing anything package-manager-related. You’re paying a context tax for a constraint that only matters some of the time, and still not guaranteed to get it enforced.
I came across an article by Matt Pocock that reframed the problem well: you’re trying to solve something deterministic with a probabilistic tool. What you actually want is a constraint the model cannot override.
Most agentic coding tools — Claude Code, Codex, Kiro and others — support PreToolUse hooks: scripts that run before a tool executes and can block it. The script receives the pending command as JSON on stdin. Exit 0 to allow, exit 2 to block. The agent sees the error output and self-corrects.
The article shows a project-local hook script for this:
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": ".claude/hooks/enforce-pnpm.sh"
}
]
}
]
}
}#!/bin/bash
# .claude/hooks/enforce-pnpm.sh
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
if echo "$COMMAND" | grep -qE "^npm "; then
echo "Blocked: use pnpm instead of npm" >&2
exit 2
fi
exit 0Per-project config means one repo enforces pnpm, another enforces yarn. No global settings, no CLAUDE.md entry.
Taking it further: a reusable dotfiles utility
The project-specific script works, but copying a variation of it into every codebase gets old. I extracted the logic into a generic script in my dotfiles — cc-enforce-x-over-y — that takes the preferred and blocked tools as arguments:
#!/bin/bash
# cc-enforce-x-over-y — installed via dotfiles to ~/.local/bin
# Usage: cc-enforce-x-over-y <preferred> <blocked> [blocked2 ...]
# e.g. cc-enforce-x-over-y pnpm npm
# cc-enforce-x-over-y yarn npm pnpm
PREFERRED="$1"
shift
BLOCKED=("$@")
COMMAND=$(jq -r '.tool_input.command // empty')
for BLOCKED_TOOL in "${BLOCKED[@]}"; do
if echo "$COMMAND" | grep -qE "(^|[^a-zA-Z0-9_-])${BLOCKED_TOOL}([^a-zA-Z0-9_-]|$)"; then
jq -n --arg preferred "$PREFERRED" --arg blocked "$BLOCKED_TOOL" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Use \($preferred) instead of \($blocked). Please replace \($blocked) with \($preferred) in your command."
}
}'
exit 0
fi
done
exit 0A few small improvements over the article’s version: structured JSON output via permissionDecision: "deny" rather than exit 2 + stderr; a word-boundary regex that catches npm mid-command and avoids false matches; and support for blocking multiple tools in one call (cc-enforce-x-over-y yarn npm pnpm).
With the script on your PATH, the per-project config shrinks to a single line:
// .claude/settings.local.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "cc-enforce-x-over-y pnpm npm" }]
}
]
}
}No hook script checked into the repo. No CLAUDE.md entry burning context. Just a constraint that holds.
There’s a broader pattern here that agentic tooling seems to be converging on: pair the probabilistic with the deterministic. Let the model handle judgment — what to do, in what order, how to respond to the unexpected — and use code to enforce the things that shouldn’t be up for debate. Hooks, permission systems, structured output schemas: they’re all expressions of the same idea.