Distribution #
Nix is the canonical, reproducible build and the source of every release artifact. The public runtime artifact is the production single-file binary from nix build .#fen; source-checkout development uses that same binary through scripts/dev/fen-dev overlays (see development.md).
Built binaries embed a git/build stamp in fen.version. Run fen --version to see the distributed code identity, for example fen a7ef7f5-dirty (nix, x86_64-linux); the interactive /status panel shows the same line.
Nix artifacts #
| Nix attribute | Target | Linking | Release asset (fen-<tag>-…) | Checks |
|---|---|---|---|---|
.#fen | x86_64 Linux musl | fully static | linux-x86_64-musl-static | fenSmoke, fenNoStoreRefs, fenNoDynamicDeps (canonical dev runtime + default public artifact) |
.#fenSingleStatic | x86_64 Linux musl | fully static | alias of .#fen | same as .#fen |
.#fen-linux-aarch64-musl-static | aarch64 Linux musl | fully static | linux-aarch64-musl-static | QEMU smoke, no-store-ref, no-dynamic-dependency |
.#fen-linux-armv7-musleabihf-static | ARMv7 hard-float musl | fully static | linux-armv7-musleabihf-static | QEMU smoke, no-store-ref, no-dynamic-dependency |
.#fen-linux-armv7-n900-musleabihf-static | ARMv7 N900 (Cortex-A8 NEON/Thumb-2) musl | fully static | linux-armv7-n900-musleabihf-static | QEMU smoke, no-store-ref, no-dynamic-dependency |
Every artifact embeds Fen's Lua module tree and Fen-owned native modules and statically links the bundled liblua, libzip, libcurl, OpenSSL, cjson, LuaSocket, termbox2, fen_http, fen_process, fen_random, and lfs pieces.
The Linux runtime is a single fully-static musl build for every architecture. The binaries have no ELF interpreter, no dynamic NEEDED entries, and no /nix/store references, so they run on any Linux of the matching architecture without a libc runtime floor. HTTPS still needs CA certificate data from the target host, or an explicit SSL_CERT_FILE / CURL_CA_BUNDLE pointing at a PEM bundle. The N900 variant passes -mcpu=cortex-a8 -mfpu=neon -mthumb through the static toolchain.
Extensions depend on pure-Lua rocks only. Static linking has no dynamic loader, so external native Lua rocks (.so modules) cannot be loaded; Fen's own native modules are embedded in the binary. Supply pure-Lua dependencies through the extension rocks tree (LUA_PATH / --dev-path).
Cross artifacts are exposed only from x86_64 Linux.
Docker scratch image #
The flake exposes a tiny scratch image containing the static fen binary, BusyBox, CA certificates, and a writable /tmp. It is useful for smoke tests or trying Fen on a host with Docker without installing the binary.
nix run .#dockerRun -- --help
OPENAI_API_KEY=... nix run .#dockerRun -- --print "say hi"
nix run .#dockerShell
dockerRun builds and loads .#scratchImage, tags it as fen:dev, mounts the current directory at /workspace, sets /workspace as the container working directory, and passes common provider environment variables through. Make aliases are available when you prefer the Makefile frontend:
make docker-run-nix ARGS='--help'
make docker-shell-nix
make docker-smoke-nix
For manual image handling, run nix run .#loadDockerDev or nix build .#scratchImage && docker load < result. The image entrypoint is /bin/fen, so arguments after the image name are Fen arguments. Container state defaults to /tmp; mount config/state directories yourself if you want persistence.
Single-file binary format #
The binary is a native launcher that statically registers the Fen-owned native modules (cjson, LuaSocket core, fen_http, fen_process, fen_random, termbox2, lfs), then appends a deterministic ZIP of the packaged share/lua/5.4 module tree to the launcher ELF. At startup it installs package.searchers entries that load Lua and Fennel modules from that embedded archive, so the archive carries only Lua/Fennel source. fen --help is intentionally lazy-loaded so it does not require JSON, HTTP, TUI, or provider modules before printing usage.
Module lookup maps Lua names to archive paths:
fen.main→fen/main.luafen.core.agent→fen/core/agent.luafen.core.extensions.register.tool→fen/core/extensions/register/tool.lua
Searcher precedence #
package.searchers is set up by the launcher before (require :fen.main) runs, in this order (lower index wins):
package.preload— standard slot, used by tests for module stubbing.dev-path-fennel— compiles.fnlfiles found via--dev-path/FEN_DEV_PATH(consultspackage.pathafterprepend_dev_pathsputs dev-path roots ahead of the floor).flat-extension— installed when--extension-root/FEN_EXTENSION_ROOTis set; resolvesfen.extensions.<snake>[.<rest>]to<extension-root>/<kebab>/<rest>.fnlfor flat-layout extensions.- Standard Lua searchers —
package.path(?.lua/?/init.lua) and the C loaders, including whatever the user set inLUA_PATH/LUA_CPATH. The fully-static release binary has no dynamic loader, so the C loaders resolve nothing; only the pure-Luapackage.pathhalf is reachable in practice. embedded-zip— loads.luafrom the appended archive (the production floor; what runs without any overlay flags).embedded-fennel— compiles.fnlfrom the appended archive (when the embedded module ships as Fennel rather than precompiled Lua).
In short: dev-path overlays beat the embedded archive; LUA_PATH extensions beat the embedded archive but lose to dev-path; package.preload always wins.
Dev overlays #
Two repeatable flags redirect module resolution to a source checkout, each with an equivalent env var:
--dev-path <dir>/FEN_DEV_PATH=<dir>[:<dir>…]— Lua-module overlay root;<dir>/foo/bar.fnlresolves the modulefoo.bar. Used for rock-shaped substrate (packages/core/src,packages/util/src,packages/fen/src).--extension-root <dir>/FEN_EXTENSION_ROOT=<dir>[:<dir>…]— manifest-walking root for flat-layout extensions; each<dir>/<kebab>/with amanifest.fnlis discoverable, andfen.util.flat_extensionsbridgesrequire :fen.extensions.<snake>…back to the flat source.
CLI flags are stripped from argv by the launcher before fen.main sees them; env vars do not affect argv. CLI values apply first, then env values. Extension roots are exposed to the loader as trusted first-party flat overlays, separate from FEN_EXTENSIONS_PATH user roots. scripts/dev/fen-dev wires all of this up for checkout work; see development.md for the iteration loop. Production users without overlay flags fall through to the embedded archive unchanged. The fenOverlaySmoke flake check verifies module overlays, extension-root loading, native-module lookup, and the wrapper against fixtures.
Embedded modules have no ordinary filesystem path. /reload fingerprinting uses package.searchpath, so a module served straight from the embedded archive is treated as distribution-time fixed; hot reload picks up .fnl edits only when the same module is shadowed by a --dev-path / --extension-root overlay.
Standard ZIP tools can inspect the appended archive, with a warning about the ELF prefix:
unzip -l result/bin/fen | head
Building without Nix #
For people who do not have Nix, make fen produces the same single-file binary by linking against the host's Lua and libcurl instead. There is no separate ./configure step: the Makefile probes the toolchain and fetches third-party sources itself, only when a portable goal is the make goal (so make test and friends never shell out to pkg-config or the network). This path is a convenience, not a release path: its binaries are not the published artifacts and carry a make source stamp rather than nix.
make fen # probe toolchain, fetch sources, compile + embed -> build/fen
sudo make install # optional: install to $PREFIX/bin (default /usr/local)
make check-portable # build build/fen and smoke --version/--help/modules
make check-portable-docker # build+smoke the whole apt path in a clean Debian container
make check-portable-docker (needs Docker, set DOCKER=podman to switch) runs the documented apt install … && make fen flow on debian:stable-slim against a read-only copy of the checkout, fetching sources over the network like a real user. It cannot run under nix flake check — that sandbox has no Docker and no network — so it is a standalone CI/maintainer check.
The host must provide a C compiler, pkg-config (to locate system Lua and libcurl), the fennel and zip CLIs, a Lua 5.4 interpreter (used only to build fennel.lua), and system libcurl with headers. On Debian/Raspberry Pi OS that is roughly apt install build-essential pkg-config libcurl4-openssl-dev liblua5.4-dev lua5.4 zip plus fennel (via luarocks install fennel).
make fen resolves the sources the Nix build normally fetches — kubazip, lua-cjson, luafilesystem, LuaSocket, fennel, dkjson, and (when no system Lua 5.4 is found) Lua itself — pinned by version and sha256 into third_party/.cache (gitignored), then reused offline on later builds. Override defaults with make variables: LUA=auto|bundled|DIR, CURL=auto|DIR, FENNEL_LUA=PATH, PREFIX=DIR, CACHE=DIR, and OFFLINE=1 (fetch nothing; fail if a source is not cached). The pinned versions and the native object list live in the Makefile and must track nix/artifacts.nix, which stays the source of truth. The checkPins flake check (run by nix flake check, or make check-pins) fails on version drift; the native object list is guarded by make check-portable failing to build.
The resulting binary links Lua, kubazip, lua-cjson, luafilesystem, LuaSocket, termbox2, fen_http, fen_process, fen_random, and the embedded module ZIP statically, keeping only libc, libm, libdl, and the host libcurl dynamic. It is not the musl-static artifact the Nix build produces; for portable or release binaries, use Nix. fen ext build native-rock support needs LuaRocks, which this build does not embed; the core agent does not require it.
Install script #
scripts/install.sh is a POSIX sh one-liner installer for the prebuilt release binaries, served from the docs site (the docs-publish target copies it to dist/docs/install.sh):
curl -fsSL https://acmiyaguchi.github.io/fen/install.sh | sh
Because the release artifacts are fully-static musl binaries with no toolchain to bootstrap, the script only resolves the target, downloads the matching asset, verifies it, and drops it on PATH — there is no managed toolchain like rustup/uv. Once installed, the binary can refresh itself in place with fen update (see below).
What it does:
- Detects the asset from
uname -s/uname -m:linux-x86_64-musl-static,linux-aarch64-musl-static, or the genericlinux-armv7-musleabihf-static. The N900-tuned build is cortex-a8-specific and is not auto-selected. - Resolves the latest tag by following the
releases/latestredirect (no GitHub API, so nojqand no unauthenticated rate limit). - Downloads
fen-<tag>-<asset>plusSHA256SUMSand verifies the checksum withsha256sum/shasumbefore installing. - Installs to
$HOME/.local/bin/fenand warns if that directory is not onPATH.
Environment overrides: FEN_VERSION=vX.Y.Z pins a tag, FEN_BIN_DIR changes the install directory, and FEN_ARCH=<asset-slug> forces an asset (e.g. linux-armv7-n900-musleabihf-static for the N900-tuned build).
Caveats: the prebuilt binaries are Linux-only — on other platforms build from source (nix build .#fen or make fen). HTTPS at runtime still needs host CA certificates or SSL_CERT_FILE/CURL_CA_BUNDLE as noted above.
Audit-conscious users can skip the script and download directly:
tag=v0.6.2; asset=linux-x86_64-musl-static
base="https://github.com/acmiyaguchi/fen/releases/download/$tag"
curl -fsSLO "$base/fen-$tag-$asset"
curl -fsSL "$base/SHA256SUMS" | grep "fen-$tag-$asset" | sha256sum -c -
install -m 0755 "fen-$tag-$asset" ~/.local/bin/fen
Self-update (fen update) #
fen update replaces the running single-file binary with the latest GitHub release. Because the binary is a C launcher with an appended zip, an update is a whole-file swap, not a partial patch. The flow lives in fen.update (packages/fen/src/fen/update.fnl) and reuses in-tree primitives only — no system curl/sha256sum dependency:
- Refuses anything that is not a tagged release artifact: source/dev checkouts, untagged local builds, and luarocks installs all print guidance and exit non-zero. Only
nix/makebuilds stamped with avX.Y.Zversion proceed. - Detects the asset slug from
uname(same mapping asinstall.sh; honors theFEN_ARCHoverride for the N900-tuned build). - Queries
…/releases/latestvia the GitHub API and comparestag_nameto the running version; an exact match prints "already up to date" and exits 0. - Downloads
fen-<tag>-<asset>andSHA256SUMSthroughfen.util.http(following the asset's CDN redirect manually, since the native transport does not follow redirects), verifies the SHA-256 with the pure-Luafen.util.sha256, then atomically renames the new binary over the running one (the live process keeps its old inode, exactly like the installer'smv -f). - Refuses gracefully when the install directory is not writable (e.g. a read-only Nix store path) — the original binary is left untouched.
The launcher (fen.c) surfaces the resolved executable path as arg.exe so the update can target the right file even when invoked as a bare name found on PATH. Restart fen after a successful update to load the new code.
Releases #
Pushing a version tag matching v* runs .github/workflows/release.yml. The workflow first runs release-targeted native checks (fennelCheck and tests), then builds the supported Linux executables in parallel matrix jobs. The x86_64 job smoke-runs the default static artifact with --help / --version and runs the no-store-reference and no-dynamic-dependency checks; cross jobs run the matching QEMU smoke, no-store-reference, and no-dynamic-dependency checks. A final publish job downloads all binaries, creates SHA256SUMS, and uploads the assets (named fen-<tag>-<asset> per the matrix above, plus SHA256SUMS) to the GitHub Release for that tag.
Maintainer flow:
git tag v0.1.0
git push origin v0.1.0
For a local preflight, build the same checks and artifacts manually:
nix build \
.#checks.x86_64-linux.fennelCheck \
.#checks.x86_64-linux.tests \
.#checks.x86_64-linux.fenSmoke
nix build .#fen \
.#fen-linux-aarch64-musl-static \
.#fen-linux-armv7-musleabihf-static \
.#fen-linux-armv7-n900-musleabihf-static
Run nix flake check before tagging for the full CI surface, including overlay/ext/no-store/dynamic-dependency and cross-QEMU smoke checks. The tag workflow uses a narrower release gate so cold runners do not rebuild every check, and parallelizes architectures; total compute is still dominated by the custom static OpenSSL/curl/Lua builds unless the Nix cache is warm.
Docker smoke #
nix run .#dockerSmokebuilds/loads a scratch-based Docker image and runsfen --help.nix run .#loadDockerDevloads the same image asfen:dev.