DevOps

I Built a Dependency Manager for CLI Tools


Every project I've worked on has the same problem lurking somewhere. We use CLI tools everywhere — in local dev, in CI, in Dockerfiles, in Makefiles — but nobody actually manages them. The README says "install this, version that," and everyone ends up with something slightly different. CI pulls the latest release until a breaking change blows up the build at 2am.

But version drift is only half the story. Every time a binary is pulled without a hash check, you're trusting that the download hasn't been tampered with. Supply chain attacks on developer tooling are real, increasingly common, and almost never caught until the damage is done.

I'd been wanting to fix this properly for a long time. So I built grip.

The idea

Three pieces, one workflow: a manifest (grip.toml) that declares which tools the project needs and where to get them, a lockfile (grip.lock) that records the exact version, download URL, and SHA-256 of every tool, and one command — grip sync — that installs everything reproducibly from the lockfile. Same idea as Cargo.lock or uv.lock, applied to CLI binaries. Commit both files. Every developer runs grip sync after pulling. That's the entire workflow.

Quickstart

# 1. Set up grip in your project (run once, from the project root)
$ grip init
Created grip.toml
Created .gitignore with .bin/

# 2. Discover tools your project already uses but hasn't declared
$ grip suggest
  jq          referenced in .github/workflows/release.yml  →  grip add jq
  shellcheck  referenced in Makefile                       →  grip add shellcheck

# 3. Add tools — grip downloads and installs them immediately.
#    owner/repo is shorthand for --source github; multiple tools in one shot.
$ grip add BurntSushi/ripgrep jq@1.7.1 shellcheck
  3 installed  (1.4s)

# 4. See what is installed
$ grip tree
  NAME         VERSION   SOURCE   INSTALLED AT
  ────────────────────────────────────────────────
  jq           1.7.1     github   2026-04-28 09:41
  ripgrep      14.1.0    github   2026-04-28 09:41
  shellcheck   0.10.0    github   2026-04-28 09:41

# 5. Commit the manifest and lockfile
$ git add grip.toml grip.lock && git commit -m "add dev tools"

On a teammate's machine — after git pull, one command installs everything:

$ grip sync --locked
  All up to date  (3 skipped, 0.0s)

--locked means the build fails if grip.lock would change — no silent version drift in CI. If your project already has a Dockerfile with RUN apt-get install lines, grip init will detect it, run the imported packages through a curated registry plus the host package manager, and offer to import the verified set into grip.toml for you.

The manifest format

Tools can come from four different sources, all declared in the same file:

# Pin an exact version from GitHub Releases
[binaries.jq]
source  = "github"
repo    = "jqlang/jq"
version = "1.7.1"

# Semver range — grip resolves to the highest matching release
# and locks the concrete version in grip.lock
[binaries.kubectl]
source  = "github"
repo    = "kubernetes/kubectl"
version = "^1.30"

# System package manager (apt or dnf)
[binaries.ripgrep]
source  = "apt"
package = "ripgrep"
binary  = "rg"        # on-PATH command differs from package name

# Direct URL (SHA-256 recorded in grip.lock)
[binaries.protoc]
source = "url"
url    = "https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip"
binary = "bin/protoc"

# System library — no binary, just ensures the package is present
[libraries.libssl-dev]
source  = "apt"
package = "libssl-dev"

You never edit grip.lock by hand — grip maintains it automatically. The GitHub adapter supports semver ranges (^, ~, >=, *), resolves them against the releases API at install time, and pins the result in the lock. Subsequent grip sync --locked runs use that pinned version exactly.

Day-to-day commands

Tools install into a project-local .bin/ directory — nothing touches system directories. To use them, either run:

$ grip run jq '.name' package.json   # one-off, no PATH change
$ eval "$(grip env)"                 # add .bin/ to PATH for this shell session

A few other commands I use regularly:

# Verify .bin/ matches the lockfile (existence + SHA-256), and surface
# consistency issues like orphaned lock entries or unpinned versions.
$ grip sync --check

# See if newer versions are available — preview a re-lock without writing.
$ grip lock --upgrade --dry-run
  BINARY    INSTALLED   LATEST    STATUS
  ──────────────────────────────────────
  jq        1.7.1       1.7.1     up to date
  kubectl   1.30.2      1.31.0    outdated

# Upgrade one tool, or all of them, then sync.
$ grip lock --upgrade-package kubectl && grip sync

# Pin all floating versions into grip.toml from what's installed
$ grip lock pin

# Remove a tool from manifest, lockfile, and .bin/
$ grip remove jq

Security

This was the part I cared most about getting right. grip has three layered supply chain controls:

GPG signature verification — add a gpg_fingerprint to any entry and grip verifies the release asset signature before installing. Two modes are supported: direct binary signature (Mode 1) and signed checksums file (Mode 2, which is how HashiCorp, Go, and jq distribute releases).

Post-install tamper detectiongrip lock verify re-hashes every binary in .bin/ against the recorded SHA-256 without re-downloading anything. It reads the lockfile directly, no manifest needed. Different purpose from grip sync --check: sync --check asks "is my setup complete and consistent?" (and reports orphaned lock entries, missing hashes, and unpinned versions); lock verify asks "was anything tampered with after install?".

Version pin enforcementgrip sync --require-pins fails before touching the network if any entry floats to "latest". Catches configuration drift before it becomes a reproducibility problem.

Docker and CI

For environments where you can't install grip itself — CI images, distroless containers — the export command generates native install instructions directly from grip.lock:

$ grip export --format dockerfile
# Generated by grip export --format dockerfile
RUN curl -fsSL -o /usr/local/bin/jq \
    "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
    && chmod +x /usr/local/bin/jq

Paste it into your Dockerfile. The URL and version come straight from grip.lock, so they stay in sync as you update your tools. Shell script and Makefile formats are also supported.

Recommended CI setup, in order:

grip suggest --check                # fail if any tool is referenced but not declared
grip lock --check                   # fail if grip.lock is stale relative to grip.toml
grip sync --locked --require-pins   # install; fail if lock would change or any version floats
grip lock verify                    # re-hash every .bin/ binary; catch tampering

Finding undeclared tools

grip suggest scans your Makefile, CI YAML, shell scripts, and optionally your source code for tools you already use but haven't declared:

$ grip suggest
  jq          referenced in .github/workflows/release.yml  →  grip add jq
  yq          referenced in scripts/deploy.sh              →  grip add yq
  shellcheck  referenced in Makefile                       →  grip add shellcheck

Add grip suggest --check to CI to fail if any tool is left unmanaged.

Auditing and SBOM

Because grip knows exactly what binaries you're running, auditing them is nearly free. grip audit cross-references every installed tool against the OSV vulnerability database and exits non-zero on findings. grip export emits a Software Bill of Materials directly from grip.lock in CycloneDX 1.5 or SPDX 2.3 format, with no network access required.

$ grip audit
  jq        1.7.1   ✓  no known vulnerabilities
  terraform 1.6.6   ✗  1 vulnerability

$ grip export --format cyclonedx -o sbom.json
$ grip export --format spdx       -o sbom.spdx.json

Where it stands

grip is open source, written in Rust, with a comprehensive integration test suite across all four adapters — each running in its own Docker container (Debian Bookworm for apt/GitHub/URL suites, Fedora 40 for dnf). The core workflow is solid. If you've felt this pain, I'd genuinely love feedback.

github.com/omnone/grip

← Back to Blog Get in touch