Rewrites npm and npx invocations to pnpm (or yarn / bun) before the Bash tool runs them and tells the model what ran through context. A tool.call hook that…

Rewrites npm / npx invocations to the package manager your project uses (pnpm by default, yarn or bun via options). The "modifying" placement: the hook forwards a copy of the event with a changed command to next, and tells the model what ran through context (the model's own tool_use block keeps the command it wrote, for prompt-cache stability).
manager: "pnpm" | "yarn" | "bun" (default "pnpm")
Declared in .claude-plugin/plugin.json (userConfig). Set them in /config, in user settings (~/.claude/settings.json, not project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "npm-to-pnpm-rewriter@skills-dir": { "options": { } } } }
npx claude-code-templates@latest --mod productivity/npm-to-pnpm-rewriter
claude
It is written to .claude/skills/npm-to-pnpm-rewriter/, which Claude Code auto-loads as npm-to-pnpm-rewriter@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/npm-to-pnpm-rewriter. claude plugin validate .claude/skills/npm-to-pnpm-rewriter prints every event it hooks and every $ call it makes.
Requirements. Mods are on by default in Claude Code 2.1.287+. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/npm-to-pnpm-rewriter.ts 69 lines1/**
2 * npm-to-pnpm-rewriter — Claude Mod
3 *
4 * Rewrites npm / npx invocations to the package manager your project uses
5 * (pnpm by default, yarn or bun via options). The "modifying" placement: the
6 * hook forwards a copy of the event with a changed `command` to `next`, and
7 * tells the model what ran through `context` (the model's own tool_use block
8 * keeps the command it wrote, for prompt-cache stability).
9 *
10 * Needs Claude Code >= 2.1.287. Typed
11 * against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
12 *
13 * Options:
14 * manager: "pnpm" | "yarn" | "bun" (default "pnpm")
15 */
16import type { Register } from 'claude-code'
17
18type Manager = 'pnpm' | 'yarn' | 'bun'
19
20// [regex on the npm form, replacement per manager]. $1 keeps the command separator.
21const REWRITES: Array<[RegExp, Record<Manager, string>]> = [
22 [/(^|&&\s*|;\s*|\|\s*)npm\s+ci\b/g, { pnpm: '$1pnpm install --frozen-lockfile', yarn: '$1yarn install --immutable', bun: '$1bun install --frozen-lockfile' }],
23 [/(^|&&\s*|;\s*|\|\s*)npm\s+(install|i|add)\b/g, { pnpm: '$1pnpm add', yarn: '$1yarn add', bun: '$1bun add' }],
24 [/(^|&&\s*|;\s*|\|\s*)npm\s+(uninstall|remove|rm)\b/g, { pnpm: '$1pnpm remove', yarn: '$1yarn remove', bun: '$1bun remove' }],
25 [/(^|&&\s*|;\s*|\|\s*)npm\s+run\b/g, { pnpm: '$1pnpm run', yarn: '$1yarn run', bun: '$1bun run' }],
26 [/(^|&&\s*|;\s*|\|\s*)npm\s+(test|start|build)\b/g, { pnpm: '$1pnpm $2', yarn: '$1yarn $2', bun: '$1bun run $2' }],
27 [/(^|&&\s*|;\s*|\|\s*)npx\s+/g, { pnpm: '$1pnpm dlx ', yarn: '$1yarn dlx ', bun: '$1bunx ' }],
28]
29
30// "<manager> add" with no package means "install everything": keep the bare install form.
31function fixBareAdd(command: string, manager: Manager): string {
32 return command.replace(new RegExp(`${manager} add(\\s*(&&|;|\\||$))`, 'g'), `${manager} install$1`)
33}
34
35export const register: Register = (on, options) => {
36 const manager: Manager =
37 options.manager === 'yarn' || options.manager === 'bun' ? options.manager : 'pnpm'
38
39 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
40 const original = e.command
41 if (!/\bnp[mx]\b/.test(original)) return next(e)
42
43 // rewrite only outside single- and double-quoted strings: `echo '&& npm test'` is text, not a call
44 const rewritten = original
45 .split(/("[^"]*"|'[^']*')/)
46 .map((part, i) => {
47 if (i % 2 === 1) return part
48 let out = part
49 for (const [re, byManager] of REWRITES) out = out.replace(re, byManager[manager])
50 return fixBareAdd(out, manager)
51 })
52 .join('')
53
54 if (rewritten === original) return next(e)
55
56 $.ui.log(`[npm-to-pnpm-rewriter] ${original} -> ${rewritten}`)
57 // Events are frozen: forward a modified copy instead of mutating e.
58 const outcome = await next({ ...e, command: rewritten })
59 if (outcome.deny !== undefined) return outcome
60 return {
61 ...outcome,
62 context: [
63 ...(outcome.context ?? []),
64 `npm-to-pnpm-rewriter ran \`${rewritten}\` instead of \`${original}\` (this project uses ${manager}).`,
65 ],
66 }
67 })
68}
69