iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔄

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