SLOPSHOPPER

npm-to-pnpm-rewriter

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…

newguard
A shopper browsing a rack in a slop shop
README

npm-to-pnpm-rewriter

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).

Options

  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": { } } } }

Install

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

Source 1 files
hooks/npm-to-pnpm-rewriter.ts 69 lines
1/**
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