Your laptop has ripgrep, installed via cargo install. ruff is there too, via uv tool install. golangci-lint came from go install. bash-language-server was npm i -g. Neovim was a tarball download. Kitty was a curl script.
Six months later you get a new machine, or you just want to upgrade or reinstall. What do you even have installed? How did you install each one? Which version? Good luck.
This is a small system that answers those questions — a single Makefile that declares every tool you care about, grouped by purpose, with one command to install anything.
Note: If you just want to use this, head straight to github.com/santhoshtr/hm and start adding your packages. The rest of this post explains how it works.
The Problem
A developer’s machine accumulates tools from five or more package managers. Each has its own syntax:
sudo apt-get install -y ripgrep
cargo install eza
uv tool install ruff
go install golang.org/x/tools/gopls@latest
sudo npm i -g prettier
curl -L https://example.com/tool | sh
You remember these the day you install them. Three months later, you remember nothing. When the next laptop arrives, or you want to upgrade or reinstall some packages, you spend a day re-discovering what you had and how to get it.
The fix is not another tool. It is a text file you already understand. Instead of running the installation commands and scripts in your terminal, you record it for the future.
One Makefile
Create a directory. Inside it, a single Makefile and a few .mk files under dev/:
hm/
├── Makefile
├── dev/
│ ├── cli.mk
│ ├── python.mk
│ ├── node.mk
│ ├── go.mk
│ ├── rust.mk
│ └── lsp.mk
└── desktop/
└── apps.mk
Each .mk file declares packages for one manager using simple += appends:
# dev/cli.mk
APT += ripgrep jq bat fzf htop tmux
CARGO += eza zoxide fd
PKG_fd := fd-find
# dev/python.mk
UV += ruff black isort pyright
# dev/go.mk
GO += gopls golangci-lint
PKG_gopls := golang.org/x/tools/gopls
PKG_golangci-lint := github.com/golangci/golangci-lint/cmd/golangci-lint
That is the entire package declaration system. Five variables (APT, CARGO, UV, GO, NPM), one per manager. Each .mk file appends to whichever variable it needs. The Makefile includes them all.
How the Makefile Works
The central Makefile does three things: include the .mk files, define target generators, and expand the lists into targets.
Include everything:
include dev/cli.mk
include dev/python.mk
# ... etc
Define the install commands:
APT_INSTALL := sudo apt-get install -y
CARGO_INSTALL := cargo install
UV_INSTALL := uv tool install
GO_INSTALL := go install
NPM_INSTALL := sudo npm i -g
Define helper functions to split name@version syntax and resolve PKG_ overrides:
pkg-name = $(firstword $(subst @, ,$(1)))
pkg-version = $(word 2,$(subst @, ,$(1)))
pkg-pkgname = $(or $(PKG_$(call pkg-name,$(1))),$(call pkg-name,$(1)))
Define a generator macro per manager. Here is gen-apt:
define gen-apt
.PHONY: $(call pkg-name,$(1))
$(call pkg-name,$(1)):
@echo "installing/upgrading $$@..."
@$(APT_INSTALL) $(call pkg-pkgname,$(1))
endef
The other four (gen-cargo, gen-uv, gen-go, gen-npm) follow the same pattern.
Expand every list into targets:
$(foreach p,$(APT),$(eval $(call gen-apt,$(p))))
$(foreach p,$(CARGO),$(eval $(call gen-cargo,$(p))))
$(foreach p,$(UV),$(eval $(call gen-uv,$(p))))
$(foreach p,$(GO),$(eval $(call gen-go,$(p))))
$(foreach p,$(NPM),$(eval $(call gen-npm,$(p))))
That is the entire mechanism. No generated files, no YAML, no DSL. make evaluates the foreach, expands each entry, and creates a .PHONY target. make ripgrep runs sudo apt-get install -y ripgrep. make ruff runs uv tool install ruff. Every target appears because it was appended to a list in some .mk file.
Every run calls the package manager without checking whether a tool is already installed — and that is intentional. This system does not duplicate that logic. apt-get skips if present, cargo install upgrades if a newer version exists, uv tool install re-installs. The Makefile is a declaration; the package manager decides what to do.
Three Patterns for Adding a Package
Pattern 1: package manager has it, name matches. Append to the list:
# dev/cli.mk
APT += htop
Done. make htop works immediately.
Pattern 2: pin a version. Use name@version:
UV += [email protected]
The gen-uv macro translates this to uv tool install black==24.2.0. The gen-go macro uses @v1.55.0. The gen-npm and gen-cargo macros handle their own syntax. The version is optional — omit it and you get the latest.
Pattern 3: target name differs from package name. Use PKG_:
CARGO += fd
PKG_fd := fd-find
make fd runs cargo install fd-find. The target name is what you type; the PKG_ value is what the manager sees.
Custom Install Scripts
Some tools are not in any package manager. They come from GitHub release tarballs, curl scripts, or local builds. Write a hand-written target:
.PHONY: uv
uv:
@echo "installing/upgrading $@..."
@curl -LsSf https://astral.sh/uv/install.sh | sh
These targets work exactly like the generated ones. They appear in make tab-completion and in the interactive installer. The difference is you write the install command yourself.
Group Targets
Individual packages are grouped by purpose:
.PHONY: cli
cli: ripgrep jq bat fzf htop tmux eza zoxide fd
.PHONY: python
python: uv ruff black isort pyright
Meta-targets roll them up:
.PHONY: dev
dev: cli python node go rust lsp
.PHONY: all
all: dev desktop
make all installs everything. make cli installs just the CLI tools. make ripgrep installs one package. Three levels of granularity, same system.
The Interactive Installer
The Makefile gives you make <target>. That works. But when you have 50 packages across 10 files, you want to browse and search.
hm.sh is a 30-line shell script that does one thing: discovers every target from the Makefiles and presents them in fzf.
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
packages() {
make -pn -C "$SCRIPT_DIR" 2>/dev/null |
grep -E '^[a-zA-Z][a-zA-Z0-9_-]*:' |
sed 's/:.*//' |
grep -vxF -f <(exclude_patterns) |
sort -u
}
The key line is make -pn. That is Make’s “dry run, print database” mode. It dumps every target, its dependencies, and its recipe — without running anything. The script parses that output, filters out group and meta targets, and feeds the remainder to fzf:
selected=$(packages | fzf \
--multi \
--prompt "install > " \
--preview "make -n -C '$SCRIPT_DIR' {} 2>/dev/null" \
--preview-window "right:50%:wrap")
The preview pane runs make -n <target> (dry run) so you see exactly which command will execute before you commit. Tab selects multiple targets. Enter runs them. The loop continues until you press Esc.
The critical property: nothing is hardcoded in the script. It reads the Makefile database live. Add a package to any .mk file, and it appears in fzf the next time you run hm. No second place to update.
Symlink it into your PATH:
ln -s "$(pwd)/hm.sh" ~/bin/hm
Now hm works from anywhere.
Cleaning Up
.PHONY: clean
clean:
@cargo cache -a 2>/dev/null || true
@sudo apt-get clean && sudo apt-get autoremove -y
@uv cache clean 2>/dev/null || true
@npm cache clean --force 2>/dev/null || true
@go clean -cache 2>/dev/null || true
One command purges every package manager cache. The || true guards ensure it does not abort if a manager is not installed.
Why Not Nix or Ansible?
Nix solves a real problem — reproducible, hermetic environments with atomic rollbacks. But it asks you to learn a functional language, adopt a parallel package universe, and debug inside Nix internals when things break. The learning curve is weeks. For a personal dev machine, that is a lot of machinery for “install ripgrep.”
Ansible is designed for fleet management. It wants an inventory, YAML playbooks, a Python runtime, and SSH connections to remote hosts. A single laptop is not a fleet.
Docker/containers solve isolation, not tool installation. You do not want to run your editor, terminal, and CLI tools inside containers.
This system uses make and bash. Every developer on Linux already knows both. There is no daemon, no hidden state, no lock-in. If something fails, you read a shell command. If you want to add a tool, you add one line. If you want to move to a new machine, you clone the repo and run make all.
The tradeoffs are honest: installs are not hermetic, there is no rollback, and reproducibility depends on upstream staying stable. For a personal development machine, those are acceptable costs in exchange for a system that takes five minutes to understand and zero ongoing maintenance.
Get Started
Clone the repo, read the Makefile, and start adding your own packages:
git clone https://github.com/santhoshtr/hm.git
cd hm
Customize the .mk files to declare your tools. Open dev/cli.mk and append to whichever list you need:
APT += wget
Then use it:
# install one package
make wget
# install one group
make cli
# install everything
make all
# interactive fuzzy installer
./hm.sh

Wrapping Up
This system installs tools. That is all it does. It does not manage their configuration — dotfiles, editor settings, shell profiles. For that, I use GNU Stow with a private git repo. Stow symlinks config files into the right locations. That is a separate concern, and keeping the two systems apart keeps both simple.
What are you using to manage the tools on your system?
Update - 2026-Sept
I used the above system for a few months. Later I tried mise and found that it can replace my system better while providing additional features. So currently I used mise with a handcrafted config file for my needs.