Claude Code with pnpm: Workspaces, Lockfiles, and Strict Isolation
Why Claude defaults to npm patterns in pnpm projects
Claude Code is aware of pnpm. It knows the package manager exists, knows the basic commands, and will write pnpm install if you ask it to. The problem surfaces one layer deeper, when Claude starts making assumptions about how your dependency tree is structured.
pnpm's core design decision is strict isolation. Every package in a pnpm project can only import what it explicitly declares as a dependency. There is no accidental access to transitive dependencies the way there is in npm's hoisted node_modules. This is intentional and it is the reason pnpm became the default choice for monorepo teams: it prevents phantom dependency bugs and makes dependency graphs auditable.
Claude's training distribution skews heavily toward npm and yarn conventions. Without explicit project context, it will generate code that imports transitive dependencies as if they were declared, suggest npm install --legacy-peer-deps as a fix for peer dependency conflicts, write scripts that assume a flat node_modules layout, and reference packages that exist in a sibling workspace without adding them to the correct package.json. Each of these works on npm, breaks on pnpm, and leaves you with a conflict between what Claude generated and what your project actually allows.
The fix is a CLAUDE.md section that describes your pnpm setup explicitly: workspace structure, hoisting policy, lockfile rules, script patterns, and the specific commands that replace their npm equivalents.
If you are setting up Claude Code for the first time on any project, the Claude Code setup guide covers authentication and the initial CLAUDE.md scaffolding before any of this applies. For monorepo context beyond pnpm itself, Claude Code for monorepos covers the workspace organization and context strategies that complement what is in this guide.
The pnpm CLAUDE.md template
This template covers single-package projects and workspaces. Start with the full version and trim the workspace sections if you are on a single-package project.
# pnpm project rules
## Package manager
- pnpm v9.x (NOT npm, NOT yarn)
- Node: 20.x
- Lockfile: pnpm-lock.yaml (committed, never hand-edit)
- Engine: set in package.json engines field
## Install commands
- Install all deps: pnpm install
- Add a dep: pnpm add <pkg> (NOT npm install, NOT yarn add)
- Add a dev dep: pnpm add -D <pkg>
- Add to specific workspace: pnpm add <pkg> --filter <workspace-name>
- Remove: pnpm remove <pkg>
- Run a script: pnpm <script> (NOT npm run)
## Hoisting policy (.npmrc)
- public-hoist-pattern[]=*eslint*
- public-hoist-pattern[]=*prettier*
- public-hoist-pattern[]=*typescript*
- public-hoist-pattern[]=*@types/*
- node-linker=isolated (default, do NOT set node-linker=hoisted)
- shamefully-hoist=false (do NOT set this)
## Import rules (CRITICAL)
- A package can ONLY import packages listed in its own package.json dependencies
- DO NOT import transitive dependencies (packages your deps depend on)
- If an import is needed, add it explicitly: pnpm add <pkg> --filter <target-package>
- Check imports against package.json before writing. If it is not listed, add it first.
## Workspace (if applicable)
- Workspace config: pnpm-workspace.yaml
- Packages: apps/*, packages/*
- Run command in one package: pnpm --filter <name> <script>
- Run command in all packages: pnpm -r <script>
- Run command in changed packages only: pnpm --filter [...origin/main] <script>
- Workspace protocol: use workspace:* for cross-package deps in package.json
## Lockfile discipline
- NEVER run pnpm install --no-lockfile
- NEVER commit with an out-of-sync lockfile
- CI must use: pnpm install --frozen-lockfile
- If lockfile is out of sync: run pnpm install locally, commit updated lockfile
## Overrides (transitive dep patches)
- Declared in package.json → pnpm → overrides (NOT resolutions)
- Use to fix transitive vulnerabilities: add to root package.json only
- After adding an override: run pnpm install and commit the updated lockfile
Three rules in this template do the most work.
The import rules section is the most important. Claude will not check your package.json before writing an import statement. It writes from pattern-matching on what is commonly available. In a pnpm project, this produces imports of transitive deps that exist in .pnpm/ but are not declared in your package. The code runs in development if pnpm's isolation is not strictly enforced, then fails in CI or after a pnpm update changes the transitive tree. The explicit rule forces Claude to add the dep before writing the import.
The lockfile discipline section prevents Claude from suggesting --no-lockfile or --ignore-scripts as quick fixes for install errors. These flags have legitimate uses, but Claude reaches for them as a general-purpose troubleshooting step. In a pnpm monorepo, an out-of-sync lockfile is a CI failure condition, not a local workaround. The rule makes that constraint explicit.
The workspace protocol line (workspace:*) matters because Claude will sometimes write cross-package dependencies using plain version numbers instead of the workspace protocol. A plain version number installs from npm; workspace:* installs from the local package and updates correctly when you change the version. This is one of the harder things to catch in review because the dep resolves correctly in development and only breaks when you try to publish or deploy.
pnpm workspace structure and the virtual store
Understanding what the .pnpm/ directory is tells you why the isolation rules exist and what Claude is missing when it generates an import from a transitive dep.
pnpm stores every installed package once on disk in a content-addressable store (usually ~/.pnpm-store). Inside your project's node_modules, it creates a .pnpm/ directory containing symlinks to those store entries. Each package gets its own isolated node_modules containing only the deps it declared. When Node resolves an import, it follows the symlink to the package's isolated environment and can only find packages that are symlinked there.
The result is that node_modules/lodash does not exist as a flat entry if your project did not declare it. It exists inside .pnpm/some-dep@1.0.0/node_modules/lodash, accessible to some-dep but not to your application code.
Claude generates imports as if the traditional flat layout existed. Adding this explanation to your CLAUDE.md helps, but the import rule is more reliable than asking Claude to reason about the virtual store:
## Virtual store structure
node_modules/
.pnpm/ <- pnpm virtual store (do not read or import from here)
package@version/
node_modules/
package/ <- isolated dep, not importable by your code
your-declared-dep/ <- symlink to .pnpm store, importable
.modules.yaml <- pnpm metadata
Rule: only import from node_modules entries at the root level that exist because
they are in your package.json. If a module is missing there, add it via pnpm add.
For a pnpm workspace, this structure repeats at each package level. The workspace root node_modules/.pnpm contains the entire dependency graph for all packages. Each individual package has its own node_modules with symlinks to only the deps it declared.
The Claude Code TypeScript guide covers how TypeScript path aliases interact with this structure. When you declare paths in tsconfig.json for cross-package imports in a workspace, those paths need to resolve through the workspace protocol in package.json, not through a relative path that bypasses pnpm's isolation.
Hoisting, .npmrc, and the public-hoist-pattern
pnpm's default isolation model is strict, but most real projects need some controlled hoisting. CLI tools, type packages, and build tooling often need to be accessible from the root of the project even though they are declared in a specific package. The public-hoist-pattern option in .npmrc controls this.
The default public-hoist-pattern values in pnpm include ESLint and Prettier packages. You can extend this or override it. The key is that Claude needs to know what is hoisted so it can reference those packages correctly.
A common .npmrc for a TypeScript monorepo:
# .npmrc (project root)
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=*typescript*
public-hoist-pattern[]=*@types/*
public-hoist-pattern[]=*jest*
public-hoist-pattern[]=*vitest*
# Do not set these:
# shamefully-hoist=true (breaks isolation entirely, defeats pnpm's purpose)
# node-linker=hoisted (switches to npm-style layout, loses pnpm isolation)
Add a section to CLAUDE.md that mirrors this so Claude knows which packages are findable from the root:
## Hoisted packages (accessible from project root)
These packages are hoisted via public-hoist-pattern in .npmrc and can be
referenced in scripts and configs at the root level:
- ESLint and plugins (eslint, @eslint/*, eslint-plugin-*)
- Prettier and plugins
- TypeScript (typescript, ts-node, tsx)
- @types/* packages
- Test runners (jest, vitest and their plugins)
All other packages: must be imported from the package that declares them.
The reason shamefully-hoist=false needs to be in CLAUDE.md is that it is Claude's instinctive fix for "package not found" errors in pnpm. When a package is missing because it is a transitive dep, Claude may suggest adding shamefully-hoist=true to .npmrc. This solves the immediate problem while breaking the isolation model for the entire project. The rule in CLAUDE.md replaces that suggestion with the correct fix: add the missing dep to the declaring package's package.json.
Lockfile discipline and CI
The pnpm lockfile (pnpm-lock.yaml) is more information-dense than npm's package-lock.json. It records the resolved versions, integrity hashes, and dependency relationships for the entire workspace in a single file. Keeping it in sync and committed is mandatory for reproducible installs.
The CI discipline that matters:
# .github/workflows/ci.yml (relevant snippet)
- name: Install dependencies
run: pnpm install --frozen-lockfile
# --frozen-lockfile does two things:
# 1. Fails the build if pnpm-lock.yaml would be updated (deps changed, lockfile not committed)
# 2. Installs exactly the versions in the lockfile, ignoring any range flexibility in package.json
Three failure patterns appear frequently when Claude is involved in a pnpm CI setup.
Pattern 1: Claude adds a dep locally but does not update the lockfile commit. The fix is in CLAUDE.md: after every pnpm add, Claude must stage both package.json and pnpm-lock.yaml together. A package.json change without the lockfile change will fail --frozen-lockfile on the next CI run.
Pattern 2: Claude suggests --no-frozen-lockfile to fix a CI failure. This is the wrong fix for almost every CI lockfile failure. The right fix is to run pnpm install locally (which updates the lockfile), then commit the updated lockfile. Add to CLAUDE.md: Do not use --no-frozen-lockfile. If CI fails with lockfile mismatch, run pnpm install locally and commit the updated pnpm-lock.yaml.
Pattern 3: pnpm install fails with peer dep conflicts. Claude's npm-trained reflex is --legacy-peer-deps. pnpm's equivalent is --no-strict-peer-dependencies or configuring auto-install-peers=true in .npmrc. Add the correct setting to CLAUDE.md:
## Peer dependency handling
- pnpm uses strict peer dep checking by default
- Do NOT suggest --legacy-peer-deps (that is an npm flag)
- For peer dep conflicts in development: add auto-install-peers=true to .npmrc
- For peer dep conflicts in CI: investigate the conflict, do not suppress with flags
- pnpm why <pkg> shows why a package is installed and what requires it
For teams using Claude Code with Nx, the --frozen-lockfile rule interacts with Nx's affected commands. Nx determines which packages changed based on git diff; pnpm's --frozen-lockfile ensures the install is deterministic for those packages. Both tools can run in the same CI job without conflict, but the install step must come first.
Dependency overrides for transitive vulnerabilities
pnpm's override system lets you pin a transitive dependency to a specific version without changing the declaring package's package.json. This is the correct tool when a dependency of a dependency ships a security vulnerability and you cannot wait for the declaring package to update.
The syntax goes in the root package.json under pnpm.overrides (not resolutions, which is a Yarn field):
{
"pnpm": {
"overrides": {
"undici": "^6.21.1",
"semver": "^7.5.4",
"tough-cookie": "^4.1.3"
}
}
}
This forces every package in the workspace that depends on undici, semver, or tough-cookie to use the pinned version. After adding overrides, run pnpm install to regenerate the lockfile, then commit both files together.
Add the pattern to CLAUDE.md:
## Dependency overrides
Location: root package.json → pnpm.overrides (NOT resolutions)
Use case: pin a transitive dep to fix a security vulnerability
Scope: applies to all packages in the workspace
After adding an override:
1. Run pnpm install (updates pnpm-lock.yaml)
2. Run pnpm why <pkg> to verify the override took effect
3. Commit package.json and pnpm-lock.yaml together
Do NOT use resolutions (that is a Yarn field, pnpm ignores it).
For more granular overrides that target a specific package's dep tree (rather than the entire workspace), pnpm v9 introduced per-package overrides. The syntax is "declaring-package>transitive-dep" as the key. Claude is less reliable with this syntax without an explicit example in CLAUDE.md:
{
"pnpm": {
"overrides": {
"some-lib>tough-cookie": "^4.1.3"
}
}
}
Claude will use the flat override syntax by default. The targeted syntax is worth adding to CLAUDE.md if you use it, because the ambiguity between the two forms causes Claude to generate the wrong one.
A related tool is .pnpmfile.mjs, which lets you modify package manifests programmatically during install. Claude knows about this file but rarely generates it correctly without an example. If your project uses it, include the file path and purpose in CLAUDE.md.
Recursive scripts and --filter orchestration
pnpm's workspace script orchestration is where it diverges most from npm workspaces. The --filter flag and the -r (recursive) flag replace npm's workspace syntax and offer more precise targeting.
The commands Claude needs to know for any monorepo task:
# Run a script in all packages that have it
pnpm -r build
# Run in all packages with dependency order (builds deps before dependents)
pnpm -r --topological build
# Run in a specific package
pnpm --filter web build
pnpm --filter @acme/ui build
# Run in all packages that have changed relative to main
pnpm --filter [...origin/main] test
# Run in a package and all packages that depend on it
pnpm --filter web... build
# Run in a package and all its dependencies
pnpm --filter ...web build
# Run in parallel (ignores topological order, use for independent tasks)
pnpm -r --parallel lint
Add this table to CLAUDE.md so Claude selects the right filter syntax for each task:
## pnpm script orchestration
pnpm -r <script> Run in all packages (respects dep order)
pnpm -r --parallel <script> Run in all packages (parallel, no ordering)
pnpm --filter <name> <script> Run in one package by name
pnpm --filter <name>... <script> Run in package and all its dependents
pnpm --filter ...<name> <script> Run in package and all its dependencies
pnpm --filter [...origin/main] <script> Run only in changed packages
Prefer: pnpm -r build for build tasks (order matters)
Prefer: pnpm -r --parallel lint for lint/test (order does not matter)
Use pnpm --filter to isolate a specific package task
For projects using Turborepo for task orchestration, the relationship is complementary: Turborepo defines the task graph and caching, pnpm handles the actual script execution. Claude sometimes conflates the two, generating turbo run build when pnpm -r build is what was asked for. A line in CLAUDE.md resolves this: Turborepo handles task caching and graph. pnpm --filter and pnpm -r handle direct package execution. Do not substitute one for the other.
For Claude Code with ESLint in a monorepo, the pnpm -r --parallel lint pattern is the right way to run ESLint across all packages simultaneously. The --parallel flag is safe here because lint tasks do not depend on each other.
Common failure modes
Claude installs with npm instead of pnpm
This happens when Claude runs a command in a new package directory where there is no pnpm-lock.yaml yet and it defaults to npm install. The hook-based fix from the pnpm community wraps Claude's install commands:
// .claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -qE '^npm (install|i |ci )'; then echo 'Use pnpm instead of npm in this project.'; exit 1; fi"
}
]
}
]
}
}
This blocks Claude's Bash tool from running npm install or npm i at all. The error message redirects Claude to use pnpm. Combined with the CLAUDE.md rule, this creates two layers of enforcement: advisory (CLAUDE.md) and deterministic (hook).
Claude writes a phantom dep import
The signature is an import that works on your machine and fails in CI or on another developer's setup. The symptom is Module not found for a package that exists in node_modules/.pnpm/ but not at the root level.
Diagnosis:
# Check if the package is in your own package.json
cat package.json | grep "some-missing-pkg"
# If not, find which package declares it
pnpm why some-missing-pkg
# Fix: add it to the correct package.json
pnpm add some-missing-pkg --filter <your-package>
Add pnpm why <pkg> to CLAUDE.md as the diagnostic command. Claude will use it when investigating a missing module if it knows the command exists.
Workspace dep resolves from npm instead of local
This happens when a cross-package dependency in package.json uses a version number instead of the workspace protocol:
// Wrong: installs from npm registry
"dependencies": {
"@acme/ui": "^1.0.0"
}
// Correct: installs from local workspace
"dependencies": {
"@acme/ui": "workspace:*"
}
Claude generates the version number form because it looks like any other dependency. The rule in CLAUDE.md prevents this: Cross-package workspace dependencies must use workspace:* protocol, not a version number.
pnpm-lock.yaml conflicts after a branch merge
Lockfile conflicts are more common in monorepos where multiple developers add dependencies on different branches. pnpm's lockfile format is YAML and merge conflicts in it are not safely hand-resolvable.
The correct resolution procedure (add to CLAUDE.md):
## Resolving lockfile merge conflicts
- Do NOT attempt to manually resolve pnpm-lock.yaml conflicts
- Accept either version of the file (choose one branch's lockfile)
- Run pnpm install to regenerate the lockfile from all package.json files
- Commit the regenerated pnpm-lock.yaml
Claude's instinct is to try to merge the conflict markers manually, which produces a corrupt lockfile. The procedure above is reliable and should be in CLAUDE.md as a named procedure Claude can follow.
Putting it together: what a pnpm-aware Claude session looks like
With the CLAUDE.md template in place, a Claude Code session on a pnpm monorepo should follow this pattern for any dependency-touching task:
- Before writing an import, check the relevant
package.jsonfor the dep. - If the dep is missing, run
pnpm add <pkg> --filter <package>before writing the import. - Use
workspace:*for any cross-package dep. - Run
pnpm --filter <package> <script>to test the change in isolation. - Run
pnpm -r buildto verify no dependents are broken. - Stage both
package.jsonandpnpm-lock.yamltogether before committing.
This workflow maps directly to how a careful developer works in a pnpm monorepo. The CLAUDE.md section does not make Claude slower, it makes Claude correct. The time saved from not debugging phantom dep errors or corrupt lockfiles after a Claude session is the return on the five minutes it takes to add the template.
The Claude Code hooks guide covers the PreToolUse hook pattern in more detail, including how to write hooks that check conditions before Claude runs any Bash command. The npm-block hook in this guide is one application of a general enforcement pattern that you can extend to other pnpm-specific constraints.
For teams adopting Claude Code across a larger engineering org, Claude Code best practices covers the CLAUDE.md maintenance process, how to keep it in sync as the project evolves, and how to share configuration across teams working in the same monorepo.
The underlying principle is consistent across every tool integration: Claude performs at the level of context you give it. A pnpm project without pnpm rules in CLAUDE.md produces npm-patterned output that fails pnpm's isolation checks. A project with the template above produces output that respects the virtual store, maintains the lockfile, and uses the right commands from the first generation.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify