FirnOS is a source-aware authoring layer for NixOS and nix-darwin. It keeps the NixOS module model, but adds a small Racket DSL (nisp), scalable module/bundle conventions, and pre-eval diagnostics that catch option typos and type errors at the original source line — often cutting edit/validate loops from ~30 seconds to ~5 seconds.
Both .rkt and .nix are committed. The flake reads regular generated
Nix. You're not trapped in a custom language — you can drop down to raw
Nix at any point, and you can stop using FirnOS by deleting the .rkt
files.
This repository is two things at once: the FirnOS framework, and the author's real NixOS + nix-darwin config built on it. If you want to use FirnOS for your own machines, start from template/ — it's a minimal host setup with the framework wired up. The full repo (hosts/whiterabbit/, dotfiles/, secrets/, all 158 modules) is here as a study reference for what a daily-driver setup looks like, not as something you should fork wholesale.
Three audiences:
- You want to manage your own NixOS or nix-darwin machine with source-aware validation and a small workflow CLI → use the template.
- You want the validation tooling but already have a Nix config → look at tompassarelli/nisp directly. The
nispdispatcher (validate/extract-schema/import/schema/rename/edit) plusnisp-lspwork standalone against any flake. - You want to read a real example of a multi-host Nix config → browse
hosts/,modules/,bundles/. Everything in the repo evaluates and builds.
$ firn host rebuild
modules/printing/default.rkt:6:7: unknown option services.pipwire.alsa.enable
did you mean: services.pipewire.alsa.enable or services.pipewire.pulse.enable?
modules/net/default.rkt:10:47: unknown package networkmanagers in pkgs set
did you mean: networkmanager, networkmanager-ssh or networkmanager-sstp?
modules/foo/default.rkt:9:34: type mismatch at services.openssh.enable:
expected bool, got string
hosts/laptop/configuration.rkt:11:47: type mismatch at boot.loader.systemd-boot.consoleMode:
"atuo" not in enum {"0", "1", "2", "5", "auto", "max", "keep"} — did you mean "auto"?
modules/bar/default.rkt:8:9: duplicate assignment to networking.hostName (first set at line 5)
file:line:col precision on the value, not the path, with did-you-mean
suggestions, before nixos-rebuild runs. That's the whole pitch.
Three pieces on top of the standard NixOS module system:
- nisp — a Racket
#langfor writing NixOS config as s-expressions. Compiles to ordinary Nix. - firn-validate — a static checker that walks every nisp
.rkt, looks up each option path against the cached NixOS options schema (~16k paths), type-checks values for bool/str/int/listOf/nullOr/enum/path mismatches, validates every package name against a cached index of 25K+ nixpkgs attrs (with overlay awareness), detects duplicate option assignments, and syntax-checks all generated.nix. On build failure, pipes the error to Claude for instant diagnosis. - firn — a CLI that wraps the daily workflow: rebuild, enable/disable, scaffolding, secrets, watch.
Plus the framework conventions: myConfig.modules.* for atomic
packages/services, myConfig.bundles.* for composable groups, directory
auto-discovery — adding a module is just creating a directory.
NixOS already validates option paths and types — but it does so during
module evaluation, after the original authoring context has been
discarded. By the time an error surfaces, the line that caused it is
several layers of mkIf/mkMerge/import indirection away. The error
message points at where the option got forced, not where the typo was
written.
FirnOS validates at a different layer. The nisp AST is a concrete data
structure in memory before any Nix is emitted. We walk it, extract every
(set 'PATH val) and (enable 'PATH), look the path up in the cached
schema (exported once via nix eval against the options tree), and
report mismatches with file:line:col from the original .rkt source.
The result: typo and type errors surface at the line you wrote, in
milliseconds, before any nixos-rebuild evaluation cost.
For your own setup, scaffold from the template:
nix flake init -t github:tompassarelli/firnos # drops template/ contents in cwd
git clone https://github.com/tompassarelli/nisp # sibling clone — firn-build expects ../nisp
cp /etc/nixos/hardware-configuration.nix .
# edit hosts/my-machine/configuration.rkt — set username, enable bundles
./scripts/firn-build && nixos-rebuild switch --flake .#my-machineTo study the full author config instead:
git clone https://github.com/tompassarelli/firnos
git clone https://github.com/tompassarelli/nisp
# poke around hosts/, modules/, bundles/. Don't try to rebuild it as-is —
# it's tuned to a specific Framework 13 laptop.On macOS, see docs/MACOS.md — FirnOS supports nix-darwin via lib.mkDarwinSystem with curated bundles-darwin/.
If you already have a hand-written Nix config, nisp import (a subcommand of the nisp toolchain) converts .nix → .rkt:
nisp import path/to/configuration.nix > hosts/my-machine/configuration.rktRound-trip is byte-equivalent for plain Nix; nisp import handles 100% of nixpkgs (2,332 modules) via rnix-parser. Comments are dropped (logged limitation). After importing, you can mix raw Nix and nisp freely — firn-build only rewrites .rkt files; hand-written .nix modules sit alongside generated ones and the flake imports them the same way.
A trivial install-package module:
;; modules/vim/default.rkt
#lang nisp
(pkg vim "Vim text editor")A bundle that toggles a list of children:
;; bundles/development/default.rkt
#lang nisp
(bundle-file development
(desc "core development workflow")
(sub-modules git gh delta vim claude direnv containers ripgrep fd))A host config:
;; hosts/my-machine/configuration.rkt
#lang nisp
(host-file
(set myConfig.modules.system.stateVersion "25.05")
(set myConfig.modules.users.username "yourname")
(enable myConfig.bundles.terminal
myConfig.bundles.development
myConfig.bundles.browsers))The pipeline: edit .rkt → firn host rebuild regenerates the .nix,
validates, builds, and tags the resulting generation. Both files are
committed (the flake reads from the git tree).
Heads-up for editors and AI coding agents:
.rktis the source-of-truth,.nixis a generated artifact. Edit the.rkt, never the.nix— the nextfirn-buildoverwrites direct.nixedits. If you're an AI agent reaching into this repo via absolute path from another working directory, your CLAUDE.md auto-load probably didn't fire here — readclaude.mdbefore making changes.
See docs/BUILDING.md for the full DSL reference.
The CLI is an entity-first walkable graph — every invocation is one
or more <node> <edge> [<leaf>] triples, with sensible defaults
filled in when the leaf is omitted (e.g. <host> defaults to the
current hostname, <scope> defaults to all):
host rebuild [<host>] firn-build → validate → nixos-rebuild → tag
host status [<host>] list every enabled module + bundle for a host
host doctor [<host>] repo health check (untracked, stale, validator)
host gen [<host>] current/next generation numbers
host list all every host directory under hosts/
module enable <name> toggle a module on in the default host
module disable <name> toggle off
module status all flat list of enabled modules
module list all|used|unused list modules, with usage filter
module refs <name> show what references this module
module add <name> scaffold a minimal module
bundle enable <name> toggle a bundle on in the default host
bundle disable <name>
bundle status <name>|all per-bundle sub-toggle tree (one or all)
bundle list all|used|unused
bundle refs <name>
bundle add <name> scaffold a new (empty) bundle
repo diff [<target>] re-emit Nix and diff vs committed .nix
repo doctor all same as `host doctor`
repo upgrade now|dry-run flake update + schema diff + validate
repo watch all re-run validator on .rkt save
schema explain <path|err-line> schema entry + repo references for an option
secret list|show|edit <name> sops list / decrypt / edit
tag list|show|filter|index module-tag index (see docs/BUILDING.md)
platform list|show|safelist NixOS vs darwin compat report
template service|submodule|home|host <name> scaffolded module/host
Walks chain — firn module list bundle list runs both with default
leaves. firn alone shows the full grid, firn <node> shows the
edges for that node. Old shapes (firn rebuild, firn status --bundles, firn explain X, firn tags --filter t, …) still work
with a one-line deprecation pointer to the new form.
For unambiguous typo cleanup across the whole tree:
nisp validate --auto-fix rewrite unambiguous typos in place
Compile to a self-contained ~1.3MB binary with ./scripts/firn-build-bin
(installs to ~/.local/bin/firn, ~80ms cold start).
You will eventually want raw Nix — for an unusual overrideAttrs, a
build-input fixup, vendor module shape that doesn't fit nisp's helpers.
There are three ways down:
(raw-file ...)— emit a single arbitrary expression, no module wrapping.(nix-ident "any.dotted.path")— produce a literal Nix identifier from a string.- Just write
.nix—firn-buildonly rewrites.rktfiles. Hand-written.nixmodules sit alongside generated ones; the flake imports them the same way.
firn repo diff confirms hand-edited .nix is byte-equivalent to what nisp
would emit, which is useful when migrating modules in either direction.
firn-validate runs a multi-layer pipeline (~5 seconds total):
firn-validate
├── firn-lint-nix syntax-check every generated .nix (catches codegen bugs)
├── auto-refresh packages if flake.lock changed, re-cache 75K+ package attr names
└── nisp validate the main static checker:
├── option paths every (set path val) against 16K NixOS option paths
├── value types bool/str/int/path/enum/listOf/attrsOf/nullOr type inference
├── enum values literal strings checked against allowed value sets
├── package names every with-pkgs and lst pkgs.* symbol against nixpkgs
├── submodule expand lazy expansion of attrsOf submodule children on demand
└── duplicate detect same option set twice in one file → flagged
On rebuild failure, the fi rebuild command pipes the Nix error to claude -p for an instant 1-3 sentence diagnosis.
The schema cache is regenerated by firn-extract-schema, which calls
nix eval against the merged options tree of a host. Output captures
the full type tree — top-level type, inner element types for
parameterized containers (listOf, nullOr, attrsOf), submodule
expansion via getSubOptions, and the legal values for every enum —
across ~16k option paths including custom myConfig.* and flake-input
options (home-manager, stylix, sops, …).
The package cache is managed by firn-extract-packages, which evaluates
nixpkgs with the flake's overlays applied and caches top-level attr
names for the pkgs, unstable, and master sets. This catches
package-name typos (the single most common class of NixOS build failure)
at validation time rather than 30+ seconds into a rebuild. The cache
auto-refreshes when flake.lock changes.
The validator skips paths inside home-of / hm-module bodies (a
heuristic — the system schema doesn't include HM submodules), paths with
${…} interpolation, and a small allowlist of HM-context roots
(programs, home, xdg, …). This trades some false negatives for
zero false positives — typos in those namespaces still surface at
nix-eval time.
The schema is host-specific (it's the merged options tree of one host's
nixosConfiguration) and ages relative to your flake.lock. The
extractor dumps the cheap top-level options once into
.firn-build/schema.json (~16k paths, a couple seconds). Submodule
contents are not eagerly extracted — the validator demand-expands
only the submodules your config actually references and caches them in
.firn-build/schema-submodules.json, keyed by flake.lock hash +
extractor version + system. First-time references trigger a one-shot
nix eval; subsequent runs are pure cache hits.
Regenerate the base schema after:
nix flake update(nixpkgs / flake inputs change)- adding/changing your own
myConfig.*options - swapping flake inputs (e.g. adding home-manager / stylix / sops-nix)
The submodule cache invalidates automatically when the lock hash changes — no manual step required.
.
├── flake.rkt source-of-truth flake (compiles to flake.nix)
├── modules/ atomic modules — one package/service each (NixOS)
├── bundles/ composition layer — pure module toggles (NixOS)
├── bundles-darwin/ parallel composition layer for nix-darwin hosts
├── hosts/ per-host configurations (NixOS + darwin)
├── scripts/ firn (CLI), firn-build, firn-validate, firn-extract-schema, firn-extract-packages, firn-lint-nix
├── template/ starting point for `nix flake init -t`
├── dotfiles/ out-of-store configs (live editing)
└── docs/ BUILDING.md, docs.md, MACOS.md
The DSL itself (#lang nisp) lives in a separate repo — tompassarelli/nisp, cloned alongside this one as a sibling. firn-build expects ../nisp (override with NISP_PATH).
Module = atom. One package or service.
Bundle = molecule. Pure composition. Enables a group of modules, never installs packages directly.
Modules and bundles are auto-discovered — the flake's dynamic imports
walks modules/ and bundles/ via builtins.readDir. No flake edits
needed when adding either.
-
Two-language requirement. Authors need the basics of Racket s-expressions in addition to Nix concepts. The DSL is small (~30 forms) and the surface vocabulary closely mirrors Nix; the BUILDING.md cheat-sheet maps every form to its Nix output.
-
Two artifacts per file. Both
.rktand.nixare committed; CI / pre-commit hooks should runfirn repo diffto ensure they stay in sync. Generated.nixis gitignored from manual edits in the typical workflow. -
Schema cache is host-specific and dated. It's tied to your flake.lock and regenerated when inputs change (see Schema freshness above). The validator is unhelpful — but harmless — when the schema is stale.
-
DSL ceiling exists. Some Nix idioms don't have first-class nisp forms yet. The escape hatch (
raw-file, hand-written.nix, directnix-ident) is the answer; the helper coverage grows as we encounter cases.
The Quick start above covers the nix flake init -t template path. As an alternative, if you want to consume FirnOS as a flake input from your own repo:
{
inputs.firnos.url = "github:tompassarelli/firnos";
outputs = { firnos, ... }: {
nixosConfigurations.my-machine = firnos.lib.mkSystem {
hostname = "my-machine";
hostConfig = ./hosts/my-machine/configuration.nix;
hardwareConfig = ./hosts/my-machine/hardware-configuration.nix;
};
# macOS:
darwinConfigurations.my-mac = firnos.lib.mkDarwinSystem {
hostname = "my-mac";
hostConfig = ./hosts/my-mac/configuration.nix;
};
};
}| required | type | default | |
|---|---|---|---|
hostname |
yes | string | — |
hostConfig |
yes | path | — |
hardwareConfig |
yes | path | — |
system |
no | string | "x86_64-linux" |
extraModules |
no | list | [] |
extraOverlays |
no | list | [] |
extraSpecialArgs |
no | attrset | {} |
| required | type | default | |
|---|---|---|---|
hostname |
yes | string | — |
hostConfig |
yes | path | — |
system |
no | string | "aarch64-darwin" |
extraModules |
no | list | [] |
extraOverlays |
no | list | [] |
extraSpecialArgs |
no | attrset | {} |
(No hardwareConfig on darwin — macOS doesn't have an analogue. See docs/MACOS.md for the bootstrap walkthrough.)
- docs/BUILDING.md — pipeline, DSL forms, validator, firn CLI
- docs/docs.md — design philosophy and conceptual primer
- docs/MACOS.md — running FirnOS on macOS via nix-darwin
- tompassarelli/nisp —
#lang nispreference (the DSL itself)
- doomemacs/doomemacs — opinionated convention layer + escape hatches
- basecamp/omarchy
- fufexan/dotfiles
- redyf/nixdots
- eduardofuncao/nixferatu
MIT