commit 3fb6fc67c642c628b17a2944f617bad6851504a3
parent 0d80e3262c9f6b0ed4e5d7d1659358576de413df
Author: DAEMON <zer0sec.xp@icloud.com>
Date: Thu, 8 Oct 2026 06:58:51 +0100
added niri and noctalia i now have the option to choose between hypr and celestia and niri and noc
Diffstat:
119 files changed, 4830 insertions(+), 3472 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -4,3 +4,7 @@ result-*
secrets/*.dec
secrets/*.plain*
keys.txt
+
+# scratch of the plan executor and a stray empty clone, never flake source
+.superpowers/
+/NixDaemon/
diff --git a/README.md b/README.md
@@ -38,69 +38,73 @@ into `$HOME`, so editing the checkout edits the live config.
```text
NixDaemon/
-├── flake.nix inputs: nixpkgs (unstable), nixpkgs-stable (26.05, bootstrap only), home-manager,
+├── flake.nix inputs: nixpkgs (unstable), flake-parts, import-tree, wrapper-modules, home-manager,
│ hyprland, caelestia-shell, caelestia-cli, llm-agents, sops-nix, nvf
-│ outputs: nixosConfigurations.nixos (the target) and .bootstrap (stage A)
+│ outputs = flake-parts.lib.mkFlake { inherit inputs; } (import-tree ./modules)
├── .sops.yaml, secrets/ sops-nix: the age recipient and the encrypted secrets file
-├── hosts/laptop/ the machine
-│ ├── default.nix boot, users (zsh login shell), greetd/tuigreet, Hyprland (uwsm), audio, fonts,
-│ │ portals, nix-ld, LocalSend port, session environment
-│ ├── fan-throttle-guard.nix vault gpu-fan-fix/, imported unchanged; fanfix + stability_guard.py beside it
-│ ├── fan-extras.nix the performance power profile (fanfix install did this by hand on Arch)
-│ ├── uniwill-laptop.nix the `uniwill` hwmon the guard reads: kernel 6.19 driver built for this kernel
-│ ├── nvidia.nix open kernel module, panel on the dGPU, colon-free DRM device names for Hyprland
-│ ├── ssd.nix Samsung 980 crypttab + /mnt/ssd (key restored by hand, or via sops)
-│ ├── nix-settings.nix flakes, hyprland.cachix.org, nh (Nix helper) + weekly clean, nvd, nom
-│ ├── toolbox.nix envfs (foreign shebangs), ~/.local/bin first on PATH, padx udev rule
-│ ├── sops.nix sops-nix for the system: secrets/secrets.yaml → /run/secrets
-│ ├── fan-cli.nix fan-ec: EC fan control as a store script, passwordless sudo for wheel
-│ ├── fan-reference/ the Arch-era captures (fan-ctl, fan-state, units, confs) and their README
-│ └── uniwill-laptop/ the driver sources (uniwill-acpi.c, uniwill-wmi.c) and their package.nix
-├── hosts/bootstrap/ stage A: the GNOME install + fan fix + toolbox prerequisites (delete after stage B)
-├── home/ home-manager for daemonsec
-│ ├── default.nix imports the modules below
-│ ├── modules/hyprland.nix Lua config wiring, helper scripts (wallpaper-picker, keybinds-menu, …), polkit, cliphist
-│ ├── modules/caelestia.nix programs.caelestia: shell.json, the CLI with both Rosé Pine schemes, Dawn as the state
-│ ├── modules/terminal.nix kitty: DMMono Nerd Font, Rosé Pine dark, tmux-style ctrl+a keys, Neovim copy mode
-│ ├── modules/shell.nix zsh wiring (ZDOTDIR → dotfiles, fzf bindings, completions), tmux plugins, secretspec
-│ ├── modules/dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles as an out-of-store symlink
-│ ├── modules/tools.nix toolbox runtime closure, python env, the general CLI tools, `ns` (package search)
-│ ├── modules/cheats.nix the cheat cards: `nix-cheat` (rebuild, nh, secrets) and `gpg-cheat` (home/cheats/*.md)
-│ ├── modules/sops.nix sops-nix for the user; sops, age, ssh-to-age
-│ ├── modules/neovim.nix nvf: Neovim with a small Nix-built plugin set, Rosé Pine, LSPs for this machine's languages
-│ ├── modules/prompt.nix starship (two-line, one Rosé Pine colour per section) and fastfetch (NixOS logo)
-│ ├── modules/fan.nix `fan`: status and watch without root; max/auto with the clamp watchdog
-│ ├── modules/ssh.nix the ssh key (private half from sops) and ~/.ssh/config
-│ ├── modules/gpg.nix the GPG main key: public in home/gpg/, secret from sops, gpg.conf, trust
-│ ├── modules/git.nix git identity, signing with the main key, aliases, delta
-│ ├── modules/media.nix mpd user service, rmpc (full config, Rosé Pine, lyrics), mpv (gpu-next, uosc, thumbfast)
-│ ├── modules/yazi.nix yazi: inline image/video/pdf previews, openers, Rosé Pine, git + full-border plugins
-│ ├── modules/gtk.nix Yaru-purple icons, cursor, prefer-dark
-│ ├── hypr/*.lua omarchy (shim) · core · looknfeel · defaults · bindings · lid · caelestia
-│ ├── kitty/scrollback.lua kitty copy mode as a Neovim buffer
-│ ├── caelestia/ scheme.json (dark) · scheme-dawn.json · rose-pine-{dark,dawn}.txt · shell-tokens.json
-│ ├── cheats/*.md the cards: nix.md, gpg.md
-│ ├── gpg/daemon-main.pub.asc the main key's public half
-│ ├── rmpc/, mpv/shaders/, yazi/flavors/ the carried rmpc config and theme, the CfL shader, the Rosé Pine flavor
-└── modules/workstation.nix Claude Code, Claude desktop, Obsidian, gh, glab
+├── scripts/wrap.sh turns a plain module file into a flake-parts module (how the tree below was made)
+└── modules/ every *.nix here is a flake-parts module; paths containing /_ are skipped
+ ├── parts.nix systems; the home-manager and wrapper-modules flake modules
+ ├── hosts/laptop/ the machine — flake.nixosModules.laptop-* and the nixosConfiguration
+ │ ├── default.nix flake.nixosConfigurations.nixos = nixosSystem { modules = [ self.nixosModules.laptop ]; }
+ │ ├── configuration.nix self.nixosModules.laptop: imports every module below by name; daemon.desktop.* switches;
+ │ │ boot, users (zsh login shell), greetd/tuigreet, audio, fonts, portals, nix-ld, LocalSend port
+ │ ├── hardware.nix laptop-hardware (nixos-generate-config output, unchanged)
+ │ ├── fan-throttle-guard.nix laptop-fan-throttle-guard: vault gpu-fan-fix/, unchanged; fanfix + stability_guard.py beside it
+ │ ├── fan-extras.nix laptop-fan-extras: the performance power profile
+ │ ├── uniwill-laptop.nix laptop-uniwill: the `uniwill` hwmon the guard reads (uniwill-laptop/_package.nix, sources)
+ │ ├── nvidia.nix laptop-nvidia: open kernel module, panel on the dGPU, colon-free DRM names for Hyprland
+ │ ├── ssd.nix laptop-ssd: Samsung 980 crypttab + /mnt/ssd
+ │ ├── nix-settings.nix laptop-nix-settings: flakes, hyprland.cachix.org, nh + weekly clean, nvd, nom
+ │ ├── toolbox.nix laptop-toolbox: envfs, ~/.local/bin first on PATH, padx udev rule
+ │ ├── sops.nix laptop-sops: secrets/secrets.yaml → /run/secrets
+ │ ├── fan-cli.nix laptop-fan-cli: fan-ec, passwordless sudo for wheel
+ │ └── fan-reference/ the Arch-era captures and their README
+ ├── features/ shared NixOS features, by name
+ │ ├── workstation.nix workstation: Claude Code, Claude desktop, Obsidian, gh, glab
+ │ ├── home-manager.nix home-manager: HM as a NixOS module, users.daemonsec = self.homeModules.daemonsec
+ │ └── desktop/
+ │ ├── options.nix desktop-options: daemon.desktop.hyprland.enable / daemon.desktop.niri.enable (both default true)
+ │ ├── hyprland.nix desktop-hyprland: programs.hyprland (uwsm), the GTK portal — gated
+ │ ├── niri.nix packages.niri (wrapper-modules: config.kdl from Nix, binds, Rosé Pine) + desktop-niri — gated
+ │ └── noctalia.nix packages.noctalia (wrapper-modules: settings.json from Nix, Rosé Pine "Rosepine" scheme)
+ └── home/ flake.homeModules.* — the user's home
+ ├── default.nix homeModules.daemonsec: imports every module below by name
+ ├── hyprland.nix Lua config wiring, helper scripts, polkit, cliphist — only with daemon.desktop.hyprland
+ ├── caelestia.nix programs.caelestia: shell.json, the CLI with both Rosé Pine schemes — same gate
+ ├── terminal.nix kitty: DMMono Nerd Font, Rosé Pine dark, tmux-style ctrl+a keys, Neovim copy mode
+ ├── shell.nix zsh wiring (ZDOTDIR → dotfiles, fzf bindings, completions), tmux plugins, secretspec
+ ├── dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles as an out-of-store symlink
+ ├── tools.nix toolbox runtime closure, python env, the general CLI tools, `ns` (package search)
+ ├── cheats.nix the cheat cards: `nix-cheat` and `gpg-cheat` (cheats/*.md)
+ ├── sops.nix sops-nix for the user; sops, age, ssh-to-age
+ ├── neovim.nix nvf: Neovim with a small Nix-built plugin set, Rosé Pine
+ ├── prompt.nix starship and fastfetch
+ ├── fan.nix `fan`: status and watch without root; max/auto with the clamp watchdog
+ ├── ssh.nix, gpg.nix, git.nix keys from sops, ~/.ssh/config, gpg.conf, git identity and signing
+ ├── media.nix mpd, rmpc, mpv
+ ├── yazi.nix, gtk.nix yazi with previews and Rosé Pine; Yaru-purple icons, cursor, prefer-dark
+ ├── hypr/*.lua omarchy (shim) · core · looknfeel · defaults · bindings · lid · caelestia
+ ├── kitty/, caelestia/, cheats/, gpg/, rmpc/, mpv/, yazi/ the data those modules read
```
## What runs
| Layer | Choice | Where |
|---|---|---|
-| Boot, login | systemd-boot · greetd + tuigreet (remembers user and session) | hosts/laptop/default.nix |
-| Compositor | Hyprland 0.56 under uwsm, configured in Lua; springy overshoot animations, square corners, blur | home/hypr/, home/modules/hyprland.nix |
-| Shell UI | Caelestia Shell: bar, launcher, dashboard, sidebar, lock, OSD, notifications. Rosé Pine dark, framed bar, 340 px panels | home/modules/caelestia.nix, home/caelestia/ |
-| Terminal | kitty, DMMono Nerd Font, Rosé Pine dark, `ctrl+a` tmux-style prefix, copy mode in Neovim | home/modules/terminal.nix |
-| Shell | zsh as login shell; config = the dotfiles' `~/.dotfiles` tree (autosuggestions, syntax highlighting, fzf-tab, zoxide, atuin). Prompt and fetch are Nix-managed | home/modules/shell.nix, prompt.nix |
-| Dotfiles | symlinked from `~/git/daemon-sec-dotfiles/home` | home/modules/dotfiles.nix |
-| Editor | Neovim via nvf: treesitter, LSP, blink completion, telescope, oil, gitsigns, which-key, Rosé Pine | home/modules/neovim.nix |
-| Tools | uv, nodejs, btop, yazi, lazygit, tmux, ripgrep/fd/fzf/bat/eza, delta, tldr, LocalSend, … | home/modules/tools.nix |
-| Secrets | sops-nix (age) for the repo; secretspec (keyring) for projects | hosts/laptop/sops.nix, home/modules/sops.nix, shell.nix |
-| GPU | NVIDIA open module, panel on the RTX; Aquamarine told the GPUs by colon-free udev symlinks | hosts/laptop/nvidia.nix |
-| Fan | `fan-throttle-guard`: 3.2 GHz floor, EC auto fan, `uniwill` hwmon from a 6.19 driver. `fan` command: status, watch, max/auto with a clamp watchdog | hosts/laptop/fan-*.nix, uniwill-laptop/, home/modules/fan.nix |
-| Nix | flakes, Hyprland cache, `nh` with weekly `clean all --keep 5 --keep-since 14d` | hosts/laptop/nix-settings.nix |
+| Boot, login | systemd-boot · greetd + tuigreet (remembers user and session) | modules/hosts/laptop/configuration.nix |
+| Compositor | Hyprland 0.56 under uwsm, configured in Lua; springy overshoot animations, square corners, blur | modules/home/hypr/, modules/home/hyprland.nix |
+| Shell UI | Caelestia Shell: bar, launcher, dashboard, sidebar, lock, OSD, notifications. Rosé Pine dark, framed bar, 340 px panels | modules/home/caelestia.nix, modules/home/caelestia/ |
+| Second desktop | Niri + Noctalia Shell (Rosé Pine "Rosepine"), both as wrapped packages: `nix run ~/NixDaemon#niri` / `#noctalia`. Pick the session in tuigreet; `daemon.desktop.{hyprland,niri}.enable` in configuration.nix drop one | modules/features/desktop/ |
+| Terminal | kitty, DMMono Nerd Font, Rosé Pine dark, `ctrl+a` tmux-style prefix, copy mode in Neovim | modules/home/terminal.nix |
+| Shell | zsh as login shell; config = the dotfiles' `~/.dotfiles` tree (autosuggestions, syntax highlighting, fzf-tab, zoxide, atuin). Prompt and fetch are Nix-managed | modules/home/shell.nix, prompt.nix |
+| Dotfiles | symlinked from `~/git/daemon-sec-dotfiles/home` | modules/home/dotfiles.nix |
+| Editor | Neovim via nvf: treesitter, LSP, blink completion, telescope, oil, gitsigns, which-key, Rosé Pine | modules/home/neovim.nix |
+| Tools | uv, nodejs, btop, yazi, lazygit, tmux, ripgrep/fd/fzf/bat/eza, delta, tldr, LocalSend, … | modules/home/tools.nix |
+| Secrets | sops-nix (age) for the repo; secretspec (keyring) for projects | modules/hosts/laptop/sops.nix, modules/home/sops.nix, shell.nix |
+| GPU | NVIDIA open module, panel on the RTX; Aquamarine told the GPUs by colon-free udev symlinks | modules/hosts/laptop/nvidia.nix |
+| Fan | `fan-throttle-guard`: 3.2 GHz floor, EC auto fan, `uniwill` hwmon from a 6.19 driver. `fan` command: status, watch, max/auto with a clamp watchdog | modules/hosts/laptop/fan-*.nix, uniwill-laptop/, modules/home/fan.nix |
+| Nix | flakes, Hyprland cache, `nh` with weekly `clean all --keep 5 --keep-since 14d` | modules/hosts/laptop/nix-settings.nix |
## Setting it up
@@ -133,19 +137,24 @@ nix flake show "$REPO"
1. Boot the NixOS live ISO, partition and mount at `/mnt`; generate
`hardware-configuration.nix` with `nixos-generate-config --root /mnt` and
- compare it with `hosts/laptop/hardware-configuration.nix` (UUIDs).
-2. `git clone git@gitlab.com:DAEMON-404/NixDaemon.git && cd NixDaemon`, drop
- the new hardware file into `hosts/laptop/`, `git add` it.
+ compare it with `modules/hosts/laptop/hardware.nix` (UUIDs).
+2. `git clone git@gitlab.com:DAEMON-404/NixDaemon.git && cd NixDaemon`, paste
+ the generated file's body into `modules/hosts/laptop/hardware.nix` (inside
+ the `flake.nixosModules.laptop-hardware =` wrapper). Do not drop a raw
+ `hardware-configuration.nix` into `modules/`: import-tree would load it as a
+ flake-parts module and evaluation would fail.
3. `sudo nixos-install --flake .#nixos`, reboot, log in at tuigreet, pick
**Hyprland (UWSM)** once.
4. Clone the dotfiles to `~/git/daemon-sec-dotfiles` (the links in
- `home/modules/dotfiles.nix` point there) and the toolbox to `~/.local/bin`.
+ `modules/home/dotfiles.nix` point there) and the toolbox to `~/.local/bin`.
5. Restore the age key to `~/.config/sops/age/keys.txt` and copy it for the
system (see Secrets), then the Samsung SSD key (below).
-### The staged migration this repo was built for (2026-10-07)
+### The staged migration this repo was built for (2026-10-07, done)
-Everything below needs `sudo`, so it was left to the owner.
+History, kept as a record. Stage A (`#bootstrap`, built from `nixpkgs-stable`)
+no longer exists: the dendritic rewrite of 2026-10-08 removed it, so none of
+the `#bootstrap` commands below work any more.
**Stage A: fan fix now, on the GNOME install.** Small switch (fan module,
driver, toolbox prerequisites, Hyprland cache). The new kernel modules only
@@ -195,7 +204,7 @@ caelestia.lua takes over carry their Caelestia descriptions; the stock
Obsidian and YouTube lines are gone because vault-open and bakx own those
keys; the two webcam-overlay binds were not carried (keycodes unknown).
-Afterwards delete `hosts/bootstrap/` and the `nixpkgs-stable` input.
+`hosts/bootstrap/` and the `nixpkgs-stable` input were deleted on 2026-10-08.
**Samsung SSD key** (vault samsung-ssd.md, section 2; needs the gpg passphrase):
@@ -209,13 +218,13 @@ sudo systemctl restart systemd-cryptsetup@ssd.service mnt-ssd.mount # or just
Until then the drive stays locked; both units are `nofail`, so boot is
unaffected. The sops way: `sops set secrets/secrets.yaml '["ssd.key"]' "\"$(gpg -d ssd.key.gpg)\""`,
-then `sops.secrets."ssd.key".path = "/etc/secrets/ssd.key";` in hosts/laptop/sops.nix.
+then `sops.secrets."ssd.key".path = "/etc/secrets/ssd.key";` in modules/hosts/laptop/sops.nix.
## The shell
-zsh is the login shell (hosts/laptop/default.nix) and its configuration is
+zsh is the login shell (modules/hosts/laptop/configuration.nix) and its configuration is
the dotfiles checkout's `home/.dotfiles` tree, reached through a Nix-managed
-`~/.zshenv` that sets `ZDOTDIR=~/.dotfiles` (home/modules/shell.nix). The
+`~/.zshenv` that sets `ZDOTDIR=~/.dotfiles` (modules/home/shell.nix). The
plugins (autosuggestions, syntax highlighting, zsh-completions, fzf-tab,
history-substring-search, you-should-use) are the copies vendored in that
tree; starship, zoxide, atuin and fzf come from nixpkgs. Edit
@@ -226,7 +235,7 @@ key bindings from the store, core.zsh only knows the Arch paths), the tool
configs linked into `~/.config` by dotfiles.nix (bat theme, atuin, crossfetch
for the splash animation, cheats, eza, btop, yazi, lazygit, carapace, and the
rest of the checkout's .config; the starship prompt and the fastfetch card are
-Nix-managed in home/modules/prompt.nix), `~/.tmux.conf` with
+Nix-managed in modules/home/prompt.nix), `~/.tmux.conf` with
its plugins from nixpkgs behind a TPM stand-in, and the binaries modern.zsh
aliases (btop, dust, duf, procs, delta, tldr, hyperfine, glow, onefetch,
lazygit, yazi, neovim). Not carried: `mise` (its downloaded runtimes
@@ -236,7 +245,7 @@ machine; `~/.gitconfig` stays).
## The dotfiles
-`home/modules/dotfiles.nix` mirrors `~/git/daemon-sec-dotfiles/home/` into
+`modules/home/dotfiles.nix` mirrors `~/git/daemon-sec-dotfiles/home/` into
`$HOME` with out-of-store symlinks: editing the checkout edits the live
config, and a rebuild is only needed to add or remove a path in the list.
The header of that file names what is deliberately not linked (hypr and
@@ -260,7 +269,7 @@ and envfs resolves the `#!/bin/bash` and `#!/usr/bin/python3` shebangs.
quietly); `clipboard-backup` and `omarchy-menu-tmux-keybindings` are bound
but not in the carried `bin/`.
- The cheat popups (`omarchy-menu-kitty`, …) pipe into `omarchy-menu-select`,
- provided here as a fuzzel wrapper (home/modules/hyprland.nix). `nix-cheat`
+ provided here as a fuzzel wrapper (modules/home/hyprland.nix). `nix-cheat`
and `gpg-cheat` are this repo's own cards, in the same style.
## Secrets
@@ -268,8 +277,8 @@ and envfs resolves the `#!/bin/bash` and `#!/usr/bin/python3` shebangs.
Two tools. **sops-nix** keeps secrets *in this repo*, encrypted with age
(`.sops.yaml` names the recipient, `secrets/secrets.yaml` holds the values)
and decrypts them at activation: `/run/secrets/NAME` for the system
-(hosts/laptop/sops.nix), `~/.config/sops-nix/secrets/NAME` for the user
-(home/modules/sops.nix). The age key is `~/.config/sops/age/keys.txt`,
+(modules/hosts/laptop/sops.nix), `~/.config/sops-nix/secrets/NAME` for the user
+(modules/home/sops.nix). The age key is `~/.config/sops/age/keys.txt`,
created on 2026-10-08 and **not in the repo**; back it up (vault) and give
the system its copy once:
@@ -281,8 +290,8 @@ Edit with `sops secrets/secrets.yaml`, declare with `sops.secrets.NAME = { };`,
rebuild. `nix-cheat secrets` has the commands.
What the file holds today: `ssh_id_ed25519` (the SSH private key, used by
-home/modules/ssh.nix), `gpg_main_secret` (the armored GPG secret key, still
-under its own passphrase, imported by home/modules/gpg.nix on first
+modules/home/ssh.nix), `gpg_main_secret` (the armored GPG secret key, still
+under its own passphrase, imported by modules/home/gpg.nix on first
activation), and `example`. So a fresh install needs exactly one secret
restored by hand, the age key; ssh, gpg and git then come up from the flake.
@@ -298,10 +307,10 @@ add NAME`, `secretspec check`, `secretspec run -- cmd`.
framed bar: a 10 px border with 25 px rounded inner corners, clock and
tray in pills, filled occupied workspaces, the Nix snowflake as the logo.
Sidebar, utilities and notification panels are narrower than stock
- (`home/caelestia/shell-tokens.json`). A Dawn mapping is registered too:
+ (`modules/home/caelestia/shell-tokens.json`). A Dawn mapping is registered too:
`caelestia scheme set -n rose-pine -f rose-pine-dawn`.
- **Bar shows the program**, not the window title: a small patch to the
- shell's ActiveWindow component (`home/caelestia/active-window-program-name.patch`,
+ shell's ActiveWindow component (`modules/home/caelestia/active-window-program-name.patch`,
applied in caelestia.nix) makes compact mode use the desktop entry's name
for the window class. The shell compiles locally because of it.
- **More shell**: desktop clock on the wallpaper (bottom right), audio
@@ -310,17 +319,17 @@ add NAME`, `secretspec check`, `secretspec run -- cmd`.
the wallpaper, a toast on track change, vim keys in the launcher, and
idle: lock after 15 min, screen off after 20 (audio or an inhibitor holds
both off).
-- **Springy windows**: `home/hypr/looknfeel.lua` carries the dotfiles'
+- **Springy windows**: `modules/home/hypr/looknfeel.lua` carries the dotfiles'
animation rice (overshoot curves on move/resize/open, shadows, blur
tuning, drag snapping, swallow, 3-finger workspace swipe, 4-finger-up
fullscreen). Loaded after core.lua.
-- **kitty, tmux-style** (`ctrl+a` prefix; table in home/modules/terminal.nix):
+- **kitty, tmux-style** (`ctrl+a` prefix; table in modules/home/terminal.nix):
`c` tab, `-`/`|` splits, `hjkl` panes, `z` zoom, `[` copy mode in Neovim
(`v` select, `y` copy, Enter copy and leave, `q`), `]` paste, `1..9` tabs,
`ctrl+a ctrl+a` sends a real ctrl+a to the shell.
- `CTRL+SUPER+SPACE` opens the Caelestia launcher in its wallpaper grid
(helper `wallpaper-picker`); `>wallpaper name` filters. The directory is
- `paths.wallpaperDir` in home/modules/caelestia.nix.
+ `paths.wallpaperDir` in modules/home/caelestia.nix.
- Keys to try first: SUPER+RETURN terminal, SUPER+SPACE launcher,
SUPER+ESCAPE session menu, SUPER+K keybindings list, SUPER+M window mode,
SUPER+` dropdown terminal, PRINT screenshot.
@@ -330,7 +339,7 @@ add NAME`, `secretspec check`, `secretspec run -- cmd`.
`stability_guard.py` reads the `uniwill` hwmon (board GPU temperature, main
fan rpm) and falls back to a permanent 1.8 GHz ceiling without it. The driver
was merged upstream in Linux 6.19; nixpkgs' 6.18 kernel predates it and the
-7.2 kernel config leaves its Kconfig submenu off. `hosts/laptop/uniwill-laptop/`
+7.2 kernel config leaves its Kconfig submenu off. `modules/hosts/laptop/uniwill-laptop/`
holds the v6.19 sources and builds them as an out-of-tree module against
whatever kernel is selected (verified on 6.18.55). Revisit when the default
NixOS kernel ships it.
@@ -339,7 +348,7 @@ NixOS kernel ships it.
- **ANSI green**: Rosé Pine puts pine (#31748f) in the green slot; the rule
says pine is never ink. kitty uses foam (#9ccfd8) for color2/color10
- meanwhile; one variable in home/modules/terminal.nix.
+ meanwhile; one variable in modules/home/terminal.nix.
- **Display manager**: greetd + tuigreet chosen (text greeter, remembers
user and session). sddm would be a one-file swap.
- **GPU / MUX**: configured for what the firmware presents, the panel on the
@@ -348,7 +357,7 @@ NixOS kernel ships it.
- **Hostname** stays `nixos` (the installer's). The flake output is also `nixos`.
- Stock Omarchy keys whose program is still not installed (spotify,
1password, signal) show a notification saying so. Add packages to
- home/modules/tools.nix when wanted.
+ modules/home/tools.nix when wanted.
---
diff --git a/docs/superpowers/plans/2026-10-08-dendritic-niri-noctalia.md b/docs/superpowers/plans/2026-10-08-dendritic-niri-noctalia.md
@@ -0,0 +1,375 @@
+# Dendritic NixDaemon with Niri + Noctalia Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Turn `~/NixDaemon` into a flake-parts + import-tree (dendritic) flake and add a Niri + Noctalia desktop beside Hyprland + Caelestia, each switchable with one boolean.
+
+**Architecture:** `flake.nix` becomes `flake-parts.lib.mkFlake { inherit inputs; } (import-tree ./modules)`; every file under `modules/` is a flake-parts module that declares `flake.nixosModules.<name>`, `flake.homeModules.<name>`, `flake.nixosConfigurations.nixos` or `perSystem.packages.<name>`, and everything is wired by name through `self`. Existing NixOS/home-manager module bodies move unchanged. Niri and Noctalia are wrapper-modules packages (`perSystem.packages.niri` / `.noctalia`) consumed by a gated `programs.niri` NixOS module.
+
+**Tech Stack:** NixOS unstable, flake-parts, import-tree, `github:BirdeeHub/nix-wrapper-modules`, home-manager (as a NixOS module), niri 26.04, noctalia-shell 4.7.7.
+
+**Spec:** `docs/superpowers/specs/2026-10-08-dendritic-niri-noctalia-design.md`
+
+## Global Constraints
+
+- Module bodies are carried over unchanged; only the wrapping and relative paths change.
+- Rosé Pine Main only (base `#191724`, rose `#ebbcba`, overlay `#26233a`); never Moon.
+- import-tree imports every `*.nix` under `modules/`; a path containing `/_` is skipped. Non-module `.nix` files must be renamed with a leading underscore.
+- New files are invisible to the flake until `git add`; every task ends with `git add -A`. No commits: the owner commits with jj.
+- `bootstrap` configuration and the `nixpkgs-stable` input are deleted.
+- Nothing is switched live until Task 8; `nvd diff` must show no removals before `nh os switch`.
+- Working directory for every command: `~/NixDaemon`.
+
+## Review Focus
+
+1. A home module referencing a data file by the old `../x` path evaluates to a missing-path error only when that option is used; the toplevel build in Task 5 exercises all of them. Pinned by Task 5 step 3.
+2. `daemon.desktop.hyprland.enable = false` must drop the Hyprland session and Caelestia from the closure without an evaluation error (the Hyprland and Caelestia HM modules from `sharedModules` still exist and must stay inert). Pinned by Task 4 step 6.
+3. Both switches `false` must fail evaluation with the assertion message, not build a system with no login session. Pinned by Task 4 step 7.
+4. The Niri config must pass `niri validate` (the wrapper runs it at build time); a typo in a bind name fails the build, not the login. Pinned by Task 7 step 3.
+5. Noctalia must start with Rosé Pine without a writable config dir (settings come from the store). Pinned by Task 6 step 4 (nested run shows the Rosé Pine bar).
+
+---
+
+### Task 1: Flake skeleton and inputs
+
+**Files:**
+- Modify: `flake.nix`
+- Create: `modules/parts.nix`
+- Delete: `hosts/bootstrap/default.nix`
+
+**Interfaces:**
+- Produces: inputs `flake-parts`, `import-tree`, `wrapper-modules`; flake-parts modules receive `{ self, inputs, ... }`; `perSystem` receives `{ pkgs, lib, self', ... }`; `flake.homeModules.*` exists (from home-manager's flake module); `wrappers` are reachable as `inputs.wrapper-modules.wrappers.<name>`.
+
+- [ ] **Step 1: Rewrite `flake.nix`**
+
+Keep the description and every existing input except `nixpkgs-stable`; add
+```nix
+flake-parts.url = "github:hercules-ci/flake-parts";
+flake-parts.inputs.nixpkgs-lib.follows = "nixpkgs";
+import-tree.url = "github:vic/import-tree";
+wrapper-modules.url = "github:BirdeeHub/nix-wrapper-modules";
+```
+Replace the whole `outputs` with
+```nix
+outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);
+```
+Carry the explanatory comments on the inputs over; drop the `bootstrap` comment block.
+
+- [ ] **Step 2: Create `modules/parts.nix`**
+
+```nix
+# modules/parts.nix — flake-parts wiring shared by every module under modules/.
+{ inputs, ... }:
+{
+ systems = [ "x86_64-linux" ];
+ imports = [
+ inputs.home-manager.flakeModules.home-manager # flake.homeModules / homeConfigurations
+ inputs.wrapper-modules.flakeModules.default # flake.wrappers, perSystem.wrappers
+ ];
+}
+```
+
+- [ ] **Step 3: Delete `hosts/bootstrap/`, move the old `modules/workstation.nix` aside**
+
+`git rm -r hosts/bootstrap`; `git mv modules/workstation.nix modules/_workstation.nix.old` (Task 4 turns it into `modules/features/workstation.nix`; the underscore keeps import-tree off it meanwhile).
+
+- [ ] **Step 4: Lock and check the empty flake**
+
+Run: `git add -A && nix flake lock && nix flake show 2>&1 | tail -20`
+Expected: `flake.lock` gains `flake-parts`, `import-tree`, `wrapper-modules` and loses `nixpkgs-stable`; `nix flake show` prints an outputs tree with `homeModules`, `nixosModules` (empty) and `packages.x86_64-linux` (empty), no error.
+
+### Task 2: Host modules become `flake.nixosModules.laptop-*`
+
+**Files:**
+- Move: `hosts/laptop/*` → `modules/hosts/laptop/*` (with `hardware-configuration.nix` → `hardware.nix`, `uniwill-laptop/package.nix` → `uniwill-laptop/_package.nix`); `hosts/laptop/default.nix` → `modules/hosts/laptop/_old-default.nix` (consumed by Task 4, then deleted)
+- Create: `scripts/wrap.sh` (helper; outside `modules/`, so import-tree never sees it)
+
+**Interfaces:**
+- Produces: `self.nixosModules.laptop-hardware`, `laptop-nvidia`, `laptop-ssd`, `laptop-nix-settings`, `laptop-sops`, `laptop-toolbox`, `laptop-fan-cli`, `laptop-fan-extras`, `laptop-fan-throttle-guard`, `laptop-uniwill`. Each is the unchanged NixOS module function `{ config, pkgs, lib, inputs, user, ... }: { … }`.
+
+- [ ] **Step 1: Write the wrapping helper `scripts/wrap.sh`**
+
+Usage: `scripts/wrap.sh FILE ATTR` rewrites FILE in place so that its leading comment block stays first, followed by
+```
+{ ... }:
+{
+ ATTR =
+ <original module, every line indented by two spaces>;
+}
+```
+Algorithm: split at the first line that does not start with `#` (and is not blank); print the comment lines, then the header, then the rest with ` ` prefixed to non-empty lines, then `;` on its own line (indented two spaces) and `}`. Implement in bash + awk.
+
+- [ ] **Step 2: Move the host files**
+
+```bash
+mkdir -p modules/hosts/laptop
+git mv hosts/laptop/hardware-configuration.nix modules/hosts/laptop/hardware.nix
+for f in nvidia ssd nix-settings sops toolbox fan-cli fan-extras fan-throttle-guard uniwill-laptop; do git mv hosts/laptop/$f.nix modules/hosts/laptop/$f.nix; done
+git mv hosts/laptop/default.nix modules/hosts/laptop/_old-default.nix
+git mv hosts/laptop/uniwill-laptop modules/hosts/laptop/uniwill-laptop
+git mv modules/hosts/laptop/uniwill-laptop/package.nix modules/hosts/laptop/uniwill-laptop/_package.nix
+git mv hosts/laptop/fanfix hosts/laptop/stability_guard.py modules/hosts/laptop/
+rmdir hosts/laptop hosts
+```
+
+- [ ] **Step 3: Wrap each file**
+
+`scripts/wrap.sh modules/hosts/laptop/<file>.nix flake.nixosModules.laptop-<name>` with names: `hardware`, `nvidia`, `ssd`, `nix-settings`, `sops`, `toolbox`, `fan-cli`, `fan-extras`, `fan-throttle-guard`, and `uniwill-laptop.nix` → `laptop-uniwill`.
+
+- [ ] **Step 4: Fix the paths that moved relative to their targets**
+
+- `modules/hosts/laptop/sops.nix`: `defaultSopsFile = ../../../secrets/secrets.yaml;`
+- `modules/hosts/laptop/uniwill-laptop.nix`: `./uniwill-laptop/_package.nix`
+- `fan-throttle-guard.nix` (`./fanfix`, `./stability_guard.py`) needs no change.
+
+- [ ] **Step 5: Verify the module set evaluates**
+
+Run: `git add -A && nix eval .#nixosModules --apply 'm: builtins.attrNames m'`
+Expected: `[ "laptop-fan-cli" "laptop-fan-extras" "laptop-fan-throttle-guard" "laptop-hardware" "laptop-nix-settings" "laptop-nvidia" "laptop-sops" "laptop-ssd" "laptop-toolbox" "laptop-uniwill" ]`
+
+### Task 3: Home modules become `flake.homeModules.*`
+
+**Files:**
+- Move: `home/modules/*.nix` → `modules/home/*.nix`; `home/{hypr,caelestia,kitty,cheats,gpg,rmpc,mpv,yazi}` → `modules/home/…`; `home/default.nix` → `modules/home/default.nix` (rewritten)
+
+**Interfaces:**
+- Consumes: `osConfig` (home-manager passes the NixOS config to HM modules when run as a NixOS module).
+- Produces: `self.homeModules.<name>` for `caelestia cheats dotfiles fan git gpg gtk hyprland media neovim prompt shell sops ssh terminal tools yazi`, and `self.homeModules.daemonsec` that imports all of them.
+
+- [ ] **Step 1: Move files**
+
+```bash
+mkdir -p modules/home
+for f in home/modules/*.nix; do git mv "$f" modules/home/; done
+for d in hypr caelestia kitty cheats gpg rmpc mpv yazi; do [ -e home/$d ] && git mv home/$d modules/home/$d; done
+git mv home/default.nix modules/home/default.nix
+rmdir home/modules home
+```
+
+- [ ] **Step 2: Wrap each module**
+
+`scripts/wrap.sh modules/home/<name>.nix flake.homeModules.<name>` for every file except `default.nix`.
+
+- [ ] **Step 3: Fix relative paths** (`../x` → `./x`)
+
+- `hyprland.nix`: seven `../hypr/*.lua` → `./hypr/*.lua`
+- `caelestia.nix`: `../caelestia/…` (five occurrences) → `./caelestia/…`
+- `terminal.nix`: `../kitty/scrollback.lua` → `./kitty/scrollback.lua`
+- `gpg.nix`: `../gpg/daemon-main.pub.asc` → `./gpg/…`
+- `media.nix`: `../rmpc/…` (three) and `../mpv/shaders` → `./…`
+- `cheats.nix`: `../cheats` → `./cheats`
+- `yazi.nix`: `../yazi/flavors/rose-pine.yazi` → `./yazi/…`
+- `sops.nix`: `../../secrets/secrets.yaml` → `../../secrets/secrets.yaml` is unchanged in depth (`modules/home/` is two levels below the root, as `home/modules/` was). Verify with `ls modules/home/../../secrets/secrets.yaml`.
+
+- [ ] **Step 4: Gate the Hyprland-only modules on the NixOS switch**
+
+In `modules/home/hyprland.nix` and `modules/home/caelestia.nix` the wrapped function becomes `{ config, pkgs, lib, inputs, osConfig, ... }:` and its body set is wrapped as `lib.mkIf osConfig.daemon.desktop.hyprland.enable { … }`. The `let` block above the set stays outside the `mkIf`.
+
+- [ ] **Step 5: Rewrite `modules/home/default.nix`**
+
+```nix
+# modules/home/default.nix — the user's home-manager configuration: every
+# home module in this directory, by name.
+{ self, ... }:
+{
+ flake.homeModules.daemonsec = { user, ... }: {
+ imports = with self.homeModules; [
+ hyprland caelestia terminal tools shell dotfiles cheats sops neovim prompt fan ssh gpg git media yazi gtk
+ ];
+ home = { username = user; homeDirectory = "/home/${user}"; stateVersion = "26.05"; };
+ programs.home-manager.enable = true;
+ xdg.enable = true;
+ };
+}
+```
+Carry the one-line per-module comments from the old file onto the import list.
+
+- [ ] **Step 6: Verify**
+
+Run: `git add -A && nix eval .#homeModules --apply 'm: builtins.length (builtins.attrNames m)'`
+Expected: `18`
+
+### Task 4: Features, desktop switches, host configuration
+
+**Files:**
+- Create: `modules/features/workstation.nix` (from `modules/_workstation.nix.old`), `modules/features/home-manager.nix`, `modules/features/desktop/options.nix`, `modules/features/desktop/hyprland.nix`, `modules/hosts/laptop/configuration.nix`, `modules/hosts/laptop/default.nix`
+- Delete: `modules/hosts/laptop/_old-default.nix`, `modules/_workstation.nix.old`
+
+**Interfaces:**
+- Produces: `self.nixosModules.workstation`, `home-manager`, `desktop-options`, `desktop-hyprland`, `laptop`; `self.nixosConfigurations.nixos`; NixOS options `daemon.desktop.hyprland.enable`, `daemon.desktop.niri.enable` (bool, default true).
+
+- [ ] **Step 1: `modules/features/workstation.nix`**
+
+`git mv modules/_workstation.nix.old modules/features/workstation.nix`, then `scripts/wrap.sh modules/features/workstation.nix flake.nixosModules.workstation`.
+
+- [ ] **Step 2: `modules/features/desktop/options.nix`**
+
+Exactly the module in the spec's "The desktop switch" section, declared as `flake.nixosModules.desktop-options`.
+
+- [ ] **Step 3: `modules/features/desktop/hyprland.nix`**
+
+`flake.nixosModules.desktop-hyprland = { config, lib, pkgs, inputs, ... }: lib.mkIf config.daemon.desktop.hyprland.enable { imports … }` cannot carry `imports` inside `mkIf`, so structure it as
+```nix
+{ imports = [ inputs.hyprland.nixosModules.default ];
+ config = lib.mkIf config.daemon.desktop.hyprland.enable {
+ programs.hyprland = { enable = true; withUWSM = true; xwayland.enable = true; };
+ xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
+ }; }
+```
+These two blocks are cut from `_old-default.nix` (the `##### Desktop` section) with their comments.
+
+- [ ] **Step 4: `modules/features/home-manager.nix`**
+
+`flake.nixosModules.home-manager = { inputs, user, ... }: { imports = [ inputs.home-manager.nixosModules.home-manager ]; home-manager = { useGlobalPkgs = true; useUserPackages = true; backupFileExtension = "hm-bak"; extraSpecialArgs = { inherit inputs user; }; sharedModules = [ inputs.hyprland.homeManagerModules.default inputs.caelestia-shell.homeManagerModules.default ]; users.${user} = self.homeModules.daemonsec; }; }` — the block from the old `flake.nix`, with the comment about Caelestia/Hyprland pins.
+
+- [ ] **Step 5: `modules/hosts/laptop/configuration.nix` and `default.nix`**
+
+`configuration.nix`: `flake.nixosModules.laptop = { config, pkgs, lib, user, ... }: { imports = with self.nixosModules; [ laptop-hardware laptop-fan-throttle-guard laptop-fan-extras laptop-uniwill laptop-nvidia laptop-ssd laptop-nix-settings laptop-toolbox laptop-sops laptop-fan-cli workstation home-manager desktop-options desktop-hyprland ]; daemon.desktop = { hyprland.enable = true; niri.enable = true; }; … }` where `…` is the body of `_old-default.nix` minus its `imports` and minus the two blocks moved in Step 3. Keep the file header comment and the per-import comments. (`desktop-niri` is added to this list in Task 7.)
+
+`default.nix`:
+```nix
+{ self, inputs, ... }:
+{
+ flake.nixosConfigurations.nixos = inputs.nixpkgs.lib.nixosSystem {
+ system = "x86_64-linux";
+ specialArgs = { inherit inputs; user = "daemonsec"; };
+ modules = [ self.nixosModules.laptop ];
+ };
+}
+```
+Then `git rm modules/hosts/laptop/_old-default.nix`.
+
+- [ ] **Step 6: Verify the system evaluates, and that the Hyprland switch is inert when off**
+
+Run: `git add -A && nix eval .#nixosConfigurations.nixos.config.system.build.toplevel.drvPath`
+Expected: a `/nix/store/…-nixos-system-nixos-….drv` path.
+
+Run: `nix eval --impure --expr '(builtins.getFlake (toString ./.)).nixosConfigurations.nixos.extendModules { modules = [ ({ lib, ... }: { daemon.desktop.hyprland.enable = lib.mkForce false; }) ]; }' --apply 'c: [ c.config.programs.hyprland.enable c.config.home-manager.users.daemonsec.wayland.windowManager.hyprland.enable c.config.home-manager.users.daemonsec.programs.caelestia.enable ]'`
+Expected: `[ false false false ]` (mkForce: the host file sets the switch explicitly; the lambda is parenthesised because it sits in a list)
+
+- [ ] **Step 7: Verify the assertion**
+
+Run: `nix eval --impure --expr '((builtins.getFlake (toString ./.)).nixosConfigurations.nixos.extendModules { modules = [ ({ lib, ... }: { daemon.desktop.hyprland.enable = lib.mkForce false; daemon.desktop.niri.enable = lib.mkForce false; }) ]; }).config.system.build.toplevel.drvPath' 2>&1 | grep -c 'enable at least one desktop'`
+Expected: `1`
+
+(`daemon.desktop.niri.enable` exists from Step 2 even before Task 7 wires it.)
+
+### Task 5: Build parity with today's system
+
+**Files:** none
+
+- [ ] **Step 1: Flake check**
+
+Run: `nix flake check`
+Expected: exits 0.
+
+- [ ] **Step 2: Build and diff against the running system**
+
+Run: `nh os build && nvd diff /run/current-system result`
+Expected: the only lines are version-neutral (`No version or selection state changes.`) or additions; the `Removed packages` section is absent. If anything is removed, the port lost a module: find which `self.*` name is missing from an import list and fix before continuing.
+
+- [ ] **Step 3: Home activation builds**
+
+Run: `nix build .#nixosConfigurations.nixos.config.home-manager.users.daemonsec.home.activationPackage --no-link --print-out-paths`
+Expected: a store path (this forces every `./hypr`, `./caelestia`, `./kitty`, `./cheats`, `./gpg`, `./rmpc`, `./mpv`, `./yazi` path to resolve).
+
+### Task 6: Noctalia package
+
+**Files:**
+- Create: `modules/features/desktop/noctalia.nix`
+
+**Interfaces:**
+- Produces: `self'.packages.noctalia` / `self.packages.x86_64-linux.noctalia` (wrapped `noctalia-shell`; `lib.getExe` gives the `noctalia-shell` binary; `bin/dump-noctalia-shell` also present).
+
+- [ ] **Step 1: Write the module**
+
+```nix
+{ inputs, ... }:
+{
+ perSystem = { pkgs, ... }: {
+ packages.noctalia = inputs.wrapper-modules.wrappers.noctalia-shell.wrap {
+ inherit pkgs;
+ settings = { … };
+ };
+ };
+}
+```
+`settings` is the attribute set listed in the spec's "Noctalia" section (`colorSchemes`, `bar`, `general`, `ui`, `wallpaper` with `directory = "/home/daemonsec/git/daemon-sec-dotfiles/home/.config/omarchy/backgrounds/rose-pine-dark"`, `location`, `appLauncher`, `idle`). Add a header comment: how to export changes made in the GUI (`dump-noctalia-shell`).
+
+- [ ] **Step 2: Build**
+
+Run: `git add -A && nix build .#noctalia --print-out-paths --no-link`
+Expected: a store path; `ls $(…)/bin` lists `noctalia-shell` and `dump-noctalia-shell`.
+
+- [ ] **Step 3: The generated settings select Rosé Pine**
+
+Run: `nix eval --raw .#noctalia.generatedConfig | xargs -I{} jq -c .colorSchemes {}/settings.json`
+Expected: `{"darkMode":true,"predefinedScheme":"Rosepine","useWallpaperColors":false}`
+
+- [ ] **Step 4: Nested smoke test (manual, in the running Hyprland session)**
+
+Run: `nix run .#noctalia` for ten seconds; a Rosé Pine bar appears at the top; Ctrl+C stops it. (Noctalia runs under Hyprland too, so this proves the package before Niri exists.)
+
+### Task 7: Niri package and NixOS module
+
+**Files:**
+- Create: `modules/features/desktop/niri.nix`
+- Modify: `modules/hosts/laptop/configuration.nix` (add `desktop-niri` to imports)
+
+**Interfaces:**
+- Consumes: `self'.packages.noctalia` (Task 6).
+- Produces: `self'.packages.niri`; `self.nixosModules.desktop-niri`.
+
+- [ ] **Step 1: Write the module**
+
+```nix
+{ self, inputs, ... }:
+{
+ perSystem = { pkgs, lib, self', ... }: {
+ packages.niri = inputs.wrapper-modules.wrappers.niri.wrap {
+ inherit pkgs;
+ settings = { … };
+ };
+ };
+ flake.nixosModules.desktop-niri = { config, lib, pkgs, ... }: {
+ config = lib.mkIf config.daemon.desktop.niri.enable {
+ programs.niri = { enable = true; package = self.packages.${pkgs.stdenv.hostPlatform.system}.niri; };
+ };
+ };
+}
+```
+`settings` implements the spec's "Niri" section: `input`, `layout`, `prefer-no-csd`, `hotkey-overlay`, `xwayland-satellite.path`, `spawn-at-startup = [ (lib.getExe self'.packages.noctalia) ]`, `environment`, and `binds` exactly as the spec's table, with every program as `lib.getExe pkgs.<x>` (kitty, firefox, nautilus, obsidian, grim, slurp, wl-clipboard's `wl-copy`, brightnessctl, playerctl; `wpctl` from `pkgs.wireplumber`). Bind syntax follows the wrapper: `"Mod+Return".spawn = [ (lib.getExe pkgs.kitty) ]; "Mod+Q".close-window = null; "Mod+1".focus-workspace = 1; "Mod+Space".spawn-sh = "${noctalia} ipc call launcher toggle";`. The Noctalia binary is `let noctalia = lib.getExe self'.packages.noctalia; in`.
+
+- [ ] **Step 2: Add `desktop-niri` to the laptop imports** in `modules/hosts/laptop/configuration.nix`.
+
+- [ ] **Step 3: Build (this runs `niri validate` on the generated config)**
+
+Run: `git add -A && nix build .#niri --print-out-paths --no-link`
+Expected: a store path. A validation failure names the bad KDL line; fix the bind and rebuild.
+
+- [ ] **Step 4: The system now carries the Niri session**
+
+Run: `nix eval .#nixosConfigurations.nixos.config.services.displayManager.sessionPackages --apply 'l: map (p: p.name) l'`
+Expected: a list naming both the Hyprland (uwsm) session package and the wrapped niri package.
+
+- [ ] **Step 5: Nested smoke test (manual)**
+
+Run: `nix run .#niri` inside Hyprland. A Niri window opens with the Noctalia bar; Alt is the modifier when nested: Alt+Return opens kitty, Alt+Space opens the launcher, Alt+Shift+E quits.
+
+### Task 8: Documentation, final build, hand-over
+
+**Files:**
+- Modify: `modules/home/cheats/nix.md` (the `layout` and `add` sections), `README.md` (its layout section)
+
+- [ ] **Step 1: Update the cheat card and README**
+
+In `nix.md` `## layout`, replace the path table with the new tree (flake.nix, modules/parts.nix, modules/hosts/laptop/*, modules/features/*, modules/home/*), and in `## add` point packages at `modules/home/tools.nix`, dotfiles at `modules/home/dotfiles.nix`, a new module at "a new `modules/home/<name>.nix` declaring `flake.homeModules.<name>` + one name in `modules/home/default.nix`", plus two lines: `daemon.desktop.niri.enable` / `hyprland.enable` in `modules/hosts/laptop/configuration.nix`, and `nix run ~/NixDaemon#niri` / `#noctalia`. Do the same edit in the README's layout section.
+
+- [ ] **Step 2: Final parity build**
+
+Run: `git add -A && nix flake check && nh os build && nvd diff /run/current-system result`
+Expected: additions only (`niri`, `noctalia-shell`, `xwayland-satellite`, `xdg-desktop-portal-gnome`, `quickshell`, their closure); no `Removed packages` section.
+
+- [ ] **Step 3: Hand over**
+
+Stop here. Report the diff summary and the two manual steps left to the owner: `nh os switch`, then log out, pick "niri" in tuigreet, and `nh os rollback` if anything is wrong. The owner commits with jj.
diff --git a/docs/superpowers/specs/2026-10-08-dendritic-niri-noctalia-design.md b/docs/superpowers/specs/2026-10-08-dendritic-niri-noctalia-design.md
@@ -0,0 +1,253 @@
+# NixDaemon: dendritic flake, Niri + Noctalia beside Hyprland + Caelestia
+
+Date: 2026-10-08. Status: approved in conversation, awaiting written review.
+
+## Goal
+
+Rewrite `~/NixDaemon` in the dendritic pattern (flake-parts + import-tree, every
+file a flake-parts module, outputs referenced by name through `self`), and add a
+second desktop, Niri with Noctalia Shell, built the way vimjoyer's video 79 does
+it (wrapper-modules, so the compositor and the shell are portable packages).
+Both desktops are installed and chosen at login; either can be switched off
+with one boolean. Theme for the new desktop: Rosé Pine Main (dark; never Moon).
+
+Success: `nh os switch` builds from the new tree with no change to what the
+Hyprland session does today; tuigreet lists both "Hyprland" and "niri";
+`nix run ~/NixDaemon#niri` and `#noctalia` work anywhere the flake is fetched.
+
+## Constraints and facts the design rests on
+
+- home-manager runs as a NixOS module here (`useGlobalPkgs = true`). There is
+ no standalone home-manager profile and none is added.
+- nixpkgs unstable already ships `niri` (26.04, with the `programs.niri` NixOS
+ module), `noctalia-shell` (4.7.7) and `xwayland-satellite`. No new package
+ inputs; three new flake inputs: `flake-parts`, `import-tree`,
+ `wrapper-modules` (`github:BirdeeHub/nix-wrapper-modules`).
+- wrapper-modules facts (read from its source):
+ `inputs.wrapper-modules.wrappers.niri.wrap { inherit pkgs; settings = …; }`
+ (settings is a freeform set translated to KDL; `binds`, `layout`,
+ `spawn-at-startup`, `window-rules`, `outputs`, `extraConfig` are typed; the
+ wrapper runs `niri validate` at build time; the package passes through
+ `providedSessions`). `wrappers.noctalia-shell.wrap { inherit pkgs; settings;
+ colors; … }`: with only `settings` set it exports `NOCTALIA_SETTINGS_FILE`
+ pointing into the store and ships `bin/dump-noctalia-shell`, which prints the
+ live settings as Nix.
+- Noctalia ships a predefined "Rosepine" scheme whose dark half is Rosé Pine
+ Main (`mSurface #191724`, `mPrimary #ebbcba`, …). Selecting it:
+ `colorSchemes = { predefinedScheme = "Rosepine"; darkMode = true;
+ useWallpaperColors = false; }`.
+- import-tree imports every `*.nix` under `modules/` recursively and ignores any
+ path containing `/_`. Non-Nix files are never touched.
+- Caelestia is started from the Hyprland Lua config, Noctalia from Niri's
+ `spawn-at-startup`; the shared user services (hyprpolkitagent, cliphist,
+ udiskie) are bound to `graphical-session.target`, which both uwsm/Hyprland and
+ `niri-session` manage. Portals are configured per desktop by NixOS. This is
+ why running both desktops side by side is safe.
+
+## Decisions
+
+1. **Full dendritic rewrite.** Every Nix file becomes a flake-parts module.
+ Module *bodies* (the NixOS and home-manager settings) are carried over
+ unchanged except for relative paths that move with them.
+2. **Both desktops installed, picked in tuigreet.** Two NixOS options,
+ `daemon.desktop.hyprland.enable` and `daemon.desktop.niri.enable`, both
+ default `true`, set in the host's `configuration.nix`. An assertion
+ requires at least one. Home-manager modules read them through `osConfig`
+ so the NixOS option is the single source of truth.
+3. **`bootstrap` and `nixpkgs-stable` are removed.** Their own comment says to
+ delete them once `nixos` is in use.
+4. **Niri and Noctalia settings live in Nix** (vimjoyer's way), not in an
+ out-of-store config dir. The GUI remains usable for trying settings in a
+ session; `dump-noctalia-shell` turns the live state into Nix to paste back.
+
+## Layout
+
+```
+flake.nix inputs; outputs = flake-parts.lib.mkFlake { inherit inputs; } (import-tree ./modules)
+.sops.yaml, secrets/ unchanged, repo root
+modules/
+ parts.nix systems = [ "x86_64-linux" ]; imports home-manager.flakeModules.home-manager
+ and wrapper-modules.flakeModules.default
+ hosts/laptop/
+ default.nix flake.nixosConfigurations.nixos = nixpkgs.lib.nixosSystem { specialArgs = { inherit inputs; user; };
+ modules = [ self.nixosModules.laptop ]; }
+ configuration.nix flake.nixosModules.laptop: imports every self.nixosModules.laptop-* and the features
+ (workstation, home-manager, desktop-hyprland, desktop-niri); sets daemon.desktop.*;
+ body = today's hosts/laptop/default.nix minus its imports list
+ hardware.nix flake.nixosModules.laptop-hardware (hardware-configuration.nix, unchanged)
+ nvidia.nix flake.nixosModules.laptop-nvidia
+ ssd.nix flake.nixosModules.laptop-ssd
+ nix-settings.nix flake.nixosModules.laptop-nix-settings (nh, caches)
+ sops.nix flake.nixosModules.laptop-sops (path to ../../../secrets/secrets.yaml)
+ toolbox.nix flake.nixosModules.laptop-toolbox
+ fan-cli.nix flake.nixosModules.laptop-fan-cli
+ fan-extras.nix flake.nixosModules.laptop-fan-extras
+ fan-throttle-guard.nix flake.nixosModules.laptop-fan-throttle-guard
+ uniwill-laptop.nix flake.nixosModules.laptop-uniwill
+ uniwill-laptop/_package.nix the kernel-module derivation; underscore so import-tree skips it
+ fanfix, stability_guard.py data, untouched
+ features/
+ workstation.nix flake.nixosModules.workstation (today's modules/workstation.nix)
+ home-manager.nix flake.nixosModules.home-manager: imports home-manager.nixosModules.home-manager;
+ useGlobalPkgs, useUserPackages, backupFileExtension = "hm-bak",
+ extraSpecialArgs = { inherit inputs; user; }, sharedModules (hyprland + caelestia
+ HM modules), users.${user} = self.homeModules.daemonsec
+ desktop/
+ options.nix flake.nixosModules.desktop-options: the two options + assertion
+ hyprland.nix flake.nixosModules.desktop-hyprland: today's Hyprland/greetd/uwsm/portal block
+ from hosts/laptop/default.nix, wrapped in mkIf daemon.desktop.hyprland.enable
+ niri.nix perSystem.packages.niri (wrapped) and flake.nixosModules.desktop-niri:
+ programs.niri = { enable; package = self'.packages.niri }, mkIf daemon.desktop.niri.enable
+ noctalia.nix perSystem.packages.noctalia (wrapped, Rosé Pine)
+ home/
+ default.nix flake.homeModules.daemonsec: imports every self.homeModules.* below;
+ home.username/homeDirectory/stateVersion, programs.home-manager, xdg
+ tools.nix … yazi.nix one flake.homeModules.<name> per today's home/modules/<name>.nix, bodies unchanged
+ hyprland.nix flake.homeModules.hyprland, body wrapped in mkIf osConfig.daemon.desktop.hyprland.enable
+ caelestia.nix flake.homeModules.caelestia, same gate
+ hypr/, caelestia/, kitty/, cheats/, gpg/, rmpc/ data directories, moved beside their modules
+docs/superpowers/specs/ this file
+```
+
+Naming: NixOS modules for this host are `laptop-<topic>`; shared features are
+bare (`workstation`, `desktop-niri`); home modules keep today's file names.
+
+greetd/tuigreet is the login for both desktops, so it stays in
+`configuration.nix` (host level), ungated. Only the Hyprland-specific lines
+(`programs.hyprland` with uwsm, the GTK portal line that exists for Hyprland)
+move to `desktop-hyprland.nix` and are gated.
+
+## The desktop switch
+
+```nix
+# modules/features/desktop/options.nix
+flake.nixosModules.desktop-options = { lib, config, ... }: {
+ options.daemon.desktop = {
+ hyprland.enable = lib.mkEnableOption "Hyprland with Caelestia Shell" // { default = true; };
+ niri.enable = lib.mkEnableOption "Niri with Noctalia Shell" // { default = true; };
+ };
+ config.assertions = [{
+ assertion = config.daemon.desktop.hyprland.enable || config.daemon.desktop.niri.enable;
+ message = "daemon.desktop: enable at least one desktop";
+ }];
+};
+```
+
+`configuration.nix` sets both explicitly so the choice is visible in the host
+file. Switching desktops day to day is: log out, pick the other session in
+tuigreet (it remembers the last one). Dropping one: set it to `false`,
+`nh os switch`.
+
+## Niri (`modules/features/desktop/niri.nix`)
+
+`perSystem = { pkgs, lib, self', ... }: { packages.niri = inputs.wrapper-modules.wrappers.niri.wrap { inherit pkgs; settings = { … }; }; }`
+
+Settings (KDL generated by the wrapper):
+
+- `input`: keyboard layout `us`, options `compose:caps,shift:both_capslock_cancel`
+ (same as the NixOS xkb settings); touchpad `tap`, `natural-scroll`;
+ `focus-follows-mouse`.
+- `layout`: `gaps 8`; `focus-ring { width 2; active-color "#ebbcba"; inactive-color "#26233a"; }`
+ (rose on overlay); `border.off`; `preset-column-widths` 1/3, 1/2, 2/3.
+- `prefer-no-csd`; `hotkey-overlay.skip-at-startup`.
+- `xwayland-satellite.path = lib.getExe pkgs.xwayland-satellite`.
+- `spawn-at-startup = [ (lib.getExe self'.packages.noctalia) ]`.
+- `environment`: `ELECTRON_OZONE_PLATFORM_HINT=auto`, `QT_QPA_PLATFORM=wayland;xcb`
+ (mirrors the session variables set for Hyprland).
+- `binds`, mirroring the Hyprland keys that have a Niri or Noctalia counterpart:
+
+ | Key | Action |
+ |---|---|
+ | Mod+Return | spawn kitty |
+ | Mod+Shift+Return, Mod+Shift+B | firefox |
+ | Mod+Shift+Alt+B | firefox --private-window |
+ | Mod+Shift+F | nautilus --new-window |
+ | Mod+Shift+O | obsidian |
+ | Mod+Shift+N | kitty -e $EDITOR |
+ | Mod+Space, Alt+Mod+Space | noctalia ipc call launcher toggle |
+ | Mod+Escape, Ctrl+Mod+P | noctalia ipc call sessionMenu toggle |
+ | Mod+A | noctalia ipc call controlCenter toggle |
+ | Mod+Comma | noctalia ipc call notifications clear |
+ | Ctrl+Mod+V | noctalia ipc call launcher clipboard |
+ | Ctrl+Mod+Space | noctalia ipc call wallpaper toggle |
+ | Mod+Q | close-window |
+ | Mod+F | maximize-column; Mod+G fullscreen-window; Mod+Shift+V toggle-window-floating |
+ | Mod+H / Mod+J / Mod+K / Mod+L (and arrows) | focus column left / window down / window up / column right |
+ | Mod+Shift+Escape | noctalia ipc call lockScreen lock |
+ | Mod+Shift+H/J/K/L | move column / window |
+ | Mod+1..9, Mod+Shift+1..9 | focus / move to workspace |
+ | Mod+Ctrl+H/L | set-column-width ∓5%; Mod+Ctrl+J/K set-window-height |
+ | Mod+WheelScrollUp/Down | focus workspace up/down |
+ | Print | grim -g "$(slurp)" to clipboard; Shift+Print full screen |
+ | XF86Audio{Raise,Lower}Volume, Mute, MicMute | wpctl |
+ | XF86MonBrightness{Up,Down} | brightnessctl |
+ | XF86Audio{Play,Pause,Next,Prev} | playerctl |
+ | Mod+Shift+E | quit (with confirmation) |
+
+ Programs are referenced with `lib.getExe pkgs.<x>` so the wrapped package
+ carries its own dependencies, exactly as in the video.
+
+Then `flake.nixosModules.desktop-niri = { config, lib, pkgs, ... }: lib.mkIf config.daemon.desktop.niri.enable { programs.niri = { enable = true; package = self.packages.${pkgs.stdenv.hostPlatform.system}.niri; }; }`.
+nixpkgs' `programs.niri` registers the session for tuigreet and adds the
+GNOME portal, which is what Niri documents.
+
+## Noctalia (`modules/features/desktop/noctalia.nix`)
+
+`perSystem = { pkgs, ... }: { packages.noctalia = inputs.wrapper-modules.wrappers.noctalia-shell.wrap { inherit pkgs; settings = { … }; }; }`
+
+Settings (only the keys that differ from Noctalia's defaults):
+
+- `colorSchemes = { predefinedScheme = "Rosepine"; darkMode = true; useWallpaperColors = false; }`
+- `bar = { position = "top"; density = "compact"; }`
+- `general = { lockOnSuspend = true; }`
+- `ui = { fontDefault = "Noto Sans"; fontFixed = "DMMono Nerd Font"; }`
+- `wallpaper = { enabled = true; directory = "<the rose-pine-dark wallpaper dir already used by Caelestia>"; fillMode = "crop"; }`
+- `location = { name = "Douglas, Isle of Man"; useFahrenheit = false; use12hourFormat = false; }` (same weather spot as Caelestia)
+- `appLauncher = { terminalCommand = "kitty -e"; }`
+- `idle = { enabled = true; lockTimeout = 600; }`: Noctalia's own lock screen after ten idle minutes.
+
+Bar widget layout stays Noctalia's default for the first build; the owner
+tunes it in the GUI and pastes `dump-noctalia-shell` output back into this
+file. Anything the dump adds that equals a default is left out.
+
+## Home-manager glue (`modules/features/home-manager.nix`)
+
+The block that lives in `flake.nix` today moves here unchanged, except that the
+user module is `self.homeModules.daemonsec`. `extraSpecialArgs` still passes
+`inputs` and `user`; `sharedModules` keeps the Hyprland and Caelestia HM
+modules (they only define options; the gated home modules decide whether they
+do anything).
+
+## Path changes that come with the move
+
+- `modules/hosts/laptop/sops.nix`: `defaultSopsFile = ../../../secrets/secrets.yaml`.
+- `modules/home/sops.nix`: same depth change.
+- `modules/home/cheats.nix`: `../cheats` becomes `./cheats`; same for
+ `caelestia.nix` (`./caelestia/...`), `hyprland.nix` (`./hypr/...`),
+ `terminal.nix` (`./kitty/...`), `gpg.nix`, `media.nix` (rmpc).
+- `uniwill-laptop.nix`: `./uniwill-laptop/_package.nix`.
+- `fan-throttle-guard.nix` and friends: their script paths (`./fanfix`,
+ `./stability_guard.py`) are unchanged because the files move with them.
+
+## Verification (before the live switch)
+
+1. `nix flake check ~/NixDaemon` evaluates every output.
+2. `nh os build && nvd diff /run/current-system result`. Expected: `niri`,
+ `noctalia-shell`, `xwayland-satellite`, `xdg-desktop-portal-gnome` and
+ their closure added; the removal of nothing that exists today. Any removal
+ is a bug in the port, fixed before switching.
+3. `nix run ~/NixDaemon#niri` inside the running Hyprland session opens Niri
+ nested in a window with Noctalia in it (Alt is the modifier when nested);
+ `nix run ~/NixDaemon#noctalia` alone works too.
+4. `nh os switch`; the Hyprland session keeps working; log out; tuigreet shows
+ "niri"; log in; Noctalia bar appears in Rosé Pine.
+5. Rollback path if anything is wrong: `nh os rollback` (or the boot menu).
+
+## Out of scope
+
+- Converting the Hyprland config itself to a wrapped package (it stays a
+ home-manager module; it works and the video does not cover it).
+- Per-project tooling, dotfiles, secrets: untouched.
+- A Rosé Pine Dawn variant for Niri/Noctalia.
+- Committing: the owner commits (jj). The rewrite is left as working-tree
+ changes plus `git add` of the new files so the flake can see them.
diff --git a/flake.lock b/flake.lock
@@ -150,6 +150,26 @@
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1790898470,
+ "narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=",
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "rev": "024633cd702b10285db5cb19b40ad48d2399ba60",
+ "type": "github"
+ },
+ "original": {
+ "owner": "hercules-ci",
+ "repo": "flake-parts",
+ "type": "github"
+ }
+ },
+ "flake-parts_2": {
+ "inputs": {
+ "nixpkgs-lib": [
"llm-agents",
"nixpkgs"
]
@@ -536,10 +556,25 @@
"type": "github"
}
},
+ "import-tree": {
+ "locked": {
+ "lastModified": 1788467110,
+ "narHash": "sha256-ljEMTXP/rH0tOvDzc9gzwww6KcHRPRnEHzd9lK48V7s=",
+ "owner": "vic",
+ "repo": "import-tree",
+ "rev": "eb1b52eaecc57f7c136d07ae8a93e724dfecac46",
+ "type": "github"
+ },
+ "original": {
+ "owner": "vic",
+ "repo": "import-tree",
+ "type": "github"
+ }
+ },
"llm-agents": {
"inputs": {
"bun2nix": "bun2nix",
- "flake-parts": "flake-parts",
+ "flake-parts": "flake-parts_2",
"nixpkgs": "nixpkgs_2",
"systems": "systems_2",
"treefmt-nix": "treefmt-nix"
@@ -611,22 +646,6 @@
"type": "github"
}
},
- "nixpkgs-stable": {
- "locked": {
- "lastModified": 1791353474,
- "narHash": "sha256-v72qr4LsSz61tQki/41nHKZKPY6NSeZavtGlAInOCng=",
- "owner": "nixos",
- "repo": "nixpkgs",
- "rev": "2efa67fd26b6df417c33e4603185c701f260dd83",
- "type": "github"
- },
- "original": {
- "owner": "nixos",
- "ref": "nixos-26.05",
- "repo": "nixpkgs",
- "type": "github"
- }
- },
"nixpkgs_2": {
"locked": {
"lastModified": 1790600678,
@@ -728,13 +747,15 @@
"inputs": {
"caelestia-cli": "caelestia-cli",
"caelestia-shell": "caelestia-shell",
+ "flake-parts": "flake-parts",
"home-manager": "home-manager",
"hyprland": "hyprland",
+ "import-tree": "import-tree",
"llm-agents": "llm-agents",
"nixpkgs": "nixpkgs_3",
- "nixpkgs-stable": "nixpkgs-stable",
"nvf": "nvf",
- "sops-nix": "sops-nix"
+ "sops-nix": "sops-nix",
+ "wrapper-modules": "wrapper-modules"
}
},
"sops-nix": {
@@ -808,6 +829,26 @@
"type": "github"
}
},
+ "wrapper-modules": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1788981141,
+ "narHash": "sha256-7xWS16u13YGlRNKWaAPzRbEyh4OLOXJe31k7h4AnXNU=",
+ "owner": "BirdeeHub",
+ "repo": "nix-wrapper-modules",
+ "rev": "1db3c116a6aa61823f8d8f3c47c306846428fc54",
+ "type": "github"
+ },
+ "original": {
+ "owner": "BirdeeHub",
+ "repo": "nix-wrapper-modules",
+ "type": "github"
+ }
+ },
"xdph": {
"inputs": {
"aquamarine": [
diff --git a/flake.nix b/flake.nix
@@ -1,13 +1,27 @@
{
- description = "NixDaemon: NixOS + home-manager for the PCSpecialist Valeon II 17 (Hyprland, Caelestia Shell, dead-GPU-fan workaround)";
+ description = "NixDaemon: NixOS + home-manager for the PCSpecialist Valeon II 17 (Hyprland + Caelestia, Niri + Noctalia, dead-GPU-fan workaround)";
+ # The flake is dendritic: flake-parts evaluates every *.nix under ./modules
+ # (import-tree) as a flake-parts module, and each file declares the outputs
+ # it owns (flake.nixosModules.<name>, flake.homeModules.<name>,
+ # flake.nixosConfigurations.<host>, perSystem.packages.<name>). Files and
+ # directories whose path contains `/_` are skipped. Modules refer to each
+ # other by name through `self`, never by path.
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
- # The release branch this machine was installed from. Only the `bootstrap`
- # configuration uses it (the fan fix on today's GNOME install, see README.md).
- # Delete this input together with hosts/bootstrap/ once `nixos` is in use.
- nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
+ flake-parts = {
+ url = "github:hercules-ci/flake-parts";
+ inputs.nixpkgs-lib.follows = "nixpkgs";
+ };
+ import-tree.url = "github:vic/import-tree";
+
+ # Wrapped, portable programs (modules/features/desktop/{niri,noctalia}.nix):
+ # `nix run ~/NixDaemon#niri` works anywhere the flake can be fetched.
+ wrapper-modules = {
+ url = "github:BirdeeHub/nix-wrapper-modules";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
home-manager = {
url = "github:nix-community/home-manager";
@@ -15,8 +29,8 @@
};
# Deliberately not following nixpkgs: Hyprland pins its own, and the
- # hyprland.cachix.org cache (hosts/laptop/nix-settings.nix) only serves
- # builds made against those pins.
+ # hyprland.cachix.org cache (modules/hosts/laptop/nix-settings.nix) only
+ # serves builds made against those pins.
hyprland.url = "github:hyprwm/Hyprland";
# Shell and CLI pin each other, so one revision of each is used everywhere.
@@ -31,68 +45,22 @@
inputs.caelestia-shell.follows = "caelestia-shell";
};
- # Claude Code and the Claude desktop app (modules/workstation.nix).
+ # Claude Code and the Claude desktop app (modules/features/workstation.nix).
llm-agents.url = "github:numtide/llm-agents.nix";
# Secrets: encrypted in secrets/ with age, decrypted at activation
- # (hosts/laptop/sops.nix for the system, home/modules/sops.nix for the user).
+ # (modules/hosts/laptop/sops.nix for the system, modules/home/sops.nix for the user).
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
- # Neovim, configured in Nix (home/modules/neovim.nix).
+ # Neovim, configured in Nix (modules/home/neovim.nix).
nvf = {
url = "github:notashelf/nvf";
inputs.nixpkgs.follows = "nixpkgs";
};
};
- outputs =
- inputs@{ self, nixpkgs, nixpkgs-stable, home-manager, ... }:
- let
- system = "x86_64-linux";
- user = "daemonsec";
- in
- {
- nixosConfigurations = {
- # The target: Hyprland + Caelestia Shell.
- # sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && reboot
- nixos = nixpkgs.lib.nixosSystem {
- inherit system;
- specialArgs = { inherit inputs user; };
- modules = [
- ./hosts/laptop
- ./modules/workstation.nix
- inputs.hyprland.nixosModules.default
- home-manager.nixosModules.home-manager
- {
- home-manager = {
- useGlobalPkgs = true;
- useUserPackages = true;
- backupFileExtension = "hm-bak";
- extraSpecialArgs = { inherit inputs user; };
- sharedModules = [
- inputs.hyprland.homeManagerModules.default
- inputs.caelestia-shell.homeManagerModules.default
- ];
- users.${user} = import ./home;
- };
- }
- ];
- };
-
- # Stage A: today's GNOME install plus the fan fix and the toolbox
- # prerequisites, built from the same nixpkgs the machine runs now.
- # sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && reboot
- bootstrap = nixpkgs-stable.lib.nixosSystem {
- inherit system;
- specialArgs = { inherit inputs user; };
- modules = [
- ./hosts/bootstrap
- ./modules/workstation.nix
- ];
- };
- };
- };
+ outputs = inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } (inputs.import-tree ./modules);
}
diff --git a/home/cheats/gpg.md b/home/cheats/gpg.md
@@ -1,321 +0,0 @@
-# gpg — the key hierarchy, and every verb
-
-GnuPG **2.4.9** here. Pinentry is the GNOME dialog (NixOS `programs.gnupg.agent`), `GPG_TTY` is exported by core.zsh,
-and **`gpgx`** (`gpgx-cheat`) is the no-flags wrapper for the daily lock/unlock/sign/verify. This card is the real thing underneath.
-Terminal card: **`gpg-cheat`** — sections `model setup keygen subkeys backup import sign verify encrypt decrypt edit revoke ssh git keyservers trust inspect offline agent fix mine`.
-`FPR` below = a full 40-hex fingerprint. Yours (the main key): `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`.
-
-## model — one primary key, several subkeys
-
-```text
-primary key [C] certify = the identity. Signs your own subkeys and user IDs, and other people's keys.
- Keep it OFFLINE once the subkeys exist; it only comes out to add/revoke subkeys or UIDs.
-subkey [S] sign = signs files, commits, mail. Rotate yearly without touching the identity.
-subkey [E] encrypt = what others encrypt TO. Rotating it does not re-encrypt old mail; keep old ones to read it.
-subkey [A] authenticate = SSH login (gpg-agent as the ssh agent).
-user ID "Name <mail>" = one per address; the primary one is what people see first.
-```
-
-- Capabilities show in `gpg -K` as `[SC]`, `[E]`, `[A]`. A primary with `[SC]` signs with the identity key itself, which is fine but not rotatable.
-- The **fingerprint** (40 hex) is the key; the "key ID" is its last 16 (long) or 8 (short, collidable — never use).
-- `sec` = secret primary present. `sec#` = primary absent (offline), only subkeys here. `ssb` = secret subkey.
-- Ownertrust (how much you trust a key to vouch for others) is separate from validity (is this key really theirs). `[ultimate]` is your own.
-
-## setup — ~/.gnupg and the agent
-
-```sh
-# ~/.gnupg/gpg.conf (create it; sane modern defaults)
-keyid-format 0xlong
-with-fingerprint
-with-subkey-fingerprints
-default-key 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4
-default-recipient-self # `gpg -e file` encrypts to you when no -r given
-personal-cipher-preferences AES256 AES192 AES
-personal-digest-preferences SHA512 SHA384 SHA256
-cert-digest-algo SHA512
-no-emit-version
-no-comments
-keyserver hkps://keys.openpgp.org
-auto-key-locate local,wkd # find a key by mail address (WKD) before asking a keyserver
-trust-model tofu+pgp # remember first-seen keys per address, warn on change
-
-# ~/.gnupg/gpg-agent.conf
-default-cache-ttl 3600 # seconds a passphrase stays cached after last use
-max-cache-ttl 28800 # hard ceiling
-# pinentry-program is set by NixOS (hosts/laptop/default.nix: pinentryPackage = pinentry-gnome3)
-```
-
-```sh
-chmod 700 ~/.gnupg # gpg refuses "unsafe permissions" otherwise
-gpg-connect-agent reloadagent /bye # after editing gpg-agent.conf
-gpgconf --kill gpg-agent # restart it entirely (it relaunches on demand)
-gpg --version # algorithms available
-```
-
-## keygen — a proper key, the modern way
-
-Certify-only primary, then three subkeys. Ed25519/Curve25519 throughout (fast, small, the 2.4 default).
-
-```sh
-gpg --quick-generate-key 'DAEMON-SEC <zer0sec.xp@icloud.com>' ed25519 cert 2y # primary: [C] only, 2-year expiry
-FPR=$(gpg -K --with-colons 'zer0sec.xp@icloud.com' | awk -F: '$1=="fpr"{print $10; exit}')
-gpg --quick-add-key "$FPR" ed25519 sign 1y # [S]
-gpg --quick-add-key "$FPR" cv25519 encr 1y # [E]
-gpg --quick-add-key "$FPR" ed25519 auth 1y # [A] (for SSH)
-gpg -K --with-subkey-fingerprints # see the four, check capabilities and expiries
-```
-
-- Expiry is a **safety net**, not a deadline: extend it any time with the primary key (see *edit*). A key that expires with its owner gone is self-cleaning.
-- Interactive version: `gpg --full-generate-key --expert` → `(11) ECC (set your own capabilities)` → toggle `S` off so only `Certify` remains → `Curve 25519`. Then add subkeys with `--quick-add-key` or in `--edit-key` → `addkey`.
-- A passphrase protects the secret key *file*; the agent caches it. Use a long one; the pinentry dialog asks.
-- The revocation certificate is written automatically to `~/.gnupg/openpgp-revocs.d/FPR.rev` — back it up with the key (see *backup*).
-
-## subkeys — add, rotate, drop
-
-```sh
-gpg --quick-add-key FPR ed25519 sign 1y # new signing subkey (old one stays until you revoke it)
-gpg --quick-set-expire FPR 1y '*' # extend EVERY subkey a year (`'*'` = all subkeys; a sub-fpr = that one)
-gpg --quick-set-expire FPR 2y # extend the primary
-gpg --edit-key FPR # interactive: `key 2` selects subkey 2 (the `*` marks it), then
- # `expire` / `revkey` / `delkey` / `passwd` / `save`
-gpg -K --with-colons | awk -F: '$1=="ssb"{print $12, $5}' # capability letters + key id of each subkey
-```
-
-Old encryption subkeys: **keep them** (revoked or expired, still in the ring) or you lose the ability to read what was sent to them.
-
-## backup — export, revocation, paper
-
-```sh
-gpg --armor --export FPR > daemon-sec.pub.asc # public key: share freely
-gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # everything: primary + subkeys (passphrase-protected)
-gpg --armor --export-secret-subkeys FPR > daemon-sec.subkeys.asc # subkeys only (what a laptop should carry; see *offline*)
-cp ~/.gnupg/openpgp-revocs.d/FPR.rev daemon-sec.rev # the revocation certificate
-gpg --export-ownertrust > ownertrust.txt # your trust assignments
-gpg --gen-revoke FPR > daemon-sec.rev # make a fresh revocation cert by hand
-nix shell nixpkgs#paperkey -c sh -c 'gpg --export-secret-keys FPR | paperkey > daemon-sec.paper.txt' # printable secret bits
-```
-
-Where it goes: the vault (encrypted), never the dotfiles repo. The SECRET export + the .rev are the two files that matter.
-The secret export is still encrypted with your passphrase; the passphrase is the third thing to keep.
-
-## import — keys, trust, restore
-
-```sh
-gpg --import daemon-sec.pub.asc # someone's public key (or your own on a new machine)
-gpg --import daemon-sec.SECRET.asc # restore: asks for the passphrase
-gpg --import-ownertrust < ownertrust.txt
-gpg --edit-key FPR trust # set ownertrust: 5 = ultimate (yours), 4 = full, 3 = marginal
-gpg --lsign-key FPR # "I checked this key": local signature, never exported
-gpg --sign-key FPR # exportable certification (web of trust)
-gpg --show-keys someone.asc # look at a key file WITHOUT importing it
-gpg --delete-keys FPR # forget a public key; --delete-secret-keys for the secret part first
-```
-
-On a fresh machine: import the SECRET file, `gpg --edit-key FPR trust` → `5`, done. Or import only the subkeys file for a laptop.
-
-## sign — files, text, commits
-
-```sh
-gpg --detach-sign --armor file # file.asc beside it: the usual way to sign a release/file
-gpg --detach-sign file # binary file.sig
-gpg --clearsign message.txt # message.txt.asc: readable text with a signature block (mail, announcements)
-gpg --sign file # file.gpg: compressed file + signature in one (recipient runs gpg -d)
-gpg -u FPR --detach-sign file # pick the key (-u / --local-user); `FPR!` = that exact subkey
-echo "text" | gpg --clearsign # from a pipe
-gpg --sign --encrypt -r mail file # sign AND encrypt (see *encrypt*)
-```
-
-Signing uses the `[S]` subkey automatically (or the primary if it has S). The agent asks for the passphrase once per cache TTL.
-
-## verify — did this come from them, unchanged
-
-```sh
-gpg --verify file.asc file # detached signature (.asc/.sig) + the file
-gpg --verify file.sig # gpg finds `file` next to it
-gpg --verify message.txt.asc # clearsigned text
-gpg --decrypt file.gpg > file # inline-signed: extracts the file and prints the verification
-gpg --verify --verbose file.asc file # which key, which subkey, when
-```
-
-Reading the result: **Good signature from "Name <mail>"** = cryptographically valid. The warning
-`This key is not certified with a trusted signature` means you have not set ownertrust / signed their key
-— the signature is still valid; the question is whether the key is really theirs. Compare the fingerprint
-out-of-band once, then `gpg --lsign-key FPR` and the warning goes away.
-`BAD signature` = the file changed or the signature is for another file. Exit code 0 only on Good.
-
-## encrypt — to people, to yourself, with a passphrase
-
-```sh
-gpg --encrypt --recipient mail@example.com --armor file # file.asc, readable only by that key
-gpg -e -r mail@example.com -r zer0sec.xp@icloud.com file # several recipients (add yourself to read it later!)
-gpg -e file # to yourself (default-recipient-self in gpg.conf)
-gpg -se -r mail file # sign + encrypt: they know it is from you
-gpg --symmetric --cipher-algo AES256 file # passphrase only, no keys (file.gpg); shares via a channel you trust
-gpg -c --armor file # same, armored
-gpg -o out.gpg -e -r mail file # choose the output name
-tar cz directory | gpg -e -r mail > directory.tgz.gpg # a whole directory, through a pipe
-gpg --hidden-recipient mail -e file # do not reveal who it is for (-R)
-gpg --encrypt-to FPR # in gpg.conf: ALWAYS add this recipient (yourself) silently
-```
-
-- `--armor` (`-a`) makes ASCII text you can paste into mail; without it the output is binary (smaller).
-- Encrypt uses the recipient's `[E]` subkey. "No public key" = you have not imported theirs; "unusable public key" = theirs expired.
-- Symmetric + a strong passphrase is fine for backups and for sending to someone with no key.
-
-## decrypt — and what to do when it fails
-
-```sh
-gpg --decrypt file.gpg > file # -d: to stdout
-gpg -o file -d file.gpg # to a named file
-gpg file.gpg # guesses: decrypts (or verifies) and writes `file`
-gpg --decrypt-files *.gpg # many at once, each to its name without .gpg
-gpg -d file.gpg | tar xz # straight into tar
-gpg --list-packets file.gpg # WHO can decrypt this: the key IDs it was encrypted to, the algorithms
-```
-
-"decryption failed: No secret key" = it was not encrypted to any key you hold (check with `--list-packets`;
-compare with `gpg -K`). On a laptop with subkeys only, that is fine as long as the `[E]` subkey is there.
-
-## edit — identities, passphrase, expiry
-
-```sh
-gpg --quick-add-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>' # add a user ID (fix a name, add an address)
-gpg --quick-set-primary-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>'
-gpg --quick-revoke-uid FPR 'DAMEON-NIX <zer0sec.xp@icloud.com>' # retire a UID (keys cannot delete published UIDs; revoke them)
-gpg --change-passphrase FPR # new passphrase for the secret key
-gpg --quick-set-expire FPR 2y # primary expiry; `0` = never
-gpg --quick-set-expire FPR 1y '*' # all subkeys
-gpg --edit-key FPR # the interactive editor; `help` lists everything:
-# uid N / key N select · adduid deluid revuid primary · addkey delkey revkey expire · passwd · trust · save / quit
-```
-
-Every change to UIDs or subkeys needs the **primary** secret key present (`sec`, not `sec#`). Then re-export the public key: others must re-import it to see the change.
-
-## revoke — when a key is lost or compromised
-
-```sh
-gpg --import daemon-sec.rev # the stored revocation certificate: marks YOUR key revoked locally
-gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish the revocation so others see it
-gpg --edit-key FPR → key 2 → revkey → save # revoke ONE subkey (compromised laptop: revoke its subkeys, keep the primary)
-gpg --quick-revoke-uid FPR 'uid string' # revoke an identity
-```
-
-Revoked ≠ deleted: the key stays in the ring so old signatures still verify (as "made with a revoked key") and old mail still decrypts.
-If the laptop is lost and you kept the primary offline: revoke its subkeys, make new ones, publish. The identity survives.
-
-## ssh — the [A] subkey as your SSH key
-
-```sh
-# hosts/laptop/default.nix: programs.gnupg.agent.enableSSHSupport = true; # gpg-agent becomes the ssh agent (SSH_AUTH_SOCK)
-gpg -K --with-keygrip # the keygrip of the [A] subkey
-echo KEYGRIP >> ~/.gnupg/sshcontrol # tell the agent to serve it
-gpg --export-ssh-key FPR # the public key in authorized_keys format
-gpg --export-ssh-key FPR >> ~/.ssh/authorized_keys_for_that_host # paste it where you log in
-ssh-add -L # the agent now lists it
-```
-
-Your file key `~/.ssh/id_ed25519` keeps working next to it; ssh tries agent keys first. Use one or the other per host in `~/.ssh/config` (`IdentitiesOnly yes` + `IdentityFile`).
-
-## git — signed commits and tags
-
-```sh
-git config --global gpg.format openpgp
-git config --global user.signingkey 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 # or a subkey fpr with `!` to force it
-git config --global commit.gpgsign true # every commit
-git config --global tag.gpgSign true
-git commit -S -m "msg" # one-off when gpgsign is off
-git tag -s v1.0 -m "release" # signed tag; git tag -v v1.0 verifies
-git log --show-signature -3 # see who signed what
-git verify-commit HEAD
-gpg --armor --export FPR | wl-copy # paste into GitHub/GitLab → Settings → GPG keys, so they show "Verified"
-```
-
-jj: `jj config set --user signing.behavior own` + `signing.backend "gpg"` + `signing.key FPR` signs the commits jj makes.
-
-## keyservers — publishing and finding keys
-
-```sh
-gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish; keys.openpgp.org mails you to verify the address before it shows the UID
-gpg --keyserver hkps://keys.openpgp.org --recv-keys FPR # fetch by fingerprint (the only safe way to fetch)
-gpg --locate-keys someone@example.com # WKD: their domain serves the key (auto-key-locate in gpg.conf)
-gpg --search-keys 'name' # interactive search; verify the fingerprint with them before trusting
-gpg --refresh-keys # pull revocations/expiry updates for every key you hold
-```
-
-## trust — validity versus ownertrust
-
-- A signature is **valid** when the key is valid. A key is valid when *you* certified it (`--sign-key` / `--lsign-key`) or enough trusted people did.
-- **Ownertrust** (`--edit-key FPR trust`, 1-5) is how much you let that person's certifications count for *other* keys. Give 5 only to yourself.
-- `trust-model tofu+pgp`: the first key seen for an address is remembered; a different one later triggers a warning. `gpg --tofu-policy good FPR` to accept a change.
-- `gpg --check-signatures FPR` lists who certified a key. `gpg --update-trustdb` recomputes validity after trust changes.
-
-## inspect — what is this thing
-
-```sh
-gpg -k # public keys (--list-keys); gpg -K = secret keys
-gpg -k --with-subkey-fingerprints --with-keygrip FPR
-gpg --fingerprint FPR # just the fingerprint, formatted for reading aloud
-gpg --list-packets file.gpg # structure of any OpenPGP file: recipients, algorithms, signature dates
-gpg --show-keys key.asc # describe a key file without importing
-gpg -k --with-colons FPR # machine-readable (pub/sub/uid/fpr records)
-gpg --card-status # a YubiKey/OpenPGP card, if one is plugged in
-gpg --export FPR | gpg --list-packets | grep -A2 'signature packet' # certifications on your key
-```
-
-## offline — primary key off the laptop
-
-The point of subkeys: the laptop carries only `[S] [E] [A]`; the primary (the identity) lives on encrypted offline storage.
-
-```sh
-gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # 1. full backup first (vault, encrypted USB)
-gpg --armor --export-secret-subkeys FPR > subkeys.asc # 2. the laptop's share
-gpg --delete-secret-keys FPR # 3. remove everything secret here
-gpg --import subkeys.asc # 4. put the subkeys back
-gpg -K # shows `sec#` = primary absent, `ssb` present: correct
-```
-
-To add a subkey / extend expiry / sign someone's key later: import the SECRET backup in a temporary `GNUPGHOME`, do the change, export the public key and subkeys again, wipe the temp dir:
-```sh
-export GNUPGHOME=$(mktemp -d); gpg --import daemon-sec.SECRET.asc; gpg --quick-set-expire FPR 2y '*'
-gpg --armor --export FPR > pub.asc; gpg --armor --export-secret-subkeys FPR > subkeys.asc; rm -rf "$GNUPGHOME"; unset GNUPGHOME
-gpg --import pub.asc subkeys.asc # back in the normal ring
-```
-A YubiKey does the same with the subkeys moved onto the card: `gpg --edit-key FPR` → `key N` → `keytocard`.
-
-## agent — passphrase caching, pinentry
-
-```sh
-gpg-connect-agent 'KEYINFO --list' /bye # which keys are cached right now (the `1` column)
-gpg-connect-agent reloadagent /bye # re-read gpg-agent.conf
-gpgconf --kill gpg-agent # forget every cached passphrase now
-gpg --pinentry-mode loopback -d file.gpg # type the passphrase in the terminal instead of the dialog (what gpgx does)
-echo test | gpg --clearsign >/dev/null # cheap way to pre-unlock the key for a batch of operations
-```
-
-`export GPG_TTY=$(tty)` must be set for terminal pinentries (core.zsh does it). The dialog here is pinentry-gnome3.
-
-## fix — the usual errors
-
-- `Inappropriate ioctl for device` → `export GPG_TTY=$(tty)` (or the session has no tty: use `--pinentry-mode loopback`).
-- `No secret key` on decrypt → not encrypted to you: `gpg --list-packets file.gpg` shows the key IDs it was made for.
-- `unusable public key` / `unusable secret key` → expired or revoked (sub)key: `gpg -k FPR`, then `--quick-set-expire` (yours) or ask for a fresh key (theirs).
-- `There is no assurance this key belongs to the named user` → ownertrust: `gpg --lsign-key FPR` after checking the fingerprint, or `--trust-model always` for one command.
-- `gpg: WARNING: unsafe permissions on homedir` → `chmod 700 ~/.gnupg; chmod 600 ~/.gnupg/*`.
-- Commands hang → a stale lock or a stuck daemon: `gpgconf --kill all`, `rm -f ~/.gnupg/*.lock ~/.gnupg/public-keys.d/*.lock`.
-- `signing failed: No pinentry` → agent cannot reach a pinentry: `gpgconf --kill gpg-agent` and retry from a terminal with `GPG_TTY` set.
-- Git: `error: gpg failed to sign the data` → almost always the two above; `GIT_TRACE=1 git commit` shows gpg's own message.
-- Key shows `[expired]` on the other side after you extended it → they need your re-exported public key (or `--refresh-keys`).
-
-## mine — this machine, today
-
-- **Main key** `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`, UID `daemon (Main_Key) <zer0sec.xp@icloud.com>`,
- RSA 4096 made 2025-12-30, imported from the vault on 2026-10-08: primary `[SC]` + one `[E]` subkey, **no expiry**,
- ownertrust ultimate, `default-key` in gpg.conf, git signs with it (`commit.gpgsign`, `tag.gpgSign` on).
- No revocation certificate is stored here yet: `gpg --gen-revoke 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 > daemon-main.rev`
- and put it in the vault. Optional upgrades, all without changing the identity: an expiry (`--quick-set-expire`),
- a `[S]` signing subkey and an `[A]` auth subkey (`--quick-add-key … rsa4096 sign|auth`), the primary offline (*offline*).
-- **Spare** `E989B40F2382569DD6080336BF684D91DF095E48` (`DAMEON-NIX`, ed25519, made 2026-10-08, never published):
- delete it with `gpg --delete-secret-and-public-keys E989B40F2382569DD6080336BF684D91DF095E48`, or keep it as a scratch key.
-- Pinentry: GNOME dialog (hosts/laptop/default.nix). `gpgx` uses loopback so the passphrase is typed in the terminal instead.
-- Secrets in the NixDaemon repo are **age**, not gpg (`nix-cheat secrets`); the vault's `ssd.key.gpg` is gpg-encrypted, decrypt it with `gpg -d`.
-- `gpgx-cheat` is the wrapper's card; `gpg-cheat SECTION` prints one section of this one.
diff --git a/home/cheats/nix.md b/home/cheats/nix.md
@@ -1,238 +0,0 @@
-# nix — rebuilding this machine from ~/NixDaemon
-
-NixOS **and** home-manager are one flake: `~/NixDaemon` (`NH_FLAKE` points at it).
-home-manager runs as a NixOS module, so **one rebuild does both**; there is no separate `home-manager switch` here.
-Terminal card: **`nix-cheat`** — sections `layout rebuild remote nh home search update rollback clean inspect add dotfiles secrets repo shell`.
-
-## layout — what lives where
-
-```text
-~/NixDaemon/flake.nix inputs (nixpkgs unstable, home-manager, hyprland, caelestia) · output nixosConfigurations.nixos
-hosts/laptop/default.nix the machine: boot, users (zsh login shell), greetd, Hyprland/uwsm, audio, fonts
-hosts/laptop/*.nix fan fix, nvidia, ssd, nix-settings (nh, caches), toolbox (envfs, PATH)
-home/default.nix home-manager entry; imports home/modules/*.nix
-home/modules/tools.nix packages: the toolbox closure and the general CLI tools ← add packages here
-home/modules/shell.nix zsh wiring (ZDOTDIR, fzf, completions), tmux plugins, secretspec
-home/modules/dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles, as symlinks ← add dotfile paths here
-home/modules/hyprland.nix Hyprland Lua config + helper scripts (wallpaper-picker, keybinds-menu …)
-home/hypr/*.lua core · looknfeel (animations) · defaults (binds) · bindings · lid · caelestia
-home/modules/caelestia.nix the shell (bar, launcher, lock), its CLI, wallpaper dir
-hosts/laptop/sops.nix sops-nix (system) · home/modules/sops.nix (user) · secrets/secrets.yaml · .sops.yaml
-```
-
-## rebuild — nixos-rebuild, the plain way
-
-```sh
-cd ~/NixDaemon
-sudo nixos-rebuild switch --flake .#nixos # build, activate now, make it the boot default
-sudo nixos-rebuild boot --flake .#nixos # build, activate on next boot only (kernel/driver changes)
-sudo nixos-rebuild test --flake .#nixos # activate now, NOT the boot default (try something out)
-nixos-rebuild build --flake .#nixos # just build → ./result, no sudo, nothing changes
-sudo nixos-rebuild switch --flake ~/NixDaemon#nixos # same, from any directory
-```
-
-- `#nixos` is the configuration name (= hostname). `#bootstrap` is the old Stage A GNOME config.
-- **New files must be known to git** or the flake does not see them: `git add path` first (see *repo*).
-- A rebuild activates home-manager too; its log: `journalctl --user -u home-manager-daemonsec -b` or `systemctl status home-manager-daemonsec`.
-
-## remote — build straight from the GitLab repo
-
-The flake does not have to be checked out: any `nix` command takes a **flake reference** to the repo.
-The repo is **private**, so use the ssh form (the key in ~/.ssh/config is registered on GitLab); `?ref=main` pins a branch, `?rev=<sha>` a commit.
-
-```sh
-REPO='git+ssh://git@gitlab.com/DAEMON-404/NixDaemon.git'
-sudo nixos-rebuild switch --flake "$REPO#nixos" # this machine, from the pushed main
-sudo nixos-rebuild boot --flake "$REPO?ref=main#nixos"
-nh os switch "$REPO" # nh takes the same reference
-nix build "$REPO#nixosConfigurations.nixos.config.system.build.toplevel" --no-link --print-out-paths
-nix flake show "$REPO" # what the repo exports
-nix flake metadata "$REPO" # which commit nix resolved
-# root (sudo) fetches with root's ssh: either run the fetch as you first (nix build …, cached), or give
-# root the key via /root/.ssh/config, or use an https token in ~/.config/nix/netrc (machine gitlab.com …).
-```
-
-**Fresh machine, from the live ISO** (disk already partitioned and mounted at /mnt):
-```sh
-sudo nixos-generate-config --root /mnt --show-hardware-config > hardware-configuration.nix # compare with hosts/laptop/
-git clone git@gitlab.com:DAEMON-404/NixDaemon.git ~/NixDaemon && cd ~/NixDaemon # ssh key first (see *secrets*)
-# copy the new hardware-configuration.nix into hosts/laptop/, git add it, then:
-sudo nixos-install --flake .#nixos
-```
-Afterwards: clone `~/git/daemon-sec-dotfiles` (every dotfile links there), restore the age key to
-`~/.config/sops/age/keys.txt` and `/var/lib/sops-nix/key.txt` (see *secrets*), and the toolbox to `~/.local/bin`.
-
-A remote reference is fetched fresh each time (nix caches it briefly, `--refresh` forces it); the local
-checkout at `~/NixDaemon` (`NH_FLAKE`) is the default for day-to-day work.
-
-## nh — the same, with a diff and a progress tree
-
-`nh` wraps nixos-rebuild: builds with nix-output-monitor, shows an **nvd diff** of what changes, then asks for sudo.
-
-```sh
-nh os switch # = sudo nixos-rebuild switch --flake ~/NixDaemon (NH_FLAKE), config picked by hostname
-nh os boot # build + boot default, activate on reboot
-nh os test # activate now, not the boot default
-nh os build # build only, no activation, no sudo
-nh os switch --dry # show what would happen, do nothing
-nh os switch --ask # show the diff, then confirm before activating
-nh os switch -H bootstrap # another configuration from the same flake (-H = hostname/attr)
-nh os switch -u # `nix flake update` first, then switch (updates EVERY input — see *update*)
-nh os info # list system generations
-nh os rollback # go back one generation (see *rollback*)
-nh os switch -- --show-trace # anything after -- goes to nix build (debug an eval error)
-```
-
-## home — home-manager in this setup
-
-- home-manager is **inside** the NixOS build (`home-manager.nixosModules.home-manager` in flake.nix, user `daemonsec` → `./home`).
- **`nh os switch` rebuilds it**; `nh home switch` / `home-manager switch` do not apply here (no standalone HM profile).
-- Did my home change apply? `ls -l ~/.config/<thing>` (links point into /nix/store or the dotfiles checkout),
- `systemctl --user status home-manager-daemonsec`, or `journalctl --user -u home-manager-daemonsec -b`.
-- HM backs up a file it has to replace as `*.hm-bak` (flake.nix `backupFileExtension`). Delete the backup once happy.
-- Only build the home part (fast check, no sudo):
- `nix build ~/NixDaemon#nixosConfigurations.nixos.config.home-manager.users.daemonsec.home.activationPackage`
-
-## search — finding packages and options
-
-```sh
-ns # fuzzy search everything indexed: nixpkgs packages, NixOS and home-manager options
-ns kitty # start with a query
-```
-Inside `ns` (fzf): type to filter, ↑↓ move, the right pane shows the description, version, homepage and
-the option's default. **Enter** prints the attribute name (paste it into `home.packages` in tools.nix),
-**ctrl-y** copies it, **ctrl-o** opens the homepage, **ctrl-s** the nixpkgs source, **Esc** quits.
-The index is built by `nix-search-tv` on first use (a few seconds) and refreshed by itself; by hand:
-```sh
-nix-search-tv print | head # the raw list · nix-search-tv preview firefox # one entry
-nh search firefox # search.nixos.org from the terminal (needs network)
-nix search nixpkgs firefox # nix's own search (slow first run: builds an eval cache)
-nix eval ~/NixDaemon#nixosConfigurations.nixos.pkgs.firefox.version # version THIS flake would install
-nix shell nixpkgs#firefox # try it without installing · nix run nixpkgs#cowsay -- hi
-```
-
-## update — moving the inputs
-
-```sh
-cd ~/NixDaemon
-nix flake update # all inputs → flake.lock (nixpkgs, home-manager, hyprland, caelestia, llm-agents)
-nix flake update nixpkgs home-manager # only these inputs
-nix flake update hyprland # Hyprland alone (uses hyprland.cachix.org, so usually no compile)
-nix flake lock # (re)write the lock without updating
-nix flake metadata # which revisions are locked right now
-nh os boot # then build it; boot = safest for kernel/driver bumps
-```
-
-Hyprland and Caelestia pin each other (flake.nix comments): update `hyprland` and `caelestia-*` together if the shell breaks.
-
-## rollback — when a generation misbehaves
-
-```sh
-nh os rollback # previous generation, now
-sudo nixos-rebuild switch --rollback # the same, plain
-nh os info # generation numbers
-sudo nix-env --switch-generation 5 -p /nix/var/nix/profiles/system && sudo /nix/var/nix/profiles/system/bin/switch-to-configuration switch
-```
-
-- At power-on the **systemd-boot menu** lists every generation: pick an older one, it boots as it was.
-- A broken Hyprland login: Ctrl+Alt+F2 for a console, then one of the above.
-
-## clean — store and generations
-
-```sh
-nh clean all --keep 5 --keep-since 14d # what the weekly timer runs (nix-settings.nix): drop old generations, GC
-nh clean all --dry # show what it would remove
-sudo nix-collect-garbage -d # the blunt version: delete all old generations, then GC
-nix store gc # GC only (nothing referenced by a generation is touched)
-du -sh /nix/store # how big is it
-```
-
-## inspect — see before you switch
-
-```sh
-nh os build && nvd diff /run/current-system result # what a switch would change
-nix build ~/NixDaemon#nixosConfigurations.nixos.config.system.build.toplevel --no-link --print-out-paths
-nix eval ~/NixDaemon#nixosConfigurations.nixos.pkgs.uv.version # version a package would get
-nix eval ~/NixDaemon#nixosConfigurations.nixos.config.programs.zsh.enable # read an option value
-ns <query> # fuzzy search nixpkgs + NixOS/HM options, with descriptions (nix-search-tv)
-nh search <package> # nixpkgs search (search.nixos.org)
-nix search nixpkgs <package> # local search (first run builds an index)
-nix shell nixpkgs#<package> # try a tool without installing it
-nix run nixpkgs#<package> -- --help
-nix flake check ~/NixDaemon # evaluate every output
-nix flake show ~/NixDaemon
-```
-
-## add — packages, options, dotfiles
-
-- **A package for the user**: `home/modules/tools.nix` → `home.packages` list → `nh os switch`.
-- **A system package / service**: `hosts/laptop/default.nix` (`environment.systemPackages`, `services.*`).
-- **A dotfile from the checkout**: `home/modules/dotfiles.nix` → add its path to the list → `nh os switch`.
-- **A Hyprland bind**: `home/hypr/bindings.lua` (user) or `defaults.lua` (stock); a helper script goes in `hyprland.nix`.
-- **A Caelestia setting**: `home/modules/caelestia.nix` → `programs.caelestia.settings`.
-- Then: `git add` anything new, `nh os switch`, and for Hyprland `hyprctl reload`.
-
-## dotfiles — the checkout is the source of truth
-
-- `~/git/daemon-sec-dotfiles/home/` is mirrored into `$HOME` by **symlinks** (dotfiles.nix): edit a file there and the live config changes at once — no rebuild.
-- A rebuild is needed only to add/remove a *path* in dotfiles.nix. `ls -l ~/.config/starship.toml` shows where a link goes.
-- zsh: `~/.dotfiles/config/*.zsh` → open a new shell (`exec zsh`). Plugins are vendored in `~/.dotfiles/config/plugins`.
-- Not linked on purpose (hypr, kitty, git, mimeapps, bash rc, .claude …): the list and reasons are at the top of dotfiles.nix.
-
-## secrets — sops-nix and secretspec
-
-Two tools, two jobs. **sops-nix**: secrets that belong in this repo, encrypted with **age**, decrypted at
-activation to `/run/secrets/NAME` (system) or `~/.config/sops-nix/secrets/NAME` (user).
-**secretspec**: runtime secrets for a *project*, declared in its `secretspec.toml`, stored in the keyring.
-
-```sh
-# sops — the one key: ~/.config/sops/age/keys.txt (user) = /var/lib/sops-nix/key.txt (system, root copy)
-sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt # once per machine
-age-keygen -y ~/.config/sops/age/keys.txt # the public key (recipient) in .sops.yaml
-sops secrets/secrets.yaml # edit: opens decrypted in $EDITOR, re-encrypts on save
-sops set secrets/secrets.yaml '["wifi-psk"]' '"hunter2"' # add/replace one value without the editor
-sops -d secrets/secrets.yaml # print decrypted
-sops -d --extract '["example"]' secrets/secrets.yaml
-sops updatekeys secrets/secrets.yaml # after editing the recipients in .sops.yaml
-```
-
-Then declare it and rebuild:
-```nix
-# hosts/laptop/sops.nix (system) # home/modules/sops.nix (user)
-sops.secrets.wifi-psk = { }; sops.secrets.my-token = { };
-sops.secrets."ssd.key".path = "/etc/secrets/ssd.key"; # e.g. the LUKS key instead of restoring it by hand
-```
-`nh os switch` → `/run/secrets/wifi-psk` (root, 0400; `owner = "daemonsec";` to read it as the user).
-Check: `sudo ls -l /run/secrets/`, `systemctl --user status sops-nix` (user side), `journalctl -b | grep sops`.
-
-```sh
-# secretspec — in a project directory (config: ~/.config/secretspec/config.toml → keyring, profile default)
-secretspec init # writes secretspec.toml (commit it; it holds names, never values)
-secretspec add DATABASE_URL # declare a secret (flags: --required/--default/--description)
-secretspec check # prompts for every missing value, stores it in the keyring
-secretspec set NAME # (re)store one value
-secretspec run -- ./server # run with the secrets in the environment
-secretspec export # print them for another tool (shell `eval`)
-secretspec claude configure # let Claude Code fetch its API credential through secretspec
-```
-
-## repo — committing ~/NixDaemon
-
-The repo is **jj, colocated with git** (`.jj` + `.git`). Nix flakes only see files git knows about, and jj keeps git's index at `@-`, so a *new* file is invisible to nix until it is added:
-
-```sh
-cd ~/NixDaemon
-git add home/modules/new.nix # make nix see a new file (modified tracked files are seen as-is)
-nh os build # or switch
-jj status # jj snapshots the working copy
-jj describe -m "what changed" && jj new # seal it (the vault ritual) — or plain: git add -A && git commit
-jj log # history
-```
-
-A dirty tree only prints `warning: Git tree '/home/daemonsec/NixDaemon' is dirty`; it still builds.
-
-## shell — after a switch
-
-- New packages are on PATH in a **new** shell (`exec zsh`); the login shell itself changes at the next login.
-- Hyprland: `hyprctl reload` re-reads `~/.config/hypr/*.lua`. Caelestia: `caelestia shell -r` restarts the shell.
-- Everything in the Nix profile: `ls /etc/profiles/per-user/daemonsec/bin`.
diff --git a/home/default.nix b/home/default.nix
@@ -1,32 +0,0 @@
-# home/default.nix — home-manager for the laptop user (imported from flake.nix).
-{ config, pkgs, lib, user, ... }:
-{
- imports = [
- ./modules/hyprland.nix # compositor config (Lua) and the carried shortcuts
- ./modules/caelestia.nix # programs.caelestia, shell.json, the Rosé Pine scheme
- ./modules/terminal.nix # kitty, fonts
- ./modules/tools.nix # toolbox runtime closure, python env, dotfiles links
- ./modules/shell.nix # zsh via the dotfiles ZDOTDIR tree, tmux plugins, secretspec
- ./modules/dotfiles.nix # every dotfile from ~/git/daemon-sec-dotfiles, as out-of-store symlinks
- ./modules/cheats.nix # nix-cheat: the rebuild / nh / flake card
- ./modules/sops.nix # sops-nix for the user (same secrets file, age key in ~/.config/sops/age)
- ./modules/neovim.nix # nvf: Neovim with a small, Nix-built plugin set
- ./modules/prompt.nix # starship prompt and fastfetch card, Rosé Pine, NixOS logo
- ./modules/fan.nix # `fan`: status/watch without root, max/auto with a clamp watchdog
- ./modules/ssh.nix # the ssh key (sops) and ~/.ssh/config
- ./modules/gpg.nix # the GPG main key (public in-repo, secret via sops) and gpg.conf
- ./modules/git.nix # git identity, signing with the main key, delta
- ./modules/media.nix # mpd + rmpc, mpv
- ./modules/yazi.nix # yazi with previews, Rosé Pine, plugins
- ./modules/gtk.nix # Yaru-purple icons, cursor, prefer-dark
- ];
-
- home = {
- username = user;
- homeDirectory = "/home/${user}";
- stateVersion = "26.05";
- };
-
- programs.home-manager.enable = true;
- xdg.enable = true;
-}
diff --git a/home/modules/caelestia.nix b/home/modules/caelestia.nix
@@ -1,249 +0,0 @@
-# home/modules/caelestia.nix — Caelestia Shell as bar, launcher, notifications,
-# OSD, lock and session menu; its CLI; the static Rosé Pine (M3) schemes.
-#
-# Look (2026-10-08): Rosé Pine dark (main, not moon) everywhere, translucent
-# shell layers over blur, and Caelestia's own framed bar: the screen edge is a
-# 10 px frame with 25 px rounded inner corners, the clock and tray sit in pill
-# backgrounds, occupied workspaces are filled, the Nix snowflake is the logo.
-# The sidebar, utilities and notification panels are narrower than stock
-# (../caelestia/shell-tokens.json: 340 / 340 / 360 px instead of 430).
-# A Rosé Pine Dawn mapping is registered too: caelestia scheme set -n rose-pine -f rose-pine-dawn
-# and back: caelestia scheme set -n rose-pine -f rose-pine-dark
-#
-{ config, pkgs, lib, inputs, ... }:
-let
- system = pkgs.stdenv.hostPlatform.system;
- hyprPkg = inputs.hyprland.packages.${system}.hyprland;
-
- # The CLI knows schemes by name and re-reads their colours whenever the
- # wallpaper changes, so the two hand-mapped Rosé Pine → M3 palettes are
- # registered as a real scheme (name rose-pine; flavours rose-pine-dark, mode
- # dark, and rose-pine-dawn, mode light). The CLI's own `rosepine/dawn` is a
- # Material palette generated from a gold seed, not the Rosé Pine colours.
- cli = (inputs.caelestia-cli.packages.${system}.default).overrideAttrs (old: {
- patchPhase = (old.patchPhase or "") + ''
- install -Dm644 ${../caelestia/rose-pine-dark.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dark/dark.txt
- install -Dm644 ${../caelestia/rose-pine-dawn.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dawn/light.txt
- '';
- });
-
- shell =
- ((inputs.caelestia-shell.packages.${system}.with-cli).override {
- caelestia-cli = cli;
- hyprland = hyprPkg; # hyprctl from the same Hyprland the compositor runs
- }).overrideAttrs (old: {
- # bar.activeWindow.compact shows the program (desktop-entry name for the
- # window class) instead of the window title. Upstream's compact mode only
- # trims the title at its last " - ". Rebuilds the shell locally.
- patches = (old.patches or [ ]) ++ [ ../caelestia/active-window-program-name.patch ];
- });
-
- wallpaperDir = "${config.home.homeDirectory}/git/daemon-sec-dotfiles/home/.config/omarchy/backgrounds/rose-pine-dark";
-in
-{
- programs.caelestia = {
- enable = true;
- package = shell;
-
- cli = {
- enable = true;
- package = cli;
- # Theming is static Rosé Pine from this repo; the CLI must not rewrite
- # kitty, GTK, Hyprland or anything else when the wallpaper changes.
- settings.theme = {
- enableTerm = false;
- enableHypr = false;
- enableDiscord = false;
- enableSpicetify = false;
- enablePandora = false;
- enableFuzzel = false;
- enableBtop = false;
- enableNvtop = false;
- enableHtop = false;
- enableGtk = false;
- enableQt = false;
- enableWarp = false;
- enableChromium = false;
- enableZed = false;
- enableCava = false;
- };
- };
-
- settings = {
- appearance = {
- font = {
- clock = "Rubik";
- workspaces = "Rubik";
- headline.family = "Noto Sans";
- title.family = "Noto Sans";
- body.family = "Noto Sans";
- label.family = "Noto Sans";
- mono.family = "DMMono Nerd Font";
- };
- anim.durations.scale = 1.15;
- transparency = {
- enabled = true;
- base = 0.85;
- layers = 0.4;
- };
- };
- general = {
- # The Nix snowflake in the bar, dashboard and lock screen (empty = Caelestia's own logo).
- logo = "/run/current-system/sw/share/icons/hicolor/scalable/apps/nix-snowflake.svg";
- apps = {
- terminal = [ "kitty" ];
- audio = [ "caelestia" "shell" "drawers" "toggle" "sidebar" ]; # was the Omarchy audio popup
- playback = [ "mpv" ];
- explorer = [ "nautilus" ];
- };
- idle = {
- lockBeforeSleep = false;
- inhibitWhenAudio = true;
- # Lock after 15 min idle, screen off after 20; audio playing or a
- # fullscreen app with an idle inhibitor holds both off.
- timeouts = [
- { timeout = 900; idleAction = "lock"; respectInhibitors = true; }
- { timeout = 1200; idleAction = "dpms off"; returnAction = "dpms on"; }
- ];
- };
- };
- # Omarchy drew the wallpaper; here Caelestia does. Colours stay static.
- background = {
- enabled = true;
- wallpaperEnabled = true;
- # Big clock on the wallpaper, bottom right, with a soft shadow.
- desktopClock = {
- enabled = true;
- position = "bottom-right";
- scale = 1.0;
- shadow.enabled = true;
- };
- # Audio visualiser along the bottom of the wallpaper while something plays (cava is built in).
- visualiser = {
- enabled = true;
- autoHide = true;
- blur = false;
- rounding = 1;
- spacing = 1;
- };
- };
- bar = {
- persistent = true;
- showOnHover = true;
- workspaces = {
- shown = 5;
- activeIndicator = true;
- occupiedBg = true; # filled pills behind workspaces that have windows
- showWindows = true;
- activeTrail = true;
- };
- activeWindow = {
- compact = true; # the program's name (patched, see `shell` above), not the title
- inverted = true; # on a primary-coloured pill
- };
- clock = {
- showDate = true;
- showIcon = true;
- background = true; # clock in its own pill
- };
- tray = {
- background = true; # tray in its own pill
- recolour = true; # tray icons tinted to the scheme
- };
- statusIcons = [
- { id = "lockStatus"; enabled = true; }
- { id = "audio"; enabled = true; }
- { id = "microphone"; enabled = true; } # shows when something is capturing
- { id = "kbLayout"; enabled = false; }
- { id = "network"; enabled = true; }
- { id = "bluetooth"; enabled = true; }
- { id = "battery"; enabled = true; }
- ];
- };
- # Caelestia's frame: the bar is one side of a border around the screen
- # whose inner corners are rounded. Stock values; the carried 5/0/0 was a flat strip.
- border = {
- thickness = 10;
- rounding = 25;
- smoothing = 20;
- };
- dashboard = {
- enabled = true;
- showWeather = true;
- performance.showGpu = true;
- };
- launcher = {
- enabled = true;
- maxShown = 8;
- vimKeybinds = true; # ctrl+j/k move, like everywhere else here
- actions = [
- { name = "Calculator"; icon = "calculate"; description = "Do simple math equations (powered by Qalc)"; command = [ "autocomplete" "calc" ]; enabled = true; dangerous = false; }
- { name = "Wallpaper"; icon = "image"; description = "Pick a wallpaper"; command = [ "caelestia" "wallpaper" ]; enabled = true; dangerous = false; }
- { name = "Lock"; icon = "lock"; description = "Lock the session"; command = [ "caelestia" "shell" "lock" "lock" ]; enabled = true; dangerous = false; }
- { name = "Sleep"; icon = "bedtime"; description = "Suspend"; command = [ "systemctl" "suspend" ]; enabled = true; dangerous = false; }
- { name = "Settings"; icon = "settings"; description = "Configure the shell"; command = [ "caelestia" "shell" "nexus" "open" ]; enabled = true; dangerous = false; }
- { name = "Logout"; icon = "exit_to_app"; description = "Log out of the current session"; command = [ "uwsm" "stop" ]; enabled = true; dangerous = true; }
- { name = "Reboot"; icon = "cached"; description = "Reboot the system"; command = [ "systemctl" "reboot" ]; enabled = true; dangerous = true; }
- { name = "Shutdown"; icon = "power_settings_new"; description = "Shutdown the system"; command = [ "systemctl" "poweroff" ]; enabled = true; dangerous = true; }
- ];
- };
- lock = {
- enabled = true; # Caelestia is the lock screen here (Omarchy had hyprlock)
- useWallpaper = true; # the wallpaper behind the lock, not a flat colour
- };
- notifs = {
- expire = true;
- defaultExpireTimeout = 6000;
- actionOnClick = true;
- };
- osd = {
- enabled = true;
- enableBrightness = true;
- };
- services = {
- gpuType = "Generic"; # must be a string
- smartScheme = false; # never derive colours from the wallpaper
- defaultPlayer = "rmpc";
- weatherLocation = "54.15,-4.48"; # Douglas, Isle of Man (dashboard weather card)
- };
- session = {
- enabled = true;
- icons.hibernate = "bedtime";
- commands = {
- logout = [ "uwsm" "stop" ];
- shutdown = [ "systemctl" "poweroff" ];
- hibernate = [ "systemctl" "suspend" ];
- reboot = [ "systemctl" "reboot" ];
- };
- };
- sidebar.enabled = true;
- utilities = {
- enabled = true;
- toasts.nowPlaying = true; # a toast when the track changes
- };
- paths.wallpaperDir = wallpaperDir;
- };
- };
-
- # The scheme the shell reads at start: Rosé Pine dark (scheme.json;
- # scheme-dawn.json is the light mapping). `caelestia scheme set …` rewrites
- # this file (home-manager backs the symlink up as .hm-bak when that happens).
- home.file.".local/state/caelestia/scheme.json".source = ../caelestia/scheme.json;
-
- # Internal size tokens: the shell reads this file (defaults in its source,
- # plugin/src/Caelestia/Config/tokens.hpp: sidebar, utilities and
- # notification width 430, bar innerWidth 40). Here: sidebar and utilities
- # 340, notifications 360, bar 36. Rounding, padding and spacing stay stock.
- home.file.".config/caelestia/shell-tokens.json".source = ../caelestia/shell-tokens.json;
-
- # First wallpaper, only if none was ever chosen (Caelestia keeps the choice in state).
- home.activation.caelestiaWallpaper = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
- st="$HOME/.local/state/caelestia/wallpaper"
- if [ ! -e "$st/path.txt" ]; then
- wp=$(ls "${wallpaperDir}"/*.png 2>/dev/null | head -1 || true)
- if [ -n "$wp" ]; then
- mkdir -p "$st" && printf '%s' "$wp" > "$st/path.txt" && ln -sfn "$wp" "$st/current"
- fi
- fi
- '';
-}
diff --git a/home/modules/cheats.nix b/home/modules/cheats.nix
@@ -1,61 +0,0 @@
-# home/modules/cheats.nix — the cheat cards this repo ships, in the house
-# style (~/.local/bin/*-cheat, rendered with the toolbox's cheat-render).
-# Every home/cheats/<name>.md becomes a `<name>-cheat` command:
-#
-# nix-cheat rebuilding this machine: nixos-rebuild, nh, remote, search, update, rollback, secrets, repo
-# gpg-cheat GnuPG from the key hierarchy to signing, encryption, SSH, git, offline primary, fixes
-#
-# <name>-cheat the whole card <name>-cheat --list the section names
-# <name>-cheat SECTION one section <name>-cheat --raw the markdown itself
-#
-# Rendered with cheat-render when that is on PATH, else glow, else printed
-# plain. These cards live here (not in the dotfiles) because they document
-# this repo and this machine; xcheats only lists ~/.local/bin cards, so call
-# these by name.
-{ pkgs, lib, ... }:
-let
- cards = [ "nix" "gpg" ];
-
- mkCheat = name: pkgs.writeShellScriptBin "${name}-cheat" ''
- set -uo pipefail
- card=${../cheats + "/${name}.md"}
-
- usage() {
- echo "usage: ${name}-cheat [SECTION] [--list] [--raw] [--help]"
- echo " sections: $(sections | tr '\n' ' ')"
- }
- sections() { # first word of each '## ' heading
- ${pkgs.gnused}/bin/sed -n 's/^## \([a-z-]*\).*/\1/p' "$card"
- }
- section() { # the heading whose first word matches $1, up to the next heading
- ${pkgs.gawk}/bin/awk -v want="$1" '
- /^## / { on = (tolower($2) == want) }
- /^# / { next }
- on
- ' "$card"
- }
- render() {
- if [ ! -t 1 ]; then cat
- elif command -v cheat-render >/dev/null 2>&1; then cheat-render - | ''${PAGER:-less -R}
- else ${pkgs.glow}/bin/glow -p -
- fi
- }
-
- case "''${1:-}" in
- -h|--help) usage; exit 0 ;;
- --list) sections; exit 0 ;;
- --raw) cat "$card"; exit 0 ;;
- "") render < "$card" ;;
- *)
- key=$(echo "$1" | tr '[:upper:]' '[:lower:]')
- out=$(section "$key")
- if [ -z "$out" ]; then
- echo "${name}-cheat: no section '$1'" >&2; usage >&2; exit 1
- fi
- { head -1 "$card"; echo; printf '%s\n' "$out"; } | render ;;
- esac
- '';
-in
-{
- home.packages = map mkCheat cards;
-}
diff --git a/home/modules/dotfiles.nix b/home/modules/dotfiles.nix
@@ -1,114 +0,0 @@
-# home/modules/dotfiles.nix — every dotfile from the dotfiles checkout, placed
-# by home-manager.
-#
-# ~/git/daemon-sec-dotfiles is the restorable snapshot of the Omarchy
-# workstation (its README: "files restored relative to $HOME"). home-manager
-# puts each of its files where it belongs as an out-of-store symlink
-# (mkOutOfStoreSymlink), so there is one source of truth: edit the checkout and
-# the live config changes; `nh os switch` is only needed when a path is added
-# or removed here. The links dangle harmlessly until the repo is cloned.
-#
-# Not linked, and why (each is one line to add if wanted):
-# .config/hypr, .config/kitty Nix-managed (home/modules/hyprland.nix, terminal.nix)
-# .config/nvim the AstroNvim tree; Neovim is nvf now (home/modules/neovim.nix)
-# .config/starship.toml, .config/fastfetch the Omarchy-era prompt and fetch; both are
-# Nix-managed now (home/modules/prompt.nix)
-# .config/mpd, rmpc, mpv, yazi Nix-managed (home/modules/media.nix, yazi.nix), carried from the checkout
-# .config/omarchy*, Omacom, hyprmoncfg Omarchy's own trees; caelestia.nix reads the
-# wallpaper directory straight from the checkout
-# .config/systemd/user Omarchy-era units; caelestia-shell.service there would
-# fight the home-manager caelestia service
-# .config/autostart Omarchy autostarts for apps not installed here
-# .config/mimeapps.list defaults point at chromium / HEY, neither installed:
-# xdg-open would fail on every link
-# .config/git turns on commit signing with a key not on this machine
-# .config/fontconfig, dconf, gtk-4.0 home-manager writes these (fonts.fontconfig, gtk.nix)
-# .config/gtk-3.0/bookmarks paths under the old /home/daemon-sec
-# .config/user-dirs.dirs, floorp XDG defaults already match; a browser profile is state
-# .config/tmux holds only a disabled backup; ~/.tmux.conf is the config
-# .bashrc, .bash_profile, .bash_logout source Omarchy's bash rc unguarded (errors on NixOS)
-# .zshrc, .zprofile the dead decoys; ZDOTDIR=~/.dotfiles bypasses them
-# .XCompose includes /usr/share/omarchy/default/xcompose
-# .claude, .codex, .agents live agent state on this machine (plugins, sessions)
-# bin, .local/bin ~/.local/bin is the live toolbox repo (README)
-# .local/share/applications Omarchy web-app launchers (omarchy-launch-webapp)
-# .local/share/icons/hicolor apps install into it; the themes are linked one by one
-{ config, lib, ... }:
-let
- repo = "${config.home.homeDirectory}/git/daemon-sec-dotfiles";
- home = "${repo}/home";
- link = path: config.lib.file.mkOutOfStoreSymlink "${home}/${path}";
-
- # Same path under $HOME as in the checkout's home/.
- same = paths: lib.genAttrs paths (p: { source = link p; });
- # Entries of .config, by name.
- config' = names: lib.genAttrs names (n: { source = link ".config/${n}"; });
-
- cursorThemes = [
- "modernxp-retro-black" # the active cursor (gtk.nix, core.lua)
- "modernxp-retro-black-hyprcursor"
- "modernxp-rose-pine"
- "modernxp-rose-pine-hyprcursor"
- "retrosmart-rose-pine"
- "retrosmart-rose-pine-hyprcursor"
- "rose-pine-hyprcursor"
- "BreezeX-RosePine-Linux"
- ];
-in
-{
- home.file =
- same [
- ".dotfiles" # the zsh ZDOTDIR tree (home/modules/shell.nix sets ZDOTDIR)
- ".tmux.conf"
- ".ripgreprc" # RIPGREP_CONFIG_PATH, exported by .dotfiles/config/ripgrep.zsh
- "Music/AGENTS.md"
- ]
- // same (map (t: ".local/share/icons/${t}") cursorThemes)
- // {
- # The patched DMMono TTFs live outside home/ in the checkout.
- ".local/share/fonts/nerd-fonts-dm-mono".source =
- config.lib.file.mkOutOfStoreSymlink "${repo}/assets/fonts/nerd-fonts-dm-mono";
- };
-
- xdg.configFile = config' [
- # shell and prompt
- "atuin"
- "bat" # the "Rose Pine" theme BAT_THEME names (shell.nix rebuilds bat's cache)
- "carapace"
- "cheats" # the markdown cheat cards (cheats.zsh, ~/.local/bin/cheat-*)
- "crossfetch" # the login splash animation (animations.zsh); the fetch card itself is prompt.nix
- "eza"
- "mise"
- # tools
- "btop"
- "lazygit"
- "jj"
- "emacs"
- "opencode"
- "feroxbuster"
- "uncover"
- "herdr"
- "tensaku"
- "ai-usagebar"
- "bg-pasticcio"
- "libvirt"
- # media
- "cava"
- "imv"
- "zathura"
- "xournalpp"
- "spicetify"
- "vesktop"
- "pipewire" # 10-sample-rates.conf
- "wireplumber" # bluetooth-a2dp-autoconnect.conf
- # terminals and desktop bits
- "alacritty"
- "foot"
- "ghostty"
- "fcitx5"
- "xdg-terminals.list" # kitty first, for xdg-terminal-exec
- "chromium-flags.conf" # read only if a chromium is ever installed
- "menus" # the chrome-apps application menu entries
- "uwsm" # env.d/50-rose-pine-cursor (same values core.lua sets)
- ];
-}
diff --git a/home/modules/fan.nix b/home/modules/fan.nix
@@ -1,120 +0,0 @@
-# home/modules/fan.nix — `fan`: the laptop's fans from the terminal, safely.
-#
-# fan one line: CPU °C, fan rpm and %, clock cap, load, clamp yes/no, EC mode
-# fan watch [s] the same line every s seconds (default 2), Ctrl-C to stop
-# fan max 100 % now, with the watchdog (below) fan 60 fixed 60 % (30-100)
-# fan auto back to the EC's own curve; stops the watchdog
-# fan log what the watchdog has done
-#
-# Why a watchdog: this laptop's EC, once in manual fan mode, asserts PROCHOT
-# and pins every core at 399 MHz as soon as load starts (fan-throttle-guard.nix).
-# `fan max` therefore starts a transient user unit (fan-watchdog) that samples
-# /proc/stat and /proc/cpuinfo every second and, the moment CPU busy ≥ 25 %
-# while no core is above 600 MHz (fanfix's clamp rule), runs `fan-ec auto`,
-# sends a notification and exits. Root access is `sudo -n fan-ec …`
-# (hosts/laptop/fan-cli.nix: a store script with a passwordless rule for wheel).
-# Reads need no root at all: k10temp and the uniwill hwmon are world-readable.
-{ pkgs, lib, ... }:
-let
- bin = lib.makeBinPath [ pkgs.coreutils pkgs.gawk pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.libnotify ];
- # NixOS's setuid sudo lives in /run/wrappers; the store copy is not setuid
- # and refuses to run ("must be owned by uid 0 and have the setuid bit set").
- sudo = "/run/wrappers/bin/sudo";
-
- # shared read-only sampler, sourced by both scripts
- lib-sh = pkgs.writeText "fan-lib.sh" ''
- TRIP_MHZ=600; BUSY_MIN=25
- STATE=''${XDG_RUNTIME_DIR:-/tmp}/fan.stat
- hw() { local n; for h in /sys/class/hwmon/hwmon*; do n=$(cat "$h/name" 2>/dev/null); [ "$n" = "$1" ] && { echo "$h"; return; }; done; }
- cpu_temp() { local h; h=$(hw k10temp); [ -n "$h" ] && echo $(( $(cat "$h/temp1_input") / 1000 )) || echo -; }
- fan_rpm() { local h; h=$(hw uniwill); [ -n "$h" ] && cat "$h/fan1_input" || echo -; }
- fan_pct() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/pwm1") * 100 / 255 )) || echo -; }
- gpu_temp() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/temp2_input") / 1000 )) || echo -; }
- cap_mhz() { echo $(( $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq) / 1000 )); }
- clocks() { awk '/^cpu MHz/ {s+=$4; n++; if ($4>m) m=$4} END {printf "%d %d", (n?s/n:0), m}' /proc/cpuinfo; }
- # CPU busy % since the previous call (snapshot in $STATE); 0 on the first call
- busy() {
- local cur prev b=0
- cur=$(head -1 /proc/stat)
- [ -r "$STATE" ] && prev=$(cat "$STATE") || prev=
- printf '%s' "$cur" > "$STATE"
- [ -n "$prev" ] && b=$(awk -v a="$prev" -v b="$cur" 'BEGIN {
- na=split(a,x," "); nb=split(b,y," "); ta=0; tb=0
- for (i=2;i<=na;i++) ta+=x[i]; for (i=2;i<=nb;i++) tb+=y[i]
- ia=x[5]+x[6]; ib=y[5]+y[6]; d=tb-ta; if (d<=0) {print 0; exit}
- printf "%d", 100*(d-(ib-ia))/d }')
- echo "$b"
- }
- clamped() { # 1 when busy yet no core above TRIP_MHZ
- local b=$1 mx=$2; [ "$b" -ge "$BUSY_MIN" ] && [ "$mx" -gt 0 ] && [ "$mx" -lt "$TRIP_MHZ" ] && echo 1 || echo 0
- }
- ec_mode() { ${sudo} -n /run/current-system/sw/bin/fan-ec mode 2>/dev/null || echo "?"; }
- status_line() {
- local b mx avg; b=$(busy); read -r avg mx <<< "$(clocks)"
- local cl; cl=$(clamped "$b" "$mx")
- printf 'CPU %s°C · GPU %s°C · fan %s rpm %s%% · cap %s MHz · clocks avg %s / max %s · load %s%% · clamp %s · fans %s\n' \
- "$(cpu_temp)" "$(gpu_temp)" "$(fan_rpm)" "$(fan_pct)" "$(cap_mhz)" "$avg" "$mx" "$b" \
- "$([ "$cl" = 1 ] && echo YES || echo no)" "$(ec_mode)"
- }
- '';
-
- fan-watchdog = pkgs.writeShellScriptBin "fan-watchdog" ''
- set -uo pipefail
- PATH=${bin}:$PATH
- . ${lib-sh}
- LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log; mkdir -p "$(dirname "$LOG")"
- log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
- log "armed: fans manual ($1), watching for the EC clamp"
- busy >/dev/null; sleep 1
- while :; do
- b=$(busy); read -r _ mx <<< "$(clocks)"
- if [ "$(clamped "$b" "$mx")" = 1 ]; then
- out=$(${sudo} -n /run/current-system/sw/bin/fan-ec auto 2>&1)
- log "CLAMP: load $b %, max core $mx MHz -> fan-ec auto: $out"
- notify-send -a fan -u critical "Fans back to auto" "EC clamp hit (load $b %, cores at $mx MHz). Manual fan mode released."
- exit 0
- fi
- sleep 1
- done
- '';
-
- fan = pkgs.writeShellScriptBin "fan" ''
- set -uo pipefail
- PATH=${bin}:$PATH
- . ${lib-sh}
- UNIT=fan-watchdog
- EC=/run/current-system/sw/bin/fan-ec
- LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log
-
- arm() { # start (or restart) the watchdog as a transient user unit
- systemctl --user stop "$UNIT" 2>/dev/null || true
- systemd-run --user --unit="$UNIT" --collect --quiet ${fan-watchdog}/bin/fan-watchdog "$1" \
- && echo "watchdog armed: back to auto the moment the EC clamps the CPU (fan log)"
- }
- disarm() { systemctl --user stop "$UNIT" 2>/dev/null || true; }
-
- if [ "$(id -u)" = 0 ]; then
- echo "fan: run this as yourself, not under sudo (it needs your user session for the watchdog; root access is handled inside)" >&2
- exit 1
- fi
-
- case "''${1:-}" in
- ""|status) status_line ;;
- watch)
- iv=''${2:-2}; busy >/dev/null; sleep "$iv"
- while :; do status_line; sleep "$iv"; done ;;
- max) ${sudo} -n "$EC" max && arm max ;;
- auto) disarm; ${sudo} -n "$EC" auto ;;
- [0-9]*) p=''${1%\%}; ${sudo} -n "$EC" "$p" && arm "$p %" ;;
- ec) ${sudo} -n "$EC" status ;;
- log) [ -r "$LOG" ] && tail -n ''${2:-20} "$LOG" || echo "no watchdog log yet" ;;
- -h|--help|help)
- echo "usage: fan [status] | watch [sec] | max | <30-100> | auto | ec | log [n]"
- echo " max / <pct> put the fans in manual mode WITH the clamp watchdog; auto releases them." ;;
- *) echo "fan: unknown command '$1' (try: fan help)" >&2; exit 2 ;;
- esac
- '';
-in
-{
- home.packages = [ fan fan-watchdog ];
-}
diff --git a/home/modules/git.nix b/home/modules/git.nix
@@ -1,81 +0,0 @@
-# home/modules/git.nix — git, from the flake (was ~/.gitconfig written by the
-# bootstrap script plus the dotfiles' .config/git, which is not linked).
-#
-# Identity: DAEMON-404 <zer0sec.xp@icloud.com>; every commit and tag signed
-# with the GPG main key (home/modules/gpg.nix), which GitLab already knows.
-# Credentials for https remotes come from gh / glab; clones use ssh anyway.
-# delta paints diffs (Rosé Pine, from the dotfiles' git config).
-{ ... }:
-{
- programs.git = {
- enable = true;
- signing = {
- key = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
- format = "openpgp";
- signByDefault = true; # commit.gpgSign and tag.gpgSign
- };
- settings = {
- user = {
- name = "DAEMON-404";
- email = "zer0sec.xp@icloud.com";
- };
- alias = {
- co = "checkout";
- br = "branch";
- ci = "commit";
- st = "status";
- lg = "log --oneline --graph --decorate -20";
- };
- init.defaultBranch = "main";
- pull.rebase = true;
- push.autoSetupRemote = true;
- diff = {
- algorithm = "histogram";
- colorMoved = "default";
- mnemonicPrefix = true;
- };
- commit.verbose = true;
- column.ui = "auto";
- branch.sort = "-committerdate";
- tag.sort = "-version:refname";
- rerere = {
- enabled = true;
- autoupdate = true;
- };
- merge.conflictstyle = "zdiff3";
- core = {
- autocrlf = "input";
- safecrlf = "warn";
- };
- credential = {
- "https://github.com".helper = "!gh auth git-credential";
- "https://gist.github.com".helper = "!gh auth git-credential";
- "https://gitlab.com".helper = "!glab auth git-credential";
- };
- };
- };
-
- programs.delta = {
- enable = true;
- enableGitIntegration = true;
- options = {
- navigate = true;
- light = false;
- line-numbers = true;
- side-by-side = false;
- hyperlinks = true;
- syntax-theme = "none";
- minus-style = "\"#eb6f92\" \"#26233a\"";
- minus-emph-style = "bold \"#eb6f92\" \"#403d52\"";
- plus-style = "\"#9ccfd8\" \"#26233a\"";
- plus-emph-style = "bold \"#31748f\" \"#403d52\"";
- line-numbers-minus-style = "\"#eb6f92\"";
- line-numbers-plus-style = "\"#31748f\"";
- line-numbers-zero-style = "\"#6e6a86\"";
- file-style = "\"#31748f\" bold";
- file-decoration-style = "\"#c4a7e7\" ul";
- hunk-header-style = "\"#eb6f92\" bold";
- hunk-header-decoration-style = "\"#6e6a86\" box";
- };
- };
-}
diff --git a/home/modules/gpg.nix b/home/modules/gpg.nix
@@ -1,51 +0,0 @@
-# home/modules/gpg.nix — the GPG main key and gpg.conf, from the flake.
-#
-# public key home/gpg/daemon-main.pub.asc → imported with ultimate trust (programs.gpg.publicKeys)
-# secret key sops secret gpg_main_secret (the armored export, still under its own passphrase):
-# imported into the keyring on activation if the keyring lacks it
-# gpg.conf programs.gpg.settings (the gpg-cheat `setup` defaults)
-#
-# The agent itself is NixOS's (programs.gnupg.agent, pinentry-gnome3), so
-# services.gpg-agent is deliberately not enabled here.
-# Key: daemon (Main_Key) <zer0sec.xp@icloud.com>, RSA 4096, 2025-12-30, the one on GitLab.
-{ config, pkgs, lib, ... }:
-let
- fpr = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
-in
-{
- sops.secrets.gpg_main_secret = { };
-
- programs.gpg = {
- enable = true;
- mutableKeys = true; # other people's keys and new subkeys stay editable
- mutableTrust = true;
- publicKeys = [
- { source = ../gpg/daemon-main.pub.asc; trust = 5; }
- ];
- settings = {
- default-key = fpr;
- default-recipient-self = true;
- keyid-format = "0xlong";
- with-fingerprint = true;
- with-subkey-fingerprints = true;
- personal-cipher-preferences = "AES256 AES192 AES";
- personal-digest-preferences = "SHA512 SHA384 SHA256";
- cert-digest-algo = "SHA512";
- no-emit-version = true;
- no-comments = true;
- keyserver = "hkps://keys.openpgp.org";
- auto-key-locate = "local,wkd";
- trust-model = "tofu+pgp";
- };
- };
-
- # Import the secret key once (idempotent: skipped when the keyring has it).
- # Runs after sops-nix has decrypted the secrets.
- home.activation.gpgMainKey = lib.hm.dag.entryAfter [ "sops-nix" ] ''
- if [ -r "${config.sops.secrets.gpg_main_secret.path}" ] \
- && ! ${pkgs.gnupg}/bin/gpg --batch --list-secret-keys ${fpr} >/dev/null 2>&1; then
- run ${pkgs.gnupg}/bin/gpg --batch --quiet --import "${config.sops.secrets.gpg_main_secret.path}" \
- && echo "gpg: imported the main secret key ${fpr}"
- fi
- '';
-}
diff --git a/home/modules/gtk.nix b/home/modules/gtk.nix
@@ -1,26 +0,0 @@
-# home/modules/gtk.nix — icons, cursor, dark preference.
-# The cursor theme files are symlinked from the dotfiles checkout in tools.nix
-# (modernxp-retro-black for Xcursor, modernxp-retro-black-hyprcursor for
-# Hyprland), so there is no package to point home.pointerCursor at.
-{ pkgs, ... }:
-{
- gtk = {
- enable = true;
- iconTheme = {
- name = "Yaru-purple";
- package = pkgs.yaru-theme;
- };
- cursorTheme = {
- name = "modernxp-retro-black";
- size = 24;
- };
- colorScheme = "dark"; # GTK prefer-dark
- };
-
- home.sessionVariables = {
- XCURSOR_THEME = "modernxp-retro-black";
- XCURSOR_SIZE = "24";
- HYPRCURSOR_THEME = "modernxp-retro-black-hyprcursor"; # name from the theme's manifest.hl
- HYPRCURSOR_SIZE = "24";
- };
-}
diff --git a/home/modules/hyprland.nix b/home/modules/hyprland.nix
@@ -1,149 +0,0 @@
-# home/modules/hyprland.nix — Hyprland (Lua config) and the carried shortcuts.
-#
-# Hyprland 0.56 is configured in Lua (hyprland.lua, `hl.*` API); that is also
-# the dialect the carried shortcuts and the toolbox helpers (dropterm, winsnap,
-# vault-open, lid-control: `hyprctl dispatch 'hl.dsp…'`) already speak. The
-# config is split like the vault's shortcuts/:
-# hypr/omarchy.lua the `o` helpers the carried files were written against
-# hypr/core.lua monitor, env, look, input, Caelestia layer rules
-# hypr/looknfeel.lua the springy animations, shadows, snap, swallow (dotfiles looknfeel.lua)
-# hypr/defaults.lua the stock Omarchy binds as captured in keybinds.txt
-# hypr/bindings.lua shortcuts/bindings.lua (user overrides, window mode)
-# hypr/lid.lua shortcuts/lid.lua
-# hypr/caelestia.lua shortcuts/caelestia.lua (Caelestia keys, always on here)
-{ config, pkgs, lib, inputs, ... }:
-let
- system = pkgs.stdenv.hostPlatform.system;
- hyprPkg = inputs.hyprland.packages.${system}.hyprland;
- shellCli = "${config.programs.caelestia.cli.package}/bin/caelestia";
-
- # Small helpers the stock Omarchy binds relied on. They exist only to serve
- # this config, so they live here rather than in ~/.local/bin.
- helpers = [
- # The picker the omarchy-menu-* cheat sheets (bin/) pipe into. Prints the chosen line.
- (pkgs.writeShellScriptBin "omarchy-menu-select" ''
- title=''${1:-Select}
- exec ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "$title › " --width 110 --lines 24
- '')
- # omarchy-not-ported "<bind description>" ["<what it did on Omarchy>"]: an
- # honest notification instead of a key that silently does nothing.
- (pkgs.writeShellScriptBin "omarchy-not-ported" ''
- ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 4000 "$1" "Not set up on this NixOS build.''${2:+ Omarchy: $2}"
- '')
- (pkgs.writeShellScriptBin "nixdaemon-show" ''
- # nixdaemon-show time|battery|calendar|kbd-backlight-cycle
- n() { ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 5000 "$@"; }
- case "''${1:-}" in
- time) n "$(date '+%H:%M')" "$(date '+%A %-d %B %Y')" ;;
- battery) b=/sys/class/power_supply/BAT0; n "Battery $(cat $b/capacity)%" "$(cat $b/status)" ;;
- calendar) n "$(date '+%B %Y')" "$(cal | tail -n +2)" ;;
- kbd-backlight-cycle)
- d=$(ls -d /sys/class/leds/*kbd_backlight 2>/dev/null | head -1); [ -n "$d" ] || exit 0
- max=$(cat "$d/max_brightness"); cur=$(cat "$d/brightness"); next=$(( (cur + 1) % (max + 1) ))
- ${pkgs.brightnessctl}/bin/brightnessctl -q -d "$(basename "$d")" set "$next" ;;
- *) echo "usage: nixdaemon-show time|battery|calendar|kbd-backlight-cycle" >&2; exit 2 ;;
- esac
- '')
- # SUPER+K: searchable list of the live binds (what Omarchy's keybindings menu did). Enter copies the key.
- (pkgs.writeShellScriptBin "keybinds-menu" ''
- sel=$(hyprctl binds -j | ${pkgs.python3}/bin/python3 -I -c '
- import json, sys
- M = {1: "SHIFT", 4: "CTRL", 8: "ALT", 64: "SUPER"}
- rows = set()
- for x in json.load(sys.stdin):
- mods = "+".join(n for v, n in sorted(M.items()) if x["modmask"] & v)
- key = x["key"] or ("code:%d" % x["keycode"])
- sub = x.get("submap", "")
- rows.add(("%s%s%s %s" % (sub + ": " if sub else "", mods + " + " if mods else "", key, x.get("description", ""))).rstrip())
- print("\n".join(sorted(rows)))' | ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "Keybindings › " --width 120 --lines 30) || exit 0
- [ -n "$sel" ] && printf '%s' "''${sel%% *}" | ${pkgs.wl-clipboard}/bin/wl-copy
- '')
- # CTRL+SUPER+SPACE: what Omarchy's background switcher did, with Caelestia's
- # own wallpaper grid. The launcher shows it when its search starts with
- # ">wallpaper ", and there is no IPC for that, so the prefix is typed in.
- (pkgs.writeShellScriptBin "wallpaper-picker" ''
- c=${shellCli}
- case "$($c shell drawers isOpen launcher 2>/dev/null)" in
- 1|true) exec $c shell drawers toggle launcher ;;
- esac
- $c shell drawers toggle launcher
- sleep 0.25
- exec ${pkgs.wtype}/bin/wtype '>wallpaper '
- '')
- (pkgs.writeShellScriptBin "nightlight-toggle" ''
- if pgrep -x hyprsunset >/dev/null; then
- pkill -x hyprsunset; ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "off"
- else
- ${pkgs.hyprsunset}/bin/hyprsunset --temperature 4000 >/dev/null 2>&1 &
- ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "on (4000 K)"
- fi
- '')
- ];
-in
-{
- wayland.windowManager.hyprland = {
- enable = true;
- package = hyprPkg; # same derivation NixOS installs; no second copy
- portalPackage = null; # programs.hyprland (NixOS) provides the portal
- configType = "lua";
- systemd.enable = false; # uwsm owns graphical-session.target
- xwayland.enable = true;
-
- extraLuaFiles = {
- omarchy = { content = ../hypr/omarchy.lua; autoLoad = false; };
- core = { content = ../hypr/core.lua; autoLoad = false; };
- looknfeel = { content = ../hypr/looknfeel.lua; autoLoad = false; };
- defaults = { content = ../hypr/defaults.lua; autoLoad = false; };
- bindings = { content = ../hypr/bindings.lua; autoLoad = false; };
- lid = { content = ../hypr/lid.lua; autoLoad = false; };
- caelestia = { content = ../hypr/caelestia.lua; autoLoad = false; };
- };
-
- # Explicit load order: look first, then the stock binds, then the carried files that
- # unbind-and-rebind the keys they take over, Caelestia's keys last.
- extraConfig = ''
- local cfg = (os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config")) .. "/hypr"
- package.path = cfg .. "/?.lua;" .. package.path
- require("omarchy")
- require("core")
- require("looknfeel")
- require("defaults")
- require("bindings")
- require("lid")
- require("caelestia")
- '';
- };
-
- home.packages = helpers ++ (with pkgs; [
- hyprpicker # colour picker (SUPER+PRINT)
- hyprsunset # night light
- fuzzel # dmenu for the cheat sheets and keybinds-menu
- nautilus # the file manager shell.json and the stock binds point at
- brightnessctl
- pamixer
- grim
- slurp
- wl-clipboard
- libnotify
- ]);
-
- # Session services under graphical-session.target (started by uwsm).
- systemd.user.services.hyprpolkitagent = {
- Unit = {
- Description = "Hyprland polkit authentication agent";
- After = [ "graphical-session.target" ];
- PartOf = [ "graphical-session.target" ];
- };
- Service = {
- ExecStart = "${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent";
- Restart = "on-failure";
- Slice = "session.slice";
- };
- Install.WantedBy = [ "graphical-session.target" ];
- };
- services.cliphist.enable = true; # history for `caelestia clipboard`
- services.udiskie = {
- enable = true;
- tray = "auto";
- };
-}
diff --git a/home/modules/media.nix b/home/modules/media.nix
@@ -1,115 +0,0 @@
-# home/modules/media.nix — music and video: mpd + rmpc, and mpv.
-#
-# mpd runs as a user service (starts at login), library ~/Music (the layout
-# contract is ~/Music/AGENTS.md), state in ~/.local/share/mpd, PipeWire
-# output plus the FIFO rmpc's visualiser reads. It listens on the socket
-# $XDG_RUNTIME_DIR/mpd/socket and on 127.0.0.1:6600.
-#
-# rmpc: the dotfiles' full config (home/rmpc/config.ron: tabs with album art,
-# lyrics and cava panes, vim keys, 1-0 tab switching), the Rosé Pine theme
-# (home/rmpc/themes/rose-pine.ron) and the LRCLIB lyrics fetcher that runs on
-# song change. The Discord presence script was not carried (1.4k lines of
-# Python with its own deps; say so if wanted).
-#
-# mpv: the dotfiles' gpu-next/Vulkan profile with the CfL chroma shader, plus
-# uosc (the on-screen UI), thumbfast (seek thumbnails) and mpris (media keys,
-# Caelestia's player widget).
-{ config, pkgs, ... }:
-let
- rt = "/run/user/1000";
-in
-{
- services.mpd = {
- enable = true;
- musicDirectory = "${config.home.homeDirectory}/Music";
- playlistDirectory = "${config.xdg.dataHome}/mpd/playlists";
- network = {
- listenAddress = "${rt}/mpd/socket";
- port = 6600;
- startWhenNeeded = false;
- };
- extraConfig = ''
- bind_to_address "127.0.0.1"
- auto_update "yes"
- restore_paused "yes"
- audio_output {
- type "pipewire"
- name "PipeWire"
- }
- audio_output {
- type "fifo"
- name "Visualizer FIFO"
- path "${rt}/mpd/fifo"
- format "44100:16:2"
- }
- '';
- };
- # the socket's directory, created by systemd before mpd starts
- systemd.user.services.mpd.Service.RuntimeDirectory = "mpd";
-
- programs.rmpc = {
- enable = true;
- config = builtins.readFile ../rmpc/config.ron;
- };
- xdg.configFile = {
- "rmpc/themes/rose-pine.ron".source = ../rmpc/themes/rose-pine.ron;
- "rmpc/scripts/fetch-lyrics" = {
- source = ../rmpc/scripts/fetch-lyrics;
- executable = true;
- };
- "mpv/shaders".source = ../mpv/shaders;
- };
-
- programs.mpv = {
- enable = true;
- scripts = with pkgs.mpvScripts; [ uosc thumbfast mpris ];
- config = {
- vo = "gpu-next";
- gpu-api = "vulkan";
- gpu-context = "waylandvk";
- profile = "high-quality";
- hwdec = "auto";
- scale = "ewa_lanczos4sharpest";
- glsl-shader = "~~/shaders/CfL_Prediction.glsl";
- cscale = "ewa_lanczossharp";
- cscale-antiring = 0.65;
- dscale = "mitchell";
- correct-downscaling = true;
- linear-downscaling = true;
- sigmoid-upscaling = true;
- deband = true;
- deband-iterations = 2;
- deband-threshold = 32;
- deband-range = 12;
- deband-grain = 16;
- dither = "error-diffusion";
- error-diffusion = "burkes";
- dither-depth = 8;
- temporal-dither = false;
- video-sync = "display-resample";
- interpolation = true;
- tscale = "oversample";
- target-colorspace-hint = "auto";
- target-colorspace-hint-mode = "target";
- target-prim = "bt.709";
- target-trc = "srgb";
- gamut-mapping-mode = "perceptual";
- tone-mapping = "auto";
- hdr-compute-peak = true;
- contrast = 4;
- saturation = 5;
- screenshot-format = "png";
- screenshot-high-bit-depth = true;
- screenshot-tag-colorspace = true;
- screenshot-directory = "~/Pictures/mpv";
- osc = false; # uosc replaces it
- border = false;
- save-position-on-quit = true;
- keep-open = true;
- sub-auto = "fuzzy";
- slang = "en,eng";
- alang = "ja,jpn,en,eng";
- ytdl-format = "bestvideo[height<=?1440]+bestaudio/best";
- };
- };
-}
diff --git a/home/modules/neovim.nix b/home/modules/neovim.nix
@@ -1,115 +0,0 @@
-# home/modules/neovim.nix — Neovim through nvf (github:notashelf/nvf): the
-# editor and its plugins are one Nix-built package, no plugin manager, no
-# ~/.config/nvim, nothing downloaded on first start. Replaces the AstroNvim
-# tree the dotfiles carried (2026-10-08): that one pulled ~60 plugins through
-# lazy.nvim and compiled treesitter parsers on the machine.
-#
-# Kept deliberately small. Languages: the ones this machine edits (Nix, Lua
-# for Hyprland, Python and Bash for the toolbox, Markdown for the vault, and
-# the config formats). Each gets treesitter, an LSP and a formatter; format on
-# save is off, `<leader>lf` formats on demand.
-#
-# <leader>ff / fg / fb telescope: files / live grep / buffers
-# - oil: edit the parent directory as a buffer
-# <leader>e oil in a floating window
-# gd gr K <leader>ca LSP: definition, references, hover, code action (nvf defaults)
-# <leader>lf format buffer
-# ]c [c <leader>gp gitsigns: next/previous hunk, preview hunk
-# gcc gc{motion} comment.nvim
-# <Esc> clear search highlight
-# <space> leader
-#
-# kitty's copy mode (home/modules/terminal.nix) starts plain pkgs.neovim with
-# -u, so it is unaffected by this configuration and stays instant.
-{ inputs, pkgs, ... }:
-{
- imports = [ inputs.nvf.homeManagerModules.default ];
-
- programs.nvf = {
- enable = true;
- settings.vim = {
- viAlias = true;
- vimAlias = true;
-
- theme = {
- enable = true;
- name = "rose-pine";
- style = "main"; # main, not moon
- transparent = false;
- };
-
- # Editor behaviour
- lineNumberMode = "relNumber";
- searchCase = "smart";
- preventJunkFiles = true;
- undoFile.enable = true;
- clipboard = {
- enable = true;
- registers = "unnamedplus";
- providers.wl-copy.enable = true;
- };
- options = {
- tabstop = 2;
- shiftwidth = 2;
- softtabstop = 2;
- scrolloff = 6;
- wrap = false;
- signcolumn = "yes";
- cursorline = true;
- splitbelow = true;
- splitright = true;
- updatetime = 250;
- timeoutlen = 400;
- };
-
- # Languages: treesitter + LSP + formatter each, chosen by nvf's defaults
- # (nil for Nix, basedpyright/ruff for Python, lua-language-server,
- # bash-language-server/shfmt, marksman, yaml/json/taplo).
- lsp = {
- enable = true;
- formatOnSave = false;
- inlayHints.enable = false;
- };
- languages = {
- enableTreesitter = true;
- enableFormat = true;
- nix = {
- enable = true;
- format.type = [ "nixfmt" ]; # the style this repo is written in
- };
- lua.enable = true;
- python.enable = true;
- bash.enable = true;
- markdown.enable = true;
- yaml.enable = true;
- json.enable = true;
- toml.enable = true;
- };
-
- autocomplete.blink-cmp.enable = true;
- telescope.enable = true;
- git.gitsigns.enable = true;
- binds.whichKey.enable = true;
- statusline.lualine.enable = true;
- autopairs.nvim-autopairs.enable = true;
- comments.comment-nvim.enable = true;
- utility.oil-nvim.enable = true;
- visuals.nvim-web-devicons.enable = true;
- ui.borders.enable = true;
-
- keymaps = [
- { key = "-"; mode = "n"; action = "<cmd>Oil<CR>"; desc = "Open parent directory"; silent = true; }
- { key = "<leader>e"; mode = "n"; action = "<cmd>Oil --float<CR>"; desc = "Explorer (oil)"; silent = true; }
- { key = "<Esc>"; mode = "n"; action = "<cmd>nohlsearch<CR>"; desc = "Clear search highlight"; silent = true; }
- { key = "<leader>w"; mode = "n"; action = "<cmd>write<CR>"; desc = "Save"; silent = true; }
- { key = "<leader>q"; mode = "n"; action = "<cmd>quit<CR>"; desc = "Quit"; silent = true; }
- { key = "<C-h>"; mode = "n"; action = "<C-w>h"; desc = "Window left"; }
- { key = "<C-j>"; mode = "n"; action = "<C-w>j"; desc = "Window down"; }
- { key = "<C-k>"; mode = "n"; action = "<C-w>k"; desc = "Window up"; }
- { key = "<C-l>"; mode = "n"; action = "<C-w>l"; desc = "Window right"; }
- { key = "<"; mode = "v"; action = "<gv"; desc = "Dedent, keep selection"; }
- { key = ">"; mode = "v"; action = ">gv"; desc = "Indent, keep selection"; }
- ];
- };
- };
-}
diff --git a/home/modules/prompt.nix b/home/modules/prompt.nix
@@ -1,245 +0,0 @@
-# home/modules/prompt.nix — the starship prompt and the fastfetch card, fresh
-# (2026-10-08), Rosé Pine, one colour per section, nothing Omarchy.
-#
-# Prompt (two lines, the dotfiles' "filigree" frame kept, the per-letter
-# gradients gone):
-#
-# ╭╌ ☧ daemonsec@nixos ┄ ~/NixDaemon ┄ main [+2 !1] ⌁⡇⡆· (right: 3s · 14:02)
-# ╰╌ ❯
-#
-# glyph iris · user rose · host foam · directory gold · git love (status subtle,
-# week heartbeat iris) · languages text · duration/jobs gold · clock rose ·
-# prompt char foam (love after an error). pine is never ink (3.3:1 on base).
-# $CROSS_GLYPH comes from the dotfiles' animations.zsh (☧ + the NixOS glyph).
-#
-# theme.zsh in the dotfiles runs `starship init zsh` and adds the transient
-# prompt, so home-manager's own shell integration stays off here.
-#
-# fastfetch: the builtin NixOS logo in iris/foam, keys in rotating Rosé Pine
-# colours, the modules that matter on this laptop. The login splash
-# (animations.zsh) runs plain `fastfetch` when CROSS_FETCH=plain, which
-# .dotfiles/.zshrc now sets, so this config draws the whole card.
-{ lib, ... }:
-let
- # Rosé Pine (main)
- rp = {
- love = "#eb6f92";
- gold = "#f6c177";
- rose = "#ebbcba";
- pine = "#31748f";
- foam = "#9ccfd8";
- iris = "#c4a7e7";
- text = "#e0def4";
- subtle = "#908caa";
- muted = "#6e6a86";
- };
- # the same colours as SGR parameters for fastfetch
- sgr = {
- love = "38;2;235;111;146";
- gold = "38;2;246;193;119";
- rose = "38;2;235;188;186";
- foam = "38;2;156;207;216";
- iris = "38;2;196;167;231";
- text = "38;2;224;222;244";
- subtle = "38;2;144;140;170";
- muted = "38;2;110;106;134";
- };
- lang = symbol: colour: {
- inherit symbol;
- format = " [$symbol($version)](fg:${colour})";
- };
-in
-{
- programs.starship = {
- enable = true;
- enableZshIntegration = false; # theme.zsh does it (with the transient prompt)
- enableBashIntegration = false;
- settings = {
- "$schema" = "https://starship.rs/config-schema.json";
- add_newline = true;
- palette = "rose_pine";
- palettes.rose_pine = rp;
-
- format = lib.concatStrings [
- "[╭╌](fg:muted) "
- "\${env_var.CROSS_GLYPH}"
- "$username"
- "$hostname"
- "$shlvl"
- "$sudo"
- "\${custom.root}"
- "$directory"
- "$git_branch"
- "$git_status"
- "\${custom.gitweek}"
- "$git_state"
- "$python"
- "$nodejs"
- "$rust"
- "$golang"
- "$lua"
- "$docker_context"
- "$package"
- "$line_break"
- "[╰╌](fg:muted) "
- "$status"
- "$character"
- ];
- right_format = lib.concatStrings [ "$cmd_duration" "$jobs" "$battery" "$time" ];
-
- # identity
- env_var.CROSS_GLYPH = {
- variable = "CROSS_GLYPH";
- default = "☧";
- format = "[$env_value](bold fg:iris) ";
- };
- username = {
- show_always = true;
- format = "[$user](bold fg:rose)";
- style_user = "bold fg:rose";
- style_root = "bold fg:love";
- };
- hostname = {
- ssh_only = false;
- format = "[@](fg:muted)[$hostname](bold fg:foam)";
- };
- shlvl = {
- disabled = false;
- threshold = 2;
- format = " [↕$shlvl](bold fg:gold)";
- };
- sudo = {
- disabled = false;
- format = " [](bold fg:love)";
- };
- custom.root = {
- command = "echo ROOT";
- when = "[ \"$(id -u)\" -eq 0 ]";
- format = " [ $output](bold underline fg:love)";
- };
-
- # place
- directory = {
- format = " [┄](fg:muted) [ $path](bold fg:gold)[$read_only](fg:love)";
- truncation_length = 4;
- truncate_to_repo = true;
- truncation_symbol = "…/";
- read_only = " ";
- };
-
- # git
- git_branch = {
- symbol = " ";
- format = " [┄](fg:muted) [$symbol$branch(:$remote_branch)](bold fg:love)";
- };
- git_status = {
- format = "( [\\[$all_status$ahead_behind\\]](fg:subtle))";
- ahead = "⇡\${count}";
- behind = "⇣\${count}";
- diverged = "⇕⇡\${ahead_count}⇣\${behind_count}";
- conflicted = "=";
- untracked = "?";
- stashed = "≡";
- modified = "!";
- staged = "+";
- renamed = "»";
- deleted = "✘";
- };
- # the last 7 days of commits as a braille pulse (carried from the dotfiles)
- custom.gitweek = {
- command = ''git log --since=7.days --date=format:%Y%m%d --pretty=%cd 2>/dev/null | sort | uniq -c | awk 'BEGIN{split("· ⡀ ⡄ ⡆ ⡇",b," ")}{c[$2]=$1}END{for(i=6;i>=0;i--){d=strftime("%Y%m%d",systime()-i*86400);v=c[d]+0;idx=(v==0)?1:(v<3)?2:(v<6)?3:(v<10)?4:5;printf "%s",b[idx]}}' '';
- when = "git rev-parse --is-inside-work-tree 2>/dev/null | grep -q true";
- format = " [⌁$output](fg:iris)";
- };
- git_state = {
- format = " [\\($state $progress_current/$progress_total\\)](bold fg:love)";
- };
-
- # toolchains: only when the directory uses them
- python = (lang " " "text") // { format = " [$symbol$version( \\($virtualenv\\))](fg:text)"; };
- nodejs = lang " " "text";
- rust = lang " " "text";
- golang = lang " " "text";
- lua = lang " " "text";
- docker_context = { symbol = " "; format = " [$symbol$context](fg:subtle)"; };
- package = { symbol = " "; format = " [$symbol$version](fg:subtle)"; };
-
- # right side
- cmd_duration = { min_time = 2000; format = "[ $duration](fg:gold) "; };
- jobs = { symbol = " "; format = "[$symbol$number](bold fg:gold) "; };
- battery = {
- full_symbol = " ";
- charging_symbol = " ";
- discharging_symbol = " ";
- unknown_symbol = " ";
- empty_symbol = " ";
- format = "[$symbol$percentage]($style) ";
- display = [
- { threshold = 20; style = "bold fg:love"; }
- { threshold = 50; style = "fg:gold"; }
- ];
- };
- time = {
- disabled = false;
- time_format = "%H:%M";
- format = "[ $time](fg:rose)";
- };
-
- # second line
- status = {
- disabled = false;
- symbol = "✗ ";
- format = "[$symbol$status](fg:love) ";
- };
- character = {
- success_symbol = "[❯](bold fg:foam)";
- error_symbol = "[❯](bold fg:love)";
- vimcmd_symbol = "[❮](bold fg:gold)";
- vimcmd_replace_one_symbol = "[❮](bold fg:rose)";
- vimcmd_replace_symbol = "[❮](bold fg:iris)";
- vimcmd_visual_symbol = "[❮](bold fg:gold)";
- };
- line_break.disabled = false;
- };
- };
-
- programs.fastfetch = {
- enable = true;
- settings = {
- "$schema" = "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json";
- logo = {
- type = "builtin";
- source = "nixos";
- color = { "1" = sgr.iris; "2" = sgr.foam; };
- padding = { top = 1; left = 2; right = 5; };
- };
- display = {
- separator = " ";
- color = { keys = sgr.foam; title = sgr.rose; };
- };
- modules = [
- { type = "title"; color = { user = sgr.rose; at = sgr.muted; host = sgr.foam; }; }
- { type = "separator"; string = "┄"; length = 34; outputColor = sgr.muted; }
- { type = "os"; key = " os"; keyColor = sgr.iris; }
- { type = "kernel"; key = " kernel"; keyColor = sgr.foam; }
- { type = "uptime"; key = " uptime"; keyColor = sgr.gold; }
- { type = "packages"; key = " packages"; keyColor = sgr.rose; }
- { type = "shell"; key = " shell"; keyColor = sgr.love; }
- "break"
- { type = "wm"; key = " wm"; keyColor = sgr.iris; }
- { type = "display"; key = " display"; keyColor = sgr.foam; compactType = "original-with-refresh-rate"; }
- { type = "terminal"; key = " terminal"; keyColor = sgr.gold; }
- { type = "terminalfont"; key = " font"; keyColor = sgr.rose; }
- "break"
- { type = "host"; key = " host"; keyColor = sgr.love; }
- { type = "cpu"; key = " cpu"; keyColor = sgr.iris; showPeCoreCount = true; }
- { type = "gpu"; key = " gpu"; keyColor = sgr.foam; detectionMethod = "pci"; format = "{name}"; }
- { type = "memory"; key = " memory"; keyColor = sgr.gold; }
- { type = "disk"; key = " disk"; keyColor = sgr.rose; folders = "/"; }
- { type = "battery"; key = " battery"; keyColor = sgr.love; }
- "break"
- { type = "colors"; symbol = "circle"; paddingLeft = 2; }
- ];
- };
- };
-}
diff --git a/home/modules/shell.nix b/home/modules/shell.nix
@@ -1,115 +0,0 @@
-# home/modules/shell.nix — zsh as the shell, configured by the dotfiles.
-#
-# The dotfiles checkout (~/git/daemon-sec-dotfiles) carries a complete zsh
-# setup in home/.dotfiles: a ZDOTDIR tree with core.zsh (history, cached
-# compinit, the plugin loader, zoxide/atuin/fzf hooks), the leaf modules
-# (aliases, fzf verbs, ripgrep, cheats, music, jj, …), theme.zsh (starship
-# with a transient prompt) and animations.zsh (the login splash). The plugins
-# it loads — zsh-autosuggestions, zsh-syntax-highlighting, zsh-completions,
-# fzf-tab, history-substring-search, you-should-use — are vendored in
-# .dotfiles/config/plugins, so the config is used as-is rather than rewritten
-# as home-manager options. This module only supplies what the Omarchy install
-# had and NixOS does not:
-#
-# ~/.zshenv sets ZDOTDIR (home-manager owns this one file)
-# ~/.dotfiles → the checkout's .dotfiles (edits follow the repo)
-# ~/.fzf.zsh fzf's key bindings from the Nix store (core.zsh looks
-# in /usr/share/fzf, which does not exist here)
-# ~/.zsh/completions generated completions for tools without shipped ones
-# tmux plugins from nixpkgs (~/.tmux.conf expects TPM; a shim loads them)
-# ~/.config/secretspec the keyring provider
-#
-# The dotfiles themselves (~/.dotfiles, ~/.tmux.conf, the tool configs under
-# ~/.config: starship, bat theme, atuin, fastfetch, cheats, …) are linked by
-# home/modules/dotfiles.nix. NixOS side (hosts/laptop/default.nix): programs.zsh with the global compinit
-# and prompt off (core.zsh and theme.zsh do those), users.<user>.shell.
-{ config, pkgs, lib, ... }:
-let
- # Completions for tools that do not ship their own under share/zsh.
- # (uv, jj, gh, atuin, zoxide, eza, bat, fd, rg do; /etc/zshrc puts the
- # profiles' site-functions on fpath before core.zsh runs compinit.)
- generatedCompletions = pkgs.runCommand "nixdaemon-zsh-completions" { } ''
- mkdir -p $out
- export HOME=$TMPDIR
- ${pkgs.secretspec}/bin/secretspec completions zsh > $out/_secretspec
- '';
-
- # ~/.tmux.conf ends with `run '~/.tmux/plugins/tpm/tpm'` and lists plugins
- # for TPM to clone. nixpkgs has the plugins but not TPM, so each plugin is
- # linked where TPM would have put it and this stand-in sources them.
- tpmShim = ''
- #!${pkgs.bash}/bin/bash
- # Stand-in for tmux-plugin-manager (NixDaemon home/modules/shell.nix): the
- # plugins come from nixpkgs and are linked into ~/.tmux/plugins; this runs
- # each plugin's entry script the way TPM would. prefix+I/U do nothing here;
- # add plugins in shell.nix instead.
- for f in "$HOME"/.tmux/plugins/*/*.tmux; do
- case "$f" in */tpm/*) continue ;; esac
- [ -x "$f" ] && "$f"
- done
- exit 0
- '';
- tmuxPlugin = name: pkg: {
- name = ".tmux/plugins/${name}";
- value.source = "${pkg}/share/tmux-plugins/${pkg.pluginName}";
- };
-in
-{
- home.packages = with pkgs; [
- zsh
- tmux
- secretspec
- ];
-
- home.file = {
- # zsh: hand over to the dotfiles' ZDOTDIR tree.
- ".zshenv".text = ''
- # Managed by home-manager (NixDaemon home/modules/shell.nix). The shell
- # configuration lives in ~/.dotfiles (→ ~/git/daemon-sec-dotfiles).
- export ZDOTDIR="$HOME/.dotfiles"
- [[ -r "$HOME/.cargo/env" ]] && . "$HOME/.cargo/env"
- '';
- ".fzf.zsh".text = ''
- # fzf key bindings (Ctrl-T, Alt-C, Ctrl-R) and completion from the Nix
- # store; sourced by ~/.dotfiles/config/core.zsh. Quiet when there is no
- # tty (the scripts restore `zle`, which fails outside a terminal).
- if [[ -t 0 ]]; then
- source ${pkgs.fzf}/share/fzf/key-bindings.zsh
- source ${pkgs.fzf}/share/fzf/completion.zsh
- else
- { source ${pkgs.fzf}/share/fzf/key-bindings.zsh; source ${pkgs.fzf}/share/fzf/completion.zsh; } 2>/dev/null
- fi
- '';
- ".zsh/completions".source = generatedCompletions;
-
- ".tmux/plugins/tpm/tpm" = {
- text = tpmShim;
- executable = true;
- };
- }
- // builtins.listToAttrs [
- (tmuxPlugin "tmux-resurrect" pkgs.tmuxPlugins.resurrect)
- (tmuxPlugin "tmux-continuum" pkgs.tmuxPlugins.continuum)
- (tmuxPlugin "tmux-yank" pkgs.tmuxPlugins.yank)
- (tmuxPlugin "tmux-open" pkgs.tmuxPlugins.open)
- ];
-
- xdg.configFile = {
- # secretspec (https://secretspec.dev): secrets in the system keyring, which
- # gnome-keyring provides and PAM unlocks at login. Per-project
- # secretspec.toml files declare what a project needs; `secretspec check`
- # prompts for anything missing, `secretspec run -- cmd` injects them.
- "secretspec/config.toml".text = ''
- [defaults]
- provider = "keyring"
- profile = "default"
- '';
- };
-
- # bat only sees a theme after its cache is rebuilt from ~/.config/bat/themes.
- home.activation.batCache = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
- if [ -d "$HOME/.config/bat/themes/" ]; then
- run ${pkgs.bat}/bin/bat cache --build >/dev/null 2>&1 || true
- fi
- '';
-}
diff --git a/home/modules/sops.nix b/home/modules/sops.nix
@@ -1,43 +0,0 @@
-# home/modules/sops.nix — sops-nix for the user: the same encrypted file,
-# decrypted into $XDG_RUNTIME_DIR/secrets.d (symlinked at ~/.config/sops-nix/secrets)
-# by a user service at login, readable only by daemonsec.
-#
-# Use this for secrets that belong to the user's programs (API tokens an app
-# reads from a file, an rclone config, …); use hosts/laptop/sops.nix for
-# anything a system service needs.
-#
-# sops.secrets.example = { }; # → ~/.config/sops-nix/secrets/example
-# sops.secrets.rclone = { path = "${config.xdg.configHome}/rclone/rclone.conf"; };
-#
-# secretspec (home/modules/shell.nix) is the complement: per-project runtime
-# secrets pulled from the keyring at `secretspec run`, declared next to the
-# project in secretspec.toml, not in this repo.
-{ config, inputs, pkgs, lib, ... }:
-{
- imports = [ inputs.sops-nix.homeManagerModules.sops ];
-
- sops = {
- defaultSopsFile = ../../secrets/secrets.yaml;
- age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
- age.sshKeyPaths = [ ];
- gnupg.sshKeyPaths = [ ];
- };
-
- home.packages = with pkgs; [
- sops
- age
- ssh-to-age # turn an ssh-ed25519 key into an age recipient when adding another machine
- ];
-
- # sops-nix's activation step restarts the sops-nix user unit. That unit is a
- # home-manager file (~/.config/systemd/user/sops-nix.service), linked by the
- # linkGeneration step, and the user systemd manager only sees new unit
- # files after a daemon-reload, which home-manager runs at the very end. With
- # the extra steps this config adds, the DAG happened to order sops-nix
- # before linkGeneration, so the first switch died with "Unit
- # sops-nix.service not found". This entry pins the order: linkGeneration →
- # daemon-reload → sops-nix.
- home.activation.reloadUserUnitsForSops = lib.hm.dag.entryBetween [ "sops-nix" ] [ "linkGeneration" "installPackages" ] ''
- ${pkgs.systemd}/bin/systemctl --user daemon-reload 2>/dev/null || true
- '';
-}
diff --git a/home/modules/ssh.nix b/home/modules/ssh.nix
@@ -1,46 +0,0 @@
-# home/modules/ssh.nix — the SSH key and client config, from the flake.
-#
-# The private key is a sops secret (secrets/secrets.yaml: ssh_id_ed25519;
-# `nix-cheat secrets`). At login sops-nix decrypts it with the age key into
-# the runtime secrets dir and ~/.config/sops-nix/secrets/ssh_id_ed25519 points
-# there; ~/.ssh/config names that path, so a fresh machine has a working key
-# as soon as ~/.config/sops/age/keys.txt is restored. The public half is
-# plain text in ~/.ssh/id_ed25519.pub (comment daemonsec@nixos; registered on
-# gitlab.com as "nixos", auth and signing, and glab's git_protocol is ssh).
-#
-# A plain copy from before this module may still sit at ~/.ssh/id_ed25519;
-# nothing reads it any more.
-{ config, ... }:
-let
- key = config.sops.secrets.ssh_id_ed25519.path;
-in
-{
- sops.secrets.ssh_id_ed25519 = { }; # → %r/secrets.d/…, mode 0400 (ssh is happy with that)
-
- home.file.".ssh/id_ed25519.pub".text = ''
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAsXOt8jkVBgL2ANFgkftVfRlswxBvBUM33Tv/bS+I5Z daemonsec@nixos
- '';
-
- programs.ssh = {
- enable = true;
- enableDefaultConfig = false;
- settings = {
- "gitlab.com" = {
- User = "git";
- IdentityFile = key;
- IdentitiesOnly = "yes";
- };
- "github.com" = {
- User = "git";
- IdentityFile = key;
- IdentitiesOnly = "yes";
- };
- "*" = {
- IdentityFile = key;
- AddKeysToAgent = "yes";
- ServerAliveInterval = 30;
- HashKnownHosts = "no";
- };
- };
- };
-}
diff --git a/home/modules/terminal.nix b/home/modules/terminal.nix
@@ -1,159 +0,0 @@
-# home/modules/terminal.nix — kitty with DMMono Nerd Font and Rosé Pine (main).
-#
-# The six patched DMMono TTFs come from the dotfiles checkout (tools.nix links
-# ~/git/daemon-sec-dotfiles/assets/fonts/nerd-fonts-dm-mono into
-# ~/.local/share/fonts). The family has no Bold, so bold maps to Medium.
-#
-# Palette rule from the migration prompt: pine #31748f is a fill, never ink,
-# so it must not sit in an ANSI foreground slot. The Rosé Pine terminal theme
-# puts pine in the green slot; the substitute is PARKED for the owner's
-# decision. Until then `ansiGreen` below carries foam, the colour the toolbox
-# itself uses wherever pine would have been read as text (bin/install.sh).
-#
-# tmux-style keys (2026-10-08): ctrl+a is a prefix, like tmux's, with tabs as
-# tmux windows and kitty windows as tmux panes:
-#
-# ctrl+a c new tab (cwd kept) ctrl+a - split below (pane)
-# ctrl+a n / p next / previous tab ctrl+a | split right
-# ctrl+a 1..9 tab N ctrl+a h j k l focus pane left/down/up/right
-# ctrl+a , rename tab ctrl+a H J K L move pane
-# ctrl+a & close tab ctrl+a o next pane
-# ctrl+a x close pane ctrl+a z zoom pane (stack layout toggle)
-# ctrl+a [ copy mode (scrollback in Neovim: v y, Enter, q)
-# ctrl+a ] paste clipboard ctrl+a space next layout
-# ctrl+a { } swap pane back / forth ctrl+a r reload kitty.conf
-# ctrl+a u pick a URL (hints) ctrl+a f pick a path (hints)
-# ctrl+a ctrl+a send a real ctrl+a to the shell (beginning-of-line)
-# ctrl+a shift+arrows resize pane ctrl+a = reset pane sizes
-#
-# Copy mode is home/kitty/scrollback.lua: the scrollback opens in a bare
-# Neovim (no AstroNvim config) with colours, vi motions and search; y copies
-# to the clipboard, Enter copies and leaves, q or Esc leaves.
-{ pkgs, lib, ... }:
-let
- ansiGreen = "#9ccfd8"; # PARKED: ask before changing; see header
-
- # kitty substitutes INPUT_LINE_NUMBER, CURSOR_LINE and CURSOR_COLUMN in the
- # pager command; the Lua reads them from vim.g.
- scrollbackPager = lib.concatStringsSep " " [
- "${pkgs.bash}/bin/bash -c"
- "'exec ${pkgs.neovim}/bin/nvim 63<&0 0</dev/null"
- "-u ${../kitty/scrollback.lua}"
- "-c \"let g:kitty_input_line=INPUT_LINE_NUMBER\""
- "-c \"let g:kitty_cursor_line=CURSOR_LINE\""
- "-c \"let g:kitty_cursor_col=CURSOR_COLUMN\"'"
- ];
-
- prefix = "ctrl+a";
- tabKeys = lib.listToAttrs (map (n: {
- name = "${prefix}>${toString n}";
- value = "goto_tab ${toString n}";
- }) (lib.range 1 9));
-in
-{
- fonts.fontconfig.enable = true;
-
- programs.kitty = {
- enable = true;
- font = {
- name = "DMMono Nerd Font";
- size = 10;
- };
- settings = {
- bold_font = ''family="DMMono Nerd Font" style="Medium"'';
- italic_font = ''family="DMMono Nerd Font" style="Italic"'';
- bold_italic_font = ''family="DMMono Nerd Font" style="Medium Italic"'';
-
- # Rosé Pine, main (dark)
- foreground = "#e0def4";
- background = "#191724";
- selection_foreground = "#e0def4";
- selection_background = "#403d52"; # highlight med
- cursor = "#524f67"; # highlight high
- cursor_text_color = "#e0def4";
- url_color = "#c4a7e7";
-
- active_border_color = "#eb6f92";
- inactive_border_color = "#6e6a86";
- active_tab_foreground = "#e0def4";
- active_tab_background = "#26233a";
- inactive_tab_foreground = "#6e6a86";
- inactive_tab_background = "#191724";
-
- color0 = "#26233a";
- color8 = "#6e6a86";
- color1 = "#eb6f92";
- color9 = "#eb6f92";
- color2 = ansiGreen;
- color10 = ansiGreen;
- color3 = "#f6c177";
- color11 = "#f6c177";
- color4 = "#9ccfd8";
- color12 = "#9ccfd8";
- color5 = "#c4a7e7";
- color13 = "#c4a7e7";
- color6 = "#ebbcba";
- color14 = "#ebbcba";
- color7 = "#e0def4";
- color15 = "#e0def4";
-
- # tmux-like layout: panes via the splits layout, stack = zoom, tabs on a
- # bottom status line with their index like tmux's window list.
- enabled_layouts = "splits,stack";
- window_border_width = "1pt";
- inactive_text_alpha = "0.8";
- tab_bar_edge = "bottom";
- tab_bar_style = "powerline";
- tab_powerline_style = "slanted";
- tab_title_template = "{index}:{title}";
- active_tab_title_template = "{index}:{title}";
- scrollback_lines = 20000;
- scrollback_pager = scrollbackPager;
- shell_integration = "enabled";
- # Always zsh, whatever $SHELL the session was started with (a session
- # begun before the login shell changed still carries SHELL=bash).
- shell = "${pkgs.zsh}/bin/zsh";
- };
-
- keybindings = {
- # tabs = tmux windows
- "${prefix}>c" = "new_tab_with_cwd";
- "${prefix}>n" = "next_tab";
- "${prefix}>p" = "previous_tab";
- "${prefix}>," = "set_tab_title";
- "${prefix}>&" = "close_tab";
- "${prefix}>w" = "select_tab";
- # panes = kitty windows
- "${prefix}>-" = "launch --location=hsplit --cwd=current";
- "${prefix}>|" = "launch --location=vsplit --cwd=current";
- "${prefix}>x" = "close_window";
- "${prefix}>o" = "next_window";
- "${prefix}>z" = "toggle_layout stack";
- "${prefix}>space" = "next_layout";
- "${prefix}>h" = "neighboring_window left";
- "${prefix}>j" = "neighboring_window down";
- "${prefix}>k" = "neighboring_window up";
- "${prefix}>l" = "neighboring_window right";
- "${prefix}>shift+h" = "move_window left";
- "${prefix}>shift+j" = "move_window down";
- "${prefix}>shift+k" = "move_window up";
- "${prefix}>shift+l" = "move_window right";
- "${prefix}>{" = "move_window_backward";
- "${prefix}>}" = "move_window_forward";
- "${prefix}>shift+left" = "resize_window narrower 3";
- "${prefix}>shift+right" = "resize_window wider 3";
- "${prefix}>shift+up" = "resize_window taller 3";
- "${prefix}>shift+down" = "resize_window shorter 3";
- "${prefix}>=" = "resize_window reset";
- # copy mode and paste
- "${prefix}>[" = "show_scrollback";
- "${prefix}>]" = "paste_from_clipboard";
- "${prefix}>u" = "open_url_with_hints";
- "${prefix}>f" = "kitten hints --type path --program -";
- # misc
- "${prefix}>r" = "load_config_file";
- "${prefix}>?" = "kitten show_key -m kitty";
- "${prefix}>${prefix}" = "send_text all \\x01";
- } // tabKeys;
- };
-}
diff --git a/home/modules/tools.nix b/home/modules/tools.nix
@@ -1,116 +0,0 @@
-# home/modules/tools.nix — runtime closure for the hand-written toolbox in
-# ~/.local/bin, plus the general command-line tools.
-#
-# ~/.local/bin itself is not managed here on purpose: it is a flat git repo
-# (vault README: "git init there afterwards so editing a file edits the live
-# command"). NixOS puts it first on PATH (hosts/laptop/toolbox.nix).
-{ config, pkgs, lib, ... }:
-let
- # ns [query]: fuzzy search of nixpkgs (+ NixOS and home-manager options) in
- # the terminal, with the package description as the preview. nix-search-tv
- # indexes search.nixos.org data locally on first run and refreshes it itself.
- # Enter print the attribute name (e.g. to paste into tools.nix)
- # ctrl-o open the homepage ctrl-s open the nixpkgs source
- # ctrl-y copy the attribute name
- ns = pkgs.writeShellScriptBin "ns" ''
- nst=${pkgs.nix-search-tv}/bin/nix-search-tv
- exec $nst print | ${pkgs.fzf}/bin/fzf \
- --query="$*" --scheme=history --prompt='nix › ' \
- --preview="$nst preview {}" --preview-window='right,60%,border-left,wrap' \
- --bind="ctrl-o:execute-silent(${pkgs.xdg-utils}/bin/xdg-open \$($nst homepage {}))" \
- --bind="ctrl-s:execute-silent(${pkgs.xdg-utils}/bin/xdg-open \$($nst source {}))" \
- --bind="ctrl-y:execute-silent(printf %s {} | ${pkgs.wl-clipboard}/bin/wl-copy)" \
- --header='enter: print · ctrl-y: copy · ctrl-o: homepage · ctrl-s: source'
- '';
-
- pythonEnv = pkgs.python3.withPackages (ps: with ps; [
- cryptography
- argon2-cffi
- rich
- questionary
- pikepdf
- mutagen
- pillow
- numpy
- pyqt6
- youtube-transcript-api
- ]);
-in
-{
- home.packages = with pkgs; [
- pythonEnv
- ns
- nix-search-tv # `ns`, and `nix-search-tv print|preview` by hand
- perl
- git
- jujutsu
- gnutar
- zstd
- pigz
- xz
- rsync
- rclone
- aria2
- p7zip
- libarchive
- gnupg
- openssl
- pinentry-gnome3
- ffmpeg
- yt-dlp
- atomicparsley
- gallery-dl
- imagemagick
- img2pdf
- resvg
- zathura
- calibre
- poppler-utils
- starship
- bat
- eza
- fd
- ripgrep
- fzf
- zoxide
- atuin
- jq
- curl
- wget
- gh
- fastfetch
- wl-clipboard
- libnotify
-
- # General tools (2026-10-08): what the dotfiles' zsh modules look for
- # (modern.zsh, core.zsh) and what the stock Omarchy keys expect.
- uv # PEP-723 scripts in ~/.local/bin (dcx, dorkforge); managed Pythons work via nix-ld
- nodejs
- btop
- lazygit
- lazydocker
- tealdeer # `tldr`
- dust
- duf
- procs
- difftastic
- hyperfine
- glow
- onefetch
- tokei
- xh
- ncdu
- parallel
- unzip
- zip
- tree
- file
- cbonsai
- cmatrix
- localsend # phone ↔ laptop file drops on the LAN; port 53317 is open in hosts/laptop/default.nix
- vesktop # Discord client; its config is linked from the dotfiles (dotfiles.nix)
- ];
-
- # Browser
- programs.floorp.enable = true;
-}
diff --git a/home/modules/yazi.nix b/home/modules/yazi.nix
@@ -1,130 +0,0 @@
-# home/modules/yazi.nix — yazi, the terminal file manager, with previews.
-#
-# Pictures preview inline in kitty (its graphics protocol; yazi draws them
-# itself, nothing else needed), video frames via ffmpeg, PDFs via poppler,
-# SVG via resvg, archives via 7z, JSON via jq, code with syntax colours, and
-# the rest as text. Enter / l on a file opens it: images in imv, video and
-# audio in mpv, PDFs and books in zathura, text in $EDITOR, anything else via
-# xdg-open; o shows every opener. The toolbox's zimg/zbook/comx/gamex/dux
-# are offered where they apply (they live in ~/.local/bin).
-#
-# y start yazi and cd to where you left it (zsh wrapper)
-# T maximise the preview pane (toggle-pane) ! shell here
-# <C-e>/<C-y> scroll the preview . toggle hidden
-# l / Enter smart-enter: open a file, enter a directory
-# Theme: Rosé Pine (home/yazi/flavors). Git status marks via the git plugin.
-{ pkgs, ... }:
-{
- programs.yazi = {
- enable = true;
- enableZshIntegration = true;
- shellWrapperName = "y";
- extraPackages = with pkgs; [
- ffmpeg
- poppler-utils
- imagemagick
- resvg
- p7zip
- jq
- fd
- ripgrep
- fzf
- zoxide
- file
- mediainfo
- imv
- ];
-
- settings = {
- mgr = {
- show_hidden = false;
- show_symlink = true;
- sort_by = "natural";
- sort_sensitive = false;
- sort_dir_first = true;
- linemode = "size";
- scrolloff = 5;
- };
- preview = {
- max_width = 1600;
- max_height = 1600;
- image_delay = 20;
- image_filter = "lanczos3";
- image_quality = 90;
- wrap = "yes";
- };
- opener = {
- edit = [ { run = ''${EDITOR:-nvim} "$@"''; desc = "Edit"; block = true; for = "unix"; } ];
- open = [ { run = ''xdg-open "$1"''; desc = "Open (system default)"; orphan = true; for = "unix"; } ];
- view-image = [
- { run = ''imv "$@"''; desc = "View (imv)"; orphan = true; for = "unix"; }
- { run = ''zimg "$1"''; desc = "View in zathura (zimg)"; orphan = true; for = "unix"; }
- { run = ''xdg-open "$1"''; desc = "Open (system default)"; orphan = true; for = "unix"; }
- ];
- play = [
- { run = ''mpv "$@"''; desc = "Play (mpv)"; orphan = true; for = "unix"; }
- { run = ''mpv --no-video "$@"''; desc = "Play audio only (mpv)"; block = true; for = "unix"; }
- ];
- open-pdf = [
- { run = ''zathura "$@"''; desc = "Open (zathura)"; orphan = true; for = "unix"; }
- { run = ''xdg-open "$1"''; desc = "Open (system default)"; orphan = true; for = "unix"; }
- ];
- open-book = [
- { run = ''zbook "$1"''; desc = "Read in zathura (zbook)"; orphan = true; for = "unix"; }
- { run = ''zathura "$@"''; desc = "Open (zathura)"; orphan = true; for = "unix"; }
- ];
- open-comic = [
- { run = ''comx "$1"''; desc = "Guided view (comx)"; orphan = true; for = "unix"; }
- { run = ''zbook "$1"''; desc = "Panels in zathura (zbook)"; orphan = true; for = "unix"; }
- { run = ''7z x -y "$1"''; desc = "Extract here (7z)"; block = true; for = "unix"; }
- ];
- extract = [ { run = ''7z x -y "$1"''; desc = "Extract here (7z)"; for = "unix"; } ];
- run-exe = [
- { run = ''gamex run "$1"''; desc = "Run (gamex, dGPU)"; block = true; for = "unix"; }
- { run = ''gamex proton "$1"''; desc = "Run with Proton-GE"; block = true; for = "unix"; }
- ];
- };
- open = {
- rules = [
- { mime = "image/gif"; use = [ "view-image" "open" ]; }
- { mime = "image/*"; use = [ "view-image" "open" ]; }
- { mime = "video/*"; use = [ "play" "open" ]; }
- { mime = "audio/*"; use = [ "play" "open" ]; }
- { mime = "application/pdf"; use = [ "open-pdf" "open" ]; }
- { mime = "application/epub+zip"; use = [ "open-book" "open" ]; }
- { name = "*.cbz"; use = [ "open-comic" "extract" ]; }
- { name = "*.cbr"; use = [ "open-comic" "extract" ]; }
- { name = "*.exe"; use = [ "run-exe" "open" ]; }
- { mime = "application/{zip,gzip,x-tar,x-bzip*,x-7z-compressed,x-rar,x-xz,zstd}"; use = [ "extract" "open" ]; }
- { mime = "inode/directory"; use = [ "edit" "open" ]; }
- { mime = "text/*"; use = [ "edit" "open" ]; }
- { mime = "application/{json,toml,x-ndjson,javascript,x-sh,x-shellscript,xml}"; use = [ "edit" "open" ]; }
- { mime = "*"; use = [ "open" "edit" ]; }
- ];
- };
- };
-
- keymap.mgr.prepend_keymap = [
- { on = [ "!" ]; run = ''shell "$SHELL" --block''; desc = "Open a shell here"; }
- { on = [ "<C-e>" ]; run = "seek 5"; desc = "Scroll preview down"; }
- { on = [ "<C-y>" ]; run = "seek -5"; desc = "Scroll preview up"; }
- { on = [ "l" ]; run = "plugin smart-enter"; desc = "Enter directory or open file"; }
- { on = [ "<Enter>" ]; run = "plugin smart-enter"; desc = "Enter directory or open file"; }
- { on = [ "T" ]; run = "plugin toggle-pane max-preview"; desc = "Maximise the preview"; }
- { on = [ "u" "d" ]; run = "shell -- dux %h %s"; desc = "Copy file(s) to the clipboard as a paste-able file"; }
- ];
-
- plugins = with pkgs.yaziPlugins; {
- inherit full-border git smart-enter toggle-pane;
- };
- flavors.rose-pine = ../yazi/flavors/rose-pine.yazi;
- theme.flavor = {
- dark = "rose-pine";
- light = "rose-pine";
- };
- initLua = ''
- require("full-border"):setup()
- require("git"):setup()
- '';
- };
-}
diff --git a/hosts/bootstrap/default.nix b/hosts/bootstrap/default.nix
@@ -1,89 +0,0 @@
-# hosts/bootstrap/default.nix
-#
-# Stage A. The GNOME install exactly as the installer left it (this is the old
-# configuration.nix, reorganised) plus:
-# - the dead-GPU-fan workaround and its sensor driver,
-# - the toolbox prerequisites (envfs, ~/.local/bin on PATH, padx udev rule,
-# a python3 with the tools' modules, jq, fzf),
-# - the Hyprland binary cache, so the `nixos` configuration downloads
-# instead of compiling.
-#
-# Built from nixpkgs-stable (the revision this machine runs), so the switch is
-# small and GNOME stays untouched. Apply, reboot (new kernel modules only load
-# from the booted system), verify with `fanfix status`, then move on to
-# `nixos`. Delete this directory and the nixpkgs-stable input afterwards.
-{ config, pkgs, lib, user, ... }:
-{
- imports = [
- ../laptop/hardware-configuration.nix
- ../laptop/fan-throttle-guard.nix
- ../laptop/fan-extras.nix
- ../laptop/uniwill-laptop.nix
- ../laptop/nix-settings.nix
- ../laptop/toolbox.nix
- ];
-
- boot.loader.systemd-boot.enable = true;
- boot.loader.efi.canTouchEfiVariables = true;
-
- networking.hostName = "nixos";
- networking.networkmanager.enable = true;
-
- time.timeZone = "Europe/Isle_of_Man";
- i18n.defaultLocale = "en_US.UTF-8";
-
- services.displayManager.gdm.enable = true;
- services.desktopManager.gnome.enable = true;
- services.xserver.xkb = {
- layout = "us";
- variant = "";
- };
-
- services.printing.enable = true;
-
- services.pulseaudio.enable = false;
- security.rtkit.enable = true;
- services.pipewire = {
- enable = true;
- alsa.enable = true;
- alsa.support32Bit = true;
- pulse.enable = true;
- };
-
- users.users.${user} = {
- isNormalUser = true;
- description = "daemon-sec";
- extraGroups = [ "networkmanager" "wheel" ];
- };
-
- programs.firefox.enable = true;
- nixpkgs.config.allowUnfree = true;
-
- # Enough of the toolbox runtime to use ~/.local/bin from GNOME meanwhile.
- # The full closure arrives with home-manager in the `nixos` configuration.
- environment.systemPackages = with pkgs; [
- (python3.withPackages (ps: with ps; [
- cryptography
- argon2-cffi
- rich
- questionary
- pikepdf
- mutagen
- pillow
- numpy
- pyqt6
- youtube-transcript-api
- ]))
- perl
- jq
- fzf
- bat
- eza
- fd
- ripgrep
- git
- jujutsu # jj, colocated with the existing .git; also in home/modules/tools.nix for Stage B
- ];
-
- system.stateVersion = "26.05";
-}
diff --git a/hosts/laptop/default.nix b/hosts/laptop/default.nix
@@ -1,142 +0,0 @@
-# hosts/laptop/default.nix
-#
-# PCSpecialist Valeon II 17 (TongFang GM7RGxM): Ryzen 9 6900HX, Radeon 680M at
-# 06:00.0, RTX 3070 Ti Laptop at 01:00.0, 2560x1440@240 panel. Hyprland under
-# uwsm, Caelestia Shell from home-manager (home/), greetd + tuigreet to log in.
-{ config, pkgs, lib, user, ... }:
-{
- imports = [
- ./hardware-configuration.nix
- ./fan-throttle-guard.nix # dead-GPU-fan workaround (vault gpu-fan-fix/, imported unchanged)
- ./fan-extras.nix # the one imperative step fanfix install did: the performance profile
- ./uniwill-laptop.nix # the `uniwill` hwmon the guard reads: kernel 6.19 driver built for this kernel
- ./nvidia.nix
- ./ssd.nix
- ./nix-settings.nix
- ./toolbox.nix
- ./sops.nix # sops-nix: secrets/secrets.yaml → /run/secrets
- ./fan-cli.nix # fan-ec: root side of the `fan` command (home/modules/fan.nix), passwordless for wheel
- ];
-
- boot.loader.systemd-boot.enable = true;
- boot.loader.efi.canTouchEfiVariables = true;
-
- networking.hostName = "nixos";
- networking.networkmanager.enable = true;
- # LocalSend (home/modules/tools.nix) discovers peers and receives on 53317.
- networking.firewall.allowedTCPPorts = [ 53317 ];
- networking.firewall.allowedUDPPorts = [ 53317 ];
-
- time.timeZone = "Europe/Isle_of_Man";
- i18n.defaultLocale = "en_US.UTF-8";
- services.xserver.xkb = {
- layout = "us";
- options = "compose:caps,shift:both_capslock_cancel";
- };
-
- nixpkgs.config.allowUnfree = true; # nvidia, obsidian, claude-desktop
-
- users.users.${user} = {
- isNormalUser = true;
- description = "daemon-sec";
- extraGroups = [ "networkmanager" "wheel" ];
- shell = pkgs.zsh;
- };
-
- ##### Shell ##################################################################
- # zsh is the login shell. Its configuration is the dotfiles' ZDOTDIR tree
- # (home/modules/shell.nix): core.zsh runs a cached compinit and theme.zsh
- # starts starship, so the global compinit and the default prompt stay off.
- # /etc/zshrc still puts every profile's share/zsh/site-functions on fpath.
- programs.zsh = {
- enable = true;
- enableGlobalCompInit = false;
- promptInit = "";
- };
-
- # Binaries that are not built by Nix (uv-managed Pythons and their wheels,
- # anything mise or npm downloads) expect /lib64/ld-linux-x86-64.so.2.
- programs.nix-ld.enable = true;
-
- ##### Desktop ################################################################
- # Hyprland package and portal come from inputs.hyprland.nixosModules.default.
- programs.hyprland = {
- enable = true;
- withUWSM = true; # systemd-managed session; graphical-session.target starts Caelestia, polkit agent, udiskie
- xwayland.enable = true;
- };
-
- # Display manager: greetd with the tuigreet text greeter. Remembers the last
- # user and session, so a boot is: password, Enter. No theme engine, no X.
- services.greetd = {
- enable = true;
- useTextGreeter = true;
- settings.default_session.command = lib.concatStringsSep " " [
- "${pkgs.tuigreet}/bin/tuigreet"
- "--time"
- "--remember"
- "--remember-session"
- "--asterisks"
- "--sessions ${config.services.displayManager.sessionData.desktops}/share/wayland-sessions"
- ];
- };
- security.pam.services.greetd.enableGnomeKeyring = true; # unlock the keyring at login (Claude desktop uses it)
-
- xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file chooser; hyprland portal does screencast
- security.polkit.enable = true; # agent: hyprpolkitagent user service (home/modules/hyprland.nix)
- services.udisks2.enable = true; # udiskie
- services.power-profiles-daemon.enable = true; # the fan fix depends on it
- services.gnome.gnome-keyring.enable = true;
- programs.dconf.enable = true;
-
- services.pulseaudio.enable = false;
- security.rtkit.enable = true;
- services.pipewire = {
- enable = true;
- alsa.enable = true;
- alsa.support32Bit = true;
- pulse.enable = true;
- wireplumber.enable = true;
- };
-
- programs.firefox.enable = true;
- services.printing.enable = true;
-
- programs.gnupg.agent = {
- enable = true;
- pinentryPackage = pkgs.pinentry-gnome3;
- };
-
- ##### Fonts ##################################################################
- # DMMono Nerd Font is not in nixpkgs; home/modules/tools.nix links it from
- # ~/git/daemon-sec-dotfiles into ~/.local/share/fonts.
- fonts.packages = with pkgs; [
- noto-fonts
- noto-fonts-color-emoji
- noto-fonts-cjk-sans
- rubik # Caelestia clock font
- material-symbols # Caelestia icons
- ];
- fonts.fontconfig.defaultFonts = {
- monospace = [ "DMMono Nerd Font" "Noto Sans Mono" ];
- sansSerif = [ "Noto Sans" ];
- serif = [ "Noto Serif" ];
- emoji = [ "Noto Color Emoji" ];
- };
-
- ##### Session environment ####################################################
- # uwsm imports these through the login shell. GPU-specific ones are in nvidia.nix.
- environment.sessionVariables = {
- ELECTRON_OZONE_PLATFORM_HINT = "auto";
- GDK_BACKEND = "wayland,x11";
- QT_QPA_PLATFORM = "wayland;xcb";
- QT_WAYLAND_DISABLE_WINDOWDECORATION = "1";
- };
-
- environment.systemPackages = with pkgs; [
- pciutils # lspci
- usbutils
- ];
-
- system.stateVersion = "26.05";
-}
diff --git a/hosts/laptop/fan-cli.nix b/hosts/laptop/fan-cli.nix
@@ -1,122 +0,0 @@
-# hosts/laptop/fan-cli.nix — root side of the `fan` command (home/modules/fan.nix).
-#
-# `fan-ec` talks to the embedded controller the way fanfix does (same
-# acpi_call recipe, same registers; see ~/.local/bin/fanfix and the TUXEDO
-# driver), but it is a fixed script in the Nix store, so the wheel group may
-# run it through sudo without a password. That is what lets the watchdog in
-# fan.nix put the fans back to EC-automatic from a background unit, where
-# sudo could not ask for one. fanfix itself lives in the user-writable
-# ~/.local/bin and must never get such a rule.
-#
-# fan-ec status mode, duty %, EC flags fan-ec max 100 % (manual)
-# fan-ec auto hand control back to the EC fan-ec <30-100> fixed % (manual)
-# fan-ec mode one word: auto | manual | curve-daemon
-#
-# Manual mode on this laptop makes the EC assert PROCHOT (all cores 399 MHz)
-# under load (fan-throttle-guard.nix, line ~97). fan-ec does not try to
-# prevent that; the user-side watchdog detects the clamp and calls `fan-ec auto`.
-{ pkgs, lib, ... }:
-let
- fan-ec = pkgs.writeShellScriptBin "fan-ec" ''
- set -uo pipefail
- [ "$(id -u)" = 0 ] || { echo "fan-ec: run as root (sudo fan-ec …)" >&2; exit 1; }
- PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.kmod pkgs.systemd ]}:$PATH
-
- ACPI_CALL=/proc/acpi/call
- EC_DEV='\_SB.INOU'
- FAN_UNIT=fanfix-fan.service
- FAN_MIN_PCT=30
-
- ec_ready() { [ -w "$ACPI_CALL" ] || modprobe acpi_call 2>/dev/null; [ -w "$ACPI_CALL" ]; }
- ec_raw() { printf '%s' "$1" > "$ACPI_CALL" || return 1; local o; o=$(tr -d '\0\n\r ' < "$ACPI_CALL"); printf '%s' "$o"; }
- ec_read() { local out; out=$(ec_raw "$(printf '%s.ECRR 0x%04x' "$EC_DEV" "$1")") || return 1
- [[ "$out" =~ ^0x[0-9a-fA-F]+$ ]] || { echo "EC read $(printf '0x%04x' "$1") failed: $out" >&2; return 1; }
- echo $(( out )); }
- ec_write() { local out; out=$(ec_raw "$(printf '%s.ECRW 0x%04x 0x%02x' "$EC_DEV" "$1" "$2")") || return 1
- case "$out" in Error*|"not called"|"") echo "EC write $(printf '0x%04x' "$1") failed: ''${out:-no result}" >&2; return 1;; esac
- sleep 0.005; }
- ec_set_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v | $2 )); }
- ec_clear_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v & ~$2 & 0xff )); }
- ec_bit() { local v; v=$(ec_read "$1") || return 1; echo $(( (v >> $2) & 1 )); }
-
- R_AP_OEM=0x0741; R_FAN_MODE=0x0751; R_PWM1=0x075B; R_PWM2=0x075C
- R_FAN_CTRL=0x078E; R_TBL_SPLIT=0x07C5; R_TBL_ENABLE=0x07C6
- R_CPU_TBL_END=0x0F00; R_CPU_TBL_START=0x0F10; R_CPU_TBL_SPEED=0x0F20
- R_GPU_TBL_END=0x0F30; R_GPU_TBL_START=0x0F40; R_GPU_TBL_SPEED=0x0F50
- R_PWM1_W=0x1804; R_PWM2_W=0x1809
-
- universal_ctrl() { ec_bit $R_FAN_CTRL 6; }
- tables_enabled() { ec_bit $R_TBL_ENABLE 2; }
- pct_to_duty() { echo $(( $1 * 200 / 100 )); }
- duty_to_pct() { echo $(( $1 * 100 / 200 )); }
-
- fan_init_tables() {
- local i
- ec_clear_bits $R_FAN_MODE 0x40
- [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] || ec_set_bits $R_TBL_SPLIT 0x80
- ec_write $R_CPU_TBL_END 115; ec_write $R_CPU_TBL_START 0; ec_write $R_CPU_TBL_SPEED 1
- ec_write $R_GPU_TBL_END 120; ec_write $R_GPU_TBL_START 0; ec_write $R_GPU_TBL_SPEED 1
- for i in $(seq 1 15); do
- ec_write $(( R_CPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_CPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_CPU_TBL_SPEED + i )) 200
- ec_write $(( R_GPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_GPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_GPU_TBL_SPEED + i )) 200
- done
- [ "$(tables_enabled)" = 1 ] || ec_set_bits $R_TBL_ENABLE 0x04
- }
- fan_apply_duty() {
- local d=$1
- if [ "$(universal_ctrl)" = 1 ]; then
- [ "$(tables_enabled)" = 1 ] && [ "$(ec_bit $R_FAN_MODE 6)" = 0 ] || fan_init_tables
- ec_write $R_CPU_TBL_SPEED "$d"; ec_write $R_GPU_TBL_SPEED "$d"
- ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"
- else
- local i; [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || ec_set_bits $R_FAN_MODE 0x40
- for i in 1 2 3 4 5 6 7 8 9 10; do ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"; sleep 0.01; done
- fi
- }
- fan_set_auto() {
- if [ "$(universal_ctrl)" = 1 ]; then
- [ "$(tables_enabled)" = 1 ] && ec_clear_bits $R_TBL_ENABLE 0x04
- [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] && ec_clear_bits $R_TBL_SPLIT 0x80
- fi
- [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] && ec_clear_bits $R_FAN_MODE 0x40
- return 0
- }
- mode_word() {
- if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then echo curve-daemon; return; fi
- if [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || [ "$(tables_enabled)" = 1 ]; then echo manual; else echo auto; fi
- }
- stop_daemon() { systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && systemctl stop "$FAN_UNIT"; return 0; }
- guard() { ec_ready || { echo "fan-ec: acpi_call not available" >&2; exit 1; }
- ec_read $R_AP_OEM >/dev/null || { echo "fan-ec: EC not reachable via $EC_DEV.ECRR" >&2; exit 1; }; }
-
- case "''${1:-status}" in
- mode) guard; mode_word ;;
- status) guard
- printf 'mode=%s duty=%s%% target=%s%% fan-abnormal=%s full-fan-bit=%s tables=%s\n' \
- "$(mode_word)" "$(duty_to_pct "$(ec_read $R_PWM1)")" \
- "$([ "$(tables_enabled)" = 1 ] && duty_to_pct "$(ec_read $R_CPU_TBL_SPEED)" || echo -)" \
- "$(ec_bit $R_AP_OEM 5)" "$(ec_bit $R_FAN_MODE 6)" "$(tables_enabled)" ;;
- auto) guard; stop_daemon; fan_set_auto && echo "fans: auto (EC curve)" ;;
- max) guard; stop_daemon; fan_apply_duty 200 && echo "fans: 100 % (manual)" ;;
- [0-9]*) p=''${1%\%}; [[ "$p" =~ ^[0-9]+$ ]] && [ "$p" -le 100 ] && [ "$p" -ge "$FAN_MIN_PCT" ] \
- || { echo "fan-ec: percent must be $FAN_MIN_PCT-100 (below that the EC trips at 79 °C)" >&2; exit 2; }
- guard; stop_daemon; fan_apply_duty "$(pct_to_duty "$p")" && echo "fans: $p % (manual)" ;;
- *) echo "usage: fan-ec status|mode|auto|max|<30-100>" >&2; exit 2 ;;
- esac
- '';
-in
-{
- environment.systemPackages = [ fan-ec ];
-
- # wheel may run fan-ec without a password: it is immutable store content
- # (via the system profile symlink, which is root-owned), does one thing,
- # and the watchdog has no terminal to type into.
- security.sudo.extraRules = [
- {
- groups = [ "wheel" ];
- commands = [
- { command = "/run/current-system/sw/bin/fan-ec"; options = [ "NOPASSWD" ]; }
- ];
- }
- ];
-}
diff --git a/hosts/laptop/fan-extras.nix b/hosts/laptop/fan-extras.nix
@@ -1,25 +0,0 @@
-# hosts/laptop/fan-extras.nix
-#
-# The part of the fan fix that `fanfix install` did imperatively on Arch and
-# that fan-throttle-guard.nix (imported unchanged) does not carry: the
-# "performance" power profile. power-profiles-daemon persists the choice, so
-# this oneshot is idempotent; it runs after ppd is up and then re-applies the
-# tmpfiles clock floor, because a profile switch rewrites per-policy boost and
-# can lift scaling_max_freq (fanfix re-runs tmpfiles for the same reason; the
-# stability guard would also catch it within a second).
-{ pkgs, lib, ... }:
-{
- systemd.services.fanfix-performance-profile = {
- description = "fanfix: select the performance power profile and re-assert the clock floor";
- after = [ "power-profiles-daemon.service" "systemd-tmpfiles-setup.service" ];
- requires = [ "power-profiles-daemon.service" ];
- # graphical.target, not multi-user: on NixOS power-profiles-daemon is itself
- # ordered After=multi-user.target, so multi-user here is an ordering cycle.
- wantedBy = [ "graphical.target" ];
- serviceConfig = {
- Type = "oneshot";
- ExecStart = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl set performance";
- ExecStartPost = "${pkgs.systemd}/bin/systemd-tmpfiles --create --prefix=/sys/devices/system/cpu";
- };
- };
-}
diff --git a/hosts/laptop/fan-throttle-guard.nix b/hosts/laptop/fan-throttle-guard.nix
@@ -1,122 +0,0 @@
-# hosts/laptop/fan-throttle-guard.nix
-#
-# Dead-GPU-fan workaround for the PCSpecialist Valeon II 17 (TongFang GM7RGxM,
-# Ryzen 9 6900HX + RTX 3070 Ti Laptop). The embedded controller sees the dead
-# "Secondary" fan (fan2: 0 rpm while commanded 100 %), raises its fan-abnormal
-# flag and, once Tctl reaches ~79 °C, asserts PROCHOT and pins all 16 threads at
-# 399 MHz until ~47 °C. That policy is firmware; Linux cannot switch it off.
-# The fix is to keep the CPU from ever reaching the trip point:
-#
-# 1. a static scaling_max_freq floor of 3.2 GHz applied by tmpfiles at boot
-# (3.2 GHz ≈ base clock → ~67-70 °C under all-core load, no trips),
-# 2. a staged guard (3200 → 2400 → 1800 MHz) driven by k10temp + the uniwill
-# board sensor, which also covers the "latched" low-temperature clamp,
-# 3. the surviving CPU fan held at 60 % duty through the EC's own ACPI
-# methods (acpi_call → \_SB.INOU.ECRR/ECRW, TUXEDO register recipe),
-# 4. power-profiles-daemon kept on, profile "performance", and the global
-# cpufreq boost flag left at 1 — never use boost=0, ppd 0.30 writes
-# per-policy boost on every switch and fails with EINVAL otherwise.
-#
-# Everything here was measured on the Arch install this was captured from
-# (fanfix 2026-08-23, stability guard 2026-09-07). Files beside this module:
-# fanfix the CLI/daemon (bash) ← copied verbatim
-# stability_guard.py the staged ceiling (python3) ← copied verbatim
-# fan-ctl, fan-state bar-widget helpers; need a polkit agent and a bar slot
-#
-# Verify on first boot: fanfix status · fanfix fan status · fanfix test 30
-# expected: cap 3200 MHz, boost 1, profile performance, no THROTTLE event,
-# peak < 75 °C. If a trip still happens: lower the floor to 3000000 below.
-{ config, pkgs, lib, ... }:
-
-let
- # fanfix is plain bash; wrap it so the shebang resolves and PATH is supplied
- # by the unit (fanDeps) rather than by whatever shell invoked it.
- fanfix = pkgs.writeShellScriptBin "fanfix" (builtins.readFile ./fanfix);
-
- fanDeps = with pkgs; [
- coreutils gnugrep gawk gnused procps util-linux
- kmod # modprobe acpi_call / uniwill-laptop
- systemd # systemctl, systemd-tmpfiles
- power-profiles-daemon # powerprofilesctl
- ];
-
- capKhz = 3200000; # the floor. 3000000 is the documented fallback.
-in
-{
- ##### 1. EC access and fan/temperature readout ##############################
- # acpi_call is out-of-tree (nixpkgs: linuxPackages.acpi_call). uniwill-laptop
- # is in-tree; `force=1` is required because the DMI match list does not carry
- # this GM7RGxM. Verify `modinfo uniwill-laptop` exists on the chosen kernel.
- boot.extraModulePackages = [ config.boot.kernelPackages.acpi_call ];
- boot.kernelModules = [ "acpi_call" "uniwill-laptop" ];
- boot.extraModprobeConfig = ''
- options uniwill-laptop force=1
- '';
-
- ##### 2. Static floor, applied before any user load exists ###################
- systemd.tmpfiles.rules = [
- "w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${toString capKhz}"
- ];
-
- ##### 3. power-profiles-daemon stays on ######################################
- services.power-profiles-daemon.enable = true;
-
- ##### 4. Staged thermal ceiling (replaces /etc/systemd/system/motherboard-stability.service)
- systemd.services.motherboard-stability = {
- description = "CPU stability limits for the GM7RGxM fan/power fault workaround";
- after = [ "systemd-tmpfiles-setup.service" ];
- wantedBy = [ "multi-user.target" ];
- serviceConfig = {
- Type = "simple";
- ExecStart = "${pkgs.python3}/bin/python3 -I ${./stability_guard.py}";
- Restart = "on-failure";
- RestartSec = 3;
- RuntimeDirectory = "motherboard-stability";
- RuntimeDirectoryMode = "0755";
- NoNewPrivileges = true;
- ProtectSystem = "strict";
- ProtectHome = true;
- ReadWritePaths = [ "/sys/devices/system/cpu" "/run/motherboard-stability" ];
- PrivateTmp = true;
- PrivateDevices = true;
- ProtectKernelModules = true;
- ProtectControlGroups = true;
- RestrictAddressFamilies = "AF_UNIX";
- LockPersonality = true;
- RestrictSUIDSGID = true;
- CapabilityBoundingSet = "";
- UMask = "0022";
- };
- };
-
- ##### 5. Surviving CPU fan at a fixed 60 % duty ##############################
- # CURVE is "temp:pct …" pairs; a flat 0:60 100:60 is what has been running.
- # fanfix refuses anything below 30 %. Edit here, not in /etc, then rebuild.
- #
- # NOT started at boot (wantedBy = []). Measured 2026-10-07 on NixOS: while the
- # daemon holds the EC in manual/custom-table fan mode, the EC asserts PROCHOT
- # (all cores 399 MHz) the moment any load starts, even at 37 °C. Stopping the
- # unit and `fanfix fan auto` cleared it instantly; 10 s all-core test then ran
- # at 3112 MHz, peak 53 °C, 0 trips. The 3.2 GHz floor + stability guard are
- # enough on their own. Start by hand to experiment: systemctl start fanfix-fan
- environment.etc."fanfix.conf".text = ''
- CURVE="0:60 100:60"
- '';
-
- systemd.services.fanfix-fan = {
- description = "fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround)";
- after = [ "multi-user.target" ];
- wantedBy = [ ]; # see note above; manual start only
- path = fanDeps;
- serviceConfig = {
- Type = "simple";
- ExecStart = "${fanfix}/bin/fanfix fan-daemon";
- ExecStopPost = "${fanfix}/bin/fanfix fan-release";
- Restart = "on-failure";
- RestartSec = 5;
- };
- };
-
- ##### 6. Tools on PATH ######################################################
- environment.systemPackages = [ fanfix pkgs.lm_sensors ] ++ fanDeps;
-}
diff --git a/hosts/laptop/hardware-configuration.nix b/hosts/laptop/hardware-configuration.nix
@@ -1,45 +0,0 @@
-# Do not modify this file! It was generated by ‘nixos-generate-config’
-# and may be overwritten by future invocations. Please make changes
-# to /etc/nixos/configuration.nix instead.
-{ config, lib, pkgs, modulesPath, ... }:
-
-{
- imports =
- [ (modulesPath + "/installer/scan/not-detected.nix")
- ];
-
- boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "usbhid" "usb_storage" "sd_mod" ];
- boot.initrd.kernelModules = [ ];
- boot.kernelModules = [ "kvm-amd" ];
- boot.extraModulePackages = [ ];
-
- fileSystems."/" =
- { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
- fsType = "btrfs";
- };
-
- fileSystems."/home" =
- { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
- fsType = "btrfs";
- options = [ "subvol=home" ];
- };
-
- fileSystems."/nix" =
- { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
- fsType = "btrfs";
- options = [ "subvol=nix" ];
- };
-
- fileSystems."/boot" =
- { device = "/dev/disk/by-uuid/D3FC-22C2";
- fsType = "vfat";
- options = [ "fmask=0077" "dmask=0077" ];
- };
-
- swapDevices =
- [ { device = "/dev/disk/by-uuid/6aee8a42-a2a1-4d18-8f5c-695195e0c122"; }
- ];
-
- nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
- hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
-}
diff --git a/hosts/laptop/nix-settings.nix b/hosts/laptop/nix-settings.nix
@@ -1,41 +0,0 @@
-# hosts/laptop/nix-settings.nix — nix daemon settings and the nh helper.
-# Imported by both the `nixos` target and the `bootstrap` stage.
-{ pkgs, ... }:
-{
- nix.settings = {
- experimental-features = [ "nix-command" "flakes" ];
- # Hyprland is built from its own flake pins, which cache.nixos.org does
- # not have. Without the Hyprland cache every update compiles it locally.
- substituters = [
- "https://cache.nixos.org"
- "https://hyprland.cachix.org"
- ];
- trusted-public-keys = [
- "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
- "hyprland.cachix.org-1:a7pgxzMz7+chwVL3/pzj6jIBMioiJM7ypFP8PwtkuGc="
- ];
- };
-
- # nh, the Nix helper: `nh os switch|boot|test`, `nh clean all`, `nh search`.
- # Wraps nixos-rebuild with nix-output-monitor progress and an nvd diff of
- # what a generation changes, and asks for sudo only for the switch itself.
- # NH_FLAKE points at this repo, so `nh os boot` works from any directory;
- # the configuration is picked by hostname (`nixos`), or with -H <name>.
- programs.nh = {
- enable = true;
- flake = "/home/daemonsec/NixDaemon";
- clean = {
- enable = true; # weekly `nh clean all`: drops old generations and runs the GC,
- dates = "weekly"; # keeping the last 5 and anything newer than 14 days
- extraArgs = "--keep 5 --keep-since 14d";
- };
- };
-
- # The two tools nh builds on, also useful by hand:
- # nvd diff /run/current-system result what a build would change
- # nom build .#… nix build with a live tree view
- environment.systemPackages = with pkgs; [
- nvd
- nix-output-monitor
- ];
-}
diff --git a/hosts/laptop/nvidia.nix b/hosts/laptop/nvidia.nix
@@ -1,53 +0,0 @@
-# hosts/laptop/nvidia.nix
-#
-# The panel (eDP-1) is wired to the RTX 3070 Ti at 01:00.0: the firmware MUX is
-# in discrete mode (amdgpu's eDP-2 reports disconnected). Linux cannot change
-# the MUX, so this configures what the hardware presents: NVIDIA open kernel
-# module with modesetting, the Radeon 680M left as a secondary DRM device.
-#
-# PARKED: if the MUX is switched to hybrid in the BIOS, swap the AQ_DRM_DEVICES
-# order (igpu-card first) and add the prime offload block at the bottom.
-{ config, pkgs, lib, ... }:
-{
- services.xserver.videoDrivers = [ "nvidia" ];
-
- hardware.graphics = {
- enable = true;
- extraPackages = [ pkgs.nvidia-vaapi-driver ];
- };
-
- hardware.nvidia = {
- open = true; # GA104 is supported by the open kernel modules
- modesetting.enable = true; # nvidia-drm.modeset=1
- package = config.boot.kernelPackages.nvidiaPackages.stable;
- nvidiaSettings = false;
- # powerManagement.enable = true; # suspend/resume helpers; untested on this laptop, left at default
- };
-
- # Stable, colon-free names for the two DRM cards. Aquamarine splits
- # AQ_DRM_DEVICES on ':', so the /dev/dri/by-path names cannot go in it (the PCI
- # address has colons): it chopped them into "pci-0000", "01", "00.0-card", found
- # no GPU and Hyprland aborted at startup with "CBackend::create() failed!".
- # ID_PATH is matched exactly so the boot-time simpledrm card (whose ID_PATH is
- # pci-0000:01:00.0-platform-simple-framebuffer.0) does not take the dGPU name.
- services.udev.extraRules = ''
- SUBSYSTEM=="drm", KERNEL=="card[0-9]*", ENV{ID_PATH}=="pci-0000:01:00.0", SYMLINK+="dri/dgpu-card"
- SUBSYSTEM=="drm", KERNEL=="card[0-9]*", ENV{ID_PATH}=="pci-0000:06:00.0", SYMLINK+="dri/igpu-card"
- '';
-
- environment.sessionVariables = {
- # Stable device order for Hyprland/aquamarine: dGPU (panel) first, iGPU second.
- AQ_DRM_DEVICES = "/dev/dri/dgpu-card:/dev/dri/igpu-card";
- LIBVA_DRIVER_NAME = "nvidia";
- __GLX_VENDOR_LIBRARY_NAME = "nvidia";
- NVD_BACKEND = "direct";
- };
-
- # Hybrid (iGPU drives the panel, dGPU on demand). Only if the BIOS MUX is set to hybrid:
- # hardware.nvidia.prime = {
- # offload.enable = true;
- # offload.enableOffloadCmd = true;
- # amdgpuBusId = "PCI:6:0:0";
- # nvidiaBusId = "PCI:1:0:0";
- # };
-}
diff --git a/hosts/laptop/sops.nix b/hosts/laptop/sops.nix
@@ -1,32 +0,0 @@
-# hosts/laptop/sops.nix — sops-nix: secrets encrypted in this repo, decrypted
-# at activation into /run/secrets (root-only tmpfs; per-secret owner/mode).
-#
-# One age identity does everything (.sops.yaml): the user edits with the sops
-# CLI (reads ~/.config/sops/age/keys.txt), the system decrypts at boot with a
-# root-only copy at /var/lib/sops-nix/key.txt. Put it there once:
-#
-# sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt
-#
-# No SSH host key is used: sshd is not enabled on this machine, so there is
-# none to derive an age key from (sshKeyPaths is emptied below for that reason).
-#
-# Declaring a secret:
-# sops.secrets.my-token = { }; # → /run/secrets/my-token, root:root 0400
-# sops.secrets."ssd.key" = { path = "/etc/secrets/ssd.key"; }; # the LUKS key, once it is
-# # added to secrets/secrets.yaml (sops set …)
-# sops.secrets.wifi-psk = { owner = user; }; # readable by the user
-# then `sops secrets/secrets.yaml` to add the value, and `nh os switch`.
-{ inputs, user, ... }:
-{
- imports = [ inputs.sops-nix.nixosModules.sops ];
-
- sops = {
- defaultSopsFile = ../../secrets/secrets.yaml;
- age = {
- keyFile = "/var/lib/sops-nix/key.txt";
- sshKeyPaths = [ ];
- generateKey = false; # the key is the user's (see header), never a fresh one
- };
- gnupg.sshKeyPaths = [ ];
- };
-}
diff --git a/hosts/laptop/ssd.nix b/hosts/laptop/ssd.nix
@@ -1,30 +0,0 @@
-# hosts/laptop/ssd.nix
-#
-# Spare Samsung SSD 980 1 TB (LUKS2, btrfs label SSD) at /mnt/ssd, unlocked at
-# boot from /etc/secrets/ssd.key. The key is restored by hand once (vault
-# samsung-ssd.md, section 2):
-#
-# sudo mkdir -p -m 700 /etc/secrets
-# gpg -d --pinentry-mode loopback ssd.key.gpg | sudo tee /etc/secrets/ssd.key >/dev/null
-# sudo chmod 400 /etc/secrets/ssd.key
-#
-# `nofail` on both lines: the laptop boots normally while the key (or the
-# drive) is missing; the unit just fails. Swap the key path for `none` to type
-# the passphrase at boot instead.
-{ ... }:
-{
- environment.etc.crypttab.text = ''
- ssd UUID=8cd17d0f-adf3-430f-9d95-d17ef42df9b0 /etc/secrets/ssd.key luks,nofail
- '';
-
- fileSystems."/mnt/ssd" = {
- device = "/dev/mapper/ssd";
- fsType = "btrfs";
- options = [
- "compress=zstd:3"
- "noatime"
- "nofail"
- "x-systemd.device-timeout=10s"
- ];
- };
-}
diff --git a/hosts/laptop/toolbox.nix b/hosts/laptop/toolbox.nix
@@ -1,26 +0,0 @@
-# hosts/laptop/toolbox.nix
-#
-# NixOS-side support for the hand-written toolbox that lives, flat, in
-# ~/.local/bin (its own git repo; not in the Nix store). Shared by the target
-# and the bootstrap configuration.
-{ pkgs, lib, user, ... }:
-{
- # Several tools keep Arch-style shebangs (#!/bin/bash: fanfix, dropterm,
- # omarchy-menu-*; #!/usr/bin/python3: lid-control). envfs resolves those
- # paths from the caller's PATH instead of patching the scripts.
- services.envfs.enable = true;
-
- # ~/.local/bin first on PATH (prepends in /etc/set-environment).
- environment.localBinInPath = true;
-
- users.users.${user}.extraGroups = [
- "input" # padx talks to the touchpad over hidraw
- "video"
- ];
-
- # padx: the Pixart 093A:0274 touchpad behind the UNIW0001 I2C-HID bridge.
- # Replaces ~/trackpad-fix/60-padx-touchpad.rules from the Arch install.
- services.udev.extraRules = ''
- KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{modalias}=="hid:b0018g*v0000093Ap00000274", GROUP="input", MODE="0660"
- '';
-}
diff --git a/hosts/laptop/uniwill-laptop.nix b/hosts/laptop/uniwill-laptop.nix
@@ -1,20 +0,0 @@
-# hosts/laptop/uniwill-laptop.nix
-#
-# fan-throttle-guard.nix expects the in-tree `uniwill-laptop` driver (the
-# `uniwill` hwmon: fan1/fan2 rpm, pwm1/pwm2, board temps; also the keyboard
-# backlight LED and the touchpad-toggle bit padx mentions). Upstream merged it
-# in Linux 6.19; the nixpkgs 6.18 kernel predates it and the 7.2 kernel config
-# leaves X86_PLATFORM_DRIVERS_UNIWILL off. Building the v6.19 sources as an
-# out-of-tree module against whatever boot.kernelPackages selects is the cheap
-# fix: a 10-second compile, no custom kernel. Verified to build on 6.18.55.
-#
-# Without this hwmon stability_guard.py pins the CPU at 1.8 GHz ("essential
-# sensor or main fan unavailable"), so this file is not optional.
-{ config, lib, pkgs, ... }:
-{
- boot.extraModulePackages = [
- (config.boot.kernelPackages.callPackage ./uniwill-laptop/package.nix { })
- ];
- # `boot.kernelModules` and the `force=1` modprobe option (this GM7RGxM is not
- # in the driver's DMI list) are set in fan-throttle-guard.nix.
-}
diff --git a/modules/features/desktop/hyprland.nix b/modules/features/desktop/hyprland.nix
@@ -0,0 +1,24 @@
+# modules/features/desktop/hyprland.nix — the NixOS side of the Hyprland
+# desktop, on when daemon.desktop.hyprland.enable (modules/features/desktop/options.nix).
+# The compositor config, Caelestia and the session services are home-manager
+# modules (modules/home/hyprland.nix, modules/home/caelestia.nix), gated the same way.
+{ ... }:
+{
+ flake.nixosModules.desktop-hyprland =
+ { config, lib, pkgs, inputs, ... }:
+ {
+ # Hyprland package and portal come from inputs.hyprland.nixosModules.default.
+ imports = [ inputs.hyprland.nixosModules.default ];
+
+ config = lib.mkIf config.daemon.desktop.hyprland.enable {
+ programs.hyprland = {
+ enable = true;
+ withUWSM = true; # systemd-managed session; graphical-session.target starts Caelestia, polkit agent, udiskie
+ xwayland.enable = true;
+ };
+
+ xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file chooser; hyprland portal does screencast
+ };
+ }
+ ;
+}
diff --git a/modules/features/desktop/niri.nix b/modules/features/desktop/niri.nix
@@ -0,0 +1,182 @@
+# modules/features/desktop/niri.nix — Niri (scrolling-column Wayland
+# compositor) with Noctalia Shell, as a wrapped, portable package plus the
+# NixOS module that installs it as a login session.
+#
+# nix run ~/NixDaemon#niri try it nested inside the running desktop (Alt is the modifier)
+# daemon.desktop.niri.enable the switch (modules/features/desktop/options.nix)
+#
+# The settings below become niri's config.kdl at build time (the wrapper runs
+# `niri validate` on it, so a bad bind fails the build, not the login) and the
+# package carries every program its binds spawn. Keys mirror the Hyprland set
+# (modules/home/hypr/defaults.lua) where a Niri or Noctalia counterpart exists.
+# Rosé Pine Main: the focus ring is rose #ebbcba on overlay #26233a.
+{ self, inputs, ... }:
+{
+ perSystem =
+ { pkgs, lib, self', ... }:
+ let
+ noctalia = lib.getExe self'.packages.noctalia;
+ kitty = lib.getExe pkgs.kitty;
+ firefox = lib.getExe pkgs.firefox;
+ nautilus = lib.getExe pkgs.nautilus;
+ grim = lib.getExe pkgs.grim;
+ slurp = lib.getExe pkgs.slurp;
+ wl-copy = "${pkgs.wl-clipboard}/bin/wl-copy";
+ wpctl = "${pkgs.wireplumber}/bin/wpctl";
+ brightnessctl = lib.getExe pkgs.brightnessctl;
+ playerctl = lib.getExe pkgs.playerctl;
+ ipc = target: "${noctalia} ipc call ${target}";
+
+ # a bind that also works on the lock screen (volume, brightness, media)
+ locked = cmd: _: {
+ props.allow-when-locked = true;
+ content.spawn-sh = cmd;
+ };
+ workspaceBinds = lib.foldl' (acc: n: acc // {
+ "Mod+${toString n}".focus-workspace = n;
+ "Mod+Shift+${toString n}".move-column-to-workspace = n;
+ }) { } (lib.range 1 9);
+ in
+ {
+ packages.niri = inputs.wrapper-modules.wrappers.niri.wrap {
+ inherit pkgs;
+ settings = {
+ input = {
+ keyboard = {
+ xkb = {
+ layout = "us";
+ options = "compose:caps,shift:both_capslock_cancel"; # as services.xserver.xkb
+ };
+ };
+ touchpad = {
+ tap = _: { };
+ natural-scroll = _: { };
+ };
+ focus-follows-mouse = _: { };
+ };
+
+ layout = {
+ gaps = 8;
+ focus-ring = {
+ width = 2;
+ active-color = "#ebbcba";
+ inactive-color = "#26233a";
+ };
+ border.off = _: { };
+ preset-column-widths = [
+ { proportion = 0.33333; }
+ { proportion = 0.5; }
+ { proportion = 0.66667; }
+ ];
+ };
+
+ prefer-no-csd = _: { };
+ hotkey-overlay.skip-at-startup = _: { };
+ xwayland-satellite.path = lib.getExe pkgs.xwayland-satellite;
+
+ # The same session variables the Hyprland side sets (hosts/laptop configuration.nix).
+ environment = {
+ ELECTRON_OZONE_PLATFORM_HINT = "auto";
+ QT_QPA_PLATFORM = "wayland;xcb";
+ };
+
+ spawn-at-startup = [ noctalia ];
+
+ binds = {
+ # apps
+ "Mod+Return".spawn = [ kitty ];
+ "Mod+Shift+Return".spawn = [ firefox ];
+ "Mod+Shift+B".spawn = [ firefox ];
+ "Mod+Shift+Alt+B".spawn = [ firefox "--private-window" ];
+ "Mod+Shift+F".spawn = [ nautilus "--new-window" ];
+ "Mod+Shift+O".spawn-sh = "obsidian"; # unfree, from the system profile (modules/features/workstation.nix)
+ "Mod+Shift+N".spawn-sh = "${kitty} -e \${EDITOR:-nano}";
+
+ # Noctalia
+ "Mod+Space".spawn-sh = ipc "launcher toggle";
+ "Mod+Alt+Space".spawn-sh = ipc "launcher toggle";
+ "Mod+Escape".spawn-sh = ipc "sessionMenu toggle";
+ "Mod+Ctrl+P".spawn-sh = ipc "sessionMenu toggle";
+ "Mod+A".spawn-sh = ipc "controlCenter toggle";
+ "Mod+Comma".spawn-sh = ipc "notifications clear";
+ "Mod+Ctrl+V".spawn-sh = ipc "launcher clipboard";
+ "Mod+Ctrl+Space".spawn-sh = ipc "wallpaper toggle";
+ "Mod+Shift+Escape".spawn-sh = ipc "lockScreen lock";
+
+ # windows and columns
+ "Mod+Q".close-window = _: { };
+ "Mod+F".maximize-column = _: { };
+ "Mod+G".fullscreen-window = _: { };
+ "Mod+Shift+V".toggle-window-floating = _: { };
+ "Mod+H".focus-column-left = _: { };
+ "Mod+J".focus-window-down = _: { };
+ "Mod+K".focus-window-up = _: { };
+ "Mod+L".focus-column-right = _: { };
+ "Mod+Left".focus-column-left = _: { };
+ "Mod+Down".focus-window-down = _: { };
+ "Mod+Up".focus-window-up = _: { };
+ "Mod+Right".focus-column-right = _: { };
+ "Mod+Shift+H".move-column-left = _: { };
+ "Mod+Shift+J".move-window-down = _: { };
+ "Mod+Shift+K".move-window-up = _: { };
+ "Mod+Shift+L".move-column-right = _: { };
+ "Mod+Shift+Left".move-column-left = _: { };
+ "Mod+Shift+Down".move-window-down = _: { };
+ "Mod+Shift+Up".move-window-up = _: { };
+ "Mod+Shift+Right".move-column-right = _: { };
+ "Mod+Ctrl+H".set-column-width = "-5%";
+ "Mod+Ctrl+L".set-column-width = "+5%";
+ "Mod+Ctrl+J".set-window-height = "-5%";
+ "Mod+Ctrl+K".set-window-height = "+5%";
+ "Mod+WheelScrollDown" = _: {
+ props.cooldown-ms = 150;
+ content.focus-workspace-down = _: { };
+ };
+ "Mod+WheelScrollUp" = _: {
+ props.cooldown-ms = 150;
+ content.focus-workspace-up = _: { };
+ };
+ "Mod+O".toggle-overview = _: { };
+ "Mod+Shift+Slash".show-hotkey-overlay = _: { };
+
+ # screenshots
+ "Print".spawn-sh = "${grim} -g \"$(${slurp})\" - | ${wl-copy}";
+ "Shift+Print".spawn-sh = "${grim} - | ${wl-copy}";
+
+ # media and hardware keys, also on the lock screen
+ "XF86AudioRaiseVolume" = locked "${wpctl} set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%+";
+ "XF86AudioLowerVolume" = locked "${wpctl} set-volume -l 1.4 @DEFAULT_AUDIO_SINK@ 5%-";
+ "XF86AudioMute" = locked "${wpctl} set-mute @DEFAULT_AUDIO_SINK@ toggle";
+ "XF86AudioMicMute" = locked "${wpctl} set-mute @DEFAULT_AUDIO_SOURCE@ toggle";
+ "XF86MonBrightnessUp" = locked "${brightnessctl} set 5%+";
+ "XF86MonBrightnessDown" = locked "${brightnessctl} set 5%-";
+ "XF86AudioPlay" = locked "${playerctl} play-pause";
+ "XF86AudioPause" = locked "${playerctl} play-pause";
+ "XF86AudioNext" = locked "${playerctl} next";
+ "XF86AudioPrev" = locked "${playerctl} previous";
+
+ "Mod+Shift+E".quit = _: { }; # niri asks for confirmation
+ } // workspaceBinds;
+ };
+ };
+ };
+
+ flake.nixosModules.desktop-niri =
+ { config, lib, pkgs, ... }:
+ {
+ config = lib.mkIf config.daemon.desktop.niri.enable {
+ # nixpkgs' module registers the session for tuigreet and adds the
+ # GNOME portal Niri documents; the package is the wrapped one above.
+ programs.niri = {
+ enable = true;
+ package = self.packages.${pkgs.stdenv.hostPlatform.system}.niri;
+ };
+ # Noctalia's battery widget reads UPower (nixpkgs' module leaves it off).
+ services.upower.enable = true;
+ # nixpkgs' portal config for niri names the gtk implementations for
+ # Access, FileChooser and Notification; keep that portal installed even
+ # when the Hyprland side (which also adds it) is switched off.
+ xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ];
+ };
+ };
+}
diff --git a/modules/features/desktop/noctalia.nix b/modules/features/desktop/noctalia.nix
@@ -0,0 +1,70 @@
+# modules/features/desktop/noctalia.nix — Noctalia Shell (bar, launcher,
+# notifications, control centre, lock screen, wallpaper) as a wrapped,
+# portable package: `nix run ~/NixDaemon#noctalia`. Niri spawns it at startup
+# (niri.nix). Rosé Pine Main is Noctalia's own "Rosepine" scheme (dark half =
+# base #191724, rose #ebbcba); never Moon.
+#
+# The settings below live in the Nix store (NOCTALIA_SETTINGS_FILE), so the
+# GUI settings panel (right-click the bar) changes them for the running
+# session only. To keep a change: tweak it in the GUI, then
+#
+# dump-noctalia-shell (in the same package) prints the live settings as Nix
+#
+# and copy the keys that differ from Noctalia's defaults into `settings` here.
+{ inputs, ... }:
+{
+ perSystem =
+ { pkgs, ... }:
+ {
+ packages.noctalia = inputs.wrapper-modules.wrappers.noctalia-shell.wrap {
+ inherit pkgs;
+ settings = {
+ # Noctalia's current settings schema (Commons/Settings.qml). Without
+ # it every migration since v0 runs at start, one of them against
+ # ~/.config/noctalia. Bump it when noctalia-shell is updated
+ # (`dump-noctalia-shell` prints the value it expects).
+ settingsVersion = 59;
+ colorSchemes = {
+ predefinedScheme = "Rosepine";
+ darkMode = true;
+ useWallpaperColors = false; # never derive colours from the wallpaper
+ };
+ bar = {
+ position = "top";
+ density = "compact";
+ };
+ general = {
+ lockOnSuspend = true;
+ telemetryEnabled = false;
+ };
+ ui = {
+ fontDefault = "Noto Sans";
+ fontFixed = "DMMono Nerd Font";
+ };
+ wallpaper = {
+ enabled = true;
+ # the same Rosé Pine wallpapers Caelestia uses (modules/home/caelestia.nix):
+ # three sets in subfolders, so the picker searches recursively
+ directory = "/home/daemonsec/Pictures/Wallpapers";
+ viewMode = "recursive";
+ fillMode = "crop";
+ };
+ location = {
+ name = "Douglas, Isle of Man"; # dashboard weather, as in Caelestia
+ useFahrenheit = false;
+ use12hourFormat = false;
+ };
+ appLauncher = {
+ terminalCommand = "kitty -e";
+ enableClipboardHistory = true; # the Ctrl+Super+V bind (niri.nix) opens this tab; needs cliphist + wl-paste (modules/home/session.nix)
+ };
+ idle = {
+ enabled = true;
+ lockTimeout = 600; # Noctalia's own lock screen after ten idle minutes
+ screenOffTimeout = 1200; # as the Hyprland side (20 min)
+ suspendTimeout = 0; # never: Noctalia's default is 30 min, and NVIDIA suspend is untested here (nvidia.nix)
+ };
+ };
+ };
+ };
+}
diff --git a/modules/features/desktop/options.nix b/modules/features/desktop/options.nix
@@ -0,0 +1,29 @@
+# modules/features/desktop/options.nix — the desktop switches.
+#
+# Both desktops are installed by default and chosen at login (tuigreet lists
+# every session and remembers the last one). Set one to false in
+# modules/hosts/laptop/configuration.nix to drop it from the system entirely.
+#
+# daemon.desktop.hyprland.enable Hyprland (uwsm) + Caelestia Shell
+# daemon.desktop.niri.enable Niri + Noctalia Shell
+#
+# Home-manager modules read the same switches through `osConfig`.
+{ ... }:
+{
+ flake.nixosModules.desktop-options =
+ { lib, config, ... }:
+ {
+ options.daemon.desktop = {
+ hyprland.enable = lib.mkEnableOption "Hyprland with Caelestia Shell" // { default = true; };
+ niri.enable = lib.mkEnableOption "Niri with Noctalia Shell" // { default = true; };
+ };
+
+ config.assertions = [
+ {
+ assertion = config.daemon.desktop.hyprland.enable || config.daemon.desktop.niri.enable;
+ message = "daemon.desktop: enable at least one desktop";
+ }
+ ];
+ }
+ ;
+}
diff --git a/modules/features/home-manager.nix b/modules/features/home-manager.nix
@@ -0,0 +1,27 @@
+# modules/features/home-manager.nix — home-manager as a NixOS module: the
+# user's home (modules/home/default.nix) is built and activated by the same
+# `nh os switch` as the system. There is no standalone home-manager profile.
+{ self, inputs, ... }:
+{
+ flake.nixosModules.home-manager =
+ { user, ... }:
+ {
+ imports = [ inputs.home-manager.nixosModules.home-manager ];
+
+ home-manager = {
+ useGlobalPkgs = true;
+ useUserPackages = true;
+ backupFileExtension = "hm-bak";
+ extraSpecialArgs = { inherit inputs user; };
+ # The Hyprland and Caelestia home-manager modules only declare options;
+ # modules/home/{hyprland,caelestia}.nix decide whether they do anything
+ # (daemon.desktop.hyprland.enable).
+ sharedModules = [
+ inputs.hyprland.homeManagerModules.default
+ inputs.caelestia-shell.homeManagerModules.default
+ ];
+ users.${user} = self.homeModules.daemonsec;
+ };
+ }
+ ;
+}
diff --git a/modules/features/workstation.nix b/modules/features/workstation.nix
@@ -0,0 +1,48 @@
+# Claude Code, the Claude desktop app, Obsidian, and the git / GitHub / GitLab
+# command-line tools. Written by nixdaemon-bootstrap.sh; edit freely.
+{ ... }:
+{
+ flake.nixosModules.workstation =
+ { lib, pkgs, inputs, ... }:
+ let
+ # Anthropic's own Linux builds, repackaged for Nix by numtide and refreshed
+ # daily. (NixOS isn't a distro Anthropic supports directly: its desktop app
+ # ships as a .deb for Debian/Ubuntu.)
+ claude = inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system};
+ in
+ {
+ nix.settings.experimental-features = [ "nix-command" "flakes" ];
+
+ # Unfree packages (Obsidian) are already allowed elsewhere in this config.
+
+ environment.systemPackages = [
+ claude.claude-code # terminal: `claude`
+ claude.claude-desktop # desktop app: "Claude" in your launcher, or `claude-desktop`
+ pkgs.obsidian
+ pkgs.git
+ pkgs.gh # GitHub CLI
+ pkgs.glab # GitLab CLI
+ pkgs.qemu_kvm # only for the desktop app's Cowork tab
+ ];
+
+ # The desktop app keeps its sign-in in the system keyring; without one it
+ # asks you to log in on every launch.
+ services.gnome.gnome-keyring.enable = lib.mkDefault true;
+
+ # The desktop app's Cowork tab runs its tasks in a local VM, which needs KVM.
+ # If you don't use Cowork, delete these two lines and qemu_kvm above.
+ boot.kernelModules = [ "vhost_vsock" ];
+ users.groups.kvm.members = [ "daemonsec" ];
+
+ # Run Electron apps (Claude, Obsidian) natively on Wayland, e.g. on Hyprland.
+ environment.sessionVariables.NIXOS_OZONE_WL = lib.mkDefault "1";
+
+ # Optional: numtide's binary cache, so the Claude packages download instead
+ # of building a couple of small helper tools locally on each update.
+ # nix.settings.extra-substituters = [ "https://cache.numtide.com" ];
+ # nix.settings.extra-trusted-public-keys = [
+ # "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g="
+ # ];
+ }
+ ;
+}
diff --git a/modules/home/caelestia.nix b/modules/home/caelestia.nix
@@ -0,0 +1,262 @@
+# modules/home/caelestia.nix — Caelestia Shell as bar, launcher, notifications,
+# OSD, lock and session menu; its CLI; the static Rosé Pine (M3) schemes.
+#
+# Look (2026-10-08): Rosé Pine dark (main, not moon) everywhere, translucent
+# shell layers over blur, and Caelestia's own framed bar: the screen edge is a
+# 10 px frame with 25 px rounded inner corners, the clock and tray sit in pill
+# backgrounds, occupied workspaces are filled, the Nix snowflake is the logo.
+# The sidebar, utilities and notification panels are narrower than stock
+# (./caelestia/shell-tokens.json: 340 / 340 / 360 px instead of 430).
+# A Rosé Pine Dawn mapping is registered too: caelestia scheme set -n rose-pine -f rose-pine-dawn
+# and back: caelestia scheme set -n rose-pine -f rose-pine-dark
+#
+{ ... }:
+{
+ flake.homeModules.caelestia =
+ { config, pkgs, lib, inputs, osConfig, ... }:
+ let
+ system = pkgs.stdenv.hostPlatform.system;
+ hyprPkg = inputs.hyprland.packages.${system}.hyprland;
+
+ # The CLI knows schemes by name and re-reads their colours whenever the
+ # wallpaper changes, so the two hand-mapped Rosé Pine → M3 palettes are
+ # registered as a real scheme (name rose-pine; flavours rose-pine-dark, mode
+ # dark, and rose-pine-dawn, mode light). The CLI's own `rosepine/dawn` is a
+ # Material palette generated from a gold seed, not the Rosé Pine colours.
+ cli = (inputs.caelestia-cli.packages.${system}.default).overrideAttrs (old: {
+ patchPhase = (old.patchPhase or "") + ''
+ install -Dm644 ${./caelestia/rose-pine-dark.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dark/dark.txt
+ install -Dm644 ${./caelestia/rose-pine-dawn.txt} src/caelestia/data/schemes/rose-pine/rose-pine-dawn/light.txt
+ '';
+ });
+
+ shell =
+ ((inputs.caelestia-shell.packages.${system}.with-cli).override {
+ caelestia-cli = cli;
+ hyprland = hyprPkg; # hyprctl from the same Hyprland the compositor runs
+ }).overrideAttrs (old: {
+ # bar.activeWindow.compact shows the program (desktop-entry name for the
+ # window class) instead of the window title. Upstream's compact mode only
+ # trims the title at its last " - ". Rebuilds the shell locally.
+ patches = (old.patches or [ ]) ++ [ ./caelestia/active-window-program-name.patch ];
+ });
+
+ # ~/Pictures/Wallpapers/{rose-pine,rose-pine-dark,rose-pine-mid-walls}; the picker scans subfolders.
+ wallpaperDir = "${config.home.homeDirectory}/Pictures/Wallpapers";
+ in
+ # Only when the Hyprland desktop is switched on (modules/features/desktop/options.nix).
+ lib.mkIf osConfig.daemon.desktop.hyprland.enable {
+ programs.caelestia = {
+ enable = true;
+ package = shell;
+
+ # The module's default target is graphical-session.target, which Niri
+ # starts too (niri.service BindsTo it), so Caelestia would come up beside
+ # Noctalia in a Niri login. uwsm's Hyprland session target only exists
+ # under Hyprland.
+ systemd.target = "wayland-session@hyprland.desktop.target";
+
+ cli = {
+ enable = true;
+ package = cli;
+ # Theming is static Rosé Pine from this repo; the CLI must not rewrite
+ # kitty, GTK, Hyprland or anything else when the wallpaper changes.
+ settings.theme = {
+ enableTerm = false;
+ enableHypr = false;
+ enableDiscord = false;
+ enableSpicetify = false;
+ enablePandora = false;
+ enableFuzzel = false;
+ enableBtop = false;
+ enableNvtop = false;
+ enableHtop = false;
+ enableGtk = false;
+ enableQt = false;
+ enableWarp = false;
+ enableChromium = false;
+ enableZed = false;
+ enableCava = false;
+ };
+ };
+
+ settings = {
+ appearance = {
+ font = {
+ clock = "Rubik";
+ workspaces = "Rubik";
+ headline.family = "Noto Sans";
+ title.family = "Noto Sans";
+ body.family = "Noto Sans";
+ label.family = "Noto Sans";
+ mono.family = "DMMono Nerd Font";
+ };
+ anim.durations.scale = 1.15;
+ transparency = {
+ enabled = true;
+ base = 0.85;
+ layers = 0.4;
+ };
+ };
+ general = {
+ # The Nix snowflake in the bar, dashboard and lock screen (empty = Caelestia's own logo).
+ logo = "/run/current-system/sw/share/icons/hicolor/scalable/apps/nix-snowflake.svg";
+ apps = {
+ terminal = [ "kitty" ];
+ audio = [ "caelestia" "shell" "drawers" "toggle" "sidebar" ]; # was the Omarchy audio popup
+ playback = [ "mpv" ];
+ explorer = [ "nautilus" ];
+ };
+ idle = {
+ lockBeforeSleep = false;
+ inhibitWhenAudio = true;
+ # Lock after 15 min idle, screen off after 20; audio playing or a
+ # fullscreen app with an idle inhibitor holds both off.
+ timeouts = [
+ { timeout = 900; idleAction = "lock"; respectInhibitors = true; }
+ { timeout = 1200; idleAction = "dpms off"; returnAction = "dpms on"; }
+ ];
+ };
+ };
+ # Omarchy drew the wallpaper; here Caelestia does. Colours stay static.
+ background = {
+ enabled = true;
+ wallpaperEnabled = true;
+ # Big clock on the wallpaper, bottom right, with a soft shadow.
+ desktopClock = {
+ enabled = true;
+ position = "bottom-right";
+ scale = 1.0;
+ shadow.enabled = true;
+ };
+ # Audio visualiser along the bottom of the wallpaper while something plays (cava is built in).
+ visualiser = {
+ enabled = true;
+ autoHide = true;
+ blur = false;
+ rounding = 1;
+ spacing = 1;
+ };
+ };
+ bar = {
+ persistent = true;
+ showOnHover = true;
+ workspaces = {
+ shown = 5;
+ activeIndicator = true;
+ occupiedBg = true; # filled pills behind workspaces that have windows
+ showWindows = true;
+ activeTrail = true;
+ };
+ activeWindow = {
+ compact = true; # the program's name (patched, see `shell` above), not the title
+ inverted = true; # on a primary-coloured pill
+ };
+ clock = {
+ showDate = true;
+ showIcon = true;
+ background = true; # clock in its own pill
+ };
+ tray = {
+ background = true; # tray in its own pill
+ recolour = true; # tray icons tinted to the scheme
+ };
+ statusIcons = [
+ { id = "lockStatus"; enabled = true; }
+ { id = "audio"; enabled = true; }
+ { id = "microphone"; enabled = true; } # shows when something is capturing
+ { id = "kbLayout"; enabled = false; }
+ { id = "network"; enabled = true; }
+ { id = "bluetooth"; enabled = true; }
+ { id = "battery"; enabled = true; }
+ ];
+ };
+ # Caelestia's frame: the bar is one side of a border around the screen
+ # whose inner corners are rounded. Stock values; the carried 5/0/0 was a flat strip.
+ border = {
+ thickness = 10;
+ rounding = 25;
+ smoothing = 20;
+ };
+ dashboard = {
+ enabled = true;
+ showWeather = true;
+ performance.showGpu = true;
+ };
+ launcher = {
+ enabled = true;
+ maxShown = 8;
+ vimKeybinds = true; # ctrl+j/k move, like everywhere else here
+ actions = [
+ { name = "Calculator"; icon = "calculate"; description = "Do simple math equations (powered by Qalc)"; command = [ "autocomplete" "calc" ]; enabled = true; dangerous = false; }
+ { name = "Wallpaper"; icon = "image"; description = "Pick a wallpaper"; command = [ "caelestia" "wallpaper" ]; enabled = true; dangerous = false; }
+ { name = "Lock"; icon = "lock"; description = "Lock the session"; command = [ "caelestia" "shell" "lock" "lock" ]; enabled = true; dangerous = false; }
+ { name = "Sleep"; icon = "bedtime"; description = "Suspend"; command = [ "systemctl" "suspend" ]; enabled = true; dangerous = false; }
+ { name = "Settings"; icon = "settings"; description = "Configure the shell"; command = [ "caelestia" "shell" "nexus" "open" ]; enabled = true; dangerous = false; }
+ { name = "Logout"; icon = "exit_to_app"; description = "Log out of the current session"; command = [ "uwsm" "stop" ]; enabled = true; dangerous = true; }
+ { name = "Reboot"; icon = "cached"; description = "Reboot the system"; command = [ "systemctl" "reboot" ]; enabled = true; dangerous = true; }
+ { name = "Shutdown"; icon = "power_settings_new"; description = "Shutdown the system"; command = [ "systemctl" "poweroff" ]; enabled = true; dangerous = true; }
+ ];
+ };
+ lock = {
+ enabled = true; # Caelestia is the lock screen here (Omarchy had hyprlock)
+ useWallpaper = true; # the wallpaper behind the lock, not a flat colour
+ };
+ notifs = {
+ expire = true;
+ defaultExpireTimeout = 6000;
+ actionOnClick = true;
+ };
+ osd = {
+ enabled = true;
+ enableBrightness = true;
+ };
+ services = {
+ gpuType = "Generic"; # must be a string
+ smartScheme = false; # never derive colours from the wallpaper
+ defaultPlayer = "rmpc";
+ weatherLocation = "54.15,-4.48"; # Douglas, Isle of Man (dashboard weather card)
+ };
+ session = {
+ enabled = true;
+ icons.hibernate = "bedtime";
+ commands = {
+ logout = [ "uwsm" "stop" ];
+ shutdown = [ "systemctl" "poweroff" ];
+ hibernate = [ "systemctl" "suspend" ];
+ reboot = [ "systemctl" "reboot" ];
+ };
+ };
+ sidebar.enabled = true;
+ utilities = {
+ enabled = true;
+ toasts.nowPlaying = true; # a toast when the track changes
+ };
+ paths.wallpaperDir = wallpaperDir;
+ };
+ };
+
+ # The scheme the shell reads at start: Rosé Pine dark (scheme.json;
+ # scheme-dawn.json is the light mapping). `caelestia scheme set …` rewrites
+ # this file (home-manager backs the symlink up as .hm-bak when that happens).
+ home.file.".local/state/caelestia/scheme.json".source = ./caelestia/scheme.json;
+
+ # Internal size tokens: the shell reads this file (defaults in its source,
+ # plugin/src/Caelestia/Config/tokens.hpp: sidebar, utilities and
+ # notification width 430, bar innerWidth 40). Here: sidebar and utilities
+ # 340, notifications 360, bar 36. Rounding, padding and spacing stay stock.
+ home.file.".config/caelestia/shell-tokens.json".source = ./caelestia/shell-tokens.json;
+
+ # First wallpaper, only if none was ever chosen (Caelestia keeps the choice in state).
+ home.activation.caelestiaWallpaper = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
+ st="$HOME/.local/state/caelestia/wallpaper"
+ if [ ! -e "$st/path.txt" ]; then
+ wp=$(ls "${wallpaperDir}"/rose-pine-dark/*.png 2>/dev/null | head -1 || true)
+ if [ -n "$wp" ]; then
+ mkdir -p "$st" && printf '%s' "$wp" > "$st/path.txt" && ln -sfn "$wp" "$st/current"
+ fi
+ fi
+ '';
+ }
+ ;
+}
diff --git a/home/caelestia/active-window-program-name.patch b/modules/home/caelestia/active-window-program-name.patch
diff --git a/home/caelestia/rose-pine-dark.txt b/modules/home/caelestia/rose-pine-dark.txt
diff --git a/home/caelestia/rose-pine-dawn.txt b/modules/home/caelestia/rose-pine-dawn.txt
diff --git a/home/caelestia/scheme-dawn.json b/modules/home/caelestia/scheme-dawn.json
diff --git a/home/caelestia/scheme.json b/modules/home/caelestia/scheme.json
diff --git a/home/caelestia/shell-tokens.json b/modules/home/caelestia/shell-tokens.json
diff --git a/modules/home/cheats.nix b/modules/home/cheats.nix
@@ -0,0 +1,67 @@
+# modules/home/cheats.nix — the cheat cards this repo ships, in the house
+# style (~/.local/bin/*-cheat, rendered with the toolbox's cheat-render).
+# Every modules/home/cheats/<name>.md becomes a `<name>-cheat` command:
+#
+# nix-cheat rebuilding this machine: layout, nixos-rebuild, nh, add, desktop (Hyprland or Niri), secrets, repo
+# gpg-cheat GnuPG from the key hierarchy to signing, encryption, SSH, git, offline primary, fixes
+# niri-cheat Niri + Noctalia: every bind, Noctalia ipc, niri msg, wallpaper, settings, fixes
+#
+# <name>-cheat the whole card <name>-cheat --list the section names
+# <name>-cheat SECTION one section <name>-cheat --raw the markdown itself
+#
+# Rendered with cheat-render when that is on PATH, else glow, else printed
+# plain. These cards live here (not in the dotfiles) because they document
+# this repo and this machine; xcheats only lists ~/.local/bin cards, so call
+# these by name.
+{ ... }:
+{
+ flake.homeModules.cheats =
+ { pkgs, lib, ... }:
+ let
+ cards = [ "nix" "gpg" "niri" ];
+
+ mkCheat = name: pkgs.writeShellScriptBin "${name}-cheat" ''
+ set -uo pipefail
+ card=${./cheats + "/${name}.md"}
+
+ usage() {
+ echo "usage: ${name}-cheat [SECTION] [--list] [--raw] [--help]"
+ echo " sections: $(sections | tr '\n' ' ')"
+ }
+ sections() { # first word of each '## ' heading
+ ${pkgs.gnused}/bin/sed -n 's/^## \([a-z-]*\).*/\1/p' "$card"
+ }
+ section() { # the heading whose first word matches $1, up to the next heading
+ ${pkgs.gawk}/bin/awk -v want="$1" '
+ /^## / { on = (tolower($2) == want) }
+ /^# / { next }
+ on
+ ' "$card"
+ }
+ render() {
+ if [ ! -t 1 ]; then cat
+ elif command -v cheat-render >/dev/null 2>&1; then cheat-render - | ''${PAGER:-less -R}
+ else ${pkgs.glow}/bin/glow -p -
+ fi
+ }
+
+ case "''${1:-}" in
+ -h|--help) usage; exit 0 ;;
+ --list) sections; exit 0 ;;
+ --raw) cat "$card"; exit 0 ;;
+ "") render < "$card" ;;
+ *)
+ key=$(echo "$1" | tr '[:upper:]' '[:lower:]')
+ out=$(section "$key")
+ if [ -z "$out" ]; then
+ echo "${name}-cheat: no section '$1'" >&2; usage >&2; exit 1
+ fi
+ { head -1 "$card"; echo; printf '%s\n' "$out"; } | render ;;
+ esac
+ '';
+ in
+ {
+ home.packages = map mkCheat cards;
+ }
+ ;
+}
diff --git a/modules/home/cheats/gpg.md b/modules/home/cheats/gpg.md
@@ -0,0 +1,321 @@
+# gpg — the key hierarchy, and every verb
+
+GnuPG **2.4.9** here. Pinentry is the GNOME dialog (NixOS `programs.gnupg.agent`), `GPG_TTY` is exported by core.zsh,
+and **`gpgx`** (`gpgx-cheat`) is the no-flags wrapper for the daily lock/unlock/sign/verify. This card is the real thing underneath.
+Terminal card: **`gpg-cheat`** — sections `model setup keygen subkeys backup import sign verify encrypt decrypt edit revoke ssh git keyservers trust inspect offline agent fix mine`.
+`FPR` below = a full 40-hex fingerprint. Yours (the main key): `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`.
+
+## model — one primary key, several subkeys
+
+```text
+primary key [C] certify = the identity. Signs your own subkeys and user IDs, and other people's keys.
+ Keep it OFFLINE once the subkeys exist; it only comes out to add/revoke subkeys or UIDs.
+subkey [S] sign = signs files, commits, mail. Rotate yearly without touching the identity.
+subkey [E] encrypt = what others encrypt TO. Rotating it does not re-encrypt old mail; keep old ones to read it.
+subkey [A] authenticate = SSH login (gpg-agent as the ssh agent).
+user ID "Name <mail>" = one per address; the primary one is what people see first.
+```
+
+- Capabilities show in `gpg -K` as `[SC]`, `[E]`, `[A]`. A primary with `[SC]` signs with the identity key itself, which is fine but not rotatable.
+- The **fingerprint** (40 hex) is the key; the "key ID" is its last 16 (long) or 8 (short, collidable — never use).
+- `sec` = secret primary present. `sec#` = primary absent (offline), only subkeys here. `ssb` = secret subkey.
+- Ownertrust (how much you trust a key to vouch for others) is separate from validity (is this key really theirs). `[ultimate]` is your own.
+
+## setup — ~/.gnupg and the agent
+
+```sh
+# ~/.gnupg/gpg.conf (create it; sane modern defaults)
+keyid-format 0xlong
+with-fingerprint
+with-subkey-fingerprints
+default-key 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4
+default-recipient-self # `gpg -e file` encrypts to you when no -r given
+personal-cipher-preferences AES256 AES192 AES
+personal-digest-preferences SHA512 SHA384 SHA256
+cert-digest-algo SHA512
+no-emit-version
+no-comments
+keyserver hkps://keys.openpgp.org
+auto-key-locate local,wkd # find a key by mail address (WKD) before asking a keyserver
+trust-model tofu+pgp # remember first-seen keys per address, warn on change
+
+# ~/.gnupg/gpg-agent.conf
+default-cache-ttl 3600 # seconds a passphrase stays cached after last use
+max-cache-ttl 28800 # hard ceiling
+# pinentry-program is set by NixOS (modules/hosts/laptop/configuration.nix: pinentryPackage = pinentry-gnome3)
+```
+
+```sh
+chmod 700 ~/.gnupg # gpg refuses "unsafe permissions" otherwise
+gpg-connect-agent reloadagent /bye # after editing gpg-agent.conf
+gpgconf --kill gpg-agent # restart it entirely (it relaunches on demand)
+gpg --version # algorithms available
+```
+
+## keygen — a proper key, the modern way
+
+Certify-only primary, then three subkeys. Ed25519/Curve25519 throughout (fast, small, the 2.4 default).
+
+```sh
+gpg --quick-generate-key 'DAEMON-SEC <zer0sec.xp@icloud.com>' ed25519 cert 2y # primary: [C] only, 2-year expiry
+FPR=$(gpg -K --with-colons 'zer0sec.xp@icloud.com' | awk -F: '$1=="fpr"{print $10; exit}')
+gpg --quick-add-key "$FPR" ed25519 sign 1y # [S]
+gpg --quick-add-key "$FPR" cv25519 encr 1y # [E]
+gpg --quick-add-key "$FPR" ed25519 auth 1y # [A] (for SSH)
+gpg -K --with-subkey-fingerprints # see the four, check capabilities and expiries
+```
+
+- Expiry is a **safety net**, not a deadline: extend it any time with the primary key (see *edit*). A key that expires with its owner gone is self-cleaning.
+- Interactive version: `gpg --full-generate-key --expert` → `(11) ECC (set your own capabilities)` → toggle `S` off so only `Certify` remains → `Curve 25519`. Then add subkeys with `--quick-add-key` or in `--edit-key` → `addkey`.
+- A passphrase protects the secret key *file*; the agent caches it. Use a long one; the pinentry dialog asks.
+- The revocation certificate is written automatically to `~/.gnupg/openpgp-revocs.d/FPR.rev` — back it up with the key (see *backup*).
+
+## subkeys — add, rotate, drop
+
+```sh
+gpg --quick-add-key FPR ed25519 sign 1y # new signing subkey (old one stays until you revoke it)
+gpg --quick-set-expire FPR 1y '*' # extend EVERY subkey a year (`'*'` = all subkeys; a sub-fpr = that one)
+gpg --quick-set-expire FPR 2y # extend the primary
+gpg --edit-key FPR # interactive: `key 2` selects subkey 2 (the `*` marks it), then
+ # `expire` / `revkey` / `delkey` / `passwd` / `save`
+gpg -K --with-colons | awk -F: '$1=="ssb"{print $12, $5}' # capability letters + key id of each subkey
+```
+
+Old encryption subkeys: **keep them** (revoked or expired, still in the ring) or you lose the ability to read what was sent to them.
+
+## backup — export, revocation, paper
+
+```sh
+gpg --armor --export FPR > daemon-sec.pub.asc # public key: share freely
+gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # everything: primary + subkeys (passphrase-protected)
+gpg --armor --export-secret-subkeys FPR > daemon-sec.subkeys.asc # subkeys only (what a laptop should carry; see *offline*)
+cp ~/.gnupg/openpgp-revocs.d/FPR.rev daemon-sec.rev # the revocation certificate
+gpg --export-ownertrust > ownertrust.txt # your trust assignments
+gpg --gen-revoke FPR > daemon-sec.rev # make a fresh revocation cert by hand
+nix shell nixpkgs#paperkey -c sh -c 'gpg --export-secret-keys FPR | paperkey > daemon-sec.paper.txt' # printable secret bits
+```
+
+Where it goes: the vault (encrypted), never the dotfiles repo. The SECRET export + the .rev are the two files that matter.
+The secret export is still encrypted with your passphrase; the passphrase is the third thing to keep.
+
+## import — keys, trust, restore
+
+```sh
+gpg --import daemon-sec.pub.asc # someone's public key (or your own on a new machine)
+gpg --import daemon-sec.SECRET.asc # restore: asks for the passphrase
+gpg --import-ownertrust < ownertrust.txt
+gpg --edit-key FPR trust # set ownertrust: 5 = ultimate (yours), 4 = full, 3 = marginal
+gpg --lsign-key FPR # "I checked this key": local signature, never exported
+gpg --sign-key FPR # exportable certification (web of trust)
+gpg --show-keys someone.asc # look at a key file WITHOUT importing it
+gpg --delete-keys FPR # forget a public key; --delete-secret-keys for the secret part first
+```
+
+On a fresh machine: import the SECRET file, `gpg --edit-key FPR trust` → `5`, done. Or import only the subkeys file for a laptop.
+
+## sign — files, text, commits
+
+```sh
+gpg --detach-sign --armor file # file.asc beside it: the usual way to sign a release/file
+gpg --detach-sign file # binary file.sig
+gpg --clearsign message.txt # message.txt.asc: readable text with a signature block (mail, announcements)
+gpg --sign file # file.gpg: compressed file + signature in one (recipient runs gpg -d)
+gpg -u FPR --detach-sign file # pick the key (-u / --local-user); `FPR!` = that exact subkey
+echo "text" | gpg --clearsign # from a pipe
+gpg --sign --encrypt -r mail file # sign AND encrypt (see *encrypt*)
+```
+
+Signing uses the `[S]` subkey automatically (or the primary if it has S). The agent asks for the passphrase once per cache TTL.
+
+## verify — did this come from them, unchanged
+
+```sh
+gpg --verify file.asc file # detached signature (.asc/.sig) + the file
+gpg --verify file.sig # gpg finds `file` next to it
+gpg --verify message.txt.asc # clearsigned text
+gpg --decrypt file.gpg > file # inline-signed: extracts the file and prints the verification
+gpg --verify --verbose file.asc file # which key, which subkey, when
+```
+
+Reading the result: **Good signature from "Name <mail>"** = cryptographically valid. The warning
+`This key is not certified with a trusted signature` means you have not set ownertrust / signed their key
+— the signature is still valid; the question is whether the key is really theirs. Compare the fingerprint
+out-of-band once, then `gpg --lsign-key FPR` and the warning goes away.
+`BAD signature` = the file changed or the signature is for another file. Exit code 0 only on Good.
+
+## encrypt — to people, to yourself, with a passphrase
+
+```sh
+gpg --encrypt --recipient mail@example.com --armor file # file.asc, readable only by that key
+gpg -e -r mail@example.com -r zer0sec.xp@icloud.com file # several recipients (add yourself to read it later!)
+gpg -e file # to yourself (default-recipient-self in gpg.conf)
+gpg -se -r mail file # sign + encrypt: they know it is from you
+gpg --symmetric --cipher-algo AES256 file # passphrase only, no keys (file.gpg); shares via a channel you trust
+gpg -c --armor file # same, armored
+gpg -o out.gpg -e -r mail file # choose the output name
+tar cz directory | gpg -e -r mail > directory.tgz.gpg # a whole directory, through a pipe
+gpg --hidden-recipient mail -e file # do not reveal who it is for (-R)
+gpg --encrypt-to FPR # in gpg.conf: ALWAYS add this recipient (yourself) silently
+```
+
+- `--armor` (`-a`) makes ASCII text you can paste into mail; without it the output is binary (smaller).
+- Encrypt uses the recipient's `[E]` subkey. "No public key" = you have not imported theirs; "unusable public key" = theirs expired.
+- Symmetric + a strong passphrase is fine for backups and for sending to someone with no key.
+
+## decrypt — and what to do when it fails
+
+```sh
+gpg --decrypt file.gpg > file # -d: to stdout
+gpg -o file -d file.gpg # to a named file
+gpg file.gpg # guesses: decrypts (or verifies) and writes `file`
+gpg --decrypt-files *.gpg # many at once, each to its name without .gpg
+gpg -d file.gpg | tar xz # straight into tar
+gpg --list-packets file.gpg # WHO can decrypt this: the key IDs it was encrypted to, the algorithms
+```
+
+"decryption failed: No secret key" = it was not encrypted to any key you hold (check with `--list-packets`;
+compare with `gpg -K`). On a laptop with subkeys only, that is fine as long as the `[E]` subkey is there.
+
+## edit — identities, passphrase, expiry
+
+```sh
+gpg --quick-add-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>' # add a user ID (fix a name, add an address)
+gpg --quick-set-primary-uid FPR 'DAEMON-SEC <zer0sec.xp@icloud.com>'
+gpg --quick-revoke-uid FPR 'DAMEON-NIX <zer0sec.xp@icloud.com>' # retire a UID (keys cannot delete published UIDs; revoke them)
+gpg --change-passphrase FPR # new passphrase for the secret key
+gpg --quick-set-expire FPR 2y # primary expiry; `0` = never
+gpg --quick-set-expire FPR 1y '*' # all subkeys
+gpg --edit-key FPR # the interactive editor; `help` lists everything:
+# uid N / key N select · adduid deluid revuid primary · addkey delkey revkey expire · passwd · trust · save / quit
+```
+
+Every change to UIDs or subkeys needs the **primary** secret key present (`sec`, not `sec#`). Then re-export the public key: others must re-import it to see the change.
+
+## revoke — when a key is lost or compromised
+
+```sh
+gpg --import daemon-sec.rev # the stored revocation certificate: marks YOUR key revoked locally
+gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish the revocation so others see it
+gpg --edit-key FPR → key 2 → revkey → save # revoke ONE subkey (compromised laptop: revoke its subkeys, keep the primary)
+gpg --quick-revoke-uid FPR 'uid string' # revoke an identity
+```
+
+Revoked ≠ deleted: the key stays in the ring so old signatures still verify (as "made with a revoked key") and old mail still decrypts.
+If the laptop is lost and you kept the primary offline: revoke its subkeys, make new ones, publish. The identity survives.
+
+## ssh — the [A] subkey as your SSH key
+
+```sh
+# modules/hosts/laptop/configuration.nix: programs.gnupg.agent.enableSSHSupport = true; # gpg-agent becomes the ssh agent (SSH_AUTH_SOCK)
+gpg -K --with-keygrip # the keygrip of the [A] subkey
+echo KEYGRIP >> ~/.gnupg/sshcontrol # tell the agent to serve it
+gpg --export-ssh-key FPR # the public key in authorized_keys format
+gpg --export-ssh-key FPR >> ~/.ssh/authorized_keys_for_that_host # paste it where you log in
+ssh-add -L # the agent now lists it
+```
+
+Your file key `~/.ssh/id_ed25519` keeps working next to it; ssh tries agent keys first. Use one or the other per host in `~/.ssh/config` (`IdentitiesOnly yes` + `IdentityFile`).
+
+## git — signed commits and tags
+
+```sh
+git config --global gpg.format openpgp
+git config --global user.signingkey 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 # or a subkey fpr with `!` to force it
+git config --global commit.gpgsign true # every commit
+git config --global tag.gpgSign true
+git commit -S -m "msg" # one-off when gpgsign is off
+git tag -s v1.0 -m "release" # signed tag; git tag -v v1.0 verifies
+git log --show-signature -3 # see who signed what
+git verify-commit HEAD
+gpg --armor --export FPR | wl-copy # paste into GitHub/GitLab → Settings → GPG keys, so they show "Verified"
+```
+
+jj: `jj config set --user signing.behavior own` + `signing.backend "gpg"` + `signing.key FPR` signs the commits jj makes.
+
+## keyservers — publishing and finding keys
+
+```sh
+gpg --keyserver hkps://keys.openpgp.org --send-keys FPR # publish; keys.openpgp.org mails you to verify the address before it shows the UID
+gpg --keyserver hkps://keys.openpgp.org --recv-keys FPR # fetch by fingerprint (the only safe way to fetch)
+gpg --locate-keys someone@example.com # WKD: their domain serves the key (auto-key-locate in gpg.conf)
+gpg --search-keys 'name' # interactive search; verify the fingerprint with them before trusting
+gpg --refresh-keys # pull revocations/expiry updates for every key you hold
+```
+
+## trust — validity versus ownertrust
+
+- A signature is **valid** when the key is valid. A key is valid when *you* certified it (`--sign-key` / `--lsign-key`) or enough trusted people did.
+- **Ownertrust** (`--edit-key FPR trust`, 1-5) is how much you let that person's certifications count for *other* keys. Give 5 only to yourself.
+- `trust-model tofu+pgp`: the first key seen for an address is remembered; a different one later triggers a warning. `gpg --tofu-policy good FPR` to accept a change.
+- `gpg --check-signatures FPR` lists who certified a key. `gpg --update-trustdb` recomputes validity after trust changes.
+
+## inspect — what is this thing
+
+```sh
+gpg -k # public keys (--list-keys); gpg -K = secret keys
+gpg -k --with-subkey-fingerprints --with-keygrip FPR
+gpg --fingerprint FPR # just the fingerprint, formatted for reading aloud
+gpg --list-packets file.gpg # structure of any OpenPGP file: recipients, algorithms, signature dates
+gpg --show-keys key.asc # describe a key file without importing
+gpg -k --with-colons FPR # machine-readable (pub/sub/uid/fpr records)
+gpg --card-status # a YubiKey/OpenPGP card, if one is plugged in
+gpg --export FPR | gpg --list-packets | grep -A2 'signature packet' # certifications on your key
+```
+
+## offline — primary key off the laptop
+
+The point of subkeys: the laptop carries only `[S] [E] [A]`; the primary (the identity) lives on encrypted offline storage.
+
+```sh
+gpg --armor --export-secret-keys FPR > daemon-sec.SECRET.asc # 1. full backup first (vault, encrypted USB)
+gpg --armor --export-secret-subkeys FPR > subkeys.asc # 2. the laptop's share
+gpg --delete-secret-keys FPR # 3. remove everything secret here
+gpg --import subkeys.asc # 4. put the subkeys back
+gpg -K # shows `sec#` = primary absent, `ssb` present: correct
+```
+
+To add a subkey / extend expiry / sign someone's key later: import the SECRET backup in a temporary `GNUPGHOME`, do the change, export the public key and subkeys again, wipe the temp dir:
+```sh
+export GNUPGHOME=$(mktemp -d); gpg --import daemon-sec.SECRET.asc; gpg --quick-set-expire FPR 2y '*'
+gpg --armor --export FPR > pub.asc; gpg --armor --export-secret-subkeys FPR > subkeys.asc; rm -rf "$GNUPGHOME"; unset GNUPGHOME
+gpg --import pub.asc subkeys.asc # back in the normal ring
+```
+A YubiKey does the same with the subkeys moved onto the card: `gpg --edit-key FPR` → `key N` → `keytocard`.
+
+## agent — passphrase caching, pinentry
+
+```sh
+gpg-connect-agent 'KEYINFO --list' /bye # which keys are cached right now (the `1` column)
+gpg-connect-agent reloadagent /bye # re-read gpg-agent.conf
+gpgconf --kill gpg-agent # forget every cached passphrase now
+gpg --pinentry-mode loopback -d file.gpg # type the passphrase in the terminal instead of the dialog (what gpgx does)
+echo test | gpg --clearsign >/dev/null # cheap way to pre-unlock the key for a batch of operations
+```
+
+`export GPG_TTY=$(tty)` must be set for terminal pinentries (core.zsh does it). The dialog here is pinentry-gnome3.
+
+## fix — the usual errors
+
+- `Inappropriate ioctl for device` → `export GPG_TTY=$(tty)` (or the session has no tty: use `--pinentry-mode loopback`).
+- `No secret key` on decrypt → not encrypted to you: `gpg --list-packets file.gpg` shows the key IDs it was made for.
+- `unusable public key` / `unusable secret key` → expired or revoked (sub)key: `gpg -k FPR`, then `--quick-set-expire` (yours) or ask for a fresh key (theirs).
+- `There is no assurance this key belongs to the named user` → ownertrust: `gpg --lsign-key FPR` after checking the fingerprint, or `--trust-model always` for one command.
+- `gpg: WARNING: unsafe permissions on homedir` → `chmod 700 ~/.gnupg; chmod 600 ~/.gnupg/*`.
+- Commands hang → a stale lock or a stuck daemon: `gpgconf --kill all`, `rm -f ~/.gnupg/*.lock ~/.gnupg/public-keys.d/*.lock`.
+- `signing failed: No pinentry` → agent cannot reach a pinentry: `gpgconf --kill gpg-agent` and retry from a terminal with `GPG_TTY` set.
+- Git: `error: gpg failed to sign the data` → almost always the two above; `GIT_TRACE=1 git commit` shows gpg's own message.
+- Key shows `[expired]` on the other side after you extended it → they need your re-exported public key (or `--refresh-keys`).
+
+## mine — this machine, today
+
+- **Main key** `3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4`, UID `daemon (Main_Key) <zer0sec.xp@icloud.com>`,
+ RSA 4096 made 2025-12-30, imported from the vault on 2026-10-08: primary `[SC]` + one `[E]` subkey, **no expiry**,
+ ownertrust ultimate, `default-key` in gpg.conf, git signs with it (`commit.gpgsign`, `tag.gpgSign` on).
+ No revocation certificate is stored here yet: `gpg --gen-revoke 3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4 > daemon-main.rev`
+ and put it in the vault. Optional upgrades, all without changing the identity: an expiry (`--quick-set-expire`),
+ a `[S]` signing subkey and an `[A]` auth subkey (`--quick-add-key … rsa4096 sign|auth`), the primary offline (*offline*).
+- **Spare** `E989B40F2382569DD6080336BF684D91DF095E48` (`DAMEON-NIX`, ed25519, made 2026-10-08, never published):
+ delete it with `gpg --delete-secret-and-public-keys E989B40F2382569DD6080336BF684D91DF095E48`, or keep it as a scratch key.
+- Pinentry: GNOME dialog (modules/hosts/laptop/configuration.nix). `gpgx` uses loopback so the passphrase is typed in the terminal instead.
+- Secrets in the NixDaemon repo are **age**, not gpg (`nix-cheat secrets`); the vault's `ssd.key.gpg` is gpg-encrypted, decrypt it with `gpg -d`.
+- `gpgx-cheat` is the wrapper's card; `gpg-cheat SECTION` prints one section of this one.
diff --git a/modules/home/cheats/niri.md b/modules/home/cheats/niri.md
@@ -0,0 +1,196 @@
+# niri — Niri + Noctalia, every key and command
+
+Niri **26.04** (scrolling columns: windows sit in columns on an endless strip, workspaces stack vertically) with
+**Noctalia 4.7.7** as bar, launcher, notifications, lock screen and wallpaper. Both are wrapped packages built from
+`modules/features/desktop/niri.nix` and `noctalia.nix`; Rosé Pine Main throughout.
+Terminal card: **`niri-cheat`** — sections `model apps shell windows move resize workspaces media screenshots ipc msg wallpaper settings session fix`.
+`Mod` = **Super** in a real login, **Alt** when nested via `nix run ~/NixDaemon#niri`.
+
+## model — columns, windows, workspaces
+
+```text
+workspace one per row; Mod+1..9 or the mouse wheel moves between them. Always one empty one at the bottom.
+column the unit you scroll through left/right. New windows open as a new column to the right.
+window a column holds one or more windows stacked vertically (Mod+J/K moves between them).
+overview Mod+O zooms out over every workspace; click or arrow to pick, Mod+O again to leave.
+```
+
+- Nothing is ever "off screen" in a bad way: the strip just scrolls. Mod+H/L to walk it.
+- Column widths come from presets (⅓, ½, ⅔); Mod+F maximises one column, Mod+G is true fullscreen.
+
+## apps — launch
+
+```text
+Mod+Return kitty
+Mod+Shift+Return firefox Mod+Shift+B firefox
+Mod+Shift+Alt+B firefox private window
+Mod+Shift+F nautilus (new window)
+Mod+Shift+O obsidian
+Mod+Shift+N kitty running $EDITOR (nano if unset)
+```
+
+## shell — Noctalia keys
+
+```text
+Mod+Space launcher (apps; type > for commands) Mod+Alt+Space same
+Mod+Ctrl+V launcher → clipboard history (cliphist)
+Mod+A control centre (wifi, bluetooth, volume, settings gear)
+Mod+Escape session menu (lock / suspend / logout / reboot / shutdown) Mod+Ctrl+P same
+Mod+Shift+Escape lock now
+Mod+Comma clear notifications
+Mod+Ctrl+Space wallpaper picker
+right-click the bar Noctalia settings
+```
+
+## windows — focus and state
+
+```text
+Mod+H / Mod+Left focus column left
+Mod+L / Mod+Right focus column right
+Mod+J / Mod+Down focus window below (same column)
+Mod+K / Mod+Up focus window above
+Mod+Q close window
+Mod+F maximise column (toggle)
+Mod+G fullscreen window (toggle)
+Mod+Shift+V toggle floating
+Mod+O overview
+Mod+Shift+/ niri's own hotkey overlay (the full live list)
+```
+
+Focus follows the mouse.
+
+## move — rearrange
+
+```text
+Mod+Shift+H / Shift+Left move column left
+Mod+Shift+L / Shift+Right move column right
+Mod+Shift+J / Shift+Down move window down (within / out of the column)
+Mod+Shift+K / Shift+Up move window up
+Mod+Shift+1..9 move column to workspace N
+```
+
+## resize — width and height
+
+```text
+Mod+Ctrl+H column width −5% Mod+Ctrl+L column width +5%
+Mod+Ctrl+J window height −5% Mod+Ctrl+K window height +5%
+```
+
+Not bound (yet): cycling the ⅓/½/⅔ presets. `niri msg action switch-preset-column-width` does it from a shell.
+
+## workspaces
+
+```text
+Mod+1..9 focus workspace N
+Mod+Shift+1..9 move focused column to workspace N
+Mod+WheelDown / Up next / previous workspace (150 ms cooldown)
+```
+
+## media — hardware keys (also work on the lock screen)
+
+```text
+XF86AudioRaiseVolume / Lower volume ±5% (cap 140%) XF86AudioMute / MicMute toggle
+XF86MonBrightnessUp / Down brightness ±5%
+XF86AudioPlay / Pause play-pause XF86AudioNext / Prev track
+```
+
+## screenshots
+
+```text
+Print select a region → clipboard (grim + slurp)
+Shift+Print whole screen → clipboard
+```
+
+Save the clipboard to a file: `wl-paste > ~/Pictures/shot.png`.
+
+## ipc — drive Noctalia from a shell
+
+The wrapped Noctalia is not on PATH yet, so go through the flake:
+
+```sh
+noct() { nix run ~/NixDaemon#noctalia -- ipc call "$@"; } # put in a zsh file to keep it
+
+noct settings toggle # settings window noct settings openTab <tab>
+noct launcher toggle # also: clipboard emoji windows command
+noct controlCenter toggle
+noct sessionMenu toggle # also: lock lockAndSuspend
+noct lockScreen lock
+noct notifications toggleHistory # also: toggleDND clear dismissAll
+noct idleInhibitor toggle # keep the screen awake enableFor 3600
+noct nightLight toggle
+noct darkMode toggle # setDark / setLight
+noct volume increase # decrease muteOutput muteInput togglePanel
+noct brightness set 50
+noct wifi toggle # bluetooth toggle airplaneMode toggle
+noct powerProfile cycle # set performance|balanced|power-saver
+noct media playPause # next previous seekRelative 10
+noct bar toggle # hideBar / showBar
+noct systemMonitor toggle
+noct calendar toggle
+noct state all # dump the shell's live state (JSON)
+```
+
+## msg — drive Niri from a shell
+
+```sh
+niri msg outputs # monitors, modes, scale
+niri msg workspaces # list, with the focused one marked
+niri msg windows # every window: id, app-id, title, workspace
+niri msg focused-window
+niri msg pick-window # click a window, get its details (for window rules)
+niri msg action <action> [args] # any bindable action, e.g.
+niri msg action focus-workspace 3
+niri msg action set-column-width 50%
+niri msg action switch-preset-column-width
+niri msg action spawn -- kitty
+niri msg -j windows | jq # JSON output for scripts
+niri validate -c file.kdl # check a config file
+```
+
+## wallpaper — Rosé Pine
+
+Wallpapers are in `~/Pictures/Wallpapers/{rose-pine,rose-pine-dark,rose-pine-mid-walls}`.
+Noctalia's picker (Mod+Ctrl+Space) and Caelestia's on Hyprland both read that folder and its subfolders
+(`wallpaper.directory` in `noctalia.nix`, `wallpaperDir` in `caelestia.nix`).
+
+```sh
+noct wallpaper toggle # the picker
+noct wallpaper random "" # random from the configured folder
+noct wallpaper set ~/Pictures/Wallpapers/rose-pine/koi-fish.png "" # any file ("" = all screens)
+noct wallpaper get ""
+```
+
+## settings — making a change stick
+
+- **A Niri bind or option**: `modules/features/desktop/niri.nix` → `settings` (validated at build; a typo fails `nix build .#niri`).
+- **A Noctalia setting**: change it in the GUI (lasts the session only; settings live in the store),
+ run `dump-noctalia-shell`, paste the differing keys into `noctalia.nix` → `settings`.
+- Then `nh os switch`. A running Niri keeps its old config until re-login, or reload it now:
+
+```sh
+niri msg action load-config-file --path "$(nix eval --raw ~/NixDaemon#niri)/niri-config.kdl"
+```
+
+## session — logging in and out
+
+```text
+tuigreet: F2 → session list → "niri" (it remembers the last one)
+Mod+Escape → Logout or Mod+Shift+E (niri asks to confirm)
+back to Hyprland: log out, pick "Hyprland (UWSM)"
+nix run ~/NixDaemon#niri nested test inside another desktop (Alt = Mod)
+```
+
+Caelestia never starts under Niri (its unit is tied to the Hyprland session). Idle: lock at 10 min, screen off at 20, never suspends.
+
+## fix — when something is off
+
+```sh
+systemctl --user list-units --state=active --no-legend | grep -E 'caelestia|polkit|cliphist|udiskie'
+ # expect polkit, cliphist, udiskie — and NO caelestia
+pkill -f noctalia; niri msg action spawn -- $(nix build --no-link --print-out-paths ~/NixDaemon#noctalia)/bin/noctalia-shell
+ # restart a crashed/hung bar
+journalctl --user -b | grep -i niri # niri's log for this boot
+Ctrl+Alt+F2 → log in → nh os rollback # can't log in at all
+```
+
+- "What's new" panel on first login: Noctalia's changelog, dismiss once and it's remembered (`~/.cache/noctalia/shell-state.json`).
diff --git a/modules/home/cheats/nix.md b/modules/home/cheats/nix.md
@@ -0,0 +1,266 @@
+# nix — rebuilding this machine from ~/NixDaemon
+
+NixOS **and** home-manager are one flake: `~/NixDaemon` (`NH_FLAKE` points at it).
+home-manager runs as a NixOS module, so **one rebuild does both**; there is no separate `home-manager switch` here.
+Terminal card: **`nix-cheat`** — sections `layout rebuild remote nh home search update rollback clean inspect add desktop dotfiles secrets repo shell`.
+
+## layout — what lives where
+
+Dendritic: `flake.nix` is `flake-parts.lib.mkFlake … (import-tree ./modules)`; every `*.nix` under `modules/` is a
+flake-parts module that declares what it owns, and modules name each other through `self` (never by path).
+
+```text
+~/NixDaemon/flake.nix inputs only · outputs = import-tree ./modules
+modules/parts.nix systems, the home-manager + wrapper-modules flake modules
+modules/hosts/laptop/default.nix flake.nixosConfigurations.nixos (modules = [ self.nixosModules.laptop ])
+modules/hosts/laptop/configuration.nix nixosModules.laptop: imports laptop-* + features by name; daemon.desktop.* switches
+modules/hosts/laptop/*.nix nixosModules.laptop-<name>: hardware, nvidia, ssd, nix-settings (nh), sops, toolbox, fan-*
+modules/features/workstation.nix nixosModules.workstation (Claude, Obsidian, gh, glab)
+modules/features/home-manager.nix nixosModules.home-manager: HM as a NixOS module → self.homeModules.daemonsec
+modules/features/desktop/options.nix daemon.desktop.hyprland.enable · daemon.desktop.niri.enable
+modules/features/desktop/niri.nix packages.niri (wrapped) + nixosModules.desktop-niri
+modules/features/desktop/noctalia.nix packages.noctalia (wrapped, Rosé Pine)
+modules/home/default.nix homeModules.daemonsec: imports every homeModules.<name>
+modules/home/tools.nix packages: the toolbox closure and the general CLI tools ← add packages here
+modules/home/dotfiles.nix every dotfile from ~/git/daemon-sec-dotfiles, as symlinks ← add dotfile paths here
+modules/home/hyprland.nix, hypr/*.lua Hyprland config + helpers (gated on daemon.desktop.hyprland)
+modules/home/caelestia.nix the Caelestia shell, its CLI, wallpaper dir (same gate)
+modules/hosts/laptop/sops.nix sops-nix (system) · modules/home/sops.nix (user) · secrets/secrets.yaml · .sops.yaml
+```
+
+## rebuild — nixos-rebuild, the plain way
+
+```sh
+cd ~/NixDaemon
+sudo nixos-rebuild switch --flake .#nixos # build, activate now, make it the boot default
+sudo nixos-rebuild boot --flake .#nixos # build, activate on next boot only (kernel/driver changes)
+sudo nixos-rebuild test --flake .#nixos # activate now, NOT the boot default (try something out)
+nixos-rebuild build --flake .#nixos # just build → ./result, no sudo, nothing changes
+sudo nixos-rebuild switch --flake ~/NixDaemon#nixos # same, from any directory
+```
+
+- `#nixos` is the configuration name (= hostname) and the only one in the flake (the old `#bootstrap` stage is gone).
+- **New files must be known to git** or the flake does not see them: `git add path` first (see *repo*).
+- A rebuild activates home-manager too; its log: `journalctl --user -u home-manager-daemonsec -b` or `systemctl status home-manager-daemonsec`.
+
+## remote — build straight from the GitLab repo
+
+The flake does not have to be checked out: any `nix` command takes a **flake reference** to the repo.
+The repo is **private**, so use the ssh form (the key in ~/.ssh/config is registered on GitLab); `?ref=main` pins a branch, `?rev=<sha>` a commit.
+
+```sh
+REPO='git+ssh://git@gitlab.com/DAEMON-404/NixDaemon.git'
+sudo nixos-rebuild switch --flake "$REPO#nixos" # this machine, from the pushed main
+sudo nixos-rebuild boot --flake "$REPO?ref=main#nixos"
+nh os switch "$REPO" # nh takes the same reference
+nix build "$REPO#nixosConfigurations.nixos.config.system.build.toplevel" --no-link --print-out-paths
+nix flake show "$REPO" # what the repo exports
+nix flake metadata "$REPO" # which commit nix resolved
+# root (sudo) fetches with root's ssh: either run the fetch as you first (nix build …, cached), or give
+# root the key via /root/.ssh/config, or use an https token in ~/.config/nix/netrc (machine gitlab.com …).
+```
+
+**Fresh machine, from the live ISO** (disk already partitioned and mounted at /mnt):
+```sh
+sudo nixos-generate-config --root /mnt --show-hardware-config > hardware-configuration.nix # compare with modules/hosts/laptop/hardware.nix
+git clone git@gitlab.com:DAEMON-404/NixDaemon.git ~/NixDaemon && cd ~/NixDaemon # ssh key first (see *secrets*)
+# paste its body into modules/hosts/laptop/hardware.nix (inside the laptop-hardware wrapper; a raw
+# hardware-configuration.nix under modules/ would be loaded by import-tree as a flake-parts module), then:
+sudo nixos-install --flake .#nixos
+```
+Afterwards: clone `~/git/daemon-sec-dotfiles` (every dotfile links there), restore the age key to
+`~/.config/sops/age/keys.txt` and `/var/lib/sops-nix/key.txt` (see *secrets*), and the toolbox to `~/.local/bin`.
+
+A remote reference is fetched fresh each time (nix caches it briefly, `--refresh` forces it); the local
+checkout at `~/NixDaemon` (`NH_FLAKE`) is the default for day-to-day work.
+
+## nh — the same, with a diff and a progress tree
+
+`nh` wraps nixos-rebuild: builds with nix-output-monitor, shows an **nvd diff** of what changes, then asks for sudo.
+
+```sh
+nh os switch # = sudo nixos-rebuild switch --flake ~/NixDaemon (NH_FLAKE), config picked by hostname
+nh os boot # build + boot default, activate on reboot
+nh os test # activate now, not the boot default
+nh os build # build only, no activation, no sudo
+nh os switch --dry # show what would happen, do nothing
+nh os switch --ask # show the diff, then confirm before activating
+nh os switch -H nixos # pick a configuration by name (-H = hostname/attr); this flake has one
+nh os switch -u # `nix flake update` first, then switch (updates EVERY input — see *update*)
+nh os info # list system generations
+nh os rollback # go back one generation (see *rollback*)
+nh os switch -- --show-trace # anything after -- goes to nix build (debug an eval error)
+```
+
+## home — home-manager in this setup
+
+- home-manager is **inside** the NixOS build (modules/features/home-manager.nix: user `daemonsec` → `self.homeModules.daemonsec`, i.e. modules/home/).
+ **`nh os switch` rebuilds it**; `nh home switch` / `home-manager switch` do not apply here (no standalone HM profile).
+- Did my home change apply? `ls -l ~/.config/<thing>` (links point into /nix/store or the dotfiles checkout),
+ `systemctl --user status home-manager-daemonsec`, or `journalctl --user -u home-manager-daemonsec -b`.
+- HM backs up a file it has to replace as `*.hm-bak` (`backupFileExtension` in modules/features/home-manager.nix). Delete the backup once happy.
+- Only build the home part (fast check, no sudo):
+ `nix build ~/NixDaemon#nixosConfigurations.nixos.config.home-manager.users.daemonsec.home.activationPackage`
+
+## search — finding packages and options
+
+```sh
+ns # fuzzy search everything indexed: nixpkgs packages, NixOS and home-manager options
+ns kitty # start with a query
+```
+Inside `ns` (fzf): type to filter, ↑↓ move, the right pane shows the description, version, homepage and
+the option's default. **Enter** prints the attribute name (paste it into `home.packages` in tools.nix),
+**ctrl-y** copies it, **ctrl-o** opens the homepage, **ctrl-s** the nixpkgs source, **Esc** quits.
+The index is built by `nix-search-tv` on first use (a few seconds) and refreshed by itself; by hand:
+```sh
+nix-search-tv print | head # the raw list · nix-search-tv preview firefox # one entry
+nh search firefox # search.nixos.org from the terminal (needs network)
+nix search nixpkgs firefox # nix's own search (slow first run: builds an eval cache)
+nix eval ~/NixDaemon#nixosConfigurations.nixos.pkgs.firefox.version # version THIS flake would install
+nix shell nixpkgs#firefox # try it without installing · nix run nixpkgs#cowsay -- hi
+```
+
+## update — moving the inputs
+
+```sh
+cd ~/NixDaemon
+nix flake update # all inputs → flake.lock (nixpkgs, home-manager, hyprland, caelestia, llm-agents)
+nix flake update nixpkgs home-manager # only these inputs
+nix flake update hyprland # Hyprland alone (uses hyprland.cachix.org, so usually no compile)
+nix flake lock # (re)write the lock without updating
+nix flake metadata # which revisions are locked right now
+nh os boot # then build it; boot = safest for kernel/driver bumps
+```
+
+Hyprland and Caelestia pin each other (flake.nix comments): update `hyprland` and `caelestia-*` together if the shell breaks.
+
+## rollback — when a generation misbehaves
+
+```sh
+nh os rollback # previous generation, now
+sudo nixos-rebuild switch --rollback # the same, plain
+nh os info # generation numbers
+sudo nix-env --switch-generation 5 -p /nix/var/nix/profiles/system && sudo /nix/var/nix/profiles/system/bin/switch-to-configuration switch
+```
+
+- At power-on the **systemd-boot menu** lists every generation: pick an older one, it boots as it was.
+- A broken Hyprland login: Ctrl+Alt+F2 for a console, then one of the above.
+
+## clean — store and generations
+
+```sh
+nh clean all --keep 5 --keep-since 14d # what the weekly timer runs (nix-settings.nix): drop old generations, GC
+nh clean all --dry # show what it would remove
+sudo nix-collect-garbage -d # the blunt version: delete all old generations, then GC
+nix store gc # GC only (nothing referenced by a generation is touched)
+du -sh /nix/store # how big is it
+```
+
+## inspect — see before you switch
+
+```sh
+nh os build && nvd diff /run/current-system result # what a switch would change
+nix build ~/NixDaemon#nixosConfigurations.nixos.config.system.build.toplevel --no-link --print-out-paths
+nix eval ~/NixDaemon#nixosConfigurations.nixos.pkgs.uv.version # version a package would get
+nix eval ~/NixDaemon#nixosConfigurations.nixos.config.programs.zsh.enable # read an option value
+ns <query> # fuzzy search nixpkgs + NixOS/HM options, with descriptions (nix-search-tv)
+nh search <package> # nixpkgs search (search.nixos.org)
+nix search nixpkgs <package> # local search (first run builds an index)
+nix shell nixpkgs#<package> # try a tool without installing it
+nix run nixpkgs#<package> -- --help
+nix flake check ~/NixDaemon # evaluate every output
+nix flake show ~/NixDaemon
+```
+
+## add — packages, options, dotfiles, modules
+
+- **A package for the user**: `modules/home/tools.nix` → `home.packages` list → `nh os switch`.
+- **A system package / service**: `modules/hosts/laptop/configuration.nix` (`environment.systemPackages`, `services.*`).
+- **A dotfile from the checkout**: `modules/home/dotfiles.nix` → add its path to the list → `nh os switch`.
+- **A new home module**: `modules/home/<name>.nix` declaring `flake.homeModules.<name> = { pkgs, ... }: { … };`,
+ then `<name>` in the imports list of `modules/home/default.nix`. A NixOS one: `flake.nixosModules.<name>` in
+ `modules/features/<name>.nix`, named in `configuration.nix`. `scripts/wrap.sh FILE ATTR` wraps a plain module file.
+- **A Hyprland bind**: `modules/home/hypr/bindings.lua` (user) or `defaults.lua` (stock); a helper script goes in `hyprland.nix`.
+- **A Niri bind**: `modules/features/desktop/niri.nix` → `settings.binds` (validated at build: a typo fails `nix build .#niri`).
+- **A Caelestia setting**: `modules/home/caelestia.nix` → `programs.caelestia.settings`.
+- **A Noctalia setting**: tweak it in the GUI, `dump-noctalia-shell`, paste the differing keys into `noctalia.nix` → `settings`.
+- Then: `git add` anything new, `nh os switch`, and for Hyprland `hyprctl reload`. A running Niri keeps its old
+ store config until you log in again, or: `niri msg action load-config-file --path "$(nix eval --raw ~/NixDaemon#niri)/niri-config.kdl"`.
+
+## desktop — Hyprland + Caelestia or Niri + Noctalia
+
+Both are installed; tuigreet lists both sessions and remembers the last one, so switching is log out → pick the other.
+
+```sh
+nix run ~/NixDaemon#niri # try Niri nested in the current desktop (Alt is the modifier); Noctalia starts inside it
+nix run ~/NixDaemon#noctalia # the bar alone
+```
+```nix
+# modules/hosts/laptop/configuration.nix — set one to false and `nh os switch` to drop it entirely
+daemon.desktop = { hyprland.enable = true; niri.enable = true; };
+```
+
+## dotfiles — the checkout is the source of truth
+
+- `~/git/daemon-sec-dotfiles/home/` is mirrored into `$HOME` by **symlinks** (dotfiles.nix): edit a file there and the live config changes at once — no rebuild.
+- A rebuild is needed only to add/remove a *path* in dotfiles.nix. `ls -l ~/.config/starship.toml` shows where a link goes.
+- zsh: `~/.dotfiles/config/*.zsh` → open a new shell (`exec zsh`). Plugins are vendored in `~/.dotfiles/config/plugins`.
+- Not linked on purpose (hypr, kitty, git, mimeapps, bash rc, .claude …): the list and reasons are at the top of dotfiles.nix.
+
+## secrets — sops-nix and secretspec
+
+Two tools, two jobs. **sops-nix**: secrets that belong in this repo, encrypted with **age**, decrypted at
+activation to `/run/secrets/NAME` (system) or `~/.config/sops-nix/secrets/NAME` (user).
+**secretspec**: runtime secrets for a *project*, declared in its `secretspec.toml`, stored in the keyring.
+
+```sh
+# sops — the one key: ~/.config/sops/age/keys.txt (user) = /var/lib/sops-nix/key.txt (system, root copy)
+sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt # once per machine
+age-keygen -y ~/.config/sops/age/keys.txt # the public key (recipient) in .sops.yaml
+sops secrets/secrets.yaml # edit: opens decrypted in $EDITOR, re-encrypts on save
+sops set secrets/secrets.yaml '["wifi-psk"]' '"hunter2"' # add/replace one value without the editor
+sops -d secrets/secrets.yaml # print decrypted
+sops -d --extract '["example"]' secrets/secrets.yaml
+sops updatekeys secrets/secrets.yaml # after editing the recipients in .sops.yaml
+```
+
+Then declare it and rebuild:
+```nix
+# modules/hosts/laptop/sops.nix (system) # modules/home/sops.nix (user)
+sops.secrets.wifi-psk = { }; sops.secrets.my-token = { };
+sops.secrets."ssd.key".path = "/etc/secrets/ssd.key"; # e.g. the LUKS key instead of restoring it by hand
+```
+`nh os switch` → `/run/secrets/wifi-psk` (root, 0400; `owner = "daemonsec";` to read it as the user).
+Check: `sudo ls -l /run/secrets/`, `systemctl --user status sops-nix` (user side), `journalctl -b | grep sops`.
+
+```sh
+# secretspec — in a project directory (config: ~/.config/secretspec/config.toml → keyring, profile default)
+secretspec init # writes secretspec.toml (commit it; it holds names, never values)
+secretspec add DATABASE_URL # declare a secret (flags: --required/--default/--description)
+secretspec check # prompts for every missing value, stores it in the keyring
+secretspec set NAME # (re)store one value
+secretspec run -- ./server # run with the secrets in the environment
+secretspec export # print them for another tool (shell `eval`)
+secretspec claude configure # let Claude Code fetch its API credential through secretspec
+```
+
+## repo — committing ~/NixDaemon
+
+The repo is **jj, colocated with git** (`.jj` + `.git`). Nix flakes only see files git knows about, and jj keeps git's index at `@-`, so a *new* file is invisible to nix until it is added:
+
+```sh
+cd ~/NixDaemon
+git add modules/home/new.nix # make nix see a new file (modified tracked files are seen as-is)
+nh os build # or switch
+jj status # jj snapshots the working copy
+jj describe -m "what changed" && jj new # seal it (the vault ritual) — or plain: git add -A && git commit
+jj log # history
+```
+
+A dirty tree only prints `warning: Git tree '/home/daemonsec/NixDaemon' is dirty`; it still builds.
+
+## shell — after a switch
+
+- New packages are on PATH in a **new** shell (`exec zsh`); the login shell itself changes at the next login.
+- Hyprland: `hyprctl reload` re-reads `~/.config/hypr/*.lua`. Caelestia: `caelestia shell -r` restarts the shell.
+- Everything in the Nix profile: `ls /etc/profiles/per-user/daemonsec/bin`.
diff --git a/modules/home/default.nix b/modules/home/default.nix
@@ -0,0 +1,41 @@
+# modules/home/default.nix — the user's home-manager configuration: every
+# home module in this directory, by name. Imported by the NixOS side in
+# modules/features/home-manager.nix (home-manager runs as a NixOS module;
+# `nh os switch` applies it, there is no standalone profile).
+{ self, ... }:
+{
+ flake.homeModules.daemonsec =
+ { user, ... }:
+ {
+ imports = with self.homeModules; [
+ hyprland # compositor config (Lua) and the carried shortcuts — only with daemon.desktop.hyprland
+ caelestia # programs.caelestia, shell.json, the Rosé Pine scheme — only with daemon.desktop.hyprland
+ session # polkit agent, cliphist, udiskie, screenshot and clipboard tools — for both desktops
+ terminal # kitty, fonts
+ tools # toolbox runtime closure, python env, dotfiles links
+ shell # zsh via the dotfiles ZDOTDIR tree, tmux plugins, secretspec
+ dotfiles # every dotfile from ~/git/daemon-sec-dotfiles, as out-of-store symlinks
+ cheats # nix-cheat: the rebuild / nh / flake card
+ sops # sops-nix for the user (same secrets file, age key in ~/.config/sops/age)
+ neovim # nvf: Neovim with a small, Nix-built plugin set
+ prompt # starship prompt and fastfetch card, Rosé Pine, NixOS logo
+ fan # `fan`: status/watch without root, max/auto with a clamp watchdog
+ ssh # the ssh key (sops) and ~/.ssh/config
+ gpg # the GPG main key (public in-repo, secret via sops) and gpg.conf
+ git # git identity, signing with the main key, delta
+ media # mpd + rmpc, mpv
+ yazi # yazi with previews, Rosé Pine, plugins
+ gtk # Yaru-purple icons, cursor, prefer-dark
+ ];
+
+ home = {
+ username = user;
+ homeDirectory = "/home/${user}";
+ stateVersion = "26.05";
+ };
+
+ programs.home-manager.enable = true;
+ xdg.enable = true;
+ }
+ ;
+}
diff --git a/modules/home/dotfiles.nix b/modules/home/dotfiles.nix
@@ -0,0 +1,119 @@
+# modules/home/dotfiles.nix — every dotfile from the dotfiles checkout, placed
+# by home-manager.
+#
+# ~/git/daemon-sec-dotfiles is the restorable snapshot of the Omarchy
+# workstation (its README: "files restored relative to $HOME"). home-manager
+# puts each of its files where it belongs as an out-of-store symlink
+# (mkOutOfStoreSymlink), so there is one source of truth: edit the checkout and
+# the live config changes; `nh os switch` is only needed when a path is added
+# or removed here. The links dangle harmlessly until the repo is cloned.
+#
+# Not linked, and why (each is one line to add if wanted):
+# .config/hypr, .config/kitty Nix-managed (modules/home/hyprland.nix, terminal.nix)
+# .config/nvim the AstroNvim tree; Neovim is nvf now (modules/home/neovim.nix)
+# .config/starship.toml, .config/fastfetch the Omarchy-era prompt and fetch; both are
+# Nix-managed now (modules/home/prompt.nix)
+# .config/mpd, rmpc, mpv, yazi Nix-managed (modules/home/media.nix, yazi.nix), carried from the checkout
+# .config/omarchy*, Omacom, hyprmoncfg Omarchy's own trees; caelestia.nix reads the
+# wallpaper directory straight from the checkout
+# .config/systemd/user Omarchy-era units; caelestia-shell.service there would
+# fight the home-manager caelestia service
+# .config/autostart Omarchy autostarts for apps not installed here
+# .config/mimeapps.list defaults point at chromium / HEY, neither installed:
+# xdg-open would fail on every link
+# .config/git turns on commit signing with a key not on this machine
+# .config/fontconfig, dconf, gtk-4.0 home-manager writes these (fonts.fontconfig, gtk.nix)
+# .config/gtk-3.0/bookmarks paths under the old /home/daemon-sec
+# .config/user-dirs.dirs, floorp XDG defaults already match; a browser profile is state
+# .config/tmux holds only a disabled backup; ~/.tmux.conf is the config
+# .bashrc, .bash_profile, .bash_logout source Omarchy's bash rc unguarded (errors on NixOS)
+# .zshrc, .zprofile the dead decoys; ZDOTDIR=~/.dotfiles bypasses them
+# .XCompose includes /usr/share/omarchy/default/xcompose
+# .claude, .codex, .agents live agent state on this machine (plugins, sessions)
+# bin, .local/bin ~/.local/bin is the live toolbox repo (README)
+# .local/share/applications Omarchy web-app launchers (omarchy-launch-webapp)
+# .local/share/icons/hicolor apps install into it; the themes are linked one by one
+{ ... }:
+{
+ flake.homeModules.dotfiles =
+ { config, lib, ... }:
+ let
+ repo = "${config.home.homeDirectory}/git/daemon-sec-dotfiles";
+ home = "${repo}/home";
+ link = path: config.lib.file.mkOutOfStoreSymlink "${home}/${path}";
+
+ # Same path under $HOME as in the checkout's home/.
+ same = paths: lib.genAttrs paths (p: { source = link p; });
+ # Entries of .config, by name.
+ config' = names: lib.genAttrs names (n: { source = link ".config/${n}"; });
+
+ cursorThemes = [
+ "modernxp-retro-black" # the active cursor (gtk.nix, core.lua)
+ "modernxp-retro-black-hyprcursor"
+ "modernxp-rose-pine"
+ "modernxp-rose-pine-hyprcursor"
+ "retrosmart-rose-pine"
+ "retrosmart-rose-pine-hyprcursor"
+ "rose-pine-hyprcursor"
+ "BreezeX-RosePine-Linux"
+ ];
+ in
+ {
+ home.file =
+ same [
+ ".dotfiles" # the zsh ZDOTDIR tree (modules/home/shell.nix sets ZDOTDIR)
+ ".tmux.conf"
+ ".ripgreprc" # RIPGREP_CONFIG_PATH, exported by .dotfiles/config/ripgrep.zsh
+ "Music/AGENTS.md"
+ ]
+ // same (map (t: ".local/share/icons/${t}") cursorThemes)
+ // {
+ # The patched DMMono TTFs live outside home/ in the checkout.
+ ".local/share/fonts/nerd-fonts-dm-mono".source =
+ config.lib.file.mkOutOfStoreSymlink "${repo}/assets/fonts/nerd-fonts-dm-mono";
+ };
+
+ xdg.configFile = config' [
+ # shell and prompt
+ "atuin"
+ "bat" # the "Rose Pine" theme BAT_THEME names (shell.nix rebuilds bat's cache)
+ "carapace"
+ "cheats" # the markdown cheat cards (cheats.zsh, ~/.local/bin/cheat-*)
+ "crossfetch" # the login splash animation (animations.zsh); the fetch card itself is prompt.nix
+ "eza"
+ "mise"
+ # tools
+ "btop"
+ "lazygit"
+ "jj"
+ "emacs"
+ "opencode"
+ "feroxbuster"
+ "uncover"
+ "herdr"
+ "tensaku"
+ "ai-usagebar"
+ "bg-pasticcio"
+ "libvirt"
+ # media
+ "cava"
+ "imv"
+ "zathura"
+ "xournalpp"
+ "spicetify"
+ "vesktop"
+ "pipewire" # 10-sample-rates.conf
+ "wireplumber" # bluetooth-a2dp-autoconnect.conf
+ # terminals and desktop bits
+ "alacritty"
+ "foot"
+ "ghostty"
+ "fcitx5"
+ "xdg-terminals.list" # kitty first, for xdg-terminal-exec
+ "chromium-flags.conf" # read only if a chromium is ever installed
+ "menus" # the chrome-apps application menu entries
+ "uwsm" # env.d/50-rose-pine-cursor (same values core.lua sets)
+ ];
+ }
+ ;
+}
diff --git a/modules/home/fan.nix b/modules/home/fan.nix
@@ -0,0 +1,125 @@
+# modules/home/fan.nix — `fan`: the laptop's fans from the terminal, safely.
+#
+# fan one line: CPU °C, fan rpm and %, clock cap, load, clamp yes/no, EC mode
+# fan watch [s] the same line every s seconds (default 2), Ctrl-C to stop
+# fan max 100 % now, with the watchdog (below) fan 60 fixed 60 % (30-100)
+# fan auto back to the EC's own curve; stops the watchdog
+# fan log what the watchdog has done
+#
+# Why a watchdog: this laptop's EC, once in manual fan mode, asserts PROCHOT
+# and pins every core at 399 MHz as soon as load starts (fan-throttle-guard.nix).
+# `fan max` therefore starts a transient user unit (fan-watchdog) that samples
+# /proc/stat and /proc/cpuinfo every second and, the moment CPU busy ≥ 25 %
+# while no core is above 600 MHz (fanfix's clamp rule), runs `fan-ec auto`,
+# sends a notification and exits. Root access is `sudo -n fan-ec …`
+# (modules/hosts/laptop/fan-cli.nix: a store script with a passwordless rule for wheel).
+# Reads need no root at all: k10temp and the uniwill hwmon are world-readable.
+{ ... }:
+{
+ flake.homeModules.fan =
+ { pkgs, lib, ... }:
+ let
+ bin = lib.makeBinPath [ pkgs.coreutils pkgs.gawk pkgs.gnugrep pkgs.gnused pkgs.systemd pkgs.libnotify ];
+ # NixOS's setuid sudo lives in /run/wrappers; the store copy is not setuid
+ # and refuses to run ("must be owned by uid 0 and have the setuid bit set").
+ sudo = "/run/wrappers/bin/sudo";
+
+ # shared read-only sampler, sourced by both scripts
+ lib-sh = pkgs.writeText "fan-lib.sh" ''
+ TRIP_MHZ=600; BUSY_MIN=25
+ STATE=''${XDG_RUNTIME_DIR:-/tmp}/fan.stat
+ hw() { local n; for h in /sys/class/hwmon/hwmon*; do n=$(cat "$h/name" 2>/dev/null); [ "$n" = "$1" ] && { echo "$h"; return; }; done; }
+ cpu_temp() { local h; h=$(hw k10temp); [ -n "$h" ] && echo $(( $(cat "$h/temp1_input") / 1000 )) || echo -; }
+ fan_rpm() { local h; h=$(hw uniwill); [ -n "$h" ] && cat "$h/fan1_input" || echo -; }
+ fan_pct() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/pwm1") * 100 / 255 )) || echo -; }
+ gpu_temp() { local h; h=$(hw uniwill); [ -n "$h" ] && echo $(( $(cat "$h/temp2_input") / 1000 )) || echo -; }
+ cap_mhz() { echo $(( $(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq) / 1000 )); }
+ clocks() { awk '/^cpu MHz/ {s+=$4; n++; if ($4>m) m=$4} END {printf "%d %d", (n?s/n:0), m}' /proc/cpuinfo; }
+ # CPU busy % since the previous call (snapshot in $STATE); 0 on the first call
+ busy() {
+ local cur prev b=0
+ cur=$(head -1 /proc/stat)
+ [ -r "$STATE" ] && prev=$(cat "$STATE") || prev=
+ printf '%s' "$cur" > "$STATE"
+ [ -n "$prev" ] && b=$(awk -v a="$prev" -v b="$cur" 'BEGIN {
+ na=split(a,x," "); nb=split(b,y," "); ta=0; tb=0
+ for (i=2;i<=na;i++) ta+=x[i]; for (i=2;i<=nb;i++) tb+=y[i]
+ ia=x[5]+x[6]; ib=y[5]+y[6]; d=tb-ta; if (d<=0) {print 0; exit}
+ printf "%d", 100*(d-(ib-ia))/d }')
+ echo "$b"
+ }
+ clamped() { # 1 when busy yet no core above TRIP_MHZ
+ local b=$1 mx=$2; [ "$b" -ge "$BUSY_MIN" ] && [ "$mx" -gt 0 ] && [ "$mx" -lt "$TRIP_MHZ" ] && echo 1 || echo 0
+ }
+ ec_mode() { ${sudo} -n /run/current-system/sw/bin/fan-ec mode 2>/dev/null || echo "?"; }
+ status_line() {
+ local b mx avg; b=$(busy); read -r avg mx <<< "$(clocks)"
+ local cl; cl=$(clamped "$b" "$mx")
+ printf 'CPU %s°C · GPU %s°C · fan %s rpm %s%% · cap %s MHz · clocks avg %s / max %s · load %s%% · clamp %s · fans %s\n' \
+ "$(cpu_temp)" "$(gpu_temp)" "$(fan_rpm)" "$(fan_pct)" "$(cap_mhz)" "$avg" "$mx" "$b" \
+ "$([ "$cl" = 1 ] && echo YES || echo no)" "$(ec_mode)"
+ }
+ '';
+
+ fan-watchdog = pkgs.writeShellScriptBin "fan-watchdog" ''
+ set -uo pipefail
+ PATH=${bin}:$PATH
+ . ${lib-sh}
+ LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log; mkdir -p "$(dirname "$LOG")"
+ log() { printf '%s %s\n' "$(date '+%F %T')" "$*" >> "$LOG"; }
+ log "armed: fans manual ($1), watching for the EC clamp"
+ busy >/dev/null; sleep 1
+ while :; do
+ b=$(busy); read -r _ mx <<< "$(clocks)"
+ if [ "$(clamped "$b" "$mx")" = 1 ]; then
+ out=$(${sudo} -n /run/current-system/sw/bin/fan-ec auto 2>&1)
+ log "CLAMP: load $b %, max core $mx MHz -> fan-ec auto: $out"
+ notify-send -a fan -u critical "Fans back to auto" "EC clamp hit (load $b %, cores at $mx MHz). Manual fan mode released."
+ exit 0
+ fi
+ sleep 1
+ done
+ '';
+
+ fan = pkgs.writeShellScriptBin "fan" ''
+ set -uo pipefail
+ PATH=${bin}:$PATH
+ . ${lib-sh}
+ UNIT=fan-watchdog
+ EC=/run/current-system/sw/bin/fan-ec
+ LOG=''${XDG_STATE_HOME:-$HOME/.local/state}/fan-watchdog.log
+
+ arm() { # start (or restart) the watchdog as a transient user unit
+ systemctl --user stop "$UNIT" 2>/dev/null || true
+ systemd-run --user --unit="$UNIT" --collect --quiet ${fan-watchdog}/bin/fan-watchdog "$1" \
+ && echo "watchdog armed: back to auto the moment the EC clamps the CPU (fan log)"
+ }
+ disarm() { systemctl --user stop "$UNIT" 2>/dev/null || true; }
+
+ if [ "$(id -u)" = 0 ]; then
+ echo "fan: run this as yourself, not under sudo (it needs your user session for the watchdog; root access is handled inside)" >&2
+ exit 1
+ fi
+
+ case "''${1:-}" in
+ ""|status) status_line ;;
+ watch)
+ iv=''${2:-2}; busy >/dev/null; sleep "$iv"
+ while :; do status_line; sleep "$iv"; done ;;
+ max) ${sudo} -n "$EC" max && arm max ;;
+ auto) disarm; ${sudo} -n "$EC" auto ;;
+ [0-9]*) p=''${1%\%}; ${sudo} -n "$EC" "$p" && arm "$p %" ;;
+ ec) ${sudo} -n "$EC" status ;;
+ log) [ -r "$LOG" ] && tail -n ''${2:-20} "$LOG" || echo "no watchdog log yet" ;;
+ -h|--help|help)
+ echo "usage: fan [status] | watch [sec] | max | <30-100> | auto | ec | log [n]"
+ echo " max / <pct> put the fans in manual mode WITH the clamp watchdog; auto releases them." ;;
+ *) echo "fan: unknown command '$1' (try: fan help)" >&2; exit 2 ;;
+ esac
+ '';
+ in
+ {
+ home.packages = [ fan fan-watchdog ];
+ }
+ ;
+}
diff --git a/modules/home/git.nix b/modules/home/git.nix
@@ -0,0 +1,86 @@
+# modules/home/git.nix — git, from the flake (was ~/.gitconfig written by the
+# bootstrap script plus the dotfiles' .config/git, which is not linked).
+#
+# Identity: DAEMON-404 <zer0sec.xp@icloud.com>; every commit and tag signed
+# with the GPG main key (modules/home/gpg.nix), which GitLab already knows.
+# Credentials for https remotes come from gh / glab; clones use ssh anyway.
+# delta paints diffs (Rosé Pine, from the dotfiles' git config).
+{ ... }:
+{
+ flake.homeModules.git =
+ { ... }:
+ {
+ programs.git = {
+ enable = true;
+ signing = {
+ key = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
+ format = "openpgp";
+ signByDefault = true; # commit.gpgSign and tag.gpgSign
+ };
+ settings = {
+ user = {
+ name = "DAEMON-404";
+ email = "zer0sec.xp@icloud.com";
+ };
+ alias = {
+ co = "checkout";
+ br = "branch";
+ ci = "commit";
+ st = "status";
+ lg = "log --oneline --graph --decorate -20";
+ };
+ init.defaultBranch = "main";
+ pull.rebase = true;
+ push.autoSetupRemote = true;
+ diff = {
+ algorithm = "histogram";
+ colorMoved = "default";
+ mnemonicPrefix = true;
+ };
+ commit.verbose = true;
+ column.ui = "auto";
+ branch.sort = "-committerdate";
+ tag.sort = "-version:refname";
+ rerere = {
+ enabled = true;
+ autoupdate = true;
+ };
+ merge.conflictstyle = "zdiff3";
+ core = {
+ autocrlf = "input";
+ safecrlf = "warn";
+ };
+ credential = {
+ "https://github.com".helper = "!gh auth git-credential";
+ "https://gist.github.com".helper = "!gh auth git-credential";
+ "https://gitlab.com".helper = "!glab auth git-credential";
+ };
+ };
+ };
+
+ programs.delta = {
+ enable = true;
+ enableGitIntegration = true;
+ options = {
+ navigate = true;
+ light = false;
+ line-numbers = true;
+ side-by-side = false;
+ hyperlinks = true;
+ syntax-theme = "none";
+ minus-style = "\"#eb6f92\" \"#26233a\"";
+ minus-emph-style = "bold \"#eb6f92\" \"#403d52\"";
+ plus-style = "\"#9ccfd8\" \"#26233a\"";
+ plus-emph-style = "bold \"#31748f\" \"#403d52\"";
+ line-numbers-minus-style = "\"#eb6f92\"";
+ line-numbers-plus-style = "\"#31748f\"";
+ line-numbers-zero-style = "\"#6e6a86\"";
+ file-style = "\"#31748f\" bold";
+ file-decoration-style = "\"#c4a7e7\" ul";
+ hunk-header-style = "\"#eb6f92\" bold";
+ hunk-header-decoration-style = "\"#6e6a86\" box";
+ };
+ };
+ }
+ ;
+}
diff --git a/modules/home/gpg.nix b/modules/home/gpg.nix
@@ -0,0 +1,56 @@
+# modules/home/gpg.nix — the GPG main key and gpg.conf, from the flake.
+#
+# public key modules/home/gpg/daemon-main.pub.asc → imported with ultimate trust (programs.gpg.publicKeys)
+# secret key sops secret gpg_main_secret (the armored export, still under its own passphrase):
+# imported into the keyring on activation if the keyring lacks it
+# gpg.conf programs.gpg.settings (the gpg-cheat `setup` defaults)
+#
+# The agent itself is NixOS's (programs.gnupg.agent, pinentry-gnome3), so
+# services.gpg-agent is deliberately not enabled here.
+# Key: daemon (Main_Key) <zer0sec.xp@icloud.com>, RSA 4096, 2025-12-30, the one on GitLab.
+{ ... }:
+{
+ flake.homeModules.gpg =
+ { config, pkgs, lib, ... }:
+ let
+ fpr = "3374F5B7E65B8CDB7949E2EBBFB092454FAFECB4";
+ in
+ {
+ sops.secrets.gpg_main_secret = { };
+
+ programs.gpg = {
+ enable = true;
+ mutableKeys = true; # other people's keys and new subkeys stay editable
+ mutableTrust = true;
+ publicKeys = [
+ { source = ./gpg/daemon-main.pub.asc; trust = 5; }
+ ];
+ settings = {
+ default-key = fpr;
+ default-recipient-self = true;
+ keyid-format = "0xlong";
+ with-fingerprint = true;
+ with-subkey-fingerprints = true;
+ personal-cipher-preferences = "AES256 AES192 AES";
+ personal-digest-preferences = "SHA512 SHA384 SHA256";
+ cert-digest-algo = "SHA512";
+ no-emit-version = true;
+ no-comments = true;
+ keyserver = "hkps://keys.openpgp.org";
+ auto-key-locate = "local,wkd";
+ trust-model = "tofu+pgp";
+ };
+ };
+
+ # Import the secret key once (idempotent: skipped when the keyring has it).
+ # Runs after sops-nix has decrypted the secrets.
+ home.activation.gpgMainKey = lib.hm.dag.entryAfter [ "sops-nix" ] ''
+ if [ -r "${config.sops.secrets.gpg_main_secret.path}" ] \
+ && ! ${pkgs.gnupg}/bin/gpg --batch --list-secret-keys ${fpr} >/dev/null 2>&1; then
+ run ${pkgs.gnupg}/bin/gpg --batch --quiet --import "${config.sops.secrets.gpg_main_secret.path}" \
+ && echo "gpg: imported the main secret key ${fpr}"
+ fi
+ '';
+ }
+ ;
+}
diff --git a/home/gpg/daemon-main.pub.asc b/modules/home/gpg/daemon-main.pub.asc
diff --git a/modules/home/gtk.nix b/modules/home/gtk.nix
@@ -0,0 +1,31 @@
+# modules/home/gtk.nix — icons, cursor, dark preference.
+# The cursor theme files are symlinked from the dotfiles checkout in tools.nix
+# (modernxp-retro-black for Xcursor, modernxp-retro-black-hyprcursor for
+# Hyprland), so there is no package to point home.pointerCursor at.
+{ ... }:
+{
+ flake.homeModules.gtk =
+ { pkgs, ... }:
+ {
+ gtk = {
+ enable = true;
+ iconTheme = {
+ name = "Yaru-purple";
+ package = pkgs.yaru-theme;
+ };
+ cursorTheme = {
+ name = "modernxp-retro-black";
+ size = 24;
+ };
+ colorScheme = "dark"; # GTK prefer-dark
+ };
+
+ home.sessionVariables = {
+ XCURSOR_THEME = "modernxp-retro-black";
+ XCURSOR_SIZE = "24";
+ HYPRCURSOR_THEME = "modernxp-retro-black-hyprcursor"; # name from the theme's manifest.hl
+ HYPRCURSOR_SIZE = "24";
+ };
+ }
+ ;
+}
diff --git a/home/hypr/bindings.lua b/modules/home/hypr/bindings.lua
diff --git a/home/hypr/caelestia.lua b/modules/home/hypr/caelestia.lua
diff --git a/home/hypr/core.lua b/modules/home/hypr/core.lua
diff --git a/home/hypr/defaults.lua b/modules/home/hypr/defaults.lua
diff --git a/home/hypr/lid.lua b/modules/home/hypr/lid.lua
diff --git a/home/hypr/looknfeel.lua b/modules/home/hypr/looknfeel.lua
diff --git a/home/hypr/omarchy.lua b/modules/home/hypr/omarchy.lua
diff --git a/modules/home/hyprland.nix b/modules/home/hyprland.nix
@@ -0,0 +1,134 @@
+# modules/home/hyprland.nix — Hyprland (Lua config) and the carried shortcuts.
+#
+# Hyprland 0.56 is configured in Lua (hyprland.lua, `hl.*` API); that is also
+# the dialect the carried shortcuts and the toolbox helpers (dropterm, winsnap,
+# vault-open, lid-control: `hyprctl dispatch 'hl.dsp…'`) already speak. The
+# config is split like the vault's shortcuts/:
+# hypr/omarchy.lua the `o` helpers the carried files were written against
+# hypr/core.lua monitor, env, look, input, Caelestia layer rules
+# hypr/looknfeel.lua the springy animations, shadows, snap, swallow (dotfiles looknfeel.lua)
+# hypr/defaults.lua the stock Omarchy binds as captured in keybinds.txt
+# hypr/bindings.lua shortcuts/bindings.lua (user overrides, window mode)
+# hypr/lid.lua shortcuts/lid.lua
+# hypr/caelestia.lua shortcuts/caelestia.lua (Caelestia keys, always on here)
+{ ... }:
+{
+ flake.homeModules.hyprland =
+ { config, pkgs, lib, inputs, osConfig, ... }:
+ let
+ system = pkgs.stdenv.hostPlatform.system;
+ hyprPkg = inputs.hyprland.packages.${system}.hyprland;
+ shellCli = "${config.programs.caelestia.cli.package}/bin/caelestia";
+
+ # Small helpers the stock Omarchy binds relied on. They exist only to serve
+ # this config, so they live here rather than in ~/.local/bin.
+ helpers = [
+ # The picker the omarchy-menu-* cheat sheets (bin/) pipe into. Prints the chosen line.
+ (pkgs.writeShellScriptBin "omarchy-menu-select" ''
+ title=''${1:-Select}
+ exec ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "$title › " --width 110 --lines 24
+ '')
+ # omarchy-not-ported "<bind description>" ["<what it did on Omarchy>"]: an
+ # honest notification instead of a key that silently does nothing.
+ (pkgs.writeShellScriptBin "omarchy-not-ported" ''
+ ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 4000 "$1" "Not set up on this NixOS build.''${2:+ Omarchy: $2}"
+ '')
+ (pkgs.writeShellScriptBin "nixdaemon-show" ''
+ # nixdaemon-show time|battery|calendar|kbd-backlight-cycle
+ n() { ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 5000 "$@"; }
+ case "''${1:-}" in
+ time) n "$(date '+%H:%M')" "$(date '+%A %-d %B %Y')" ;;
+ battery) b=/sys/class/power_supply/BAT0; n "Battery $(cat $b/capacity)%" "$(cat $b/status)" ;;
+ calendar) n "$(date '+%B %Y')" "$(cal | tail -n +2)" ;;
+ kbd-backlight-cycle)
+ d=$(ls -d /sys/class/leds/*kbd_backlight 2>/dev/null | head -1); [ -n "$d" ] || exit 0
+ max=$(cat "$d/max_brightness"); cur=$(cat "$d/brightness"); next=$(( (cur + 1) % (max + 1) ))
+ ${pkgs.brightnessctl}/bin/brightnessctl -q -d "$(basename "$d")" set "$next" ;;
+ *) echo "usage: nixdaemon-show time|battery|calendar|kbd-backlight-cycle" >&2; exit 2 ;;
+ esac
+ '')
+ # SUPER+K: searchable list of the live binds (what Omarchy's keybindings menu did). Enter copies the key.
+ (pkgs.writeShellScriptBin "keybinds-menu" ''
+ sel=$(hyprctl binds -j | ${pkgs.python3}/bin/python3 -I -c '
+ import json, sys
+ M = {1: "SHIFT", 4: "CTRL", 8: "ALT", 64: "SUPER"}
+ rows = set()
+ for x in json.load(sys.stdin):
+ mods = "+".join(n for v, n in sorted(M.items()) if x["modmask"] & v)
+ key = x["key"] or ("code:%d" % x["keycode"])
+ sub = x.get("submap", "")
+ rows.add(("%s%s%s %s" % (sub + ": " if sub else "", mods + " + " if mods else "", key, x.get("description", ""))).rstrip())
+ print("\n".join(sorted(rows)))' | ${pkgs.fuzzel}/bin/fuzzel --dmenu --prompt "Keybindings › " --width 120 --lines 30) || exit 0
+ [ -n "$sel" ] && printf '%s' "''${sel%% *}" | ${pkgs.wl-clipboard}/bin/wl-copy
+ '')
+ # CTRL+SUPER+SPACE: what Omarchy's background switcher did, with Caelestia's
+ # own wallpaper grid. The launcher shows it when its search starts with
+ # ">wallpaper ", and there is no IPC for that, so the prefix is typed in.
+ (pkgs.writeShellScriptBin "wallpaper-picker" ''
+ c=${shellCli}
+ case "$($c shell drawers isOpen launcher 2>/dev/null)" in
+ 1|true) exec $c shell drawers toggle launcher ;;
+ esac
+ $c shell drawers toggle launcher
+ sleep 0.25
+ exec ${pkgs.wtype}/bin/wtype '>wallpaper '
+ '')
+ (pkgs.writeShellScriptBin "nightlight-toggle" ''
+ if pgrep -x hyprsunset >/dev/null; then
+ pkill -x hyprsunset; ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "off"
+ else
+ ${pkgs.hyprsunset}/bin/hyprsunset --temperature 4000 >/dev/null 2>&1 &
+ ${pkgs.libnotify}/bin/notify-send -a NixDaemon -t 2000 "Night light" "on (4000 K)"
+ fi
+ '')
+ ];
+ in
+ # Only when the Hyprland desktop is switched on (modules/features/desktop/options.nix).
+ lib.mkIf osConfig.daemon.desktop.hyprland.enable {
+ wayland.windowManager.hyprland = {
+ enable = true;
+ package = hyprPkg; # same derivation NixOS installs; no second copy
+ portalPackage = null; # programs.hyprland (NixOS) provides the portal
+ configType = "lua";
+ systemd.enable = false; # uwsm owns graphical-session.target
+ xwayland.enable = true;
+
+ extraLuaFiles = {
+ omarchy = { content = ./hypr/omarchy.lua; autoLoad = false; };
+ core = { content = ./hypr/core.lua; autoLoad = false; };
+ looknfeel = { content = ./hypr/looknfeel.lua; autoLoad = false; };
+ defaults = { content = ./hypr/defaults.lua; autoLoad = false; };
+ bindings = { content = ./hypr/bindings.lua; autoLoad = false; };
+ lid = { content = ./hypr/lid.lua; autoLoad = false; };
+ caelestia = { content = ./hypr/caelestia.lua; autoLoad = false; };
+ };
+
+ # Explicit load order: look first, then the stock binds, then the carried files that
+ # unbind-and-rebind the keys they take over, Caelestia's keys last.
+ extraConfig = ''
+ local cfg = (os.getenv("XDG_CONFIG_HOME") or (os.getenv("HOME") .. "/.config")) .. "/hypr"
+ package.path = cfg .. "/?.lua;" .. package.path
+ require("omarchy")
+ require("core")
+ require("looknfeel")
+ require("defaults")
+ require("bindings")
+ require("lid")
+ require("caelestia")
+ '';
+ };
+
+ home.packages = helpers ++ (with pkgs; [
+ hyprpicker # colour picker (SUPER+PRINT)
+ hyprsunset # night light
+ fuzzel # dmenu for the cheat sheets and keybinds-menu
+ nautilus # the file manager shell.json and the stock binds point at
+ brightnessctl
+ pamixer
+ ]); # grim, slurp, wl-clipboard, libnotify: session.nix (shared with Niri)
+
+ # The polkit agent, cliphist and udiskie (graphical-session.target) are in
+ # session.nix: both desktops need them.
+ }
+ ;
+}
diff --git a/home/kitty/scrollback.lua b/modules/home/kitty/scrollback.lua
diff --git a/modules/home/media.nix b/modules/home/media.nix
@@ -0,0 +1,120 @@
+# modules/home/media.nix — music and video: mpd + rmpc, and mpv.
+#
+# mpd runs as a user service (starts at login), library ~/Music (the layout
+# contract is ~/Music/AGENTS.md), state in ~/.local/share/mpd, PipeWire
+# output plus the FIFO rmpc's visualiser reads. It listens on the socket
+# $XDG_RUNTIME_DIR/mpd/socket and on 127.0.0.1:6600.
+#
+# rmpc: the dotfiles' full config (modules/home/rmpc/config.ron: tabs with album art,
+# lyrics and cava panes, vim keys, 1-0 tab switching), the Rosé Pine theme
+# (modules/home/rmpc/themes/rose-pine.ron) and the LRCLIB lyrics fetcher that runs on
+# song change. The Discord presence script was not carried (1.4k lines of
+# Python with its own deps; say so if wanted).
+#
+# mpv: the dotfiles' gpu-next/Vulkan profile with the CfL chroma shader, plus
+# uosc (the on-screen UI), thumbfast (seek thumbnails) and mpris (media keys,
+# Caelestia's player widget).
+{ ... }:
+{
+ flake.homeModules.media =
+ { config, pkgs, ... }:
+ let
+ rt = "/run/user/1000";
+ in
+ {
+ services.mpd = {
+ enable = true;
+ musicDirectory = "${config.home.homeDirectory}/Music";
+ playlistDirectory = "${config.xdg.dataHome}/mpd/playlists";
+ network = {
+ listenAddress = "${rt}/mpd/socket";
+ port = 6600;
+ startWhenNeeded = false;
+ };
+ extraConfig = ''
+ bind_to_address "127.0.0.1"
+ auto_update "yes"
+ restore_paused "yes"
+ audio_output {
+ type "pipewire"
+ name "PipeWire"
+ }
+ audio_output {
+ type "fifo"
+ name "Visualizer FIFO"
+ path "${rt}/mpd/fifo"
+ format "44100:16:2"
+ }
+ '';
+ };
+ # the socket's directory, created by systemd before mpd starts
+ systemd.user.services.mpd.Service.RuntimeDirectory = "mpd";
+
+ programs.rmpc = {
+ enable = true;
+ config = builtins.readFile ./rmpc/config.ron;
+ };
+ xdg.configFile = {
+ "rmpc/themes/rose-pine.ron".source = ./rmpc/themes/rose-pine.ron;
+ "rmpc/scripts/fetch-lyrics" = {
+ source = ./rmpc/scripts/fetch-lyrics;
+ executable = true;
+ };
+ "mpv/shaders".source = ./mpv/shaders;
+ };
+
+ programs.mpv = {
+ enable = true;
+ scripts = with pkgs.mpvScripts; [ uosc thumbfast mpris ];
+ config = {
+ vo = "gpu-next";
+ gpu-api = "vulkan";
+ gpu-context = "waylandvk";
+ profile = "high-quality";
+ hwdec = "auto";
+ scale = "ewa_lanczos4sharpest";
+ glsl-shader = "~~/shaders/CfL_Prediction.glsl";
+ cscale = "ewa_lanczossharp";
+ cscale-antiring = 0.65;
+ dscale = "mitchell";
+ correct-downscaling = true;
+ linear-downscaling = true;
+ sigmoid-upscaling = true;
+ deband = true;
+ deband-iterations = 2;
+ deband-threshold = 32;
+ deband-range = 12;
+ deband-grain = 16;
+ dither = "error-diffusion";
+ error-diffusion = "burkes";
+ dither-depth = 8;
+ temporal-dither = false;
+ video-sync = "display-resample";
+ interpolation = true;
+ tscale = "oversample";
+ target-colorspace-hint = "auto";
+ target-colorspace-hint-mode = "target";
+ target-prim = "bt.709";
+ target-trc = "srgb";
+ gamut-mapping-mode = "perceptual";
+ tone-mapping = "auto";
+ hdr-compute-peak = true;
+ contrast = 4;
+ saturation = 5;
+ screenshot-format = "png";
+ screenshot-high-bit-depth = true;
+ screenshot-tag-colorspace = true;
+ screenshot-directory = "~/Pictures/mpv";
+ osc = false; # uosc replaces it
+ border = false;
+ save-position-on-quit = true;
+ keep-open = true;
+ sub-auto = "fuzzy";
+ slang = "en,eng";
+ alang = "ja,jpn,en,eng";
+ ytdl-format = "bestvideo[height<=?1440]+bestaudio/best";
+ };
+ };
+ }
+ ;
+}
diff --git a/home/mpv/shaders/CfL_Prediction.LICENSE b/modules/home/mpv/shaders/CfL_Prediction.LICENSE
diff --git a/home/mpv/shaders/CfL_Prediction.glsl b/modules/home/mpv/shaders/CfL_Prediction.glsl
diff --git a/modules/home/neovim.nix b/modules/home/neovim.nix
@@ -0,0 +1,120 @@
+# modules/home/neovim.nix — Neovim through nvf (github:notashelf/nvf): the
+# editor and its plugins are one Nix-built package, no plugin manager, no
+# ~/.config/nvim, nothing downloaded on first start. Replaces the AstroNvim
+# tree the dotfiles carried (2026-10-08): that one pulled ~60 plugins through
+# lazy.nvim and compiled treesitter parsers on the machine.
+#
+# Kept deliberately small. Languages: the ones this machine edits (Nix, Lua
+# for Hyprland, Python and Bash for the toolbox, Markdown for the vault, and
+# the config formats). Each gets treesitter, an LSP and a formatter; format on
+# save is off, `<leader>lf` formats on demand.
+#
+# <leader>ff / fg / fb telescope: files / live grep / buffers
+# - oil: edit the parent directory as a buffer
+# <leader>e oil in a floating window
+# gd gr K <leader>ca LSP: definition, references, hover, code action (nvf defaults)
+# <leader>lf format buffer
+# ]c [c <leader>gp gitsigns: next/previous hunk, preview hunk
+# gcc gc{motion} comment.nvim
+# <Esc> clear search highlight
+# <space> leader
+#
+# kitty's copy mode (modules/home/terminal.nix) starts plain pkgs.neovim with
+# -u, so it is unaffected by this configuration and stays instant.
+{ ... }:
+{
+ flake.homeModules.neovim =
+ { inputs, pkgs, ... }:
+ {
+ imports = [ inputs.nvf.homeManagerModules.default ];
+
+ programs.nvf = {
+ enable = true;
+ settings.vim = {
+ viAlias = true;
+ vimAlias = true;
+
+ theme = {
+ enable = true;
+ name = "rose-pine";
+ style = "main"; # main, not moon
+ transparent = false;
+ };
+
+ # Editor behaviour
+ lineNumberMode = "relNumber";
+ searchCase = "smart";
+ preventJunkFiles = true;
+ undoFile.enable = true;
+ clipboard = {
+ enable = true;
+ registers = "unnamedplus";
+ providers.wl-copy.enable = true;
+ };
+ options = {
+ tabstop = 2;
+ shiftwidth = 2;
+ softtabstop = 2;
+ scrolloff = 6;
+ wrap = false;
+ signcolumn = "yes";
+ cursorline = true;
+ splitbelow = true;
+ splitright = true;
+ updatetime = 250;
+ timeoutlen = 400;
+ };
+
+ # Languages: treesitter + LSP + formatter each, chosen by nvf's defaults
+ # (nil for Nix, basedpyright/ruff for Python, lua-language-server,
+ # bash-language-server/shfmt, marksman, yaml/json/taplo).
+ lsp = {
+ enable = true;
+ formatOnSave = false;
+ inlayHints.enable = false;
+ };
+ languages = {
+ enableTreesitter = true;
+ enableFormat = true;
+ nix = {
+ enable = true;
+ format.type = [ "nixfmt" ]; # the style this repo is written in
+ };
+ lua.enable = true;
+ python.enable = true;
+ bash.enable = true;
+ markdown.enable = true;
+ yaml.enable = true;
+ json.enable = true;
+ toml.enable = true;
+ };
+
+ autocomplete.blink-cmp.enable = true;
+ telescope.enable = true;
+ git.gitsigns.enable = true;
+ binds.whichKey.enable = true;
+ statusline.lualine.enable = true;
+ autopairs.nvim-autopairs.enable = true;
+ comments.comment-nvim.enable = true;
+ utility.oil-nvim.enable = true;
+ visuals.nvim-web-devicons.enable = true;
+ ui.borders.enable = true;
+
+ keymaps = [
+ { key = "-"; mode = "n"; action = "<cmd>Oil<CR>"; desc = "Open parent directory"; silent = true; }
+ { key = "<leader>e"; mode = "n"; action = "<cmd>Oil --float<CR>"; desc = "Explorer (oil)"; silent = true; }
+ { key = "<Esc>"; mode = "n"; action = "<cmd>nohlsearch<CR>"; desc = "Clear search highlight"; silent = true; }
+ { key = "<leader>w"; mode = "n"; action = "<cmd>write<CR>"; desc = "Save"; silent = true; }
+ { key = "<leader>q"; mode = "n"; action = "<cmd>quit<CR>"; desc = "Quit"; silent = true; }
+ { key = "<C-h>"; mode = "n"; action = "<C-w>h"; desc = "Window left"; }
+ { key = "<C-j>"; mode = "n"; action = "<C-w>j"; desc = "Window down"; }
+ { key = "<C-k>"; mode = "n"; action = "<C-w>k"; desc = "Window up"; }
+ { key = "<C-l>"; mode = "n"; action = "<C-w>l"; desc = "Window right"; }
+ { key = "<"; mode = "v"; action = "<gv"; desc = "Dedent, keep selection"; }
+ { key = ">"; mode = "v"; action = ">gv"; desc = "Indent, keep selection"; }
+ ];
+ };
+ };
+ }
+ ;
+}
diff --git a/modules/home/prompt.nix b/modules/home/prompt.nix
@@ -0,0 +1,250 @@
+# modules/home/prompt.nix — the starship prompt and the fastfetch card, fresh
+# (2026-10-08), Rosé Pine, one colour per section, nothing Omarchy.
+#
+# Prompt (two lines, the dotfiles' "filigree" frame kept, the per-letter
+# gradients gone):
+#
+# ╭╌ ☧ daemonsec@nixos ┄ ~/NixDaemon ┄ main [+2 !1] ⌁⡇⡆· (right: 3s · 14:02)
+# ╰╌ ❯
+#
+# glyph iris · user rose · host foam · directory gold · git love (status subtle,
+# week heartbeat iris) · languages text · duration/jobs gold · clock rose ·
+# prompt char foam (love after an error). pine is never ink (3.3:1 on base).
+# $CROSS_GLYPH comes from the dotfiles' animations.zsh (☧ + the NixOS glyph).
+#
+# theme.zsh in the dotfiles runs `starship init zsh` and adds the transient
+# prompt, so home-manager's own shell integration stays off here.
+#
+# fastfetch: the builtin NixOS logo in iris/foam, keys in rotating Rosé Pine
+# colours, the modules that matter on this laptop. The login splash
+# (animations.zsh) runs plain `fastfetch` when CROSS_FETCH=plain, which
+# .dotfiles/.zshrc now sets, so this config draws the whole card.
+{ ... }:
+{
+ flake.homeModules.prompt =
+ { lib, ... }:
+ let
+ # Rosé Pine (main)
+ rp = {
+ love = "#eb6f92";
+ gold = "#f6c177";
+ rose = "#ebbcba";
+ pine = "#31748f";
+ foam = "#9ccfd8";
+ iris = "#c4a7e7";
+ text = "#e0def4";
+ subtle = "#908caa";
+ muted = "#6e6a86";
+ };
+ # the same colours as SGR parameters for fastfetch
+ sgr = {
+ love = "38;2;235;111;146";
+ gold = "38;2;246;193;119";
+ rose = "38;2;235;188;186";
+ foam = "38;2;156;207;216";
+ iris = "38;2;196;167;231";
+ text = "38;2;224;222;244";
+ subtle = "38;2;144;140;170";
+ muted = "38;2;110;106;134";
+ };
+ lang = symbol: colour: {
+ inherit symbol;
+ format = " [$symbol($version)](fg:${colour})";
+ };
+ in
+ {
+ programs.starship = {
+ enable = true;
+ enableZshIntegration = false; # theme.zsh does it (with the transient prompt)
+ enableBashIntegration = false;
+ settings = {
+ "$schema" = "https://starship.rs/config-schema.json";
+ add_newline = true;
+ palette = "rose_pine";
+ palettes.rose_pine = rp;
+
+ format = lib.concatStrings [
+ "[╭╌](fg:muted) "
+ "\${env_var.CROSS_GLYPH}"
+ "$username"
+ "$hostname"
+ "$shlvl"
+ "$sudo"
+ "\${custom.root}"
+ "$directory"
+ "$git_branch"
+ "$git_status"
+ "\${custom.gitweek}"
+ "$git_state"
+ "$python"
+ "$nodejs"
+ "$rust"
+ "$golang"
+ "$lua"
+ "$docker_context"
+ "$package"
+ "$line_break"
+ "[╰╌](fg:muted) "
+ "$status"
+ "$character"
+ ];
+ right_format = lib.concatStrings [ "$cmd_duration" "$jobs" "$battery" "$time" ];
+
+ # identity
+ env_var.CROSS_GLYPH = {
+ variable = "CROSS_GLYPH";
+ default = "☧";
+ format = "[$env_value](bold fg:iris) ";
+ };
+ username = {
+ show_always = true;
+ format = "[$user](bold fg:rose)";
+ style_user = "bold fg:rose";
+ style_root = "bold fg:love";
+ };
+ hostname = {
+ ssh_only = false;
+ format = "[@](fg:muted)[$hostname](bold fg:foam)";
+ };
+ shlvl = {
+ disabled = false;
+ threshold = 2;
+ format = " [↕$shlvl](bold fg:gold)";
+ };
+ sudo = {
+ disabled = false;
+ format = " [](bold fg:love)";
+ };
+ custom.root = {
+ command = "echo ROOT";
+ when = "[ \"$(id -u)\" -eq 0 ]";
+ format = " [ $output](bold underline fg:love)";
+ };
+
+ # place
+ directory = {
+ format = " [┄](fg:muted) [ $path](bold fg:gold)[$read_only](fg:love)";
+ truncation_length = 4;
+ truncate_to_repo = true;
+ truncation_symbol = "…/";
+ read_only = " ";
+ };
+
+ # git
+ git_branch = {
+ symbol = " ";
+ format = " [┄](fg:muted) [$symbol$branch(:$remote_branch)](bold fg:love)";
+ };
+ git_status = {
+ format = "( [\\[$all_status$ahead_behind\\]](fg:subtle))";
+ ahead = "⇡\${count}";
+ behind = "⇣\${count}";
+ diverged = "⇕⇡\${ahead_count}⇣\${behind_count}";
+ conflicted = "=";
+ untracked = "?";
+ stashed = "≡";
+ modified = "!";
+ staged = "+";
+ renamed = "»";
+ deleted = "✘";
+ };
+ # the last 7 days of commits as a braille pulse (carried from the dotfiles)
+ custom.gitweek = {
+ command = ''git log --since=7.days --date=format:%Y%m%d --pretty=%cd 2>/dev/null | sort | uniq -c | awk 'BEGIN{split("· ⡀ ⡄ ⡆ ⡇",b," ")}{c[$2]=$1}END{for(i=6;i>=0;i--){d=strftime("%Y%m%d",systime()-i*86400);v=c[d]+0;idx=(v==0)?1:(v<3)?2:(v<6)?3:(v<10)?4:5;printf "%s",b[idx]}}' '';
+ when = "git rev-parse --is-inside-work-tree 2>/dev/null | grep -q true";
+ format = " [⌁$output](fg:iris)";
+ };
+ git_state = {
+ format = " [\\($state $progress_current/$progress_total\\)](bold fg:love)";
+ };
+
+ # toolchains: only when the directory uses them
+ python = (lang " " "text") // { format = " [$symbol$version( \\($virtualenv\\))](fg:text)"; };
+ nodejs = lang " " "text";
+ rust = lang " " "text";
+ golang = lang " " "text";
+ lua = lang " " "text";
+ docker_context = { symbol = " "; format = " [$symbol$context](fg:subtle)"; };
+ package = { symbol = " "; format = " [$symbol$version](fg:subtle)"; };
+
+ # right side
+ cmd_duration = { min_time = 2000; format = "[ $duration](fg:gold) "; };
+ jobs = { symbol = " "; format = "[$symbol$number](bold fg:gold) "; };
+ battery = {
+ full_symbol = " ";
+ charging_symbol = " ";
+ discharging_symbol = " ";
+ unknown_symbol = " ";
+ empty_symbol = " ";
+ format = "[$symbol$percentage]($style) ";
+ display = [
+ { threshold = 20; style = "bold fg:love"; }
+ { threshold = 50; style = "fg:gold"; }
+ ];
+ };
+ time = {
+ disabled = false;
+ time_format = "%H:%M";
+ format = "[ $time](fg:rose)";
+ };
+
+ # second line
+ status = {
+ disabled = false;
+ symbol = "✗ ";
+ format = "[$symbol$status](fg:love) ";
+ };
+ character = {
+ success_symbol = "[❯](bold fg:foam)";
+ error_symbol = "[❯](bold fg:love)";
+ vimcmd_symbol = "[❮](bold fg:gold)";
+ vimcmd_replace_one_symbol = "[❮](bold fg:rose)";
+ vimcmd_replace_symbol = "[❮](bold fg:iris)";
+ vimcmd_visual_symbol = "[❮](bold fg:gold)";
+ };
+ line_break.disabled = false;
+ };
+ };
+
+ programs.fastfetch = {
+ enable = true;
+ settings = {
+ "$schema" = "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json";
+ logo = {
+ type = "builtin";
+ source = "nixos";
+ color = { "1" = sgr.iris; "2" = sgr.foam; };
+ padding = { top = 1; left = 2; right = 5; };
+ };
+ display = {
+ separator = " ";
+ color = { keys = sgr.foam; title = sgr.rose; };
+ };
+ modules = [
+ { type = "title"; color = { user = sgr.rose; at = sgr.muted; host = sgr.foam; }; }
+ { type = "separator"; string = "┄"; length = 34; outputColor = sgr.muted; }
+ { type = "os"; key = " os"; keyColor = sgr.iris; }
+ { type = "kernel"; key = " kernel"; keyColor = sgr.foam; }
+ { type = "uptime"; key = " uptime"; keyColor = sgr.gold; }
+ { type = "packages"; key = " packages"; keyColor = sgr.rose; }
+ { type = "shell"; key = " shell"; keyColor = sgr.love; }
+ "break"
+ { type = "wm"; key = " wm"; keyColor = sgr.iris; }
+ { type = "display"; key = " display"; keyColor = sgr.foam; compactType = "original-with-refresh-rate"; }
+ { type = "terminal"; key = " terminal"; keyColor = sgr.gold; }
+ { type = "terminalfont"; key = " font"; keyColor = sgr.rose; }
+ "break"
+ { type = "host"; key = " host"; keyColor = sgr.love; }
+ { type = "cpu"; key = " cpu"; keyColor = sgr.iris; showPeCoreCount = true; }
+ { type = "gpu"; key = " gpu"; keyColor = sgr.foam; detectionMethod = "pci"; format = "{name}"; }
+ { type = "memory"; key = " memory"; keyColor = sgr.gold; }
+ { type = "disk"; key = " disk"; keyColor = sgr.rose; folders = "/"; }
+ { type = "battery"; key = " battery"; keyColor = sgr.love; }
+ "break"
+ { type = "colors"; symbol = "circle"; paddingLeft = 2; }
+ ];
+ };
+ };
+ }
+ ;
+}
diff --git a/home/rmpc/config.ron b/modules/home/rmpc/config.ron
diff --git a/home/rmpc/scripts/fetch-lyrics b/modules/home/rmpc/scripts/fetch-lyrics
diff --git a/home/rmpc/themes/rose-pine.ron b/modules/home/rmpc/themes/rose-pine.ron
diff --git a/modules/home/session.nix b/modules/home/session.nix
@@ -0,0 +1,39 @@
+# modules/home/session.nix — what every Wayland session needs, whichever
+# compositor is running: the polkit agent (privilege prompts), clipboard
+# history, auto-mounting, and the screenshot / clipboard tools the Niri binds
+# and the toolbox scripts expect on PATH. Not gated on a desktop switch, so a
+# Niri-only system keeps working. Everything hangs off graphical-session.target,
+# which uwsm (Hyprland) and niri-session both manage.
+{ ... }:
+{
+ flake.homeModules.session =
+ { pkgs, ... }:
+ {
+ home.packages = with pkgs; [
+ grim
+ slurp
+ wl-clipboard
+ libnotify
+ ];
+
+ systemd.user.services.hyprpolkitagent = {
+ Unit = {
+ Description = "Hyprland polkit authentication agent";
+ After = [ "graphical-session.target" ];
+ PartOf = [ "graphical-session.target" ];
+ };
+ Service = {
+ ExecStart = "${pkgs.hyprpolkitagent}/libexec/hyprpolkitagent";
+ Restart = "on-failure";
+ Slice = "session.slice";
+ };
+ Install.WantedBy = [ "graphical-session.target" ];
+ };
+ services.cliphist.enable = true; # history for `caelestia clipboard` and Noctalia's clipboard tab
+ services.udiskie = {
+ enable = true;
+ tray = "auto";
+ };
+ }
+ ;
+}
diff --git a/modules/home/shell.nix b/modules/home/shell.nix
@@ -0,0 +1,120 @@
+# modules/home/shell.nix — zsh as the shell, configured by the dotfiles.
+#
+# The dotfiles checkout (~/git/daemon-sec-dotfiles) carries a complete zsh
+# setup in home/.dotfiles: a ZDOTDIR tree with core.zsh (history, cached
+# compinit, the plugin loader, zoxide/atuin/fzf hooks), the leaf modules
+# (aliases, fzf verbs, ripgrep, cheats, music, jj, …), theme.zsh (starship
+# with a transient prompt) and animations.zsh (the login splash). The plugins
+# it loads — zsh-autosuggestions, zsh-syntax-highlighting, zsh-completions,
+# fzf-tab, history-substring-search, you-should-use — are vendored in
+# .dotfiles/config/plugins, so the config is used as-is rather than rewritten
+# as home-manager options. This module only supplies what the Omarchy install
+# had and NixOS does not:
+#
+# ~/.zshenv sets ZDOTDIR (home-manager owns this one file)
+# ~/.dotfiles → the checkout's .dotfiles (edits follow the repo)
+# ~/.fzf.zsh fzf's key bindings from the Nix store (core.zsh looks
+# in /usr/share/fzf, which does not exist here)
+# ~/.zsh/completions generated completions for tools without shipped ones
+# tmux plugins from nixpkgs (~/.tmux.conf expects TPM; a shim loads them)
+# ~/.config/secretspec the keyring provider
+#
+# The dotfiles themselves (~/.dotfiles, ~/.tmux.conf, the tool configs under
+# ~/.config: starship, bat theme, atuin, fastfetch, cheats, …) are linked by
+# modules/home/dotfiles.nix. NixOS side (modules/hosts/laptop/configuration.nix): programs.zsh with the global compinit
+# and prompt off (core.zsh and theme.zsh do those), users.<user>.shell.
+{ ... }:
+{
+ flake.homeModules.shell =
+ { config, pkgs, lib, ... }:
+ let
+ # Completions for tools that do not ship their own under share/zsh.
+ # (uv, jj, gh, atuin, zoxide, eza, bat, fd, rg do; /etc/zshrc puts the
+ # profiles' site-functions on fpath before core.zsh runs compinit.)
+ generatedCompletions = pkgs.runCommand "nixdaemon-zsh-completions" { } ''
+ mkdir -p $out
+ export HOME=$TMPDIR
+ ${pkgs.secretspec}/bin/secretspec completions zsh > $out/_secretspec
+ '';
+
+ # ~/.tmux.conf ends with `run '~/.tmux/plugins/tpm/tpm'` and lists plugins
+ # for TPM to clone. nixpkgs has the plugins but not TPM, so each plugin is
+ # linked where TPM would have put it and this stand-in sources them.
+ tpmShim = ''
+ #!${pkgs.bash}/bin/bash
+ # Stand-in for tmux-plugin-manager (NixDaemon modules/home/shell.nix): the
+ # plugins come from nixpkgs and are linked into ~/.tmux/plugins; this runs
+ # each plugin's entry script the way TPM would. prefix+I/U do nothing here;
+ # add plugins in shell.nix instead.
+ for f in "$HOME"/.tmux/plugins/*/*.tmux; do
+ case "$f" in */tpm/*) continue ;; esac
+ [ -x "$f" ] && "$f"
+ done
+ exit 0
+ '';
+ tmuxPlugin = name: pkg: {
+ name = ".tmux/plugins/${name}";
+ value.source = "${pkg}/share/tmux-plugins/${pkg.pluginName}";
+ };
+ in
+ {
+ home.packages = with pkgs; [
+ zsh
+ tmux
+ secretspec
+ ];
+
+ home.file = {
+ # zsh: hand over to the dotfiles' ZDOTDIR tree.
+ ".zshenv".text = ''
+ # Managed by home-manager (NixDaemon modules/home/shell.nix). The shell
+ # configuration lives in ~/.dotfiles (→ ~/git/daemon-sec-dotfiles).
+ export ZDOTDIR="$HOME/.dotfiles"
+ [[ -r "$HOME/.cargo/env" ]] && . "$HOME/.cargo/env"
+ '';
+ ".fzf.zsh".text = ''
+ # fzf key bindings (Ctrl-T, Alt-C, Ctrl-R) and completion from the Nix
+ # store; sourced by ~/.dotfiles/config/core.zsh. Quiet when there is no
+ # tty (the scripts restore `zle`, which fails outside a terminal).
+ if [[ -t 0 ]]; then
+ source ${pkgs.fzf}/share/fzf/key-bindings.zsh
+ source ${pkgs.fzf}/share/fzf/completion.zsh
+ else
+ { source ${pkgs.fzf}/share/fzf/key-bindings.zsh; source ${pkgs.fzf}/share/fzf/completion.zsh; } 2>/dev/null
+ fi
+ '';
+ ".zsh/completions".source = generatedCompletions;
+
+ ".tmux/plugins/tpm/tpm" = {
+ text = tpmShim;
+ executable = true;
+ };
+ }
+ // builtins.listToAttrs [
+ (tmuxPlugin "tmux-resurrect" pkgs.tmuxPlugins.resurrect)
+ (tmuxPlugin "tmux-continuum" pkgs.tmuxPlugins.continuum)
+ (tmuxPlugin "tmux-yank" pkgs.tmuxPlugins.yank)
+ (tmuxPlugin "tmux-open" pkgs.tmuxPlugins.open)
+ ];
+
+ xdg.configFile = {
+ # secretspec (https://secretspec.dev): secrets in the system keyring, which
+ # gnome-keyring provides and PAM unlocks at login. Per-project
+ # secretspec.toml files declare what a project needs; `secretspec check`
+ # prompts for anything missing, `secretspec run -- cmd` injects them.
+ "secretspec/config.toml".text = ''
+ [defaults]
+ provider = "keyring"
+ profile = "default"
+ '';
+ };
+
+ # bat only sees a theme after its cache is rebuilt from ~/.config/bat/themes.
+ home.activation.batCache = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
+ if [ -d "$HOME/.config/bat/themes/" ]; then
+ run ${pkgs.bat}/bin/bat cache --build >/dev/null 2>&1 || true
+ fi
+ '';
+ }
+ ;
+}
diff --git a/modules/home/sops.nix b/modules/home/sops.nix
@@ -0,0 +1,48 @@
+# modules/home/sops.nix — sops-nix for the user: the same encrypted file,
+# decrypted into $XDG_RUNTIME_DIR/secrets.d (symlinked at ~/.config/sops-nix/secrets)
+# by a user service at login, readable only by daemonsec.
+#
+# Use this for secrets that belong to the user's programs (API tokens an app
+# reads from a file, an rclone config, …); use modules/hosts/laptop/sops.nix for
+# anything a system service needs.
+#
+# sops.secrets.example = { }; # → ~/.config/sops-nix/secrets/example
+# sops.secrets.rclone = { path = "${config.xdg.configHome}/rclone/rclone.conf"; };
+#
+# secretspec (modules/home/shell.nix) is the complement: per-project runtime
+# secrets pulled from the keyring at `secretspec run`, declared next to the
+# project in secretspec.toml, not in this repo.
+{ ... }:
+{
+ flake.homeModules.sops =
+ { config, inputs, pkgs, lib, ... }:
+ {
+ imports = [ inputs.sops-nix.homeManagerModules.sops ];
+
+ sops = {
+ defaultSopsFile = ../../secrets/secrets.yaml;
+ age.keyFile = "${config.home.homeDirectory}/.config/sops/age/keys.txt";
+ age.sshKeyPaths = [ ];
+ gnupg.sshKeyPaths = [ ];
+ };
+
+ home.packages = with pkgs; [
+ sops
+ age
+ ssh-to-age # turn an ssh-ed25519 key into an age recipient when adding another machine
+ ];
+
+ # sops-nix's activation step restarts the sops-nix user unit. That unit is a
+ # home-manager file (~/.config/systemd/user/sops-nix.service), linked by the
+ # linkGeneration step, and the user systemd manager only sees new unit
+ # files after a daemon-reload, which home-manager runs at the very end. With
+ # the extra steps this config adds, the DAG happened to order sops-nix
+ # before linkGeneration, so the first switch died with "Unit
+ # sops-nix.service not found". This entry pins the order: linkGeneration →
+ # daemon-reload → sops-nix.
+ home.activation.reloadUserUnitsForSops = lib.hm.dag.entryBetween [ "sops-nix" ] [ "linkGeneration" "installPackages" ] ''
+ ${pkgs.systemd}/bin/systemctl --user daemon-reload 2>/dev/null || true
+ '';
+ }
+ ;
+}
diff --git a/modules/home/ssh.nix b/modules/home/ssh.nix
@@ -0,0 +1,51 @@
+# modules/home/ssh.nix — the SSH key and client config, from the flake.
+#
+# The private key is a sops secret (secrets/secrets.yaml: ssh_id_ed25519;
+# `nix-cheat secrets`). At login sops-nix decrypts it with the age key into
+# the runtime secrets dir and ~/.config/sops-nix/secrets/ssh_id_ed25519 points
+# there; ~/.ssh/config names that path, so a fresh machine has a working key
+# as soon as ~/.config/sops/age/keys.txt is restored. The public half is
+# plain text in ~/.ssh/id_ed25519.pub (comment daemonsec@nixos; registered on
+# gitlab.com as "nixos", auth and signing, and glab's git_protocol is ssh).
+#
+# A plain copy from before this module may still sit at ~/.ssh/id_ed25519;
+# nothing reads it any more.
+{ ... }:
+{
+ flake.homeModules.ssh =
+ { config, ... }:
+ let
+ key = config.sops.secrets.ssh_id_ed25519.path;
+ in
+ {
+ sops.secrets.ssh_id_ed25519 = { }; # → %r/secrets.d/…, mode 0400 (ssh is happy with that)
+
+ home.file.".ssh/id_ed25519.pub".text = ''
+ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAsXOt8jkVBgL2ANFgkftVfRlswxBvBUM33Tv/bS+I5Z daemonsec@nixos
+ '';
+
+ programs.ssh = {
+ enable = true;
+ enableDefaultConfig = false;
+ settings = {
+ "gitlab.com" = {
+ User = "git";
+ IdentityFile = key;
+ IdentitiesOnly = "yes";
+ };
+ "github.com" = {
+ User = "git";
+ IdentityFile = key;
+ IdentitiesOnly = "yes";
+ };
+ "*" = {
+ IdentityFile = key;
+ AddKeysToAgent = "yes";
+ ServerAliveInterval = 30;
+ HashKnownHosts = "no";
+ };
+ };
+ };
+ }
+ ;
+}
diff --git a/modules/home/terminal.nix b/modules/home/terminal.nix
@@ -0,0 +1,164 @@
+# modules/home/terminal.nix — kitty with DMMono Nerd Font and Rosé Pine (main).
+#
+# The six patched DMMono TTFs come from the dotfiles checkout (tools.nix links
+# ~/git/daemon-sec-dotfiles/assets/fonts/nerd-fonts-dm-mono into
+# ~/.local/share/fonts). The family has no Bold, so bold maps to Medium.
+#
+# Palette rule from the migration prompt: pine #31748f is a fill, never ink,
+# so it must not sit in an ANSI foreground slot. The Rosé Pine terminal theme
+# puts pine in the green slot; the substitute is PARKED for the owner's
+# decision. Until then `ansiGreen` below carries foam, the colour the toolbox
+# itself uses wherever pine would have been read as text (bin/install.sh).
+#
+# tmux-style keys (2026-10-08): ctrl+a is a prefix, like tmux's, with tabs as
+# tmux windows and kitty windows as tmux panes:
+#
+# ctrl+a c new tab (cwd kept) ctrl+a - split below (pane)
+# ctrl+a n / p next / previous tab ctrl+a | split right
+# ctrl+a 1..9 tab N ctrl+a h j k l focus pane left/down/up/right
+# ctrl+a , rename tab ctrl+a H J K L move pane
+# ctrl+a & close tab ctrl+a o next pane
+# ctrl+a x close pane ctrl+a z zoom pane (stack layout toggle)
+# ctrl+a [ copy mode (scrollback in Neovim: v y, Enter, q)
+# ctrl+a ] paste clipboard ctrl+a space next layout
+# ctrl+a { } swap pane back / forth ctrl+a r reload kitty.conf
+# ctrl+a u pick a URL (hints) ctrl+a f pick a path (hints)
+# ctrl+a ctrl+a send a real ctrl+a to the shell (beginning-of-line)
+# ctrl+a shift+arrows resize pane ctrl+a = reset pane sizes
+#
+# Copy mode is modules/home/kitty/scrollback.lua: the scrollback opens in a bare
+# Neovim (no AstroNvim config) with colours, vi motions and search; y copies
+# to the clipboard, Enter copies and leaves, q or Esc leaves.
+{ ... }:
+{
+ flake.homeModules.terminal =
+ { pkgs, lib, ... }:
+ let
+ ansiGreen = "#9ccfd8"; # PARKED: ask before changing; see header
+
+ # kitty substitutes INPUT_LINE_NUMBER, CURSOR_LINE and CURSOR_COLUMN in the
+ # pager command; the Lua reads them from vim.g.
+ scrollbackPager = lib.concatStringsSep " " [
+ "${pkgs.bash}/bin/bash -c"
+ "'exec ${pkgs.neovim}/bin/nvim 63<&0 0</dev/null"
+ "-u ${./kitty/scrollback.lua}"
+ "-c \"let g:kitty_input_line=INPUT_LINE_NUMBER\""
+ "-c \"let g:kitty_cursor_line=CURSOR_LINE\""
+ "-c \"let g:kitty_cursor_col=CURSOR_COLUMN\"'"
+ ];
+
+ prefix = "ctrl+a";
+ tabKeys = lib.listToAttrs (map (n: {
+ name = "${prefix}>${toString n}";
+ value = "goto_tab ${toString n}";
+ }) (lib.range 1 9));
+ in
+ {
+ fonts.fontconfig.enable = true;
+
+ programs.kitty = {
+ enable = true;
+ font = {
+ name = "DMMono Nerd Font";
+ size = 10;
+ };
+ settings = {
+ bold_font = ''family="DMMono Nerd Font" style="Medium"'';
+ italic_font = ''family="DMMono Nerd Font" style="Italic"'';
+ bold_italic_font = ''family="DMMono Nerd Font" style="Medium Italic"'';
+
+ # Rosé Pine, main (dark)
+ foreground = "#e0def4";
+ background = "#191724";
+ selection_foreground = "#e0def4";
+ selection_background = "#403d52"; # highlight med
+ cursor = "#524f67"; # highlight high
+ cursor_text_color = "#e0def4";
+ url_color = "#c4a7e7";
+
+ active_border_color = "#eb6f92";
+ inactive_border_color = "#6e6a86";
+ active_tab_foreground = "#e0def4";
+ active_tab_background = "#26233a";
+ inactive_tab_foreground = "#6e6a86";
+ inactive_tab_background = "#191724";
+
+ color0 = "#26233a";
+ color8 = "#6e6a86";
+ color1 = "#eb6f92";
+ color9 = "#eb6f92";
+ color2 = ansiGreen;
+ color10 = ansiGreen;
+ color3 = "#f6c177";
+ color11 = "#f6c177";
+ color4 = "#9ccfd8";
+ color12 = "#9ccfd8";
+ color5 = "#c4a7e7";
+ color13 = "#c4a7e7";
+ color6 = "#ebbcba";
+ color14 = "#ebbcba";
+ color7 = "#e0def4";
+ color15 = "#e0def4";
+
+ # tmux-like layout: panes via the splits layout, stack = zoom, tabs on a
+ # bottom status line with their index like tmux's window list.
+ enabled_layouts = "splits,stack";
+ window_border_width = "1pt";
+ inactive_text_alpha = "0.8";
+ tab_bar_edge = "bottom";
+ tab_bar_style = "powerline";
+ tab_powerline_style = "slanted";
+ tab_title_template = "{index}:{title}";
+ active_tab_title_template = "{index}:{title}";
+ scrollback_lines = 20000;
+ scrollback_pager = scrollbackPager;
+ shell_integration = "enabled";
+ # Always zsh, whatever $SHELL the session was started with (a session
+ # begun before the login shell changed still carries SHELL=bash).
+ shell = "${pkgs.zsh}/bin/zsh";
+ };
+
+ keybindings = {
+ # tabs = tmux windows
+ "${prefix}>c" = "new_tab_with_cwd";
+ "${prefix}>n" = "next_tab";
+ "${prefix}>p" = "previous_tab";
+ "${prefix}>," = "set_tab_title";
+ "${prefix}>&" = "close_tab";
+ "${prefix}>w" = "select_tab";
+ # panes = kitty windows
+ "${prefix}>-" = "launch --location=hsplit --cwd=current";
+ "${prefix}>|" = "launch --location=vsplit --cwd=current";
+ "${prefix}>x" = "close_window";
+ "${prefix}>o" = "next_window";
+ "${prefix}>z" = "toggle_layout stack";
+ "${prefix}>space" = "next_layout";
+ "${prefix}>h" = "neighboring_window left";
+ "${prefix}>j" = "neighboring_window down";
+ "${prefix}>k" = "neighboring_window up";
+ "${prefix}>l" = "neighboring_window right";
+ "${prefix}>shift+h" = "move_window left";
+ "${prefix}>shift+j" = "move_window down";
+ "${prefix}>shift+k" = "move_window up";
+ "${prefix}>shift+l" = "move_window right";
+ "${prefix}>{" = "move_window_backward";
+ "${prefix}>}" = "move_window_forward";
+ "${prefix}>shift+left" = "resize_window narrower 3";
+ "${prefix}>shift+right" = "resize_window wider 3";
+ "${prefix}>shift+up" = "resize_window taller 3";
+ "${prefix}>shift+down" = "resize_window shorter 3";
+ "${prefix}>=" = "resize_window reset";
+ # copy mode and paste
+ "${prefix}>[" = "show_scrollback";
+ "${prefix}>]" = "paste_from_clipboard";
+ "${prefix}>u" = "open_url_with_hints";
+ "${prefix}>f" = "kitten hints --type path --program -";
+ # misc
+ "${prefix}>r" = "load_config_file";
+ "${prefix}>?" = "kitten show_key -m kitty";
+ "${prefix}>${prefix}" = "send_text all \\x01";
+ } // tabKeys;
+ };
+ }
+ ;
+}
diff --git a/modules/home/tools.nix b/modules/home/tools.nix
@@ -0,0 +1,121 @@
+# modules/home/tools.nix — runtime closure for the hand-written toolbox in
+# ~/.local/bin, plus the general command-line tools.
+#
+# ~/.local/bin itself is not managed here on purpose: it is a flat git repo
+# (vault README: "git init there afterwards so editing a file edits the live
+# command"). NixOS puts it first on PATH (modules/hosts/laptop/toolbox.nix).
+{ ... }:
+{
+ flake.homeModules.tools =
+ { config, pkgs, lib, ... }:
+ let
+ # ns [query]: fuzzy search of nixpkgs (+ NixOS and home-manager options) in
+ # the terminal, with the package description as the preview. nix-search-tv
+ # indexes search.nixos.org data locally on first run and refreshes it itself.
+ # Enter print the attribute name (e.g. to paste into tools.nix)
+ # ctrl-o open the homepage ctrl-s open the nixpkgs source
+ # ctrl-y copy the attribute name
+ ns = pkgs.writeShellScriptBin "ns" ''
+ nst=${pkgs.nix-search-tv}/bin/nix-search-tv
+ exec $nst print | ${pkgs.fzf}/bin/fzf \
+ --query="$*" --scheme=history --prompt='nix › ' \
+ --preview="$nst preview {}" --preview-window='right,60%,border-left,wrap' \
+ --bind="ctrl-o:execute-silent(${pkgs.xdg-utils}/bin/xdg-open \$($nst homepage {}))" \
+ --bind="ctrl-s:execute-silent(${pkgs.xdg-utils}/bin/xdg-open \$($nst source {}))" \
+ --bind="ctrl-y:execute-silent(printf %s {} | ${pkgs.wl-clipboard}/bin/wl-copy)" \
+ --header='enter: print · ctrl-y: copy · ctrl-o: homepage · ctrl-s: source'
+ '';
+
+ pythonEnv = pkgs.python3.withPackages (ps: with ps; [
+ cryptography
+ argon2-cffi
+ rich
+ questionary
+ pikepdf
+ mutagen
+ pillow
+ numpy
+ pyqt6
+ youtube-transcript-api
+ ]);
+ in
+ {
+ home.packages = with pkgs; [
+ pythonEnv
+ ns
+ nix-search-tv # `ns`, and `nix-search-tv print|preview` by hand
+ perl
+ git
+ jujutsu
+ gnutar
+ zstd
+ pigz
+ xz
+ rsync
+ rclone
+ aria2
+ p7zip
+ libarchive
+ gnupg
+ openssl
+ pinentry-gnome3
+ ffmpeg
+ yt-dlp
+ atomicparsley
+ gallery-dl
+ imagemagick
+ img2pdf
+ resvg
+ zathura
+ calibre
+ poppler-utils
+ starship
+ bat
+ eza
+ fd
+ ripgrep
+ fzf
+ zoxide
+ atuin
+ jq
+ curl
+ wget
+ gh
+ fastfetch
+ wl-clipboard
+ libnotify
+
+ # General tools (2026-10-08): what the dotfiles' zsh modules look for
+ # (modern.zsh, core.zsh) and what the stock Omarchy keys expect.
+ uv # PEP-723 scripts in ~/.local/bin (dcx, dorkforge); managed Pythons work via nix-ld
+ nodejs
+ btop
+ lazygit
+ lazydocker
+ tealdeer # `tldr`
+ dust
+ duf
+ procs
+ difftastic
+ hyperfine
+ glow
+ onefetch
+ tokei
+ xh
+ ncdu
+ parallel
+ unzip
+ zip
+ tree
+ file
+ cbonsai
+ cmatrix
+ localsend # phone ↔ laptop file drops on the LAN; port 53317 is open in modules/hosts/laptop/configuration.nix
+ vesktop # Discord client; its config is linked from the dotfiles (dotfiles.nix)
+ ];
+
+ # Browser
+ programs.floorp.enable = true;
+ }
+ ;
+}
diff --git a/modules/home/yazi.nix b/modules/home/yazi.nix
@@ -0,0 +1,135 @@
+# modules/home/yazi.nix — yazi, the terminal file manager, with previews.
+#
+# Pictures preview inline in kitty (its graphics protocol; yazi draws them
+# itself, nothing else needed), video frames via ffmpeg, PDFs via poppler,
+# SVG via resvg, archives via 7z, JSON via jq, code with syntax colours, and
+# the rest as text. Enter / l on a file opens it: images in imv, video and
+# audio in mpv, PDFs and books in zathura, text in $EDITOR, anything else via
+# xdg-open; o shows every opener. The toolbox's zimg/zbook/comx/gamex/dux
+# are offered where they apply (they live in ~/.local/bin).
+#
+# y start yazi and cd to where you left it (zsh wrapper)
+# T maximise the preview pane (toggle-pane) ! shell here
+# <C-e>/<C-y> scroll the preview . toggle hidden
+# l / Enter smart-enter: open a file, enter a directory
+# Theme: Rosé Pine (modules/home/yazi/flavors). Git status marks via the git plugin.
+{ ... }:
+{
+ flake.homeModules.yazi =
+ { pkgs, ... }:
+ {
+ programs.yazi = {
+ enable = true;
+ enableZshIntegration = true;
+ shellWrapperName = "y";
+ extraPackages = with pkgs; [
+ ffmpeg
+ poppler-utils
+ imagemagick
+ resvg
+ p7zip
+ jq
+ fd
+ ripgrep
+ fzf
+ zoxide
+ file
+ mediainfo
+ imv
+ ];
+
+ settings = {
+ mgr = {
+ show_hidden = false;
+ show_symlink = true;
+ sort_by = "natural";
+ sort_sensitive = false;
+ sort_dir_first = true;
+ linemode = "size";
+ scrolloff = 5;
+ };
+ preview = {
+ max_width = 1600;
+ max_height = 1600;
+ image_delay = 20;
+ image_filter = "lanczos3";
+ image_quality = 90;
+ wrap = "yes";
+ };
+ opener = {
+ edit = [ { run = ''${EDITOR:-nvim} "$@"''; desc = "Edit"; block = true; for = "unix"; } ];
+ open = [ { run = ''xdg-open "$1"''; desc = "Open (system default)"; orphan = true; for = "unix"; } ];
+ view-image = [
+ { run = ''imv "$@"''; desc = "View (imv)"; orphan = true; for = "unix"; }
+ { run = ''zimg "$1"''; desc = "View in zathura (zimg)"; orphan = true; for = "unix"; }
+ { run = ''xdg-open "$1"''; desc = "Open (system default)"; orphan = true; for = "unix"; }
+ ];
+ play = [
+ { run = ''mpv "$@"''; desc = "Play (mpv)"; orphan = true; for = "unix"; }
+ { run = ''mpv --no-video "$@"''; desc = "Play audio only (mpv)"; block = true; for = "unix"; }
+ ];
+ open-pdf = [
+ { run = ''zathura "$@"''; desc = "Open (zathura)"; orphan = true; for = "unix"; }
+ { run = ''xdg-open "$1"''; desc = "Open (system default)"; orphan = true; for = "unix"; }
+ ];
+ open-book = [
+ { run = ''zbook "$1"''; desc = "Read in zathura (zbook)"; orphan = true; for = "unix"; }
+ { run = ''zathura "$@"''; desc = "Open (zathura)"; orphan = true; for = "unix"; }
+ ];
+ open-comic = [
+ { run = ''comx "$1"''; desc = "Guided view (comx)"; orphan = true; for = "unix"; }
+ { run = ''zbook "$1"''; desc = "Panels in zathura (zbook)"; orphan = true; for = "unix"; }
+ { run = ''7z x -y "$1"''; desc = "Extract here (7z)"; block = true; for = "unix"; }
+ ];
+ extract = [ { run = ''7z x -y "$1"''; desc = "Extract here (7z)"; for = "unix"; } ];
+ run-exe = [
+ { run = ''gamex run "$1"''; desc = "Run (gamex, dGPU)"; block = true; for = "unix"; }
+ { run = ''gamex proton "$1"''; desc = "Run with Proton-GE"; block = true; for = "unix"; }
+ ];
+ };
+ open = {
+ rules = [
+ { mime = "image/gif"; use = [ "view-image" "open" ]; }
+ { mime = "image/*"; use = [ "view-image" "open" ]; }
+ { mime = "video/*"; use = [ "play" "open" ]; }
+ { mime = "audio/*"; use = [ "play" "open" ]; }
+ { mime = "application/pdf"; use = [ "open-pdf" "open" ]; }
+ { mime = "application/epub+zip"; use = [ "open-book" "open" ]; }
+ { name = "*.cbz"; use = [ "open-comic" "extract" ]; }
+ { name = "*.cbr"; use = [ "open-comic" "extract" ]; }
+ { name = "*.exe"; use = [ "run-exe" "open" ]; }
+ { mime = "application/{zip,gzip,x-tar,x-bzip*,x-7z-compressed,x-rar,x-xz,zstd}"; use = [ "extract" "open" ]; }
+ { mime = "inode/directory"; use = [ "edit" "open" ]; }
+ { mime = "text/*"; use = [ "edit" "open" ]; }
+ { mime = "application/{json,toml,x-ndjson,javascript,x-sh,x-shellscript,xml}"; use = [ "edit" "open" ]; }
+ { mime = "*"; use = [ "open" "edit" ]; }
+ ];
+ };
+ };
+
+ keymap.mgr.prepend_keymap = [
+ { on = [ "!" ]; run = ''shell "$SHELL" --block''; desc = "Open a shell here"; }
+ { on = [ "<C-e>" ]; run = "seek 5"; desc = "Scroll preview down"; }
+ { on = [ "<C-y>" ]; run = "seek -5"; desc = "Scroll preview up"; }
+ { on = [ "l" ]; run = "plugin smart-enter"; desc = "Enter directory or open file"; }
+ { on = [ "<Enter>" ]; run = "plugin smart-enter"; desc = "Enter directory or open file"; }
+ { on = [ "T" ]; run = "plugin toggle-pane max-preview"; desc = "Maximise the preview"; }
+ { on = [ "u" "d" ]; run = "shell -- dux %h %s"; desc = "Copy file(s) to the clipboard as a paste-able file"; }
+ ];
+
+ plugins = with pkgs.yaziPlugins; {
+ inherit full-border git smart-enter toggle-pane;
+ };
+ flavors.rose-pine = ./yazi/flavors/rose-pine.yazi;
+ theme.flavor = {
+ dark = "rose-pine";
+ light = "rose-pine";
+ };
+ initLua = ''
+ require("full-border"):setup()
+ require("git"):setup()
+ '';
+ };
+ }
+ ;
+}
diff --git a/home/yazi/flavors/rose-pine.yazi/flavor.toml b/modules/home/yazi/flavors/rose-pine.yazi/flavor.toml
diff --git a/home/yazi/flavors/rose-pine.yazi/tmtheme.xml b/modules/home/yazi/flavors/rose-pine.yazi/tmtheme.xml
diff --git a/modules/hosts/laptop/configuration.nix b/modules/hosts/laptop/configuration.nix
@@ -0,0 +1,156 @@
+# modules/hosts/laptop/configuration.nix — the laptop as a NixOS module
+# (self.nixosModules.laptop), assembled from the named modules it imports.
+#
+# PCSpecialist Valeon II 17 (TongFang GM7RGxM): Ryzen 9 6900HX, Radeon 680M at
+# 06:00.0, RTX 3070 Ti Laptop at 01:00.0, 2560x1440@240 panel. Hyprland under
+# uwsm, Caelestia Shell from home-manager (modules/home/), or Niri with
+# Noctalia Shell (modules/features/desktop/), greetd + tuigreet to log in.
+{ self, ... }:
+{
+ flake.nixosModules.laptop =
+ { config, pkgs, lib, user, ... }:
+ {
+ imports = with self.nixosModules; [
+ laptop-hardware
+ laptop-fan-throttle-guard # dead-GPU-fan workaround (vault gpu-fan-fix/, imported unchanged)
+ laptop-fan-extras # the one imperative step fanfix install did: the performance profile
+ laptop-uniwill # the `uniwill` hwmon the guard reads: kernel 6.19 driver built for this kernel
+ laptop-nvidia
+ laptop-ssd
+ laptop-nix-settings # nix daemon settings, caches, nh
+ laptop-toolbox # envfs, ~/.local/bin on PATH, the touchpad udev rule
+ laptop-sops # sops-nix: secrets/secrets.yaml → /run/secrets
+ laptop-fan-cli # fan-ec: root side of the `fan` command (modules/home/fan.nix), passwordless for wheel
+ workstation # Claude Code / desktop, Obsidian, git, gh, glab
+ home-manager # the user's home, built with the system (modules/home/)
+ desktop-options # daemon.desktop.* switches
+ desktop-hyprland # Hyprland + Caelestia (NixOS side)
+ desktop-niri # Niri + Noctalia: the wrapped package as the login session
+ ];
+
+ # The desktops. Both are installed and chosen at login; set one to false to
+ # drop it (modules/features/desktop/options.nix).
+ daemon.desktop = {
+ hyprland.enable = true;
+ niri.enable = true;
+ };
+
+ boot.loader.systemd-boot.enable = true;
+ boot.loader.efi.canTouchEfiVariables = true;
+
+ networking.hostName = "nixos";
+ networking.networkmanager.enable = true;
+ # LocalSend (modules/home/tools.nix) discovers peers and receives on 53317.
+ networking.firewall.allowedTCPPorts = [ 53317 ];
+ networking.firewall.allowedUDPPorts = [ 53317 ];
+
+ time.timeZone = "Europe/Isle_of_Man";
+ i18n.defaultLocale = "en_US.UTF-8";
+ services.xserver.xkb = {
+ layout = "us";
+ options = "compose:caps,shift:both_capslock_cancel";
+ };
+
+ nixpkgs.config.allowUnfree = true; # nvidia, obsidian, claude-desktop
+
+ users.users.${user} = {
+ isNormalUser = true;
+ description = "daemon-sec";
+ extraGroups = [ "networkmanager" "wheel" ];
+ shell = pkgs.zsh;
+ };
+
+ ##### Shell ##################################################################
+ # zsh is the login shell. Its configuration is the dotfiles' ZDOTDIR tree
+ # (modules/home/shell.nix): core.zsh runs a cached compinit and theme.zsh
+ # starts starship, so the global compinit and the default prompt stay off.
+ # /etc/zshrc still puts every profile's share/zsh/site-functions on fpath.
+ programs.zsh = {
+ enable = true;
+ enableGlobalCompInit = false;
+ promptInit = "";
+ };
+
+ # Binaries that are not built by Nix (uv-managed Pythons and their wheels,
+ # anything mise or npm downloads) expect /lib64/ld-linux-x86-64.so.2.
+ programs.nix-ld.enable = true;
+
+ ##### Desktop ################################################################
+ # The compositors themselves are features (modules/features/desktop/): this
+ # section is what every desktop shares — the greeter, portals, polkit, audio.
+
+ # Display manager: greetd with the tuigreet text greeter. Remembers the last
+ # user and session, so a boot is: password, Enter. No theme engine, no X.
+ services.greetd = {
+ enable = true;
+ useTextGreeter = true;
+ settings.default_session.command = lib.concatStringsSep " " [
+ "${pkgs.tuigreet}/bin/tuigreet"
+ "--time"
+ "--remember"
+ "--remember-session"
+ "--asterisks"
+ "--sessions ${config.services.displayManager.sessionData.desktops}/share/wayland-sessions"
+ ];
+ };
+ security.pam.services.greetd.enableGnomeKeyring = true; # unlock the keyring at login (Claude desktop uses it)
+
+ security.polkit.enable = true; # agent: hyprpolkitagent user service (modules/home/hyprland.nix)
+ services.udisks2.enable = true; # udiskie
+ services.power-profiles-daemon.enable = true; # the fan fix depends on it
+ services.gnome.gnome-keyring.enable = true;
+ programs.dconf.enable = true;
+
+ services.pulseaudio.enable = false;
+ security.rtkit.enable = true;
+ services.pipewire = {
+ enable = true;
+ alsa.enable = true;
+ alsa.support32Bit = true;
+ pulse.enable = true;
+ wireplumber.enable = true;
+ };
+
+ programs.firefox.enable = true;
+ services.printing.enable = true;
+
+ programs.gnupg.agent = {
+ enable = true;
+ pinentryPackage = pkgs.pinentry-gnome3;
+ };
+
+ ##### Fonts ##################################################################
+ # DMMono Nerd Font is not in nixpkgs; modules/home/tools.nix links it from
+ # ~/git/daemon-sec-dotfiles into ~/.local/share/fonts.
+ fonts.packages = with pkgs; [
+ noto-fonts
+ noto-fonts-color-emoji
+ noto-fonts-cjk-sans
+ rubik # Caelestia clock font
+ material-symbols # Caelestia icons
+ ];
+ fonts.fontconfig.defaultFonts = {
+ monospace = [ "DMMono Nerd Font" "Noto Sans Mono" ];
+ sansSerif = [ "Noto Sans" ];
+ serif = [ "Noto Serif" ];
+ emoji = [ "Noto Color Emoji" ];
+ };
+
+ ##### Session environment ####################################################
+ # uwsm imports these through the login shell. GPU-specific ones are in nvidia.nix.
+ environment.sessionVariables = {
+ ELECTRON_OZONE_PLATFORM_HINT = "auto";
+ GDK_BACKEND = "wayland,x11";
+ QT_QPA_PLATFORM = "wayland;xcb";
+ QT_WAYLAND_DISABLE_WINDOWDECORATION = "1";
+ };
+
+ environment.systemPackages = with pkgs; [
+ pciutils # lspci
+ usbutils
+ ];
+
+ system.stateVersion = "26.05";
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/default.nix b/modules/hosts/laptop/default.nix
@@ -0,0 +1,17 @@
+# modules/hosts/laptop/default.nix — the system this laptop boots.
+#
+# nh os switch (NH_FLAKE = ~/NixDaemon, picked by hostname)
+# sudo nixos-rebuild switch --flake ~/NixDaemon#nixos (the plain way)
+#
+# Everything the machine is comes from self.nixosModules.laptop (configuration.nix).
+{ self, inputs, ... }:
+{
+ flake.nixosConfigurations.nixos = inputs.nixpkgs.lib.nixosSystem {
+ system = "x86_64-linux";
+ specialArgs = {
+ inherit inputs;
+ user = "daemonsec";
+ };
+ modules = [ self.nixosModules.laptop ];
+ };
+}
diff --git a/modules/hosts/laptop/fan-cli.nix b/modules/hosts/laptop/fan-cli.nix
@@ -0,0 +1,127 @@
+# modules/hosts/laptop/fan-cli.nix — root side of the `fan` command (modules/home/fan.nix).
+#
+# `fan-ec` talks to the embedded controller the way fanfix does (same
+# acpi_call recipe, same registers; see ~/.local/bin/fanfix and the TUXEDO
+# driver), but it is a fixed script in the Nix store, so the wheel group may
+# run it through sudo without a password. That is what lets the watchdog in
+# fan.nix put the fans back to EC-automatic from a background unit, where
+# sudo could not ask for one. fanfix itself lives in the user-writable
+# ~/.local/bin and must never get such a rule.
+#
+# fan-ec status mode, duty %, EC flags fan-ec max 100 % (manual)
+# fan-ec auto hand control back to the EC fan-ec <30-100> fixed % (manual)
+# fan-ec mode one word: auto | manual | curve-daemon
+#
+# Manual mode on this laptop makes the EC assert PROCHOT (all cores 399 MHz)
+# under load (fan-throttle-guard.nix, line ~97). fan-ec does not try to
+# prevent that; the user-side watchdog detects the clamp and calls `fan-ec auto`.
+{ ... }:
+{
+ flake.nixosModules.laptop-fan-cli =
+ { pkgs, lib, ... }:
+ let
+ fan-ec = pkgs.writeShellScriptBin "fan-ec" ''
+ set -uo pipefail
+ [ "$(id -u)" = 0 ] || { echo "fan-ec: run as root (sudo fan-ec …)" >&2; exit 1; }
+ PATH=${lib.makeBinPath [ pkgs.coreutils pkgs.kmod pkgs.systemd ]}:$PATH
+
+ ACPI_CALL=/proc/acpi/call
+ EC_DEV='\_SB.INOU'
+ FAN_UNIT=fanfix-fan.service
+ FAN_MIN_PCT=30
+
+ ec_ready() { [ -w "$ACPI_CALL" ] || modprobe acpi_call 2>/dev/null; [ -w "$ACPI_CALL" ]; }
+ ec_raw() { printf '%s' "$1" > "$ACPI_CALL" || return 1; local o; o=$(tr -d '\0\n\r ' < "$ACPI_CALL"); printf '%s' "$o"; }
+ ec_read() { local out; out=$(ec_raw "$(printf '%s.ECRR 0x%04x' "$EC_DEV" "$1")") || return 1
+ [[ "$out" =~ ^0x[0-9a-fA-F]+$ ]] || { echo "EC read $(printf '0x%04x' "$1") failed: $out" >&2; return 1; }
+ echo $(( out )); }
+ ec_write() { local out; out=$(ec_raw "$(printf '%s.ECRW 0x%04x 0x%02x' "$EC_DEV" "$1" "$2")") || return 1
+ case "$out" in Error*|"not called"|"") echo "EC write $(printf '0x%04x' "$1") failed: ''${out:-no result}" >&2; return 1;; esac
+ sleep 0.005; }
+ ec_set_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v | $2 )); }
+ ec_clear_bits() { local v; v=$(ec_read "$1") || return 1; ec_write "$1" $(( v & ~$2 & 0xff )); }
+ ec_bit() { local v; v=$(ec_read "$1") || return 1; echo $(( (v >> $2) & 1 )); }
+
+ R_AP_OEM=0x0741; R_FAN_MODE=0x0751; R_PWM1=0x075B; R_PWM2=0x075C
+ R_FAN_CTRL=0x078E; R_TBL_SPLIT=0x07C5; R_TBL_ENABLE=0x07C6
+ R_CPU_TBL_END=0x0F00; R_CPU_TBL_START=0x0F10; R_CPU_TBL_SPEED=0x0F20
+ R_GPU_TBL_END=0x0F30; R_GPU_TBL_START=0x0F40; R_GPU_TBL_SPEED=0x0F50
+ R_PWM1_W=0x1804; R_PWM2_W=0x1809
+
+ universal_ctrl() { ec_bit $R_FAN_CTRL 6; }
+ tables_enabled() { ec_bit $R_TBL_ENABLE 2; }
+ pct_to_duty() { echo $(( $1 * 200 / 100 )); }
+ duty_to_pct() { echo $(( $1 * 100 / 200 )); }
+
+ fan_init_tables() {
+ local i
+ ec_clear_bits $R_FAN_MODE 0x40
+ [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] || ec_set_bits $R_TBL_SPLIT 0x80
+ ec_write $R_CPU_TBL_END 115; ec_write $R_CPU_TBL_START 0; ec_write $R_CPU_TBL_SPEED 1
+ ec_write $R_GPU_TBL_END 120; ec_write $R_GPU_TBL_START 0; ec_write $R_GPU_TBL_SPEED 1
+ for i in $(seq 1 15); do
+ ec_write $(( R_CPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_CPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_CPU_TBL_SPEED + i )) 200
+ ec_write $(( R_GPU_TBL_END + i )) $(( 115 + i + 1 )); ec_write $(( R_GPU_TBL_START + i )) $(( 115 + i )); ec_write $(( R_GPU_TBL_SPEED + i )) 200
+ done
+ [ "$(tables_enabled)" = 1 ] || ec_set_bits $R_TBL_ENABLE 0x04
+ }
+ fan_apply_duty() {
+ local d=$1
+ if [ "$(universal_ctrl)" = 1 ]; then
+ [ "$(tables_enabled)" = 1 ] && [ "$(ec_bit $R_FAN_MODE 6)" = 0 ] || fan_init_tables
+ ec_write $R_CPU_TBL_SPEED "$d"; ec_write $R_GPU_TBL_SPEED "$d"
+ ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"
+ else
+ local i; [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || ec_set_bits $R_FAN_MODE 0x40
+ for i in 1 2 3 4 5 6 7 8 9 10; do ec_write $R_PWM1_W "$d"; ec_write $R_PWM2_W "$d"; sleep 0.01; done
+ fi
+ }
+ fan_set_auto() {
+ if [ "$(universal_ctrl)" = 1 ]; then
+ [ "$(tables_enabled)" = 1 ] && ec_clear_bits $R_TBL_ENABLE 0x04
+ [ "$(ec_bit $R_TBL_SPLIT 7)" = 1 ] && ec_clear_bits $R_TBL_SPLIT 0x80
+ fi
+ [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] && ec_clear_bits $R_FAN_MODE 0x40
+ return 0
+ }
+ mode_word() {
+ if systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null; then echo curve-daemon; return; fi
+ if [ "$(ec_bit $R_FAN_MODE 6)" = 1 ] || [ "$(tables_enabled)" = 1 ]; then echo manual; else echo auto; fi
+ }
+ stop_daemon() { systemctl is-active --quiet "$FAN_UNIT" 2>/dev/null && systemctl stop "$FAN_UNIT"; return 0; }
+ guard() { ec_ready || { echo "fan-ec: acpi_call not available" >&2; exit 1; }
+ ec_read $R_AP_OEM >/dev/null || { echo "fan-ec: EC not reachable via $EC_DEV.ECRR" >&2; exit 1; }; }
+
+ case "''${1:-status}" in
+ mode) guard; mode_word ;;
+ status) guard
+ printf 'mode=%s duty=%s%% target=%s%% fan-abnormal=%s full-fan-bit=%s tables=%s\n' \
+ "$(mode_word)" "$(duty_to_pct "$(ec_read $R_PWM1)")" \
+ "$([ "$(tables_enabled)" = 1 ] && duty_to_pct "$(ec_read $R_CPU_TBL_SPEED)" || echo -)" \
+ "$(ec_bit $R_AP_OEM 5)" "$(ec_bit $R_FAN_MODE 6)" "$(tables_enabled)" ;;
+ auto) guard; stop_daemon; fan_set_auto && echo "fans: auto (EC curve)" ;;
+ max) guard; stop_daemon; fan_apply_duty 200 && echo "fans: 100 % (manual)" ;;
+ [0-9]*) p=''${1%\%}; [[ "$p" =~ ^[0-9]+$ ]] && [ "$p" -le 100 ] && [ "$p" -ge "$FAN_MIN_PCT" ] \
+ || { echo "fan-ec: percent must be $FAN_MIN_PCT-100 (below that the EC trips at 79 °C)" >&2; exit 2; }
+ guard; stop_daemon; fan_apply_duty "$(pct_to_duty "$p")" && echo "fans: $p % (manual)" ;;
+ *) echo "usage: fan-ec status|mode|auto|max|<30-100>" >&2; exit 2 ;;
+ esac
+ '';
+ in
+ {
+ environment.systemPackages = [ fan-ec ];
+
+ # wheel may run fan-ec without a password: it is immutable store content
+ # (via the system profile symlink, which is root-owned), does one thing,
+ # and the watchdog has no terminal to type into.
+ security.sudo.extraRules = [
+ {
+ groups = [ "wheel" ];
+ commands = [
+ { command = "/run/current-system/sw/bin/fan-ec"; options = [ "NOPASSWD" ]; }
+ ];
+ }
+ ];
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/fan-extras.nix b/modules/hosts/laptop/fan-extras.nix
@@ -0,0 +1,30 @@
+# modules/hosts/laptop/fan-extras.nix
+#
+# The part of the fan fix that `fanfix install` did imperatively on Arch and
+# that fan-throttle-guard.nix (imported unchanged) does not carry: the
+# "performance" power profile. power-profiles-daemon persists the choice, so
+# this oneshot is idempotent; it runs after ppd is up and then re-applies the
+# tmpfiles clock floor, because a profile switch rewrites per-policy boost and
+# can lift scaling_max_freq (fanfix re-runs tmpfiles for the same reason; the
+# stability guard would also catch it within a second).
+{ ... }:
+{
+ flake.nixosModules.laptop-fan-extras =
+ { pkgs, lib, ... }:
+ {
+ systemd.services.fanfix-performance-profile = {
+ description = "fanfix: select the performance power profile and re-assert the clock floor";
+ after = [ "power-profiles-daemon.service" "systemd-tmpfiles-setup.service" ];
+ requires = [ "power-profiles-daemon.service" ];
+ # graphical.target, not multi-user: on NixOS power-profiles-daemon is itself
+ # ordered After=multi-user.target, so multi-user here is an ordering cycle.
+ wantedBy = [ "graphical.target" ];
+ serviceConfig = {
+ Type = "oneshot";
+ ExecStart = "${pkgs.power-profiles-daemon}/bin/powerprofilesctl set performance";
+ ExecStartPost = "${pkgs.systemd}/bin/systemd-tmpfiles --create --prefix=/sys/devices/system/cpu";
+ };
+ };
+ }
+ ;
+}
diff --git a/hosts/laptop/fan-reference/99-cpu-freq-cap.conf b/modules/hosts/laptop/fan-reference/99-cpu-freq-cap.conf
diff --git a/hosts/laptop/fan-reference/README.md b/modules/hosts/laptop/fan-reference/README.md
diff --git a/hosts/laptop/fan-reference/fan-ctl b/modules/hosts/laptop/fan-reference/fan-ctl
diff --git a/hosts/laptop/fan-reference/fan-state b/modules/hosts/laptop/fan-reference/fan-state
diff --git a/hosts/laptop/fan-reference/fanfix-fan.service b/modules/hosts/laptop/fan-reference/fanfix-fan.service
diff --git a/hosts/laptop/fan-reference/fanfix.conf b/modules/hosts/laptop/fan-reference/fanfix.conf
diff --git a/hosts/laptop/fan-reference/modules-load.conf b/modules/hosts/laptop/fan-reference/modules-load.conf
diff --git a/hosts/laptop/fan-reference/motherboard-stability.service b/modules/hosts/laptop/fan-reference/motherboard-stability.service
diff --git a/hosts/laptop/fan-reference/uniwill-laptop.conf b/modules/hosts/laptop/fan-reference/uniwill-laptop.conf
diff --git a/modules/hosts/laptop/fan-throttle-guard.nix b/modules/hosts/laptop/fan-throttle-guard.nix
@@ -0,0 +1,127 @@
+# modules/hosts/laptop/fan-throttle-guard.nix
+#
+# Dead-GPU-fan workaround for the PCSpecialist Valeon II 17 (TongFang GM7RGxM,
+# Ryzen 9 6900HX + RTX 3070 Ti Laptop). The embedded controller sees the dead
+# "Secondary" fan (fan2: 0 rpm while commanded 100 %), raises its fan-abnormal
+# flag and, once Tctl reaches ~79 °C, asserts PROCHOT and pins all 16 threads at
+# 399 MHz until ~47 °C. That policy is firmware; Linux cannot switch it off.
+# The fix is to keep the CPU from ever reaching the trip point:
+#
+# 1. a static scaling_max_freq floor of 3.2 GHz applied by tmpfiles at boot
+# (3.2 GHz ≈ base clock → ~67-70 °C under all-core load, no trips),
+# 2. a staged guard (3200 → 2400 → 1800 MHz) driven by k10temp + the uniwill
+# board sensor, which also covers the "latched" low-temperature clamp,
+# 3. the surviving CPU fan held at 60 % duty through the EC's own ACPI
+# methods (acpi_call → \_SB.INOU.ECRR/ECRW, TUXEDO register recipe),
+# 4. power-profiles-daemon kept on, profile "performance", and the global
+# cpufreq boost flag left at 1 — never use boost=0, ppd 0.30 writes
+# per-policy boost on every switch and fails with EINVAL otherwise.
+#
+# Everything here was measured on the Arch install this was captured from
+# (fanfix 2026-08-23, stability guard 2026-09-07). Files beside this module:
+# fanfix the CLI/daemon (bash) ← copied verbatim
+# stability_guard.py the staged ceiling (python3) ← copied verbatim
+# fan-ctl, fan-state bar-widget helpers; need a polkit agent and a bar slot
+#
+# Verify on first boot: fanfix status · fanfix fan status · fanfix test 30
+# expected: cap 3200 MHz, boost 1, profile performance, no THROTTLE event,
+# peak < 75 °C. If a trip still happens: lower the floor to 3000000 below.
+{ ... }:
+{
+ flake.nixosModules.laptop-fan-throttle-guard =
+ { config, pkgs, lib, ... }:
+
+ let
+ # fanfix is plain bash; wrap it so the shebang resolves and PATH is supplied
+ # by the unit (fanDeps) rather than by whatever shell invoked it.
+ fanfix = pkgs.writeShellScriptBin "fanfix" (builtins.readFile ./fanfix);
+
+ fanDeps = with pkgs; [
+ coreutils gnugrep gawk gnused procps util-linux
+ kmod # modprobe acpi_call / uniwill-laptop
+ systemd # systemctl, systemd-tmpfiles
+ power-profiles-daemon # powerprofilesctl
+ ];
+
+ capKhz = 3200000; # the floor. 3000000 is the documented fallback.
+ in
+ {
+ ##### 1. EC access and fan/temperature readout ##############################
+ # acpi_call is out-of-tree (nixpkgs: linuxPackages.acpi_call). uniwill-laptop
+ # is in-tree; `force=1` is required because the DMI match list does not carry
+ # this GM7RGxM. Verify `modinfo uniwill-laptop` exists on the chosen kernel.
+ boot.extraModulePackages = [ config.boot.kernelPackages.acpi_call ];
+ boot.kernelModules = [ "acpi_call" "uniwill-laptop" ];
+ boot.extraModprobeConfig = ''
+ options uniwill-laptop force=1
+ '';
+
+ ##### 2. Static floor, applied before any user load exists ###################
+ systemd.tmpfiles.rules = [
+ "w /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq - - - - ${toString capKhz}"
+ ];
+
+ ##### 3. power-profiles-daemon stays on ######################################
+ services.power-profiles-daemon.enable = true;
+
+ ##### 4. Staged thermal ceiling (replaces /etc/systemd/system/motherboard-stability.service)
+ systemd.services.motherboard-stability = {
+ description = "CPU stability limits for the GM7RGxM fan/power fault workaround";
+ after = [ "systemd-tmpfiles-setup.service" ];
+ wantedBy = [ "multi-user.target" ];
+ serviceConfig = {
+ Type = "simple";
+ ExecStart = "${pkgs.python3}/bin/python3 -I ${./stability_guard.py}";
+ Restart = "on-failure";
+ RestartSec = 3;
+ RuntimeDirectory = "motherboard-stability";
+ RuntimeDirectoryMode = "0755";
+ NoNewPrivileges = true;
+ ProtectSystem = "strict";
+ ProtectHome = true;
+ ReadWritePaths = [ "/sys/devices/system/cpu" "/run/motherboard-stability" ];
+ PrivateTmp = true;
+ PrivateDevices = true;
+ ProtectKernelModules = true;
+ ProtectControlGroups = true;
+ RestrictAddressFamilies = "AF_UNIX";
+ LockPersonality = true;
+ RestrictSUIDSGID = true;
+ CapabilityBoundingSet = "";
+ UMask = "0022";
+ };
+ };
+
+ ##### 5. Surviving CPU fan at a fixed 60 % duty ##############################
+ # CURVE is "temp:pct …" pairs; a flat 0:60 100:60 is what has been running.
+ # fanfix refuses anything below 30 %. Edit here, not in /etc, then rebuild.
+ #
+ # NOT started at boot (wantedBy = []). Measured 2026-10-07 on NixOS: while the
+ # daemon holds the EC in manual/custom-table fan mode, the EC asserts PROCHOT
+ # (all cores 399 MHz) the moment any load starts, even at 37 °C. Stopping the
+ # unit and `fanfix fan auto` cleared it instantly; 10 s all-core test then ran
+ # at 3112 MHz, peak 53 °C, 0 trips. The 3.2 GHz floor + stability guard are
+ # enough on their own. Start by hand to experiment: systemctl start fanfix-fan
+ environment.etc."fanfix.conf".text = ''
+ CURVE="0:60 100:60"
+ '';
+
+ systemd.services.fanfix-fan = {
+ description = "fanfix: temperature → fan-duty curve for the surviving CPU fan (dead GPU fan workaround)";
+ after = [ "multi-user.target" ];
+ wantedBy = [ ]; # see note above; manual start only
+ path = fanDeps;
+ serviceConfig = {
+ Type = "simple";
+ ExecStart = "${fanfix}/bin/fanfix fan-daemon";
+ ExecStopPost = "${fanfix}/bin/fanfix fan-release";
+ Restart = "on-failure";
+ RestartSec = 5;
+ };
+ };
+
+ ##### 6. Tools on PATH ######################################################
+ environment.systemPackages = [ fanfix pkgs.lm_sensors ] ++ fanDeps;
+ }
+ ;
+}
diff --git a/hosts/laptop/fanfix b/modules/hosts/laptop/fanfix
diff --git a/modules/hosts/laptop/hardware.nix b/modules/hosts/laptop/hardware.nix
@@ -0,0 +1,50 @@
+# Do not modify this file! It was generated by ‘nixos-generate-config’
+# and may be overwritten by future invocations. Please make changes
+# to /etc/nixos/configuration.nix instead.
+{ ... }:
+{
+ flake.nixosModules.laptop-hardware =
+ { config, lib, pkgs, modulesPath, ... }:
+
+ {
+ imports =
+ [ (modulesPath + "/installer/scan/not-detected.nix")
+ ];
+
+ boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "usbhid" "usb_storage" "sd_mod" ];
+ boot.initrd.kernelModules = [ ];
+ boot.kernelModules = [ "kvm-amd" ];
+ boot.extraModulePackages = [ ];
+
+ fileSystems."/" =
+ { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
+ fsType = "btrfs";
+ };
+
+ fileSystems."/home" =
+ { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
+ fsType = "btrfs";
+ options = [ "subvol=home" ];
+ };
+
+ fileSystems."/nix" =
+ { device = "/dev/disk/by-uuid/e2664576-4e65-4c79-ac1c-9e2fa9e12a26";
+ fsType = "btrfs";
+ options = [ "subvol=nix" ];
+ };
+
+ fileSystems."/boot" =
+ { device = "/dev/disk/by-uuid/D3FC-22C2";
+ fsType = "vfat";
+ options = [ "fmask=0077" "dmask=0077" ];
+ };
+
+ swapDevices =
+ [ { device = "/dev/disk/by-uuid/6aee8a42-a2a1-4d18-8f5c-695195e0c122"; }
+ ];
+
+ nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
+ hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware;
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/nix-settings.nix b/modules/hosts/laptop/nix-settings.nix
@@ -0,0 +1,46 @@
+# modules/hosts/laptop/nix-settings.nix — nix daemon settings and the nh helper.
+# Imported by both the `nixos` target and the `bootstrap` stage.
+{ ... }:
+{
+ flake.nixosModules.laptop-nix-settings =
+ { pkgs, ... }:
+ {
+ nix.settings = {
+ experimental-features = [ "nix-command" "flakes" ];
+ # Hyprland is built from its own flake pins, which cache.nixos.org does
+ # not have. Without the Hyprland cache every update compiles it locally.
+ substituters = [
+ "https://cache.nixos.org"
+ "https://hyprland.cachix.org"
+ ];
+ trusted-public-keys = [
+ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
+ "hyprland.cachix.org-1:a7pgxzMz7+chwVL3/pzj6jIBMioiJM7ypFP8PwtkuGc="
+ ];
+ };
+
+ # nh, the Nix helper: `nh os switch|boot|test`, `nh clean all`, `nh search`.
+ # Wraps nixos-rebuild with nix-output-monitor progress and an nvd diff of
+ # what a generation changes, and asks for sudo only for the switch itself.
+ # NH_FLAKE points at this repo, so `nh os boot` works from any directory;
+ # the configuration is picked by hostname (`nixos`), or with -H <name>.
+ programs.nh = {
+ enable = true;
+ flake = "/home/daemonsec/NixDaemon";
+ clean = {
+ enable = true; # weekly `nh clean all`: drops old generations and runs the GC,
+ dates = "weekly"; # keeping the last 5 and anything newer than 14 days
+ extraArgs = "--keep 5 --keep-since 14d";
+ };
+ };
+
+ # The two tools nh builds on, also useful by hand:
+ # nvd diff /run/current-system result what a build would change
+ # nom build .#… nix build with a live tree view
+ environment.systemPackages = with pkgs; [
+ nvd
+ nix-output-monitor
+ ];
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/nvidia.nix b/modules/hosts/laptop/nvidia.nix
@@ -0,0 +1,58 @@
+# modules/hosts/laptop/nvidia.nix
+#
+# The panel (eDP-1) is wired to the RTX 3070 Ti at 01:00.0: the firmware MUX is
+# in discrete mode (amdgpu's eDP-2 reports disconnected). Linux cannot change
+# the MUX, so this configures what the hardware presents: NVIDIA open kernel
+# module with modesetting, the Radeon 680M left as a secondary DRM device.
+#
+# PARKED: if the MUX is switched to hybrid in the BIOS, swap the AQ_DRM_DEVICES
+# order (igpu-card first) and add the prime offload block at the bottom.
+{ ... }:
+{
+ flake.nixosModules.laptop-nvidia =
+ { config, pkgs, lib, ... }:
+ {
+ services.xserver.videoDrivers = [ "nvidia" ];
+
+ hardware.graphics = {
+ enable = true;
+ extraPackages = [ pkgs.nvidia-vaapi-driver ];
+ };
+
+ hardware.nvidia = {
+ open = true; # GA104 is supported by the open kernel modules
+ modesetting.enable = true; # nvidia-drm.modeset=1
+ package = config.boot.kernelPackages.nvidiaPackages.stable;
+ nvidiaSettings = false;
+ # powerManagement.enable = true; # suspend/resume helpers; untested on this laptop, left at default
+ };
+
+ # Stable, colon-free names for the two DRM cards. Aquamarine splits
+ # AQ_DRM_DEVICES on ':', so the /dev/dri/by-path names cannot go in it (the PCI
+ # address has colons): it chopped them into "pci-0000", "01", "00.0-card", found
+ # no GPU and Hyprland aborted at startup with "CBackend::create() failed!".
+ # ID_PATH is matched exactly so the boot-time simpledrm card (whose ID_PATH is
+ # pci-0000:01:00.0-platform-simple-framebuffer.0) does not take the dGPU name.
+ services.udev.extraRules = ''
+ SUBSYSTEM=="drm", KERNEL=="card[0-9]*", ENV{ID_PATH}=="pci-0000:01:00.0", SYMLINK+="dri/dgpu-card"
+ SUBSYSTEM=="drm", KERNEL=="card[0-9]*", ENV{ID_PATH}=="pci-0000:06:00.0", SYMLINK+="dri/igpu-card"
+ '';
+
+ environment.sessionVariables = {
+ # Stable device order for Hyprland/aquamarine: dGPU (panel) first, iGPU second.
+ AQ_DRM_DEVICES = "/dev/dri/dgpu-card:/dev/dri/igpu-card";
+ LIBVA_DRIVER_NAME = "nvidia";
+ __GLX_VENDOR_LIBRARY_NAME = "nvidia";
+ NVD_BACKEND = "direct";
+ };
+
+ # Hybrid (iGPU drives the panel, dGPU on demand). Only if the BIOS MUX is set to hybrid:
+ # hardware.nvidia.prime = {
+ # offload.enable = true;
+ # offload.enableOffloadCmd = true;
+ # amdgpuBusId = "PCI:6:0:0";
+ # nvidiaBusId = "PCI:1:0:0";
+ # };
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/sops.nix b/modules/hosts/laptop/sops.nix
@@ -0,0 +1,37 @@
+# modules/hosts/laptop/sops.nix — sops-nix: secrets encrypted in this repo, decrypted
+# at activation into /run/secrets (root-only tmpfs; per-secret owner/mode).
+#
+# One age identity does everything (.sops.yaml): the user edits with the sops
+# CLI (reads ~/.config/sops/age/keys.txt), the system decrypts at boot with a
+# root-only copy at /var/lib/sops-nix/key.txt. Put it there once:
+#
+# sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt
+#
+# No SSH host key is used: sshd is not enabled on this machine, so there is
+# none to derive an age key from (sshKeyPaths is emptied below for that reason).
+#
+# Declaring a secret:
+# sops.secrets.my-token = { }; # → /run/secrets/my-token, root:root 0400
+# sops.secrets."ssd.key" = { path = "/etc/secrets/ssd.key"; }; # the LUKS key, once it is
+# # added to secrets/secrets.yaml (sops set …)
+# sops.secrets.wifi-psk = { owner = user; }; # readable by the user
+# then `sops secrets/secrets.yaml` to add the value, and `nh os switch`.
+{ ... }:
+{
+ flake.nixosModules.laptop-sops =
+ { inputs, user, ... }:
+ {
+ imports = [ inputs.sops-nix.nixosModules.sops ];
+
+ sops = {
+ defaultSopsFile = ../../../secrets/secrets.yaml;
+ age = {
+ keyFile = "/var/lib/sops-nix/key.txt";
+ sshKeyPaths = [ ];
+ generateKey = false; # the key is the user's (see header), never a fresh one
+ };
+ gnupg.sshKeyPaths = [ ];
+ };
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/ssd.nix b/modules/hosts/laptop/ssd.nix
@@ -0,0 +1,35 @@
+# modules/hosts/laptop/ssd.nix
+#
+# Spare Samsung SSD 980 1 TB (LUKS2, btrfs label SSD) at /mnt/ssd, unlocked at
+# boot from /etc/secrets/ssd.key. The key is restored by hand once (vault
+# samsung-ssd.md, section 2):
+#
+# sudo mkdir -p -m 700 /etc/secrets
+# gpg -d --pinentry-mode loopback ssd.key.gpg | sudo tee /etc/secrets/ssd.key >/dev/null
+# sudo chmod 400 /etc/secrets/ssd.key
+#
+# `nofail` on both lines: the laptop boots normally while the key (or the
+# drive) is missing; the unit just fails. Swap the key path for `none` to type
+# the passphrase at boot instead.
+{ ... }:
+{
+ flake.nixosModules.laptop-ssd =
+ { ... }:
+ {
+ environment.etc.crypttab.text = ''
+ ssd UUID=8cd17d0f-adf3-430f-9d95-d17ef42df9b0 /etc/secrets/ssd.key luks,nofail
+ '';
+
+ fileSystems."/mnt/ssd" = {
+ device = "/dev/mapper/ssd";
+ fsType = "btrfs";
+ options = [
+ "compress=zstd:3"
+ "noatime"
+ "nofail"
+ "x-systemd.device-timeout=10s"
+ ];
+ };
+ }
+ ;
+}
diff --git a/hosts/laptop/stability_guard.py b/modules/hosts/laptop/stability_guard.py
diff --git a/modules/hosts/laptop/toolbox.nix b/modules/hosts/laptop/toolbox.nix
@@ -0,0 +1,31 @@
+# modules/hosts/laptop/toolbox.nix
+#
+# NixOS-side support for the hand-written toolbox that lives, flat, in
+# ~/.local/bin (its own git repo; not in the Nix store). Shared by the target
+# and the bootstrap configuration.
+{ ... }:
+{
+ flake.nixosModules.laptop-toolbox =
+ { pkgs, lib, user, ... }:
+ {
+ # Several tools keep Arch-style shebangs (#!/bin/bash: fanfix, dropterm,
+ # omarchy-menu-*; #!/usr/bin/python3: lid-control). envfs resolves those
+ # paths from the caller's PATH instead of patching the scripts.
+ services.envfs.enable = true;
+
+ # ~/.local/bin first on PATH (prepends in /etc/set-environment).
+ environment.localBinInPath = true;
+
+ users.users.${user}.extraGroups = [
+ "input" # padx talks to the touchpad over hidraw
+ "video"
+ ];
+
+ # padx: the Pixart 093A:0274 touchpad behind the UNIW0001 I2C-HID bridge.
+ # Replaces ~/trackpad-fix/60-padx-touchpad.rules from the Arch install.
+ services.udev.extraRules = ''
+ KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{modalias}=="hid:b0018g*v0000093Ap00000274", GROUP="input", MODE="0660"
+ '';
+ }
+ ;
+}
diff --git a/modules/hosts/laptop/uniwill-laptop.nix b/modules/hosts/laptop/uniwill-laptop.nix
@@ -0,0 +1,25 @@
+# modules/hosts/laptop/uniwill-laptop.nix
+#
+# fan-throttle-guard.nix expects the in-tree `uniwill-laptop` driver (the
+# `uniwill` hwmon: fan1/fan2 rpm, pwm1/pwm2, board temps; also the keyboard
+# backlight LED and the touchpad-toggle bit padx mentions). Upstream merged it
+# in Linux 6.19; the nixpkgs 6.18 kernel predates it and the 7.2 kernel config
+# leaves X86_PLATFORM_DRIVERS_UNIWILL off. Building the v6.19 sources as an
+# out-of-tree module against whatever boot.kernelPackages selects is the cheap
+# fix: a 10-second compile, no custom kernel. Verified to build on 6.18.55.
+#
+# Without this hwmon stability_guard.py pins the CPU at 1.8 GHz ("essential
+# sensor or main fan unavailable"), so this file is not optional.
+{ ... }:
+{
+ flake.nixosModules.laptop-uniwill =
+ { config, lib, pkgs, ... }:
+ {
+ boot.extraModulePackages = [
+ (config.boot.kernelPackages.callPackage ./uniwill-laptop/_package.nix { })
+ ];
+ # `boot.kernelModules` and the `force=1` modprobe option (this GM7RGxM is not
+ # in the driver's DMI list) are set in fan-throttle-guard.nix.
+ }
+ ;
+}
diff --git a/hosts/laptop/uniwill-laptop/Makefile b/modules/hosts/laptop/uniwill-laptop/Makefile
diff --git a/hosts/laptop/uniwill-laptop/package.nix b/modules/hosts/laptop/uniwill-laptop/_package.nix
diff --git a/hosts/laptop/uniwill-laptop/uniwill-acpi.c b/modules/hosts/laptop/uniwill-laptop/uniwill-acpi.c
diff --git a/hosts/laptop/uniwill-laptop/uniwill-wmi.c b/modules/hosts/laptop/uniwill-laptop/uniwill-wmi.c
diff --git a/hosts/laptop/uniwill-laptop/uniwill-wmi.h b/modules/hosts/laptop/uniwill-laptop/uniwill-wmi.h
diff --git a/modules/parts.nix b/modules/parts.nix
@@ -0,0 +1,9 @@
+# modules/parts.nix — flake-parts wiring shared by every module under modules/.
+{ inputs, ... }:
+{
+ systems = [ "x86_64-linux" ];
+ imports = [
+ inputs.home-manager.flakeModules.home-manager # flake.homeModules / flake.homeConfigurations
+ inputs.wrapper-modules.flakeModules.default # flake.wrappers, perSystem.wrappers
+ ];
+}
diff --git a/modules/workstation.nix b/modules/workstation.nix
@@ -1,43 +0,0 @@
-# Claude Code, the Claude desktop app, Obsidian, and the git / GitHub / GitLab
-# command-line tools. Written by nixdaemon-bootstrap.sh; edit freely.
-{ lib, pkgs, inputs, ... }:
-let
- # Anthropic's own Linux builds, repackaged for Nix by numtide and refreshed
- # daily. (NixOS isn't a distro Anthropic supports directly: its desktop app
- # ships as a .deb for Debian/Ubuntu.)
- claude = inputs.llm-agents.packages.${pkgs.stdenv.hostPlatform.system};
-in
-{
- nix.settings.experimental-features = [ "nix-command" "flakes" ];
-
- # Unfree packages (Obsidian) are already allowed elsewhere in this config.
-
- environment.systemPackages = [
- claude.claude-code # terminal: `claude`
- claude.claude-desktop # desktop app: "Claude" in your launcher, or `claude-desktop`
- pkgs.obsidian
- pkgs.git
- pkgs.gh # GitHub CLI
- pkgs.glab # GitLab CLI
- pkgs.qemu_kvm # only for the desktop app's Cowork tab
- ];
-
- # The desktop app keeps its sign-in in the system keyring; without one it
- # asks you to log in on every launch.
- services.gnome.gnome-keyring.enable = lib.mkDefault true;
-
- # The desktop app's Cowork tab runs its tasks in a local VM, which needs KVM.
- # If you don't use Cowork, delete these two lines and qemu_kvm above.
- boot.kernelModules = [ "vhost_vsock" ];
- users.groups.kvm.members = [ "daemonsec" ];
-
- # Run Electron apps (Claude, Obsidian) natively on Wayland, e.g. on Hyprland.
- environment.sessionVariables.NIXOS_OZONE_WL = lib.mkDefault "1";
-
- # Optional: numtide's binary cache, so the Claude packages download instead
- # of building a couple of small helper tools locally on each update.
- # nix.settings.extra-substituters = [ "https://cache.numtide.com" ];
- # nix.settings.extra-trusted-public-keys = [
- # "niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g="
- # ];
-}
diff --git a/scripts/wrap.sh b/scripts/wrap.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+# scripts/wrap.sh FILE ATTR — turn a plain NixOS / home-manager module file
+# into a flake-parts module that exports it under ATTR, in place:
+#
+# # leading comment block, kept as is
+# { ... }:
+# {
+# ATTR =
+# <the original module, indented by two spaces>;
+# }
+#
+# The split is at the first line that is neither a `#` comment nor blank, so
+# the file's header comment stays at the top where editors and readers expect it.
+set -euo pipefail
+file=$1
+attr=$2
+tmp=$(mktemp)
+awk -v attr="$attr" '
+ !body && ($0 ~ /^#/ || $0 ~ /^[[:space:]]*$/) { print; next }
+ !body { body = 1; print "{ ... }:"; print "{"; print " " attr " =" }
+ body { if ($0 ~ /^[[:space:]]*$/) print ""; else print " " $0 }
+ END { print " ;"; print "}" }
+' "$file" > "$tmp"
+mv "$tmp" "$file"