I'm too lazy with managing self-downloading and self-installing tools. Maybe you're too! So I asked AI to make me one tool to manage them. I ended up with this. I'm too lazy to give it a name so I let AI suggest one, then I thought it's good enough.
A declarative, self-contained GitHub release binary installer and manager written in pure Bash.
Define the apps you want once in apps.conf. The script handles fetching, downloading, extracting, installing, updating, and removing them — with zero package manager dependency.
~/.config/haul/
├── apps.conf declarative app registry
├── vars.conf optional; user-defined @PLACEHOLDER@ values
└── state/ auto-created; one .state file per managed app
├── croc.state
└── spf.state
- Config Generator — Use
haul-addto automatically generateapps.confentries for any GitHub repo - Inline editor —
haul-addlets you edit the generated entry in$EDITORbefore saving - XDG config — config, state, and vars live in
~/.config/haul/by default - Migration prompt — if the XDG config dir is absent but legacy files exist in the current directory, both scripts offer to copy them over automatically
- Declarative config — one INI section per app, no scripting required
- Auto update — compares installed vs. latest GitHub release; skips if already current
- Smart extraction — handles
.tar.gz,.tar.xz,.tar.bz2,.zip, and raw binaries - Subdirectory-aware — locates the binary even when it's nested inside a subdirectory in the archive
- Regex asset matching — uses full POSIX ERE (via
jq/Oniguruma) to select the right release asset - Built-in placeholders —
@PLATFORM@and@ARCH@expand to the current OS and CPU arch - Custom placeholders — define your own
@KEY@tokens invars.conf; no scripting required - Case-insensitive matching — opt-in via
(?i)inline flag in any pattern - State tracking — records install path, version, repo, and timestamp in
state/ - List command — tabular view of all managed apps, their versions, and install paths
- Uninstall command — removes the binary and cleans up state
- No external state — no package database, no system-wide config, no root required to read state
| Tool | Purpose |
|---|---|
bash 4.2+ |
Associative arrays, declare -g |
curl |
GitHub API requests |
jq |
JSON parsing and regex asset selection |
wget |
Asset download |
tar / unzip |
Archive extraction |
sudo |
Optional — only needed when installing to system paths like /usr/local/bin |
# Clone or put haul somewhere on your PATH
git clone https://github.com/you/haul
cd haul
# Add your first app (generates the entry, opens editor if you choose)
haul-add schollz/croc
# Install everything in the config
haul install
# Install a specific app
haul install croc
# List what's managed
haul list
# Remove an app
haul uninstall crochaul reads its configuration from:
| Priority | Path |
|---|---|
| 1 | --config FILE (explicit override) |
| 2 | $XDG_CONFIG_HOME/haul/apps.conf |
| 3 | ~/.config/haul/apps.conf (default) |
State and vars files live in the same directory as apps.conf (either the XDG default or the custom --config directory).
If ~/.config/haul/ does not exist but the current working directory contains apps.conf, state/, or vars.conf (a legacy workspace layout), both haul and haul-add will detect this and ask:
haul: config directory not found: /home/you/.config/haul
But the current directory looks like a haul workspace:
apps.conf found in /home/you/haul
state/ found in /home/you/haul
vars.conf found in /home/you/haul
Migrate these files to /home/you/.config/haul? [Y/n]
Answering Y (or Enter) copies all found files/directories to the XDG location. Answering n skips migration and continues.
haul [--config FILE] <command> [options] [section...]
Run haul or haul help for the full built-in man-page.
| Command | Description |
|---|---|
haul install |
Install or update all apps in the config |
haul install <section> [...] |
Install/update only the named section(s) |
haul update |
Update all currently installed apps (skips uninstalled ones) |
haul update <section> [...] |
Update specific app(s), only if already installed |
haul list |
Print a table of all managed apps (version, path, repo) |
haul uninstall <section> [...] |
Remove the binary and state for each named app |
haul remove <section> [...] |
Alias for uninstall |
haul help / haul --help |
Show the full help page |
haul version / haul --version / haul -v |
Print the version and exit |
haul --config FILE install |
Use a custom config file |
listanduninstallwork even ifapps.confhas been deleted — they only readstate/.
haul-add automates the creation of apps.conf sections. It fetches the latest release from GitHub, detects your current OS/Architecture/libc, and generates a robust regex pattern using @PLATFORM@ and @ARCH@ placeholders.
haul-add [--help] [-o|--out FILE] [-n|--name NAME] <owner/repo>| Option | Description |
|---|---|
-o, --out FILE |
Write entry to FILE instead of ~/.config/haul/apps.conf. Creates the file and its parent dirs if they don't exist. |
-n, --name NAME |
Use NAME as the section name and binary_name instead of deriving from the repo name. |
--help, -h |
Show the built-in help page. |
--version, -v |
Print the version and exit. |
Examples:
# Add croc using auto-detection
haul-add schollz/croc
# Specify a custom binary name
haul-add -n spf yorukot/superfile
# Write to a project-local config
haul-add -o ./apps.conf schollz/crocAfter the config block is generated and displayed, haul-add asks:
What would you like to do?
[a] Append as-is to ~/.config/haul/apps.conf
[e] Edit the block first, then append
[n] Abort
Choice [a/e/n]:
The response is case-insensitive (E, edit, EDIT all trigger the editor).
a/ Enter / anything else — appends the block as-is.e— opens the block in$EDITOR(falls back tovi, thennano). After you save and exit, the edited block is printed and you get a final[Y/n]confirmation before anything is written.n— aborts with no changes.
The config is an INI-style file. Each [section] declares one application.
| Key | Required | Description |
|---|---|---|
repo |
✅ | GitHub repository in owner/name format |
binary_name |
✅ | The binary filename to install and look up in PATH |
asset_pattern_<arch> |
✅* | Regex to select the release asset for a given CPU architecture |
asset_pattern |
✅* | Generic fallback pattern if no arch-specific key matches |
version_flag |
✅ | CLI flag that prints the installed version (e.g. --version, -v) |
* At least one
asset_patternkey is required (either arch-specific or generic).
| Key suffix | uname -m value |
Typical CPU |
|---|---|---|
_x86_64 |
x86_64 |
AMD/Intel 64-bit |
_aarch64 |
aarch64 |
ARM 64-bit (Apple Silicon, Raspberry Pi 4+) |
_armv7l |
armv7l |
ARM 32-bit hard-float |
_armv5tel |
armv5tel |
ARM 32-bit soft-float |
_riscv64 |
riscv64 |
RISC-V 64-bit |
arm64(macOS naming) is automatically normalized toaarch64.
Patterns are POSIX ERE regexes matched against each release asset's filename.
The underlying engine is Oniguruma (via jq), which is a superset of POSIX ERE.
A literal string is a valid regex. No change required for simple cases:
asset_pattern_x86_64=linux-64bitUse ^ to anchor to the start of the filename when a project ships multiple assets
that share a common prefix (e.g. myapp and myapp-extra):
# matches tuios_0.7.0_Linux_x86_64.tar.gz
# does NOT match tuios-web_0.7.0_Linux_x86_64.tar.gz
asset_pattern_x86_64=^tuios_.*Linux_x86_64Placeholders are expanded by bash before the pattern is passed to jq. They use
@KEY@ delimiters, which have no meaning in ERE and cannot interfere with regex syntax.
Built-in (always available):
| Placeholder | Expands to | Example value |
|---|---|---|
@PLATFORM@ |
uname -s output |
Linux, Darwin, FreeBSD |
@ARCH@ |
uname -m output |
x86_64, aarch64 |
# Works on Linux (→ Linux_amd64) and macOS (→ Darwin_amd64) without config changes
asset_pattern_x86_64=(?i)@PLATFORM@_amd64User-defined (via vars.conf):
See Custom Placeholders (vars.conf) below.
Prefix the pattern with the Oniguruma inline flag (?i) to match regardless of case:
asset_pattern_x86_64=(?i)linux-64bit # matches Linux-64bit, linux-64bit, LINUX-64BITFlags can be combined: (?is) enables case-insensitive + single-line mode.
# Accept either naming convention from the same release
asset_pattern_x86_64=(?i)(x86_64|amd64)-linuxCreate a vars.conf file in ~/.config/haul/ to define your own @KEY@ placeholders.
They expand in asset_pattern values in apps.conf, alongside the built-in ones.
vars.conf format (KEY=value, comments with #):
# vars.conf
CHANNEL=stable
VARIANT=musl
MY_ORG=mycompanyUse them in apps.conf:
[mytool]
repo=mycompany/mytool
binary_name=mytool
asset_pattern_x86_64=(?i)@MY_ORG@-@CHANNEL@-@PLATFORM@-x86_64-@VARIANT@
version_flag=--versionKey naming rules:
| Rule | Detail |
|---|---|
Must start with [A-Z] |
Lowercase keys are rejected with a warning |
| Allowed characters | A-Z, 0-9, _ |
| Reserved names | PLATFORM and ARCH cannot be used (built-in) |
The file is optional — if vars.conf is absent, the script is silent and built-in
placeholders still work normally.
[croc]
repo=schollz/croc
binary_name=croc
asset_pattern_x86_64=(?i)linux-64bit
asset_pattern_aarch64=(?i)linux-arm64
asset_pattern_armv7l=(?i)linux-arm
asset_pattern_armv5tel=(?i)linux-armv5
asset_pattern_riscv64=(?i)linux-riscv64
version_flag=--version
[superfile]
repo=yorukot/superfile
binary_name=spf
# Assets: spf_<version>_<linux|darwin>_<amd64|arm64>.tar.gz
asset_pattern_x86_64=(?i)@PLATFORM@_amd64
asset_pattern_aarch64=(?i)@PLATFORM@_arm64
version_flag=-v~/.config/haul/
├── apps.conf declarative app registry (INI format)
├── vars.conf optional; user-defined @PLACEHOLDER@ values
└── state/ created on first install
└── <section>.state one file per managed app (key=value)
haul is structured in three layers:
┌──────────────────────────────────────────────────────┐
│ 1. Bootstrap (top-level, runs at start) │ resolve config path, detect
│ │ SYS_OS / SYS_ARCH, early flags
├──────────────────────────────────────────────────────┤
│ 2. Function library (definitions only) │ cmd_help · offer_migration
│ │ parse_conf · load_vars
│ │ record_install · read_state
│ │ cmd_list · cmd_uninstall
│ │ install_app
├──────────────────────────────────────────────────────┤
│ 3. Main (subcommand dispatch + loop) │ routes to help / version /
│ │ list / uninstall / install
└──────────────────────────────────────────────────────┘
- Resolves
DEFAULT_CONFIG_DIR($XDG_CONFIG_HOME/haulor~/.config/haul) - Parses
--config <path>before any subcommand detection; updatesSTATE_DIRandVARS_FILErelative to that path - Dispatches
help/--help/no-args →cmd_help;version/--version/-v→ version string - Detects
SYS_OS(uname -s) andSYS_ARCH(uname -m) once, globally
Reads the INI config into the global associative array CONF keyed as
"<section>.<key>" (e.g. CONF["croc.repo"]). Also builds SECTIONS — an ordered
list of section names that drives the default install loop.
Three functions form the state layer:
| Function | Role |
|---|---|
record_install |
Atomically writes state/<section>.state via mktemp + mv |
read_state |
Parses a state file into STATE_* global vars using declare -g |
cmd_list |
Iterates state/*.state and prints a formatted table |
cmd_uninstall |
Reads state, removes the binary (sudo if needed), deletes state file |
State file format (state/croc.state):
binary_name=croc
install_path=/usr/local/bin/croc
version=10.0.3
repo=schollz/croc
installed_at=2026-06-30T07:35:00Z
For each application the script runs:
Step 1 — Check existing install
command -v <binary> → resolve current install path
<binary> <version_flag> → parse first semver from output
Step 2 — Fetch latest GitHub release
curl → GitHub API /repos/<owner>/<name>/releases/latest
jq extracts tag_name
Step 3 — Compare versions
Strip leading 'v' from tag; skip if current == latest
Step 4 — Download & extract
jq selects asset URL:
· asset_pattern regex (test()) matches filename
· sidecar filter rejects .sha256 / .sig / .asc etc.
wget downloads; dispatch on file extension:
.tar.gz / .tgz → tar -xzf
.tar.xz → tar -xJf
.tar.bz2 → tar -xjf
.zip → unzip
(anything else) → raw binary
Step 5 — Install binary
If already installed: cp in-place (sudo if path not writable)
Fresh install, priority order:
1. /usr/local/bin (writable?)
2. /usr/local/bin (sudo)
3. ~/.local/bin (fallback, with PATH warning)
Step 6 — Record state
record_install writes state/<section>.state atomically
The main block handles early-exit commands before requiring apps.conf:
haul help / --help / (no args) → cmd_help (no config needed)
haul version / --version / -v → print version (no config needed)
haul list → cmd_list (reads state/, no config needed)
haul uninstall / remove ... → cmd_uninstall (reads state/, no config needed)
haul install [sections...] → validate config, run install loop
haul update [sections...] → install loop, skip uninstalled apps
.assets[]
| select(.name | test($pat)) # user pattern
| select(.name | test("\\.(sha256|sha512|md5|sig|asc|pem|sbom)$") | not) # strip sidecars
| .browser_download_url$patis passed as a--arg(data, not code) — no jq injection possible- Checksum and signature files are excluded automatically even if they match the pattern
| Concern | Mitigation |
|---|---|
| Repo value injection | Validated against ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ before use in URL |
| jq code injection via pattern | Pattern is --arg (data), never interpolated into jq code |
| State file injection | read_state uses an explicit key allow-list via case — only known keys are set |
| Temp file race | mktemp + atomic mv for state writes |
| Arbitrary archive paths | find with -name <binary_name> limits extraction search to the expected binary |
No asset matching pattern found
The script lists all available asset names when a pattern fails to match:
Error: No asset matching pattern 'Linux_amd64' found for 'spf'.
Available assets:
spf_0.2.0_linux_amd64.tar.gz
spf_0.2.0_linux_arm64.tar.gz
...
Adjust your pattern to match the actual filename. Use (?i) if casing is inconsistent.
Could not parse installed version
The version_flag output doesn't contain a MAJOR.MINOR.PATCH semver. The script will
still proceed with a fresh download on every run. Check the flag with:
<binary> <version_flag> 2>&1Binary not in PATH after install
The script fell back to ~/.local/bin. Add it to your shell:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcPattern matching a sidecar file
If your pattern is too broad it might match a .sha256 or .sig file. The sidecar
filter handles this automatically — those extensions are always excluded regardless of
whether the base name matches.
No editor found when using haul-add -e
Set the EDITOR environment variable to your preferred editor:
export EDITOR=nvim # or vim, nano, emacs, etc.Config file not found
If ~/.config/haul/apps.conf does not exist and there are no legacy files in the
current directory, create it manually:
mkdir -p ~/.config/haul
touch ~/.config/haul/apps.conf
haul-add schollz/croc # add your first appMIT