Package providers
The theme: a declaration, not a state
A dependency set is a declaration you can re-derive, not state you maintain. Name the packages — inline or in a file — and kapsl builds the environment, caches it by content hash, scans it, and runs. Same declaration, same bytes. Delete the cache and it is rebuilt identically; the machine's state is irrelevant to what the build is.
Name the difference to the alternatives, because it is the point:
- A Containerfile is a build recipe for an image you own and age. Somebody has to remember to rebuild it, push it, re-pull it, and the image's packages drift from the project's manifest the moment they stop being edited together. The recipe and the running bytes are two things.
- A native package environment is host state someone else's tool
mutated. A global site-packages, a
node_modulesyou forgot about, a venv you must source, an image you must remember to rebuild — each one is a fact about this machine that the project directory does not record, and therefore cannot reproduce.
Neither is re-derivable from the project directory alone. The
requirements.txt plus the runtime name is, and that is the entire
mechanism.
The three forms
Inline
Packages after a colon, run the tool with them:
kapsl [email protected]:pyyaml -c "import yaml; print('pyyaml', yaml.__version__)"
▍ ■ BUILDING kapsl-env-python-3.12:uv-ad44534d
▍ ■ BUILDING installing · 0%
▍ ■ BUILDING capturing · 66%
▍ ■ BUILDING exporting kapsl-env-python-3.12:uv-ad44534d (3.2 MB)
▍ ■ BUILDING cataloguing kapsl-env-python-3.12:uv-ad44534d
▍ ■ BUILDING signing kapsl-env-python-3.12:uv-ad44534d
▍ ■ SCANNING python · reading 13 inventories
▍ ■ SCANNING python · 13 images
pyyaml 6.0.3
The first run builds (57 s on this machine, including the scan of the
whole composed set — 13 images, the tool and the environment); every
subsequent run starts from the content-hashed cache in under three
seconds. The environment image's tag is the declaration:
kapsl-env-python-3.12:uv-ad44534d is the runtime, the provider, and the
hash of what was installed.
File-based
-e @provider:file loads the dependency set from a manifest; the
provider is always named explicitly:
kapsl -e @pip:requirements.txt python -c "import yaml; print('pyyaml', yaml.__version__)"
▍ ■ BUILDING kapsl-env-python-latest:pip-df4be484
▍ ■ BUILDING installing · 0%
▍ ■ BUILDING exporting kapsl-env-python-latest:pip-df4be484 (534 KB)
▍ ■ BUILDING cataloguing kapsl-env-python-latest:pip-df4be484
▍ ■ BUILDING signing kapsl-env-python-latest:pip-df4be484
▍ ■ SCANNING python · reading 13 inventories
▍ ■ SCANNING python · 13 images
pyyaml 6.0.2
Note what the file did that the inline form could not: requirements.txt
pins pyyaml==6.0.2, and the environment got 6.0.2 — the inline pyyaml
above resolved to 6.0.3. The manifest is the declaration, and pins are
part of it.
Omit the filename (keep the provider) and kapsl auto-detects the right
manifest in the current directory — -e @pip finds requirements.txt,
-e @npm finds package.json. And --watch watches the file and
rebuilds on change, so an editor saving requirements.txt updates the
environment the next run picks up:
kapsl --watch -e @pip:requirements.txt python script.pyShebang
A script that is its own environment:
#!/usr/bin/env -S kapsl [email protected]:pyyaml
import yaml
print("pyyaml", yaml.__version__)chmod +x script.py
./script.py
pyyaml 6.0.3
0.6 s once the environment is cached. Anyone with kapsl on the machine
can run the script — no virtualenv setup, no pip install. One
mechanical note, because the binary dispatches on argv[0]: the shebang
must name the binary itself (CLI mode is "argv[0] is my own name"; a
symlink with any other name is a shim for the tool of that name). On a
machine where the binary is still spelled boks, the line says boks.
The built-in providers
The registry is a curated name-to-builder table in
boks-core/src/env/mod.rs. Seventeen builders, reached through the
package-manager name and the runtime name where the two differ:
| Provider(s) | Builder | Ecosystem |
|---|---|---|
pip, uv, python | pip / uv | Python |
npm, node | npm | JavaScript/TypeScript |
yarn | yarn | JavaScript/TypeScript (yarn.lock) |
apt, ubuntu | apt | Debian/Ubuntu packages |
poetry | poetry | Python (poetry.lock) |
maven, java, javac, mvn | maven | JVM |
gem, ruby | gem | Ruby |
cpanm, perl | cpanm | Perl |
cargo, rustc | cargo | Rust |
cabal, ghc | cabal | Haskell |
composer, php | composer | PHP |
go-install, go | go-install | Go |
renv, r | renv | R |
hex, elixir | hex | Elixir |
luarocks, lua | luarocks | Lua |
galaxy, ansible | ansible-galaxy | Ansible collections |
The alias rule is deliberate: the name the user types for the runtime
(python, node, ruby, go, r, lua, php, java, perl,
rustc, ghc, mvn, javac, ubuntu) resolves to the same builder as
the package manager's own name, so tool:packages inline form and
-e @name agree on what "install" means. One alias is load-bearing and
worth the footnote it carries in the code: python routes to uv, not
pip. The reason is lockfiles, not speed — every other ecosystem records
what it resolved, so an unpinned manifest is still reproducible; plain
pip produces no resolved set at all, and requirements.txt is a manifest
rather than a lock. uv can emit one (uv pip compile), so routing the
default through uv is what lets Python join the rest of the design rather
than being permanently exempt from it. -e @pip remains available and
unchanged for anyone who wants plain pip — the inline form above is uv
under the hood, the file-based form is pip, and both are the same
mechanism with different install commands.
Custom providers
A provider is almost entirely data: the only thing that varies between
built-ins is the install command. So a custom provider is two templated
commands in kapsl.toml, no Rust, no plugin system, no separate process:
[package_providers.mypm]
install_command_inline = "RUN mypm add {packages}"
install_command_file = "RUN mypm install -r /tmp/{file}"
{packages} is the space-joined inline list; {file} is the copied
manifest's filename. A typo in a placeholder fails at config load rather
than silently installing nothing. The containment invariants are
structural, not policy checks — the Dockerfile is assembled in a fixed
shape and the template goes into exactly one slot:
FROM <kapsl-resolved base> ← kapsl chooses this, never the template
[COPY <file> /tmp/<file>] ← only for file-based installs
USER root ← kapsl injects, so installs can write system dirs
<template> ← the provider's install command, verbatim
USER 1000 ← kapsl injects LAST, so the image runs unprivileged
The base is kapsl's resolved one (same image, same freshness window, same
digest-based cache invalidation); USER 1000 is always the final line, so
the built environment runs unprivileged; and the template cannot add
--privileged or host mounts, because a Dockerfile has no such
directives. A user provider also cannot alias a built-in name —
[package_providers.pip] is rejected at config load rather than silently
overriding the curated provider.
And the honest line, from the code that implements this:
The security boundary is NOT "can the user run arbitrary install commands" — every built-in provider already does that via
RUNas root inpodman build, and the user already controls their[tools]image and--skip-scan. The boundary is: the scan gate still runs on a user-provider env, a user provider can't alias a curated name likepip, and there's no backdoor into host-path mounts beyond the index-onlypackage_rules…
A custom provider is invoked explicitly (-e @mypm:file or
-e @mypm tool:pkg1,pkg2); it is never inferred from an image name and
has no default manifest filenames. It also has no index entry, so it
participates in no package_rules — no package-driven dotfile mounts or
capability auto-enablement, the same position as the manifest-DSL
built-ins (gem, hex, luarocks).
Composition
Environments are not islands; the compose set is part of the declaration:
reqis walked transitively. flake8 declaresrequires = ["python"]—kapsl flake8runs with python present, with no-eanywhere, because the hard dependency is a property of the built artifact that the client resolves. The--pulloutput on the CI page shows the shape:mkdocsprepared four images — base, git, mkdocs, andpython:3.14— the last one transitive.defis the primary tool's convenience set, applied only to the primary: what the agent-style tools compose for their shell environment (the agentic page walks claude's fifteen composed tools).[groups]name a set and grant nothing. The built-incoreutilsgroup expands to ~95 tool names and is what makes a barekapsl bashwork — bash's compose default is the group, and the group members are individually declared (mostro, a few that write). A user group shadows a built-in of the same name, and groups cannot nest.- The
-esegment form composes tools into the environment's container:-e git,python:flake8 vimruns vim with git and a flake8-bearing python mounted into the same container, each tool at its own declared boundary.
Package rules
Per-package runtime grants live in the signed catalogue's
package_rules — grants keyed by (provider, package), because a grant
that no image declaration could know belongs to the package the user
asked for at run time, not to the image it lands in. The full current
set (catalogue 2026.35.16, nine rules):
| Provider | Package | Grant | Why (from the catalogue's own comments) |
|---|---|---|---|
| pip | huggingface_hub | mount ~/.cache/huggingface (dir); pass HF_TOKEN, UV_NO_HF_TOKEN | the hub client re-downloads multi-GB weights every run without the cache; the token is a credential and a package rule is the narrowest thing that can carry it |
| pip | requests | cap: net | "might make an outbound call" — the package exists to make requests |
| pip | httpx | cap: net | same |
| pip | aiohttp | cap: net | same |
| pip | flask | cap: net | a framework is useless without network — every quickstart's first command is a dev server |
| pip | django | cap: net | same |
| pip | fastapi | cap: net | same |
| npm | express | cap: net | same reasoning, npm side |
| npm | @nestjs/core | cap: net | same |
Dotfile grants in a rule are confined to ~/.cache/ and ~/.config/ —
the client rejects anything else, so a rule cannot reach into the home
directory. A project can carry its own package_rules in the
.kapslrc for internal packages that will
never get a catalogue entry — the same shape, project-scoped,
trust-gated.
The caveat, from the file's own header comment, and worth internalizing before adding a rule: a rule that does not match fires silently. An unrecognised field name is ignored rather than refused, and an unrecognised provider is accepted too; neither shows up as an error, only as a grant that never arrives. Check a new rule by running the tool, not by reading it.
What "declarative" buys you, concretely
- Scanned. The environment is part of the composed set: the 13-image
scan in the inline capture above includes everything
pyyamlpulled, before it ever runs. Transitive CVEs are findings with a version, not a hope — native package managers run the resolve and never look back. - Reproducible. The content hash in the tag is the reproducibility claim: same declaration, same base digest, same bytes. Pin the base digest for exact reproducibility of the runtime itself.
- Disposable.
kapsl --cleanremoves every environment image and package-cache volume; the next run rebuilds from the declaration. There is no un-do of apip installto track down, because there is no install to un-do. - Reviewable. The project's dependency file goes through the same
trust review as everything else in a
.kapslrc: first run lists what theenv_filewould install and what grants its packages trigger via the rules, tagged with the source, and a human says yes. Arequirements.txtthat addsrequestsis a network grant, and the review says so.
Caching and lifecycle
Where things live (the split the --status output on the
CI page makes visible): ~/.cache/kapsl holds the
regenerable — environment images, package caches, the scanner database,
the catalogue cache — and ~/.local/share/kapsl holds the decisions.
--clean (default targets) removes the environment images and caches
and never touches base images or decisions.
The base-image freshness loop closes the loop: floating base tags
re-pull on the freshness window (default 7 days), and the environment
hash covers the base digest — so when the base moves, the next run
builds a fresh environment on the new base, automatically. The known
open item, stated as such: a rebuild triggered by the base moving
re-resolves the package spec, which can pick up newer package versions.
Package-set replay is planned — record the resolved set when an
environment is first built, so a rebuild reuses the same versions instead
of silently re-resolving, with re-resolution triggered only by a scan
failure naming a locked package or an explicit request. Until it ships,
pin exact versions in the spec if you need cross-rebuild package
stability. --watch (above) is the development-side half of the same
lifecycle.
The dogfood: this site builds with kapsl zola
The docs site you are reading builds through the product: kapsl zola build and kapsl zola serve, with the project overlay in this repo's
.kapslrc. The whole deployment is the six lines under tools:; the
rest of the file is the reasoning, which is the point:
tools:
zola:
subcommands:
serve:
ports: ["1111"]
args: ["--port", "1111"]
The mechanism is the per-subcommand args/ports the catalogue carries:
the zola index entry supplies --interface 0.0.0.0 and the 1111 port
publish for the serve subcommand (the image's own labels dropped them
in the 0.23 rebuild, so the index carries them), and the project overlay
pins the listen port on the command line as well — so the port zola
binds and the port the index publishes cannot drift apart, and it must
not repeat --interface, because the index already supplies it and
clap rejects the argument a second time. Keyed on serve, zola build
and zola check get neither the port nor the bind address — they never
asked to be a server. That is the per-subcommand pattern the
catalogue page describes, doing real work:
a default that is the exception, and an exception that justifies
itself.