commit 734ff2e5a7c1f490ed00fecf416409c518d6fcbb
parent 2e3a77ad5222762d438ec017f5e7301bd7966182
Author: DAEMON-404 <zer0sec.xp@icloud.com>
Date: Thu, 8 Oct 2026 02:01:33 +0100
NixOS flake initial commit | SYSTEM://daemonsec@nixos | DATE://Thu Oct 8 03:33:36 AM BST 2026
Diffstat:
31 files changed, 2528 insertions(+), 103 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -1,2 +1,6 @@
result
result-*
+# never commit decrypted material or the age key
+secrets/*.dec
+secrets/*.plain*
+keys.txt
diff --git a/.sops.yaml b/.sops.yaml
@@ -0,0 +1,17 @@
+# sops configuration for ~/NixDaemon (sops-nix). One age identity, used by
+# the user (~/.config/sops/age/keys.txt, where the sops CLI looks) and by the
+# system at activation (/var/lib/sops-nix/key.txt, a root-only copy of it).
+#
+# sops secrets/secrets.yaml edit (decrypts in $EDITOR, re-encrypts on save)
+# sops -d secrets/secrets.yaml print decrypted
+# sops updatekeys secrets/secrets.yaml re-encrypt after changing the keys below
+#
+# To add a second machine or key: generate its age key, add the public key to
+# `keys` and the rule, then `sops updatekeys` every file.
+keys:
+ - &daemonsec age1pu5wcvqqh92xr6ces8zmj4xvs52v7yfm4jsfcxm3z82a32u9n97qesldfv
+creation_rules:
+ - path_regex: secrets/.*\.(yaml|json|env|ini)$
+ key_groups:
+ - age:
+ - *daemonsec
diff --git a/README.md b/README.md
@@ -1,60 +1,148 @@
-# NixDaemon
+<div align="center">
+ <h1>☧ NixDaemon</h1>
+ <p><em>NixOS + home-manager for the PCSpecialist Valeon II 17: Hyprland in Lua, Caelestia Shell and kitty in Rosé Pine, the dead-GPU-fan workaround, and the hand-written toolbox.</em></p>
+</div>
-NixOS + home-manager for the PCSpecialist Valeon II 17 (TongFang GM7RGxM):
-Hyprland (Lua config, uwsm) with Caelestia Shell, the dead-GPU-fan workaround,
-and the hand-written toolbox. Built from the vault's
-`04Tools/NixDaemon-Migration/` material on 2026-10-07.
+<p align="center">
+ <a href="https://nixos.org/"><img alt="NixOS unstable" src="https://img.shields.io/badge/NixOS-unstable-9ccfd8?style=for-the-badge&labelColor=191724&logo=nixos&logoColor=e0def4"></a>
+ <a href="https://github.com/nix-community/home-manager"><img alt="home-manager" src="https://img.shields.io/badge/home--manager-module-c4a7e7?style=for-the-badge&labelColor=191724&logo=nixos&logoColor=e0def4"></a>
+ <a href="https://hypr.land/"><img alt="Hyprland 0.56, Lua config" src="https://img.shields.io/badge/Hyprland-0.56_·_Lua-31748f?style=for-the-badge&labelColor=191724&logo=hyprland&logoColor=e0def4"></a>
+ <a href="https://github.com/caelestia-dots/shell"><img alt="Caelestia Shell" src="https://img.shields.io/badge/Caelestia-shell-ebbcba?style=for-the-badge&labelColor=191724"></a>
+ <a href="https://rosepinetheme.com/"><img alt="Rosé Pine" src="https://img.shields.io/badge/Theme-Ros%C3%A9_Pine-eb6f92?style=for-the-badge&labelColor=191724"></a>
+</p>
-## Layout
+<p align="center">
+ <a href="https://www.zsh.org/"><img alt="zsh" src="https://img.shields.io/badge/Shell-zsh-f6c177?style=flat-square&labelColor=26233a&logo=zsh&logoColor=e0def4"></a>
+ <a href="https://github.com/Mic92/sops-nix"><img alt="sops-nix" src="https://img.shields.io/badge/Secrets-sops--nix_·_age-9ccfd8?style=flat-square&labelColor=26233a"></a>
+ <a href="https://secretspec.dev/"><img alt="secretspec" src="https://img.shields.io/badge/Runtime_secrets-secretspec-c4a7e7?style=flat-square&labelColor=26233a"></a>
+ <a href="https://github.com/viperML/nh"><img alt="nh" src="https://img.shields.io/badge/Rebuild-nh-31748f?style=flat-square&labelColor=26233a"></a>
+ <a href="https://jj-vcs.github.io/jj/"><img alt="jj colocated with git" src="https://img.shields.io/badge/VCS-jj_·_git-908caa?style=flat-square&labelColor=26233a&logo=git&logoColor=e0def4"></a>
+ <img alt="Chi-Rho" src="https://img.shields.io/badge/%E2%98%A7-daemon--sec-ebbcba?style=flat-square&labelColor=26233a">
+</p>
+---
+
+One flake, one machine: the TongFang GM7RGxM (Ryzen 9 6900HX, Radeon 680M,
+RTX 3070 Ti, 2560×1440@240). Everything the machine is comes from here:
+the kernel modules that keep the dead GPU fan from throttling the CPU, the
+NVIDIA setup for Hyprland, the greeter, the compositor's Lua config and
+keybinds, Caelestia as bar/launcher/lock, kitty with tmux-style keys, zsh
+through the dotfiles checkout, the general tools, and the secrets. Built from
+the vault's `04Tools/NixDaemon-Migration/` material on 2026-10-07.
+
+The companion repo is [`daemon-sec-dotfiles`](https://gitlab.com/DAEMON-404):
+home-manager links every dotfile from its checkout (`~/git/daemon-sec-dotfiles`)
+into `$HOME`, so editing the checkout edits the live config.
+
+## Structure
+
+```text
+NixDaemon/
+├── flake.nix inputs: nixpkgs (unstable), nixpkgs-stable (26.05, bootstrap only), home-manager,
+│ hyprland, caelestia-shell, caelestia-cli, llm-agents, sops-nix, nvf
+│ outputs: nixosConfigurations.nixos (the target) and .bootstrap (stage A)
+├── .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/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
+└── modules/workstation.nix Claude Code, Claude desktop, Obsidian, gh, glab
```
-flake.nix inputs: nixpkgs (unstable), nixpkgs-stable (26.05, bootstrap only),
- home-manager, hyprland, caelestia-shell, caelestia-cli, llm-agents
-hosts/laptop/ the machine
- default.nix boot, users, greetd/tuigreet, Hyprland (uwsm), audio, fonts, portals
- 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, built from Linux 6.19 sources (see below)
- nvidia.nix open kernel module, panel on the dGPU, device order for Hyprland
- ssd.nix Samsung 980 crypttab + /mnt/ssd (key restored by hand, see below)
- 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
- fan-reference/ the Arch-era captures (fan-ctl, fan-state, units, confs) and their README
-hosts/bootstrap/ stage A: today's GNOME install + fan fix + toolbox prerequisites
-home/ home-manager for daemonsec
- modules/hyprland.nix Lua config wiring, helper scripts, polkit agent, cliphist, udiskie
- modules/caelestia.nix programs.caelestia, the carried shell.json, static Rosé Pine scheme
- modules/terminal.nix kitty, DMMono Nerd Font, Rosé Pine colours
- modules/tools.nix toolbox runtime closure, python env, dotfiles symlinks (fonts, cursors)
- modules/gtk.nix Yaru-purple, cursor, prefer-dark
- hypr/*.lua omarchy (shim) · core · defaults (stock Omarchy binds) · bindings · lid · caelestia
- caelestia/ scheme.json, rose-pine-dark.txt (CLI scheme), shell-tokens.json
-modules/workstation.nix Claude Code, Claude desktop, Obsidian, gh, glab (from the bootstrap script)
-```
-## Applying
+## 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 |
+
+## Setting it up
+
+### Day to day (this machine)
-Everything below needs `sudo`, so it is left to the owner.
+```sh
+nh os switch # build, nvd diff, sudo, activate; = sudo nixos-rebuild switch --flake ~/NixDaemon#nixos
+nh os boot # same, but activate on next boot (kernel / driver changes)
+nix-cheat # the full card: rebuild, remote, nh, home, search, update, rollback, clean, …
+nix-cheat nh # one section
+ns kitty # fuzzy search nixpkgs + NixOS/home-manager options, with descriptions
+```
-`nh` (Nix helper) is installed by both configurations; once Stage A is live it
-is the nicer way to run the same steps. It shows a diff of what a generation
-changes before asking for sudo, and the weekly `nh clean all` keeps the store
-tidy (last 5 generations, 14 days). `NH_FLAKE` already points at this repo.
+home-manager is a NixOS module here, so one rebuild does both; there is no
+separate `home-manager switch`. New files must be `git add`ed before nix sees
+them (the repo is jj, colocated with git; `nix-cheat repo`).
+
+### From the repo, without a checkout
```sh
-nh os switch -H bootstrap # same as the Stage A command below
-nh os boot # Stage B (picks nixosConfigurations.nixos by hostname)
-nh os build; nvd diff /run/current-system result # dry look at what would change
-nh search <package> # nixpkgs search
-nh clean all --keep 5 --keep-since 14d
+sudo nixos-rebuild switch --flake gitlab:DAEMON-404/NixDaemon#nixos
+nh os switch gitlab:DAEMON-404/NixDaemon
+nix flake show gitlab:DAEMON-404/NixDaemon
```
+### Fresh install
+
+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 https://gitlab.com/DAEMON-404/NixDaemon && cd NixDaemon`, drop
+ the new hardware file into `hosts/laptop/`, `git add` it.
+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`.
+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)
+
+Everything below needs `sudo`, so it was left to the owner.
+
**Stage A: fan fix now, on the GNOME install.** Small switch (fan module,
driver, toolbox prerequisites, Hyprland cache). The new kernel modules only
load from the booted system, hence the reboot.
```sh
-sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && sudo reboot
+sudo nixos-rebuild switch --flake ~/NixDaemon#bootstrap && sudo reboot # or: nh os switch -H bootstrap
```
First-boot check (vault README and gpu-fan-fix/README.md):
@@ -70,18 +158,18 @@ cat /run/motherboard-stability/status.json # limit_mhz 3200, thermal_stage 0,
`fanfix status` will say "cap is not persisted": on NixOS the floor is the
`systemd.tmpfiles.rules` line in the module, not `/etc/tmpfiles.d/99-cpu-freq-cap.conf`.
Treat `fanfix install` / `uninstall` / `fan setup` as no-ops here; change
-`capKhz` in the module instead (gpu-fan-fix README).
+`capKhz` in the module instead (gpu-fan-fix README). `fanfix-fan` is expected
+inactive: its manual fan mode makes the EC clamp all cores to 399 MHz under
+load; EC auto fan with the 3.2 GHz floor passed `fanfix test 30` at 61 °C.
-**Stage B: the desktop.** Build, then boot into it (everything heavy is already
-in the store if the build below finished; otherwise this downloads Hyprland
-from its cache and compiles the NVIDIA modules).
+**Stage B: the desktop.**
```sh
-sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && sudo reboot
+sudo nixos-rebuild boot --flake ~/NixDaemon#nixos && sudo reboot # or: nh os boot
```
tuigreet appears on tty1; pick `Hyprland (UWSM)` once, it is remembered.
-Then the second vault check, the keybind diff:
+Then the keybind diff against the vault capture:
```sh
hyprctl binds -j | python3 -I -c 'import json,sys
@@ -93,10 +181,9 @@ cut -d'|' -f1-4 ~/git/NetrunnerVault/04Tools/NixDaemon-Migration/shortcuts/keybi
Expected differences: the keycode binds (workspaces, resize, bar panels,
group windows) show `code:0` in the capture and an empty key here; the keys
-caelestia.lua takes over carry their Caelestia descriptions (launcher,
-session menu, panels, notifications, media keys); 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).
+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.
@@ -110,7 +197,42 @@ sudo chmod 400 /etc/secrets/ssd.key
sudo systemctl restart systemd-cryptsetup@ssd.service mnt-ssd.mount # or just reboot
```
-Until then the drive stays locked; both units are `nofail`, so boot is unaffected.
+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.
+
+## The shell
+
+zsh is the login shell (hosts/laptop/default.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
+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
+`~/git/daemon-sec-dotfiles/home/.dotfiles/config/*.zsh` and open a new shell.
+
+Things the shell modules want that this build provides: `~/.fzf.zsh` (fzf's
+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
+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
+duplicate nixpkgs; `nix-ld` is on so `uv`'s managed Pythons work), and the
+repo's git config (it turns on commit signing with a key that is not on this
+machine; `~/.gitconfig` stays).
+
+## The dotfiles
+
+`home/modules/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
+kitty are Nix-managed, the omarchy trees, the systemd units, mimeapps,
+git's signing config, the bash rc files, `.claude`/`.codex`, the toolbox
+`bin` directories) and why.
## The toolbox
@@ -128,7 +250,64 @@ 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).
+ provided here as a fuzzel wrapper (home/modules/hyprland.nix). `nix-cheat`
+ and `gpg-cheat` are this repo's own cards, in the same style.
+
+## Secrets
+
+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`,
+created on 2026-10-08 and **not in the repo**; back it up (vault) and give
+the system its copy once:
+
+```sh
+sudo install -D -m 600 -o root -g root ~/.config/sops/age/keys.txt /var/lib/sops-nix/key.txt
+```
+
+Edit with `sops secrets/secrets.yaml`, declare with `sops.secrets.NAME = { };`,
+rebuild. `nix-cheat secrets` has the commands.
+
+**secretspec** is for a project's runtime secrets: declared next to the
+project in `secretspec.toml`, values in the system keyring
+(`~/.config/secretspec/config.toml`: provider `keyring`, profile `default`;
+gnome-keyring is unlocked at login by PAM). `secretspec init`, `secretspec
+add NAME`, `secretspec check`, `secretspec run -- cmd`.
+
+## Look and keys
+
+- **Caelestia in Rosé Pine dark** (main), translucent over blur, with its
+ 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:
+ `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`,
+ 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
+ visualiser along the bottom while something plays, weather on the
+ dashboard (Douglas), audio and microphone status icons, lock screen over
+ 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'
+ 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):
+ `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.
+- 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.
## Why `uniwill-laptop` is built here
@@ -151,7 +330,10 @@ NixOS kernel ships it.
RTX 3070 Ti (discrete). The hybrid alternative is a commented block in
nvidia.nix; it only applies after changing the MUX in the BIOS.
- **Hostname** stays `nixos` (the installer's). The flake output is also `nixos`.
-- Programs some stock Omarchy keys expect but which are not in the toolbox
- closure (spotify, lazydocker, btop, tmux, yazi, neovim, 1password,
- signal): those keys show a notification saying so. Add packages to
+- 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.
+
+---
+
+<p align="center">☧ · <a href="https://rosepinetheme.com/">Rosé Pine</a> all the way down · built with Claude Code</p>
diff --git a/flake.lock b/flake.lock
@@ -131,6 +131,22 @@
"type": "github"
}
},
+ "flake-compat_2": {
+ "flake": false,
+ "locked": {
+ "lastModified": 1777699697,
+ "narHash": "sha256-Eg9b/rq/ECYwNwEXs5i9wHyhxNI0JrYx2srdI2uZMaQ=",
+ "ref": "refs/heads/main",
+ "rev": "382052b74656a369c5408822af3f2501e9b1af81",
+ "revCount": 94,
+ "type": "git",
+ "url": "https://git.lix.systems/lix-project/flake-compat.git"
+ },
+ "original": {
+ "type": "git",
+ "url": "https://git.lix.systems/lix-project/flake-compat.git"
+ }
+ },
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
@@ -564,6 +580,21 @@
"type": "github"
}
},
+ "mnw": {
+ "locked": {
+ "lastModified": 1784154424,
+ "narHash": "sha256-HRn4O1X2ShYtbq5Egji72/aemyc2cIQicjRZD9hBqM8=",
+ "owner": "Gerg-L",
+ "repo": "mnw",
+ "rev": "0151d9fa87992cbeb67c86616fb9170dcec830b6",
+ "type": "github"
+ },
+ "original": {
+ "owner": "Gerg-L",
+ "repo": "mnw",
+ "type": "github"
+ }
+ },
"nixpkgs": {
"locked": {
"lastModified": 1791048980,
@@ -628,6 +659,28 @@
"type": "github"
}
},
+ "nvf": {
+ "inputs": {
+ "flake-compat": "flake-compat_2",
+ "mnw": "mnw",
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791215772,
+ "narHash": "sha256-GBC/HfZM1Bh5dM/B0csBr8YoVGk3DTmCF/Qx646kThE=",
+ "owner": "notashelf",
+ "repo": "nvf",
+ "rev": "b5a8011df7e743d5bd992d25983d548f99e9ff83",
+ "type": "github"
+ },
+ "original": {
+ "owner": "notashelf",
+ "repo": "nvf",
+ "type": "github"
+ }
+ },
"pre-commit-hooks": {
"inputs": {
"flake-compat": "flake-compat",
@@ -679,7 +732,29 @@
"hyprland": "hyprland",
"llm-agents": "llm-agents",
"nixpkgs": "nixpkgs_3",
- "nixpkgs-stable": "nixpkgs-stable"
+ "nixpkgs-stable": "nixpkgs-stable",
+ "nvf": "nvf",
+ "sops-nix": "sops-nix"
+ }
+ },
+ "sops-nix": {
+ "inputs": {
+ "nixpkgs": [
+ "nixpkgs"
+ ]
+ },
+ "locked": {
+ "lastModified": 1791103873,
+ "narHash": "sha256-nFxM+pKoZ8LJAEnUXARyCaOAloWgaW9kZQOSjWzKcTE=",
+ "owner": "Mic92",
+ "repo": "sops-nix",
+ "rev": "dcd241ba97088c22569d1573286e1b9daad340c0",
+ "type": "github"
+ },
+ "original": {
+ "owner": "Mic92",
+ "repo": "sops-nix",
+ "type": "github"
}
},
"systems": {
diff --git a/flake.nix b/flake.nix
@@ -33,6 +33,19 @@
# Claude Code and the Claude desktop app (modules/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).
+ sops-nix = {
+ url = "github:Mic92/sops-nix";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
+
+ # Neovim, configured in Nix (home/modules/neovim.nix).
+ nvf = {
+ url = "github:notashelf/nvf";
+ inputs.nixpkgs.follows = "nixpkgs";
+ };
};
outputs =
diff --git a/home/caelestia/active-window-program-name.patch b/home/caelestia/active-window-program-name.patch
@@ -0,0 +1,26 @@
+--- a/modules/bar/components/ActiveWindow.qml
++++ b/modules/bar/components/ActiveWindow.qml
+@@ -1,6 +1,7 @@
+ pragma ComponentBehavior: Bound
+
+ import QtQuick
++import Quickshell
+ import Caelestia.Config
+ import Caelestia.I18n
+ import qs.components
+@@ -19,6 +20,15 @@
+ if (!title)
+ return Tr.trCtx("Desktop", "shown when no window is focused");
+ if (Config.bar.activeWindow.compact) {
++ // NixDaemon: show the program, not the window title. The desktop
++ // entry's name for the window class, else the class itself, else
++ // the last " - " segment of the title as upstream does.
++ const cls = Hypr.activeToplevel?.lastIpcObject.class;
++ const entry = cls ? DesktopEntries.heuristicLookup(cls) : null;
++ if (entry?.name)
++ return entry.name;
++ if (cls)
++ return cls;
+ // " - " (standard hyphen), " — " (em dash), " – " (en dash)
+ const parts = title.split(/\s+[\-\u2013\u2014]\s+/);
+ if (parts.length > 1)
diff --git a/home/caelestia/rose-pine-dawn.txt b/home/caelestia/rose-pine-dawn.txt
@@ -0,0 +1,74 @@
+background faf4ed
+surface faf4ed
+surfaceDim f2e9e1
+surfaceBright fffaf3
+surfaceContainerLowest fffaf3
+surfaceContainerLow f4ede8
+surfaceContainer f2e9e1
+surfaceContainerHigh dfdad9
+surfaceContainerHighest cecacd
+surfaceVariant f2e9e1
+onBackground 575279
+onSurface 575279
+onSurfaceVariant 797593
+outline 9893a5
+outlineVariant dfdad9
+inverseSurface 575279
+inverseOnSurface faf4ed
+inversePrimary eb6f92
+surfaceTint b4637a
+shadow 000000
+scrim 000000
+primary_paletteKeyColor b4637a
+secondary_paletteKeyColor 907aa9
+tertiary_paletteKeyColor 56949f
+neutral_paletteKeyColor 9893a5
+neutral_variant_paletteKeyColor 797593
+term0 f2e9e1
+term1 b4637a
+term2 286983
+term3 ea9d34
+term4 56949f
+term5 907aa9
+term6 d7827e
+term7 575279
+term8 9893a5
+term9 b4637a
+term10 286983
+term11 ea9d34
+term12 56949f
+term13 907aa9
+term14 d7827e
+term15 575279
+primary b4637a
+onPrimary fffaf3
+primaryContainer f3dde4
+onPrimaryContainer 7a3650
+primaryFixed f3dde4
+primaryFixedDim b4637a
+onPrimaryFixed 7a3650
+onPrimaryFixedVariant 7a3650
+secondary 907aa9
+onSecondary fffaf3
+secondaryContainer e9e1f0
+onSecondaryContainer 5a4a6e
+secondaryFixed e9e1f0
+secondaryFixedDim 907aa9
+onSecondaryFixed 5a4a6e
+onSecondaryFixedVariant 5a4a6e
+tertiary 56949f
+onTertiary fffaf3
+tertiaryContainer dbe9ea
+onTertiaryContainer 2f5a62
+tertiaryFixed dbe9ea
+tertiaryFixedDim 56949f
+onTertiaryFixed 2f5a62
+onTertiaryFixedVariant 2f5a62
+error b4637a
+onError fffaf3
+errorContainer f3dde4
+onErrorContainer 7a3650
+success 286983
+onSuccess fffaf3
+successContainer dbe6ec
+onSuccessContainer 1f4e62
diff --git a/home/caelestia/scheme-dawn.json b/home/caelestia/scheme-dawn.json
@@ -0,0 +1,82 @@
+{
+ "name": "rose-pine",
+ "flavour": "rose-pine-dawn",
+ "mode": "light",
+ "variant": "tonalspot",
+ "colours": {
+ "background": "faf4ed",
+ "surface": "faf4ed",
+ "surfaceDim": "f2e9e1",
+ "surfaceBright": "fffaf3",
+ "surfaceContainerLowest": "fffaf3",
+ "surfaceContainerLow": "f4ede8",
+ "surfaceContainer": "f2e9e1",
+ "surfaceContainerHigh": "dfdad9",
+ "surfaceContainerHighest": "cecacd",
+ "surfaceVariant": "f2e9e1",
+ "onBackground": "575279",
+ "onSurface": "575279",
+ "onSurfaceVariant": "797593",
+ "outline": "9893a5",
+ "outlineVariant": "dfdad9",
+ "inverseSurface": "575279",
+ "inverseOnSurface": "faf4ed",
+ "inversePrimary": "eb6f92",
+ "surfaceTint": "b4637a",
+ "shadow": "000000",
+ "scrim": "000000",
+ "primary_paletteKeyColor": "b4637a",
+ "secondary_paletteKeyColor": "907aa9",
+ "tertiary_paletteKeyColor": "56949f",
+ "neutral_paletteKeyColor": "9893a5",
+ "neutral_variant_paletteKeyColor": "797593",
+ "term0": "f2e9e1",
+ "term1": "b4637a",
+ "term2": "286983",
+ "term3": "ea9d34",
+ "term4": "56949f",
+ "term5": "907aa9",
+ "term6": "d7827e",
+ "term7": "575279",
+ "term8": "9893a5",
+ "term9": "b4637a",
+ "term10": "286983",
+ "term11": "ea9d34",
+ "term12": "56949f",
+ "term13": "907aa9",
+ "term14": "d7827e",
+ "term15": "575279",
+ "primary": "b4637a",
+ "onPrimary": "fffaf3",
+ "primaryContainer": "f3dde4",
+ "onPrimaryContainer": "7a3650",
+ "primaryFixed": "f3dde4",
+ "primaryFixedDim": "b4637a",
+ "onPrimaryFixed": "7a3650",
+ "onPrimaryFixedVariant": "7a3650",
+ "secondary": "907aa9",
+ "onSecondary": "fffaf3",
+ "secondaryContainer": "e9e1f0",
+ "onSecondaryContainer": "5a4a6e",
+ "secondaryFixed": "e9e1f0",
+ "secondaryFixedDim": "907aa9",
+ "onSecondaryFixed": "5a4a6e",
+ "onSecondaryFixedVariant": "5a4a6e",
+ "tertiary": "56949f",
+ "onTertiary": "fffaf3",
+ "tertiaryContainer": "dbe9ea",
+ "onTertiaryContainer": "2f5a62",
+ "tertiaryFixed": "dbe9ea",
+ "tertiaryFixedDim": "56949f",
+ "onTertiaryFixed": "2f5a62",
+ "onTertiaryFixedVariant": "2f5a62",
+ "error": "b4637a",
+ "onError": "fffaf3",
+ "errorContainer": "f3dde4",
+ "onErrorContainer": "7a3650",
+ "success": "286983",
+ "onSuccess": "fffaf3",
+ "successContainer": "dbe6ec",
+ "onSuccessContainer": "1f4e62"
+ }
+}
diff --git a/home/caelestia/shell-tokens.json b/home/caelestia/shell-tokens.json
@@ -1,3 +1,17 @@
{
- "sizes": { "bar": { "innerWidth": 28 } }
+ "sizes": {
+ "bar": {
+ "innerWidth": 36
+ },
+ "sidebar": {
+ "width": 340
+ },
+ "utilities": {
+ "width": 340,
+ "toastWidth": 340
+ },
+ "notifs": {
+ "width": 360
+ }
+ }
}
diff --git a/home/cheats/gpg.md b/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 (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
@@ -0,0 +1,236 @@
+# 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.
+`gitlab:DAEMON-404/NixDaemon` is the public repo; `?ref=main` pins a branch, `?rev=<sha>` a commit.
+
+```sh
+sudo nixos-rebuild switch --flake gitlab:DAEMON-404/NixDaemon#nixos # this machine, from the pushed main
+sudo nixos-rebuild boot --flake 'gitlab:DAEMON-404/NixDaemon?ref=main#nixos'
+nh os switch gitlab:DAEMON-404/NixDaemon # nh takes the same reference
+nix build gitlab:DAEMON-404/NixDaemon#nixosConfigurations.nixos.config.system.build.toplevel --no-link --print-out-paths
+nix flake show gitlab:DAEMON-404/NixDaemon # what the repo exports
+nix flake metadata gitlab:DAEMON-404/NixDaemon # which commit nix resolved
+git+https://gitlab.com/DAEMON-404/NixDaemon.git # the long form of the same reference
+```
+
+**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 https://gitlab.com/DAEMON-404/NixDaemon ~/NixDaemon && cd ~/NixDaemon
+# 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
@@ -6,6 +6,13 @@
./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/gtk.nix # Yaru-purple icons, cursor, prefer-dark
];
diff --git a/home/hypr/defaults.lua b/home/hypr/defaults.lua
@@ -67,7 +67,7 @@ o.bind("XF86PowerOff", "Power menu", hl.dsp.global("caelestia:session"), locked)
o.bind("SUPER + K", "Keybindings", "keybinds-menu")
o.bind("SUPER + SHIFT + SPACE", "Toggle top bar", gone("Toggle top bar", "the Caelestia bar has no toggle"))
o.bind("SUPER + SHIFT + CTRL + SPACE", "Theme menu", gone("Theme menu", "the theme is static Rosé Pine here"))
-o.bind("CTRL + SUPER + SPACE", "Background switcher", "caelestia wallpaper -r")
+o.bind("CTRL + SUPER + SPACE", "Background switcher", "wallpaper-picker") -- Caelestia launcher in wallpaper mode (home/modules/hyprland.nix)
o.bind("CTRL + SUPER + O", "Toggle menu", gone("Toggle menu"))
o.bind("CTRL + SUPER + D", "Display", gone("Display", "omarchy display menu"))
o.bind("CTRL + SUPER + H", "Hardware menu", gone("Hardware menu"))
diff --git a/home/hypr/looknfeel.lua b/home/hypr/looknfeel.lua
@@ -0,0 +1,104 @@
+-- looknfeel.lua — the springy animation rice from the dotfiles' hypr/looknfeel.lua
+-- (daemon-sec-dotfiles/home/.config/hypr/looknfeel.lua), carried on 2026-10-08.
+--
+-- Loaded after core.lua, which pins the square corners, blur and borders. This
+-- file adds what the carried version added on Omarchy: overshoot curves on
+-- window motion (the "bouncy" / wobbly feel), shadows, blur tuning, snapping
+-- while dragging, manual-drag physics, swallowing, and two touchpad gestures.
+-- Left out on purpose: the omarchy-* layer rule (caelestia.lua has the
+-- Caelestia one), the `pop` rounding rule (rounding is 0 everywhere already),
+-- and the 4-finger-down gesture (bindings.lua binds it to the dropdown terminal).
+
+hl.config({
+ decoration = {
+ rounding = 0,
+ active_opacity = 1.0,
+ inactive_opacity = 1.0, -- kitty keeps its own transparency in its config
+ dim_inactive = false,
+
+ shadow = {
+ enabled = true,
+ range = 24,
+ render_power = 3,
+ color = "rgba(00000055)",
+ },
+
+ blur = {
+ enabled = true,
+ size = 7,
+ passes = 3,
+ new_optimizations = true,
+ xray = true, -- blur the wallpaper, not other tiled windows
+ popups = true,
+ noise = 0.015,
+ contrast = 1.1,
+ brightness = 1.0,
+ },
+ },
+})
+
+-- Hyprland's stock curves live in its own share/hypr/hyprland.lua, which this
+-- home-manager config does not load, so the ones used below are defined here
+-- (same control points as upstream).
+hl.curve("linear", { type = "bezier", points = { { 0, 0 }, { 1, 1 } } })
+hl.curve("almostLinear", { type = "bezier", points = { { 0.5, 0.5 }, { 0.75, 1.0 } } })
+hl.curve("quick", { type = "bezier", points = { { 0.15, 0 }, { 0.1, 1.0 } } })
+
+-- Overshoot curves: control points with y > 1 push past the target and settle
+-- back, which is what reads as "springy" in a compositor without jelly deformation.
+hl.curve("overshoot", { type = "bezier", points = { { 0.34, 1.56 }, { 0.64, 1.0 } } })
+hl.curve("springy", { type = "bezier", points = { { 0.16, 1.36 }, { 0.30, 1.02 } } })
+hl.curve("snappy", { type = "bezier", points = { { 0.05, 0.9 }, { 0.10, 1.05 } } })
+hl.curve("squish", { type = "bezier", points = { { 0.40, -0.20 }, { 0.20, 1.30 } } })
+
+-- Window motion: dragging, tiling and resizing all overshoot slightly and settle.
+hl.animation({ leaf = "windows", enabled = true, speed = 4.6, bezier = "overshoot" })
+hl.animation({ leaf = "windowsIn", enabled = true, speed = 5.0, bezier = "springy", style = "popin 60%" })
+hl.animation({ leaf = "windowsOut", enabled = true, speed = 3.4, bezier = "squish", style = "popin 70%" })
+hl.animation({ leaf = "windowsMove", enabled = true, speed = 4.4, bezier = "overshoot" })
+
+-- Borders track the spring instead of lagging behind it.
+hl.animation({ leaf = "border", enabled = true, speed = 7.0, bezier = "snappy" })
+
+-- Fades: quick enough to stay out of the way.
+hl.animation({ leaf = "fade", enabled = true, speed = 4.5, bezier = "quick" })
+hl.animation({ leaf = "fadeIn", enabled = true, speed = 3.2, bezier = "almostLinear" })
+hl.animation({ leaf = "fadeOut", enabled = true, speed = 2.4, bezier = "almostLinear" })
+
+-- Workspace switching: slide with a fade tail; the dropdown terminal rides slidevert.
+hl.animation({ leaf = "workspaces", enabled = true, speed = 5.0, bezier = "overshoot", style = "slidefade 15%" })
+hl.animation({ leaf = "specialWorkspace", enabled = true, speed = 4.6, bezier = "springy", style = "slidevert" })
+
+-- Layer surfaces (bar, launcher, notifications) pop in with the same character.
+hl.animation({ leaf = "layers", enabled = true, speed = 4.5, bezier = "springy" })
+hl.animation({ leaf = "layersIn", enabled = true, speed = 4.5, bezier = "springy", style = "popin 80%" })
+hl.animation({ leaf = "layersOut", enabled = true, speed = 3.0, bezier = "linear", style = "popin 85%" })
+
+-- The border gradient angle can be animated; left off so the GPU can idle.
+-- hl.animation({ leaf = "borderangle", enabled = true, speed = 40, bezier = "linear", style = "loop" })
+
+hl.config({
+ general = {
+ -- Floating windows snap to each other and to screen edges while dragging.
+ snap = {
+ enabled = true,
+ window_gap = 12,
+ monitor_gap = 12,
+ respect_gaps = true,
+ },
+ },
+ misc = {
+ -- Manual drags and resizes ride the curves above: the window lags the
+ -- cursor and settles with overshoot (poor man's wobbly windows).
+ animate_manual_resizes = true,
+ animate_mouse_windowdragging = true,
+ -- Launch a GUI app from a terminal and the terminal hides until it exits.
+ enable_swallow = true,
+ swallow_regex = "^(kitty|foot|org\\.codeberg\\.dnkl\\.foot|Alacritty|com\\.mitchellh\\.ghostty)$",
+ },
+})
+
+-- Touchpad: 3-finger horizontal surfs workspaces with the slidefade above;
+-- 4-finger up toggles fullscreen (4-finger down is the dropdown terminal, bindings.lua).
+hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
+hl.gesture({ fingers = 4, direction = "up", action = "fullscreen" })
diff --git a/home/kitty/scrollback.lua b/home/kitty/scrollback.lua
@@ -0,0 +1,67 @@
+-- scrollback.lua — kitty's copy mode (ctrl+a>[) as a Neovim buffer.
+-- Started by home/modules/terminal.nix as
+-- bash -c 'exec nvim 63<&0 0</dev/null -u <this file> -c "let g:kitty_input_line=INPUT_LINE_NUMBER" …'
+-- kitty hands the scrollback (with colours) on fd 63; it is rendered through a
+-- :terminal so the ANSI colours survive, then the buffer is left in normal
+-- mode at the line that was under the cursor. Vi motions and search work as
+-- usual; / ? n N, v V ctrl-v select, y copies to the clipboard (wl-copy),
+-- Enter copies and leaves, q or Esc leave. Nothing from the AstroNvim config
+-- is loaded (-u), so this starts instantly.
+
+local o = vim.opt
+o.number = false
+o.relativenumber = false
+o.list = false
+o.showtabline = 0
+o.foldcolumn = "0"
+o.laststatus = 0
+o.signcolumn = "no"
+o.ruler = false
+o.showmode = false
+o.cmdheight = 1
+o.scrollback = 100000
+o.termguicolors = true
+o.background = "dark"
+o.clipboard = "unnamedplus" -- y → system clipboard
+o.ignorecase = true
+o.smartcase = true
+o.incsearch = true
+o.hlsearch = true
+o.shell = "bash"
+
+local function quit()
+ vim.cmd("qa!")
+end
+local map = vim.keymap.set
+map({ "n", "v" }, "q", quit, { silent = true, desc = "leave copy mode" })
+map("n", "<Esc>", quit, { silent = true, desc = "leave copy mode" })
+map("n", "i", "<Nop>") -- no insert in a dead terminal
+map("n", "a", "<Nop>")
+map("v", "y", '"+y', { silent = true, desc = "copy selection" })
+map("v", "<CR>", '"+y<cmd>qa!<CR>', { silent = true, desc = "copy selection and leave (tmux)" })
+map("n", "<CR>", "<Nop>")
+
+vim.schedule(function()
+ -- the -c flags have run by now, so the kitty positions are in vim.g
+ local input_line = tonumber(vim.g.kitty_input_line) or 0
+ local cursor_line = tonumber(vim.g.kitty_cursor_line) or 1
+ local cursor_col = tonumber(vim.g.kitty_cursor_col) or 1
+
+ vim.api.nvim_create_autocmd("TermEnter", { callback = function() vim.cmd("stopinsert") end })
+ vim.api.nvim_create_autocmd("TermClose", {
+ once = true,
+ callback = function()
+ vim.cmd("stopinsert")
+ local target = math.max(0, input_line - 1) + cursor_line
+ pcall(vim.fn.cursor, target, cursor_col)
+ vim.cmd("normal! zz")
+ if vim.env.KITTY_SCROLLBACK_TEST then
+ -- smoke test (terminal.nix build check): print the text and leave
+ io.stdout:write(table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n"), "\n")
+ quit()
+ end
+ end,
+ })
+ -- strip kitty's OSC 8 file:// hyperlinks, which would print as noise
+ vim.cmd([[terminal sed </dev/fd/63 -e 's/\x1b]8;;file:[^\\]*\\//g' && sleep 0.01 && printf '\x1b]2;\a']])
+end)
diff --git a/home/modules/caelestia.nix b/home/modules/caelestia.nix
@@ -1,31 +1,42 @@
# home/modules/caelestia.nix — Caelestia Shell as bar, launcher, notifications,
-# OSD, lock and session menu; its CLI; the static Rosé Pine (M3) scheme.
+# 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
#
-# Settings are the carried shell.json with the Omarchy-isms swapped for NixOS
-# ones (vault caelestia/README.md "On NixOS"): launcher actions and session
-# commands, the audio app, the wallpaper directory. `useTwelveHourClock` no
-# longer exists in this shell version and was dropped. The shell is started by
-# the module's systemd user service under graphical-session.target (uwsm), so
-# there is no `caelestia shell -d` exec-once: two starts would mean two shells.
{ 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 carried Rosé Pine mapping is registered as a real
- # scheme (name rose-pine, flavour rose-pine-dark, mode dark). Its colours are
- # ../caelestia/rose-pine-dark.txt, generated from the same scheme.json.
+ # 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
- };
+ 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
@@ -77,6 +88,8 @@ in
};
};
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
@@ -86,14 +99,33 @@ in
idle = {
lockBeforeSleep = false;
inhibitWhenAudio = true;
- timeouts = [ ];
+ # 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;
- visualiser.enabled = false;
+ # 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;
@@ -101,28 +133,39 @@ in
workspaces = {
shown = 5;
activeIndicator = true;
+ occupiedBg = true; # filled pills behind workspaces that have windows
showWindows = true;
activeTrail = true;
};
- activeWindow.compact = false;
+ 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 = false; }
+ { 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 = 5;
- rounding = 0;
- smoothing = 0;
+ thickness = 10;
+ rounding = 25;
+ smoothing = 20;
};
dashboard = {
enabled = true;
@@ -132,6 +175,7 @@ in
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; }
@@ -143,7 +187,10 @@ in
{ 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)
+ 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;
@@ -157,7 +204,7 @@ in
gpuType = "Generic"; # must be a string
smartScheme = false; # never derive colours from the wallpaper
defaultPlayer = "rmpc";
- weatherLocation = "";
+ weatherLocation = "54.15,-4.48"; # Douglas, Isle of Man (dashboard weather card)
};
session = {
enabled = true;
@@ -170,18 +217,23 @@ in
};
};
sidebar.enabled = true;
- utilities.enabled = true;
+ utilities = {
+ enabled = true;
+ toasts.nowPlaying = true; # a toast when the track changes
+ };
paths.wallpaperDir = wallpaperDir;
};
};
- # The scheme the shell reads at start: the carried Rosé Pine → M3 mapping.
- # `caelestia scheme set -n rose-pine` rewrites this file with the same
- # colours (home-manager backs the symlink up as .hm-bak when that happens).
+ # 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;
- # Carried for reference: this shell version has no loader for it (the bar
- # width token came from the Omarchy-side build overlay).
+ # 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).
diff --git a/home/modules/cheats.nix b/home/modules/cheats.nix
@@ -0,0 +1,61 @@
+# 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
@@ -0,0 +1,117 @@
+# 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/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"
+ "yazi"
+ "lazygit"
+ "jj"
+ "emacs"
+ "opencode"
+ "feroxbuster"
+ "uncover"
+ "herdr"
+ "tensaku"
+ "ai-usagebar"
+ "bg-pasticcio"
+ "libvirt"
+ # media
+ "mpd"
+ "rmpc"
+ "mpv"
+ "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
@@ -0,0 +1,112 @@
+# 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 pkgs.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; }
+
+ 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/hyprland.nix b/home/modules/hyprland.nix
@@ -6,6 +6,7 @@
# 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
@@ -14,6 +15,7 @@
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.
@@ -56,6 +58,18 @@ let
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"
@@ -78,19 +92,21 @@ in
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: the stock binds first, then the carried files that
+ # 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")
diff --git a/home/modules/neovim.nix b/home/modules/neovim.nix
@@ -0,0 +1,115 @@
+# 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
@@ -0,0 +1,245 @@
+# 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
@@ -0,0 +1,115 @@
+# 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
@@ -0,0 +1,31 @@
+# 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, ... }:
+{
+ 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
+ ];
+}
diff --git a/home/modules/terminal.nix b/home/modules/terminal.nix
@@ -9,9 +9,46 @@
# 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;
@@ -59,6 +96,64 @@ in
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,13 +1,27 @@
# home/modules/tools.nix — runtime closure for the hand-written toolbox in
-# ~/.local/bin, plus the files that come from the dotfiles checkout.
+# ~/.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
- dotfiles = "${config.home.homeDirectory}/git/daemon-sec-dotfiles";
- link = path: config.lib.file.mkOutOfStoreSymlink "${dotfiles}/${path}";
+ # 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
@@ -25,6 +39,8 @@ in
{
home.packages = with pkgs; [
pythonEnv
+ ns
+ nix-search-tv # `ns`, and `nix-search-tv print|preview` by hand
perl
git
jujutsu
@@ -69,13 +85,35 @@ in
fastfetch
wl-clipboard
libnotify
- ];
- # From the dotfiles checkout, as symlinks (no copy into the store, and the
- # files follow the repo). They dangle harmlessly until the repo is cloned.
- home.file = {
- ".local/share/fonts/nerd-fonts-dm-mono".source = link "assets/fonts/nerd-fonts-dm-mono";
- ".local/share/icons/modernxp-retro-black".source = link "home/.local/share/icons/modernxp-retro-black";
- ".local/share/icons/modernxp-retro-black-hyprcursor".source = link "home/.local/share/icons/modernxp-retro-black-hyprcursor";
- };
+ # 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
+ yazi
+ lazygit
+ lazydocker
+ tealdeer # `tldr`
+ dust
+ duf
+ procs
+ delta
+ 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)
+ ];
}
diff --git a/hosts/laptop/default.nix b/hosts/laptop/default.nix
@@ -14,6 +14,8 @@
./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;
@@ -21,6 +23,9 @@
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";
@@ -35,8 +40,24 @@
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 = {
diff --git a/hosts/laptop/fan-cli.nix b/hosts/laptop/fan-cli.nix
@@ -0,0 +1,122 @@
+# 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/nvidia.nix b/hosts/laptop/nvidia.nix
@@ -5,8 +5,8 @@
# 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, replace the AQ_DRM_DEVICES
-# order with the amdgpu card first and add the prime offload block at the bottom.
+# 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" ];
@@ -24,9 +24,20 @@
# 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/by-path/pci-0000:01:00.0-card:/dev/dri/by-path/pci-0000:06:00.0-card";
+ AQ_DRM_DEVICES = "/dev/dri/dgpu-card:/dev/dri/igpu-card";
LIBVA_DRIVER_NAME = "nvidia";
__GLX_VENDOR_LIBRARY_NAME = "nvidia";
NVD_BACKEND = "direct";
diff --git a/hosts/laptop/sops.nix b/hosts/laptop/sops.nix
@@ -0,0 +1,32 @@
+# 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/secrets/secrets.yaml b/secrets/secrets.yaml
@@ -0,0 +1,20 @@
+#ENC[AES256_GCM,data:+DgIj7B9b9rqP2Y1h5x6Nb4vDONzAd4hB/kTF0LStamzaLZ5DMjUFnrmNXlMn+r/c1MFFGYoKlPdEaUVS+8GQ/BWTFWa4RtUc3x3N0/jJUy8VqP3jA==,iv:6BosyZToJSeQIjo+MgW9vRNW1xs1aOlzh134HG+6ONM=,tag:QODtSWiv4va/7uJrwhtpSg==,type:comment]
+#ENC[AES256_GCM,data:f8KyKY4EEPz5kexEO3BaKdsYLW8/3nwNXjZ7IRpdLHeN49phiSqQXZ1J9v+TSo2BUmhY2+LTKZ6+XpsMxmQS8T3OWXw5ubXQgi/uh+CNyDDc,iv:luukOqefrSrN4EdOjo1kBbzx0WgJhD8j/qk+62DHBd4=,tag:uMTlGty8KaQmsVUnjZabQw==,type:comment]
+#ENC[AES256_GCM,data:tM+6SNM68Ic6u4+prSUMzOZHnBzcffTZdlDyopfXDEuIaBxUofzyL1BhYsojlFHAZ34GXVZ3feBT+INw72d1jH1y/HJX7aJ3NnRKfBhV,iv:pfuzt7HNngh72wSmuqY23l1rrKBYY1A7sHa318ITBsA=,tag:PZXXy8nmNMtsH36zsD+09A==,type:comment]
+#ENC[AES256_GCM,data:a2p5hr+IkHu5tV8yGp/BrT6Lo8NGpafVVv6AXuVVHqQxGjz1F1JCm2udrOTNYUjfq5ktUALQZxtvFjRJuFNoKcGgaDf8luu5cA==,iv:PWN3vdbdD7fSuaKs7o47c/Ynxgxdr8ZlIwRDCRiz720=,tag:/rJ8dymNnO7Hnjc1uIow3w==,type:comment]
+example: ENC[AES256_GCM,data:EgrtVBB9Lt+pu0u3g9DP7Xwzr5PktqBr4vLw50rn/YlPVdMUzTgO/JmVgyVmjBlFXZySHDk=,iv:lFC1wvWwTdvFFbnXVNeXZGV5nGNCWFHq9kvBNn9bA2U=,tag:ta6b1cPg5MUimd7rRdT1Xw==,type:str]
+sops:
+ age:
+ - enc: |
+ -----BEGIN AGE ENCRYPTED FILE-----
+ YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwZXQyMjZUZ2hWV0pNVi9N
+ ZzF0MEI0VUUrWlJTdjhSZU5pZnN0bW12SEZJCndrUnBmaE1qVlZIbGNJdCtwN2dH
+ bURHdjRibWloY1lqWVdsMktaTFFrd1UKLS0tIGlxY2ZwR3g2MVZlN2JmTm40UG1S
+ bnBFamxSMm5uRzdNMCs4RFVaOHczR28KAqpWcBSuTIoFjrm6BiXDuP4kM/Sxbie9
+ NV86EcQtCT8AQqgtugSBUOjmZU6D45/rhEXAM98yP01b8Iw2HhEAuw==
+ -----END AGE ENCRYPTED FILE-----
+ recipient: age1pu5wcvqqh92xr6ces8zmj4xvs52v7yfm4jsfcxm3z82a32u9n97qesldfv
+ lastmodified: "2026-10-08T01:04:15Z"
+ mac: ENC[AES256_GCM,data:glJkL/drRDrChgo/69OXHe/nwQ99DCZXeWs92EDZn8EcqJbgKU+QoSEeU4THB6e0OYjRRbUvXtMM6vUbQ638UyT4ueDlxEqAsEpfN23/56GTdoqiqj/JiDRPwr5xFq2R09itmrnjjy1Tt3zodLL/nTmQQ9Sm6CONeeRwvoP9gnQ=,iv:+X8NyDUYY8NySWUaYfBGPTPFAa2YJHaz9gT/5pp6u8U=,tag:W4utRNHzEL4oXRjW0SowLw==,type:str]
+ unencrypted_suffix: _unencrypted
+ version: 3.13.3