AlexI maintain Slashi, a VS Code extension that, among other things, offers your project's commands in a...
I maintain Slashi, a VS Code extension that, among other things, offers your project's commands in a popup: run a script, install dependencies, add a package. To do that it has to answer a question every editor plugin, CI script and onboarding doc eventually runs into: which package manager does this project use?
I assumed it was a lockfile check. It mostly is. The part that bit me came later, as a bug report: a Yarn 1 project where Slashi suggested yarn dlx, which doesn't exist in Yarn 1.
This post is the detection order I ended up with, the Yarn split that matters, and the cases it still gets wrong.
packageManager first
If package.json has a packageManager field, it's the most explicit statement a project can make. Corepack reads it, and so should your tool.
{
"name": "my-app",
"packageManager": "pnpm@9.12.0"
}
Only the prefix before @ matters for picking the tool. The version matters later, for Yarn.
No field? Look at what's on disk, in the package's own folder first and then at the workspace root (in a monorepo, the lockfile usually lives only at the root):
| File | Package manager |
|---|---|
pnpm-lock.yaml, pnpm-workspace.yaml
|
pnpm |
yarn.lock, .yarnrc.yml
|
Yarn |
bun.lock, bun.lockb
|
Bun |
package-lock.json, npm-shrinkwrap.json
|
npm |
| nothing | npm (the default that ships with Node) |
Here's that logic as a standalone function (Node 18+, no dependencies):
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const LOCKFILES = [
['pnpm', ['pnpm-lock.yaml', 'pnpm-workspace.yaml']],
['yarn', ['yarn.lock', '.yarnrc.yml']],
['bun', ['bun.lock', 'bun.lockb']],
['npm', ['package-lock.json', 'npm-shrinkwrap.json']],
];
export function detectPackageManager(dir, workspaceRoot = dir) {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
const field = typeof pkg.packageManager === 'string' ? pkg.packageManager : '';
for (const [pm] of LOCKFILES) if (field.startsWith(pm)) return pm;
for (const folder of [dir, workspaceRoot]) {
for (const [pm, files] of LOCKFILES) {
if (files.some((f) => existsSync(join(folder, f)))) return pm;
}
}
return 'npm';
}
console.log(detectPackageManager(process.cwd())); // e.g. "pnpm"
The order of that table is a decision, not a fact. A repo with both pnpm-lock.yaml and a stale package-lock.json is common after someone switches tools and forgets to delete the old file. Checking pnpm, Yarn and Bun before npm means the leftover npm lockfile loses, which matched every case I've seen. It's still a guess.
"Yarn" is two tools with one name. Yarn 1 (Classic) is frozen; Yarn 2 and later (Berry, now Yarn 4) is a rewrite with different commands. Getting the generation wrong produces commands that fail outright.
These are the commands Slashi now picks for each, from the detection code (Slashi 0.6.0, October 2026):
| Task | Yarn 1 (Classic) | Yarn 2+ (Berry) |
|---|---|---|
| Install from the lockfile only | yarn install --frozen-lockfile |
yarn install --immutable |
| Update packages | yarn upgrade |
yarn up <packages> |
| List outdated | yarn outdated |
yarn upgrade-interactive |
| Security audit | yarn audit |
yarn npm audit |
| List top-level dependencies | yarn list --depth=0 |
yarn info --name-only |
| Run a package without installing it | npx |
yarn dlx |
The signals I use for "this is Berry":
packageManager says yarn@2 or higher. If it says yarn@1.22.22, it's Classic, end of story..yarnrc.yml exists. Yarn 1 reads .yarnrc (no .yml), Berry reads .yarnrc.yml.export function isYarnBerry(pkg, hasYarnrcYml) {
const major = /^yarn@(\d+)/.exec(pkg.packageManager ?? '')?.[1];
if (major) return Number(major) >= 2;
return hasYarnrcYml;
}
Passing extra arguments to a script works differently too. pnpm run test --watch, yarn test --watch and bun run test --watch all hand --watch to the script. npm needs a separator: npm run test -- --watch. Without the --, npm treats --watch as its own option. If your tool appends arguments, add the -- for npm only.
.yarnrc.yml and no packageManager field. It's possible, and my rules call it Classic. As far as I can tell it's rare, because Berry projects almost always commit .yarnrc.yml, but it isn't impossible.yarn.lock and a package-lock.json for different consumers. The fixed order picks pnpm, then Yarn, then Bun, then npm, which may not be what the maintainers intend.deno.json and package.json needs its own rules. I treat Deno tasks separately and don't try to pick between them.If your tool has to get this right, read packageManager first, use lockfiles second, and treat "Yarn" as two different tools.
I'm the author of Slashi, which uses this detection to show a project's scripts and package-manager commands in VS Code. It's free and MIT-licensed: Marketplace · Open VSX · GitHub.