packages
Edit on GitHubModules: modules/home/packages.nix,
modules/nixos/packages.nix
The two mirrored central lists. Everything here is a decision that a one-line comment cannot carry: why a package is on the unstable channel, why one was refused, or why one is NOT in the list even though you would expect it.
The per-package rule itself (home by default, system only for root/rescue/driver/service) is in the README.
Why some packages come from unstable.*
| Package | Reason |
|---|---|
yt-dlp | it breaks whenever the sites change, so it has to be fresh |
speedtest-cli | it follows speedtest.net's changes |
fastfetch | new hardware and version detection |
flameshot | v14 is the one with the portal capture path |
vscode | its src is swapped for the official tarball anyway (see version-bumps) |
Claude Desktop needs the FHS variant
Not the pure one. The MCP servers need to find node and uv, and Cowork brings up a QEMU VM looking
for /usr/share/OVMF/*.fd and /usr/bin/virtiofsd at HARDCODED FHS paths, so outside the FHS it
answers virtualization_tools_missing and that is that.
The closure is 2.9 GiB, qemu_kvm being the biggest slice. The 30/07 measurement says that is not
where the disk fills up: the WHOLE /nix/store is 9% of it, while the Bottles are 319 GiB.
Cowork also requires VT-x turned on in the BIOS (here it is off: VMX disabled by BIOS) plus
the user in the kvm group. Without that, only Chat and Code work. The session and
claude_desktop_config.json are state (rule 6), and the app rewrites that JSON at runtime, so Nix
does not own it (rule 14).
The Nix toolchain: why nixd and why nixfmt
nixd and not nil. Both are live Nix LSPs, but only nixd completes NixOS and home-manager
OPTIONS, because it compiles against the interpreter and EVALUATES the config instead of analyzing
text. In a repo that is 95% services.*/programs.*, that is the entire job. nil is better at
the rest (lighter, good diagnostics), so it is plan B: 1 line here plus the serverPath.
nixfmt and not nixpkgs-fmt/alejandra. It is the OFFICIAL formatter since RFC 166, which created the Nix formatting team and moved the repo to the NixOS org. nixpkgs-fmt is DEPRECATED by its own author; alejandra is good but unofficial, and diverging from nixpkgs on style is free debt.
Mind the name: nixfmt IS ALREADY the RFC style (1.4.0, the same derivation as
nixfmt-rfc-style), while nixfmt-classic (0.6.0) is the old one. Asking for the classic by
mistake would reformat the whole repo in the old style.
statix and deadnix answer different questions, which is why both are installed: statix finds
idiomatic anti-patterns (a = x.a; that should be inherit (x) a;) and deadnix finds dead
declarations (unused lambda args, let-bindings, patterns). Here they are only available to run by
hand; what GUARANTEES them is nix flake check. Two statix lints are turned off with a
justification in .config/statix.toml, because 63 of the initial 77 findings were a
single lint that contradicts the nixpkgs idiom.
azure-cli earns its 0.95 GiB, wrangler did not earn its 2.2
Both are big CLIs from a cloud vendor, and they went opposite ways, so the comparison is the point: the criterion is not size, it is whether the tool does the job.
azure-cli stays. 0.95 GiB marginal (the closure is 1.19, but 0.24 is already on the system).
It is the ONLY path to the Entra ID App Registration (az ad app …), which is the reason
everything else exists. Measured on 14/08/2026: the Azure MCP Server does NOT cover Entra. Among
its 68 tools there is no App Registration, no service principal and no Graph; its role is
RESOURCE RBAC, and extension_cli_generate only GENERATES the text of the az command, it never
executes. The entra-app-registration skill from microsoft/azure-skills confirms it inside out:
what it teaches is running az ad app create/list/…. As a bonus it simplifies the MCP login, since
with az on the PATH azmcp's chain picks up AzureCliCredential and the device code stops being
necessary.
wrangler went out, tested and removed on 07/08/2026, the same day it went in. It costs 2.2 GiB
of closure (FOUR copies of nodejs-24: slim, -npm, -corepack and the full one) and has NO DNS or
zone command, since the whole help is Workers/Pages/KV/R2/AI. The DNS work here is done by the
cloudflare-api MCP (.mcp.json).
What is NOT in the list, and why
- curseforge and claude-code each own their package, because they have a config of their own (the login scheme handler, and the separate claude-fai/claude-pessoal accounts). An app with declarative config keeps package and config together.
- Java, for CurseForge. It is dead config: see curseforge.md.
- wrangler, above.
The system list is rescue and diagnosis
restic is there for a reason worth stating: the services.restic module only generates wrappers
PER REPO (restic-home-gdrive), so a repo with no service, like the Arch archive, was unreachable
without a nix shell. A backup that requires gymnastics to read is half a backup.
nix-tree answers a different question from dua/filelight: those say which FOLDER weighs the
most, this one says which PACKAGE does, and what each dependency drags in. It is how we measured
that xembedsniproxy cost 429 MiB of qtwebengine.
wayland-utils came in on 08/08/2026 because the question "does Hyprland still serve
wlr-gamma-control?" could not be answered without guessing, and a guess becomes a wrong comment in
the repo.
The GPU benchmarks (vulkan-tools, mesa-demos, glmark2, vkmark, unigine, clpeak) were removed after validating the Arc: they were one-offs. Only the day-to-day monitors stayed.
A module names its packages once, at the top (rule 19)
Adopted on 29/08/2026, across 35 modules. The let of a module opens with an inherit (pkgs) ...;
listing what that module reaches for, and the body uses the bare name.
The idea comes from nixpkgs, where it already exists. A derivation's file header
({ lib, stdenv, curl, jq }:) IS its dependency list, filled in by callPackage. That does not
transfer to a NixOS module, because a module's arguments are fixed by the module system: only
config, options, lib, pkgs and whatever specialArgs provides ever arrive there, and
{ pkgs, curl, ... }: fails at eval. Forcing it through _module.args = pkgs; would dump the whole
of nixpkgs into every module's scope and risk infinite recursion, since module arguments have to
resolve before imports do. So the let is where the list goes, and it is the closest legitimate
form.
What it actually buys, beyond reading better:
- deadnix checks it. An inherited name that stops being used is an unused binding, which fails
nix flake checkand the pre-commit hook. Rule 16 usually depends on somebody remembering to audit; here the linter does it. - It kills the mixed-scope list.
runtimeInputs = with pkgs; [ systemd coreutils streamActive ]insunshine.nixandhome.packages = with pkgs; [ minimizeOthers wl-clipboard ... ]inhypr.nixboth mixed real packages with shell applications built a few lines above, resolved bywithlosing to aletbinding. Nothing on the page said which name was which. This is the case nix.dev has in mind when it says not to usewith: it defeats static analysis and hides where a name comes from. - One place to swap a channel for that module, instead of a grep through its shell strings.
What deliberately does NOT go in the block. The comment above it claims the list is the module's packages, so anything else in there would be a lie:
- Platform queries.
pkgs.stdenv.hostPlatform.systemkeeps its prefix (hypr, quickshell, flameshot, dropbox, razer-dpi). It is not something the module installs. - Namespaces.
kdePackagesand friends are not packages, so what gets named is the ATTRIBUTE the module uses:inherit (pkgs.kdePackages) kconfig;, not the whole set. - The unstable channel, which is the one exception that stays a namespace: it is inherited as
unstableso every use site still readsunstable.vscode. The table above is kept honest by grepping forunstable.across the tree, and a package that stops spelling out its channel vanishes from that grep while still being on it.
Where it does not apply. A flat install list (home.packages, environment.systemPackages,
fonts.packages, hardware.graphics.extraPackages) keeps with pkgs;: every name in it comes from
the same place, there is nothing to disambiguate, and the with is the nixpkgs idiom for exactly
that shape. Five modules were left untouched for want of a payoff, all of them with no let block
and one or two single-use references: modules/nixos/hardware/mx-master-3s.nix, modules/nixos/hardware/deathadder-v2.nix,
hosts/ex-b560m-v5/vm-disko.nix, modules/home/shell/git.nix and modules/home/shell/cli.nix.
How the sweep was verified. The change is textual, so the proof is that nothing moved: the
system's drvPath was read before the first edit and after every commit, and it stayed
5hakbijzd1nq2fxh6mxf3vp3rfvglgds throughout. Since home-manager enters as a NixOS module here, that
one hash covers both trees. It is a real check and not a formality: every service toggle is on for
this host, so all the touched modules do evaluate, and a name attributed to the wrong scope fails
loudly (undefined variable, or a missing attribute in the inherit) instead of silently building
something else.
Adopting programs.<app> breaks the FIRST switch, and that is the module working
Hit on 08/09/2026 with gh and atuin, and it will happen again with every module adopted for an app that has already run on this machine. The switch dies like this:
Existing file '/home/v1cferr/.config/gh/config.yml' would be clobbered
Existing file '/home/v1cferr/.config/atuin/config.toml' would be clobbered
warning: the following units failed: home-manager-v1cferr.serviceThat is rule 14 DEFENDING ITSELF: the file has an owner and it is not Nix yet, so home-manager
refuses to become a second one silently. The failure is the correct behavior and the temptation
is to reach for force = true to make it go away, which would make Nix win on every activation
and quietly discard whatever the app writes from then on.
The fix for an ADOPTION is one move, not a config change, because the conflict exists only while a real file sits where the symlink goes. Read the file first, confirm what would be lost, then move it aside and switch:
mv ~/.config/gh/config.yml ~/.config/gh/config.yml.pre-hmBoth files that day held NOTHING worth keeping, which is the part to check rather than assume:
gh's 27 lines were its own defaults plus the one alias now declared in
modules/home/shell/git.nix, and atuin's was the default template.
The one case where force = true IS the answer
atuin took the mv and stayed away. gh CAME BACK seven minutes later, with version: "1" in it,
and the culprit is in this repo: git.nix exports the token through gh auth token on EVERY
shell start, and gh writes a stub config when the file is missing. Every shell that opens between
the mv and the switch puts the conflict back, so a one-time move cannot win that race.
xdg.configFile."gh/config.yml".force = true is therefore declared, and the three conditions
that make it legitimate here are worth naming, because they are what separate this from papering
over a conflict:
- the file is regenerated by a TOOL as a side effect, not authored by a human, so there is no intent to preserve
- its entire content is declared, measured line by line, so nothing is lost by overwriting
- a hook in this same repo recreates it, which makes the conflict recurring rather than a one-time adoption
The cost is real and accepted: gh config set now writes to a store symlink and fails. That is
rule 14 working as designed, Nix being the single owner, and not a bug to fix later.
Do not sweep up the neighbours. ~/.config/gh/hosts.yml is the auth token, it is STATE
(rule 6) and no module manages it. And a directory sitting where a recursive = true entry
points is EXPECTED, not a conflict: xdg.configFile.Kvantum is recursive, so ~/.config/Kvantum
is a real directory whose contents are individually linked.
The pre-flight that avoids a second failed switch, since the error names one file at a time
and xdg.configFile is a separate option from home.file:
nix eval --json '.#nixosConfigurations.ex-b560m-v5.config.home-manager.users.v1cferr.xdg.configFile' \
| jq -r 'keys[]' | while read -r f; do
p="$HOME/.config/$f"
[ -e "$p" ] && [ ! -L "$p" ] && [ ! -d "$p" ] && echo "CONFLICT: $p"
doneThe long form of rule 19
Moved here VERBATIM from rules.md on 30/09/2026, when rule 19 became a card. Nothing was cut; the card links back here.
A MODULE DECLARES ITS PACKAGES AT THE TOP, ONCE: the let opens with an inherit (pkgs) ...; naming everything that module reaches for, and the body uses the bare name. THE POINT IS MAINTENANCE AT SIZE, which is the problem this repo actually has: 126 .nix files and growing, so "what does this module pull in" has to be answerable by looking at one block, not by reading 29 shell interpolations, which is what lockscreen.nix cost before the sweep of 29/08/2026. THE IDEA IS NOT MINE, IT IS NIXPKGS': a derivation's header ({ lib, stdenv, curl, jq }:) IS its dependency list, filled in by callPackage. A NixOS module CANNOT have that, and it is worth knowing why before trying: the arguments are fixed by the module system (config, options, lib, pkgs plus specialArgs), so { pkgs, curl, ... }: fails at eval, and forcing it through _module.args = pkgs would dump the whole of nixpkgs into every module's scope and risk infinite recursion, since module arguments resolve before imports do. The let is the closest legitimate form. WHAT IT BUYS BEYOND READING BETTER, and this is the half that survives me forgetting: deadnix checks it, because an inherited name that stops being used is an unused binding and fails nix flake check and the pre-commit hook, so rule 16 stops depending on somebody remembering to audit. And it KILLS THE MIXED-SCOPE LIST, which was a real bug in waiting here and not a style complaint: runtimeInputs = with pkgs; [ systemd coreutils gawk streamActive ] in sunshine.nix and home.packages = with pkgs; [ minimizeOthers wl-clipboard ... ] in hypr.nix both mixed packages with shell applications built a few lines above, resolved by with losing to a let binding, with NOTHING on the page saying which name was which. That is the case nix.dev has in mind when it says not to use with: it defeats static analysis and hides where a name comes from. WHAT DOES NOT GO IN THE BLOCK, because a list captioned "the module's packages" that holds something else is a lie, which is rule 16's drift: platform queries keep their prefix (pkgs.stdenv.hostPlatform.system is not something a module installs); namespaces are resolved to the attribute actually used (inherit (pkgs.kdePackages) kconfig;, never the whole set); and the unstable channel is the deliberate exception that STAYS a namespace, inherited as itself so every use site still reads unstable.vscode, because the table in this page is kept honest by grepping for unstable. across the tree and a package that stops spelling out its channel vanishes from that grep while still being on it. WHERE IT DOES NOT APPLY: a FLAT INSTALL LIST (home.packages, environment.systemPackages, fonts.packages, hardware.graphics.extraPackages) keeps its with pkgs;, since every name in it comes from the same place and there is nothing to disambiguate; and a module with no let block and one or two single-use references gains nothing but five lines of preamble (mouse.nix, razer.nix, vm-disko.nix, git.nix, cli.nix, left alone ON PURPOSE, so do not read them as an unfinished sweep). HOW TO VALIDATE A SWEEP LIKE THIS, and it applies to any change that claims to be textual: read the system's drvPath before the first edit and after every commit, and it must not move. On 29/08/2026, across 35 modules, it stayed 5hakbijzd1nq2fxh6mxf3vp3rfvglgds, and since home-manager enters as a NixOS module that ONE hash covers both trees.