floxify
You are setting up a Flox environment for an existing software project. This may be someone's first time seeing Flox. Treat this as a first impression. Be fast, transparent, and precise. Start immediately — no greeting, no preamble.
Usage
Primary use case: The developer is already inside their repo — they ran /floxify
from within it, or said something like "floxify this project" or "set up Flox for my
sentry repo." Treat the current working directory as the target unless given a specific
local path. The GitHub URL path exists for exploration but is not the normal workflow.
Core principle: Flox manages system-level dependencies (language runtimes, system
libraries, databases). It does NOT replace pip, npm, cargo, or composer. The
on-activate hook bridges them: flox activate loads the pinned runtime AND runs
pip install or npm install automatically. One command, full environment.
Input: $ARGUMENTS
Delegating to a cheaper model? If you are the parent session deciding
whether to hand this conversion to a cheaper-model subagent, read
references/delegation.md first. If you are the delegated subagent, skip
straight to Phase 0 — the verify gate, not this note, carries correctness.
Phase 0: Setup
Check available tools
Use flox via Bash for all operations. If flox-mcp tools are present in your tool list
(search_packages / mcp__flox__search_packages, init_new_environment, etc.), you may
use them as an alternative to the bash equivalents — but bash is the default.
Check flox availability:
flox --version 2>/dev/null || echo "FLOX_NOT_FOUND"
Note: flox activate may print ! Your FloxHub token has expired — this is cosmetic.
Local activation works fine without it. Do NOT surface this warning in your output.
If the user asks, tell them: flox auth login silences it permanently.
If flox is not installed: generate the manifest content and print it with exact
instructions. Install URL: https://flox.dev/docs/install-flox/install
Then: flox init && flox activate
Resolve the target
Parse $ARGUMENTS and assign TARGET_DIR:
ARGUMENTS="$ARGUMENTS" # the skill input
if [ -z "$ARGUMENTS" ]; then
TARGET_DIR="$(pwd)"
elif echo "$ARGUMENTS" | grep -q '^https://github.com/'; then
# GitHub URL — clones into the current directory, same as if you'd run git clone yourself
REPO_NAME="$(echo "$ARGUMENTS" | sed 's|.*/||')"
TARGET_DIR="$(pwd)/$REPO_NAME"
if [ -d "$TARGET_DIR" ]; then
echo "$REPO_NAME/ already exists here. cd into it and run /floxify with no arguments."
exit 1
fi
echo "Cloning $REPO_NAME into $(pwd)/$REPO_NAME ..."
git clone --depth=1 "$ARGUMENTS" "$TARGET_DIR" 2>&1
if [ $? -ne 0 ]; then
echo "Error: git clone failed. Check the URL, your internet connection, and repo access."
exit 1
fi
elif [ -d "$ARGUMENTS" ]; then
TARGET_DIR="$(realpath "$ARGUMENTS")"
elif [ -f "$ARGUMENTS" ]; then
echo "Error: '$ARGUMENTS' is a file, not a directory."
exit 1
else
# Natural language or unrecognized — treat as hints, use cwd
TARGET_DIR="$(pwd)"
fi
PROJECT_NAME="$(basename "$TARGET_DIR")"
Print this immediately after TARGET_DIR is set:
Scanning <project-name>/ detecting runtimes, services, and build tools...
Check for existing Flox setup
test -d "$TARGET_DIR/.flox" && echo "HAS_FLOX" || echo "CLEAN"
If HAS_FLOX: Switch to audit mode — do NOT initialize or modify anything.
Read references/conversion-modes.md § Audit Mode and follow it.
If devbox.json exists (and no .flox/): Switch to DevBox conversion mode.
Read references/conversion-modes.md § DevBox Conversion Mode and follow it.
If flake.nix or shell.nix exists (and no .flox/):
Ask:
This project uses Nix directly. Options:
1 Audit only — show what Flox would add without touching anything
2 Set up Flox alongside it — they coexist fine
3 Skip
Which? (default: 1)
If clean (.flox/ absent, no Nix files): Continue to Phase 1.
Phase 1: Read the project
Ground every version and service in a file — never guess. Run the bundled analyzer first (Step 1a): it reads the pin files, lockfiles, and docker-compose services deterministically. Then read the high-signal files yourself (Step 1b) for nuance it only summarizes.
Step 1a — Run the grounded analyzer (do this first)
The analyzer ships with this skill at scripts/detect.py (next to this
SKILL.md). Run it through Flox so you don't depend on a system Python — this is
also the fastest way to run a one-off script. Save its output alongside
printing it — Phase 3c's verify.py re-reads these same facts to check the
manifest you eventually write against them:
DETECT_JSON="/tmp/floxify-detect.json" # one floxify run at a time; fine to reuse
"<skill-dir>/scripts/flox-python.sh" "<skill-dir>/scripts/detect.py" "$TARGET_DIR" | tee "$DETECT_JSON"
<skill-dir> is this skill's own directory — the folder that holds this
SKILL.md (the same place you'd read scripts/ or a reference file from). Use
its absolute path. $DETECT_JSON is a fixed path — remember it verbatim for
Phase 3c, since each Bash call starts a fresh shell and cannot inherit a
variable from this step.
- If
flox runerrors with an unknown-subcommand or usage message, the user's Flox predates 1.13 (flox runshipped in 1.13). Tell them once, plainly: "Your Flox is older than 1.13, so I can't use the fast analyzer — upgrade to getflox run: https://flox.dev/docs/install-flox/install". Then fall back topython3 "<skill-dir>/scripts/detect.py" "$TARGET_DIR" | tee "$DETECT_JSON"if apython3is on PATH, and if neither works, scan manually (Step 1b) and skip the$DETECT_JSONfile entirely — Phase 3c's verify.py runs with reduced coverage (no detect facts to cross-check) but never blocks on a missing file. The analyzer is an accelerator, not a hard dependency — never block on it.
The analyzer prints one JSON object; every fact carries the file it came from:
runtimes— each version pin with itssourcefile. Use these versions verbatim in Phase 2 — do not round, bump, or substitute a version from memory. If your recollection disagrees with asource-tagged fact, the file wins.package_managers— bundler / pnpm / uv / poetry versions read from lockfiles (e.g.Gemfile.lockBUNDLED WITH,packageManagerfield).services— docker-compose services withimage,tag, akindguess, and aconfig_coupledflag (the service mounts config volumes ordepends_onothers — a hint it may not reduce to a single catalog package).service_clients/native_hints— client libraries (pg,psycopg2, …) and apt deps mapped to catalogsearch_terms. These are terms to VERIFY in Phase 2 withflox search/flox show— never paste asearch_termin as apkg-path.orchestrators,monorepo,lockfiles,notes— context for Phase 3.
Step 1b — Read the high-signal files for nuance
The analyzer covers the deterministic pins and services; you still read the
files below for what it only hints at (Dockerfile FROM/RUN specifics, CI
apt-get lines, README setup steps, monorepo layout) and to print the
recognition lines.
Print each file as you read it — immediately, one line per file, as processed:
.python-version python 3.11
.nvmrc node 20.11.0
.github/workflows/ci.yml python 3.11 · postgres:15 in services
docker-compose.yml postgres:15 · redis:7
requirements.txt psycopg2 · celery · redis → 503 pip packages
package.json react 18 · typescript → npm install
This real-time output is the key trust-building moment — the developer recognizes their own project as each line appears. Do not buffer — print as each file is read.
Files to scan (priority order — higher sources win for version numbers):
.devcontainer/devcontainer.json— full conversion:image,features,postCreateCommand,containerEnv(seereferences/conversion-modes.md§ Dev Container Full Conversion)devbox.json— if present, handled viareferences/conversion-modes.md§ DevBox Conversion Mode (skip to Phase 3)Brewfile—brew "name"lines mapped to Flox catalog (seereferences/conversion-modes.md§ Brewfile Conversion Mode).github/workflows/*.yml—setup-node/setup-python/setup-goaction version values;services:blocks with image names and versions;apt-get install -ylines.gitlab-ci.yml—image:field for runtime hints;before_scriptapt-get installs.circleci/config.yml—image:and orb version paramsDockerfile/Dockerfile.dev—FROMline runtime+version;RUN apt-get installdocker-compose.yml/docker-compose.dev.yml— service images with versions.nvmrc,.node-version— exact Node version.python-version— exact Python version.tool-versions— asdf/mise multi-runtime pins (supports all languages).mise.toml— mise multi-runtime pins; same format as.tool-versionsrust-toolchain.toml— Rust channel (stable/nightly/1.x)go.mod— Go version fromgo 1.21directiveglobal.json— .NET SDK version (sdk.versionfield)mix.exs— Elixir version from@minimum_otp_versionorelixir: "~> X.Y"build.sbt— Scala version fromscalaVersion := "X.Y.Z"pubspec.yaml— Dart/Flutter (environment.sdkconstraint;flutter:key = Flutter)build.zigorbuild.zig.zon— presence signals Zig projectPackage.swift— presence signals Swift project;swift-tools-versioncommentpyproject.toml—requires-pythonconstraint; package names for service signalsrequirements.txt,Pipfile— package names for service signals onlyenvironment.yml— Conda env; extractdependencies:for system-level signalspackage.json—engines.node,packageManager,volta.nodefields; service signalsCargo.toml— presence ofbuild.rs, native dep hints.env.example,.env.sample— confirm services; flag required-but-unset vars
Never scan: .venv/, venv/, node_modules/, __pycache__/, .tox/, vendor/,
.git/, dist/, build/, target/, .next/, coverage/, .cache/
While scanning manifests (requirements.txt, pyproject.toml, package.json, etc.),
note any database or service client packages (psycopg2, redis, pymysql, pymongo,
pg, ioredis, celery, cryptography, lxml, Pillow, etc.). These are NOT
installed via Flox themselves — they signal system-level dependencies to resolve in Phase 2.
After reading all files, print a detection summary with source attribution:
Found: Python 3.11 (← .python-version) · Node 20.11.0 (← .nvmrc) · PostgreSQL · Redis (← requirements.txt)
Phase 2: Resolve packages in the Flox catalog
Search first — never assume catalog names. The catalog evolves; hardcoded guesses go stale. For every runtime, library, and tool detected in Phase 1, search the catalog:
flox search --all "<term>" 2>/dev/null | head -5
# or if flox-mcp is present: search_packages(search_term="<term>", limit=5)
Batch all searches silently, then print one clean resolution table. Fire all
independent lookups in parallel (e.g. all system libs in one batch, runtimes in another),
suppress the raw flox search output entirely, collect the results, and only then print
the resolution table. Never stream raw search output — it floods the terminal and makes
the output jarring.
Resolving packages...
node 22 → search "nodejs 22" → nodejs_22 ✓
python 3.12 → search "python 3.12" → python312 ✓
postgresql → search "postgresql 16" → postgresql_16 ✓
wkhtmltopdf → search "wkhtmltopdf" → no match ✗
Search term strategies
- Node.js: search
"nodejs <major>" - Python: search
"python <major.minor>" - Go: search
"go <major.minor>" - PostgreSQL: search
"postgresql <major>"not"postgres"— catalog name differs - Rust: search
"cargo"and"rustc"separately; also"clippy"and"rustfmt"for dev tooling - Elixir: search
"elixir"only — Erlang/OTP is bundled; do NOT search "erlang" separately - PHP: search
"php <major.minor>"; the catalog exposes PHP as a versioned package (verify the exact name withflox show). Extensions come from aphpvariant, not separateext-*packages — resolve what you can and list the rest in ✗ - Deno: a
deno.json/deno.jsoncor a*-edge-runtimecompose image (e.g. Supabase edge functions) means a SECOND runtime — search"deno"and pin it alongsidenodejs. A monorepo that pins only Node silently drops the edge-functions runtime - Flutter: search
"flutter"only — Dart SDK is bundled; do NOT search "dart" separately - Mise / asdf (
.mise.toml/.tool-versions): eachkey = "version"is an independent search;python = "3.12.3"→ search"python 3.12". These patch pins routinely sit AHEAD of the catalog — resolve the major/minor, and if the exact version isn't available, fall back to the language manifest's floor (mix.exs "~> 1.18",go.mod,requires-python) and note the gap; never force a nonexistent exact pin. If the exact patch IS available, emit<id>.versionfor it — see "Emitting an exact pin" below - Conda (
environment.yml): search top-leveldependencies:binaries only; skip- pip:entries (handle via uv) - Volta (
"volta": {"node": "22.4.0"}): search"nodejs 22" - packageManager field (
pnpm@9.x,yarn@4.x): search the package manager name; when the pin is an exact version, see "Pinned package manager" and "Emitting an exact pin" below for whether it resolves directly, needs corepack, or (Yarn Berry) self-delegates via.yarn/releases/ bun.lockbpresent: search"bun"and use it instead of nodejs
Picking from search results
Prefer the versioned pkg-path over a version range — nodejs_22, not
nodejs + version = "^22.0". The catalog encodes the major version in the
name, and a versioned name is both more precise and verifiable, where a range
is neither. Add <id>.version only for an exact patch the repo itself pins
("Emitting an exact pin" below). The flox skill's [install] version
section is canonical for the full preference ladder and the reasoning; this
skill states the operational half.
- Prefer the most specific versioned name (
python312overpython) - For unversioned tools, pick the plain name (
redis,cmake,jq) - If ambiguous, read the description in results to confirm intent
- If no match after 1–2 attempts: add to ✗ section, don't install
Reading flox show correctly
Wrong catalog claims in review (AI-451's false-hallucination accusation,
AI-455's per-system misses) all traced to one root cause: reading only the
Latest: headline and stopping. flox show <pkg-path> prints far more —
read all of it before asserting anything about a package:
- Read the FULL version list, not just
Latest:. TheOther versions:block is the actual catalog, often 20+ entries deep. A version the project pins (elixir_1_19@1.19.5, say) can be correct and present even when it isn't the newest entry — accusing it of being hallucinated because it doesn't matchLatest:is exactly AI-451's mistake. Scroll the whole list before concluding a version doesn't exist. - Check the systems annotation for the SPECIFIC version you're pinning,
not the top-of-file
Systems:line. That top line describesLatest:only. EachOther versions:entry carries its own systems: no parenthetical means all four platforms;(sys1, sys2 only)restricts it to exactly those. A version can lose (or never have had) a build for a platform the top-line summary doesn't reflect — e.g.nodejs_24's newest build lacksx86_64-darwineven when older 24.x builds have it. Ifoptions.systems(or the package's ownsystemsoverride) declares a platform, verify the PINNED version's own parenthetical covers it —verify.py's catalog check (Phase 3c Step 4) automates exactly this, but read it yourself here too rather than relying on it to catch a bad pin after the fact. A per-system availability hold discovered here is one of the two legitimate recorded-reason categories in "Version-pinning discipline" below — record it the same way. - Query the versioned
pkg-pathdirectly — never infer a ceiling from the bare name.flox show <versioned>(flox show ruby_4_0,nodejs_24,go_1_23,python313) is authoritative for a pinned runtime; the bare name may report a lower ceiling belonging to a different catalog entry — sometimes a whole major behind, asflox show ruby(3.4.x) is behindruby_4_0(4.x) — and trusting it silently downgrades the runtime. Thefloxskill'sversionsection carries the fuller worked example. Verify live whether the versioned page reaches the repo's exact patch or only the nearest prior one — same live-verify discipline as "Emitting an exact pin" below; the catalog moves forward, so don't trust a number cited elsewhere in this guidance over today'sflox show. Search the versionedpkg-pathfirst; fall back to the bare name only for genuinely unversioned tools. - When the question is package CONTENTS (does this build include a given
extension/module?), don't infer it from the name — execute it.
flox showdescribes outputs and versions; it does not enumerate what's compiled into a package.flox run -p php85 -- php -mlists PHP's actual loaded modules; the equivalent for any interpreter/toolchain is to run it and ask, not to guess from the catalog description.
The version-string format matters too: some catalog entries carry a package-
specific prefix that doesn't match the bare version a human would write
(python313's versions read python3-3.13.13, not 3.13.13) — pin the
exact string flox show prints, not a normalized guess. verify.py's
catalog check treats a mismatched prefix as a real, non-resolving pin,
because it is one.
Version mismatches: If the catalog is one patch version behind the project's pin
(e.g. project pins node 24.14.0, catalog has 24.13.0): install the closest available,
note the mismatch in source attribution (← .nvmrc (project pins 24.14.0; catalog has 24.13.0)).
Only add to ✗ if the major or minor version differs — patch mismatches rarely cause issues.
Services and system dependencies
Only install these when docker-compose does NOT already manage them.
| Detected in manifests | Search for | Flox catalog name |
|---|---|---|
psycopg2 (non-binary) |
"postgresql" + "pkg-config" + "openssl" |
postgresql_16, pkg-config, openssl |
psycopg2-binary, psycopg, pg (npm) |
"postgresql" |
postgresql_16 |
redis, ioredis, celery |
"redis" |
redis |
pymysql, mysql2, mysql-connector-python |
"mariadb" |
mariadb |
pymongo, motor, mongoose |
"mongodb" |
mongodb-ce |
cryptography, cffi, bcrypt, pynacl |
"pkg-config" + "openssl" |
pkg-config, openssl |
lxml |
"libxml2" + "libxslt" |
verify names with flox search |
Pillow, PIL |
"libjpeg" + "zlib" |
verify names |
fluent-ffmpeg, ffmpeg-static, @elastic/elasticsearch |
"ffmpeg" / "elasticsearch" |
ffmpeg, elasticsearch |
Other services in catalog (no specific dependency signal): rabbitmq (RabbitMQ).
HARD FLOOR — every leaf datastore the app needs at runtime gets a
[services.*] block. If the app will not run without a datastore — its
config, .env.example, or your own [vars] name a DATABASE_URL /
REDIS_URL / host+port — then that datastore MUST be installed and wired
as a Flox service. Not "consider", not "prefer". A manifest that advertises an
endpoint nothing serves is broken: flox activate exits 0 and the developer
still has no database. If you are about to emit [vars] pointing at a
datastore with no matching [services.*], stop — that is the bug.
The repo already having a way to start it is NEVER a reason to defer. A
scripts/start_dev_db.sh, a make postgres target, a docker run recipe, a
compose service, a README step — every project has one of these. That manual
step is the reason floxify was invoked; it is not an orchestrator to hand the
work back to.
Launcher intricacy is not a licence either. When the repo's launcher does
fiddly setup — a cluster under ./target, a unix socket inside the repo tree, a
percent-encoded socket path in DATABASE_URL, loading db/schema.sql — read
it and port those steps into the service command and hook. That is the work.
"The script does something clever I can't reproduce" is an argument for reading
it more carefully, not for leaving the developer with nothing. If one detail
genuinely cannot be reproduced, wire the service anyway and note the divergence
in ⚠.
Deferring also cascades into the rest of the manifest. Because lemmy's
start_dev_db.sh puts its cluster under $PWD/target, deferring to it dragged
CARGO_TARGET_DIR into the repo tree as well — one deferral, two defects.
When the app needs a leaf datastore (the HARD FLOOR above), read
references/service-patterns.md for the PostgreSQL (socket-default) and
Redis (TCP+socket) manifest patterns before wiring [services.*].
Catalog presence does NOT mean "wire it as a Flox service." The floor above
covers the leaf datastores the app depends on directly (usually postgres,
redis, mariadb). Everything else is a judgement call: a service can exist in
the catalog and still be the wrong thing to run as a bare [services.*]. Defer
a non-leaf service to docker-compose or the project's own orchestrator when
any of these hold:
- the analyzer flags its compose service
config_coupled— it mounts server config files ordepends_onother services (a bare package can't reproduce that), - it's reached only transitively, through another service's dependency graph, or
- it's a customized image (e.g.
supabase/postgresships extensions that stockpostgresqllacks — note the caveat and wire stock postgres for plain dev only).
ClickHouse and Kafka ARE in the catalog now, but PostHog's ClickHouse mounts
server config and depends on kafka/zookeeper, and Sentry's ClickHouse/Kafka
arrive transitively through snuba's devservices graph — both belong to their
project's orchestrator, not a Flox [services.*]. Start them via docker-compose
(install docker-compose, bring them up in the hook when Docker is available)
or hand off to the orchestrator, and say so in ⚠ — never hallucinate a catalog
package for them, and never silently drop them. Truly-absent-from-catalog:
Zookeeper, Cassandra. For Temporal: try flox search temporal-cli first.
Native C-extension system libraries often live in the Dockerfile, Aptfile,
or Brewfile — not the language manifest. The analyzer scans Dockerfile
RUN apt-get install and Aptfile lines for these and maps them to catalog
search terms; still confirm each with flox show. Mastodon's vips / ffmpeg /
icu / libidn are in its Aptfile + Dockerfile, not the Gemfile — and ffmpeg
never appears as a gem at all. Watch the specific-variant gotchas: idn-ruby
needs GNU libidn v1 (libidn), not libidn2; charlock_holmes links system
ICU (icu).
In a multi-stage Dockerfile, attribute each RUN apt-get install to its
FROM … AS <stage>. Packages installed in a builder stage are build deps;
packages in the runner stage are runtime-only and do NOT imply a build input.
Lemmy's runner-stage libssl-dev does not mean openssl is a build dep — its
Cargo.lock has no openssl-sys. Don't promote a runner-stage lib to [install]
on the strength of an apt-get line alone.
A native library's outputs are not always installed by default — check
before assuming headers or a shared lib are present. flox show <pkg-path>
prints an Outputs: line (dev, doc, jit, lib, man*, out*, ... — * marks
what installs by default). When a package feeds a native build — a
C-extension gem (ruby-vips, charlock_holmes), a Rust *-sys crate
(pq-sys), a Python native wheel built from source (non-binary psycopg2,
lxml) — the compiler needs headers that live in the dev output, and that
output is frequently NOT starred as default. Worse, a package's default
outputs can omit the piece the build actually links against: vips'
Outputs: line is bin*, dev, man*, out — out (which holds libvips.so)
is NOT starred, so a plain vips.pkg-path = "vips" install is missing the
shared library a native build needs. When you find this, add
<id>.outputs = ["out", "dev"] (or "all") to the [install] entry rather
than assuming the default set is enough. verify.py's outputs heuristic
(Phase 3c Step 4) flags exactly this shape as an ADVISORY note when the
analyzer's native_hints name a package with no outputs declared — read
the note, but the underlying check (flox show <pkg-path>'s Outputs:
line) is the same one you'd run by hand.
Build tool signals
| File or pattern | Search for |
|---|---|
CMakeLists.txt |
"cmake" + "gcc" + "pkg-config" |
Cargo.toml with build.rs |
"pkg-config" + "gcc" |
Makefile with $(CC) |
"gcc" + "gnumake" |
jq in CI/scripts |
"jq" |
curl in CI/scripts |
"curl" |
CMakeLists.txt: scan all pkg_check_modules and find_package calls — each one
is a potential Flox package. For each dep, search the catalog and apply the
platform-conditional rule above: cross-platform deps get no systems filter,
platform-specific deps get the right systems scope. Do not silently drop any dep —
if it's in the catalog, add it; if it's not, put it in ✗. The same logic applies to
Makefile targets, Dockerfile RUN apt-get/brew install lines, and any other
build-system dep declaration you encounter.
Curb dev-tooling scope creep — install for BUILD and RUN, not CI parity.
[install] exists to make the project buildable and runnable, plus its
declared native chain (Dockerfile/Aptfile/*-sys crates and the like) — not
to replicate everything a CI matrix, Makefile, or deferred script happens
to invoke. A judge reviewing a live floxify run called an over-broad
install list "defensible CI parity, but none substitute for the missing
service" — CI parity is not the goal; a working local build+run is.
The carve-out that keeps this from over-correcting: toolchain-standard
lint/format tooling is in scope; third-party auxiliary tooling is opt-in.
Lemmy's golden (evals/floxify/expected/lemmy.toml) installs seven
packages total — cargo/rustc/postgresql_18/pkg-config/gcc build
and run lemmy_server directly, and clippy/rustfmt round out the seven
because they're the Rust toolchain's OWN lint/format tools (driven by
.woodpecker.yml's cargo clippy/cargo fmt steps and .rustfmt.toml,
per lemmy-notes.md) — installing them is installing more of the same
toolchain, not scope creep. A third-party formatter or linter with no
toolchain relationship to the runtime being installed (a standalone binary
like taplo, typos, or shfmt) is a different case — CI-only, opt-in,
and belongs in the conversion report rather than [install].
- CI-only third-party lint/format tooling (
taplo,typos,shfmt,pgformatter, and similar standalone tools with no toolchain relationship to the runtime you're installing) is opt-in per the carve-out above — mention it in the conversion report instead of adding it to[install], so the developer can bring it in themselves. - Don't install
gitas an env dependency unless a build step genuinely shells out to it — abuild.rsthat clones a dependency, or agit+source inCargo.lock/the lockfile. Lemmy has zerogit+sources inCargo.lock(confirmed in lemmy-notes.md) and modern cargo uses the sparse crates.io index, not git — a CI runner installinggitto check out the repo, or a Dockerfile installing it in the runtime image, is not evidence the BUILD needs it — same builder-vs-runner-stage distinction as the Dockerfile rule above. - A tool referenced only by a script the Flox service replaces doesn't
carry over. Lemmy's
jqexisted solely to URL-encode a socket path inscripts/start_dev_db.sh(per lemmy-notes.md); once[services.postgres]wires the DB directly (the hard floor above), that script — and its dependency — is no longer in the loop. Don't install a deferred script's own dependencies once you've stopped deferring to it.
Custom service orchestrators
This section is about services the orchestrator genuinely owns — never the
leaf datastores. A Tilt/Skaffold/k8s topology, or a store reached only through
another service's graph, belongs to the orchestrator. A plain postgres that a
devservices/config.yml, Makefile target, or shell script happens to launch
does not — that is a leaf datastore and the hard floor above applies: wire
it. Sentry is the worked example: devservices owns snuba → ClickHouse/Kafka
(defer those), but shared-postgres/shared-redis are direct leaf deps and
must still be wired as Flox services. Do not read "the project has an
orchestrator" as "the project's datastores are not my problem."
If the project uses a custom tool to manage its non-leaf services and there is
no docker-compose.yml at the root, do NOT try to wire those via docker-compose.
List them in ⚠ with the tool name and the command to start them. Don't claim these are
a gap.
When there's no root docker-compose.yml, the service topology usually lives
elsewhere — probe before concluding a project has no services:
devservices/config.yml (Sentry), compose.yaml / compose.yml, Procfile /
Procfile.dev, .devcontainer/, devenv/, Tiltfile, and dev targets in the
Makefile. Sentry's entire postgres/redis/clickhouse/kafka topology is
invisible if you only look for docker-compose.yml. The analyzer surfaces the
common orchestrators (orchestrators field) and any compose*.yml it finds.
| Signal | Orchestrator | What to say in ⚠ |
|---|---|---|
devservices/ directory |
Sentry devservices | managed by devservices — run: devservices up |
Tiltfile |
Tilt | managed by Tilt — run: tilt up |
skaffold.yaml |
Skaffold | managed by Skaffold — run: skaffold dev |
devspace.yaml |
DevSpace | managed by DevSpace — run: devspace dev |
k3d-*.yaml or .k3d/ |
k3d (local k8s) | managed by k3d — run: k3d cluster start |
ctlptl config |
ctlptl | managed by ctlptl — run: ctlptl apply |
For Tilt/Skaffold/DevSpace/k3d projects: do NOT install docker-compose via Flox. Flox's role is the developer toolchain (runtimes, CLI tools) — the orchestrator owns services.
Services deferred to docker-compose — wire the hook
Applies to services you are NOT wiring as [services.*]: the genuinely
absent-from-catalog ones (Zookeeper, Cassandra) and the present-but-coupled
ones deferred by the rules above (e.g. ClickHouse, Kafka). Wire them so
flox activate still starts everything:
- Install
docker-composevia Flox (it IS in the catalog) - Add an on-activate hook that starts those services if Docker is available
[install]
docker-compose.pkg-path = "docker-compose"
[hook]
on-activate = '''
if command -v docker >/dev/null && docker info >/dev/null 2>&1; then
docker-compose up -d 2>&1 | tail -5 >&2
else
echo "⚠ Docker not running — start Docker Desktop then re-activate" >&2
fi
'''
For selective startup: docker-compose up -d clickhouse kafka
Report these in ⚠ (neutral): <service> starts via docker-compose on activate — requires Docker Desktop running
Verify each package
flox search --all "<pkgname>" 2>/dev/null | head -10
# or if flox-mcp is present: search_packages(search_term="<pkgname>", limit=10)
Print one line per package:
Checking catalog...
python311 ✓
postgresql_16 ✓
redis ✓
clickhouse – (docker-compose, correct)
wkhtmltopdf ✗ not found → try: flox search weasyprint
- Exact match → install, show ✓
- Close match with different name → install the actual name, note it in report
- No match → do NOT install; list in ✗ section with
flox search <name>as next step - Docker-managed → show – (not a failure)
Phase 3: Build the environment
3a. Initialize
init_new_environment(environment_dir="<absolute-target-dir>")
# or: cd "$TARGET_DIR" && flox init --no-auto-setup
--no-auto-setup skips interactive prompts since we write the manifest ourselves.
If this flag is unsupported, flox init works too — we overwrite the manifest next.
3b. Write .flox/env/manifest.toml
Write the complete manifest directly. Use only the validated patterns below. Do not invent syntax.
Manifest rules:
schema-version— use whateverflox initgenerated (e.g."1.12.0"); don't hardcode"1"- Always add
# Generated by /floxifyon the line immediately afterschema-version - Omit sections that have nothing in them
- Package format:
<install-id>.pkg-path = "<catalog-name>"(one entry per line) [vars]values are LITERAL STRINGS —$HOMEis the literal text "$HOME", not your home dir- Dynamic values (computed paths, conditionals) belong in
[hook] on-activate $FLOX_ENV_CACHE— per-project local cache, not pushed to FloxHub; use for venvs$FLOX_ENV_PROJECT— the project root directory[hook] on-activateruns as Bash; its output goes to stderr[profile]scripts are sourced into the user's interactive shell — keep fast, use for venv activation- Services: each service is
[services.<name>]withcommand = '...'on its own [hook] on-activateand[services.*] commanduse literal strings —'''…'''for multi-line,'…'for one-line — never TOML's basic"""…"""/"…"strings (see "TOML string types" below)
TOML string types — literal, not basic, for shell content. Basic
strings ("""…""", "…") are escape-processed: a shell line-continuation
backslash, or a literal \d/\. inside a path or regex, gets consumed as a
TOML escape sequence before the shell ever sees it, silently truncating or
corrupting the command. Literal strings ('''…''', '…') leave backslashes
and $ completely inert, so the shell script reads exactly as written.
Every [hook] on-activate and [services.*] command this skill's own
patterns emit uses '''…''' for this reason — e.g.
evals/floxify/expected/node-postgres.toml's on-activate and command blocks
are both '''…''' (that file's postgres service still uses the old TCP
default this same PR replaces — cited here only for its string type, not
its socket/TCP shape; see the PostgreSQL pattern in
references/service-patterns.md for the current default). Not every expected/*.toml reference
follows the literal-string rule yet — firefly-iii.toml, lemmy.toml, and
supabase.toml still carry a basic-string block each — that gap is a
separate, pre-existing golden defect (tracked outside this guidance-only
change), not something this rule claims is already universal.
Pkg-group economy — fewest groups possible is a first-order goal. Every
distinct pkg-group is a distinct catalog page, and every page downloads its
own full transitive closure down to libc — an extra group is potential
duplicated download cost, not just an organizational nicety. It also forfeits
version coherence for compiled extensions: a runtime's C extensions compile
against the headers on its OWN page and load libraries from it at runtime, so
splitting a runtime from the native libraries its extensions link against
risks a version mismatch between the two pages that the activation smoke test
cannot catch (nothing there loads the extension and checks the symbols
resolve).
When pins cannot co-resolve in one group ("constraints for group 'X' are too
tight" from flox activate), work the escalation ladder in order — do not
jump straight to isolating a package:
- FIRST — pin the toolchain, unpin the libraries. Keep the top-level
runtime/toolchain pinned exactly (that's usually the one with a real
provenance source —
.nvmrc,rust-toolchain.toml,requires-python) and drop the exactversionpin on the OTHER packages that must stay compatible with it, letting them float within the same group. This is the cheapest fix and preserves the single-group economy. The final user-facing report MUST carry a caveat naming the libraries left unpinned for compatibility (Phase 4 "Installs" or a dedicated report line) — an unpin is a real trade-off the developer should see, not a silent workaround. - SECOND — split along dependency seams. If step 1 still doesn't co-resolve, split by seam, not by package: a runtime and ALL of its native build deps move together as one cluster into their own group, never separated from each other. A second runtime with no native-linkage coupling to that cluster (e.g. a Node frontend build alongside a Ruby backend with C-extension gems) can get its own group without forfeiting anything, since there's no ABI relationship to protect.
- LAST — isolate a single package. Only when co-resolution has demonstrably failed even along a dependency seam AND the package has no native-linkage coupling to anything else in the manifest (the diesel-cli shape — a standalone tool built with its own feature flags, sharing no native ABI surface with the rest of the manifest) does it get isolated alone. This is the most expensive rung: it guarantees a dedicated closure download for that one package.
Every non-default pkg-group gets a comment recording the demonstrated
failure that forced it, date-stamped — not "these might conflict," but "flox
activate confirmed X" with the date the resolution was tested:
[install]
# 2026-07-17: `flox activate` failed ("constraints for group 'toplevel' are
# too tight") with ruby_4_0 + postgresql_14 + vips + icu + libidn all in
# toplevel. Fix: keep them together in one group (ABI-coherent — ruby's
# C-extension gems compile against these) rather than isolating ruby alone.
ruby.pkg-path = "ruby_4_0"
ruby.pkg-group = "runtime-and-native"
postgresql.pkg-path = "postgresql_14"
postgresql.pkg-group = "runtime-and-native"
vips.pkg-path = "vips"
vips.pkg-group = "runtime-and-native"
Version-pinning discipline — pin only when necessary. Pins keep an environment historical and reproducible, but continuous upgrade (the catalog moving forward under an unpinned package) is a core Flox benefit that an unnecessary pin forfeits. Default to unpinned; add a pin only when something in the repo, or the catalog itself, requires it.
Gradation, from least to most consequential — treat each step up as needing more justification, not more syntax:
>=floors are cheap and safe — they express a minimum without freezing the ceiling.<=ceilings likely encode a deliberate compatibility decision someone made — introduce them with care, and say what they're protecting against.- Exact pins (
version = "24.18.0") are the most consequential — doubly careful, since they freeze the package to one catalog entry.
Every pin the skill writes carries its recorded reason — the requirement is the recording, not pin abstinence. Legitimate reasons include:
- A repo-derived pin:
rust-toolchain.toml,.nvmrc/.node-version,packageManager(see "Pinned package manager" above),.python-version,requires-python. - A per-system availabili
…(truncated)