Package Managers

boks supports these package manager providers via the -e flag. The provider is always explicit now — -e @pip:requirements.txt, never a bare -e requirements.txt — so there's no ambiguity between providers that happen to share a filename convention (@uv/@poetry both reading pyproject.toml, for instance).

Python

ProviderTriggerDefault file
@pip-e @pip or -e @pip:filerequirements.txt
@uv-e @uv or -e @uv:filerequirements.txt, pyproject.toml
@poetry-e @poetry or -e @poetry:filepyproject.toml

Examples

boks -e @pip:requirements.txt python script.py
boks -e @pip python script.py           # auto-finds requirements.txt
boks -e @uv:requirements.txt python script.py
boks -e @uv python script.py            # auto-finds requirements.txt or pyproject.toml
boks -e @poetry:pyproject.toml python script.py
boks -e @poetry python:requests,click script.py   # inline: synthesizes a throwaway pyproject.toml

Packages install directly into the base Python (poetry config virtualenvs.create false) rather than a nested venv — a boks env already is the isolated environment, so poetry's own venv layer would be redundant.

Package-specific rules

Some packages need more than just installing — they want a host-side cache persisted, or a runtime capability auto-enabled. boks handles both with the same mechanism, keyed on the package rather than the tool itself (the same idea as cargo/rust's ~/.cargo/registry dotfiles cache, see Tool Index).

Dotfileshuggingface_hub is the example: installing it (inline or via a file) mounts a host-backed ~/.cache/huggingface, so a downloaded model or dataset persists instead of re-downloading on every run.

boks --cap net python:huggingface_hub -c "from huggingface_hub import hf_hub_download; hf_hub_download('org/repo', 'file')"
boks python:huggingface_hub -c "from huggingface_hub import hf_hub_download; hf_hub_download('org/repo', 'file', local_files_only=True)"  # offline, reads the cache

Capabilities — an HTTP-client library's whole point is to make network calls, but boks's default is net=none. Rather than make you remember --cap net every time, boks auto-enables network for the packages that exist to use it:

boks python:requests -c "import requests; print(requests.get('https://example.com').status_code)"   # → 200, no --cap net needed

Today requests, httpx, and aiohttp under pip auto-enable net. As with any auto-capability, an explicit CLI flag always wins — --cap net on a requests run is a harmless no-op (already on), and any future opt-out flag would take precedence. huggingface_hub deliberately gets only a dotfiles mount and no auto-net: its offline-cache workflow relies on a second, un-networked run reading the cache, so silently granting it network would defeat that test.

Detection works for both the inline form (python:requests) and file-based installs — a requests line in requirements.txt or pyproject.toml triggers the same rule. The mechanism itself (package_rules in the index) is provider-scoped, so a future entry could grant the same capability for a different language's HTTP client without affecting unrelated packages — and a package with no rule (e.g. jinja2) stays fully isolated, exactly as if no packages were involved.

This mapping is index-curated, not locally configurable: it lives on the tool's global index entry (package_rules, see Tool Index) and is deliberately constrained — dotfile paths to ~/.cache/ or ~/.config/ (the same security boundary as every other dotfiles mount), capabilities to the same set any tool can declare — just keyed on a package name instead of a tool name.

Conda

Not implemented. -e @conda fails with Unknown package provider: conda — there is no conda builder in boks, and everything in this section describes a design that was never shipped. Verified 2026-08-11. It is kept here as the intended design, not as documentation of working behaviour.

Note also that the --exec-as requirement described below is a symptom of exactly the resolution-contract problem that -e @npm and -e @composer just had fixed: needing a boks-specific flag to reach the environment means unmodified code does not work. Conda should be built so that a plain python script.py uses the environment's own interpreter, rather than shipping the workaround.

ProviderTriggerDefault file
@conda (not implemented)-e @conda or -e @conda:fileenvironment.yml

Examples

boks -e @conda:environment.yml --exec-as /opt/conda/bin/python3 python@3.12 script.py
boks -e @conda python@3.12:numpy,pandas --exec-as /opt/conda/bin/python3 script.py

Unlike every other provider here, no base image ships conda — @conda bootstraps Miniforge (not Anaconda's own installer: Anaconda's default channel carries a commercial-use Terms of Service, which Miniforge's conda-forge-only default avoids entirely) into /opt/conda on first use, then installs packages via conda install -c conda-forge (inline) or conda env update (file-based, against environment.yml's own dependencies: list — no package-rules detection, since it's YAML and boks has no YAML parser dependency).

--exec-as /opt/conda/bin/python3 is required to actually use conda's packages. Miniforge installs its own Python into /opt/conda, separate from the base image's system Python — and a tool image's ENTRYPOINT is typically an absolute path (e.g. /usr/bin/python3 for boks.sh/ python), which bypasses $PATH lookup entirely, so the PATH prepend alone doesn't redirect the entrypoint itself. Point --exec-as at conda's interpreter directly to run under it.

@conda needs bash and ldd inside the build environment for Miniforge's own installer script, which a minimal Wolfi/Alpine base doesn't ship by default — @conda installs both via apk first when apk is available, a harmless no-op on any other base that already has them (e.g. Debian).

Node.js

ProviderTriggerDefault file
@npm-e @npm or -e @npm:filepackage.json
@yarn-e @yarn or -e @yarn:filepackage.json

Examples

boks -e @npm:package.json node app.js
boks -e @npm node app.js    # auto-finds package.json
boks -e @yarn:package.json node app.js
boks -e @yarn node app.js   # auto-finds package.json, installs with yarn instead of npm

package.json alone doesn't say whether a project wants npm or yarn — name the provider explicitly (@npm vs @yarn) either way.

R

ProviderTriggerDefault file
@renv-e @renv or -e @renv:filerenv.lock

Examples

boks -e @renv:renv.lock Rscript script.R
boks -e @renv Rscript script.R    # auto-finds renv.lock

R and Rscript are two separate tools, mirroring a local install: R is the interactive REPL (boks R launches it), Rscript runs a script file and exits (boks Rscript script.R, like Rscript script.R on a host) — use Rscript here, not R. The tool name is capital R/Rscript, matching the real commands on Linux (there's no lowercase r). Alpine ships a single current R release, so there's no per-minor-version axis the way Python/Node have.

Ruby

ProviderTriggerDefault file
@gem-e @gem or -e @gem:fileGemfile

Examples

boks -e @gem:Gemfile ruby app.rb
boks -e @gem ruby app.rb    # auto-finds Gemfile

PHP

ProviderTriggerDefault file
@composer-e @composer or -e @composer:filecomposer.json

Examples

boks -e @composer:composer.json php app.php
boks -e @composer php app.php    # auto-finds composer.json

Elixir

ProviderTriggerDefault file
@hex-e @hex or -e @hex:filemix.exs

Examples

boks -e @hex:mix.exs elixir script.exs
boks -e @hex elixir script.exs    # auto-finds mix.exs

Hex has no pip install <pkg>-style equivalent for arbitrary packages, so an inline install (boks elixir:jason) synthesizes a minimal mix.exs behind the scenes.

Dependencies are discovered automatically, like pip's site-packages: the provider sets ERL_LIBS, which the Erlang code server reads at startup, so an ordinary script works with no boilerplate.

$ cat idiomatic.exs
IO.puts("hex idiomatic: " <> Jason.encode!(%{ok: true}))

$ boks -e @hex elixir idiomatic.exs
hex idiomatic: {"ok":true}

Correction (2026-08-11): this page previously said a plain elixir invocation "has no automatic way to discover compiled dependencies" and pointed at a Code.prepend_path incantation. That was wrong — verified live, both with the script above and with a control run that correctly fails without -e @hex. boks-examples/elixir-hex still carries the unnecessary boilerplate.

Lua

ProviderTriggerDefault file
@luarocks-e @luarocks:<file>.rockspecnone (no default filename)

Examples

boks -e @luarocks:myproject-0.1-1.rockspec lua script.lua
boks lua:dkjson -e 'print(require("dkjson").encode({ok = true}))'

@luarocks has no default-file auto-detection (-e @luarocks alone has no .rockspec to fall back to): rockspecs are named <package>-<version>-<revision>.rockspec by convention, not a single canonical filename like Gemfile/composer.json/mix.exs, so the file must always be passed explicitly alongside the provider. Unlike Hex/Composer, no boilerplate is needed in the script itself — the provider sets LUA_PATH/LUA_CPATH at image-build time, which Lua's runtime reads automatically at startup, the same mechanism as Python's PYTHONPATH.

Ansible

ProviderTriggerDefault file
@galaxy-e @galaxy or -e @galaxy:filerequirements.yml

Examples

boks -e @galaxy:requirements.yml ansible-playbook -- playbook.yml
boks -e @galaxy ansible-playbook -- playbook.yml   # auto-finds requirements.yml
boks ansible-playbook:community.general -- playbook.yml   # inline, no manifest

The galaxy provider installs ansible-galaxy collections into the env image at a fixed path (/opt/ansible/collections) and sets ANSIBLE_COLLECTIONS_PATH to find them — so a playbook uses community.general.* modules with no per-playbook setup, no --cap net, and a read-only rootfs. Like @luarocks, discovery is automatic (ansible reads the env var at startup, like Lua reads LUA_PATH); unlike terraform (whose providers are project-local to the working tree), collections can be baked into an image layer and scanned before any playbook runs.

ansible-playbook is a first-class tool name (a separate index entry sharing the ansible image, bin=/usr/bin/ansible-playbook) — the boks <cmd> prefix reaches it directly, mirroring ansible-playbook on a host. There's also a matching ansible-galaxy entry for ad-hoc boks ansible-galaxy collection install … outside the provider. See boks-examples/ansible-galaxy for the full walkthrough, including the two tmp/path gotchas the provider handles invisibly.

Running the paired command in the same environment

pip and npm are also runnable as bare tool names — boks pip and boks npm resolve to the exact same image as boks python and boks node respectively (same digest), so building via one and then invoking the other against the same environment file reuses the identical cached image instead of building a second one:

boks -e @pip:requirements.txt python@3.12 script.py    # builds the env
boks -e @pip:requirements.txt pip@3.12 list            # reuses it — same cache tag

This works for the inline-package form too (python:requests and pip:requests share a cache). The same hand-duplicated-image pattern also pairs R/Rscript (the REPL and the script runner — two different tools, see the R section above) and cargo/rustc, each sharing one image under a different bin. boks --search pip/boks --search Rscript/boks --search rustc show them like any other tool.

Running a package-installed command with --exec-as

pip/npm cover the two paired commands every language ships. For anything else a package installs — a formatter, a linter, a CLI added via requirements.txt/package.json — use --exec-as to run it inside the same resolved environment instead:

boks -e @pip:requirements.txt --exec-as black python@3.12 -- --check .

This runs black (installed via requirements.txt) inside the python@3.12 environment, rather than python itself. --exec-as takes one executable, not a shell command line (no spaces) — the tool's own arguments go after the tool name as usual.

Auto-detection

There is no bare -e . — the provider is always named explicitly. What auto-detects is the file: -e @provider:. (or just -e @provider, same thing) scans the current directory for that provider's own default filename:

boks -e @pip:. python script.py   # finds requirements.txt
boks -e @npm:. node app.js        # finds package.json
boks -e @gem:. ruby app.rb        # finds Gemfile
boks -e @composer:. php app.php   # finds composer.json
boks -e @renv:. Rscript script.R  # finds renv.lock
boks -e @hex:. elixir script.exs  # finds mix.exs
boks -e @galaxy:. ansible-playbook -- playbook.yml  # finds requirements.yml
boks -e @conda:. --exec-as /opt/conda/bin/python3 python script.py  # finds environment.yml

This is a deliberate change from earlier versions, which let a bare -e . guess the provider from whichever file it found — that guess was ambiguous exactly where two providers share a filename convention (@uv/@poetry both read pyproject.toml; a yarn.lock alone doesn't distinguish @npm from @yarn), and silently wrong the rest of the time is worse than an extra few characters typed. Naming the provider is now required everywhere, @luarocks included — it has no default filename to fall back to at all (see its section above), so -e @luarocks:. isn't meaningful; the .rockspec path must always be given.