iTranslated by AI
Solving the Issue of Claude Code Running npm install in pnpm Projects Using Hooks
Introduction
When generating Bash commands, Claude Code tends to use npm install or npx. However, the package manager used varies by project. If npm install is executed in a project using pnpm, a package-lock.json is generated, leading to lockfile conflicts. Similar issues occur with yarn or bun.
In this article, I will introduce a mechanism to automatically rewrite package manager commands using Claude Code's hooks (PreToolUse).
Is CLAUDE.md Not Enough?
The first solution that comes to mind is to write "Use pnpm in this project" in CLAUDE.md.
## Package Manager
- Use pnpm in this project
- Use pnpm install instead of npm install
In fact, this solves the problem in most cases. However, it is not perfect.
- Instructions in CLAUDE.md are followed with high probability, but not 100%. When the context becomes long, commands different from the instructions are occasionally generated.
- There is the effort of writing CLAUDE.md for every project. If you have 10 repositories, you have to write it 10 times.
- There is a difference between "likely to be followed" and "guaranteed by a mechanism."
Hooks fill this remaining gap. They intercept package manager commands before execution and rewrite them via ni. This functions as a safety net that reinforces the instructions in CLAUDE.md.
ni: A lockfile-based package manager unification tool
ni is a tool that detects the lockfile and automatically selects the package manager suitable for the project. It uses pnpm if pnpm-lock.yaml exists, and yarn if yarn.lock exists.
ni # → pnpm install (for pnpm projects)
ni axios # → pnpm add axios
nr dev # → pnpm run dev
nlx vitest # → pnpm exec vitest
na why react # → pnpm why react (delegates to the detected package manager)
It is convenient for humans, but it works particularly well with AI agents because it allows the tool to absorb the decision of "which package manager to use" rather than leaving it to the AI.
Claude Code's PreToolUse hooks
Claude Code has a mechanism called hooks that intervenes in the tool execution lifecycle. In this case, we use PreToolUse, which fires immediately before a tool is executed.
Hooks are configured in ~/.claude/settings.json (common to all projects) or .claude/settings.json (per project).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/auto-package-manager.sh"
}
]
}
]
}
}
A hook script receives JSON via stdin and returns JSON via stdout. For PreToolUse, you can control the following:
| Field | Description |
|---|---|
permissionDecision |
"allow" (Auto-approve) / "deny" (Block) / "ask" (Confirm with user) |
permissionDecisionReason |
Reason for the decision (displayed to the user) |
updatedInput |
Rewrite the tool's input parameters |
When rewriting a command with updatedInput, set permissionDecision to either "allow" or "ask". If "allow", the converted command is automatically approved; if "ask", the user is asked to confirm the converted command before execution.
Implementation: auto-package-manager.sh
Below is the full text of the hook script.
#!/bin/bash
# PreToolUse hook: Convert package manager commands to ni equivalents
# jq and ni must be installed
command -v jq >/dev/null 2>&1 || exit 0
command -v ni >/dev/null 2>&1 || exit 0
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
# Only process package manager commands
FIRST_TOKEN=$(echo "$COMMAND" | awk '{print $1}')
case "$FIRST_TOKEN" in
npm|npx|pnpm|yarn|bun|bunx) ;;
*) exit 0 ;;
esac
convert_to_ni() {
local cmd="$1"
# npx / bunx → nlx
if [[ "$cmd" =~ ^npx\ (.+) ]]; then echo "nlx ${BASH_REMATCH[1]}"; return; fi
if [[ "$cmd" =~ ^bunx\ (.+) ]]; then echo "nlx ${BASH_REMATCH[1]}"; return; fi
local rest="${cmd#* }"
local subcmd="${rest%% *}"
local args=""
if [[ "$rest" == *" "* ]]; then
args="${rest#* }"
fi
case "$subcmd" in
install|i)
if [ -z "$args" ] || [ "$subcmd" = "$rest" ]; then
echo "ni"
else
echo "ni $args"
fi
;;
add)
if [ -n "$args" ]; then echo "ni $args"; else echo "ni"; fi
;;
ci)
echo "nci"
;;
run)
echo "nr $args"
;;
test|t)
if [ -n "$args" ]; then echo "nr test $args"; else echo "nr test"; fi
;;
start)
if [ -n "$args" ]; then echo "nr start $args"; else echo "nr start"; fi
;;
exec|dlx|x)
if [ -n "$args" ]; then echo "nlx $args"; else echo ""; fi
;;
uninstall|remove|rm|un)
if [ -n "$args" ]; then echo "nun $args"; else echo ""; fi
;;
update|up|upgrade)
if [ -z "$args" ] || [ "$subcmd" = "$rest" ]; then
echo "nup"
else
echo "nup $args"
fi
;;
*)
# Bare command (e.g. "yarn") → install, unknown subcommand → delegate to na
if [ "$rest" = "$cmd" ]; then
echo "ni"
elif [ -n "$args" ]; then
echo "na $subcmd $args"
else
echo "na $subcmd"
fi
;;
esac
}
NEW_COMMAND=$(convert_to_ni "$COMMAND")
if [ -z "$NEW_COMMAND" ]; then
exit 0
fi
jq -n \
--arg cmd "$NEW_COMMAND" \
--arg from "$FIRST_TOKEN" \
'{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "allow",
permissionDecisionReason: ($from + " → converted to ni"),
updatedInput: {
command: $cmd
}
}
}'
exit 0
I'll explain the key points.
Safe failure with early return
If the first token of the command is not a package manager, it does nothing with exit 0. When the exit code is 0 and stdout is empty, Claude Code executes the original command as is.
Delegating unknown subcommands to na
If there is no corresponding ni command, such as for why or cache, it delegates to na (agent alias). na calls the detected package manager directly. For example, yarn why react is converted to na why react, which is executed as pnpm why react in a pnpm project.
Bare commands without arguments (like yarn, pnpm, etc.) are converted to ni (= install). The original command is only passed through as-is if convert_to_ni returns an empty string.
Automatic approval with permissionDecision: "allow"
The converted command is automatically approved with "allow". This is based on the judgment that converting to ni is a safe operation and does not require asking for user confirmation every time.
Example of operation
Here is the flow when Claude tries to execute npm install axios.
JSON received by the hook (stdin):
{
"tool_name": "Bash",
"tool_input": {
"command": "npm install axios"
}
}
JSON returned by the hook (stdout):
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "npm → converted to ni",
"updatedInput": {
"command": "ni axios"
}
}
}
List of conversion examples:
| Original Command | After Conversion |
|---|---|
npm install |
ni |
npm install axios |
ni axios |
yarn add react |
ni react |
pnpm run dev |
nr dev |
npx vitest |
nlx vitest |
bunx tsc |
nlx tsc |
npm uninstall lodash |
nun lodash |
bun run build |
nr build |
yarn |
ni |
yarn why react |
na why react |
npm cache clean |
na cache clean |
Limitations and edge cases
There are cases where this hook cannot handle things correctly.
Commands containing chains or pipes are not fully converted. For npm install axios && npm run build, the leading npm install axios is converted to ni axios. However, the npm run build after && remains as is, resulting in the execution of ni axios && npm run build. It is important to note that not all commands will be unified to ni.
If you execute a package manager-specific subcommand via na, it will fail if no command with the same name exists in other managers. For example, pnpm store prune is a pnpm-specific operation; if na store prune is executed in an npm project, it will fail.
Global installation (npm install -g) is converted to ni -g. However, the behavior of ni during global installation depends on the globalAgent setting in ~/.nirc.
Comparison with corepack
corepack is a tool that blocks the execution of the wrong package manager based on the packageManager field in package.json. It was bundled with Node.js before version 24, but from Node.js 25 onwards, it was removed and requires separate installation via npm install -g corepack.
Corepack is a mechanism to "force the use of the correct package manager" on a per-project basis. On the other hand, ni is a "cross-cutting abstraction" that detects the lockfile and converts it to the appropriate command. They can be used together, such as a configuration that uses corepack for protection while improving convenience with ni.
Summary
Transparent conversion using hooks has higher reliability compared to instructions in CLAUDE.md. It is an approach to structurally control AI output and does not depend on the AI following instructions.
The settings are applied to all projects simply by writing them in ~/.claude/settings.json. Writing CLAUDE.md for each project becomes unnecessary. If the risk of auto-allow is unacceptable, you can just switch to "ask". It has the flexibility to be adjusted according to your needs.
Discussion