Why I Replaced Homebrew with Mise
When I started looking into modern terminal tools, a lot of them didn’t have apt packages, but almost all of them had Homebrew packages. So it was natural that my first foray into modern TUI tooling would be centered around Homebrew. I liked the setup I landed on: apt for system tools, Homebrew for TUI. It let me use cutting-edge tools on a stable base, and since Homebrew installs into /home/linuxbrew/, it’s easy to undo. Linux support does lag behind macOS - you don’t need to know the history of the project to see that it was developed for macOS first -but overall it was working for me.
The problem came when I started building CUTEkit, my bootstrap script for setting up this environment. I didn’t see a clear, predictable path for Homebrew in a script—especially around PATH setup. And as a longtime Linux user, installing everything into /home/linuxbrew/ had always felt more like a workaround than a foundation.
So as the script progressed, I started accumulating alternatives. First apt-repo tools and curl installers, then GitHub release binaries and git clones. Each method had its own install logic and update logic. What had started as a simple script was turning into something that required more maintenance than it was worth.
For example, let’s look at the GitHub CLI and fzf, neither of which have packages in the default Ubuntu 24.04 apt repository. This is what the config.yml entry looked like with my first iteration of the script:
# ── APT-REPO TOOLS ────────────────────────────────────────────────────────
# Packages that need a third-party apt repo added first.
apt_repos:
- name: gh
package: gh
action_required: "gh auth login && gh auth setup-git"
key_url: https://cli.github.com/packages/githubcli-archive-keyring.gpg
key_path: /etc/apt/keyrings/githubcli-archive-keyring.gpg
source: "deb [arch={arch} signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main"
source_file: /etc/apt/sources.list.d/github-cli.list
# ── GITHUB RELEASE BINARIES ───────────────────────────────────────────────
# Fetched from latest GitHub release, extracted, dropped in ~/.local/bin.
github_releases:
- name: fzf
repo: junegunn/fzf
url: "https://github.com/{repo}/releases/download/v{version}/fzf-{version}-linux_amd64.tar.gz"
binary: fzf
version_cmd: "fzf --version | awk '{print $1}'"
zshrc_block:
marker: "# --- workbench: fzf ---"
content: 'source <(fzf --zsh)'
Not exactly user-friendly. I’m practically writing the shell script out in the YAML file. So I started looking for alternatives. At some point during that research, I found mise-en-place, a powerful tool for managing packages, languages and environment variables. After some refactoring, those same two entries look like this:
# ── TUI tools (via mise) ────────────────────────────────────────────────
- name: gh
version: latest
reference:
- 'Authenticate after install: gh auth login'
- 'Wire up git credentials: gh auth setup-git'
- name: fzf
version: latest
shell_setup:
- 'source <(fzf --zsh)'
Mise handles installation from apt repos, aqua, npm, cargo, pipx, and GitHub releases—a single interface for most of what I’d been doing with four or five separate methods. It pins versions and supports a lockfile, so the environment is reproducible. It also manages language runtimes natively, which means it can replace pyenv entirely. If you’ve spent any time wrestling with Python versions and virtual environments, you know why that matters.
The real revelation happened when I discovered the mise task runner. Whenever you are building something, you start out with an idea of how it is going to turn out. As your idea meets reality, it turns out to be more complicated than you thought it would be. You have to make compromises and find workarounds, and hopefully, the thing you eventually make is close to your original intent. That’s just how it goes.
But the mise task runner wasn’t like that. I generated the file and all of my packages were there, functioning the way I expected them to. I think anyone who has done work like this can appreciate how rare that is.
So now a lot of the logic I’d been writing in my generator script could be offloaded to the task runner directly. Mise can act as a kind of universal package manager, but it’s a powerful tool that can do quite a bit more. Getting the most out of it requires changing the way you think about using and installing packages - a topic that deserves its own post.
That doesn’t mean there aren’t any downsides. Mise works by hooking into your shell prompt and rebuilding your path every time you change directories. This process is nearly instantaneous, but there are cases where things don’t work exactly as expected. Cron tasks, for example, need to be prepended with mise x; the same goes for bash scripts that are not running in zsh.
There is some initial friction to navigate, certainly, but it’s the right kind of friction - the kind that leads to a better system. Moving to mise means I’ve stopped fighting the tools and started building a workbench that finally rewards the effort I put into it.
| Feature | Homebrew (Linux) | mise |
|---|---|---|
| Primary Design Target | macOS | Developer environments (Linux-first) |
| Runtime Management | Via separate formulae | Native, built-in |
| Replaces pyenv | No | Yes |
| Lockfile / Reproducibility | External (Brewfile) | Yes |
| PATH Management | Shims / brew shellenv | mise activate (Shell hook) |
| Backends Supported | Homebrew | aqua, github, cargo, go, npm, pipx, gem |