Package Managers
kapsl 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
| Provider | Trigger | Default file |
|---|---|---|
@pip | -e @pip or -e @pip:file | requirements.txt |
@uv | -e @uv or -e @uv:file | requirements.txt, pyproject.toml |
@poetry | -e @poetry or -e @poetry:file | pyproject.toml |
Examples
kapsl -e @pip:requirements.txt python script.py
kapsl -e @pip python script.py # auto-finds requirements.txt
kapsl -e @uv:requirements.txt python script.py
kapsl -e @uv python script.py # auto-finds requirements.txt or pyproject.toml
kapsl -e @poetry:pyproject.toml python script.py
kapsl -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 kapsl 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. kapsl 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).
Dotfiles — huggingface_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.
kapsl --cap net python:huggingface_hub -c "from huggingface_hub import hf_hub_download; hf_hub_download('org/repo', 'file')"
kapsl 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 kapsl's default is net=none. Rather than make you remember --cap net
every time, kapsl auto-enables network for the packages that exist to use it:
kapsl 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 @condafails withUnknown package provider: conda— there is no conda builder in kapsl, 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-asrequirement described below is a symptom of exactly the resolution-contract problem that-e @npmand-e @composerjust had fixed: needing a kapsl-specific flag to reach the environment means unmodified code does not work. Conda should be built so that a plainpython script.pyuses the environment's own interpreter, rather than shipping the workaround.
| Provider | Trigger | Default file |
|---|---|---|
@conda (not implemented) | -e @conda or -e @conda:file | environment.yml |
Examples
kapsl -e @conda:environment.yml --exec-as /opt/conda/bin/python3 [email protected] script.py
kapsl -e @conda [email protected]: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 kapsl 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 kapsl.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
| Provider | Trigger | Default file |
|---|---|---|
@npm | -e @npm or -e @npm:file | package.json |
@yarn | -e @yarn or -e @yarn:file | package.json |
Examples
kapsl -e @npm:package.json node app.js
kapsl -e @npm node app.js # auto-finds package.json
kapsl -e @yarn:package.json node app.js
kapsl -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
| Provider | Trigger | Default file |
|---|---|---|
@renv | -e @renv or -e @renv:file | renv.lock |
Examples
kapsl -e @renv:renv.lock Rscript script.R
kapsl -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 (kapsl R launches it), Rscript runs a script file and
exits (kapsl 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
| Provider | Trigger | Default file |
|---|---|---|
@gem | -e @gem or -e @gem:file | Gemfile |
Examples
kapsl -e @gem:Gemfile ruby app.rb
kapsl -e @gem ruby app.rb # auto-finds GemfilePHP
| Provider | Trigger | Default file |
|---|---|---|
@composer | -e @composer or -e @composer:file | composer.json |
Examples
kapsl -e @composer:composer.json php app.php
kapsl -e @composer php app.php # auto-finds composer.jsonElixir
| Provider | Trigger | Default file |
|---|---|---|
@hex | -e @hex or -e @hex:file | mix.exs |
Examples
kapsl -e @hex:mix.exs elixir script.exs
kapsl -e @hex elixir script.exs # auto-finds mix.exs
Hex has no pip install <pkg>-style equivalent for arbitrary packages, so an
inline install (kapsl 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}))
$ kapsl -e @hex elixir idiomatic.exs
hex idiomatic: {"ok":true}
Correction (2026-08-11): this page previously said a plain
elixirinvocation "has no automatic way to discover compiled dependencies" and pointed at aCode.prepend_pathincantation. That was wrong — verified live, both with the script above and with a control run that correctly fails without-e @hex.kapsl-examples/elixir-hexstill carries the unnecessary boilerplate.
Lua
| Provider | Trigger | Default file |
|---|---|---|
@luarocks | -e @luarocks:<file>.rockspec | none (no default filename) |
Examples
kapsl -e @luarocks:myproject-0.1-1.rockspec lua script.lua
kapsl 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
| Provider | Trigger | Default file |
|---|---|---|
@galaxy | -e @galaxy or -e @galaxy:file | requirements.yml |
Examples
kapsl -e @galaxy:requirements.yml ansible-playbook -- playbook.yml
kapsl -e @galaxy ansible-playbook -- playbook.yml # auto-finds requirements.yml
kapsl 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 kapsl <cmd> prefix
reaches it directly, mirroring ansible-playbook on a host. There's also a
matching ansible-galaxy entry for ad-hoc kapsl ansible-galaxy collection install … outside the provider. See kapsl-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 — kapsl pip and kapsl npm
resolve to the exact same image as kapsl python and kapsl 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:
kapsl -e @pip:requirements.txt [email protected] script.py # builds the env
kapsl -e @pip:requirements.txt [email protected] 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. kapsl --search pip/kapsl --search Rscript/kapsl --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:
kapsl -e @pip:requirements.txt --exec-as black [email protected] -- --check .
This runs black (installed via requirements.txt) inside the [email protected]
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:
kapsl -e @pip:. python script.py # finds requirements.txt
kapsl -e @npm:. node app.js # finds package.json
kapsl -e @gem:. ruby app.rb # finds Gemfile
kapsl -e @composer:. php app.php # finds composer.json
kapsl -e @renv:. Rscript script.R # finds renv.lock
kapsl -e @hex:. elixir script.exs # finds mix.exs
kapsl -e @galaxy:. ansible-playbook -- playbook.yml # finds requirements.yml
kapsl -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.