The fastest Homebrew-compatible package manager for everyday macOS work.
malt is a macOS-only package manager written in Zig that consumes Homebrew's existing formula, bottle, cask, and tap ecosystem. It ships as a single binary (malt, ~3 MB) with sub-millisecond cold start. malt downloads pre-built bottles from the Homebrew infrastructure — it is a fast client for Homebrew's package registry, not a fork. Requires macOS 11 (Big Sur) or later on Apple Silicon or Intel.
Warm installs of packages with dependencies — the workload that dominates day-to-day development, CI rebuilds, and dev-environment provisioning — are 5–17× faster than every measured alternative. First-time cold installs are competitive with nanobrew and faster than Homebrew on tree and ffmpeg. See Benchmarks for the full table and methodology.
Note
Experimental project. malt is a human-in-the-loop AI experiment. The design specification, architecture decisions, implementation strategy, and quality assurance were directed by a human. All implementation code was written by AI — Claude Code and ruflo. Every commit, bug fix, and feature was reviewed and validated by the human operator before merging. This is an exploration of what's possible when a human architect drives an AI coder on a non-trivial systems project.
Features · Install · Quick Start · Command Reference · Benchmarks
- Isolated — installs to its own prefix, never touches Homebrew's files
- Deduplicated storage — identical files across versions are stored only once
- Parallel downloads — fetches multiple packages at the same time
- Brew fallback — hands off to Homebrew for anything it doesn't support
- Rollback — revert to a previous version of any package
- Ephemeral run —
malt runlaunches a formula without installing it permanently - Safe under concurrency — multiple malt processes won't corrupt state
curl -fsSL https://raw.githubusercontent.com/indaco/malt/main/scripts/install.sh | bashDownloads the latest release, verifies the SHA256 checksum, installs the binary to /usr/local/bin/, and creates /opt/malt with proper ownership. Falls back to building from source if no release is available.
brew tap indaco/tap
brew install maltClone the repo and run the install script — it detects the local checkout and builds from source automatically:
git clone https://github.com/indaco/malt.git
cd malt
./scripts/install.shRequires Zig 0.15.x.
Note
zig build produces both malt and mt in zig-out/bin/. Both are identical — use whichever you prefer. All install methods (script, Homebrew, source) install both.
Tip:
mtis a built-in alias formalt. Every command works with either name — usemtif you prefer fewer keystrokes.Additional aliases:
removeforuninstall,lsforlist.
# Install a formula (resolves dependencies automatically)
malt install wget
# Install a cask (auto-detected)
malt install --cask firefox
# Install from a tap (inline — no separate tap step)
malt install user/tap/formula
# Install multiple packages (downloads in parallel)
malt install jq wget ripgrep
# List installed packages
malt list --versions
# Uninstall
malt uninstall wgetThe examples below use
mt(the shorter alias). All commands work identically withmalt.
Install formulas, casks, or tap formulas.
mt install <package> # auto-detect formula or cask
mt install <package>@<version> # versioned formula (e.g. openssl@3)
mt install --cask <app> # explicit cask
mt install --formula <name> # explicit formula
mt install <user>/<tap>/<formula> # inline tap (no separate tap step)
mt install <package> [<package> ...] # multiple packages| Flag | Description |
|---|---|
--cask |
Force cask installation |
--formula |
Force formula installation |
--dry-run |
Show what would be installed without installing |
--force |
Overwrite existing installations |
--quiet, -q |
Suppress all output except errors |
--json |
Output result as JSON |
Important
malt does not execute Ruby post_install hooks. Formulae that define one (e.g. node, postgresql@16) are skipped before any download with a message pointing you at brew install <formula> — nothing is written to the store, Cellar, or prefix for that package. Its dependencies in the same invocation are also skipped, since they would otherwise be orphaned until another install pulls them in.
Remove installed packages.
mt uninstall <package>
mt uninstall --cask <app>
mt uninstall <package> --force # ignore dependents check| Flag | Description |
|---|---|
--force, -f |
Remove even if other packages depend on it |
--cask |
Force cask uninstall |
Checks for dependent packages before removing. If dependents exist, refuses unless --force is passed. For casks, checks if the application is running and refuses unless --force is passed. Store entries are preserved for mt gc.
Upgrade installed packages to latest versions.
mt upgrade <package> # upgrade a specific formula or cask
mt upgrade --cask # upgrade all outdated casks
mt upgrade --formula # upgrade all outdated formulas
mt upgrade --dry-run # show what would be upgraded| Flag | Description |
|---|---|
--cask |
Upgrade casks only |
--formula |
Upgrade formulas only |
--dry-run |
Preview without upgrading |
Formula upgrades install the new version, verify it, switch symlinks atomically, and only remove the old version after success. On failure, the old version is restored automatically.
Refresh the local formula/cask metadata cache.
mt updateInvalidates all entries in the API cache. The next install, search, or info command fetches fresh data from the Homebrew API.
List packages with newer versions available.
mt outdated
mt outdated --json
mt outdated --cask
mt outdated --formula| Flag | Description |
|---|---|
--json |
Output as JSON |
--formula |
Show outdated formulas only |
--cask |
Show outdated casks only |
--quiet, -q |
Suppress status messages |
Compares installed versions against the latest from the Homebrew API. Checks both formulas and casks by default.
wget (1.24.5) < 1.25.0
openssl@3 (3.3.2) < 3.4.1
List installed packages.
mt list
mt list --versions
mt list --cask
mt list --formula
mt list --pinned
mt list --jsonShow detailed information about a formula or cask.
mt info <package>
mt info <package> --json
mt info --cask <app>
mt info --formula <name>| Flag | Description |
|---|---|
--formula |
Show formula info only |
--cask |
Show cask info only |
--json |
Output as JSON |
Auto-detects whether the package is a formula or cask. For formulas, shows version, tap, cellar path, and pinned status. For casks, shows version, download URL, app path, and auto-update status.
Search formulas and casks by name.
mt search <query>
mt search <query> --formula
mt search <query> --cask
mt search <query> --jsonRemove old package versions and prune caches.
mt cleanup
mt cleanup --dry-run
mt cleanup --prune=<days> # cache age threshold (default: 30)
mt cleanup -s # scrub entire download cacheGarbage collect unreferenced store entries.
mt gc
mt gc --dry-runScans store/ for entries not referenced by any installed keg. Removes them to reclaim disk space.
System health check.
mt doctor| Check | Pass | Fail |
|---|---|---|
| SQLite integrity | PRAGMA integrity_check returns ok |
Error: database corrupt |
| Directory structure | All required directories exist under prefix | Warn: missing directory |
| Stale lock | No lock file, or lock PID is running | Warn: suggest removal |
| APFS volume | /opt/malt is on APFS |
Warn: clonefile unavailable |
| API reachable | HEAD to formulae.brew.sh returns 2xx |
Warn: offline |
| Orphaned store | All store entries referenced by a keg | Warn: suggest mt gc |
| Missing kegs | All DB keg paths exist on disk | Error: suggest reinstall |
| Broken symlinks | All symlinks in bin/, lib/ etc. resolve | Warn: suggest mt cleanup |
| Disk space | > 1 GB free on prefix volume | Warn: low disk space |
Exits with code 0 (all OK), 1 (warnings found), or 2 (errors found).
Manage taps explicitly. Taps are auto-resolved during install, so this is optional.
mt tap <user>/<repo> # register a tap
mt tap # list registered taps
mt untap <user>/<repo> # remove a tapRemove orphaned dependencies no longer needed by any directly-installed package.
mt autoremove
mt autoremove --dry-runFinds kegs installed as dependencies that are no longer required by any directly-installed package, and removes them.
Import an existing Homebrew installation.
mt migrate
mt migrate --dry-runScans the Homebrew Cellar, resolves each installed package via the API, and installs it through malt. Does not modify the Homebrew installation. Packages requiring post_install hooks are skipped with a report.
Dump the list of directly-installed formulas and casks to a plain-text file for later restoration on the same or another machine.
mt backup # writes malt-backup-<timestamp>.txt to cwd
mt backup --output my-setup.txt # custom path
mt backup -o - # write to stdout
mt backup --versions # pin each entry to its installed version| Flag | Description |
|---|---|
--output, -o |
Destination path (- for stdout) |
--versions |
Append @<version> to each entry for exact pinning |
--quiet, -q |
Suppress status messages |
Only directly-installed formulas are recorded — transitive dependencies are resolved again on restore. The file format is plain text, one entry per line (formula <name> or cask <token>), with # comments. It is safe to hand-edit before restoring.
Reinstall every entry in a backup file produced by mt backup.
mt restore my-setup.txt
mt restore my-setup.txt --dry-run # preview what would be installed
mt restore my-setup.txt --force # pass --force to the underlying installs| Flag | Description |
|---|---|
--dry-run |
Print the list of packages without installing |
--force |
Forward --force to mt install for each package |
--quiet, -q |
Suppress status messages |
Formulas and casks are batched into two mt install invocations, so dependency resolution, parallel downloads, and the atomic install protocol all apply. Lines prefixed with # and blank lines are ignored, and entries with a @<version> suffix are installed at that exact version.
Completely wipe a malt installation from disk — every package, the content-addressable store, linked binaries, the cache, and the SQLite database.
mt purge # interactive, requires typing `purge`
mt purge --dry-run # preview every target with sizes
mt purge --backup ~/malt-snapshot.txt # dump restorable manifest first
mt purge --keep-cache # leave cache/ intact (faster reinstall)
mt purge --remove-binary --yes # also unlink /usr/local/bin/{mt,malt}| Flag | Description |
|---|---|
--backup, -b path |
Write a mt restore-compatible manifest of installed packages before any deletion |
--keep-cache |
Preserve the cache directory (downloaded bottles stay on disk for a later reinstall) |
--remove-binary |
Also unlink /usr/local/bin/mt and /usr/local/bin/malt (opt-in — these live outside the prefix) |
--yes, -y |
Skip the typed confirmation (required for non-interactive / CI use) |
--dry-run |
Preview every target without touching disk |
Interactive by default: prints a warning banner with every target and its size, then requires you to type the literal word purge (not y) to proceed. Refuses to run when stdin is not a TTY unless --yes is passed, so a stray echo y | mt purge cannot trigger a wipe.
Acquires {prefix}/db/malt.lock before deleting so concurrent malt processes cannot race, and releases the lock before removing the db/ directory itself. Honours MALT_PREFIX and MALT_CACHE, so pointing those at a throwaway path is the safe way to test the command end-to-end.
Use mt uninstall <name> for per-package removal, mt cleanup for cache-only cleanup, and mt autoremove / mt gc for orphan removal — mt purge is specifically the nuclear option for uninstalling malt entirely.
Revert a formula to its previous version using the content-addressable store.
mt rollback <package>
mt rollback <package> --dry-runThe store retains all previously installed bottle versions. Rollback unlinks the current version, materializes the previous one from the store, and updates the database. No re-download needed.
Run a package binary without installing it.
mt run <package> -- <args...>
mt run jq -- --version
mt run ripgrep -- --helpDownloads the bottle to a temp directory, extracts the binary, executes it with the provided arguments, and cleans up. If the package is already installed, runs the installed binary directly.
Manage symlinks for installed kegs.
mt link <formula> # create prefix symlinks for a keg
mt link <formula> --overwrite # replace conflicting symlinks
mt unlink <formula> # remove symlinks (keg stays installed)| Flag | Description |
|---|---|
--overwrite, --force, -f |
Replace existing symlinks |
link scans for symlink conflicts before creating links. If conflicts are found, it reports them and aborts unless --overwrite is passed. unlink removes symlinks from bin/, lib/, etc. and the opt/ symlink, but leaves the keg installed in the Cellar.
Show the current version or self-update the binary.
mt version # show current version
mt version update # download and install latest
mt version update --check # check without installingupdate queries the GitHub releases API, downloads the correct binary for the current platform, and replaces the running binary in-place.
Generate a shell completion script for bash, zsh, or fish. The script is printed to stdout, so it can be eval'd immediately or redirected to a file for permanent install.
# Temporary (current shell only)
eval "$(malt completions bash)"
eval "$(malt completions zsh)" # run AFTER `compinit`
malt completions fish | source
# Permanent
malt completions bash > /usr/local/etc/bash_completion.d/malt
malt completions zsh > "${fpath[1]}/_malt"
malt completions fish > ~/.config/fish/completions/malt.fishCompletes subcommands (for both malt and mt), per-command flags, global flags, and the positional shell name for completions itself. Unknown shell names exit non-zero with an error.
| Flag | Description |
|---|---|
--verbose, -v |
Verbose output (all commands) |
--quiet, -q |
Suppress non-error output (all commands) |
--json |
JSON output (read commands) |
--dry-run |
Preview without executing (mutating commands) |
--help, -h |
Show help |
--version |
Show version |
malt installs to /opt/malt — its own prefix, fully isolated from Homebrew. The shorter path guarantees that Mach-O load command patching always has room to replace the original Homebrew path.
/opt/malt/
├── store/ # Content-addressable bottle storage (immutable, by SHA256)
├── Cellar/ # Installed kegs (APFS cloned from store/)
├── Caskroom/ # Installed cask applications
├── opt/ # Versioned formula symlinks
├── bin/ # Symlinks to keg binaries
├── lib/ # Symlinks to keg libraries
├── include/ # Symlinks to keg headers
├── share/ # Symlinks to keg shared data
├── tmp/ # In-progress downloads and extractions
├── cache/ # Cached API responses (TTL-based)
└── db/ # SQLite database + advisory lock
Bottles are stored by their SHA256 hash. The same bottle is never downloaded or extracted twice. Multiple installed kegs can reference the same store entry. Store entries are immutable — only mt gc removes them.
Kegs in Cellar/ are materialized from store/ via APFS clonefile(), which creates a copy-on-write clone at zero disk cost. On non-APFS volumes, a regular recursive copy is used as fallback.
Each bottle download is a single-pass pipeline:
Network (HTTPS from GHCR CDN)
├──-> SHA256 hasher (streaming — computed as chunks arrive)
└──-> gzip/zstd decompressor
└──-> tar extractor
└──-> filesystem write to tmp/
No intermediate archive file is written to disk. The SHA256 is verified against the Homebrew API manifest immediately after the stream completes. On mismatch, the extracted directory is deleted.
Homebrew bottles contain hardcoded paths like /opt/homebrew/Cellar/... in Mach-O load commands. Since malt uses its own prefix, these paths must be rewritten.
malt parses Mach-O headers using struct-aware parsing (not raw byte scanning), identifies all relevant load commands (LC_ID_DYLIB, LC_LOAD_DYLIB, LC_RPATH, etc.), and replaces paths in-place, padding the remaining space with null bytes. On arm64, every patched binary is ad-hoc codesigned via codesign --force --sign -.
Text files (.pc configs, shell scripts) containing @@HOMEBREW_PREFIX@@ or @@HOMEBREW_CELLAR@@ placeholders are also patched.
Patching is always performed on the Cellar copy, never the store original. If patching fails, the Cellar copy is deleted and the store entry remains pristine for retry.
Every install follows a strict 9-step protocol. Failure at any step triggers cleanup of that step only — no prior state is modified.
- Acquire lock — exclusive advisory lock on
db/malt.lock - Pre-flight — resolve dependencies, check disk space, detect link conflicts
- Download — fetch bottles from GHCR CDN with streaming SHA256 verification
- Extract — decompress and untar to
tmp/ - Commit to store — atomic rename from
tmp/tostore/ - Materialize — APFS clonefile from
store/toCellar/, patch Mach-O, codesign - Link — create symlinks in
bin/,lib/, etc., record in DB - DB commit — insert into kegs, dependencies, links tables in a single transaction
- Release lock — clean up tmp files
- SHA256 verification — streaming hash computed during download, verified before extraction. No unverified data touches the store.
- Pre-flight checks — dependencies resolved, disk space verified, link conflicts detected, and formulae requiring
post_installhooks rejected before any download begins. No bottle is fetched, no file is written under the prefix, and no DB row is created for a skipped formula. - Link conflict detection — all target symlink paths scanned before creating any links. Conflicts abort the operation with a clear report.
- Atomic installs — the 9-step protocol uses
errdeferat every stage. Interrupted installs leave no partial state. - Concurrent access — an advisory file lock with a 30-second timeout prevents concurrent mutations. Read-only commands (
list,info,search) do not acquire the lock. - Upgrade rollback — new version is fully installed and verified before the old version is touched. On failure, old symlinks are restored.
- Store immutability — store entries are never modified after commit. Patching happens on the Cellar clone. Only
malt gcdeletes store entries.
| Variable | Description | Default |
|---|---|---|
MALT_PREFIX |
Override install prefix | /opt/malt |
MALT_CACHE |
Override cache directory | {prefix}/cache |
NO_COLOR |
Disable colored output | unset |
MALT_NO_EMOJI |
Disable emoji in output | unset |
HOMEBREW_GITHUB_API_TOKEN |
GitHub token for higher API rate limits | unset |
For commands not implemented by malt (e.g., mt services, mt bundle), malt checks if brew is installed and silently delegates the command to it.
If brew is not found, malt prints:
malt: 'services' is not a malt command and brew was not found.
Install Homebrew: https://brew.sh
# Requires Zig 0.15.x
zig build # debug build
zig build -Doptimize=ReleaseSafe # release build (~3 MB)
zig build test # run tests
zig build universal # universal binary (arm64 + x86_64 via lipo)Install times on macOS 14 (Apple Silicon), comparing malt against other Homebrew-compatible package managers.
| Tool | Size |
|---|---|
| malt | 3.3M |
| nanobrew | 1.4M |
| zerobrew | 8.6M |
| bru | 1.8M |
| Package | malt | nanobrew | zerobrew | bru | Homebrew |
|---|---|---|---|---|---|
| tree (0 deps) | 0.954s | 0.573s | 2.062s | 0.780s‡ | 4.334s |
| wget (6 deps) | 2.958s | 5.357s | 6.422s | 0.579s‡ | 4.115s |
| ffmpeg (11 deps) | 3.942s | 3.043s | 7.378s | 3.515s‡ | 18.673s |
| Package | malt | nanobrew | zerobrew | bru |
|---|---|---|---|---|
| tree (0 deps) | 0.007s | 0.012s | 0.318s | 0.052s |
| wget (6 deps) | 0.077s | 0.630s | 0.947s | 0.080s |
| ffmpeg (11 deps) | 0.079s | 0.898s | 2.715s | 1.132s |
Every package is installed cold exactly once per machine — the first time you type mt install ffmpeg on a fresh checkout. Everything after that — upgrades, reinstalls, dev-environment rebuilds (devbox, nix-style), CI cache restores, post-cleanup reinstalls — is a warm install against the existing store. In a realistic developer workflow the ratio is roughly 1 cold : 10+ warm over a machine's lifetime, so the warm row is where the minutes actually add up.
On that row malt beats every measured alternative by 5–17× on packages with dependencies, which is the common case (wget has 6 deps, ffmpeg has 11 — most useful packages do). Warm tree (0 deps) is within 1 ms of nanobrew, effectively tied. Cold installs are competitive — roughly tied with nanobrew on wget, ahead of Homebrew on tree and ffmpeg — but they represent a one-time cost you pay per package, not an ongoing one.
Put plainly: the number that matters after your first day using malt is the warm row, and on that row malt is the fastest tool measured here.
Raw install time is only one axis — a few architectural choices behind these numbers are worth calling out, because they trade ms against correctness or features:
- Binary size (3.2 M). malt embeds SQLite where nanobrew (1.4 M) and bru (1.8 M) use a flat
state.json. The extra ~1.5 M buys ACID state, reverse-dep queries (mt uses openssl@3), linker conflict detection, and atomic rollback on interrupted installs — features that are either missing or hand-rolled in the JSON-backed tools. zerobrew (8.6 M) pays a similar cost for Rust + its own stack. - Ad-hoc codesign on arm64. Every Mach-O binary malt patches (rewriting
/opt/homebrew->MALT_PREFIXin load commands) is re-signed afterwards — roughly 15 ms per package. Skipping this step is faster, but leaves arm64dyldrefusing to load binaries whose ad-hoc signature was invalidated by the patch. malt pays the ms; nanobrew doesn't. - Global install lock + conflict detection.
flockondb/malt.lockprevents two concurrentmt installprocesses from racing on state (~0.5 ms uncontended). Before linking, malt walks the existing symlink tree and refuses to overwrite another keg's files (~2-3 ms). Both checks are absent in the JSON-backed tools. BENCH_TRUE_COLD=1methodology. Each tool's prefix is wiped between the cold and warm runs, socoldreally does mean "no bottle in the store." Seescripts/bench.sh.
Note
bru keeps its bottle download cache under ~/.bru/ and ~/Library/Caches/bru/, outside the wiped /tmp/bru prefix, so its cold numbers reflect warm cache + materialise, not a real network fetch. bru's warm row is still an apples-to-apples comparison.
Note
Benchmarks on Apple Silicon (GitHub Actions macos-14), 2026-04-12. Auto-updated weekly via benchmark workflow.
Contributions are welcome. Please open an issue to discuss before submitting large changes.
MIT
