Documentation
paq installs and manages CLI tools from GitHub releases, direct URLs and the
npm registry, driven by a simple TOML configuration.
paq is built for one job: installing tools and applications, fast, without touching the rest of your environment. It drops binaries into plain directories you own — no daemon, no runtime, no shell hooks — so it stays out of your way and is never invasive.
It’s meant to run alongside your system’s official package manager, not to replace it. Reach for paq when a tool isn’t in the official repositories yet, when you want a bleeding-edge release ahead of what your distro ships, or when you need several versions of the same tool at once.
It is not a version manager and won’t switch “active” versions for you. What it does make effortless is keeping several versions of the same tool side by side (see Installing multiple versions), each in its own destination, nothing else disturbed.
Install
Download a release
Pre-built binaries are published on the
GitHub releases page for linux/amd64,
linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, and
windows/arm64. Each asset is a zip named
paq-<version>-<os>-<arch>.zip, and every release ships a SHA256SUMS file to
verify the download.
# Download and extract the binary for your platform (linux/amd64 shown)
VERSION=v0.3.0
curl -LO "https://github.com/enr/paq/releases/download/${VERSION}/paq-${VERSION}-linux-amd64.zip"
unzip "paq-${VERSION}-linux-amd64.zip"
install -m 0755 paq ~/.local/bin/paq
On Windows, download paq-<version>-windows-amd64.zip, unzip it, and move
paq.exe somewhere on your PATH.
Once paq is installed, keep it current with paq self-update.
Build from source
git clone https://github.com/enr/paq
cd paq
./.sdlc/build
# Binary: ./bin/paq
Once installed, run paq doctor to check that the install directory is on your
PATH and that the environment is set up correctly. On Windows,
paq doctor --fix adds the bin dir to your user PATH (no administrator
rights needed); restart the terminal afterwards.
Quickstart
For any tool in the registry, that’s all you need — no config file required:
paq install ripgrep # or the short alias: paq i ripgrep
paq downloads the tool, installs it into your default bin directory, and records
a default entry in the manifest for you. Browse what’s available with
paq search <query>. To install without touching the manifest, add --no-save.
Writing a config.toml (below) is only needed when you want to customise
destinations or versions, keep several versions side by side, or add your own
recipes.
Configuration
Editing the config is optional. Create ~/.config/paq/config.toml to customise
how tools are installed:
[apps.rg]
use = "ripgrep"
version = "latest"
dest = "~/bin/rg{{ext}}"
[apps.jdk]
use = "jdk"
version = "21"
dest = "~/opt/jdk-{{version}}"
{{ext}} expands to .exe on Windows, empty on Linux/macOS.
Custom manifest location
By default paq reads/writes ~/.config/paq/config.toml (%APPDATA%\paq\config.toml
on Windows). Point it at a different file with --config <path> or the PAQ_CONFIG
environment variable (the flag wins if both are set) — useful for multiple profiles,
CI, or a manifest kept under version control elsewhere:
paq --config ~/dotfiles/paq-work.toml install
PAQ_CONFIG=~/dotfiles/paq-work.toml paq ls
Only the manifest path moves; the state DB and registry cache still follow
XDG_STATE_HOME/XDG_CACHE_HOME as usual.
Project-local installs (–local)
A project can pin its own tools (a given Node.js, say) and install them inside
its own directory, kept apart from your user-wide tools. Commit a paq.toml at
the project root:
[apps.node]
use = "node"
version = "22.11.0"
dest = ".paq/node"
and run paq with --local, or with PAQ_LOCAL=1 in the environment (any value
but empty, 0 or false):
paq --local install # installs node into <project>/.paq/node
paq --local ls # lists only this project's tools
In local mode:
- The manifest is the nearest
paq.toml, searched from the current directory upwards (like git), so paq works from any subdirectory of the project. With nopaq.tomlanywhere above, the current directory is the project root andpaq --local install <tool>creates the manifest there. paq.lock.tomllives next topaq.toml, as usual: commit both.- The state DB is
<project>/.paq/state.json:ls,doctor,uninstallandupgradesee only the project’s tools, and two projects installing the same tool and version never collide. - The built-in default directories become
.paq/binand.paq/opt, so an entry withoutdest(and the onepaq import/paq install <tool>writes) lands under.paq/. A[defaults]table inpaq.tomlstill overrides them. - A relative
dest(in the manifest or from--dest) is resolved against the project root, not the current directory. - The user manifest and
PAQ_CONFIGare ignored;--configcannot be combined with--local. The registry cache and the download cache stay shared.
Add .paq/ to the project’s .gitignore. paq does not touch PATH: put the
project’s tools on it with your own tooling, e.g. with direnv
in an .envrc that also turns local mode on:
export PAQ_LOCAL=1
PATH_add .paq/node/bin
Default install directories
An optional [defaults] table sets the base directories used to derive an
app’s dest when the entry omits it. bin is used for single/multiple
executables, opt for tools installed as a directory:
[defaults]
bin = "~/.local/bin"
opt = "~/.local/opt"
[apps.rg]
use = "ripgrep" # no dest → installed to <bin>/rg{{ext}}
[apps.jdk]
use = "jdk" # no dest → installed to <opt>/jdk
If [defaults] (or one of its keys) is omitted, paq falls back to the built-in
defaults: ~/.local/bin and ~/.local/opt on Linux/macOS,
%LOCALAPPDATA%\paq\bin and %LOCALAPPDATA%\paq\opt on Windows. The same
defaults drive the dest generated by paq import. Run paq config show to
see the effective values.
Version field
The version field has three modes:
version | Behaviour |
|---|---|
| omitted | installs the recipe’s default_version (a curated, stable release); if the recipe has none, resolves latest |
"latest" | resolves the newest version live (see Resolving latest); fails if the recipe has no strategy |
"x.y.z" | pins that exact version |
Installing multiple versions of the same tool
The [apps.<name>] key is just a logical name; the use field selects the
registry recipe. To keep several versions of the same tool side by side, add
one entry per version under distinct names, all pointing at the same recipe
via use, with a different dest for each:
[apps.node18]
use = "node"
version = "18.20.0"
dest = "~/.local/opt/node-{{version}}"
[apps.node20]
use = "node"
version = "20.11.0"
dest = "~/.local/opt/node-{{version}}"
Each entry is tracked independently (paq ls, install state are keyed by the
app name). The two dest paths must resolve to different locations — otherwise
the second install overwrites the first; templating dest with {{version}}
is the easy way to guarantee this.
For a one-off version that should not go in the manifest (a CI build that
needs a project-specific Terraform, say), --dest gives a single install its
own destination instead:
paq install terraform@1.9.8 --no-save --dest '/opt/paq/terraform-{{version}}/terraform'
paq install terraform@1.5.7 --no-save --dest '/opt/paq/terraform-{{version}}/terraform'
Reproducible installs (paq.lock.toml)
An app that tracks "latest" (version = "latest", or no version on a
recipe without default_version) resolves to whatever release is newest
at install time — two machines running paq install from the same manifest
on different days can end up with different versions. paq closes that gap
with a lockfile, paq.lock.toml, generated next to the manifest:
- The first time a “latest”-tracking app is installed, paq resolves it live
and records the result (version, release tag and this platform’s sha256)
in
paq.lock.toml. The tag keeps what the version alone loses, such as the Temurin build number injdk-21.0.12+8. - Every later
paq installfor that app reuses the locked version instead of resolving “latest” again — no upstream lookup, and the exact same version on every machine that shares the manifest and lockfile. - The sha256 is recorded per platform (
linux-amd64-gnu,darwin-arm64, …), since each one downloads a different archive. An install from the lock must get exactly the archive its platform recorded, or it fails; a platform with no hash yet adds its own. If upstream really replaced the archive,paq lock delete <app>drops the entry so the next install re-resolves it. Asha256written by paq before 0.4.0 names no platform and is ignored. paq upgradealways checks upstream regardless of the lock, and refreshes the lock entry to whatever it installs.
Commit paq.lock.toml alongside config.toml in version control so a
teammate’s paq install reproduces your setup exactly. It only pins apps
that track "latest"; a fixed version or a spec’s default_version is
already deterministic and gets no entry. paq info <app> shows an app’s
locked version, if any. The file is generated — don’t hand-edit it; paq upgrade is what moves a pin forward, and paq lock delete <app> drops one.
An install from an entry written by an older paq records the entry again with
its tag and this platform’s sha256; if the recipe’s asset name needs the
build number the old entry lacks, that install fails: paq lock delete <app>,
then install again.
Download cache
Set PAQ_DOWNLOAD_CACHE to a directory to keep every downloaded archive
there, named by its sha256. A later install whose archive hash is known
before the download (from the recipe’s checksum, or from this platform’s lock entry)
takes it from the cache instead of the network. A cached archive is
re-checked against its hash and goes through the same verification as a
fresh download. The cache is off by default; point it at a directory your CI
keeps between builds (a CodeBuild or GitHub Actions cache path, a Docker
cache mount):
export PAQ_DOWNLOAD_CACHE=/var/cache/paq-downloads
paq install
paq never prunes the cache: delete the directory to clear it.
Commands
| Command | Aliases | Description |
|---|---|---|
paq install [app...] | i | Install one or more tools, or all tools from the manifest when no app is given |
paq import <spec> | Generate a default manifest entry for a registry tool | |
paq init | Create a commented manifest skeleton at the default config path | |
paq upgrade [app...] | up, u | Upgrade tools pinned to latest to the newest release |
paq outdated | List installed tools that have a newer upstream version (without installing) | |
paq audit | Check installed tools for known vulnerabilities (via OSV and the Node.js Security WG) | |
paq uninstall <app[@version]>... | rm, remove | Uninstall one or more tools |
paq lock delete <app>... | Drop apps’ entries from paq.lock.toml | |
paq ls | list | List installed tools |
paq which <app[@version]> | Print the installed path(s) of a tool | |
paq info <app> | Show definition and install state for a manifest app | |
paq search <query> | s | Search the registry for tool definitions |
paq registry list [query] | reg | List tool definitions in the registry, with their source (paq reg ls also works) |
paq registry show <name> | reg | Show details of a single registry definition |
paq registry status | reg | Show the embedded vs external registry state and overrides |
paq registry update | reg | Download, verify and install the latest registry snapshot |
paq config show | Show the config path, effective default directories, registry cache, and apps | |
paq doctor | Check the paq environment, paths, PATH, and installed-tool drift | |
paq self-update | Update paq itself to the latest release | |
paq version | Print the paq version | |
paq completion <shell> | Print a shell completion script (bash, zsh, fish, powershell) |
install
# Install a registry tool: records a default entry in the manifest, then installs
paq install ripgrep
# Install without recording it in the manifest (ephemeral)
paq install ripgrep --no-save
# Install a specific version
paq install ripgrep@14.1.0
# Reinstall even if already installed
paq install ripgrep --force
# Install to a specific destination (a template; --no-save keeps it out of the manifest)
paq install terraform@1.9.8 --no-save --dest '/opt/paq/terraform-{{version}}/terraform'
# Refuse recipes with no checksum or signature
paq install --require-verify
# Install several tools at once (up to 3 in parallel)
paq install ripgrep bat delta
# Install all tools from the manifest
paq install
name@version installs that version and records it as the app’s version in
the manifest: a new app is imported with it, and for an app already in the
manifest only its version line is rewritten. With --no-save the version
applies to that run only.
--dest sets the install destination of a single app, with the same
placeholders as a manifest dest. A new app is recorded with it; for an app
already in the manifest it needs --no-save, since the manifest’s own dest
is never rewritten.
An app already installed in the requested version is skipped, unless its files are missing on disk (a CI cache that restored the state but not the tools, say): then it is reinstalled with a warning.
A recipe with no checksum or signature installs with a warning. With
--require-verify, or PAQ_REQUIRE_VERIFY=1 in the environment (which also
covers paq upgrade), it fails instead, before anything is downloaded. Use it
for CI images that must not contain unverified binaries. Some embedded
recipes configure no verification, so strict mode refuses them: bat,
delta and porffor publish no checksum, signature or attestation, the
zipp recipe configures none, and fd only publishes GitHub attestations
(correct from 10.5.0), which the embedded recipe does not enable
(a manifest recipe can add them: see “GitHub build-provenance attestations”
under Adding a custom recipe).
When several tools are installed in one run, a failing one does not abort the others: every app is attempted and the command reports the failures at the end.
| Flag | Description |
|---|---|
-f, --force | Reinstall even if already installed |
--no-save | Install without recording the tool in the manifest (ephemeral) |
--dest <path> | Install destination for a single app (a template) |
--require-verify | Fail instead of warning when a recipe has no checksum or signature (also PAQ_REQUIRE_VERIFY=1) |
import
import generates a manifest entry for a registry tool without installing it,
so you can review or customise it first.
# Print a default manifest entry
paq import ripgrep
# Add it to your manifest
paq import ripgrep --write
# Customise name / destination / version, then install by that name
paq import ripgrep --as rg --dest ~/bin/rg --write
paq install rg
| Flag | Description |
|---|---|
-a, --as <name> | Name for the manifest entry (default: spec name) |
--dest <path> | Install destination (default: derived from the spec) |
--version <ver> | Version to pin (default: the recipe’s default_version, or latest when it has none) |
-w, --write | Add the entry to the manifest instead of printing it |
-f, --force | Overwrite an existing entry (requires --write) |
init
Writes a commented manifest skeleton to the default config path
(~/.config/paq/config.toml or its platform equivalent), so a new install has
something to edit instead of a blank file. Everything in the skeleton is
commented out, so paq still falls back to its built-in defaults. Not required —
paq install <tool> creates the manifest on the fly.
paq init
paq init --force # overwrite an existing manifest
| Flag | Description |
|---|---|
-f, --force | Overwrite an existing manifest |
upgrade
Upgrades a tool — or every tool tracked in the manifest — pinned to latest to
the most recent upstream release. Tools pinned to a fixed version are left
untouched. Old versions are removed after a successful upgrade.
paq upgrade # upgrade all latest-pinned tools
paq upgrade rg # upgrade a single app
paq upgrade rg bat # upgrade several apps
As with install, one failing app does not abort the rest of the batch.
outdated
Checks, for every installed app pinned to latest, whether a newer upstream
version is available — without installing anything. Apps pinned to a fixed
version, not installed, or whose backend cannot resolve latest are skipped
(shown with --verbose).
A newer release held back by minimum release age is
also listed, dimmed, with its age and when it becomes eligible (paq upgrade
won’t install it until then):
APP INSTALLED LATEST
fresh 0.5.1 0.5.2
gip 0.9.2 0.9.3 (18h old, eligible in 5h)
paq outdated
paq outdated --verbose # also report why apps were skipped
paq outdated --min-age 0h # ignore minimum_release_age for this run
audit
Looks up every installed tool version in a vulnerability database and lists
the known vulnerabilities affecting it. Most tools are checked against
OSV, with all lookups in a single request to api.osv.dev;
the Node.js runtime, which OSV does not cover, against the
Node.js Security WG feed,
downloaded once per run. Only the tool versions are sent to OSV; nothing else
about your machine is.
APP VERSION VULNS
go 1.22.0 82 GO-2024-2598, GO-2024-2599, GO-2024-2600 (+79 more)
esbuild 0.24.0 1 GHSA-67mh-4wv8-2f99
ripgrep 14.1.1 none known
jq 1.7.1 ? (no vulnerability source)
Only tools whose recipe names a vulnerability source can be checked: recipes
using the npm backend are mapped to OSV automatically, the others through an
audit table. The rest are shown with ?,
which means “not checked”, not “no vulnerabilities”. --json lists every ID
(the table shows the first three); look one up at
https://osv.dev/vulnerability/<id>. Node.js entries without a CVE are listed
as NSWG-CORE-<n>, entry <n> of the Security WG feed.
paq exits with code 3 when at least one installed version is vulnerable, so
paq audit can gate a CI job.
uninstall
Use app@version to disambiguate when multiple versions are installed.
paq uninstall rg
paq uninstall rg bat # remove several tools at once
paq uninstall node@18.20.0
paq uninstall rg --dry-run # show what would be removed
| Flag | Description |
|---|---|
--dry-run | Show what would be removed without removing anything |
-y, --yes | Skip the confirmation prompt (required in non-interactive sessions) |
lock
paq lock delete drops apps’ entries from paq.lock.toml (see Reproducible
installs), so the next paq install
resolves “latest” again and records a fresh entry. Use it when upstream
replaced a release’s archive and the install fails against the locked sha256.
A name with no entry is reported as an error.
paq lock delete rg
paq lock delete rg bat
which
Prints where a tool is installed: the destination file or directory, or one
line per binary for a multi-binary install. If several versions are installed
and no @version is given, all of them are printed.
paq which rg
paq which rg@14.1.1
A recorded path that is no longer on disk is never printed: which feeds
scripts ($(paq which rg)), so handing one a path that does not exist would
turn a missing tool into a confusing failure somewhere else. Such a version is
skipped, and when nothing is left which exits non-zero.
self-update
paq self-update # update to the latest release
paq self-update --check # only report whether an update is available
paq self-update --force # reinstall even if already up to date
| Flag | Description |
|---|---|
-c, --check | Only check for an available update, don’t install |
-f, --force | Reinstall even if already up to date |
The registry is versioned and published together with paq, so once the new
binary is in place self-update also refreshes the external registry
snapshot, when one is installed: otherwise the older
cached recipes would keep overlaying the ones embedded in the new binary, and
paq version would keep reporting a registry older than the binary. A snapshot
from a custom source has its own version line and is left untouched. A failed
refresh only warns — the new binary is already installed.
Daily update notice
Independently of self-update, paq checks for a newer release at most once a
day and prints a one-line hint after a command when one is available. The
check runs in a detached background process, so it never delays the command you
ran: the network lookup happens for the next invocation to display. It is
skipped in non-interactive contexts (output redirected or piped, --json,
--quiet) and for source builds.
Disable it with either:
export PAQ_NO_UPDATE_CHECK=1
or in the manifest:
[defaults]
check_updates = false
The last check timestamp and the latest known version are cached in
~/.cache/paq/update-check.json (Linux/macOS) or
%LOCALAPPDATA%\paq\cache\update-check.json (Windows).
In a Dockerfile
paq self-update exits successfully when paq is already up to date, so an image can bootstrap a pinned release and then bring it to the latest one, much like apt-get update:
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip \
&& rm -rf /var/lib/apt/lists/*
# Bootstrap a known release; self-update takes it to the latest one below.
ARG PAQ_VERSION=v0.3.0
ARG TARGETARCH
RUN curl -fsSL -o /tmp/paq.zip \
"https://github.com/enr/paq/releases/download/${PAQ_VERSION}/paq-${PAQ_VERSION}-linux-${TARGETARCH:-amd64}.zip" \
&& unzip -o /tmp/paq.zip paq -d /usr/local/bin \
&& rm /tmp/paq.zip
# Change PAQ_REFRESH (e.g. --build-arg PAQ_REFRESH=$(date +%s)) to bypass the
# layer cache and pick up a newer release.
ARG PAQ_REFRESH
# The optional GITHUB_TOKEN secret avoids GitHub's unauthenticated API rate limit.
RUN --mount=type=secret,id=github_token,env=GITHUB_TOKEN \
paq self-update
docker build \
--build-arg PAQ_REFRESH=$(date +%s) \
--secret id=github_token,env=GITHUB_TOKEN \
-t my-image .
The self-update layer is cached like any other, so without a changing PAQ_REFRESH (or --no-cache) Docker reuses the paq version from the previous build.
config show
Shows the configuration exactly as paq evaluates it: the path of the user
manifest (and whether it exists), the effective default install directories,
the registry cache location (and whether a snapshot is installed there), and
the apps it declares. Useful to confirm which bin/opt roots are in effect
before installing. Add --json for machine-readable output.
paq config show
completion
Prints a shell completion script to stdout. Load it in your shell to complete
subcommands, flags, and tool names (registry specs, installed apps, apps in
paq.lock.toml for paq lock delete).
# bash (current session)
source <(paq completion bash)
# zsh (persist it)
paq completion zsh > "${fpath[1]}/_paq"
Supported shells: bash, zsh, fish, powershell. Run
paq completion <shell> --help for the per-shell install instructions.
Global flags
| Flag | Description |
|---|---|
--config <path> | Use this manifest file instead of the default location (also PAQ_CONFIG) |
--local | Project-local mode: use the nearest paq.toml and keep state and default install dirs under .paq/ next to it (also PAQ_LOCAL); see Project-local installs |
--no-color | Disable color output |
-j, --json | Output as JSON (ls, registry list/show/status, info, config show, import, search, outdated, audit, which, doctor); fails on commands that don’t support it |
-q, --quiet | Suppress non-essential output |
-v, --verbose | Verbose output |
--debug | Detailed debug trace on stderr (implies --verbose) |
The NO_COLOR environment variable also disables colors. The progress bar is
written to stderr, and only when stderr is a terminal: in CI logs and pipes a
download shows just its “Downloading” and “Downloaded” lines. Data goes to
stdout.
Set GITHUB_TOKEN to avoid rate-limiting when installing GitHub-backed tools.
Without a token, a pinned version whose asset name has no glob is downloaded
straight from github.com/<repo>/releases/download/..., with no API call;
resolving "latest" and matching a glob asset still use the API. With a token,
assets are always looked up through the API, which also covers private repos.
To read the token from another variable, name it in [defaults]; when that
variable is set, it takes precedence over GITHUB_TOKEN:
[defaults]
github_token_env = "GH_MY_TOKEN"
A recipe can name its own token variable, for a repo the default token cannot read (for instance a private repo of an organization, which a fine-grained token scoped to another owner does not cover). When that variable is set, it is used for that recipe’s repo instead of the default token:
[specs.mytool]
backend = "github"
repo = "my-org/mytool"
github_token_env = "GH_MY_ORG_TOKEN"
Exit codes
| Code | Meaning |
|---|---|
0 | Success |
1 | Generic failure (network, missing config, app/spec not found, …) |
2 | Wrong CLI usage (unknown flag/command, bad argument count) |
3 | paq audit found known vulnerabilities in installed tools |
4 | Checksum or signature verification failed, or the archive does not match paq.lock.toml |
Adding a custom recipe
You are not limited to the embedded registry. Add your own recipes to
~/.config/paq/config.toml under the [specs.<name>] table, using the same
fields as the embedded ones. A user recipe is installable like any registry
tool — paq install <name> auto-imports it into the manifest. If a user recipe
shares its name with an embedded one, the user recipe wins, so you can also
patch a stale embedded recipe without waiting for a release.
The embedded registry currently ships bat, biome, bun, cosign, dbeaver,
delta, deno, esbuild, fd, fresh, gh, gip, go, goss, hugo, inner, jdk, jq,
maven, micro, node, nub, porffor, rclone, ripgrep, runp, shellcheck, terraform,
vscode, yq, zipp and temurin-11/temurin-17/temurin-21/temurin-26. Run
paq registry list for the definitive list of what your binary knows about.
A recipe for a GitHub-hosted tool:
[specs.mytool]
backend = "github"
repo = "owner/mytool"
asset = "mytool-{{version}}-{{rust_target}}.tar.gz"
archive = "tar.gz"
extract = "mytool{{ext}}"
chmod = "0755"
[specs.mytool.arch]
amd64 = "x86_64"
arm64 = "aarch64"
[specs.mytool.verify]
sha256_asset = "{{asset}}.sha256"
# Then install it (auto-imported into [apps.mytool]):
# paq install mytool
archive accepts tar.gz (or tgz), tar.xz, tar.zst, tar.bz2 and zip
(and gz, see below).
With the github backend, asset may be a glob (*, ?, [...], as in shell
patterns) when part of the name cannot be derived from the version and the
platform, such as a build number. It must match exactly one asset of the
release, otherwise the install fails listing the candidates. {{asset}} expands
to the name of the matched asset, so sha256_asset = "{{asset}}.sha256" keeps
working:
[specs.mytool]
backend = "github"
repo = "owner/mytool"
asset = "mytool-{{version}}+*-{{os}}-{{arch}}.tar.gz"
[specs.mytool.arch] and [specs.mytool.os] remap the canonical {{arch}} /
{{os}} values to the vocabulary the release uses. When a tool ships a
different C environment per arch (e.g. musl on x86_64 but gnu on aarch64),
[specs.mytool.env_arch] overrides {{env}} for a single arch:
[specs.mytool.env_arch]
amd64 = "musl" # x86_64 ships only musl; other arches keep the default gnu
When a platform needs a different asset/source, archive, extract,
chmod, strip_components or subdir, put it in a per-OS section
[specs.mytool.<os>], optionally refined per arch with
[specs.mytool.<os>.<arch>] (the arch section is applied after the OS one):
[specs.mytool.windows]
asset = "mytool-{{version}}-{{os}}{{arch}}.zip" # Windows ships a zip
archive = "zip"
[specs.mytool.darwin.amd64]
asset = "mytool-{{version}}-osx.tar.gz" # legacy name, Intel macOS only
A recipe for an archive containing multiple executables (each installed into a bin directory):
[specs.mytool]
backend = "github"
repo = "owner/mytool"
asset = "mytool-{{version}}_{{os}}_{{arch}}.zip"
archive = "zip"
strip_components = 1
chmod = "0755"
binaries = [
{ from = "foo{{ext}}" },
{ from = "bar{{ext}}", to = "mybar{{ext}}" }, # optional rename on install
]
With binaries set, dest is treated as a directory (e.g. ~/.local/bin)
and each listed executable is extracted and installed into it. from is the
file name inside the archive; to (optional) is the installed name. binaries
is mutually exclusive with extract.
binaries also works without an archive: when archive is omitted, the
downloaded asset is the executable. This covers releases that publish a bare
binary whose name embeds os/arch/version, installing it under a clean name:
[specs.mytool]
backend = "github"
repo = "owner/mytool"
asset = "mytool_{{version}}_{{os}}_{{arch}}{{ext}}"
chmod = "0755"
binaries = [ { to = "mytool{{ext}}" } ] # one entry; the artifact is the binary
A single executable compressed with gzip (e.g. mytool-linux-amd64.gz) works the
same way with archive = "gz": it is decompressed, then installed as the one
binaries entry. Without to, it is installed under the asset name minus .gz.
A recipe for a direct URL:
[specs.mytool]
backend = "url"
source = "https://example.com/releases/mytool-{{version}}-{{os}}-{{arch}}.tar.gz"
archive = "tar.gz"
strip_components = 1
A recipe for a native binary published on npm, the way esbuild, biome or
turbo ship theirs: the package lists one package per platform in its
optionalDependencies (@esbuild/linux-x64, @esbuild/darwin-arm64, …),
each declaring the os, cpu and libc it runs on. paq reads them from the
registry and downloads the one matching the current platform, as npm install
would, so the recipe names only the package. Node is not needed:
[specs.esbuild]
backend = "npm"
package = "esbuild" # scoped names work too: "@biomejs/biome"
archive = "tar.gz" # npm packages are always .tgz
extract = "esbuild{{ext}}" # matched by basename, wherever it sits in the package
chmod = "0755"
The download is always checked against the sha512 integrity the registry
records for the platform package, so the recipe needs no [verify] block.
latest is the package’s latest dist-tag, subject to the
minimum release age. On musl systems a platform package
declaring libc = ["musl"] is preferred over one that runs on any libc. Only
packages that ship a real executable fit: those whose platform packages hold a
Node addon (*.node, e.g. @swc/core) still need Node to run.
A SHA-256 checksum file may hold a single bare hash, <hash> <filename> lines
(the sha256sum layout), or BSD tag lines, SHA256 (<filename>) = <hash>
(sha256sum --tag); in the last layout, lines for other algorithms are skipped.
Some projects publish checksums only through an API, with no checksum file next
to the artifact. sha256_url takes an absolute (templated) URL instead of a
name resolved as a sibling of the asset, and sha256_json reads the hash out of
a JSON document, following a dot-separated path through objects and arrays:
[specs.mytool.verify]
sha256_url = "https://api.example.com/releases/{{version}}/{{os}}-{{arch}}"
sha256_json = "build.digest" # or "assets.0.digest" to index an array
sha256_url and sha256_asset are mutually exclusive: they are two ways of
locating the same document. sha256_json only says how to read it, so it works
with either. An algorithm prefix (sha256:, sha256-) is stripped from the
selected value.
SHA-512 is available through the same two shapes, and is checked after the SHA-256 one when both are configured:
[specs.mytool.verify]
sha512 = "9f86d081..." # the digest, inlined in the recipe
sha512_asset = "{{asset}}.sha512" # or a checksum file next to the asset
A recipe can also require a minisign
signature. The signature covers the checksum file, not the artifact: paq
verifies it first, then trusts the checksum to verify what was downloaded.
public_key and signed_asset must be set together, and one of sha256_asset
or sha256_url is required (there must be a document to sign):
[specs.mytool.verify]
sha256_asset = "{{asset}}.sha256"
[specs.mytool.verify.minisign]
public_key = "RWQ...publisher-minisign-public-key..."
signed_asset = "{{asset}}.sha256.minisig"
A key-based cosign signature
(cosign sign-blob --key) is supported too. public_key is the PEM content of
the publisher’s cosign.pub (ECDSA P-256 or Ed25519) and signature names the
base64 .sig asset; both must be set together. The signature covers the
checksum document when sha256_asset or sha256_url is configured (checked
before the artifact is downloaded), otherwise the artifact itself:
[specs.mytool.verify]
sha256_asset = "checksums.txt"
[specs.mytool.verify.cosign]
public_key = """
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
"""
signature = "checksums.txt.sig"
cosign v3 writes a Sigstore bundle (.sigstore.json) by default instead of a
.sig: name it with bundle (mutually exclusive with signature). With
public_key the bundle is still verified by paq itself, against the key; its
transparency log entries are not checked, the key being the trust anchor.
A keyless bundle, signed with a short-lived certificate (as goreleaser does
from GitHub Actions), has no key to pin: what it proves is who signed, so the
recipe must say who is expected. certificate_oidc_issuer and exactly one of
certificate_identity (the exact signer, templated) or
certificate_identity_regexp (which must start with ^) are required:
[specs.mytool.verify]
sha256_asset = "checksums.txt"
[specs.mytool.verify.cosign]
bundle = "checksums.txt.sigstore.json"
certificate_oidc_issuer = "https://token.actions.githubusercontent.com"
certificate_identity = "https://github.com/owner/mytool/.github/workflows/release.yml@refs/tags/v{{version}}"
Keyless bundles are verified by running the cosign binary (v3 or later),
which needs network access to fetch the Sigstore trust root. paq uses, in
order: the path in [defaults] cosign = "/path/to/cosign", cosign on PATH,
a cosign installed with paq install cosign, and finally a private copy of a
release whose hash is pinned in paq, kept in paq’s cache directory
(tools/cosign). When none exists, the first install that needs it downloads
the private copy; in an interactive terminal paq first asks whether to install
cosign as a regular tool instead, or to abort. Installing cosign with
paq install cosign removes the private copy. paq doctor shows which cosign
is in use. The legacy keyless format (.sig plus .pem, without a bundle) is
not supported.
Projects that build their releases with GitHub Actions can publish GitHub
build-provenance attestations (actions/attest-build-provenance), stored by
GitHub rather than in the release. They sign the artifact itself: paq looks up
the attestations for the downloaded file’s sha256 and has cosign check that
one of them is a valid SLSA provenance signed by a workflow of the expected
repository, before anything is installed. The block’s presence enables it;
repo defaults to the recipe’s repo, and signer_workflow optionally
narrows the signer to one workflow (possibly a reusable one in another
repository):
[specs.mytool.verify.github_attestation]
# repo = "owner/mytool"
# signer_workflow = "owner/mytool/.github/workflows/release.yml"
This queries the GitHub API for every install: set GITHUB_TOKEN to avoid its
rate limit. It uses cosign like keyless bundles (see above), and supports
public repositories only (private ones are signed by GitHub’s own Sigstore
instance). The embedded gh recipe does not enable it, to keep paq install gh free of the cosign download. To require it, add the whole recipe to your
manifest with the extra block (a user recipe replaces the embedded one, it is
not merged with it):
[specs.gh]
backend = "github"
repo = "cli/cli"
asset = "gh_{{version}}_{{os}}_{{arch}}.tar.gz"
archive = "tar.gz"
extract = "gh{{ext}}"
chmod = "0755"
[specs.gh.os]
darwin = "macOS"
[specs.gh.darwin]
asset = "gh_{{version}}_{{os}}_{{arch}}.zip"
archive = "zip"
[specs.gh.windows]
asset = "gh_{{version}}_{{os}}_{{arch}}.zip"
archive = "zip"
[specs.gh.verify]
sha256_asset = "gh_{{version}}_checksums.txt"
[specs.gh.verify.github_attestation]
musl-based systems (Alpine)
paq detects musl-based Linux systems (Alpine, Void musl, …) by the presence
of the musl dynamic loader (/lib/ld-musl-*.so.1) and sets {{env}} to musl
there, so recipes built on {{rust_target}} pick the *-unknown-linux-musl
asset with no changes. paq doctor shows it in the Platform row.
When the musl build has a different name, put the differences in a
[specs.mytool.musl] section, optionally refined per arch with
[specs.mytool.musl.<arch>]. It accepts the same fields as a per-OS section
and is applied after [specs.mytool.linux] / [specs.mytool.linux.<arch>],
only on musl systems:
[specs.mytool.musl]
asset = "mytool-{{os}}-{{arch}}-musl.tar.gz"
When a tool publishes no musl build, map {{env}} back to gnu so musl
systems get the glibc build instead of failing on a missing asset (it runs
only with a glibc compatibility layer such as gcompat):
[specs.mytool.env]
musl = "gnu" # or, for a single arch: [specs.mytool.env_arch] arm64 = "gnu"
After installing, paq reads the dynamic loader each installed executable
requires (its ELF interpreter) and warns when it is missing from the system:
such a binary installs but cannot run, typically a glibc build on a musl
system without gcompat. Static binaries (most Go tools) run everywhere and
are never flagged. Tools installed as a directory are not checked.
Restricting the supported platforms
When a project does not publish a build for every platform, list the ones it
does with platforms. An entry may name an OS alone (matching all its
architectures) or an os/arch pair. Omitting the field means “no restriction”:
[specs.mytool]
backend = "github"
repo = "owner/mytool"
asset = "mytool-{{version}}-{{os}}-{{arch}}.tar.gz"
archive = "tar.gz"
platforms = ["linux", "darwin/arm64"]
Installing on a platform outside the list fails up front with
"mytool" is not available for windows/amd64 (supported: linux, darwin/arm64),
instead of downloading an asset that does not exist. paq registry show lists
the supported platforms of a recipe that sets the field.
Custom template placeholders
templates defines placeholders of your own, usable in every templated field
of the recipe. It keeps a fragment that appears in several fields (a download
URL and its checksum URL, typically) defined in one place.
templates_os.<os> overrides a placeholder on a given OS:
[specs.mytool]
backend = "url"
source = "https://example.com/{{version}}/{{my_platform}}/mytool.tar.gz"
archive = "tar.gz"
[specs.mytool.templates]
my_platform = "{{os}}-{{arch}}"
[specs.mytool.templates_os.windows]
my_platform = "{{os}}-{{arch}}-archive"
[specs.mytool.verify]
sha256_url = "https://example.com/api/{{my_platform}}/latest"
sha256_json = "sha256hash"
Custom placeholders may reference the built-in ones from the
table below. {{rust_target}} is itself defined this
way, in the registry’s shared templates.toml.
Resolving latest
When an app is pinned to version = "latest", paq resolves the newest version
through a latest version strategy. By default the github backend queries the
GitHub releases API and the npm backend reads the package’s latest
dist-tag; the url backend has no built-in strategy.
For tools distributed via a direct URL, set latest_strategy to pick an
explicit source. The arch-linux strategy reads the version a package has in
the official Arch Linux repositories (core/extra) via the official JSON API:
[specs.mytool]
backend = "url"
source = "https://example.com/releases/mytool-{{version}}-{{os}}-{{arch}}.tar.gz"
archive = "tar.gz"
default_version = "1.2.3" # installed when the app sets no version
latest_strategy = "arch-linux" # used only when the app sets version = "latest"
arch_pkg = "mytool" # package name in the official Arch repos
latest_strategy takes precedence over the backend, so the download channel
(url) and the version source (Arch) are independent. Requesting "latest"
when the recipe has no strategy (and no built-in one) fails with a clear “not
implemented” error — pin a version or omit it to use default_version.
The json strategy reads the version out of a JSON document fetched over HTTP,
with the same dot-separated path syntax as sha256_json:
[specs.mytool]
backend = "url"
source = "https://example.com/releases/{{version}}/mytool-{{os}}-{{arch}}.tar.gz"
archive = "tar.gz"
latest_strategy = "json"
latest_url = "https://api.example.com/releases/latest"
latest_json = "productVersion"
latest_url is fetched before the platform is known, so unlike every other
field it is not templated: point it at a document that describes the release
as a whole. Pointing it at the same document as verify.sha256_url is what
makes a recipe self-consistent when a project only ever publishes metadata for
its newest build: version and checksum then come from one source instead of
racing each other. Such a recipe tracks the newest release and cannot be pinned,
since an older version’s checksum is no longer published.
default_version and latest_strategy are independent and can coexist:
default_version is the stable release installed when the app sets no version,
while latest_strategy is only consulted for version = "latest". This lets a
URL-based tool ship a curated, offline-friendly default while still allowing an
explicit opt-in to the newest release.
Vulnerability lookup (audit)
paq audit checks a tool only when its recipe says where to look, with an
audit table: source names the vulnerability source, the other keys are
that source’s parameters. Recipes using the npm backend are checked in OSV
against their npm package automatically.
source | Parameters | Checks against |
|---|---|---|
"osv" | ecosystem, package | the OSV database |
"nodejs" | none | the Node.js Security WG feed (the Node.js runtime only) |
For OSV, name the package using OSV’s ecosystem names:
[specs.mytool]
backend = "github"
repo = "owner/mytool"
audit = { source = "osv", ecosystem = "crates.io", package = "mytool" } # or "Go", "PyPI", ...
The nodejs source skips vulnerabilities the feed lists only for other
operating systems. A source this paq does not know is reported with a warning
and the tool is shown as not checked.
The paq 0.5.0 form osv = { ecosystem = "...", package = "..." } is still
read, as audit = { source = "osv", ... }, when no audit table is set.
The installed version is queried as is, so map a tool only to a package whose
versions match its releases (for a Go module, use the module path of the
major version the recipe installs, e.g. github.com/cli/cli/v2).
Custom release tags
For pinned versions the github backend assumes the release tag is
v + version (e.g. v14.1.1). If a repository uses a different tag scheme,
set tag to the tag template:
[specs.bun]
backend = "github"
repo = "oven-sh/bun"
tag = "bun-v{{version}}" # release tags are bun-v1.3.14, not v1.3.14
tag only affects pinned versions (including default_version); "latest"
always reads the real tag from the releases API. Besides the version fields,
tag can use {{version_build}}, the part of the pinned version after +
(the Temurin recipes use tag = "jdk-{{version}}+{{version_build}}", pinned
as e.g. version = "21.0.11+10"). Versions keep every numeric component, so a
four-part Temurin release pins as version = "21.0.12.1+1".
Minimum release age
To avoid installing or upgrading to a release the moment it’s published
(before wider testing has caught a regression or a compromised release),
version = "latest" only resolves to a release at least minimum_release_age
old. Drafts, prereleases and anything younger are skipped in favor of the
newest release that qualifies. The github and npm backends support it
(they expose per-release publish dates); it’s ignored for other
backends/strategies. With npm, when the latest dist-tag is too young, paq
picks the highest older stable version not above it, as pnpm does: this reads
the package’s full registry metadata, the only document carrying publish
dates (a few hundred KB compressed for a package with hundreds of versions).
The built-in default is 24 hours, applied even if you configure nothing.
Set a global default in [defaults], and override it per tool in the spec:
[defaults]
minimum_release_age = "7d" # global default: 7 days
[specs.mytool]
backend = "github"
repo = "example/mytool"
minimum_release_age = "0h" # this tool always tracks the true latest release
Accepted units: h (hours), d (days), mo (months, 30 days) and y
(years, 365 days) - e.g. "24h", "7d", "6mo", "1y". "0" in any unit
(e.g. "0h") disables the restriction. A spec’s minimum_release_age takes
precedence over [defaults], which takes precedence over the built-in
default. It only applies to version = "latest"; pinned versions (including
default_version) are never affected.
To override it for a single run, pass --min-age to upgrade or outdated;
it takes precedence over every spec and [defaults] value:
paq upgrade gip --min-age 0h # install a release published less than 24h ago
External registry
The registry is compiled into the binary, so paq works fully offline. It can also be overlaid with an external snapshot updated independently of the binary, to pick up new or fixed recipes without a paq release:
paq registry update # download, verify and install the latest snapshot
paq registry update --force # reinstall even if already up to date or older
paq registry status # embedded vs external versions and overrides
The snapshot is cached under ${XDG_CACHE_HOME:-~/.cache}/paq/registry
(%LOCALAPPDATA%\paq\cache\registry on Windows). Only paq registry update
and paq self-update use the network; every other command reads the cache, and
a missing or corrupt cache silently falls back to the embedded registry.
Precedence is embedded < external snapshot < your [specs.*].
paq version, paq registry status and paq doctor mark the snapshot as
stale when its version is older than the running binary: its recipes are
then overlaying newer ones embedded in the binary, and paq registry update
brings it back in line (registry status --json exposes it as stale). A
snapshot from a custom source has its own version line and is never marked
stale.
The archive is always verified against a SHA-256 checksum, and the checksum
itself can be signed with minisign. A
failed verification aborts with exit code 4.
Signature verification of the default source is still being rolled out:
official releases do not publish a .minisig yet, so the binaries ship without
an embedded trust anchor and paq registry update falls back to checksum-only
verification, printing a warning. A custom source is different — it must
supply its own public key, and its signature is always verified. Set a
[registry] table (the URL must be https):
[registry]
url = "https://example.com/paq/registry.tar.gz"
public_key = "RWQ...your-minisign-public-key..."
paq registry update then fetches url, url.sha256 and url.sha256.minisig.
Template placeholders
| Placeholder | Value |
|---|---|
{{os}} | linux, darwin, windows |
{{arch}} | amd64, arm64 |
{{vendor}} | unknown, apple, pc |
{{env}} | gnu (linux), musl (musl-based linux, e.g. Alpine), `` (others) |
{{ext}} | .exe (windows), `` (others) |
{{version}} | Resolved version, e.g. 14.1.1 (every numeric component: 21.0.12.1 for Temurin’s jdk-21.0.12.1+1) |
{{version_major}} | e.g. 14 |
{{version_minor}} | e.g. 1 |
{{version_patch}} | e.g. 1 |
{{version_build}} | Build number after + in the release tag, e.g. 10 for 21.0.11+10 (empty when absent) |
{{rust_target}} | e.g. x86_64-unknown-linux-gnu |
{{asset}} | Resolved asset filename |
State
Install state is stored in ~/.local/state/paq/state.json (Linux/macOS) or
%LOCALAPPDATA%\paq\state.json (Windows). In local mode it is
<project>/.paq/state.json instead (see Project-local
installs). Destinations are recorded as
absolute paths: outside local mode a relative dest is resolved against the
current directory.
paq does not watch that directory, so removing an installed file by hand leaves a record claiming the tool is still there. Every command that reads the state checks the filesystem rather than trusting it:
paq lslists the record and warns how many tools are missing on disk;paq ls --jsoncarries amissingfield per entry.paq whichnever prints a path that is gone, and exits non-zero when that leaves nothing to print.paq doctornames each missing record and exits non-zero.paq installreinstalls a tool whose record is there but whose files are missing, instead of skipping it as already installed.
Reinstall the tool with paq install <name>, or drop the stale record with
paq uninstall <name>.