mise: My Go-To Tool Manager and Task Runner
For years my machine has had a graveyard of version managers: rbenv for Ruby, nvm for Node, pyenv for Python, etc. Each one had its own shell hook, its own config format, and its own way of getting confused when I would cd into a project.
Then somewhere along the way I picked up asdf, which at least unified the interface. But it was slow, and the plugin quality was hit-or-miss.
I’ve since replaced all of them with mise, and it’s one of those rare tools where the more I use it, the more I find it can do. This post is a tour of why I think every dev should have it installed.
A lot of the examples here will come from my latest project, Setlist HQ.
What is Setlist HQ?
Setlist HQ lets bands organize their song library, track gigs, and build set lists. It's a side project I've been working on — the backend is in Swift with Vapor, the frontend in Vite/React. I use mise heavily on this project.One Config, Every Tool
Here’s the top of mise.toml from Setlist HQ:
[tools]
node = "20"
overmind = "latest"
stripe-cli = "latest"
xcbeautify = "latest"
That single block pins the Node version, and three CLI tools that aren’t language runtimes. When I cd into the project, mise activates all of them. When a teammate (or likely just me on a different machine) clones the repo, mise install sets everything up.
Backends: It’s Not Just Languages
This is the part I think people miss about mise. It’s not a language version manager that also happens to install other tools, it is a general tool installer with a pluggable backend system. When you declare xcbeautify = "latest", mise asks its registry where to get it from. You can see the options:
$ mise registry | grep xcbeautify
xcbeautify aqua:cpisciotta/xcbeautify ubi:cpisciotta/xcbeautify asdf:mise-plugins/asdf-xcbeautify
Three backends for one tool. aqua pulls a signed release from the aqua registry. ubi grabs the binary directly from GitHub releases. asdf uses the legacy asdf plugin. mise picks a sensible default, but you can force a specific backend with a prefix.
Here are a few of the backends I regularly use:
ubi:— install any binary from a GitHub release.ubi:cli/cligives you the GitHub CLI,ubi:charmbracelet/glowgives you Glow. No plugin required, it just works from the release assets.aqua:— the aqua registry, which has curated install recipes with checksum verification for hundreds of tools.spm:— Swift Package Manager.spm:danger/swiftinstallsdanger-swift. Any Swift package that builds an executable product works here, which is huge for the Swift side of Setlist HQ.cargo:,go:,npm:,pipx:— install from the respective language registries.cargo:ripgrep,go:github.com/charmbracelet/glow, etc.
So if you want ripgrep pinned per-project, you can just say:
[tools]
"cargo:ripgrep" = "14.1.0"
It’s Also a Task Runner
This is the feature that made me uninstall just, which was my previous Make replacement.
mise.toml supports a [tasks.*] section. Here’s a simple one from Setlist HQ:
[tasks.db-migrate]
description = "Run database migrations"
run = "cd backend && swift run App migrate --yes"
[tasks.db-revert]
description = "Revert last migration"
run = "cd backend && swift run App migrate --revert --yes"
Running mise run db-migrate (or mise r db-migrate) executes it. mise tasks lists every task with its description. Tab-completion works in fish/zsh/bash.
The killer feature is dependencies. My dev task brings up the database first, then starts the app:
[tasks.dev]
description = "Run both backend and frontend using overmind"
depends = ["db-up"]
run = """
echo "Starting services with overmind..."
overmind start -f Procfile.dev
"""
Running mise run dev runs db-up first, then the main command. If db-up fails, dev doesn’t run. My test task uses dependencies with no run of its own — it’s just a fan-out:
[tasks.test]
description = "Run all tests (backend + frontend)"
depends = ["test-backend", "test-frontend"]
mise run test runs both children in parallel. That’s a Makefile-style dependency graph without any of the tab-vs-space purgatory.
Tasks as Files
The TOML inline form is fine for one-liners, but longer tasks belong in their own file. Drop an executable script in mise-tasks/ and mise picks it up automatically. Here’s mise-tasks/test-backend from Setlist HQ:
#!/usr/bin/env bash
#MISE description="Run backend tests"
#MISE env._.file=".env.test"
set -e
cleanup() {
docker-compose -p setlisthq-test -f docker-compose.test.yml down 2>/dev/null || true
}
trap cleanup EXIT INT TERM
docker-compose -p setlisthq-test -f docker-compose.test.yml up -d
echo "Waiting for postgres to be ready..."
for i in {1..30}; do
if docker-compose -p setlisthq-test -f docker-compose.test.yml exec -T postgres-test pg_isready -U bandmate -d bandmate_test > /dev/null 2>&1; then
echo "[postgres is ready]"
break
fi
sleep 1
done
cd backend && time swift test --no-parallel | xcbeautify
The #MISE comments configure the task’s description, environment file, whether to hide it from the task list, etc. The filename then becomes the task name. This gives you real shell scripts with real tooling (shellcheck, your editor’s syntax highlighting) instead of quote-escaping hell inside a TOML triple-string.
You can also prefix a task file with _ (like mise-tasks/_test-env-start) to mark it as internal. That will make it hidden in mise tasks but other tasks can still call it.
Environment Variables, Too
mise also manages environment variables per project. The top of Setlist HQ’s mise.toml:
[env]
_.file = { path = ".env", redact = false }
LOG_LEVEL = "debug"
When I cd into the project, .env is loaded and LOG_LEVEL is set. When I leave, they’re unloaded.
Arguments and Usage
Tasks can declare their arguments with the usage spec, which gives you validation and help text for free:
[tasks.block-ip]
usage = '''
arg "ip" help="The ip address to block"
'''
run = 'ssh root@$SERVER_IP "sudo ufw deny from $usage_ip"'
mise run block-ip 1.2.3.4 passes the argument through as $usage_ip. mise run block-ip --help prints the auto-generated help.
(Side note, yes even for a new project I regularly have to block bad actors. The internet is a dangerous place!)
Unexpected Benefits
I previously had a whole lot of random scripts in each of my projects, and a lot of them would have to detect if a a tool was installed, and if not, prompt to install it with Homebrew. Now I can have these migrated to mise-tasks as a file, and include their tool dependencies to a special comment at the top, and these tools will get automatically installed when the script is run.
Here’s a script I had which uses gum to provide nice selection UI for terminal scripts:
#!/usr/bin/env bash
#MISE description="<description of the script...>"
#MISE tools={"gum"="latest"}
# we can now assume `gum` is available!
...
The syntax is a little strange, but it’s so useful. Also, when your tasks have descriptions like this, you can type mise r <enter> and it will bring up a list of your configured tasks (whether they be in the toml file or a separate file in mise-tasks) and you can type to filter, then press enter to run the task. Super discoverable.
After using mise for quite a while now, I’m a fan.