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:

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:

versionBehaviour
omittedinstalls 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:

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

CommandAliasesDescription
paq install [app...]iInstall 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 initCreate a commented manifest skeleton at the default config path
paq upgrade [app...]up, uUpgrade tools pinned to latest to the newest release
paq outdatedList installed tools that have a newer upstream version (without installing)
paq auditCheck installed tools for known vulnerabilities (via OSV and the Node.js Security WG)
paq uninstall <app[@version]>...rm, removeUninstall one or more tools
paq lock delete <app>...Drop apps’ entries from paq.lock.toml
paq lslistList 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>sSearch the registry for tool definitions
paq registry list [query]regList tool definitions in the registry, with their source (paq reg ls also works)
paq registry show <name>regShow details of a single registry definition
paq registry statusregShow the embedded vs external registry state and overrides
paq registry updateregDownload, verify and install the latest registry snapshot
paq config showShow the config path, effective default directories, registry cache, and apps
paq doctorCheck the paq environment, paths, PATH, and installed-tool drift
paq self-updateUpdate paq itself to the latest release
paq versionPrint 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.

FlagDescription
-f, --forceReinstall even if already installed
--no-saveInstall without recording the tool in the manifest (ephemeral)
--dest <path>Install destination for a single app (a template)
--require-verifyFail 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
FlagDescription
-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, --writeAdd the entry to the manifest instead of printing it
-f, --forceOverwrite 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
FlagDescription
-f, --forceOverwrite 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
FlagDescription
--dry-runShow what would be removed without removing anything
-y, --yesSkip 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
FlagDescription
-c, --checkOnly check for an available update, don’t install
-f, --forceReinstall 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

FlagDescription
--config <path>Use this manifest file instead of the default location (also PAQ_CONFIG)
--localProject-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-colorDisable color output
-j, --jsonOutput 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, --quietSuppress non-essential output
-v, --verboseVerbose output
--debugDetailed 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

CodeMeaning
0Success
1Generic failure (network, missing config, app/spec not found, …)
2Wrong CLI usage (unknown flag/command, bad argument count)
3paq audit found known vulnerabilities in installed tools
4Checksum 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.

sourceParametersChecks against
"osv"ecosystem, packagethe OSV database
"nodejs"nonethe 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

PlaceholderValue
{{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:

Reinstall the tool with paq install <name>, or drop the stale record with paq uninstall <name>.