Build from source
ScaiFlux ships as a single PyInstaller-bundled executable so end
users don't need Python, pip, or a virtualenv on their machine — just
a compatible libc and they can run ./scaiflux. This document
describes the toolchain that builds that binary from source.
For end users who do have Python, prefer pip install / pipx install of the wheel — it's smaller, faster to install, and easier
to upgrade. The standalone binary is for the no-Python case.
TL;DR#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Prerequisites#
-
Python 3.11+ on the build host.
-
The Python shared library (
libpython3.X.so.1.0). Distros vary:Distro Install command Debian / Ubuntu sudo apt install libpython3.13(substitute your version)Fedora / RHEL sudo dnf install python3-libsArch included by default in the pythonpackagemacOS use the python.org installer, or pyenv install <ver>withPYTHON_CONFIGURE_OPTS="--enable-shared"The build script detects when this is missing and prints the same hint instead of letting PyInstaller fail 80 seconds in.
-
curlandtar(for one-timepatchelfdownload — see "Portability via staticx" below). Both are present on virtually every Linux install. -
No other prereqs. The script creates an isolated build venv at
.build-venv/, installs ScaiFlux + PyInstaller + staticx into it, caches a staticpatchelfbinary at.build-tools/, and never touches the user's main environment. Nosudo, no Docker.
What the build script does#
scripts/build_dist.sh:
- Optionally cleans
build/,dist/,.build-venv/,.build-tools/(--clean). - Creates an isolated build venv (skip with
--no-venv). pip installs the project (minus dev extras), PyInstaller, and staticx into the venv.- Checks for the Python shared library and aborts with a clear hint if missing.
- One-time: downloads
patchelf 0.18+to.build-tools/(needed by staticx; the pip-packagedpatchelfships 0.17 which has a bug that fires on modern-glibc PyInstaller binaries). - Runs
pyinstaller --clean --noconfirm packaging/scaiflux.spec. - Runs
staticxon the PyInstaller output to bundle every shared library dependency (libpython, libc, libm, …) into the binary itself — eliminates glibc-version sensitivity. See Portability via staticx below. Skip with--no-portableif you want a dynamic-only binary for some reason. - Smoke-tests the resulting binary:bash
1 2 3
./dist/scaiflux --version ./dist/scaiflux --help ./dist/scaiflux {setup,serve,doctor} --help - Stamps a versioned + platform-tagged copy, gzips it, and writes a
.sha256next to both raw and gzipped artifacts.
A typical successful build takes 60–120 seconds on a warm cache,
4–6 minutes from cold. The resulting binary lives in
dist/scaiflux-<version>-<os>-<arch> and is in the 25–30 MB range
(stripped + statically linked).
Portability via staticx#
Without staticx, a PyInstaller binary built on Debian 13 (glibc 2.41) fails on Debian 12 (glibc 2.36) with:
1 2 | |
Symbol versions in glibc go forward, not backward — a binary built against newer glibc can't load on older glibc.
staticx solves this by running over the finished PyInstaller binary and:
- Reading its dynamic dependencies via
ldd. - Bundling every
.so(includinglibpython,libm,libc,libpthread, etc.) into a custom archive appended to the executable. - Patching the ELF (via
patchelf) so the binary uses an embedded loader from staticx's own runtime instead of the system's/lib64/ld-linux-x86-64.so.2.
The result is verified via ldd dist/scaiflux reporting
not a dynamic executable. The binary runs on any Linux x86_64
regardless of distro vintage.
Cost: about 1 MB of binary growth and a few seconds of build time.
If for some reason you want the dynamic build (smaller, but host-glibc-locked):
1 | |
The raw PyInstaller output is preserved as dist/scaiflux.dynamic
regardless of --no-portable, so you can always compare.
What's in the spec file#
packaging/scaiflux.spec deserves a quick tour because PyInstaller
isn't drop-in for projects that use dynamic imports.
-
Entry script:
packaging/scaiflux_entrypoint.py— a tiny shim that callsscaiflux.cli:main(). PyInstaller can't analyse a[project.scripts]entry point declaration, so we hand it a real.pyfile. -
hiddenimports: ScaiFlux loads tools, slash commands, and CLI subcommands by name. PyInstaller's static analyser can't follow those, so each module is enumerated explicitly. The spec usescollect_submodules()for big trees (api,auth,runtime,telemetry,plugins,agents,skills) and a hand list fortools/commands/subcommands(small enough to keep visible; safer than catching too much). -
datas: bundles the two non-Python resources ScaiFlux reads at runtime —scaiflux/schemas/scaiflux.schema.json(the.scaiflux.jsonJSON-schema) andscaiflux/cli/working_verbs.txt(the indicator's verb pool). Both loaders resolve adjacent to__file__, which PyInstaller arranges correctly inside the bundle. -
collect_allforpydantic_core,h2,hpack,hyperframe,httpcore— these have C extensions or non-Python data files that PyInstaller needs help finding. -
excludes:mcp,fastapi,uvicorn,pytest, etc. — optional / dev-only deps that would otherwise bloat the binary meaningfully. Users who need the MCP integration can install the wheel instead; the standalone binary's tool catalog is the built-in surface only. -
onefile=True: produces a single executable rather than a directory. Costs a few hundred ms of self-extraction on first run per process; almost always worth it for distribution simplicity. -
upx=False: UPX shaves ~30 % off the binary size at the cost of measurably slower startup. For an interactive CLI we'd rather pay the disk than the perceived latency.
Limits of the bundled binary#
The frozen binary is not a one-to-one substitute for pip install scaiflux in every dimension:
-
No entry-point plugin discovery. Plugins registered via
[project.entry-points."scaiflux.tools"](orcommands/plugins) on the target machine can't be picked up — there's nopip installpath inside the binary. The frozen surface is whatever was compiled intoscaiflux.spec. Plugin authors should ship the wheel instead. -
No MCP client.
mcpis excluded to keep the binary slim; if you need MCP, install the wheel withpip install scaiflux[mcp]. -
OS / arch lock-in. A Linux x86_64 binary won't run on ARM64, macOS, or Windows. Build per-arch / per-OS. (Within Linux x86_64 the staticx wrapping above makes the build host's glibc irrelevant.)
-
First-run extraction. PyInstaller's one-file mode extracts the bundle to
$TMPDIRon first invocation, which adds 200–500 ms to startup. Subsequent invocations reuse the extraction.
Re-building after source changes#
The build script doesn't watch for changes — re-run it after edits.
Use --clean to wipe caches if PyInstaller misbehaves; otherwise
incremental builds are fine and meaningfully faster (~30 s vs 90 s).
CI considerations#
To build in CI:
1 2 3 4 5 6 7 | |
For cross-OS distribution, run the script in a matrix job — one per target OS. PyInstaller cannot cross-compile; macOS binaries must be built on macOS, Windows on Windows, etc. Note: staticx is Linux-only — the macOS / Windows paths produce a regular dynamic binary and may need their own portability story (macOS binaries are generally less version-sensitive thanks to system-wide framework loading; Windows binaries usually need MSVC redistributables on the target).