Instant Nix dev environments. simmer evaluates a project's Nix dev shell once, caches the finished environment, and replays it into fish, bash, or zsh at every prompt in a few milliseconds — no bash, no nix, no subprocess on the hot path. When a watched file changes, the next prompt rebuilds the environment in the foreground, with a live progress view, and you get the fresh result before you type your next command.
It is a standalone replacement for the direnv + nix-direnv load path, and
it reads existing .envrc files, so a project needs no changes to use it.
~/src/app $ cd .
⠼ warming ~/src/app 4.1s 1 running · 2 done · 0 failed · 0 queued
⠼ nix build 3.9s building 'dev-shell.drv'
│ unpacking sources
│ configuring
│ building
- Fast where it matters. The per-prompt check is a stat of the watched files and one string compare. Entering a project applies a cached script. Measured numbers are in Performance.
- Correct invalidation. An environment is keyed by a content hash of the
files that define it (
flake.nix,flake.lock,.envrc, your extrawatchlist). Edit one and the next prompt rebuilds; touch nothing and nothing runs. It does not depend on Nix's eval cache, which a dirty git tree silently disables. - It waits, and tells you what it is doing. A rebuild blocks the prompt with a live view (or plain lines in CI, or JSON for tools), propagates a failing step's exit status, keeps the failing step's log, and stops every process it started when you press Ctrl-C.
- Safe by default. Nothing in a project runs until you
simmer allowit. - Works with what you have.
.simmer.toml, a bareflake.nix, or an existing.envrcall work. Editors that calldirenvcan be pointed at simmer through adirenv-named shim.
simmer needs Nix with flakes enabled
(experimental-features = nix-command flakes), bash, and Linux or macOS.
With Nix:
nix profile install github:seryl/simmeror, in a flake-based NixOS or home-manager configuration, add
github:seryl/simmer as an input and put
inputs.simmer.packages.${system}.default in your packages.
With Cargo (Rust 1.82 or newer):
cargo install --locked --git https://github.com/seryl/simmer simmerEither way you get two binaries: simmer, and simmer-daemon (the
optional pre-warm daemon, started with simmer daemon).
-
Hook your shell (once):
# fish — in ~/.config/fish/config.fish simmer hook fish | source # bash — in ~/.bashrc eval "$(simmer hook bash)" # zsh — in ~/.zshrc eval "$(simmer hook zsh)"
-
Allow a project. In a directory with a
flake.nix(or.envrc, or.simmer.toml):cd ~/src/app simmer allow
Entering a project you have not allowed prints a one-time hint saying so.
-
That's it. The next prompt builds the environment (with progress) and loads it. Leave the directory and it unloads; come back and it loads from the cache. Edit
flake.nixand the next prompt rebuilds.
To try it without a flake of your own, examples/ has a
small flake with a dev shell, a manifest, and an annotated env.json.
| Command | What it does |
|---|---|
simmer hook <shell> |
Print the prompt hook for fish, bash, or zsh. |
simmer allow / simmer deny |
Trust or distrust the current project. |
simmer warm |
Build the environment now and wait for it (also allows the project). |
simmer export <shell> |
What the hook runs: print shell code that loads the environment. Waits for a rebuild when stale. |
simmer status |
The current directory's state, as JSON. |
simmer doctor |
Timings, cache state, and configuration hazards, as JSON. |
simmer daemon |
Re-warm allowed projects as soon as a watched file changes (optional). |
simmer direnv … |
direnv-compatible CLI for editor plugins (see Editors). |
simmer --help and simmer <command> --help document every flag.
Commands that build (warm, and export when the environment is stale)
report progress on stderr:
-
On a terminal, a live view redraws in place about 15 times a second: a header with the elapsed time and running/done/failed/queued counts, one line per running step (spinner, current activity such as the derivation nix is building, elapsed time) and the last few lines of its output. A finished step leaves a single
✓(or✗, or!for a warning) line with its duration. Lines are cut to the terminal width, and a resize is picked up on the next frame. -
Not on a terminal (CI, pipes,
--plain): one line per state change, no escape codes:simmer: start nix build simmer: ok nix build (3.2s) simmer: start provider GPU_ARCH simmer: warning provider GPU_ARCH (0.0s): `bash` exited with status 2; GPU_ARCH left unset simmer: warmed ~/src/app (42 vars, native, 3.7s) -
--json: one JSON object per event per line (queued,start,phase,output,finish,note, and a finalsummarywith every step's status, duration and, for failures, the tail of its log). Forsimmer warm --jsonthe events go to stdout. -
-qshows only failures;-vadds every output line, nix's activities, and a final summary table.
When a step fails, simmer prints that step's captured output (the last 40
lines; --log-lines N changes that, --log-lines 0 prints all of it) and a
summary table of every step, and saves the full log in the project's cache
(the path is printed with the failure). Each step's output is held in a
bounded in-memory ring that spills to a temporary file, so a noisy build
cannot grow simmer's memory.
Exit status is 0 on success, the failing step's own exit status when a
step fails, 124 when --timeout expires, and 128+N when stopped by signal
N (130 for Ctrl-C).
Ctrl-C / SIGTERM stop every process the build started (its whole
process group: SIGTERM, then SIGKILL after a grace period), then print the
summary. Nothing is left running, and the next prompt does not start the
build again: it keeps the previous environment until you save a watched
file or run simmer warm.
--timeout 10m (or SIMMER_TIMEOUT=10m) bounds how long simmer waits;
the default is no limit.
A failed build is not retried at every prompt. simmer remembers that
this exact version failed, keeps the previous environment loaded, and says
so once per shell. Saving any watched file is a new version and is tried
again; simmer warm retries explicitly.
Two shells, one project: if a build is already running (another shell, the daemon), the second one waits for it instead of starting its own.
Prefer not to wait? simmer export --background (or
SIMMER_BACKGROUND=1 in your shell config) serves the previous environment
immediately and rebuilds in a detached process.
A project is the nearest directory (walking up) that contains any of
flake.nix, .envrc, .simmer.toml, or simmer.toml.
Every key is optional; a bare flake project needs no manifest at all. Unknown keys are errors, so a typo cannot silently do nothing.
# Which dev shell to load. Default: "." when flake.nix exists. A bare
# fragment (".#dev") means devShells.<your system>.dev.
flake = ".#dev"
# Extra files whose content defines the environment (flake.nix,
# flake.lock and the manifest itself are always watched). A wildcard entry
# is expanded at build time and its directory tree is watched by the
# daemon, so a file that appears later still triggers a rebuild.
watch = ["nix/**/*.nix"]
# KEY=value files to load (watched automatically).
dotenv = [".env.local"]
# Directories to prepend to PATH, relative to the project root.
path = ["bin", "scripts"]
# Literal variables.
[env]
DATABASE_URL = "postgres://localhost/dev"
# Commands whose stdout becomes a variable, run once per build in the
# finished environment. A failing or empty provider leaves its variable
# unset and is reported as a warning; it does not fail the build.
[[provider]]
env = "GPU_ARCH"
command = "nvidia-smi --query-gpu=compute_cap --format=csv,noheader | head -1 | tr -d ."
invalidate = "boot" # also re-run after a reboot; default "generation"An annotated copy is in examples/simmer.toml.
An allowed .envrc runs in a scrubbed bash harness with a direnv-compatible
subset of its standard library: use flake, use nix, PATH_add,
path_add, MANPATH_add, dotenv, dotenv_if_exists, source_env,
source_env_if_exists, source_up, watch_file (feeds simmer's
invalidation), env_vars_required, has, expand_path, log_status,
log_error. layout is not supported (it warns). If use flake fails, the
build fails with nix's exit status. Trust for an .envrc project is bound
to the file's content: editing it revokes trust, as with direnv.
A dev shell whose output contains an env.json is loaded natively: one
nix build, then the variables, providers and generated config files are
applied in Rust without sourcing any bash. The schema (closed: unknown
fields are errors) is documented in
crates/simmer-core/src/envspec.rs;
examples/env.json shows every field.
vars:set,prefix(merged in front of the caller's value, likePATH),unset,root(the project root).configs: files linked, copied, or rendered from a template (@VAR@,@VAR:-default@) into the project at build time and listed in a marked block of.gitignore; the cache's GC root keeps their sources alive.providers: like the manifest's.motdandcommandsbecome the entry banner, printed to stderr when you enter the project (only on a terminal, never into a captured environment;SIMMER_NO_MOTD=1silences it).
A native build also roots every flake input next to the shell's GC root
(refreshed when flake.lock changes), so a rebuild after nix store gc
does not re-download sources.
| Variable | Effect |
|---|---|
SIMMER_PROGRESS |
Default --progress mode: auto, live, plain, json, quiet. |
SIMMER_TIMEOUT |
Default --timeout (90s, 10m, 1h; 0 = no limit). |
SIMMER_BACKGROUND=1 |
export rebuilds in the background instead of waiting. |
SIMMER_NO_MOTD=1 |
Do not print entry banners. |
SIMMER_NO_HINTS=1 |
Do not suggest simmer allow when entering unallowed projects. |
SIMMER_DIRENV_FALLBACK |
Path of the real direnv for the shim to delegate to. |
NO_COLOR |
Disable colours in the progress view. |
XDG_CACHE_HOME |
Cache location (default ~/.cache/simmer). |
Nothing is executed for a project until you allow it:
simmer allow # or `simmer warm`, an explicit action that implies allow
simmer deny # simmer goes inert here and stops hintingFor flake and manifest projects trust sticks to the directory (flake edits
are routine, and the daemon must be able to rebuild them). For .envrc
projects it is bound to the file's content.
simmer allow is also the migration switch from direnv: until you allow a
project, simmer leaves it alone and direnv (if hooked) keeps owning it.
simmer daemon # run in the foreground
# or as a systemd user service (Linux):
simmer daemon --print-systemd > ~/.config/systemd/user/simmer.service
systemctl --user enable --now simmerThe daemon watches every allowed project simmer has built and rebuilds it as soon as a watched file changes, so the multi-second evaluation happens when you save, and the next prompt usually finds the environment ready. It has no socket or protocol: the on-disk cache is the interface.
Editor plugins (VS Code, Zed, Emacs, …) and launchers call direnv export json or direnv exec. Install the direnv-named shim ahead of the real
direnv on PATH:
nix profile install github:seryl/simmer#direnv-shimFor projects you have allowed, the shim answers from simmer (waiting for a
rebuild the way direnv waits for an .envrc); everything else is passed to
the real direnv ($SIMMER_DIRENV_FALLBACK, or the next direnv on PATH).
Without a real direnv, the shim is the only loader.
Measured on an Apple Silicon laptop (macOS, 2026-10-12) with
hyperfine, 2000 runs each,
interleaved, for a project with 23 watched files and a 40-variable
environment. /usr/bin/true is the cost of starting any process at all.
| Path | 0.1.0 (previous) | now | /usr/bin/true |
|---|---|---|---|
Prompt, environment current (simmer export fish) |
5.1 ms | 3.1 ms | 1.6 ms |
| Entering the project (apply script) | 5.2 ms | 2.9 ms |
The in-process work on the prompt path is about 0.14 ms; the rest is process start-up. That is why the prompt binary links nothing but the system C library: the daemon, which needs the platform's file-watching frameworks, is a separate binary.
- Build (rare: a watched file changed). Either native —
nix buildthe dev shell and apply itsenv.json— or harness — runnix print-dev-envand the shell hook (or the project's.envrc) in a bash harness with a scrubbed environment, and capture what comes out. The result is stored with a GC root that pins its closure. - Export (every prompt). Fingerprint the watched files by stat (mtime, size, inode); hash their content only if a fingerprint changed; compare with the cached version and with the version the shell already holds. Emit nothing, or a native script that applies the difference.
- Session state travels inside the shell (
__SIMMER_STATE), so leaving a project restores exactly what was there before, without reading the cache. PATHandXDG_DATA_DIRSare stored as "nix part + your current value" and merged against your current value at export time.
simmer doctorexplains what simmer sees for the current directory: whether it is trusted, what is watched, the last build and its failure, and how long the prompt path takes.simmer statusis the same state in a smaller, stable JSON shape.- A build failed: the full log of the failing step is saved in the
project's cache (the path is printed with the failure);
simmer warm -vshows everything as it happens. - Nothing happens in a project: is it allowed (
simmer allow)? Is the hook installed (type __simmer_hookin your shell)?
See CONTRIBUTING.md. Changes are listed in CHANGELOG.md.
Licensed under the MIT license. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in simmer by you shall be licensed as above, without any additional terms or conditions.