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

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

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).

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.

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 @conda fails with Unknown 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-as requirement described below is a symptom of exactly the resolution-contract problem that -e @npm and -e @composer just had fixed: needing a kapsl-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

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

ProviderTriggerDefault file
@npm-e @npm or -e @npm:filepackage.json
@yarn-e @yarn or -e @yarn:filepackage.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

ProviderTriggerDefault file
@renv-e @renv or -e @renv:filerenv.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

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

Examples

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

PHP

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

Examples

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

Elixir

ProviderTriggerDefault file
@hex-e @hex or -e @hex:filemix.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 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. kapsl-examples/elixir-hex still carries the unnecessary boilerplate.

Lua

ProviderTriggerDefault file
@luarocks-e @luarocks:<file>.rockspecnone (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

ProviderTriggerDefault file
@galaxy-e @galaxy or -e @galaxy:filerequirements.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.