hausfold

haus

Customize a desktop

One file holds every choice you make: the dials worth knowing, the rooms you can switch off, and how to beat anything your desktop set.

A desktop ships a whole opinion, already wired. So customising it isn't assembly: it's turning a handful of dials, and every one of them lives in the same file.

The one file you edit

There are two halves to a haus machine, and they never mix:

  • haus (github:hausfold/haus): the layer. Generic, no identity, upstream. You never edit it to use it.
  • Your config (~/.config/nix): a small flake that imports haus, plus one host file, hosts/<hostname>/default.nix, where you live.

Everything on this page goes in that host file:

haus edit       # opens it in $EDITOR
haus rebuild    # builds first; a broken config never activates

It's a plain nix-darwin module, so whatever you write merges with what haus already declares. Because it's a separate repo, haus update pulls a newer layer without touching your identity, your apps or your overrides.

Finding the knob

Three ways in, and none of them is reading a reference end to end:

  • Your own config already lists them. A fresh install drops a hosts/<host>/options.nix beside your host file: every haus.* option at its default, one sentence each, all commented out. Uncomment what you want. haus options regenerates it from the build you're actually running, so it can't advertise something your machine hasn't got. If you've edited your copy it writes options.nix.new beside it rather than overwriting your work.
  • One line, no editor. haus set theme.accent teal writes the setting, type-checks it and rebuilds in one go; haus get reads one back and haus reset removes it. The haus CLI reference has the model.
  • Ask for it in words. haus installs a skill that teaches a coding agent this whole surface, generated from the revision you run; see Changing your Mac with an agent.

Scaffolded, not active

Nothing in options.nix does anything until you uncomment a line and import it; haus options prints the imports = [ ./options.nix ]; line to add if it's missing. Commented-out is deliberate, and for two reasons: a file that spelled every default out would pin every one of them against your desktop, and it would freeze haus's own defaults against ever being retuned upstream. Uncomment what you actually mean.

The cookbook

The tour of what's worth knowing. Each block is host-file Nix.

Identity

haus.git.name = "Ada Lovelace";
haus.git.email = "ada@example.com";
haus.git.signingKey = "6F7BD6F43A7C1420";  # "" disables commit signing
haus.git.org = "analytical-engine";        # the GitHub owner your work lives under

org is the odd one out: not who you are, but where you work. gh-dash reads it to fill ⌘G's PR tabs with that owner's open, green, red and just-shipped work, so an org rename is one word rather than one per tab. On its own it does nothing: set haus.hearth.ghDash.enable = true alongside it, and keep the Git pack on (haus.developer.git.enable); that's what installs gh, and turning it off while the overlay is on stops the build.

The look

haus.theme.accent = "sapphire";     # any of the 14 Catppuccin accent names
haus.wallpaper.style = "minimal";   # none | minimal | orbits | constellation | flow | bold

The accent recolours lazygit, fzf, yazi, Glow, the browser and the generated desktop together, but not the terminal, and not the bar beyond its logo pill. Single-file dotfiles that bake a palette at their own theme slot (Ghostty, starship, tmux, bat, zellij) keep their built-in colour on purpose, and a flake check fails the build if one of them ever starts following. minimal is the desktop haus generates for you (a flat field in your palette, the ⌂ mark at its centre, a bloom in your accent), and "none" keeps the wallpaper you already have. The Appearance room has the whole palette and the desktop's own dials.

The terminal

haus.hearth.editorName = "neovim";          # the ONE editor: installed, and $EDITOR
haus.hearth.hijackFileAssociations = false; # make that editor the default file opener

More in the Development room.

Apps, windows and the launcher

haus.roster.slack = { key = "s"; name = "Slack"; cask = "slack"; };
haus.prowl.enable = true;          # false: no tiling, no Caps-Lock remap
haus.pounce.enable = true;         # false: ⌘Space stays Spotlight
haus.pounce.signingIdentity =      # sign it, and its Accessibility grant survives rebuilds
  "Developer ID Application: Ada Lovelace (ABCDE12345)";

The roster is the most common edit on the machine and has a page of its own.

The bar

haus.sill.enable = true;        # false: the native macOS menu bar stays
haus.sill.items.elgato = true;  # opt-in extras, see The bar

The optional pills are personal (an Elgato key-light toggle, a Harvest timer, an agents readout), so they're off unless you ask. The bar lists them.

Homebrew behaviour

haus.homebrew.cleanup = "none";    # none | uninstall | zap: what a rebuild does to undeclared apps
haus.homebrew.autoUpdate = false;  # brew update before each rebuild
haus.homebrew.upgrade = false;     # upgrade outdated packages each rebuild

cleanup is "none" so nothing you installed by hand is ever removed; "zap" gets you a fully declarative machine where an undeclared cask is uninstalled on the next rebuild.

A rebuild keeps a cask installed, not current

autoUpdate and upgrade are off so a rebuild is reproducible: it installs a cask once, then leaves whatever version brew first laid down. That's why an app can sit on an old version on a fully synced machine. Upgrade one on demand with brew upgrade --cask <name>, or set both to true and accept that two rebuilds days apart can land different app versions.

Turning rooms off

Every room you can see has a switch, and turning one off leaves the rest working:

Set this to falseAnd you lose
haus.prowl.enableTiling, the leader key; Caps Lock stays Caps Lock
haus.sill.enableThe custom bar; the native macOS menu bar comes back
haus.pounce.enableThe ⌘Space launcher
haus.perch.enableThe notch file shelf; the copied /Applications/Perch.app stays until you delete it
haus.hush.enableThe quiet switch: bar pill, palette command and hush CLI
haus.collar.enableTouch ID for sudo, and back to the password prompt
haus.developer.enableThe Development room: language toolchains, the Git tooling, the CLI toolbelt. Sub-switches (.git, .toolbelt, .languages) take out one piece at a time
haus.ai.enableThe AI room: the agent clients, holt, and the lifecycle wiring around them
haus.snippets.enableText expansion
haus.apps.videoPlayer.enableIINA; videos keep whatever handler they have

The macOS defaults, the Homebrew policy and the haus CLI itself are the foundation the rest stands on, and have no switch. Every room you can see does, and each one is off in the blank desktop, so what's on your Mac is whatever your desktop asked for plus whatever you did.

pounce doesn't hand ⌘Space back

Turning pounce off later doesn't return ⌘Space to Spotlight: haus disables Spotlight's shortcut with a one-way write, and nothing re-enables it. Put it back yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts. (hush is the opposite case: it adds a Do Not Disturb chord macOS ships disabled, so switching the room off leaves a spare binding rather than taking one away.)

If what you want is tiling without its keyboard claims, don't switch the room off: set haus.keys.leader = "none" and haus.keys.windowNav = "none" instead, and prowl keeps arranging windows while Caps Lock stays Caps Lock. The launcher has the same escape: haus.keys.palette takes alt-space, ctrl-space or "none", and since only cmd-space displaces Spotlight, moving it is how you keep both. See rebinding.

Disagreeing with your desktop

Your host file wins. Anything the desktop chose, you change with a plain assignment (no lib.mkForce, no import order to reason about):

# the desktop set theme.accent = "mauve"; you'd rather have teal
haus.theme.accent = "teal";
haus.sill.enable = false;      # and no bar, thanks

That's the ladder doing its job: rooms declare defaults, a desktop states its choices above them, your host sits above both, and anything haus set writes sits above that. Switching desktops is one line in your flake; see Choose a desktop.

Naming a list replaces it, so restate the parts you want

Your host wins whole. A list-typed option (tour.steps, keys.leaderExtras, snippets.matches) that you name at all discards the desktop's version rather than adding to it, so if you want most of what the desktop had plus one of your own, write out all of them:

haus.snippets.matches = [
  { trigger = "@@"; replace = "ada@example.com"; }   # the desktop's, kept
  { trigger = ";sig"; replace = "— Ada"; }           # and mine
];

To take a list away entirely, use its room's switch: haus.tour.enable = false rather than an empty tour.steps, which is a type error.

Bigger type

haus.appearance.largePrint = true is one named answer to a whole-machine question, and it pulls two complementary levers. haus.ui.scale moves haus's own type together (the terminal, the palette, the bar, the Dock, Finder's sidebar, the tiling gaps), and haus.displays.main.uiScale moves the display one step toward larger text, which enlarges everything including apps haus knows nothing about. They multiply, which is the intended effect and a bigger jump than either alone. hausdisp list prints the persistent UUIDs if you want to name one display rather than "main".

One limit is macOS's rather than haus's: the menu bar's height. There is no setting for it, so the bar's type follows ui.scale only so far and then stops. Making the whole bar bigger is what display scaling is for: it changes what a point means, which is the only thing that band responds to.

Beating a default haus set

haus sets Dock, Finder, trackpad and keyboard defaults, and every one is a soft default: set the same key plainly in your host file and yours wins.

# haus hides the Dock and puts it on the bottom. Put it on the left, always shown:
system.defaults.dock.autohide = false;
system.defaults.dock.orientation = "left";

Anything under system.defaults is fair game.

Four keys that don't play by that rule

Two menu-bar keys track whether the bar is on, so haus sets them plainly rather than as mkDefaults. _HIHideMenuBar is the auto-hide: set it yourself and you get a conflicting definition and a failed evaluation, not a quiet win. That is the good outcome, because forcing it while the bar is on puts two bars in the same strip of pixels. SLSMenuBarUseBlurredAppearance (System Settings ▸ Menu Bar ▸ "Show menu bar background") haus rewrites on every activation, because Tahoe's Liquid Glass made the hidden menu bar's hover-reveal see-through; flip it by hand and the next rebuild flips it back.

One macOS ignores outright: NSGlobalDomain.AppleInterfaceStyle (light or dark). It is a real nix-darwin option, and on macOS 26 writing it does nothing: the key is where macOS mirrors the appearance it's showing, not a lever, so even a plist read-back agrees with you while nothing changes. Use haus.theme.systemAppearance; haus diff flags the other one.

And one that works, but not the way it reads: nix-darwin's own power.sleep.computer and its neighbours run systemsetup, which has no way to say which power source you mean: on macOS 26 one of them wrote the charger profile while the Mac was on battery, and nothing said so. Use haus.power.*, which says battery and charger separately.

Secrets

Your config is text in a git repo, so secret values never go in it. haus declares which secrets exist and fetches them at runtime through secretspec:

secretspec check          # what's declared and still missing
secretspec set <NAME>     # fill one in

By default they live in this Mac's login keychain, which is why they don't ride along when you clone the config onto a second machine. Point haus.secrets.provider at a cloud vault if you'd rather they followed you.

Publishing your config? Put gitleaks in front of it.

If you'd rather share the repo than keep it private, add a gitleaks pre-commit hook so a stray key never gets to be a commit, and scan the history you already have before you flip it public. Deleting a line later doesn't unpublish the key, so rotate anything it finds.

cd ~/.config/nix
printf '#!/bin/sh\nexec gitleaks git --pre-commit --staged --redact --verbose\n' \
  > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

On this page