You Have Too Many Tools Managing Your Tools
You’ve got asdf for runtime versions. direnv for environment variables. A Makefile that hasn’t been touched since 2019 and breaks on macOS because of some GNU vs BSD flag nonsense. Maybe a .env file that gets sourced differently depending on who’s asking. And a scripts/ folder with shell scripts that only Dave knows how to run.
This is the status quo for most dev setups, and it’s genuinely exhausting.
Mise (pronounced “meez”, like mise en place) is a single tool that replaces all of that. It manages your language runtimes, your environment variables per-project, and your task runner — all from one config file. It’s a drop-in asdf replacement with a saner config format and a task system that doesn’t make you feel like you’re writing an ancient Makefile from memory.
As of mid-2026, mise is at v2025.x and has stabilized enough that you should absolutely be using it instead of whatever patchwork you have now.
Here’s how the three major features actually work, and how they fit together.
Part 1: Tool Version Management
This is what most people use mise for first, because it’s the obvious starting point. You want Node 22 in one project, Node 20 in another, Python 3.12 here, Go 1.22 there. Mise handles this via a .mise.toml file in your project root.
[tools]node = "22"python = "3.12"go = "1.22"That’s it. Run mise install once and mise downloads and activates those versions. Switch directories and it automatically switches versions. No extra shell hooks to think about, no asdf reshim incantations.
$ mise install
$ node --versionv22.14.0Mise uses shims by default but also supports a PATH-manipulation mode if you hate shims (you can set MISE_USE_SHIMS=false). The shim approach is generally fine and makes tool activation instant without requiring a shell restart.
Pinning Exact Versions
Fuzzy versions like "22" track the latest 22.x patch. That’s fine for most teams. For production parity where you care deeply about patch-level reproducibility, pin exact versions:
[tools]node = "22.14.0"python = "3.12.9"Run mise ls to see what’s installed and what’s active:
$ mise lsTool Version Source Requestednode 22.14.0 .mise.toml 22.14.0python 3.12.9 .mise.toml 3.12.9go 1.22.5 .mise.toml 1.22Global Defaults
You can also set global tool defaults so you always have a working version everywhere, even outside a project directory:
$ mise use --global node@22This writes to ~/.config/mise/config.toml. Project-level .mise.toml always overrides it. Think of global as your “baseline so you’re never completely broken” and project-level as your “this is what everyone on this team needs to use.”
Non-Standard Tools
Mise isn’t just for language runtimes. It has a plugin ecosystem (compatible with asdf plugins) and first-party support for a wide range of tools:
[tools]node = "22"terraform = "1.8"kubectl = "1.30"awscli = "2"$ mise install$ terraform --versionTerraform v1.8.5This alone is worth the switch from asdf. Having your infra tooling versioned alongside your app tooling in the same file, committed to the repo, means “works on my machine” starts being a solvable problem instead of a vibe.
Part 2: Environment Variables
Here’s where it gets interesting and where a lot of people stop short with mise. Most people know about tool management. Fewer realize mise also replaces direnv for env var management.
Inside your .mise.toml, you can declare env vars at the project level:
[tools]node = "22"
[env]APP_ENV = "development"DATABASE_URL = "postgres://localhost:5432/myapp_dev"LOG_LEVEL = "debug"When you cd into the directory, mise activates those vars. When you leave, they’re gone. No source .env gymnastics. No “wait, is this var set from the global shell or the project?” confusion.
$ cd ~/projects/myappmise: loaded .mise.toml$ echo $APP_ENVdevelopment$ cd ~$ echo $APP_ENV
$Secrets and .env Files
Checking secrets into .mise.toml is obviously not the move. Mise has a clean answer for this: load a separate .env file.
[env]_.file = ".env.local"APP_ENV = "development".env.local gets read and merged in. You add .env.local to your .gitignore and commit .mise.toml with the safe vars. Your team clones the repo, copies a .env.local.example, and they’re set up without needing a Confluence page explaining which env vars go where.
DATABASE_PASSWORD=supersecretSTRIPE_SECRET_KEY=sk_test_....env.localPath Manipulation
One underused feature: mise can prepend to PATH from within the env config block.
[env]_.path = ["./bin", "./node_modules/.bin"]APP_ENV = "development"Now ./bin scripts and locally-installed Node binaries are on your path automatically when you’re in the project. This is what you were hacking around with npm run wrappers and shell aliases. Just… add it to the config.
Part 3: Tasks
This is the feature people sleep on most, and it’s genuinely good. Mise has a built-in task runner that replaces Makefiles for most use cases and works better on every OS than make does.
[tasks.build]description = "Build the app"run = "npm run build"
[tasks.test]description = "Run tests"run = "npm test"
[tasks.dev]description = "Start dev server with env"run = "node server.js"Run with mise run or the shorthand mise r:
$ mise run devmise: running task devnode server.jsTasks get tab-completion in your shell if you’ve got mise shell integration set up, which you should.
Multi-Step Tasks and Dependencies
Real tasks often need to run things in sequence. Mise handles this:
[tasks.setup]description = "Full project setup"depends = ["install-deps", "migrate-db"]
[tasks."install-deps"]run = "npm install"
[tasks."migrate-db"]run = "npm run db:migrate"mise run setup will run install-deps and migrate-db first, in parallel if there are no explicit ordering constraints between them. You can also use depends_post for teardown steps.
Multi-Line Commands
For anything more complex, you can write a full shell script inline:
[tasks.ci]description = "Run full CI pipeline locally"run = """set -enpm installnpm run lintnpm testnpm run buildecho "CI passed locally""""This is the thing Makefiles are awful at: readable multi-line scripts with real error handling. set -e means the whole thing stops on first failure. Try doing that cleanly in a Makefile without it turning into a research project.
File-Based Tasks
If your tasks get long enough that inline TOML feels unwieldy, you can put them in a mise-tasks/ directory as standalone scripts. Mise discovers them automatically:
mise-tasks/ build.sh test.sh deploy.sh#!/usr/bin/env bashset -e
echo "Deploying to $APP_ENV..."rsync -avz dist/ user@server:/var/www/app/echo "Done."Make it executable and it shows up in mise tasks:
$ chmod +x mise-tasks/deploy.sh
$ mise tasksName Descriptionbuild Build the appdeploy (from mise-tasks/deploy.sh)test Run testsThis is the sweet spot for most teams: simple tasks live inline in .mise.toml, complex tasks live as their own scripts that you can actually lint, test, and review properly.
Putting All Three Together
Here’s what a real-world .mise.toml looks like when you’re using all three features:
[tools]node = "22.14.0"python = "3.12.9"
[env]_.file = ".env.local"_.path = ["./bin", "./node_modules/.bin"]APP_ENV = "development"DATABASE_URL = "postgres://localhost:5432/myapp_dev"LOG_LEVEL = "debug"NODE_ENV = "development"
[tasks.install]description = "Install all dependencies"run = "npm install && pip install -r requirements.txt"
[tasks.dev]description = "Start development servers"run = "npm run dev"
[tasks.test]description = "Run full test suite"run = """set -enpm testpython -m pytest tests/"""
[tasks.lint]description = "Lint all source files"run = "npm run lint && ruff check ."
[tasks.build]description = "Production build"run = "npm run build"
[tasks.ci]description = "Run CI pipeline locally"depends = ["install", "lint", "test", "build"]A new dev clones the repo, runs mise install to get the right tool versions, copies .env.local.example to .env.local, and they’re done. No README full of “first you need to install nvm, then pyenv, then…” instructions. No “oh you need to source that script first” slack messages at 9am.
The Edge Cases Worth Knowing
Shell integration matters. Mise needs a hook in your shell for the directory-change activation to work. Add this to your .zshrc or .bashrc:
$ eval "$(mise activate zsh)" # for zsh$ eval "$(mise activate bash)" # for bashWithout it, you’d need to run mise shell explicitly in each new shell. Do it once, forget about it.
Trust prompts. The first time you enter a directory with a .mise.toml, mise asks if you trust it. This is a security feature, not a bug. Run mise trust to approve a directory, or mise trust --all if you want to stop seeing prompts globally (not recommended for shared machines).
Existing asdf users. Mise reads your existing .tool-versions files. Migration is genuinely zero-effort. Point mise at the same directory, it picks up your existing config. You can switch back and forth while you’re evaluating.
CI/CD. Mise works fine in GitHub Actions and GitLab CI. Use mise install --non-interactive in pipelines to skip the trust prompt. Some teams commit a .mise.toml with CI-specific tool pins and let the runner install fresh each time. Others pre-bake a Docker image with mise + tools installed and just run tasks. Either works.
Should You Bother?
If you’re using asdf, honestly yes. The config format is nicer, the tool is faster, the task runner is legitimately good, and the env var handling means you can retire direnv. That’s three tools consolidated into one.
If you’re not using any runtime version manager yet, mise is the obvious starting point in 2026. It’s the kind of tool you install, configure once, and then it just works until you remember it exists three months later when a coworker asks why your node --version is different from theirs.
If you’re on a team still using raw Makefiles, introduce this with the task runner angle first. That’s the easiest sell. Once people are using mise run test and getting proper error messages, the tool management part is a natural follow-on.
The setup is twenty minutes. The benefit is not having to explain your local dev environment ever again.