My literate configurations with Org and Nix
Table of Contents
- 1. About
- 2. Philosophy
- 3. Configuration
- 3.1. Flake
- 3.1.1. Inputs
- 3.1.1.1. Core
- 3.1.1.2. Flake Infrastructure
- 3.1.1.3. Transitive Dependencies
- 3.1.1.4. System Configuration
- 3.1.1.5. Infrastructure
- 3.1.1.6. Development Tools
- 3.1.1.7. Desktop & Theming
- 3.1.1.8. Applications
- 3.1.1.8.1. brew-nix
- 3.1.1.8.2. edgepkgs
- 3.1.1.8.3. emacs-overlay
- 3.1.1.8.4. felis
- 3.1.1.8.5. firefox-addons
- 3.1.1.8.6. hermes-agent
- 3.1.1.8.7. mcp-servers
- 3.1.1.8.8. org-clickup
- 3.1.1.8.9. nur-packages
- 3.1.1.8.10. paneru
- 3.1.1.8.11. simple-wol-manager
- 3.1.1.8.12. spoor
- 3.1.1.8.13. vicinae
- 3.1.1.8.14. zen-browser
- 3.1.2. Nix Config
- 3.1.3. Hosts
- 3.1.4. Outputs
- 3.1.5. Per-system modules
- 3.1.1. Inputs
- 3.2. Overlays
- 3.3. Modules
- 3.3.1. Overview
- 3.3.2. Nix
- 3.3.3. Networking
- 3.3.4. File Synchronization
- 3.3.5. Shell
- 3.3.6. Version Control
- 3.3.7. Continuous Integration
- 3.3.8. Devices
- 3.3.9. Authentication
- 3.3.10. Browser
- 3.3.11. Editor
- 3.3.12. Speech to Text
- 3.3.13. Launcher
- 3.3.14. Terminal
- 3.3.15. Darwin
- 3.4. Scripts
- 3.1. Flake
- 4. Development
1. About#
This repository manages system configurations for multiple machines using Nix and Org mode. All configuration is written as literate programs—Org documents where prose explains the reasoning behind each decision, and Nix code blocks are tangled into the actual configuration files.
1.1. Documentation#
The full configuration document is published at:
- English: https://natsukium.github.io/dotfiles/
- Japanese: https://natsukium.github.io/dotfiles/ja/
1.2. Nix#
Nix is a purely functional package manager and build system. This repository uses several Nix ecosystem tools:
- Flakes for reproducible dependency management
- NixOS for declarative Linux system configuration
- nix-darwin for declarative macOS system configuration
- home-manager for user environment management
- nix-on-droid for Android (Termux) environment
1.3. Machines#
| Name | Platform | Device | Role |
|---|---|---|---|
| kilimanjaro | NixOS (x86_64) | i5-12400F / RTX 3080 | Main desktop |
| tarangire | NixOS (x86_64) | Ryzen 9 9950X | Build server |
| manyara | NixOS (x86_64) | Beelink Mini S12 | Home server |
| arusha | NixOS (x86_64) | WSL2 | WSL environment |
| serengeti | NixOS (aarch64) | OCI A1 Flex | Build server |
| katavi | macOS (aarch64) | M1 MacBook Air | Main laptop |
| work | macOS (aarch64) | M4 MacBook Pro | Work laptop |
| mikumi | macOS (aarch64) | M1 Mac mini | Build server |
| android | nix-on-droid | Galaxy S24 FE | Phone |
2. Philosophy#
2.1. Literate Configuration#
Nix is declarative. Reading a Nix expression reveals what the system should become, and Nix itself handles how to get there. But neither the code nor the build system captures why a particular configuration exists, or why alternatives were rejected.
Why was fish chosen over zsh or bash? Why does the desktop profile enable this specific set of services? Why was a particular package pinned to an older version? The code shows the decision, but not the reasoning behind it. Without this context, future changes risk undoing intentional tradeoffs or repeating previously rejected approaches.
This repository uses literate programming to preserve intent. Configuration lives in Org mode documents where prose surrounds code.
For most settings, one or two sentences—what it enables and the visible effect—is enough. Reserve the full problem-and-alternatives form for non-obvious trade-offs: architectural choices, package pins, temporary workarounds.
3. Configuration#
3.1. Flake#
This flake manages NixOS, nix-darwin, and home-manager configurations for multiple machines across different platforms (macOS, Linux, Android). It uses flake-parts for modular organization and includes tooling for development, code formatting, and pre-commit hooks.
{ description = "dotfiles"; inputs = { # Core <<nixpkgs>> <<nixpkgs-stable>> <<nixpkgs-cuda>> # Flake Infrastructure <<flake-parts>> # Transitive Dependencies <<flake-utils>> # System Configuration <<darwin>> <<home-manager>> <<nixos-wsl>> <<nix-on-droid>> <<disko>> <<impermanence>> <<lanzaboote>> <<nixos-facter-modules>> # Infrastructure <<comin>> <<microvm>> <<niks3>> <<sops-nix>> <<tsnsrv>> # Development Tools <<git-hooks>> <<treefmt-nix>> # Desktop & Theming <<nix-colors>> <<nix-wallpaper>> # Applications <<brew-nix>> <<edgepkgs>> <<emacs-overlay>> <<felis>> <<firefox-addons>> <<hermes-agent>> <<mcp-servers>> <<org-clickup>> <<nur-packages>> <<paneru>> <<simple-wol-manager>> <<spoor>> <<vicinae>> <<zen-browser>> }; nixConfig = { <<nix-config>> }; outputs = { self, flake-parts, ... }@inputs: flake-parts.lib.mkFlake { inherit inputs; } { <<outputs>> }; }
3.1.1. Inputs#
External flake dependencies.
To minimize evaluation time caused by dependency graph bloat, almost all flakes are configured to follow the same nixpkgs and other common inputs wherever possible.
3.1.1.1. Core#
3.1.1.1.1. nixpkgs#
https://github.com/NixOS/nixpkgs
Nix Packages collection & NixOS
The primary package set. Using nixos-unstable-small instead of nixos-unstable for faster
channel updates. The -small variant skips some less critical CI tests, allowing new package
versions to propagate faster while maintaining stability for core packages.
For channel selection guidance, see https://nix.dev/concepts/faq.html#which-channel-branch-should-i-use
Channel status can be checked at https://status.nixos.org/
Using git+https:// with shallow=1 instead of github: for slightly faster file extraction
on large repositories like nixpkgs.
nixpkgs.url = "git+https://github.com/nixos/nixpkgs?shallow=1&ref=nixos-unstable-small";
3.1.1.1.2. nixpkgs-stable#
Provides stable packages when unstable has build failures or regressions. Mainly used by the
stable overlay (see overlays/configuration.org) for packages that are broken in unstable.
nixpkgs-stable.url = "git+https://github.com/nixos/nixpkgs?shallow=1&ref=nixos-26.05";
3.1.1.1.3. nixpkgs-cuda#
kilimanjaro builds with cudaSupport, and those builds are only cached by
cache.nixos-cuda.org, for the revision at the head of nixos-unstable-cuda. That branch
trails nixos-unstable-small, so the cuda overlay (see overlays/configuration.org) takes
ollama and handy from this input while the rest of the system follows the main one.
nixpkgs-cuda.url = "git+https://github.com/nixos-cuda/nixpkgs?shallow=1&ref=nixos-unstable-cuda";
3.1.1.2. Flake Infrastructure#
3.1.1.2.1. flake-parts#
https://github.com/hercules-ci/flake-parts
Simplify Nix Flakes with the module system
Framework for organizing flake outputs. Provides module system for flakes, making complex configurations more maintainable through separation of concerns.
flake-parts = { url = "github:hercules-ci/flake-parts"; inputs.nixpkgs-lib.follows = "nixpkgs"; };
3.1.1.3. Transitive Dependencies#
3.1.1.3.1. flake-utils#
https://github.com/numtide/flake-utils
Pure Nix flake utility functions
Common flake utilities. Only used transitively via follows to unify the
flake-utils version across inputs that depend on it.
flake-utils.url = "github:numtide/flake-utils";
3.1.1.4. System Configuration#
3.1.1.4.1. darwin#
https://github.com/nix-darwin/nix-darwin
Manage your macOS using Nix
nix-darwin provides NixOS-style system configuration for macOS. Essential for managing macOS system settings, launchd services, and Homebrew declaratively.
darwin = { url = "github:nix-darwin/nix-darwin"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.4.2. home-manager#
https://github.com/nix-community/home-manager
Manage a user environment using Nix
User environment management. Manages dotfiles, user services, and per-user packages declaratively. The backbone of user-level configuration in this repository.
home-manager = { url = "github:nix-community/home-manager"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.4.3. nixos-wsl#
https://github.com/nix-community/nixos-wsl
NixOS on WSL
NixOS on Windows Subsystem for Linux. Provides NixOS experience within WSL2, useful for Windows machines that need Linux development environments.
nixos-wsl = { url = "github:nix-community/nixos-wsl"; inputs.flake-compat.follows = ""; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.4.4. nix-on-droid#
https://github.com/nix-community/nix-on-droid
Nix-enabled environment for your Android device.
Nix environment for Android via Termux. Enables the same declarative configuration approach on mobile devices.
nix-on-droid = { url = "github:nix-community/nix-on-droid"; inputs.home-manager.follows = "home-manager"; inputs.nix-formatter-pack.follows = ""; inputs.nixpkgs-docs.follows = "nixpkgs"; inputs.nixpkgs-for-bootstrap.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs"; inputs.nmd.follows = ""; };
3.1.1.4.5. disko#
https://github.com/nix-community/disko
Declarative disk partitioning and formatting using nix
Used for reproducible NixOS installations with automated partition layout, filesystem creation, and encryption setup.
disko = { url = "github:nix-community/disko"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.4.6. impermanence#
https://github.com/nix-community/impermanence
Modules to help you handle persistent state on systems with ephemeral root storage
Manages stateful paths on systems with ephemeral root filesystems. Used with btrfs snapshots to ensure only explicitly declared state persists across reboots.
impermanence.url = "github:nix-community/impermanence";
3.1.1.4.7. lanzaboote#
https://github.com/nix-community/lanzaboote
Secure Boot for NixOS
Signs boot components with custom keys, enabling Secure Boot on NixOS machines.
lanzaboote = { url = "github:nix-community/lanzaboote"; inputs.nixpkgs.follows = "nixpkgs"; inputs.pre-commit.follows = "git-hooks"; };
3.1.1.4.8. nixos-facter-modules#
https://github.com/numtide/nixos-facter-modules
A series of NixOS modules to be used in conjunction with nixos-facter
Hardware detection for NixOS. Automatically generates hardware configuration based on detected hardware, simplifying initial system setup.
nixos-facter-modules.url = "github:numtide/nixos-facter-modules";
3.1.1.5. Infrastructure#
3.1.1.5.1. comin#
https://github.com/nlewo/comin
GitOps For NixOS Machines
Automatically deploys configuration changes when pushed to the repository,
enabling continuous deployment for servers without manual nixos-rebuild switch.
comin = { url = "github:nlewo/comin"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.5.2. microvm#
https://github.com/astro/microvm.nix
NixOS modules for declaring & running virtual machines from within your system Flake configuration
Lightweight per-VM isolation suitable for running somewhat untrusted code. Used to sandbox third-party agent code (hermes-agent) so crashes, runaway tool invocations, or compromise of the agent runtime cannot reach the host's service stack. A microvm is preferred over a plain systemd-nspawn container because the qemu/firecracker boundary blocks kernel-level escapes that container namespacing alone does not.
microvm = { url = "github:astro/microvm.nix"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.5.3. sops-nix#
https://github.com/Mic92/sops-nix
Atomic secret provisioning for NixOS based on sops
Secrets management using Mozilla SOPS. Encrypts secrets in the repository that are decrypted at activation time using age keys.
sops-nix = { url = "github:Mic92/sops-nix"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.5.4. tsnsrv#
https://github.com/boinkor-net/tsnsrv
A reverse proxy that exposes services on your tailnet (as their own tailscale participants)
Tailscale service proxy. Exposes local services to the Tailscale network with automatic HTTPS certificates.
tsnsrv = { url = "github:boinkor-net/tsnsrv"; inputs.flake-parts.follows = "flake-parts"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.5.5. niks3#
https://github.com/Mic92/niks3
S3-backed Nix binary cache with garbage collection
niks3 is a self-hosted, S3-backed Nix binary cache that resolves the upload limits I ran into with the alternatives: it hands clients presigned S3 URLs so NARs upload straight to R2, and the cloudflared tunnel only carries small API calls. Cachix's free 5 GB was not enough for my dotfiles closures, so builds kept re-running instead of being fetched from the cache. attic worked, but behind a free cloudflared tunnel every upload was proxied through the tunnel and hit Cloudflare's 100 MiB request-body cap on large derivations.
niks3 = { url = "github:Mic92/niks3"; inputs.nixpkgs.follows = "nixpkgs"; inputs.treefmt-nix.follows = "treefmt-nix"; };
3.1.1.6. Development Tools#
3.1.1.6.1. git-hooks#
https://github.com/cachix/git-hooks.nix
Seamless integration of pre-commit.com git hooks with Nix.
Pre-commit hooks as Nix derivations. Ensures code quality checks run consistently across all development environments without requiring global tool installation.
Inputs that don't affect this flake are removed to prevent lockfile bloat.
git-hooks = { url = "github:cachix/git-hooks.nix"; inputs.flake-compat.follows = ""; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.6.2. treefmt-nix#
https://github.com/numtide/treefmt-nix
treefmt nix configuration
Unified code formatter configuration. Runs multiple formatters (oxfmt, nixfmt, shfmt, etc.) through a single interface, ensuring consistent formatting across the repository.
treefmt-nix = { url = "github:numtide/treefmt-nix"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.7. Desktop & Theming#
3.1.1.7.1. nix-colors#
https://github.com/misterio77/nix-colors
Modules and schemes to make theming with Nix awesome.
Base16 color scheme framework for Nix. Provides consistent theming across applications through a single color scheme definition.
nix-colors = { url = "github:misterio77/nix-colors"; inputs.nixpkgs-lib.follows = "nixpkgs"; };
3.1.1.7.2. nix-wallpaper#
https://github.com/natsukium/nix-wallpaper
A configurable wallpaper for nix systems
Generates Nix logo wallpapers. Using a custom branch (custom-logo) that supports
additional logo variants.
nix-wallpaper = { url = "github:natsukium/nix-wallpaper/custom-logo"; inputs.flake-utils.follows = "flake-utils"; inputs.nixpkgs.follows = "nixpkgs"; inputs.pre-commit-hooks.follows = "git-hooks"; };
3.1.1.8. Applications#
3.1.1.8.1. brew-nix#
https://github.com/BatteredBunny/brew-nix
Experimental nix expression to package all MacOS casks from homebrew automatically
Provides Homebrew casks as Nix packages for darwin. Useful for proprietary macOS applications not available in nixpkgs.
The brew-api input is marked as non-flake because it's just a data source (JSON API dump
from Homebrew's API).
brew-api = { url = "github:BatteredBunny/brew-api"; flake = false; }; brew-nix = { url = "github:BatteredBunny/brew-nix"; inputs.brew-api.follows = "brew-api"; inputs.nix-darwin.follows = "darwin"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.2. edgepkgs#
https://github.com/natsukium/edgepkgs
Personal repository for bleeding-edge packages. Contains packages not yet in nixpkgs, those requiring modifications, or packages that wouldn't be accepted upstream (e.g., niche or experimental software).
edgepkgs = { url = "github:natsukium/edgepkgs"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.3. emacs-overlay#
https://github.com/nix-community/emacs-overlay
Bleeding edge emacs overlay
Provides latest Emacs builds including native compilation and pure GTK variants. Also includes MELPA packages updated more frequently than nixpkgs. Mainly used for the utility that parses org files and automatically configures dependency packages.
emacs-overlay = { url = "github:nix-community/emacs-overlay"; inputs.nixpkgs-stable.follows = "nixpkgs-stable"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.4. felis#
https://git.natsukium.com/natsukium/felis
A terminal that redraws the boundary of what a terminal is responsible for
felis = { url = "git+https://git.natsukium.com/natsukium/felis"; inputs.nixpkgs.follows = "nixpkgs"; inputs.flake-parts.follows = "flake-parts"; inputs.treefmt-nix.follows = "treefmt-nix"; inputs.git-hooks.follows = "git-hooks"; };
3.1.1.8.5. firefox-addons#
https://gitlab.com/rycee/nur-expressions
A few Nix expressions suitable for inclusion in Nix User Repository
Firefox/browser extensions packaged for Nix. Allows declarative browser extension management through home-manager.
firefox-addons = { url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.6. hermes-agent#
https://github.com/NousResearch/hermes-agent
The self-improving AI agent built by Nous Research
Provides the upstream NixOS module (nixosModules.default) and packaged Python
build of hermes-agent.
The URL points at my fork: upstream reads package.json, pyproject.toml and uv.lock
out of builtins.path copies that only materialise under a writable store, so the
nix flake check --no-build CI runs fails with path '...-hermes-python-source' is not
valid. Back to upstream once PR #71228 merges.
hermes-agent = { url = "github:natsukium/hermes-agent/fix/nix-read-only-eval"; inputs.flake-parts.follows = "flake-parts"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.7. mcp-servers#
https://github.com/natsukium/mcp-servers-nix
A Nix-based configuration framework for Model Control Protocol (MCP) servers with ready-to-use packages.
Provides both a configuration framework and packaged MCP servers for Nix. Used with Claude Code and other MCP-compatible AI assistants. See MCP Servers for configuration details.
mcp-servers = { url = "github:natsukium/mcp-servers-nix"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.8. org-clickup#
https://git.natsukium.com/natsukium/org-clickup
Emacs package for bidirectional ClickUp ↔ org-mode sync.
org-clickup = { url = "git+https://git.natsukium.com/natsukium/org-clickup"; flake = false; };
3.1.1.8.9. nur-packages#
https://github.com/natsukium/nur-packages
Personal NUR (Nix User Repository). Contains packages maintained personally that are either too niche for nixpkgs or require customizations.
nur-packages = { url = "github:natsukium/nur-packages"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.10. paneru#
https://github.com/karinushka/paneru
A sliding, tiling window manager for MacOS.
Paneru brings the Niri-style scrollable tiling workflow used on Linux machines to macOS. Among the window managers tried for this purpose, it has run the most stably in day-to-day use, which is why it was chosen.
paneru = { url = "github:karinushka/paneru"; inputs.flake-parts.follows = "flake-parts"; inputs.nixpkgs.follows = "nixpkgs"; inputs.nix-darwin.follows = "darwin"; };
3.1.1.8.11. simple-wol-manager#
https://git.natsukium.com/natsukium/simple-wol-manager
A web-based application for managing Wake-on-LAN (WoL) devices
Personal Wake-on-LAN management tool. Provides a web interface for sending WoL packets and managing device configurations.
simple-wol-manager = { url = "git+https://git.natsukium.com/natsukium/simple-wol-manager"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.12. spoor#
https://git.natsukium.com/natsukium/spoor
A standalone, terminal-agnostic hint picker for scrollback.
My own take on tmux-thumbs' standalone mode: pipe text in, it labels the matches, and on selection it copies, opens, or runs a command with the pick. The felis terminal uses it for URL hints — spoor links felis' own VT parser and cell grid, so its overlay lands on exactly the columns felis drew.
spoor = { url = "git+https://git.natsukium.com/natsukium/spoor"; inputs.nixpkgs.follows = "nixpkgs"; inputs.flake-parts.follows = "flake-parts"; };
3.1.1.8.13. vicinae#
https://github.com/vicinaehq/vicinae
A focused, keyboard-driven launcher for getting things done.
Vicinae is a Raycast-style native launcher.
vicinae = { url = "github:vicinaehq/vicinae"; inputs.nixpkgs.follows = "nixpkgs"; };
3.1.1.8.14. zen-browser#
https://github.com/0xc000022070/zen-browser-flake
Community-driven Nix Flake for the Zen browser
Firefox-based browser focused on privacy. Community flake providing Nix packaging with home-manager integration.
zen-browser = { url = "github:0xc000022070/zen-browser-flake"; inputs.nixpkgs.follows = "nixpkgs"; inputs.home-manager.follows = "home-manager"; };
3.1.2. Nix Config#
Nix settings used by this flake. While these settings are already configured on all managed machines, documenting them here helps with initial setup and allows others to use this flake.
For detailed documentation on each setting, see https://nix.dev/manual/nix/latest/command-ref/conf-file.html
Note: flake.nix uses a restricted subset of the Nix language that prevents code reuse
(see https://github.com/NixOS/nix/issues/4945). The values below are currently hardcoded in
this section. Once machine configurations are migrated to org-mode, noweb references will
allow sharing these values across both the flake and machine-specific settings.
3.1.2.1. Binary Caches#
Binary cache (substituter) configuration for building this flake. While optional, configuring these caches significantly reduces build times by downloading pre-built binaries instead of compiling from source.
Using extra-substituters and extra-trusted-public-keys instead of substituters and
trusted-public-keys ensures this flake's cache configuration is additive rather than
replacing the user's existing settings. This respects any caches the user has already
configured in their nix.conf or system configuration.
extra-substituters = [ "https://nix-cache.natsukium.com" "https://natsukium.cachix.org" "https://cache.nixos-cuda.org" ]; extra-trusted-public-keys = [ "niks3-1:SoIFTPtiPoCW3/OzUkIBKlLG5znMZfbihlr11XAOles=" "natsukium.cachix.org-1:STD7ru7/5+KJX21m2yuDlgV6PnZP/v5VZWAJ8DZdMlI=" "cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=" ];
3.1.2.1.1. cache.nixos-cuda.org#
Binary cache for CUDA-related packages. These were distributed under the nix-community namespace before, and since November 2025 come from the CUDA team's own infrastructure. Build status can be monitored at https://hydra.nixos-cuda.org/project/nixos-cuda
The nixos-cuda team describes this cache as being for development purposes only. Alternatively, Flox's binary cache can be used for CUDA packages. As of September 2025, Flox has partnered with NVIDIA to obtain redistribution rights. See https://discourse.nixos.org/t/nix-flox-nvidia-opening-up-cuda-redistribution-on-nix/69189
3.1.2.1.2. natsukium.cachix.org#
Personal binary cache containing outputs from this flake. Packages not available in the
official cache.nixos.org are built on GitHub Actions and pushed here.
3.1.3. Hosts#
An overview of the managed machines. Each host's full configuration lives in
its own hosts/<platform>/<name>/ directory, picked up by the loader in
modules/hosts.nix.
3.1.3.1. katavi#
Main laptop (M1 MacBook Air).
3.1.3.2. mikumi#
Build server (M1 Mac mini).
3.1.3.3. work#
Laptop for work (M4 MacBook Pro).
3.1.3.4. kilimanjaro#
Main desktop (Intel Core i5-12400F).
Wake-on-LAN (WoL) is enabled via the following BIOS setting:
Advanced > APM Configuration > Power On By PCI-E > Enabled
3.1.3.5. arusha#
WSL (dual boot with kilimanjaro).
3.1.3.5.1. Setup#
Setting up arusha requires both Windows-side and WSL-side steps. The process follows NixOS-WSL's quick start guide.
- Installing WSL
--no-distributionavoids installing the default Ubuntu distribution, which would just be removed after importing NixOS.powershellwsl --install --no-distribution
- Importing NixOS-WSL
Download the latest NixOS-WSL release image and import it.
powershellInvoke-RestMethod -Uri https://api.github.com/repos/nix-community/nixos-wsl/releases/latest ` | Select-Object -ExpandProperty assets ` | Where-Object { $_.name -eq "nixos.wsl" } ` | ForEach-Object { Invoke-WebRequest -Uri $_.browser_download_url -OutFile $_.name }powershell./nixos.wsl
- Applying the configuration
After the initial NixOS-WSL boot, apply this flake's configuration directly from GitHub. No local clone is needed for the first run.
powershellwsl -d NixOS
bashsudo nixos-rebuild switch --flake github:natsukium/dotfiles#arusha
- Windows-side CLI tools
Windows 24H2 includes a built-in
sudocommand, sowingetcan be elevated inline without a separate UAC prompt.
3.1.3.6. manyara#
A mini PC with Intel N100, serving as a lightweight home server. https://www.bee-link.com/products/beelink-mini-s12-pro-n100
BIOS is configured with the following setting to automatically power on after an outage:
Chipset > PCH-IO Configuration > State After G3 > S0 State
3.1.3.7. serengeti#
Build server (OCI A1 Flex).
3.1.3.8. tarangire#
Build server (Ryzen 9 9950X).
Wake-on-LAN (WoL) is enabled via the following BIOS setting:
Advanced > APM Configuration > Power On By PCI-E > Enabled
3.1.3.9. android#
Phone (Pixel 7a).
3.1.4. Outputs#
Each per-system concern lives in its own modules/per-system/*.nix file,
documented in [[Per-system modules]] below. The outputs body wires them via
imports; flake-parts merges perSystem contributions across all modules.
systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ]; imports = [ ./modules ]; flake = { overlays = import ./overlays { inherit inputs; }; };
3.1.5. Per-system modules#
Per-concern flake-parts modules that each contribute to perSystem. Splitting
them keeps flake.nix small and lets each concern carry its own documentation.
flake-parts merges contributions across all imported modules, so each file only
needs to define what it owns.
The shared pattern is:
{ ... }: # or { self, inputs, ... }: when the module needs them { perSystem = { ... }: # the usual perSystem args (pkgs, self', config, system, ...) { <the-attribute-this-module-owns> = { ... }; }; }
3.1.5.1. Nixpkgs instance#
Constructs the per-system pkgs with overlays applied, exposed to all other
perSystem consumers via _module.args.pkgs.
allowUnfree is enabled because several desktop hosts pull in unfree packages
(NVIDIA drivers on kilimanjaro, 1password, etc.) and gating each call-site
with an override would only add noise.
{ self, inputs, ... }: { perSystem = { system, ... }: { _module.args.pkgs = import inputs.nixpkgs { inherit system; config.allowUnfree = true; overlays = [ inputs.nur-packages.overlays.default ] ++ builtins.attrValues self.overlays; }; }; }
3.1.5.2. Checks#
Re-exports the NixOS VM tests under ./tests as flake checks for the current
system. Kept in its own module so the test wiring is easy to find independent
of the per-system pkgs construction.
{ inputs, self, ... }: { perSystem = { pkgs, ... }: { checks = import ../../tests { inherit (inputs) nixpkgs; inherit pkgs self; }; }; }
3.1.5.3. Packages#
Per-system packages exposed as flake outputs.
The html package builds the published documentation (Documentation). The build
itself lives in scripts/build-html.sh rather than in this derivation, so that a
local preview does not have to go through Nix; the derivation only runs that
script. The styling is my own (assets/org-html.css), inlined into each page
by scripts/org-to-html.el, so a page loads nothing external.
{ ... }: { perSystem = { pkgs, lib, ... }: { packages = { fastfetch = pkgs.callPackage ../../pkgs/fastfetch { }; html = with pkgs; stdenvNoCC.mkDerivation { name = "dotfiles"; src = lib.cleanSource ../..; nativeBuildInputs = [ (emacs.pkgs.withPackages (epkgs: [ epkgs.htmlize epkgs.nix-ts-mode (epkgs.treesit-grammars.with-grammars (g: [ g.tree-sitter-nix ])) ])) gettext po4a ]; buildPhase = '' runHook preBuild patchShebangs scripts scripts/build-html.sh --output build runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out cp -r build/. $out/ runHook postInstall ''; }; }; }; }
3.2. Overlays#
3.2.1. Overview#
This file defines nixpkgs overlays for patching broken packages, pinning specific versions, and adding local workarounds. Each overlay modifies the package set to address issues that would otherwise block the system build or cause runtime problems.
For details on overlay mechanics (final: prev: pattern, composition order, etc.),
see nixpkgs overlay documentation.
Overlays are organized into five categories based on their purpose and expected lifetime:
- stable: Packages fetched from nixpkgs-stable when broken in unstable and the fix would trigger excessive rebuilds or is too complex to patch locally.
- cuda: Packages taken from the CUDA channel, the only revision whose CUDA builds are cached.
- temporary-fix: Local overrides (e.g., disabling tests) that don't require a different nixpkgs version. Remove once fixed upstream.
- pre-release: Alpha, beta, or pre-release packages for testing before they land in nixpkgs.
- patches: Workarounds not suitable for upstream contribution (e.g., locale-specific fixes, local tooling shims). Expected to remain indefinitely.
{ inputs }:
{
<<stable>>
<<cuda>>
<<temporary-fix>>
<<pre-release>>
<<patches>>
}
3.2.1.1. stable#
Fetch packages from nixpkgs-stable when broken in unstable. This includes cases where the upstream fix would trigger excessive rebuilds, or where the issue is too complex to patch locally.
stable = final: prev: { };
3.2.1.2. cuda#
cache.nixos-cuda.org only covers the head of nixos-unstable-cuda, so kilimanjaro takes
its CUDA packages from nixpkgs-cuda. Naming ollama and onnxruntime is enough:
openvino, opencv and cudnn-frontend are only reachable through them.
The package set is imported plain because the CUDA Hydra builds plain nixpkgs, and a local
overlay reaching into that closure would move the store paths off the cache. The guard
keeps the pin off hosts that leave cudaSupport unset.
cuda = final: prev: prev.lib.optionalAttrs (prev.config.cudaSupport or false) ( let pkgs = import inputs.nixpkgs-cuda { inherit (prev.stdenv.hostPlatform) system; config = { cudaSupport = true; allowUnfree = true; }; }; in { inherit (pkgs) onnxruntime ollama; } );
3.2.1.3. temporary-fix#
Local overrides for packages that build but have failing tests or minor issues.
Unlike the stable overlay, these don't require fetching packages from a different
nixpkgs branch. We simply override specific attributes (like doCheck) on the
existing packages. Remove these overrides once the issues are fixed upstream.
temporary-fix = final: prev: { <<python313-package-set>> <<handy>> };
3.2.1.3.1. Python313 package set#
Override problematic Python packages using packageOverrides.
Python packages in nixpkgs form an interconnected dependency graph. The packageOverrides
mechanism ensures that when a package is overridden, all dependent packages automatically
see the modified version. This is essential for consistency—a direct overlay override
(e.g., python313Packages.foo = ...) would only affect top-level access, leaving
internal dependencies using the original broken version.
See nixpkgs Python documentation for details.
python313 = prev.python313.override { packageOverrides = pyfinal: pyprev: { <<rapidocr-onnxruntime>> <<lxml-html-clean>> }; };
- rapidocr-onnxruntime
The test suite causes a segmentation fault during execution. The root cause is still under investigation.
This package is pulled in as a transitive dependency. Runtime functionality has been verified to work correctly in actual use, so disabling the test suite is a safe workaround.
<<rapidocr-onnxruntime>>rapidocr-onnxruntime = pyprev.rapidocr-onnxruntime.overridePythonAttrs (_: { doCheck = false; });
- lxml-html-clean
Tests fail due to breaking changes in libxml2 2.14, which modified how certain DOM operations handle whitespace and entity encoding. These changes cause test assertions to fail even though the actual HTML cleaning functionality works correctly.
Tracked upstream in fedora-python/lxml_html_clean#24. Remove this override once the test suite is updated for libxml2 2.14 compatibility.
<<lxml-html-clean>>lxml-html-clean = pyprev.lxml-html-clean.overridePythonAttrs (_: { doCheck = false; });
3.2.1.3.2. Handy#
For its custom provider Handy attached a top-level reasoning_effort of "none" to
every post-processing request, and Google's compatibility endpoint rejects that with
400 INVALID_ARGUMENT. Handy pasted the raw transcript instead of reporting anything,
so against my Gemini base URL post-processing never once ran.
The patch is the upstream fix, which retries without the reasoning fields after a 400 or 422. It landed after 0.9.4, so this goes away once nixpkgs ships a release carrying it.
handy = prev.handy.overrideAttrs (oldAttrs: { patches = (oldAttrs.patches or [ ]) ++ [ ./handy-retry-without-reasoning.patch ]; });
3.2.1.4. pre-release#
Overlay for testing alpha, beta, or pre-release versions of packages before they land in nixpkgs. Useful for evaluating release candidates, nightly builds, or packages pending upstream review. Once a package is available in nixpkgs, remove it from this overlay.
pre-release = final: prev: { };
3.2.1.5. patches#
Workarounds that are not suitable for upstream contribution.
These patches address issues that upstream would likely not accept—either because they
are specific to this configuration (e.g., locale settings), bypass intended behavior,
or solve problems in unconventional ways. Unlike temporary-fix, these are expected
to remain indefinitely.
patches = final: prev: { <<gh-dash>> <<command-line-tools-shim>> };
3.2.1.5.1. gh-dash#
The preview pane renders incorrectly when LANG=ja_JP.UTF-8 is set. The issue stems from
gh-dash's terminal width calculation, which miscounts the display width of certain UTF-8
characters (particularly CJK characters and some emoji). This causes text wrapping and
alignment to break.
Setting LANG=C.UTF-8 forces ASCII-compatible width calculations while preserving UTF-8
encoding support, which fixes the rendering issue. We use writeShellApplication to create
a wrapper that sets this environment variable before invoking the real binary.
Reported upstream in dlvhdr/gh-dash#316.
gh-dash = (final.writeShellApplication { name = "gh-dash"; text = '' LANG=C.UTF-8 ${final.lib.getExe prev.gh-dash} "$@" ''; }).overrideAttrs { pname = "gh-dash"; };
3.2.1.5.2. mkShim#
Shim utility for providing stub implementations of macOS Command Line Tools.
On darwin systems without Xcode Command Line Tools installed, invoking commands like
cc or python3 triggers an annoying system popup prompting installation.
These shims intercept such calls and either delegate to Nix-provided tools or return
appropriate exit codes, suppressing the popup and preventing spurious build failures.
See pkgs/mkShim for the implementation details and list of shimmed commands.
inherit (final.callPackage ../pkgs/mkShim { }) mkShim commandLineToolsShim;
3.3. Modules#
3.3.1. Overview#
This file defines custom modules that extend the standard NixOS, nix-darwin, and home-manager module systems. Each module addresses specific use cases or provides opinionated defaults not available upstream.
Modules are organized by functional domain (e.g., Shell, Networking, Version Control) rather than by module system target. When a domain contains both system-level and user-level configuration, subheaders separate them explicitly.
3.3.2. Nix#
Nix package manager and nixpkgs configuration. These modules are shared across NixOS and nix-darwin, providing consistent Nix behavior on all machines.
3.3.2.1. Core Settings#
Core Nix daemon configuration including flakes, garbage collection, binary caches, and sandbox settings.
Registered under both NixOS and nix-darwin, since Nix daemon behavior is shared
across platforms; the systemModule binding lets both classes reuse the exact
same module rather than duplicating it. The user-level counterpart
(User-Level Settings) is a distinct flake.modules.homeManager.nix entry in this
same file, since XDG base directories and the global gitignore belong to the
home-manager scope, not the system scope.
{ ... }:
let
systemModule =
{
config,
lib,
pkgs,
...
}:
let
cfg = config.my.nix;
in
{
<<nix-options>>
<<nix-config>>
};
in
{
flake.modules.nixos.nix = systemModule;
flake.modules.darwin.nix = systemModule;
<<nix-home-manager>>
}
3.3.2.1.1. Options#
options.my.nix = { enable = lib.mkEnableOption "Nix configuration"; enableFlakes = lib.mkOption { default = true; example = false; description = "Whether to enable flakes."; type = lib.types.bool; }; };
3.3.2.1.2. Configuration#
config = lib.mkIf cfg.enable ( lib.mkMerge [ <<nix-flakes>> { <<nix-store-optimisation>> <<nix-warn-dirty>> <<nix-substituters>> <<nix-sandbox>> <<nix-trusted-users>> <<nix-gc>> <<nix-extra-options>> } ] );
- Flakes
Flakes are the de facto standard for Nix project management and are used throughout this repository.
Channels are disabled because they introduce mutable state (
/nix/var/nix/profiles/per-user/*/channels) that is difficult to reproduce across machines. However, this does not break legacy commands likenix-shell— NixOS and nix-darwin automatically setNIX_PATHfrom the flake's inputs by default (seenixpkgs.flake.setNixPath), so<nixpkgs>lookups continue to work on all machines managed by this dotfiles repository.<<nix-flakes>>(lib.mkIf cfg.enableFlakes { nix = { settings.experimental-features = [ "flakes" "nix-command" ]; channel.enable = false; }; })
- Store Optimisation
Two complementary deduplication mechanisms are enabled:
nix.optimise.automaticperiodically runsnix-store --optimiseto hard-link identical files already in the store.nix.settings.auto-optimise-storededuplicates at build time as new paths are added.
auto-optimise-storeis Linux-only because enabling it on macOS corrupts the store — the build fails witherror: cannot link '/nix/store/.tmp-link' to '/nix/store/.links/...': File exists. See NixOS/nix#7273.<<nix-store-optimisation>>nix.optimise.automatic = true; nix.settings.auto-optimise-store = pkgs.stdenv.hostPlatform.isLinux;
- Garbage Collection
Periodically runs
nix-collect-garbageto reclaim disk space. Generations older than 7 days are deleted automatically — keeping old builds beyond that provides little value since they can always be rebuilt from the flake lock file.<<nix-gc>>nix.gc = { automatic = true; options = "--delete-older-than 7d"; };
- Dirty Warning
Suppress the "Git tree is dirty" warning during flake evaluation. This warning fires on every build when there are uncommitted changes, which is the normal state during development.
<<nix-warn-dirty>>nix.settings.warn-dirty = false;
- Binary Caches
I configure three binary caches.
nix-cache.natsukium.comis my self-hosted niks3 cache (on manyara, backed by Cloudflare R2); CI pushes this dotfiles repository's pre-built artifacts to it.natsukiumis my Cachix cache, which my other repositories still push to — only dotfiles has moved to niks3, so for now niks3 is dotfiles-only and Cachix covers everything else, though I may broaden niks3's scope later.nixos-cudacovers CUDA packages, which kilimanjaro needs because it builds withcudaSupport; without it, every bump to a package in that closure turns into a local rebuild. These builds came fromnix-communityuntil November 2025, when the CUDA team moved to its own Hydra.<<nix-substituters>>nix.settings = { substituters = [ "https://nix-cache.natsukium.com" "https://natsukium.cachix.org" "https://cache.nixos-cuda.org" ]; trusted-public-keys = [ "niks3-1:SoIFTPtiPoCW3/OzUkIBKlLG5znMZfbihlr11XAOles=" "natsukium.cachix.org-1:STD7ru7/5+KJX21m2yuDlgV6PnZP/v5VZWAJ8DZdMlI=" "cache.nixos-cuda.org:74DUi4Ye579gUqzH4ziL9IyiJBlDpMRn9MBN8oNan9M=" ]; };
- Sandbox
The sandbox is set to
"relaxed"on Darwin to avoid unexpected build failures. Withsandbox = true, some packages fail to build on macOS due to sandbox restrictions — likely related to localhost networking required by test suites that start local servers, though the exact mechanism is not fully understood. Nixpkgs provides__darwinAllowLocalNetworkingto allow localhost access within the sandbox, which may address the same class of issues."relaxed"keeps normal derivations sandboxed while allowing derivations with__noChroot = trueto bypass the sandbox, preventing these failures.<<nix-sandbox>>nix.settings.sandbox = if pkgs.stdenv.hostPlatform.isDarwin then "relaxed" else true;
- Trusted Users
On macOS, administrative users belong to the
admingroup, notwheel(which only containsroot). Without@admin, the primary user would not be trusted by the Nix daemon on Darwin.<<nix-trusted-users>>nix.settings.trusted-users = [ "root" "@wheel" ] ++ lib.optional pkgs.stdenv.hostPlatform.isDarwin "@admin";
- Extra Options
Terminates builds that produce no output for 3600 seconds (1 hour). Some heavy builds (e.g., Chromium, kernel compilation, CUDA-based deep learning libraries) can be silent for extended periods, so the timeout is set generously to avoid false positives while still catching genuinely hung builds.
<<nix-extra-options>>nix.extraOptions = '' max-silent-time = 3600 '';
3.3.2.2. Nixpkgs#
Nixpkgs configuration. allowUnfree is enabled by default. While free software is
preferred where possible, strictly enforcing it is impractical — NVIDIA hardware requires
unfree drivers and CUDA libraries, and specifying each unfree package individually via
allowUnfreePredicate would be prohibitively tedious for the number of transitive
dependencies involved.
Registered under both NixOS and nix-darwin from the same module binding, since nixpkgs configuration has no platform-specific behavior.
{ ... }: let module = { config, lib, ... }: let cfg = config.my.nixpkgs; in { options.my.nixpkgs = { enable = lib.mkEnableOption "Nixpkgs configuration"; allowUnfree = lib.mkOption { default = true; example = false; description = "Whether to allow unfree packages."; type = lib.types.bool; }; }; config = lib.mkIf cfg.enable { nixpkgs = { config.allowUnfree = cfg.allowUnfree; }; }; }; in { flake.modules.nixos.nixpkgs = module; flake.modules.darwin.nixpkgs = module; }
3.3.2.3. Distributed Builds#
Every non-server host offloads builds to the rest of the fleet over SSH, so my laptop
can hand an aarch64-linux or CUDA derivation to a machine that builds it natively
instead of emulating it. This used to sit outside the registry as an unconditional
import, and server hosts opted out by overriding nix.distributedBuilds directly. I
folded it into a my.nix.distributedBuilds feature so that the base profile turns it
on and the server profile turns it off the same way every other feature is toggled,
instead of through a setting only this one subsystem knew about.
The build machines, their host keys, and the SSH connection tuning all derive from one
machines attrset. Without that single source the set of hostnames would appear three
times — in nix.buildMachines, in programs.ssh.knownHosts, and in the ssh_config
Host line — and the three copies would drift as machines join and leave.
Registered under both NixOS and nix-darwin from one systemModule, since the offloading
logic and the SSH host trust it depends on are identical on both. Each host reaches the
other machines' evaluated configurations through inputs.self.outputs; the options
below explain what the builder entries read from them.
{ ... }:
let
systemModule =
{
inputs,
config,
lib,
...
}:
let
cfg = config.my.nix.distributedBuilds;
protocol = if (config.services ? hydra && config.services.hydra.enable) then "ssh" else "ssh-ng";
inherit (inputs.self.outputs.nixosConfigurations) kilimanjaro serengeti tarangire;
inherit (inputs.self.outputs.darwinConfigurations) mikumi;
in
{
<<distributed-builds-options>>
<<distributed-builds-config>>
};
in
{
flake.modules.nixos.distributed-builds = systemModule;
flake.modules.darwin.distributed-builds = systemModule;
}
3.3.2.3.1. Protocol#
ssh-ng is preferred over plain ssh because it transfers store paths more efficiently
and supports content-addressed derivations. Hydra does not speak ssh-ng
(NixOS/hydra#688), so a host running Hydra falls back to ssh.
3.3.2.3.2. Options#
Each machine entry hardcodes only what its host's configuration cannot answer: the host
key and the platforms it builds for. The job limit and build features are read from the
machine's own config.nix.settings, so raising max-jobs on tarangire updates its
builder entry everywhere with no second edit. mikumi's feature list is the one
exception, spelled out by hand because its configuration does not declare
system-features.
excludeHosts keeps a machine out of one host's builder list beyond the automatic
self-exclusion. work is a company Mac that should not offload to mikumi, my personal
one, so mikumi names work there.
connectTimeout caps how long a host waits for a builder that is not answering. When a
builder is down and its packets are silently dropped, SSH waits out the operating
system's default connect timeout — tens of seconds — before Nix gives up and builds
locally, so every build stalls on the dead machine first. Five seconds is long enough to
reach a machine that is merely slow to answer and short enough that a dead one barely
registers.
options.my.nix.distributedBuilds = { enable = lib.mkEnableOption "distributed builds"; connectTimeout = lib.mkOption { type = lib.types.ints.positive; default = 5; description = "SSH ConnectTimeout in seconds for reaching build machines."; }; machines = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options = { publicKey = lib.mkOption { type = lib.types.str; }; systems = lib.mkOption { type = lib.types.listOf lib.types.str; }; maxJobs = lib.mkOption { type = lib.types.ints.positive; }; supportedFeatures = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; }; excludeHosts = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; }; }; } ); description = "Build machines the fleet can offload to."; default = { tarangire = { publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEJJYgE/dmYLXYBrVnPicd0qsaUeqcBtXB8H9LHkJ2j4"; systems = [ "x86_64-linux" "i686-linux" ]; maxJobs = tarangire.config.nix.settings.max-jobs; supportedFeatures = tarangire.config.nix.settings.system-features; }; kilimanjaro = { publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILhpfAalh6A5xDSE+HOdNE29ZgIjlP7tdlhHs82boSwp"; systems = [ "x86_64-linux" "i686-linux" ]; maxJobs = kilimanjaro.config.nix.settings.max-jobs; supportedFeatures = kilimanjaro.config.nix.settings.system-features; }; serengeti = { publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDWwhfhDSZ+M2XDwP2MlC/zFfVpk3WjUxV/JWFgGzgNW"; systems = [ "aarch64-linux" ]; maxJobs = serengeti.config.nix.settings.max-jobs; supportedFeatures = serengeti.config.nix.settings.system-features; }; mikumi = { publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPfWOWKBFuDV08g6xP9MMY78CERI02CNG+5dy8CXQmXs"; systems = [ "aarch64-darwin" "x86_64-darwin" ]; maxJobs = mikumi.config.nix.settings.max-jobs; supportedFeatures = [ "apple-virt" "benchmark" "big-parallel" "nixos-test" ]; excludeHosts = [ "work" ]; }; }; }; };
3.3.2.3.3. Configuration#
A host builds its nix.buildMachines list from every machine except itself and any that
named it in excludeHosts. The host keys go into programs.ssh.knownHosts for the whole
set, since unattended SSH cannot answer an interactive host-key prompt, and the
ConnectTimeout applies to the same set through an ssh_config Host block.
config = lib.mkIf cfg.enable { nix = { distributedBuilds = true; extraOptions = '' builders-use-substitutes = true ''; buildMachines = lib.pipe cfg.machines [ (lib.filterAttrs ( name: machine: name != config.networking.hostName && !(lib.elem config.networking.hostName machine.excludeHosts) )) (lib.mapAttrsToList ( hostName: machine: { inherit hostName protocol; inherit (machine) systems maxJobs supportedFeatures; sshUser = "natsukium"; speedFactor = 1; mandatoryFeatures = [ ]; } )) ]; }; programs.ssh.knownHosts = lib.mapAttrs (_: machine: { inherit (machine) publicKey; }) cfg.machines; programs.ssh.extraConfig = '' Host ${lib.concatStringsSep " " (lib.attrNames cfg.machines)} ConnectTimeout ${toString cfg.connectTimeout} ''; };
3.3.2.4. User-Level Settings#
User-level Nix settings, managed through home-manager because they belong to the user's environment rather than the system configuration.
XDG base directories are enabled to keep ~/.nix-defexpr and ~/.nix-profile under
~/.config/nix/ and ~/.local/state/nix/, respectively, reducing dotfile clutter in
$HOME.
The result symlink is added to git ignores because nix build creates result symlinks
in the project root by default, and these should never be committed. This applies to the
user's global gitignore, not to any specific repository.
flake.modules.homeManager.nix = { config, lib, ... }: let cfg = config.my.nix; in { options.my.nix.enable = lib.mkEnableOption "nix"; config = lib.mkIf cfg.enable { nix.settings.use-xdg-base-directories = config.xdg.enable; programs.git.ignores = [ "result" ]; }; };
3.3.3. Networking#
3.3.3.1. Tailscale#
Opinionated Tailscale VPN configuration for NixOS systems. This module provides sensible defaults for running Tailscale with MagicDNS, SSH access, and proper firewall configuration.
{ ... }: { flake.modules.nixos.tailscale = { config, lib, ... }: let cfg = config.my.services.tailscale; in { <<tailscale-options>> <<tailscale-config>> }; }
3.3.3.1.1. Options#
Module options for controlling Tailscale behavior.
options.my.services.tailscale = { enable = lib.mkEnableOption "Tailscale VPN"; <<configureResolver-option>> };
- configureResolver
Desktop systems may experience DNS resolution failures after suspend/resume. When the system resumes, external domain resolution (e.g., github.com) fails while Tailnet hostnames continue to work. This is a known issue with Tailscale's DNS state management during network transitions.
Enabling
configureResolveractivates systemd-resolved, which helps mitigate DNS resolution issues after suspend/resume. While this doesn't completely eliminate the issue (the upstream bug remains open), it provides the most reliable MagicDNS experience.For headless servers, this option is typically unnecessary because servers don't suspend, and thus never encounter this resume-related DNS bug.
See tailscale/tailscale#4254 for the upstream discussion.
<<configureResolver-option>>configureResolver = lib.mkOption { type = lib.types.bool; default = false; description = '' Enable systemd-resolved for Tailscale DNS. Recommended for desktop systems to mitigate DNS failures after suspend/resume. https://github.com/tailscale/tailscale/issues/4254 ''; };
3.3.3.1.2. Configuration#
config = lib.mkIf cfg.enable ( lib.mkMerge [ { <<tailscale-service>> <<tailscale-networking>> <<tailscale-secrets>> } <<tailscale-resolver>> ] );
- Service Settings
Core Tailscale service configuration.
useRoutingFeatures = "server"enables this machine to act as a subnet router or exit node. All machines in this configuration can potentially serve as exit nodes for other devices, providing flexibility when traveling or on restricted networks.authKeyFilepoints to a SOPS-managed secret containing a Tailscale auth key. Using auth keys enables unattended authentication, which is essential for automated deployments and GitOps workflows with tools like comin.--sshenables Tailscale SSH, allowing SSH access over the Tailscale network without managing SSH keys or exposing port 22 to the public internet.<<tailscale-service>>services.tailscale = { enable = true; useRoutingFeatures = "server"; authKeyFile = config.sops.secrets.tailscale-authkey.path; extraUpFlags = [ "--ssh" ]; };
- Networking
Firewall and DNS configuration for Tailscale integration.
The
tailscale0interface is trusted because all traffic on this interface is authenticated by Tailscale. This allows services to be exposed only to the tailnet without additional firewall rules.100.100.100.100is Tailscale's MagicDNS resolver, enabling resolution of tailnet hostnames (e.g.,hostname.tail4108.ts.net).8.8.8.8provides fallback for non-tailnet queries, though in practice MagicDNS handles forwarding to upstream resolvers.The search domain is the tailnet domain, allowing short hostnames (e.g.,
ssh manyarainstead ofssh manyara.tail4108.ts.net).<<tailscale-networking>>networking = { firewall = { trustedInterfaces = [ "tailscale0" ]; allowedUDPPorts = [ config.services.tailscale.port ]; }; nameservers = [ "100.100.100.100" "8.8.8.8" ]; search = [ "tail4108.ts.net" ]; };
- Secrets
SOPS secret declaration for the Tailscale auth key. The actual key is stored encrypted in the repository's secrets file and decrypted at activation time.
<<tailscale-secrets>>sops.secrets.tailscale-authkey = { };
- Resolver
Conditional systemd-resolved configuration. Only enabled when
configureResolveris true, typically on desktop systems that suspend/resume.<<tailscale-resolver>>(lib.mkIf cfg.configureResolver { services.resolved.enable = cfg.configureResolver; })
3.3.4. File Synchronization#
3.3.4.1. org-sync#
I keep my org notes in ~/dropbox/org and have synced them across my desktop
and laptops through Dropbox. I added Syncthing so the hermes-agent VM can read
the same notes, and I plan to drop Dropbox and rely on Syncthing alone once it
has proven itself.
This is a user-level services.syncthing instance, separate from the
system-level daemon kilimanjaro already runs; its ports are shifted off the
defaults so the two do not collide.
{ ... }: { flake.modules.homeManager."org-sync" = { config, lib, ... }: let cfg = config.my.services.org-sync; in { options.my.services.org-sync = { enable = lib.mkEnableOption "syncing the org folder across the user's devices"; devices = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { options.id = lib.mkOption { type = lib.types.str; description = "Syncthing device ID."; }; } ); default = { }; description = "Peer devices that participate in the org folder."; }; }; config = lib.mkIf cfg.enable { services.syncthing = { enable = true; guiAddress = "127.0.0.1:8385"; overrideDevices = true; overrideFolders = true; settings = { options.listenAddresses = [ "tcp://0.0.0.0:22001" "quic://0.0.0.0:22001" ]; devices = cfg.devices; folders.org = { path = "${config.home.homeDirectory}/dropbox/org"; devices = lib.attrNames cfg.devices; }; }; }; }; }; }
3.3.5. Shell#
Interactive shells and scripting shells serve different purposes. An interactive shell does not need to be POSIX-compatible — what matters is usability, broad environment support, and extensibility.
3.3.5.1. Fish#
Fish is the primary interactive shell, chosen for its out-of-the-box experience: syntax highlighting, autosuggestions, and tab completions work without configuration or plugins. Among shells with broad environment and software support, fish provides the most convenient and extensible interactive experience with the least setup effort.
Both NixOS and nix-darwin hand fish its environment by sourcing each
environment.*Init snippet – and on NixOS the whole session environment – through a
bash foreign-env round-trip on every interactive startup. Spawning bash is a fixed cost
paid even when a snippet is empty; I measured ~22ms across those calls on one of my NixOS
hosts. So I enable useBabelfish to translate the environment to native fish once at
build time instead.
programs.fish.useBabelfish = true;
{ ... }: let # System scope: install fish, register it as a login shell, and make it the # primary user's default shell. systemModule = { config, lib, pkgs, ... }: { options.my.programs.fish.enable = lib.mkEnableOption "fish"; config = lib.mkIf config.my.programs.fish.enable { programs.fish.enable = true; environment.shells = [ pkgs.fish ]; users.users.${config.my.username}.shell = pkgs.fish; <<useBabelfish>> }; }; # Darwin layers macOS-only setup on top of the shared system config. darwinModule = { config, lib, ... }: { imports = [ systemModule ]; config = lib.mkIf config.my.programs.fish.enable { # distributed builds fail with "fish: Unknown command: nix-store" over SSH; # source the nix-daemon vars so nix-store is reachable. # https://github.com/NixOS/nix/issues/7508#issuecomment-2597403478 programs.fish.shellInit = '' if test -e '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.fish' && test -n "$SSH_CONNECTION" source '/nix/var/nix/profiles/default/etc/profile.d/nix-daemon.fish' end ''; # nix-darwin only manages a user's shell when the account is "known" and its # uid matches the existing one, so the shell setting above takes effect. # https://github.com/LnL7/nix-darwin/issues/1237#issuecomment-2562242340 users.users.${config.my.username}.uid = lib.mkDefault 501; users.knownUsers = [ config.my.username ]; }; }; in { flake.modules.nixos.fish = systemModule; flake.modules.darwin.fish = darwinModule; flake.modules.homeManager.fish = { config, lib, pkgs, ... }: let <<fish-any-nix-shell>> in { options.my.programs.fish.enable = lib.mkEnableOption "fish"; config = lib.mkIf config.my.programs.fish.enable { programs.fish = { enable = true; <<fish-interactive-shell-init>> <<fish-abbreviations>> <<fish-functions>> <<fish-plugins>> }; xdg.configFile = { "fish/functions/nix.fish".source = anyNixShellFunction "nix"; "fish/functions/nix-shell.fish".source = anyNixShellFunction "nix-shell"; }; }; }; }
3.3.5.1.1. Interactive Shell Init#
Settings applied when fish starts an interactive session: keybindings, plugin configuration, environment variables, and shell integrations.
interactiveShellInit = '' <<fish-keybindings>> <<fish-done-config>> <<fish-pinentry>> <<fish-extra-abbrs>> '';
- Keybindings
Ctrl+Sis bound tozi(zoxide interactive mode) for fuzzy directory jumping.<<fish-keybindings>>bind \cs zi
- Pinentry
When connected via SSH, GPG's pinentry is switched to the curses (terminal) variant. The default graphical pinentry cannot display on a remote session without X11/Wayland forwarding, so the TUI fallback is necessary for signing commits and decrypting secrets over SSH. See Gentoo Wiki: GnuPG - Changing pinentry for SSH logins.
<<fish-pinentry>># set environment variable for pinentry if test "$SSH_CONNECTION" != "" set -x PINENTRY_USER_DATA "USE_CURSES" end
- Extra Abbreviations
Position-aware abbreviations that use fish's advanced features (
--position anywhere,--regex,--function) which are not available through home-manager'sshellAbbrsoption.<<fish-extra-abbrs>># extra abbrs abbr -a L --position anywhere --set-cursor "% | less" abbr -a !! --position anywhere --function _abbr_last_history_item abbr -a extract_tar_gz --position command --regex ".+\.tar\.gz" --function _abbr_extract_tar_gz abbr -a dotdot --regex '^\.\.+$' --function _abbr_multicd
3.3.5.1.2. Abbreviations#
shellAbbrs = { <<fish-abbr-general>> <<fish-abbr-nix>> };
- General
<<fish-abbr-general>># spellchecker:off l = "ls";
- Nix Remote Build
Abbreviations for specifying Nix build target systems, primarily used when working on nixpkgs. Each abbreviation expands to
--system <triple>and conditionally appends-j0when the target system differs from the current host.-j0sets the local job limit to zero, forcing all builds to be delegated to remote builders (see Distributed Builds). This prevents Nix from accidentally attempting to build on the host, which would fail with an architecture mismatch.<<fish-abbr-nix>>"--sxl" = { position = "anywhere"; expansion = "--system x86_64-linux" + pkgs.lib.optionalString ( pkgs.stdenv.hostPlatform.isDarwin || pkgs.stdenv.hostPlatform.isAarch64 ) " -j0"; }; "--sal" = { position = "anywhere"; expansion = "--system aarch64-linux" + pkgs.lib.optionalString ( pkgs.stdenv.hostPlatform.isDarwin || pkgs.stdenv.hostPlatform.isx86_64 ) " -j0"; }; "--sxd" = { position = "anywhere"; expansion = "--system x86_64-darwin" + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux " -j0"; }; "--sad" = { position = "anywhere"; expansion = "--system aarch64-darwin" + pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux " -j0"; }; # spellchecker:on
3.3.5.1.3. Functions#
Helper functions used by the abbreviation system. These are separated from the
abbreviation definitions because fish requires functions to be defined before they
can be referenced by --function abbreviations.
functions = { _abbr_last_history_item = "echo $history[1]"; _abbr_extract_tar_gz = "echo tar avfx $argv"; _abbr_multicd = "echo cd (string repeat -n (math (string length -- $argv[1]) - 1) ../)"; };
- any-nix-shell
any-nix-shell keeps fish as the interactive shell inside
nix shellandnix developenvironments; without it, entering a Nix shell drops into bash and loses fish's interactive features.any-nix-shell fishonly emits two function definitions (nixandnix-shell), but generating them shells out to/bin/shplus threewhichlookups — ~24ms on every interactive startup if sourced eagerly. Running it once at build time and splitting the output into fish's autoload directory moves that cost off the hot path (fish parses each function on first call), and uses any-nix-shell's real wrappers rather than a hardcoded copy, so itsnix runversion gate and--purehandling stay in sync with the upstream package.<<fish-any-nix-shell>># Generate any-nix-shell's fish wrappers at build time. Each call extracts one # function definition into its own file for fish's autoload directory, so the # wrappers stay in sync with the package rather than being hardcoded. anyNixShellFunction = name: pkgs.runCommand "any-nix-shell-${name}.fish" { nativeBuildInputs = [ pkgs.any-nix-shell ]; } '' any-nix-shell fish | awk -v want=${name} ' $1 == "function" { current = $2; depth = 0 } current == want { print if ($1 ~ /^(function|begin|if|for|while|switch)$/) depth++ else if ($1 == "end" && --depth == 0) current = "" } ' > $out '';
3.3.5.1.4. Plugins#
plugins = [ { name = "done"; src = pkgs.fishPlugins.done.src; } { name = "fzf-fish"; src = pkgs.fishPlugins.fzf-fish.src; } ];
The done plugin provides desktop notifications for long-running commands. The threshold is set to 15 seconds — commands shorter than this are typically interactive and do not benefit from notifications, while longer commands (builds, test suites, large file operations) are often run in the background where a notification is valuable.
# set done's variable set -U __done_min_cmd_duration 15000
fzf-fish integrates fzf with fish's tab completion, history search, and file/directory
navigation. It replaces fish's built-in history search (Ctrl+R) with fzf's fuzzy
finder, which handles large histories more effectively.
3.3.5.2. Bash#
Bash is configured as a minimal fallback shell rather than a full interactive environment. The primary use case is ensuring a usable shell exists when fish is unavailable, and providing a POSIX-compatible shell for scripts and tools that assume bash. It also serves as a testing environment when fish exhibits unexpected behaviour — having a clean bash available helps isolate whether an issue is fish-specific.
Registered as a home-manager feature behind my.programs.bash.enable, matching
the toggle every other shell in this section already has, rather than applying
unconditionally to every home that imports the profile it used to live in.
{ ... }: { flake.modules.homeManager.bash = { pkgs, lib, config, ... }: let cfg = config.my.programs.bash; in { options.my.programs.bash.enable = lib.mkEnableOption "bash"; config = lib.mkIf cfg.enable { home.packages = with pkgs; [ bashInteractive ]; programs.bash = { enable = true; <<bash-history>> <<bash-completion>> <<bash-aliases>> <<bash-init>> }; }; }; }
3.3.5.2.1. History#
History is stored under XDG_CONFIG_HOME to keep $HOME clean, consistent with
the repository's preference for XDG base directories (see User-Level Settings).
historyFile = "$XDG_CONFIG_HOME/bash/history";
3.3.5.2.2. Completion#
Bash completion is disabled because bash is not used as the primary interactive shell. Loading completion scripts adds startup time (~100ms+) with no benefit when the shell is only used as a fallback or for running scripts.
enableCompletion = false;
3.3.5.2.3. Aliases#
Basic aliases that provide a minimal quality-of-life improvement if bash is used interactively. These are intentionally simple — fish handles the rich interactive experience.
shellAliases = { l = "ls -CF"; grep = "grep --color=auto"; fgrep = "fgrep --color=auto"; egrep = "egrep --color=auto"; };
3.3.5.2.4. Init#
This configuration is a remnant from before fish was set as the default login shell.
Previously, bash was the login shell and .bashrc launched fish, so these settings
were needed in every interactive session. They are retained because bash is still
used as a fallback.
initExtra = '' <<bash-terminal-settings>> '' + lib.optionalString (!config.programs.kitty.enable) '' <<bash-tmux-autostart>> '';
- Terminal Settings
stty stop undefdisablesCtrl+Sfrom sendingXOFF, freeing it for use as a keybinding in fish and other TUI applications. Without it,Ctrl+Sfreezes the terminal untilCtrl+Qis pressed.<<bash-terminal-settings>>stty stop undef # Ctrl-s
- TMUX Auto-attach
Automatically starts or attaches to a tmux session when bash is the interactive shell, but only when kitty is not the terminal emulator. Kitty has its own tab/window management (splits, layouts, tabs) that conflicts with tmux's multiplexing — running tmux inside kitty creates redundant nesting. When using a simpler terminal (e.g.,
xterm,alacritty, or a Linux console), tmux provides the session persistence and window management that would otherwise be missing.<<bash-tmux-autostart>># TMUX (from ArchWiki) if type tmux > /dev/null 2>&1; then # if no session is started, start a new session test -z $TMUX && tmux # when quitting tmux, try to attach while test -z $TMUX; do tmux attach || break done fi
3.3.5.3. Readline#
readline provides line editing for bash and for most REPLs with a plain prompt, such as
the Python interpreter and psql. Configuring it here rather than through bash
keybindings covers all of them at once.
Ctrl+W deletes back to the previous / rather than the previous word boundary, which
suits the paths that make up most of what I type at a prompt. The binding only holds
with bind-tty-special-chars off: readline otherwise copies the terminal's werase
character into its keymap after reading inputrc, which puts Ctrl+W back on
unix-word-rubout.
completion-ignore-case makes tab completion case-insensitive.
{ ... }: { flake.modules.homeManager.readline = { lib, config, ... }: let cfg = config.my.programs.readline; in { options.my.programs.readline.enable = lib.mkEnableOption "readline"; config = lib.mkIf cfg.enable { programs.readline = { enable = true; variables = { completion-ignore-case = true; bind-tty-special-chars = false; }; bindings = { "\\C-w" = "unix-filename-rubout"; }; }; }; }; }
3.3.5.4. Nushell#
Nushell is enabled for its ability to handle structured data natively, similar to
PowerShell. Where traditional shells pipe text between commands, Nushell operates on
serialised data formats (JSON, YAML, CSV, etc.) as first-class tables and records,
making simple data manipulation possible without reaching for external tools like jq.
Like fish, it is non-POSIX, but POSIX compatibility is not a requirement for an
interactive shell.
No custom configuration is added yet — the defaults are sufficient for exploratory use, and committing to Nushell-specific workflows would be premature.
Registered behind my.programs.nushell.enable, like the other shells in this section.
{ ... }: { flake.modules.homeManager.nushell = { config, lib, pkgs, ... }: let cfg = config.my.programs.nushell; completerCfg = config.ext.programs.nushell.externalCompleter; in { options.my.programs.nushell.enable = lib.mkEnableOption "nushell"; <<nushell-external-completer-option>> config = lib.mkMerge [ (lib.mkIf cfg.enable { programs.nushell.enable = true; }) <<nushell-external-completer-config>> ]; }; }
3.3.5.4.1. External Completer#
Nushell has no built-in tab-completion source for external (non-Nushell) commands. Piping candidates through fish's own completion engine reuses every completion already defined for fish — package names, git subcommands, CLI flags — instead of duplicating that logic in Nushell's completer syntax.
externalCompleter defaults to following whether Nushell itself is enabled, so
enabling the shell is enough to get external completions without a second
toggle; enableFishCompleter exists to opt out if fish is not the shell doing
the completing.
options.ext.programs.nushell.externalCompleter = { enable = lib.mkOption { type = lib.types.bool; default = config.programs.nushell.enable; }; enableFishCompleter = lib.mkOption { type = lib.types.bool; default = true; }; };
(lib.mkIf completerCfg.enable { programs.nushell.extraConfig = lib.mkIf completerCfg.enableFishCompleter '' let fish_completer = {|spans| ${lib.getExe pkgs.fish} --command $'complete "--do-complete=($spans | str join " ")"' | $"value(char tab)description(char newline)" + $in | from tsv --flexible --no-infer } $env.config = ($env.config? | default {}) $env.config.completions = ($env.config.completions? | default {}) $env.config.completions.external = ( $env.config.completions.external? | default {} | insert enable { true } | insert completer { $fish_completer } ) ''; })
3.3.5.5. Starship#
Starship is a cross-shell prompt written in Rust, used across all shells configured here to provide a consistent prompt experience. It has been used since its predecessor spacefish, and its easy TOML-based configuration is the main reason it has been kept.
Starship does not natively support asynchronous prompt rendering, so this section also includes a custom async prompt module for fish.
Registered behind my.programs.starship.enable, with enableFishAsyncPrompt
as a second, independent toggle on the same module — a host can run starship
without the async prompt, but not the reverse.
{ ... }: { flake.modules.homeManager.starship = { config, lib, ... }: let cfg = config.my.programs.starship; in { options.my.programs.starship = { enable = lib.mkEnableOption "starship"; enableFishAsyncPrompt = lib.mkOption { type = lib.types.bool; default = false; }; }; config = lib.mkMerge [ (lib.mkIf cfg.enable { programs.starship = { enable = true; settings = builtins.fromTOML (builtins.readFile ./starship.toml); }; }) <<starship-async-prompt-config>> ]; }; }
3.3.5.5.1. Prompt Format#
The prompt format prepends a shell indicator before the default modules. This makes it immediately visible which shell is active, useful when switching between fish and bash for testing or debugging.
"$schema" = "https://starship.rs/config-schema.json" format = "$shell$all$line_break$character"
3.3.5.5.2. Shell Indicator#
Each shell has a distinct icon: (fish icon) for fish. Bash and nushell
use the default indicator.
[shell] fish_indicator = "" powershell_indicator = "" disabled = false
3.3.5.5.3. Remote Container Detection#
A custom module detects when running inside a VS Code Remote Container
(Dev Container) by checking the REMOTE_CONTAINERS environment variable,
displaying a whale emoji (🐋) as a visual reminder. This has not been used for
several years and may no longer be relevant.
[custom.remote-container] when = """ test "$REMOTE_CONTAINERS" """ symbol = "🐋" format = " in $symbol "
3.3.5.5.4. Disabled Modules#
gcloud is disabled because it reads the active GCP configuration from the
home directory (~/.config/gcloud/), causing it to appear in every repository
regardless of whether the project uses GCP.
[gcloud] disabled = true
3.3.5.5.5. Async Prompt Module#
Starship's default fish integration is replaced with an asynchronous variant,
gated on the same module's enableFishAsyncPrompt toggle rather than a
separate file, since the two options always travel together. The async prompt
script is cherry-picked from
duament's gist, based on fish-async-prompt.
See also fish-shell#8223 for upstream discussion on async prompt support.
(lib.mkIf cfg.enableFishAsyncPrompt { # use my own script to ensure the execution order programs.starship.enableFishIntegration = lib.mkForce false; programs.fish.interactiveShellInit = '' if test "$TERM" != dumb ${lib.getExe config.programs.starship.package} init fish | source source ${./async_prompt.fish} end ''; })
- Async Prompt Script
# cherry picked from https://gist.github.com/duament/bac0181935953b97ca71640727c9c029 status is-interactive or exit 0 if test -n "$XDG_RUNTIME_DIR" set -g __starship_async_tmpdir "$XDG_RUNTIME_DIR"/fish-async-prompt else set -g __starship_async_tmpdir /tmp/fish-async-prompt end mkdir -p "$__starship_async_tmpdir" set -g __starship_async_signal SIGUSR1 # Starship set -g VIRTUAL_ENV_DISABLE_PROMPT 1 builtin functions -e fish_mode_prompt set -gx STARSHIP_SHELL fish set -gx STARSHIP_SESSION_KEY (random 10000000000000 9999999999999999) # Prompt function fish_prompt printf '\e[0J' # Clear from cursor to end of screen if test -e "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt cat "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt else __starship_async_simple_prompt end end # Async task function __starship_async_fire --on-event fish_prompt switch "$fish_key_bindings" case fish_hybrid_key_bindings fish_vi_key_bindings set STARSHIP_KEYMAP "$fish_bind_mode" case '*' set STARSHIP_KEYMAP insert end set STARSHIP_CMD_PIPESTATUS $pipestatus set STARSHIP_CMD_STATUS $status set STARSHIP_DURATION "$CMD_DURATION" set STARSHIP_JOBS (count (jobs -p)) set -l tmpfile "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt fish -c ' starship prompt --terminal-width="'$COLUMNS'" --status='$STARSHIP_CMD_STATUS' --pipestatus="'$STARSHIP_CMD_PIPESTATUS'" --keymap='$STARSHIP_KEYMAP' --cmd-duration='$STARSHIP_DURATION' --jobs='$STARSHIP_JOBS' > '$tmpfile' kill -s "'$__starship_async_signal'" '$fish_pid & disown end function __starship_async_simple_prompt set_color brgreen echo -n '❯' set_color normal echo ' ' end function __starship_async_repaint_prompt --on-signal "$__starship_async_signal" commandline -f repaint end function __starship_async_cleanup --on-event fish_exit rm -f "$__starship_async_tmpdir"/"$fish_pid"_fish_prompt end # https://github.com/acomagu/fish-async-prompt # https://github.com/fish-shell/fish-shell/issues/8223
3.3.6. Version Control#
3.3.6.1. Git#
Git version control configuration. Git is configured directly through home-manager's
programs.git module, which generates ~/.config/git/config declaratively.
Registered behind my.programs.git.enable. The Scalar performance tuning below is
a second, independent toggle on the same module rather than a separate file, since
scalar has no meaning without the git module it extends.
{ ... }: { flake.modules.homeManager.git = { config, lib, ... }: let cfg = config.my.programs.git; scalarCfg = config.programs.git.scalar; in { options.my.programs.git.enable = lib.mkEnableOption "git"; <<git-scalar-option>> config = lib.mkMerge [ (lib.mkIf cfg.enable { programs.git = { enable = true; <<git-settings>> <<git-signing>> <<git-ignores>> <<git-scalar>> }; <<git-gh>> <<git-delta>> <<git-difftastic>> <<git-lazygit>> <<git-fish-abbreviations>> }) <<git-scalar-config>> ]; }; }
3.3.6.1.1. Core Settings#
Basic git behavior settings.
settings = { <<git-user-identity>> <<git-github-user>> <<git-editor>> <<git-color>> <<git-default-branch>> <<git-push-safety>> };
- User Identity
<<git-user-identity>>user = { name = "natsukium"; email = "[email protected]"; };
- GitHub Identity
I read GitHub issues and notifications in Emacs through forge, whose API layer
ghubtakes the account name fromgithub.userto find the matching~/.authinfo.ageentry. Without it a pull aborts with "Cannot determine username" before any request goes out, and the notification list keeps showing whatever the last successful fetch stored. The token that goes with it is described in the forge section below.<<git-github-user>>github.user = "natsukium";
- Editor
core.editor = "vim"is set as the fallback editor for git operations. Although theEDITORenvironment variable is set tonvimin the home configuration, some contexts (likegit commitin a minimal environment) may not inherit the session's environment variables. Setting it explicitly in gitconfig ensures consistent behavior.<<git-editor>>core.editor = "vim";
- Color
Auto-detect terminal color support for all git subcommands.
<<git-color>>color = { status = "auto"; diff = "auto"; branch = "auto"; interactive = "auto"; grep = "auto"; };
- Default Branch
<<git-default-branch>>init.defaultBranch = "main";
- Force Push Safety
push.useForceIfIncludesenables a safety check for force pushes: git verifies that the local branch includes the remote's current tip before allowing a force push. This prevents accidentally overwriting commits pushed by others since the last fetch, which--force-with-leasealone cannot catch if the remote ref was updated in the background (e.g., by lazygit's periodic fetch or a backgroundgit fetch).<<git-push-safety>>push.useForceIfIncludes = true;
3.3.6.1.2. Commit Signing#
SSH-based commit signing. SSH keys were chosen over GPG because SSH keys are already required for push authentication, eliminating the need to manage a separate GPG keychain. Git's SSH signing support (added in Git 2.34) provides the same integrity guarantees as GPG signing with simpler key management — one key pair for both authentication and signing.
GitHub has supported SSH commit verification since August 2022, and since November 2024 verification results are persisted server-side. This persistent verification eliminates the primary concern with SSH signing — that key rotation could invalidate historical signatures — which was GPG's main advantage for long-term identity assurance.
In the future, migrating to a keyless solution like gitsign (Sigstore-based signing) would further simplify the workflow by removing key management entirely. This is pending GitHub's native support for Sigstore verification.
signByDefault = true ensures all commits are signed without requiring git commit -S,
preventing unsigned commits from slipping through.
signing = { format = "ssh"; key = "~/.ssh/id_ed25519.pub"; signByDefault = true; };
3.3.6.1.3. Global Ignores#
Patterns that should never be committed across all repositories. These are global ignores
rather than per-repo .gitignore entries because they reflect personal tooling choices
that should not be imposed on collaborators.
ignores = [ <<git-ignores-os>> <<git-ignores-dev>> <<git-ignores-workflow>> <<git-ignores-private>> ];
- OS Artifacts
macOS Finder metadata files. Present in the global ignore because this is a cross-platform dotfiles repository used on both Linux and macOS.
<<git-ignores-os>>".DS_Store" - Development Environment
Artifacts generated by development tools that should remain local:
.aider*matches aider's conversation history and configuration files. These contain session-specific context and should not leak into repositories..direnv,.envrc: direnv state and configuration. Each project may define its own.envrc, but since this repository uses flake-based devshells, the.envrcfiles are auto-generated and should not be committed..ipynb_checkpoints: Jupyter Notebook autosave files..pre-commit-config.yaml: Managed per-project by devshell environments rather than committed, to avoid version conflicts across contributors with different tool versions..vscode/: Editor-specific settings that vary per developer.__pycache__/: Python bytecode cache, regenerated on each run.
<<git-ignores-dev>>".aider*" ".direnv" ".envrc" ".ipynb_checkpoints" ".pre-commit-config.yaml" ".vscode/" "__pycache__/"
- Workflow
.worktree: Marker file used by git worktree workflows.
<<git-ignores-workflow>>".worktree" - Private Notes
.private/is a directory for personal notes, LLM context files, and other local-only documentation that should never be shared.<<git-ignores-private>>".private/"
3.3.6.1.4. Scalar#
Scalar optimizes git performance for large repositories. Enabled specifically for the nixpkgs repository, which contains over 600,000 commits and benefits significantly from Scalar's background maintenance (prefetch, commit-graph, loose-objects) and filesystem monitor integration.
programs.git.scalar is a custom option this module extends git with, configuring
git's built-in performance optimizations (multipack index, preload index, untracked
cache, fsmonitor) whenever a repository is registered. It is a second toggle rather
than folded into the base git config unconditionally, since these settings only pay
off on the handful of very large repositories that need them.
options.programs.git.scalar = { enable = lib.mkEnableOption "scalar"; repo = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ ]; example = [ "${config.home.homeDirectory}/NixOS/nixpkgs" ]; }; };
scalar = { enable = true; repo = [ "${config.programs.git.settings.ghq.root}/github.com/natsukium/nixpkgs" ]; };
(lib.mkIf scalarCfg.enable { programs.git = { settings = { scalar.repo = scalarCfg.repo; maintenance.repo = scalarCfg.repo; }; includes = map (repo: { condition = "gitdir:${repo}/"; contents = { core = { multipackindex = true; preloadindex = true; untrackedcache = true; autocrlf = false; safecrlf = false; fsmonitor = true; }; am.keepcr = true; credential = { "https://dev.azure.com".usehttppath = true; validate = false; }; gc.auto = 0; gui.gcwarning = false; index = { threads = true; version = 4; }; merge = { stat = false; renames = true; }; pack = { usebitmaps = false; usesparse = true; }; receive.autogc = false; feature = { manyfiles = false; experimental = false; }; fetch = { unpacklimit = 1; writecommitgraph = false; showforcedupdates = false; }; status.aheadbehind = false; commitgraph.generationversion = 1; log.excludedecoration = "refs/prefetch/*"; maintenance = { auto = false; strategy = "incremental"; }; }; }) scalarCfg.repo; }; })
3.3.6.1.5. GitHub CLI#
gh (GitHub CLI) is enabled alongside git because it provides a credential helper
(programs.gh.gitCredentialHelper.enable, on by default) that handles HTTPS
authentication for GitHub repositories, for fetch and push alike. So git talks to
GitHub over HTTPS only, and an SSH key is never needed to move commits — signing is
the one thing SSH is still used for.
I used to rewrite push URLs to SSH with url."[email protected]:".pushInsteadOf, on the
theory that it was a harmless fallback for machines without gh. It was not harmless:
URL rewriting happens before the credential helper is ever consulted, so pushes really
did go over SSH, and the fallback could never fire anyway because the rewrite is
tangled from the same module that installs gh. Since the work account needs pushes to
authenticate as the right GitHub user, one auth path is easier to reason about than
two, and I dropped the rewrite. The work host narrows the same helper to a second gh
config directory for repositories under its own root.
programs.gh.enable = true;
3.3.6.1.6. Delta#
delta provides syntax-highlighted diffs in the terminal with line numbers and side-by-side view support. Chosen for its clean visual presentation.
programs.delta = { enable = true; enableGitIntegration = true; };
3.3.6.1.7. Difftastic#
Difftastic is a structural diff tool that operates at the AST level rather than comparing lines. It understands that moving a function or renaming a variable is a single semantic change, not a block of deletions and insertions. This is particularly valuable for JSX refactoring, indentation shifts, and nested tag restructuring, where line-based diffs produce noisy, hard-to-read output.
Both difftastic and delta are kept because they serve complementary roles: delta
enhances git's built-in line diffs with syntax highlighting for everyday operations
(git diff, git log), while difftastic is reached for via lazygit when structural
understanding matters.
programs.difftastic = { enable = true; };
3.3.6.1.8. Lazygit#
Lazygit is a terminal UI for git. A TUI was chosen over raw git commands for interactive operations like staging individual hunks, interactive rebase, and conflict resolution, where the visual feedback loop significantly reduces errors. Previously used gitui, but switched to lazygit for its broader feature set — gitui's performance advantage did not materialize in practice, and it lacked several workflow-critical operations.
Performance degrades noticeably on large repositories like nixpkgs, but the ergonomics and active development make it a trusted part of the workflow despite this tradeoff.
programs.lazygit = { enable = true; settings = { <<lazygit-gui>> <<lazygit-git>> }; };
- GUI
Enable Nerd Font icons in the lazygit interface for visual clarity.
<<lazygit-gui>>gui = { showIcons = true; };
- Git Integration
overrideGpg = truetells lazygit to run git commands inline instead of spawning a subprocess for GPG/SSH signing. By default, lazygit defers to a subprocess so the user can type a passphrase interactively. However, this subprocess mode prevents lazygit from orchestrating interactive rebase internally — rewording, reordering, or editing any commit other than HEAD is disabled entirely because the rebase would trigger multiple signing prompts that lazygit cannot handle mid-rebase. WithoverrideGpg = true, lazygit assumes the signing agent (ssh-agent, macOS Keychain) caches the passphrase, so no interactive prompt is needed and all rebase operations work normally.The pager configuration integrates both delta and difftastic into lazygit's diff views. Delta provides syntax-highlighted line diffs (
--dark --paging=neversince lazygit handles its own paging), and difftastic is available as the external diff command for structural comparison.<<lazygit-git>>git = { overrideGpg = true; pagers = [ { colorArg = "always"; pager = "delta --dark --paging=never"; } { externalDiffCommand = "difft --color=always"; } ]; };
3.3.6.1.9. Fish Abbreviations#
Shell abbreviations for frequent git operations. Abbreviations (not aliases) are used because fish expands them inline before execution, making the actual command visible in history and allowing modification before running.
programs.fish.shellAbbrs = { <<git-abbr-push>> <<git-abbr-pull>> <<git-abbr-commit>> <<git-abbr-status>> <<git-abbr-stash>> <<git-abbr-switch>> };
- Push
Force push with lease — safer than
--forceas it prevents overwriting remote changes not yet fetched.<<git-abbr-push>>gpf = "git push --force-with-lease";
- Pull
gpmpulls from the remote's default branch, dynamically detected viagit remote showrather than hardcodingmainormaster. This handles repositories that still usemasteror other branch naming conventions.gpupulls from upstream — used in fork workflows to sync with the original repository.<<git-abbr-pull>>gpm = "git pull (git remote show origin | sed -n '/HEAD branch/s/.*: //p')"; gpu = "git pull upstream";
- Commit
gcicreates a commit. The trailing space allows directly appending-m "message".gcaamends the last commit, frequently used during interactive rebase workflows.<<git-abbr-commit>>gci = "git commit "; gca = "git commit --amend";
- Status
<<git-abbr-status>>gs = "git status";
- Stash
<<git-abbr-stash>>gst = "git stash"; gstp = "git stash pop";
- Switch
<<git-abbr-switch>>gsw = "git switch"; gswc = "git switch -c";
3.3.7. Continuous Integration#
3.3.7.1. Forgejo Actions Runner#
My Forgejo instance dispatches Actions jobs to registered runners. Linux hosts
use nixpkgs' services.gitea-actions-runner; macOS has no such module, so I add
services.forgejo-runner for Darwin, driven by launchd and shaped like the
NixOS option so a runner reads the same on either platform.
I split this into three modules. The nix-darwin mechanism is neutral, so I
publish it as flake.darwinModules.forgejo-runner via exports.nix. The
my.services.forgejo-runner wrappers bake my defaults (instance URL, labels,
niks3 client) and live in the internal flake.modules.{darwin,nixos} registry.
A host enables a wrapper and supplies its own registration token.
{ ... }: let <<forgejo-runner-wrapper-shared>> <<forgejo-runner-wrappers>> in { flake.modules.darwin.forgejo-runner = darwinWrapper; flake.modules.nixos.forgejo-runner = nixosWrapper; }
3.3.7.1.1. nix-darwin module#
This module carries nothing personal — just services.forgejo-runner, mirroring
the instance schema of nixpkgs' services.gitea-actions-runner
(token=/=tokenFile, labels, freeform settings). Each enabled instance
becomes a launchd job that registers, then execs the daemon. I record a hash of
token plus labels in .registration-state so registration re-runs when
either changes; the token is reusable, so re-registering is fine.
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.forgejo-runner;
settingsFormat = pkgs.formats.yaml { };
<<forgejo-runner-instance-submodule>>
enabledInstances = lib.filterAttrs (_: i: i.enable) cfg.instances;
in
{
<<forgejo-runner-darwin-options>>
<<forgejo-runner-darwin-config>>
}
- Instance options
An instance carries its registration details and the tools its jobs need on
PATH. Exactly one oftokenortokenFilesupplies the token, matching the NixOS module. Host-executed jobs inherit only the daemon's environment, so every tool a workflow expects must be listed inhostPackages.<<forgejo-runner-instance-submodule>>instanceModule = { name, ... }: { options = { enable = lib.mkEnableOption "this runner instance"; name = lib.mkOption { type = lib.types.str; default = name; defaultText = lib.literalExpression "the attribute name"; description = "Name the runner registers under."; }; url = lib.mkOption { type = lib.types.str; example = "https://git.example.com"; description = "Address of the Forgejo instance the runner registers with."; }; token = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = '' Registration token as a plaintext string. Mutually exclusive with {option}`tokenFile`. Prefer {option}`tokenFile`: a token set here ends up in the world-readable launchd plist and the Nix store. ''; }; tokenFile = lib.mkOption { type = lib.types.nullOr (lib.types.either lib.types.str lib.types.path); default = null; description = '' Path to an environment file holding the registration token as `TOKEN=...`. Mutually exclusive with {option}`token`. The token is read on first registration and whenever it or {option}`labels` change; otherwise the persisted {file}`.runner` credential in {option}`stateDir` is used. ''; }; labels = lib.mkOption { type = lib.types.listOf lib.types.str; example = [ "macos:host" ]; description = "Labels the runner advertises."; }; settings = lib.mkOption { type = settingsFormat.type; default = { }; description = '' Runner configuration written to {file}`config.yaml` and passed to the daemon with `--config`. ''; }; hostPackages = lib.mkOption { type = lib.types.listOf lib.types.package; default = with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ]; defaultText = lib.literalExpression '' with pkgs; [ bash coreutils curl gawk gitMinimal gnused nodejs wget ] ''; description = '' Packages put on PATH for host-executed jobs. Host jobs inherit only the daemon's PATH, so every tool a workflow expects must be listed. ''; }; stateDir = lib.mkOption { type = lib.types.str; default = "/var/lib/forgejo-runner/${name}"; defaultText = lib.literalExpression ''"/var/lib/forgejo-runner/<name>"''; description = "Directory holding the runner credential and working files."; }; }; };
- Module options
The daemons run as a dedicated unprivileged user, not root: a root daemon is a trusted-user client of the nix daemon, whose post-build-hook push path the multi-user daemon ignores — so nothing reaches my binary cache. An untrusted user takes the store-scan push path that actually uploads. I may revisit making it a trusted user once the post-build-hook path works for me.
<<forgejo-runner-darwin-options>>options.services.forgejo-runner = { package = lib.mkPackageOption pkgs "forgejo-runner" { }; user = lib.mkOption { type = lib.types.str; default = "_forgejo-runner"; description = "Dedicated unprivileged user the runner daemons execute as."; }; group = lib.mkOption { type = lib.types.str; default = "_forgejo-runner"; description = "Primary group for the runner user."; }; uid = lib.mkOption { type = lib.types.int; default = 530; description = "UID for the runner user (free system-range id on the host)."; }; gid = lib.mkOption { type = lib.types.int; default = 530; description = "GID for the runner group."; }; instances = lib.mkOption { type = lib.types.attrsOf (lib.types.submodule instanceModule); default = { }; description = "Runner instances; each enabled one becomes a launchd daemon."; }; };
- Configuration
<<forgejo-runner-darwin-config>>config = lib.mkIf (enabledInstances != { }) { assertions = lib.mapAttrsToList (_: instance: { assertion = (instance.token == null) != (instance.tokenFile == null); message = "services.forgejo-runner.instances.${instance.name}: set exactly one of token or tokenFile."; }) enabledInstances; <<forgejo-runner-daemons>> <<forgejo-runner-user>> <<forgejo-runner-ownership>> };
- Launchd daemons
One launchd job per enabled instance. launchd has no
EnvironmentFile, so atokenFileinstance sources it in-script while an inlinetokenrides onEnvironmentVariables; either way the script sees$TOKEN. I prependcoreutilstoPATHso the script's ownsha256sum=/=cutresolve even if a host trimshostPackages. Two launchd quirks shape each:WorkingDirectorycannot be the state directory: launchd chdir()s into it before exec, so on first boot the spawn aborts withEX_CONFIG(78) before the script can create it. The script sets its own cwd instead.- The logs live in the user-owned state directory, not
/var/log: launchd opens them asUserName, and an unprivileged daemon cannot create a root-owned file under/var/log, so it would fail to spawn — silently, before any log line.
<<forgejo-runner-daemons>>launchd.daemons = lib.mapAttrs' ( _: instance: let configFile = settingsFormat.generate "config.yaml" instance.settings; in lib.nameValuePair "forgejo-runner-${instance.name}" { script = '' set -eu mkdir -p ${lib.escapeShellArg instance.stateDir} cd ${lib.escapeShellArg instance.stateDir} ${lib.optionalString (instance.tokenFile != null) '' set -a . ${lib.escapeShellArg (toString instance.tokenFile)} set +a ''} labels=${lib.escapeShellArg (lib.concatStringsSep "," instance.labels)} state="$(printf '%s' "$TOKEN" | sha256sum | cut -d' ' -f1) $labels" if [ ! -f .runner ] || [ "$(cat .registration-state 2>/dev/null)" != "$state" ]; then rm -f .runner ${lib.getExe cfg.package} register \ --no-interactive \ --instance ${lib.escapeShellArg instance.url} \ --token "$TOKEN" \ --name ${lib.escapeShellArg instance.name} \ --labels "$labels" \ --config ${configFile} printf '%s' "$state" > .registration-state fi exec ${lib.getExe cfg.package} daemon --config ${configFile} ''; serviceConfig = { UserName = cfg.user; GroupName = cfg.group; KeepAlive = true; RunAtLoad = true; ProcessType = "Background"; EnvironmentVariables = { HOME = instance.stateDir; PATH = "${ lib.makeBinPath ([ pkgs.coreutils ] ++ instance.hostPackages) }:/usr/bin:/bin:/usr/sbin:/sbin"; } // lib.optionalAttrs (instance.token != null) { TOKEN = instance.token; }; StandardOutPath = "${instance.stateDir}/forgejo-runner.log"; StandardErrorPath = "${instance.stateDir}/forgejo-runner.err.log"; }; } ) enabledInstances;
- Runner user
nix-darwin only manages accounts listed in
users.knownUsers/users.knownGroups, so I add the entries.I leave
createHomeoff: it runscreatehomedir, which resolves the/varfirmlink and records the home as/private/var/…, then nix-darwin string-compares that against the configuredhomeand aborts activation on the mismatch. The daemon uses its state directory asHOMEanyway, created and owned below.<<forgejo-runner-user>>users.users.${cfg.user} = { uid = cfg.uid; gid = cfg.gid; isHidden = true; description = "Forgejo Actions runner"; }; users.groups.${cfg.group}.gid = cfg.gid; users.knownUsers = [ cfg.user ]; users.knownGroups = [ cfg.group ];
- State ownership
Each state directory must exist and belong to the runner user before its daemon starts. With
createHomeoff nothing else creates it, so activation makes and chowns itmkBeforethe launchd step that loads the daemons.<<forgejo-runner-ownership>>system.activationScripts.launchd.text = lib.mkBefore ( lib.concatMapStringsSep "\n" (instance: '' mkdir -p ${lib.escapeShellArg instance.stateDir} chown -R ${lib.escapeShellArg cfg.user}:${lib.escapeShellArg cfg.group} ${lib.escapeShellArg instance.stateDir} '') (lib.attrValues enabledInstances) );
- Launchd daemons
3.3.7.1.2. Opinionated wrappers#
The two platforms drive different options — services.forgejo-runner on Darwin,
services.gitea-actions-runner on NixOS — but since the nix-darwin module mirrors
the NixOS instance schema, the wrappers share one option surface and one
mkInstance builder. My my.services.forgejo-runner options give enable,
url (defaulting to my Forgejo instance), labels, and a tokenFile path; the
host owns the secret and hands only its path. Each wrapper holds only the
per-platform deltas: the labels default, hostPackages, and — on NixOS — the
package and Docker for container jobs. Both push to my binary cache, so each
hostPackages adds nix and the niks3 client.
wrapperOptions = { lib, ... }: { options.my.services.forgejo-runner = { enable = lib.mkEnableOption "Forgejo Actions runner"; url = lib.mkOption { type = lib.types.str; default = "https://git.natsukium.com"; description = "Forgejo instance the runner registers with."; }; tokenFile = lib.mkOption { type = lib.types.path; description = '' Path to an environment file holding the registration token as `TOKEN=...`. The host supplies this — typically a secret-manager path — so the feature stays free of any secret-store assumption. ''; }; labels = lib.mkOption { type = lib.types.listOf lib.types.str; description = "Labels the runner advertises; each platform sets a default."; }; }; }; mkInstance = config: hostPackages: { enable = true; name = config.networking.hostName; url = config.my.services.forgejo-runner.url; tokenFile = config.my.services.forgejo-runner.tokenFile; labels = config.my.services.forgejo-runner.labels; inherit hostPackages; };
darwinWrapper = { config, lib, pkgs, inputs, ... }: { imports = [ ./darwin-module.nix wrapperOptions ]; config = lib.mkIf config.my.services.forgejo-runner.enable { my.services.forgejo-runner.labels = lib.mkDefault [ "macos:host" "macos-aarch64:host" ]; services.forgejo-runner.instances.default = mkInstance config ( with pkgs; [ bash curl git nix nodejs toybox inputs.niks3.packages.${pkgs.stdenv.hostPlatform.system}.niks3 ] ); }; }; nixosWrapper = { config, lib, pkgs, inputs, ... }: { imports = [ wrapperOptions ]; config = lib.mkIf config.my.services.forgejo-runner.enable { my.services.forgejo-runner.labels = lib.mkDefault [ "ubuntu-latest:docker://node:22-bookworm" "nix:host" ]; services.gitea-actions-runner = { package = pkgs.forgejo-runner; instances.default = mkInstance config ( with pkgs; [ bash busybox curl git nix nodejs inputs.niks3.packages.${pkgs.stdenv.hostPlatform.system}.niks3 ] ); }; virtualisation.docker.enable = true; }; };
3.3.8. Devices#
Peripherals that move between machines belong here, not in
hardware-configuration, which describes a host's fixed internals. Each device
is a switch under my.devices.*, exposed through the
flake.modules.nixos.devices registry; a host enables only the ones it has
plugged in.
3.3.8.1. Audio#
3.3.8.1.1. AT2040 microphone#
The AT2040 is a USB microphone that also exposes a headphone output. WirePlumber
gives that output a higher default priority (1109) than the HDMI sink (1000), so
plugging the mic in silently hijacks the default output. I demote the AT2040
sink to the lowest priority so the host's existing output stays selected; the
media.class guard holds back the sink alone, leaving the mic input at its own
high priority to still become the default source.
The rule matches on alsa.card_name rather than the serial-bearing node.name,
so it survives re-plugging and would apply to a replacement unit.
{ ... }: { # Internal registry: hosts enable my.devices.*. flake.modules.nixos.devices = { config, lib, ... }: let cfg = config.my.devices; in { options.my.devices.audio.at2040.enable = lib.mkEnableOption "AT2040 USB mic sink demotion so it does not steal the default output"; config = lib.mkIf cfg.audio.at2040.enable { services.pipewire.wireplumber.extraConfig."51-at2040-demote-sink" = { "monitor.alsa.rules" = [ { matches = [ { "media.class" = "Audio/Sink"; "alsa.card_name" = "AT2040USB"; } ]; actions.update-props = { "priority.driver" = 0; "priority.session" = 0; }; } ]; }; }; }; }
3.3.9. Authentication#
3.3.9.1. Ente Auth#
Ente Auth holds my TOTP secrets and syncs them with end-to-end encryption between Android and every machine here, so a lost phone is an inconvenience rather than a lockout. Client and server are both open source, so I can read what happens to the secrets and self-host the backend later without re-enrolling anything.
Before this I used Google Authenticator and then Microsoft Authenticator, out of inertia more than choice. Neither could hand its secrets to a new device at the time, so replacing a phone meant re-enrolling every account by hand.
A password manager would solve the sync problem too (Bitwarden sells TOTP on a paid plan, and a self-hosted Vaultwarden gives it away), but I keep the two apart deliberately. Storing the codes in the vault means anyone who gets into the vault has already cleared the second factor, which leaves me with one factor wearing two hats.
nixpkgs builds ente-auth for Linux only. The app is written in Flutter, and the
Flutter tooling in nixpkgs can only produce Linux desktop bundles; a macOS bundle
requires Xcode, which a sandboxed Nix build cannot use. So darwin installs the
upstream cask through brew-nix instead.
{ ... }: { flake.modules.homeManager.ente-auth = { config, lib, pkgs, ... }: let cfg = config.my.programs.ente-auth; in { options.my.programs.ente-auth = { enable = lib.mkEnableOption "Ente Auth"; package = lib.mkOption { type = lib.types.package; # nixpkgs marks ente-auth as Linux-only, so darwin falls back to the # upstream cask exposed by the brew-nix overlay. default = if pkgs.stdenv.hostPlatform.isDarwin then pkgs.brewCasks.ente-auth else pkgs.ente-auth; defaultText = lib.literalExpression "pkgs.ente-auth"; description = "The Ente Auth package to install."; }; }; config = lib.mkIf cfg.enable { home.packages = [ cfg.package ]; }; }; }
3.3.10. Browser#
3.3.10.1. Zen Browser#
Zen Browser is a Firefox-based browser with a focus on privacy and customization.
Firefox derivatives were preferred over Chromium-based browsers because Firefox's extension
ecosystem and about:config provide deeper control over browser behavior, and the Nix
ecosystem has mature tooling for managing Firefox profiles declaratively via home-manager.
Zen Browser was chosen over vanilla Firefox for its improved UI/UX while retaining full Firefox compatibility — extensions, search engines, and profile settings work identically.
{ ... }: { flake.modules.homeManager."zen-browser" = { inputs, config, lib, pkgs, ... }: let cfg = config.my.programs.zen-browser; in { imports = [ inputs.zen-browser.homeModules.beta ]; options.my.programs.zen-browser = { enable = lib.mkEnableOption "Zen Browser"; }; config = lib.mkIf cfg.enable { programs.zen-browser = { enable = true; profiles.natsukium = { <<zen-browser-settings>> <<zen-browser-search>> <<zen-browser-extensions>> }; }; }; }; }
3.3.10.1.1. Settings#
Auto-install extensions without user prompts. By default, Firefox shows a confirmation dialog
for each extension installed via the profile. Setting extensions.autoDisableScopes to 0
disables this behavior, which is necessary for fully declarative extension management —
otherwise the first launch after a profile rebuild would require manual approval for every
extension.
settings = { "extensions.autoDisableScopes" = 0; };
3.3.10.1.2. Search Engines#
Custom search engines for quick access to development-related package registries and
documentation. Each engine is assigned a short alias (e.g., @np) for use in the address bar,
avoiding the need to navigate to each site manually.
search = { force = true; engines = { <<zen-browser-search-engine-nix-packages>> <<zen-browser-search-engine-nixos-wiki>> <<zen-browser-search-engine-noogle>> <<zen-browser-search-engine-crates-io>> <<zen-browser-search-engine-npm>> <<zen-browser-search-engine-pypi>> }; };
- Nix Packages
Search the official NixOS/nixpkgs repository. Uses
nixos-iconsfrom nixpkgs for the icon to avoid depending on an external URL that could change or become unavailable.<<zen-browser-search-engine-nix-packages>>nix-packages = { name = "Nix Packages"; urls = [ { template = "https://search.nixos.org/packages"; params = [ { name = "type"; value = "packages"; } { name = "query"; value = "{searchTerms}"; } ]; } ]; icon = "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"; definedAliases = [ "@np" ]; };
- NixOS Wiki
Search the NixOS community wiki for configuration examples and troubleshooting guides.
<<zen-browser-search-engine-nixos-wiki>>nixos-wiki = { name = "NixOS Wiki"; urls = [ { template = "https://wiki.nixos.org/w/index.php?search={searchTerms}"; } ]; icon = "https://wiki.nixos.org/favicon.ico"; definedAliases = [ "@nw" ]; };
- noogle
noogle is a Nix function search engine — the equivalent of Hoogle for Haskell. Useful for discovering library functions by type signature or name when writing Nix expressions.
<<zen-browser-search-engine-noogle>>noogle = { name = "noogle"; urls = [ { template = "https://noogle.dev/q?term={searchTerms}"; } ]; icon = "https://noogle.dev/favicon.png"; definedAliases = [ "@noogle" ]; };
- crates.io
Search the Rust package registry for crate discovery and version information.
<<zen-browser-search-engine-crates-io>>crates-io = { name = "crates.io"; urls = [ { template = "https://crates.io/search?q={searchTerms}"; } ]; icon = "https://crates.io/favicon.ico"; definedAliases = [ "@crates" ]; };
- npm
Search the npm registry for JavaScript/TypeScript package discovery.
<<zen-browser-search-engine-npm>>npm = { name = "npm"; urls = [ { template = "https://www.npmjs.com/search?q={searchTerms}"; } ]; icon = "https://www.google.com/s2/favicons?domain=npmjs.com&sz=64"; definedAliases = [ "@npm" ]; };
- PyPI
Search the Python Package Index for Python package discovery.
<<zen-browser-search-engine-pypi>>pypi = { name = "PyPI"; urls = [ { template = "https://pypi.org/search/?q={searchTerms}"; } ]; icon = "https://pypi.org/favicon.ico"; definedAliases = [ "@pypi" ]; };
3.3.10.1.3. Extensions#
Browser extensions managed declaratively. Extensions are sourced from two overlay-provided
sets: firefox-addons (from the NUR firefox-addons repository) and my-firefox-addons
(custom additions not available upstream).
extensions = { packages = (with pkgs.firefox-addons; [ bitwarden instapaper-official keepa onepassword-password-manager refined-github vimium violentmonkey wayback-machine zotero-connector ]) ++ (with pkgs.my-firefox-addons; [ adguard-adblocker calilay kiseppe-price-chart-kindle ]); };
3.3.11. Editor#
3.3.11.1. Neovim#
Neovim is the editor I spend the most time in, primarily for coding —
though the gravitational pull of org-mode is slowly dragging me over
to Emacs.
Everything Neovim-related lives under features/neovim/: the
wrapped derivation (package.nix) and the lua configuration. The
flake-parts module pulls double duty from a single file:
perSystem.packages.neovimmakesnix run .#neovimwork on every supported system.flake.modules.homeManager.neovimregisters the module in the internal registry — not the publichomeManagerModulesexport, since themy.*options make it opinionated — and lets a home-manager config opt in withmy.programs.neovim.enable, which installs the wrapped Neovim plusneovim-remoteand exportsEDITOR=nvim.
{ ... }: { flake.modules.homeManager.neovim = { config, lib, pkgs, ... }: { options.my.programs.neovim.enable = lib.mkEnableOption "neovim"; config = lib.mkIf config.my.programs.neovim.enable { home.packages = [ (pkgs.callPackage ./package.nix { }) pkgs.neovim-remote ]; home.sessionVariables.EDITOR = "nvim"; }; }; perSystem = { pkgs, ... }: { packages.neovim = pkgs.callPackage ./package.nix { }; }; }
3.3.11.2. Emacs#
Emacs has been my second-most-used editor since early 2026. The draw is
org-mode — the workflows built around org-capture and org-agenda
are slowly winning me over, and a widening set of daily inputs (email,
RSS feeds, and more) now lives inside emacs, where the ongoing
experiment is wiring each of them back into org.
The feature lives alongside Neovim under features/emacs/ and
pulls the same double duty:
perSystem.packages.emacsexposes a wrapped emacs as a flake output.init.orgis pre-tangled intodefault.eland passed toemacsWithPackagesFromUsePackagesonix run .#emacsloads the full config without home-manager.flake.modules.homeManager.emacsregisters the module in the internal registry — not the publichomeManagerModulesexport, since themy.*options make it opinionated — and addsmy.programs.emacs.enablefor home-manager hosts, wiring upservices.emacs(daemon), the tangled~/.config/emacs/{init,early-init}.el, and the encrypted~/.authinfo.age.
# Requires: inputs.emacs-overlay { lib, inputs, ... }: let withEmacsOverlay = pkgs: pkgs.extend inputs.emacs-overlay.overlays.default; emacsUnwrapped = pkgs: let epkgs = withEmacsOverlay pkgs; in if pkgs.stdenv.hostPlatform.isDarwin then epkgs.emacs-plus else epkgs.emacs-pgtk; # Pass target-file to org-babel-tangle-file so blocks without an explicit # :tangle header still get tangled; emacs-overlay's defaultInitFile path # calls plain org-babel-tangle, which would tangle 0 blocks here. tangle = pkgs: { name, org, }: pkgs.runCommand name { nativeBuildInputs = [ (emacsUnwrapped pkgs) ]; } '' cp ${org} tmp.org emacs -Q --batch --eval \ "(progn (require 'ob-tangle) (org-babel-tangle-file \"tmp.org\" \"emacs-lisp\"))" install emacs-lisp $out ''; tangleEl = pkgs: org: let stem = name: lib.head (lib.splitString "." name); in tangle pkgs { name = "${stem (baseNameOf (toString org))}.el"; inherit org; }; mkEmacs = pkgs: (withEmacsOverlay pkgs).callPackage ./package.nix { defaultInitFile = tangle pkgs { name = "default.el"; org = ./init.org; }; org-clickup-src = inputs.org-clickup; }; in { flake.modules.homeManager.emacs = { config, lib, pkgs, ... }: { options.my.programs.emacs.enable = lib.mkEnableOption "emacs"; config = lib.mkIf config.my.programs.emacs.enable { programs.emacs = { enable = true; package = mkEmacs pkgs; }; services.emacs = { enable = true; client.enable = true; }; <<emacs-daemon-path>> xdg.configFile."emacs/init.el".source = tangleEl pkgs ./init.org; xdg.configFile."emacs/early-init.el".source = tangleEl pkgs ./early-init.org; home.file.".authinfo.age".source = ./authinfo.age; home.shellAliases = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin { emacs = "${config.programs.emacs.package}/Applications/Emacs.app/Contents/MacOS/Emacs"; }; }; }; perSystem = { pkgs, ... }: { packages.emacs = mkEmacs pkgs; }; }
{ stdenv, age, beancount, beancount-language-server, difftastic, emacs-pgtk, emacs-plus, emacsWithPackagesFromUsePackage, gettext, nixd, nixfmt, notmuch, terraform-ls, yaml-language-server, defaultInitFile, org-clickup-src, }: let emacs-unwrapped = if stdenv.hostPlatform.isDarwin then emacs-plus else emacs-pgtk; in emacsWithPackagesFromUsePackage { package = emacs-unwrapped; config = ./init.org; alwaysTangle = true; # Bundle the tangled init.org as default.el so the package is usable # standalone (e.g. `nix run .#emacs`); a home-manager-managed # ~/.config/emacs/init.el still takes precedence when present. inherit defaultInitFile; override = epkgs: epkgs // { org-clickup = epkgs.melpaBuild { pname = "org-clickup"; ename = "org-clickup"; version = builtins.substring 0 8 (org-clickup-src.lastModifiedDate or "00000000"); commit = org-clickup-src.shortRev or "unknown"; files = ''("lisp/*.el")''; src = org-clickup-src; }; }; extraEmacsPackages = epkgs: [ epkgs.treesit-grammars.with-all-grammars notmuch.emacs age beancount beancount-language-server difftastic gettext nixd nixfmt yaml-language-server terraform-ls ]; }
3.3.11.2.1. Daemon PATH#
launchd hands its agents only the system PATH, and init.org repairs
PATH with exec-path-from-shell only when window-system is non-nil,
which a daemon start never satisfies. So the daemon came up with no Nix
profile in sight, died during package initialization, and KeepAlive
restarted it every ten seconds — each start reaching /usr/bin/gcc for
native compilation, which on a machine without Xcode is the stub that
opens the Command Line Tools installer. I hand the agent a PATH of its
own.
launchd.agents = lib.optionalAttrs pkgs.stdenv.hostPlatform.isDarwin { emacs.config.EnvironmentVariables.PATH = lib.concatStringsSep ":" [ "${config.home.profileDirectory}/bin" "/run/current-system/sw/bin" "/nix/var/nix/profiles/default/bin" "/usr/bin" "/bin" "/usr/sbin" "/sbin" ]; };
3.3.11.2.2. early-init.org#
- disable some bars
The tool bar duplicates functionality already accessible via keybindings and the menu bar, while the vertical scroll bar wastes horizontal space without providing useful navigation—line numbers and modeline indicators are more precise. Setting these in
default-frame-alistrather than callingtool-bar-mode/scroll-bar-modeavoids a brief flash of the bars during startup before the modes disable them.elisp(push '(tool-bar-lines . 0) default-frame-alist) (push '(vertical-scroll-bars) default-frame-alist)
- turn off the annoying bell
The default audible bell is distracting and provides no actionable information. Replacing it with
ignoresilences it entirely; a visual bell (visible-bell) was not chosen because the screen flash is equally disruptive.elisp(setq ring-bell-function 'ignore) - disable backup files
Emacs already skips backup files for version-controlled files by default (
vc-make-backup-filesisnil), but files outside of a repository still produce backups. Since virtually all editing happens in Git-managed trees, the remaining cases are rare enough that the clutter outweighs the safety net. Auto-save files are likewise redundant when unsaved work can be recovered through Git stashes or reflog.elisp(setq make-backup-files nil) (setq auto-save-default nil)
- disable lock files
Lock files (
.#filename) prevent concurrent editing across multiple Emacs instances, but in a single-instance workflow they only clutter the working directory and interfere with file watchers and build tools.elisp(setq create-lockfiles nil)
3.3.11.2.3. init.org#
- basic
elisp(add-to-list 'default-frame-alist '(undecorated-round . t)) (use-package doom-themes :ensure t :config (load-theme 'doom-nord :no-confirm))
Enable visual-line-mode globally instead of using auto-fill-mode here to preserve logical line structure while still wrapping long lines visually for better readability without modifying the actual file.
elisp(visual-line-mode 1)
Highlight the cursor line.
elisp(global-hl-line-mode 1)
Accept single-character
y/nresponses instead of requiring the full wordyes/no.elisp(setq use-short-answers t)- Customize file
Emacs defaults
custom-filetoinit.el, but hereinit.elis a read-only symlink into the Nix store, so anything Customize tries to persist would fail to write. Stateful files like this must live outside Nix management to stay mutable: pointcustom-fileat a writable path underuser-emacs-directory(the directory itself is writable; only the symlinked files are not) and load it so the saved values take effect.elisp(setq custom-file (locate-user-emacs-file "custom.el")) (when (file-exists-p custom-file) (load custom-file nil t))
- Font
Moralerspace is a monaspace-based composite font family that pairs a Latin programming typeface with Japanese glyphs. Each variant—Argon, Krypton, Radon, Xenon—has its own typographic character, so assigning a different variant to each face produces visually distinct bold, italic, and bold-italic rendering without relying on Emacs's synthetic transformations.
:weight 'normaland:slant 'normalprevent Emacs from further synthesizing bold or italic on top of the variant's own design.font-lock-comment-faceuses Radon (the italic variant) to give code comments a softer, visually distinct appearance.elisp(set-face-attribute 'default nil :family "Moralerspace Argon HW" :height 140) (set-face-attribute 'bold nil :family "Moralerspace Krypton HW" :weight 'normal) (set-face-attribute 'italic nil :family "Moralerspace Radon HW" :slant 'normal) (set-face-attribute 'bold-italic nil :family "Moralerspace Xenon HW" :weight 'normal :slant 'normal) (set-face-attribute 'font-lock-comment-face nil :family "Moralerspace Radon HW" :slant 'normal)
Without an explicit
set-language-environment, Emacs may select a Chinese font for CJK characters based on locale order, causing Japanese text to render with Chinese glyphs.elisp(set-language-environment "Japanese") - Nix wrapper paths
The Nix Emacs wrapper (
extraEmacsPackages) adds binaries toexec-paththat are absent from the shell'sPATH. Both exec-path-from-shell and envrc replaceexec-pathduring operation, losing these entries. Capturing them once at init allows both packages to merge them back.elisp(setq my/nix-exec-path exec-path) - Darwin
On macOS, Emacs cannot inherit environment variables (such as
$PATH) from the shell, so exec-path-from-shell is needed as a workaround. emacs-plus injects PATH into Emacs's Info.plist, so applying that patch might eliminate the need for this package.exec-path-from-shell-initializereplacesexec-pathwith the login shell'sPATH. Mergingmy/nix-exec-pathback after initialization preserves wrapper-provided paths.When launched from an application launcher (Dock, Spotlight), Emacs inherits only the minimal launchd environment, which lacks Nix-related variables. Without these,
direnv exportmay produce incomplete results. (envrc#92)Separately, fish sources
conf.d/*.fisheven for non-interactive invocations (fish -c "..."). nix-darwin places environment setup scripts there that re-initialize PATH from system profiles when guard variables (__fish_nixos_env_preinit_sourced,__NIX_DARWIN_SET_ENVIRONMENT_DONE,__HM_SESS_VARS_SOURCED) are absent. Without propagating these guards, subprocesses spawned bycompileorshell-commandlose the buffer-local PATH that envrc constructed—even though envrc itself applied the correct environment.elisp(use-package exec-path-from-shell :ensure t :config (when (memq window-system '(mac ns x)) (dolist (var '("SSH_AUTH_SOCK" "SSH_AGENT_PID" "GPG_AGENT_INFO" "LANG" "LC_CTYPE" "NIX_SSL_CERT_FILE" "NIX_PATH" "__fish_nixos_env_preinit_sourced" "__NIX_DARWIN_SET_ENVIRONMENT_DONE" "__HM_SESS_VARS_SOURCED")) (add-to-list 'exec-path-from-shell-variables var)) (exec-path-from-shell-initialize) (setq exec-path (delete-dups (append exec-path my/nix-exec-path)))))
- envrc
envrc.el provides buffer-local environment variables based on direnv. This enables Emacs to recognize project-specific development environments managed by direnv.
Unlike direnv.el which sets environment variables globally, envrc.el keeps them buffer-local. This is essential when working on multiple projects simultaneously, each with its own .envrc.
The mode must be enabled early (via after-init hook) so that other packages can inherit the correct environment.
When envrc updates a buffer's environment, it replaces
exec-pathwith values fromdirenv export, losing the Nix wrapper paths. (envrc#9) Mergingmy/nix-exec-pathback after each update preserves access to wrapper-provided executables (e.g. yaml-language-server) inside direnv-managed buffers.elisp(use-package envrc :ensure t :hook (after-init . envrc-global-mode) :config (advice-add 'envrc--update :after (defun my/envrc-preserve-nix-path (&rest _) (when (local-variable-p 'exec-path) (setq-local exec-path (delete-dups (append exec-path my/nix-exec-path)))))))
- Customize file
- UI
- mode-line
elisp(use-package moody :ensure t :config (moody-replace-mode-line-front-space) (moody-replace-mode-line-buffer-identification) (moody-replace-vc-mode))
- headerline
elisp(use-package breadcrumb :ensure t :config (breadcrumb-mode))
- indent-bars
indent-bars displays configurable vertical guide bars at each indentation level. Tree-sitter integration is available for scope-aware highlighting, where bars outside the current scope are de-emphasized.
elisp(use-package indent-bars :ensure t :hook (prog-mode . indent-bars-mode))
- mode-line
- minibuffer
Referring to https://protesilaos.com/codelog/2024-11-28-basic-emacs-configuration/
elisp(use-package vertico :ensure t :hook (after-init . vertico-mode))
elisp(use-package marginalia :ensure t :hook (after-init . marginalia-mode))
elisp(use-package orderless :ensure t :config (setq completion-styles '(orderless basic)) (setq completion-category-defaults nil) (setq completion-category-overrides nil))
The built-in savehist package keeps a record of user inputs and stores them across sessions. Thus, the user will always see their latest choices closer to the top (such as with M-x).
elisp(use-package savehist :ensure nil ; it is built-in :hook (after-init . savehist-mode))
elisp(use-package corfu :ensure t :hook (after-init . global-corfu-mode) :bind (:map corfu-map ("<tab>" . corfu-complete)) :config (setq tab-always-indent 'complete) (setq corfu-auto t) (setq corfu-auto-prefix 1) (setq corfu-preview-current nil) (setq corfu-min-width 20) (setq corfu-popupinfo-delay '(1.25 . 0.5)) (corfu-popupinfo-mode 1) ; shows documentation after `corfu-popupinfo-delay' ;; Sort by input history (no need to modify `corfu-sort-function'). (with-eval-after-load 'savehist (corfu-history-mode 1) (add-to-list 'savehist-additional-variables 'corfu-history)))
- embark
elisp(use-package embark :ensure t :bind (("C-." . embark-act) ;; pick some comfortable binding ("C-;" . embark-dwim) ;; good alternative: M-. ("C-h B" . embark-bindings)) ;; alternative for `describe-bindings' :init ;; Optionally replace the key help with a completing-read interface (setq prefix-help-command #'embark-prefix-help-command) ;; Show the Embark target at point via Eldoc. You may adjust the ;; Eldoc strategy, if you want to see the documentation from ;; multiple providers. Beware that using this can be a little ;; jarring since the message shown in the minibuffer can be more ;; than one line, causing the modeline to move up and down: ;; (add-hook 'eldoc-documentation-functions #'embark-eldoc-first-target) ;; (setq eldoc-documentation-strategy #'eldoc-documentation-compose-eagerly) ;; Add Embark to the mouse context menu. Also enable `context-menu-mode'. ;; (context-menu-mode 1) ;; (add-hook 'context-menu-functions #'embark-context-menu 100) :config ;; Hide the mode line of the Embark live/completions buffers (add-to-list 'display-buffer-alist '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*" nil (window-parameters (mode-line-format . none)))))
- embark
- version control system
- git
elisp(use-package magit :ensure t :bind (("C-x g" . magit-status))) (use-package diff-hl :ensure t :init (global-diff-hl-mode) (diff-hl-flydiff-mode) (add-hook 'dired-mode-hook 'diff-hl-dired-mode) (add-hook 'magit-post-refresh-hook 'diff-hl-magit-post-refresh))
- difftastic.el
I already use difftastic for git outside Emacs, and difftastic.el brings the same structural diff to magit.
difftastic-bindings-modeaddsM-dandM-cto themagit-difftransient, so magit's own diff stays the default and difftastic is one key away.elisp(use-package difftastic :ensure t :init (difftastic-bindings-mode))
- forge
Forge extends Magit to work with GitHub, GitLab, and other forges directly from Emacs—browsing issues, reviewing pull requests, and managing notifications without leaving the editor.
Forge authenticates via
ghub, which reads credentials fromauth-sources. Theauthinfo.agefile should contain a GitHub API token entry:machine api.github.com login <username>^forge password <token>
The token must be a classic personal access token, not a fine-grained one, because the
/notificationsREST endpoints only accept classic tokens. A 6-month expiration is set so the token must be regenerated periodically.Required scopes:
repo— issues and pull requestsnotifications— read the notification inbox behindforge-list-notificationsuser— resolve the authenticated user's profileread:org— enumerate organization repositories and teams
C-c gopens the notification list.forge-list-notificationsrenders only what the local database already holds, so I pull afterwards rather than before. The pull refreshes whichever buffer was current when it started, and running it second makes that buffer the list itself.elisp(use-package forge :ensure t :after magit) (defun my/forge-notifications () "Display GitHub notifications, then pull new ones from the forge." (interactive) (forge-list-notifications) (forge-pull-notifications)) (global-set-key (kbd "C-c g") #'my/forge-notifications)
- consult-gh
consult-gh drives the
ghCLI through a consult interface, so searching repositories, issues, pull requests, and code across GitHub happens from the minibuffer with live preview.Forge already covers issues and pull requests for repositories tracked locally in magit. consult-gh extends that reach to arbitrary repositories: scanning an upstream's recent issues, opening another project's README, or finding a snippet by code search—without first cloning or visiting the repo in magit.
elisp(use-package consult-gh :ensure t :after consult :config (consult-gh-enable-default-keybindings))
consult-gh-embark-modeadds embark actions on results (clone, browse, copy URL, …).elisp(use-package consult-gh-embark :ensure t :after (consult-gh embark) :config (consult-gh-embark-mode +1))
consult-gh-forge-moderoutes issue and PR viewing through forge buffers, keeping the editing surface consistent with the existing magit/forge workflow.elisp(use-package consult-gh-forge :ensure t :after (consult-gh forge) :config (consult-gh-forge-mode +1))
- difftastic.el
- git
- LSP
lsp-mode is a Language Server Protocol client for Emacs. It provides IDE-like features (completion, diagnostics, navigation) by communicating with external language servers.
Eglot is built into Emacs 29+ as a lighter alternative, but it assumes one server per major mode, making it cumbersome to run multiple servers simultaneously on the same buffer (e.g. a language server alongside a linter or formatter). lsp-mode handles multiple concurrent servers natively and provides more granular control over LSP behavior.
Using
lsp-deferredinstead oflspto delay server startup until the buffer is visible, avoiding unnecessary processes for buffers opened in the background.Disabling
lsp-headerline-breadcrumb-modeto avoid conflict with the existingbreadcrumbpackage in the header line.elisp(use-package lsp-mode :ensure t :commands (lsp lsp-deferred) :init (setq lsp-keymap-prefix "C-c l") :config (setq lsp-headerline-breadcrumb-enable nil))
- language support
- tree-sitter
Use maximum font-lock level for tree-sitter modes.
elisp(setq treesit-font-lock-level 4) - treesit-fold
treesit-fold provides code folding driven by the tree-sitter syntax tree, so foldable regions follow real syntactic nodes (functions, blocks, comments) rather than indentation heuristics.
global-treesit-fold-modeactivates it automatically in every supported tree-sitter mode.TAB is rebound to a wrapper that mimics
org-cycle: when point sits on a foldable node, the fold is toggled; otherwise the originalindent-for-tab-commandruns, so ordinary indentation in code keeps working.Language-specific fold rules (e.g. Nix
let ... inblocks) live with their respective language sections rather than here, so the catalogue of foldable nodes per language stays next to the rest of that mode's setup.elisp(use-package treesit-fold :ensure t :hook (after-init . global-treesit-fold-mode) :preface (defun my/treesit-fold-toggle-or-indent () "Toggle the fold at point when on a foldable node; otherwise indent." (interactive) (if (treesit-fold--foldable-node-at-pos) (treesit-fold-toggle) (indent-for-tab-command))) :bind (:map treesit-fold-mode-map ("TAB" . my/treesit-fold-toggle-or-indent)))
- Beancount
beancount-mode is the major mode bundled with Beancount. LSP integration uses beancount-language-server via lsp-mode's bundled
lsp-beancountclient.Upstream only auto-registers
.beancount, so.beanis added explicitly here for the common short extension.elisp(use-package beancount :ensure t :mode ("\\.bean\\(count\\)?\\'" . beancount-mode) :hook (beancount-mode . lsp-deferred))
- CSV
csv-mode provides a major mode for editing csv and tsv files.
elisp(use-package csv-mode :ensure t)
- Markdown
https://github.com/jrblevin/markdown-mode
markdown-mode is a major mode for editing Markdown-formatted text.
First, enable
markdown-fontify-code-blocks-nativelyso fenced code blocks are highlighted by each language's major mode. A```nixblock then reads like a real Nix buffer rather than a flat monochrome region.Also enable
markdown-marginalize-headersto push leading#characters into the left margin. Header text then starts at the same column across all depths, keeping the outline aligned with body paragraphs.Finally, set
markdown-indent-on-enterto'indent-and-new-itemsoRETinside a list inserts the next bullet or number at the correct indent level.elisp(use-package markdown-mode :ensure t :mode ("README\\.md\\'" . gfm-mode) :init (setq markdown-command "multimarkdown") :bind (:map markdown-mode-map ("C-c C-e" . markdown-do)) :config (setq markdown-header-scaling t) (setq markdown-fontify-code-blocks-natively t) (setq markdown-marginalize-headers t) (setq markdown-indent-on-enter 'indent-and-new-item))
- Nix
https://github.com/nix-community/nix-ts-mode
Tree-sitter based major mode for Nix expressions.
nixd (configured via lsp-mode) provides IDE features for Nix expressions by interoperating with the C++ Nix evaluator. Unlike nil which relies on static analysis, nixd performs actual Nix evaluation, enabling accurate completion for attribute paths (e.g.
pkgs.), NixOS options, and flake inputs.The bundled
treesit-fold-parsers-nixonly knows about attrsets, interpolations, lists, and comments. Two extra rules are registered here solet ... inblocks and indented (''...'') multi-line strings fold as well.elisp(use-package nix-ts-mode :ensure t :mode "\\.nix\\'" :hook (nix-ts-mode . lsp-deferred) :init ;; org-src-lang-modes is defined in org-src, which may not be loaded yet at init time (with-eval-after-load 'org-src (add-to-list 'org-src-lang-modes '("nix" . nix-ts))) :config (setq lsp-nix-nixd-formatting-command ["nixfmt"]) (with-eval-after-load 'treesit-fold (setf (alist-get 'nix-ts-mode treesit-fold-range-alist) (append (alist-get 'nix-ts-mode treesit-fold-range-alist) `((let_expression . ,(lambda (node offset) (treesit-fold-range-markers node offset "let" "in"))) (indented_string_expression . ,(lambda (node offset) (treesit-fold-range-markers node offset "''" "''"))))))))
- Terraform
terraform-mode provides syntax highlighting and indentation for Terraform (HCL) files.
No tree-sitter variant is available in MELPA, so using the traditional major mode.
terraform-ls (configured via lsp-mode) provides IDE features such as completion, diagnostics via
terraform validate, and go-to-definition for Terraform configurations.elisp(use-package terraform-mode :ensure t :hook (terraform-mode . lsp-deferred))
- PO
po-mode is Emacs's major mode for editing GNU gettext PO (Portable Object) files. PO files store translations for software internationalization.
Editing PO files as plain text is error-prone because the format has strict requirements for escaping and structure. po-mode provides structured navigation between entries, automatic validation, and prevents common formatting errors.
- Common Operations
PO mode is not derived from text mode. The buffer is read-only and has its own keymap, so standard text editing commands do not work directly. Translations must be edited through the subedit buffer (
RET).- Main Commands
Commands for file operations, validation, and general PO mode management.
Key Function Description _po-undoUndo last modification qpo-confirm-and-quitQuit with confirmation ?hpo-helpShow help about PO mode Use or
qto quit instead ofC-x k(kill-buffer), as they properly handle unsaved changes and warn about untranslated entries.See Main PO mode Commands for more details.
- Entry Positioning
Commands for navigating between entries in the PO file.
Key Function Description npo-next-entryMove to next entry ppo-previous-entryMove to previous entry <po-first-entryMove to first entry >po-last-entryMove to last entry See Entry Positioning for more details.
- Modifying Translations
Commands for editing translation strings. Press
RETto open a subedit buffer where standard Emacs editing works normally.Key Function Description RETpo-edit-msgstrOpen subedit buffer for editing C-c C-cpo-subedit-exitFinish editing and apply changes C-c C-kpo-subedit-abortAbort editing and discard changes DELpo-fade-out-entryDelete the translation See Modifying Translations for more details.
- Main Commands
- Configuration
elisp(use-package po-mode :ensure t)
- Common Operations
- Protocol Buffers
protobuf-ts-mode is a tree-sitter-based major mode for editing proto3 files.
The mode auto-registers
.protofiles when theprotogrammar is available.elisp(use-package protobuf-ts-mode :ensure t)
- Justfile
just-ts-mode is a tree-sitter-based major mode for editing just command runner files.
C-c 'opens a dedicated editing buffer for the recipe body at point, with automatic shebang-based language detection.elisp(use-package just-ts-mode :ensure t)
- YAML
yaml-ts-modeis a built-in tree-sitter-based major mode for YAML files. The tree-sitter grammar is already available viatreesit-grammars.with-all-grammars, so the mode activates automatically when the grammar is present.yaml-language-server (configured via lsp-mode) provides schema validation using SchemaStore. For example, files under
.github/workflows/are automatically matched to the GitHub Actions workflow schema, enabling completion and diagnostics for workflow definitions.elisp(use-package yaml-ts-mode :ensure nil :mode ("\\.ya?ml\\'") :hook (yaml-ts-mode . lsp-deferred))
- tree-sitter
- org
- Semantic Line Breaks
Semantic Line Breaks (SemBr) is a writing convention where line breaks are placed at logical boundaries in sentences, such as after punctuation marks or between phrases. This makes diffs more meaningful in version control and improves readability without affecting the rendered output.
The recommended line length is around 80 characters. I set this as an upper limit in the editor to prevent lines from becoming unnecessarily long.
elisp(add-hook 'text-mode-hook (lambda () (auto-fill-mode 1) (setq fill-column 80))) - org-capture
elisp(global-set-key (kbd "C-c c") 'org-capture) (global-set-key (kbd "C-c l") 'org-store-link) (setq org-root "~/dropbox/org/") (setq org-capture-templates `(("t" "Todo" entry (file+headline ,(concat org-root "todo.org") "Tasks") "* TODO %?\n %i\n %a") ("j" "Journal" entry (file+olp+datetree ,(concat org-root "journal.org")) "* %U\n%?\n %i\n %a") ("f" "Fleeting" entry (file ,(concat org-root "fleeting.org")) "* %?\n %U\n %i\n %a")))
- org-agenda
elisp(global-set-key (kbd "C-c a") 'org-agenda) (setq org-agenda-files '("~/dropbox/org"))
- org-habit
Some of my todos are things I want to keep doing rather than finish once.
org-habitdraws a consistency graph next to those entries in the agenda, so a repeating task shows whether I actually kept it up instead of only when it is next due. Marking an entry as a habit is described in Tracking your habits.Org ships the module but does not load it by default. I require the feature after Org loads rather than adding it to
org-modules, because that option is read once while Org starts up and is easy to set too late.elisp(with-eval-after-load 'org (require 'org-habit))
- org-gcal
org-gcal provides bidirectional sync between Google Calendar and Org files via the native Google Calendar API.
elisp(use-package org-gcal :ensure t :defer t)
Prerequisites for this configuration to function:
- On the personal Cloud Project, the Google Calendar API is enabled
and
https://www.googleapis.com/auth/calendaris added to the OAuth consent screen scopes. ~/.authinfo.agecontains entries:machine org-gcal login client-id password <client-id> machine org-gcal login client-secret password <client-secret>
org-gcal registers an
oauth2-autoprovider entry at package load time only when bothorg-gcal-client-idandorg-gcal-client-secretare already set; otherwise it skips registration and warns. Since these variables are populated after the package loads,org-gcal-reload-client-id-secretmust be called explicitly to perform the registration with the populated values.elisp(with-eval-after-load 'org-gcal (setq org-gcal-client-id (auth-source-pick-first-password :host "org-gcal" :user "client-id")) (setq org-gcal-client-secret (auth-source-pick-first-password :host "org-gcal" :user "client-secret")) (org-gcal-reload-client-id-secret))
org-gcal supports only one global OAuth client. To sync calendars from multiple accounts, share the secondary calendars to the authorized account via Google Calendar's sharing UI with "Make changes to events" permission.
elisp(with-eval-after-load 'org-gcal (setq org-gcal-fetch-file-alist `(("[email protected]" . ,(concat org-root "gcal-personal.org")) ("[email protected]" . ,(concat org-root "gcal-work.org")))))
The empty-file pre-creation works around emacs-oauth2-auto issue #6: when
~/.config/emacs/contains symlinks (here from home-manager managing other emacs config files), oauth2-auto's first write to the plstore fails an internal file/buffer identity check. Pre-creating the file before the first sync lets that check succeed.elisp(with-eval-after-load 'oauth2-auto (unless (file-exists-p oauth2-auto-plstore) (write-region "" nil oauth2-auto-plstore)))
The plstore holding the OAuth tokens is encrypted symmetrically, so pinentry pops several times per sync unless something caches the passphrase.
plstore-cache-passphrase-for-symmetric-encryptionkeeps it in an Emacs variable for the session, but only under loopback pinentry: that is the one mode in which epg runs the passphrase callback plstore installs, instead of leaving the prompt to gpg-agent and receiving the plaintext alone.Caching in gpg-agent rather than in Emacs would survive Emacs restarts, which loopback does not. It also means dropping
no-symkey-cachefrom home-manager'sprograms.gpgdefaults, loosening the cache policy of every GPG operation on the machine for the sake of one package. plstore's own recommendation, public-key encryption viaplstore-encrypt-to, fits even worse: my encryption subkey lives on a smartcard, so a sync would fail whenever the card is not plugged in.Loopback keeps the change inside Emacs, and although the mode is global to epg, plstore is the only epg caller left here now that authinfo is decrypted by age.el and magit signs through git's own gpg invocation. In exchange the passphrase sits in a variable in cleartext for as long as Emacs runs, and the confirmation prompt is gone, so a typo while recreating the plstore silently becomes the new passphrase.
elisp(setq epg-pinentry-mode 'loopback) (setq plstore-cache-passphrase-for-symmetric-encryption t)
- On the personal Cloud Project, the Google Calendar API is enabled
and
- org-clickup
org-clickup is a personal package that bidirectionally syncs ClickUp tasks with org-mode TODO entries. Work tasks live in ClickUp, but the authoritative planning surface is org-mode (agenda, capture, refile) so daily triage stays in Emacs instead of switching to a browser tab.
The API token must live in
~/.authinfo.ageas:machine api.clickup.com login <workspace-id> password <token>
elisp(use-package org-clickup :ensure t :defer t)
- org-roam
elisp(use-package org-roam :ensure t :custom (org-roam-directory "~/dropbox/org-roam") (org-roam-db-location "~/.local/share/org-roam.db") :bind (("C-c n l" . org-roam-buffer-toggle) ("C-c n f" . org-roam-node-find) ("C-c n g" . org-roam-graph) ("C-c n i" . org-roam-node-insert) ("C-c n c" . org-roam-capture) ("C-c n j" . org-roam-dailies-capture-today)) :config (setq org-roam-capture-templates '(("p" "permanent" plain "%?" :target (file+head "permanent/${slug}.org" "#+title: ${title}\n") :unnarrowed t) ("l" "literature" plain "%?" :target (file+head "literature/${title}.org" "#+title: ${title}\n") :unnarrowed t))) (setq org-roam-node-display-template (concat "${title:*} " (propertize "${tags:10}" 'face 'org-tag))) (org-roam-db-autosync-mode) (require 'org-roam-protocol) )
- htmlize
Used when converting Org files to HTML with syntax highlighting for code blocks.
C-c C-e h hexports the current Org buffer to HTML.elisp(use-package htmlize :ensure t)
- Semantic Line Breaks
- document
- pdf-tools
Emacs's built-in DocView mode renders PDFs as rasterized images page-by-page, resulting in blurry text at most zoom levels and lacking interactive features like text selection, incremental search, and annotation.
pdf-tools replaces DocView with a viewer powered by
poppler, providing sharp rendering, isearch integration, annotation support, and SyncTeX for LaTeX workflows.Using
pdf-loader-installinstead ofpdf-tools-installto defer initialization until a PDF is actually opened. With Nix's pre-builtepdfinfobinary, both functions behave identically, but the loader variant avoids unnecessary work at startup.elisp(use-package pdf-tools :ensure t :mode ("\\.pdf\\'" . pdf-view-mode) :config (pdf-loader-install))
- pdf-tools
- RSS
elfeed is a web feed reader for Emacs. Using it with elfeed-protocol to read feeds from Miniflux via the Fever API. This keeps feed management centralized in Miniflux and avoids duplicating the subscription list across devices.
The Fever API must be enabled in Miniflux (Settings -> Integrations -> Fever API) before this configuration will work.
Credentials are stored in
~/.authinfo.age.machine rss.home.natsukium.com login natsukium password <fever-password>"
elisp(use-package elfeed :ensure t :bind ("C-x w" . elfeed)) (use-package elfeed-protocol :ensure t :after elfeed :config (setq elfeed-use-curl t) (setq elfeed-feeds '(("fever+http://[email protected]" :api-url "http://rss.home.natsukium.com/fever/" :use-authinfo t))) (setq elfeed-protocol-enabled-protocols '(fever)) (elfeed-protocol-enable))
- mail
notmuch is a tag-based email indexer and searcher. notmuch.el provides an Emacs interface for reading and organizing email. It reads mail from a local maildir synced by mbsync.
notmuch was chosen over mu4e because the notmuch indexer is already configured for all email accounts. Adding notmuch.el only requires the Emacs frontend, avoiding a second indexer for the same maildir.
notmuch has no built-in delete operation; it manages metadata (tags) rather than maildir folder placement. Pressing
dtags the message with+deletedand removesinbox. The actual deletion happens on the nextnotmuch new: a post-new hook moves tagged files to Gmail's Trash maildir folder after indexing, so the move is synced to the server on the next mbsync run.Dreverses the operation, restoringinboxand removingdeleted. This only works before the nextnotmuch newmoves the file; once mbsync has synced the move, the message is in Gmail's Trash.shr-use-colorsis disabled so sender HTML colors don't clash with the dark theme. Inline CID images ship with the mail and are enabled, butshr-blocked-imagesis set to.(matches every URL) to block remote images, which marketing platforms use as tracking pixels — Gmail's web UI proxies them, but notmuch fetches directly.Irefreshes the current message with blocking disabled when a sender is trusted.notmuch-show-part-button-default-actiondefaults to saving an attachment to disk; I switch it tonotmuch-show-view-partso the part opens with its mailcap viewer instead, letting me press the button on a PDF to open it directly.elisp(use-package notmuch :ensure nil :bind ("C-c M" . notmuch) :custom (notmuch-fcc-dirs nil) (notmuch-search-oldest-first nil) (notmuch-show-part-button-default-action 'notmuch-show-view-part) (notmuch-saved-searches '((:name "inbox" :query "tag:inbox" :key "i") (:name "unread" :query "tag:unread" :key "u") (:name "action" :query "tag:github::action-required" :key "a") (:name "attmcojp" :query "tag:github::attmcojp" :key "w") (:name "github" :query "tag:github and not tag:github::action-required" :key "g") (:name "all" :query "*" :key "A"))) (shr-use-colors nil) (mm-inline-text-html-with-images t) (shr-blocked-images ".") :config (keymap-set notmuch-search-mode-map "d" (lambda () (interactive) (notmuch-search-tag '("+deleted" "-inbox")) (notmuch-search-next-thread))) (keymap-set notmuch-show-mode-map "d" (lambda () (interactive) (notmuch-show-tag '("+deleted" "-inbox")))) (keymap-set notmuch-search-mode-map "D" (lambda () (interactive) (notmuch-search-tag '("-deleted" "+inbox")) (notmuch-search-next-thread))) (keymap-set notmuch-show-mode-map "D" (lambda () (interactive) (notmuch-show-tag '("-deleted" "+inbox")))) (keymap-set notmuch-show-mode-map "I" (lambda () (interactive) (let ((shr-blocked-images nil)) (notmuch-show-refresh-view t)))))
ol-notmuch integrates notmuch with Org mode's link system. Calling
org-store-link(C-c l) in a notmuch buffer stores a link to the current message or thread, which can then be inserted into org-capture templates via%a.elisp(use-package ol-notmuch :ensure t :after (notmuch org))
- terminal
- vterm
emacs-libvterm is a fully-fledged terminal emulator based on libvterm. It provides better performance and compatibility than pure Emacs Lisp alternatives, making it suitable for running interactive CLI tools like Claude Code.
elisp(use-package vterm :ensure t)
- vterm
- encryption
age.el provides transparent encryption and decryption of
.agefiles in Emacs using the age encryption tool.Using SSH keys as identity/recipient so that no separate age keypair is needed.
elisp(use-package age :ensure t :custom (age-default-identity "~/.ssh/id_ed25519") (age-default-recipient "~/.ssh/id_ed25519.pub") :config (age-file-enable) (setq auth-sources '("~/.authinfo.age")))
- AI
- gptel
gptel turns any buffer into a chat with an LLM backend.
Authenticate once with
gptel-openai-oauth-login(ChatGPT device flow); the token is cached under.cache/gptel-openai/inuser-emacs-directory.elisp(use-package gptel :ensure t :config (setq gptel-model 'gpt-5.6-luna gptel-backend (gptel-make-openai-oauth "ChatGPT")))
- gptel
- misc
- vundo
elisp(use-package vundo :ensure t :bind (("C-x u" . vundo)) :config (setq vundo-glyph-alist vundo-unicode-symbols))
- consult
elisp;; Example configuration for Consult (use-package consult :ensure t ;; Replace bindings. Lazily loaded by `use-package'. :bind (;; C-c bindings in `mode-specific-map' ("C-c M-x" . consult-mode-command) ("C-c h" . consult-history) ("C-c k" . consult-kmacro) ("C-c m" . consult-man) ("C-c i" . consult-info) ([remap Info-search] . consult-info) ;; C-x bindings in `ctl-x-map' ("C-x M-:" . consult-complex-command) ;; orig. repeat-complex-command ("C-x b" . consult-buffer) ;; orig. switch-to-buffer ("C-x 4 b" . consult-buffer-other-window) ;; orig. switch-to-buffer-other-window ("C-x 5 b" . consult-buffer-other-frame) ;; orig. switch-to-buffer-other-frame ("C-x t b" . consult-buffer-other-tab) ;; orig. switch-to-buffer-other-tab ("C-x r b" . consult-bookmark) ;; orig. bookmark-jump ("C-x p b" . consult-project-buffer) ;; orig. project-switch-to-buffer ;; Custom M-# bindings for fast register access ("M-#" . consult-register-load) ("M-'" . consult-register-store) ;; orig. abbrev-prefix-mark (unrelated) ("C-M-#" . consult-register) ;; Other custom bindings ("M-y" . consult-yank-pop) ;; orig. yank-pop ;; M-g bindings in `goto-map' ("M-g e" . consult-compile-error) ("M-g f" . consult-flymake) ;; Alternative: consult-flycheck ("M-g g" . consult-goto-line) ;; orig. goto-line ("M-g M-g" . consult-goto-line) ;; orig. goto-line ("M-g o" . consult-outline) ;; Alternative: consult-org-heading ("M-g m" . consult-mark) ("M-g k" . consult-global-mark) ("M-g i" . consult-imenu) ("M-g I" . consult-imenu-multi) ;; M-s bindings in `search-map' ("M-s d" . consult-find) ;; Alternative: consult-fd ("M-s c" . consult-locate) ("M-s g" . consult-grep) ("M-s G" . consult-git-grep) ("M-s r" . consult-ripgrep) ("M-s l" . consult-line) ("M-s L" . consult-line-multi) ("M-s k" . consult-keep-lines) ("M-s u" . consult-focus-lines) ;; Isearch integration ("M-s e" . consult-isearch-history) :map isearch-mode-map ("M-e" . consult-isearch-history) ;; orig. isearch-edit-string ("M-s e" . consult-isearch-history) ;; orig. isearch-edit-string ("M-s l" . consult-line) ;; needed by consult-line to detect isearch ("M-s L" . consult-line-multi) ;; needed by consult-line to detect isearch ;; Minibuffer history :map minibuffer-local-map ("M-s" . consult-history) ;; orig. next-matching-history-element ("M-r" . consult-history)) ;; orig. previous-matching-history-element ;; The :init configuration is always executed (Not lazy) :init ;; Tweak the register preview for `consult-register-load', ;; `consult-register-store' and the built-in commands. This improves the ;; register formatting, adds thin separator lines, register sorting and hides ;; the window mode line. (advice-add #'register-preview :override #'consult-register-window) (setq register-preview-delay 0.5) ;; Use Consult to select xref locations with preview (setq xref-show-xrefs-function #'consult-xref xref-show-definitions-function #'consult-xref) ;; Configure other variables and modes in the :config section, ;; after lazily loading the package. :config ;; Optionally configure preview. The default value ;; is 'any, such that any key triggers the preview. ;; (setq consult-preview-key 'any) ;; (setq consult-preview-key "M-.") ;; (setq consult-preview-key '("S-<down>" "S-<up>")) ;; For some commands and buffer sources it is useful to configure the ;; :preview-key on a per-command basis using the `consult-customize' macro. (consult-customize consult-theme :preview-key '(:debounce 0.2 any) consult-ripgrep consult-git-grep consult-grep consult-man consult-bookmark consult-recent-file consult-xref consult-source-bookmark consult-source-file-register consult-source-recent-file consult-source-project-recent-file ;; :preview-key "M-." :preview-key '(:debounce 0.4 any)) ;; Optionally configure the narrowing key. ;; Both < and C-+ work reasonably well. (setq consult-narrow-key "<") ;; "C-+" ;; Optionally make narrowing help available in the minibuffer. ;; You may want to use `embark-prefix-help-command' or which-key instead. ;; (keymap-set consult-narrow-map (concat consult-narrow-key " ?") #'consult-narrow-help) )
elisp;; Consult users will also want the embark-consult package. (use-package embark-consult :ensure t)
- compilation
The compilation buffer does not inherit from comint-mode, so ANSI escape sequences are displayed as raw text by default. Adding
ansi-color-compilation-filterto the compilation filter hook interprets these sequences and renders them as colors.elisp(add-hook 'compilation-filter-hook 'ansi-color-compilation-filter)
- copy-region-reference
Copy the absolute path and line range of the selected region in
file:start-endformat (e.g./path/to/file.el:10-20). Pasting the result into a coding agent's prompt gives it an unambiguous file reference to work with.elisp(defun my/copy-region-reference (start end) "Copy the file path and line range of the current region to the clipboard. The format is \"/path/to/file:START-END\"." (interactive "r") (let* ((file (buffer-file-name)) (line-start (line-number-at-pos start)) (line-end (line-number-at-pos (1- end))) (ref (format "%s:%d-%d" file line-start line-end))) (kill-new ref) (message "%s" ref))) (global-set-key (kbd "C-c r") #'my/copy-region-reference)
- Others
elisp(which-key-mode) (setq-default indent-tabs-mode nil) (require 'org-tempo) (org-babel-do-load-languages 'org-babel-load-languages '((shell . t))) (setq org-src-preserve-indentation t)
- project.el
Register all repositories cloned by ghq (https://github.com/x-motemen/ghq) as projects.
Specifically, directories under
~/src/$ACCOUNT/$VCS_HOST/$OWNER/$REPO.elisp(defun my/sync-project-list () "Find all projects under ~/src and synchronize the project-list-file." (interactive) (let* (;; 1. Retrieve directory list as a string using find command (command (format "find %s -mindepth 4 -maxdepth 4 -type d" (expand-file-name "~/src"))) (dir-list-string (shell-command-to-string command)) ;; 2. Split string by newlines and exclude empty lines to create a list (dirs (split-string dir-list-string "\n" t))) ;; 3. Build file contents in a temporary buffer (with-temp-buffer (insert ";;; -*- lisp-data -*-\n") (insert "(\n") (dolist (dir dirs) (insert (format " (\"%s/\")\n" dir))) (insert ")\n") ;; 4. Write the built contents to file (write-file project-list-file)))) (my/sync-project-list)
Sort the project list by modification time, placing recently updated projects first.
Using advice instead of sorting in
my/sync-project-listensures that projects are sorted by their current mtime at selection time, not at file generation time. This keeps the order fresh even during long Emacs sessions.elisp(defun my/sort-projects-by-mtime (projects) "Sort PROJECTS by modification time, most recent first." (sort projects (lambda (a b) (let ((time-a (file-attribute-modification-time (file-attributes a))) (time-b (file-attribute-modification-time (file-attributes b)))) (time-less-p time-b time-a))))) (advice-add 'project-known-project-roots :filter-return #'my/sort-projects-by-mtime)
- project.el
- vundo
3.3.12. Speech to Text#
3.3.12.1. Handy#
Handy is an offline speech-to-text app: press a
hotkey, speak, and the transcription is typed into the focused field. The binary
is in nixpkgs (pkgs.handy, Linux and macOS), but neither nixpkgs nor
home-manager ships a module, and the upstream flake's own modules are thin and
Linux-only. So I wrote my own, driven from one switch across every desktop.
Almost everything is per-user and cross-platform, so it lives in a home-manager
module: the package plus an autostart agent, a systemd user service on Linux and
a launchd agent on macOS. Each platform's block is guarded with mkIf so it
only references options that exist there, and to avoid an
attribute-name-versus-pkgs evaluation cycle under useGlobalPkgs.
The one piece home-manager cannot express is system-level. Handy reads its
global hotkey straight from /dev/input/event* via evdev, which only the
input group may read, and group membership is a NixOS setting. So a thin
flake.modules.nixos.handy adds the user to that group, sharing the
my.programs.handy.enable switch with the home module. (/dev/uinput, used to
type the result back, is already granted to the session user by uaccess, so no
udev rule is needed.) macOS has no evdev and no group to join; there the hotkey
and microphone need Accessibility and Microphone permissions, granted once by
hand, since TCC has no declarative path.
The desktop profile enables it by default, setting the home switch on every
desktop and, on NixOS only, adding the input group through its nixos block.
{ ... }: let homeModule = { config, lib, pkgs, ... }: let cfg = config.my.programs.handy; in { options.my.programs.handy = { enable = lib.mkEnableOption "Handy offline speech-to-text"; autostart = lib.mkOption { type = lib.types.bool; default = true; description = "Start Handy on login."; }; }; config = lib.mkIf cfg.enable ( lib.mkMerge [ { home.packages = [ pkgs.handy ]; } (lib.mkIf pkgs.stdenv.hostPlatform.isLinux { systemd.user.services.handy = lib.mkIf cfg.autostart { Unit = { Description = "Handy speech-to-text"; PartOf = [ "graphical-session.target" ]; After = [ "graphical-session.target" ]; }; Service = { ExecStart = lib.getExe pkgs.handy; Restart = "on-failure"; RestartSec = 5; }; Install.WantedBy = [ "graphical-session.target" ]; }; }) (lib.mkIf pkgs.stdenv.hostPlatform.isDarwin { launchd.agents.handy = lib.mkIf cfg.autostart { enable = true; config = { ProgramArguments = [ "${pkgs.handy}/Applications/Handy.app/Contents/MacOS/handy" ]; RunAtLoad = true; # Relaunch only on a crash, not on a clean quit, so closing the # settings window does not immediately resurrect Handy. KeepAlive.SuccessfulExit = false; }; }; }) ] ); }; nixosModule = { config, lib, ... }: { options.my.programs.handy.enable = lib.mkEnableOption "Handy offline speech-to-text"; config = lib.mkIf config.my.programs.handy.enable { # Handy reads /dev/input/event* via evdev for its global hotkey, which is # only readable by the input group; home-manager cannot grant it. users.users.${config.my.username}.extraGroups = [ "input" ]; }; }; in { flake.modules.homeManager.handy = homeModule; flake.modules.nixos.handy = nixosModule; }
3.3.13. Launcher#
3.3.13.1. Vicinae#
Vicinae is a cross-platform launcher for Linux and macOS, designed to run extensions from Raycast, the popular macOS launcher. I use both Linux and macOS day to day, so having the same experience on both platforms matters a lot to me.
On macOS I disable Spotlight in favor of Vicinae, as described later.
# Requires: inputs.vicinae { inputs, ... }: { flake.modules.homeManager.vicinae = { config, lib, pkgs, ... }: { imports = [ inputs.vicinae.homeManagerModules.default ]; options.my.programs.vicinae.enable = lib.mkEnableOption "vicinae launcher"; config = lib.mkIf config.my.programs.vicinae.enable { programs.vicinae = { enable = true; systemd.enable = true; launchd = { enable = true; environment.PATH = "${lib.makeBinPath [ pkgs.rbw ]}:/usr/bin:/bin:/usr/sbin:/sbin"; }; extensions = [ (inputs.vicinae.lib.${pkgs.stdenv.hostPlatform.system}.mkVicinaeExtension (finalAttrs: { pname = "rbw"; version = "0-unstable-2026-07-01"; src = pkgs.fetchFromGitea { domain = "git.natsukium.com"; owner = "natsukium"; repo = "vicinae-extension-rbw"; rev = "66444c3c02bd4121f7127ced30dfc5b1d29b5bcf"; hash = "sha256-jhbn2eICx7Sf8lm7d5/6cYM3Cl1b/llQyVKuAQvWVWE="; }; npmDeps = pkgs.fetchNpmDeps { inherit (finalAttrs) src; hash = "sha256-Mo+OxyoCaDIbwoW5KRFl0GZarWVp0iFNW/NtkUlr35Q="; }; npmConfigHook = pkgs.npmHooks.npmConfigHook; })) ]; settings = { keybinding = "emacs"; theme.dark.name = "nord"; }; }; }; }; }
3.3.14. Terminal#
3.3.14.1. Felis#
felis is a daemon/client terminal whose defining feature is session persistence across hosts. Apart from the client set up on desktops, deploying the daemon in every environment keeps a session running stably even over SSH.
The client names a separate Moralerspace flavor per text style — Neon, Xenon, Radon, Krypton. felis derives bold and italic from a family's own faces, but these flavors are separate families rather than weights of one, so naming them per style is the only way to reach them.
Two keymap entries work around problems outside felis. On a JIS keyboard the yen
key still types ¥ though macOS is set to emit \, because winit drops the
substituted character unless an IME preedit is active; rebinding it here avoids
waiting for a winit release. Shift+Enter already goes out as CSI 13;2u under
the Kitty keyboard protocol, but Claude Code enables that protocol only for
terminals on a hardcoded list, so I send the Option+Enter newline it does
recognize instead. Both go away once upstream catches up.
The built-in switch_{next,previous}_session bindings only step one session at
a time. felis-switch (Ctrl+Shift+p, via the run action) instead pipes
felis sessions list through fzf, previewing each session's grid in colour, and
switches to the pick.
felis makes only OSC 8 hyperlinks clickable, not the plain-text URLs that fill
real output, leaving that heuristic to an external tool as kitty does.
felis-hints (Ctrl+Shift+o) is felis' side: the pipe action ships the
visible grid to spoor, my own hint picker, which overlays single-key labels and
opens the pick. I chose spoor over thumbs because it links felis' own VT parser
and cell grid, so its labels land on exactly the columns felis drew; thumbs
rebuilds the grid itself and drifts on a wide character or soft wrap. Open-only
for now — routing a pick back to the prompt would need felis to pass the
transient command its originating session id.
felis-nix-log (Ctrl+Shift+l, ported from kitty) scans the grid for a nix
log <drv> line and pages that build log. It uses grep + fzf rather than a label
overlay because a hint picker can't suppress its built-in path matchers and the
point here is matching only the .drv; a lone drv, the usual failed-build
case, opens with no picker at all.
Desktop notifications now come from felis' own home-manager module, not a helper
of mine: notifications.enable runs a relay that streams felis notifications
subscribe and forwards each alert to terminal-notifier (macOS) or
notify-send (Linux), surfacing only detached sessions since an attached one
already flashes its own window. It replaces the subscribe loop I used to
hand-roll here.
{ inputs, ... }: let daemon = { config, lib, pkgs, ... }: { options.my.programs.felis.enable = lib.mkEnableOption "felis"; config = lib.mkIf config.my.programs.felis.enable { environment.systemPackages = [ inputs.felis.packages.${pkgs.stdenv.hostPlatform.system}.default ]; }; }; in { flake.modules.nixos.felis = daemon; flake.modules.darwin.felis = daemon; flake.modules.homeManager.felis = { config, lib, pkgs, ... }: let inherit (config.colorScheme) palette; package = inputs.felis.packages.${pkgs.stdenv.hostPlatform.system}.default; # The configured Neovim, reused as a read-only scrollback viewer. neovim = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.neovim; # My standalone hint picker; it links felis' own VT parser and cell # grid, so its label overlay lands on exactly the columns felis drew. spoor = inputs.spoor.packages.${pkgs.stdenv.hostPlatform.system}.default; # The pickers and hints live in a sibling file so this module # reads as configuration, not writeShellApplication bodies. inherit (import ./utilities.nix { inherit lib pkgs package neovim spoor ; }) felis-switch felis-kill felis-grep felis-scrollback felis-hints felis-nix-log ; # Moralerspace ships each style as its own flavor, not weights of # one family, so each style names its flavor. moralerspace = flavor: "Moralerspace ${flavor} HW"; font-features = [ "calt" "liga" "ss01" "ss02" "ss03" "ss05" "ss09" ]; in { imports = [ inputs.felis.homeManagerModules.felis ]; options.my.programs.felis.enable = lib.mkEnableOption "felis"; config = lib.mkIf config.my.programs.felis.enable { my.programs.coding-agents.skills.felis = "${inputs.felis}/skills/felis"; home.packages = [ felis-switch felis-kill felis-grep ]; programs.felis = { enable = true; inherit package; # Runs the module's relay (systemd user unit on Linux, launchd # agent on macOS) forwarding alerts to the platform notifier. notifications.enable = true; settings = { font = { family = moralerspace "Neon"; size = 14.0; features = font-features; # Each flavor is a distinct family, so felis' bold/italic # derivation from the primary can't reach them; name them. bold.family = moralerspace "Xenon"; italic.family = moralerspace "Radon"; bold_italic.family = moralerspace "Krypton"; }; theme = { fg = "#${palette.base05}"; bg = "#${palette.base00}"; cursor = "#${palette.base05}"; palette = { black = "#${palette.base00}"; red = "#${palette.base08}"; green = "#${palette.base0B}"; yellow = "#${palette.base0A}"; blue = "#${palette.base0D}"; magenta = "#${palette.base0E}"; cyan = "#${palette.base0C}"; white = "#${palette.base05}"; bright_black = "#${palette.base03}"; bright_red = "#${palette.base08}"; bright_green = "#${palette.base0B}"; bright_yellow = "#${palette.base0A}"; bright_blue = "#${palette.base0D}"; bright_magenta = "#${palette.base0E}"; bright_cyan = "#${palette.base0C}"; bright_white = "#${palette.base07}"; # extended base16 colors indexed = { "16" = "#${palette.base09}"; "17" = "#${palette.base0F}"; "18" = "#${palette.base01}"; "19" = "#${palette.base02}"; "20" = "#${palette.base04}"; "21" = "#${palette.base06}"; }; }; }; window = { decorations = false; }; keymap = { "¥" = { kind = "send_string"; text = "\\"; escapes = "none"; }; "shift+enter" = { kind = "send_string"; text = "\\e\\r"; escapes = "cstyle"; }; "ctrl+]" = { kind = "switch_next_session"; }; "ctrl+[" = { kind = "switch_previous_session"; }; "ctrl+shift+n" = { kind = "new_session"; }; # `run` launches the picker in a transient session over the # live grid; an absolute store path keeps it independent of # the daemon's minimal PATH. "ctrl+shift+p" = { kind = "run"; command = [ "${felis-switch}/bin/felis-switch" ]; }; "ctrl+shift+d" = { kind = "run"; command = [ "${felis-kill}/bin/felis-kill" ]; }; "ctrl+shift+g" = { kind = "run"; command = [ "${felis-grep}/bin/felis-grep" ]; }; # The default +h binding keeps the pager; this routes the # same scrollback region into Neovim instead. "ctrl+shift+e" = { kind = "pipe"; source = "scrollback"; command = [ "${felis-scrollback}/bin/felis-scrollback" ]; }; # Hints over the visible grid. `ansi` defaults to false # (plain), which is exactly what spoor wants — embedded # SGR would corrupt the URL match. "ctrl+shift+o" = { kind = "pipe"; source = "visible"; command = [ "${felis-hints}/bin/felis-hints" ]; }; # The kitty Ctrl+Shift+l port: page the build log of a # `nix log <drv>` shown on screen. "ctrl+shift+l" = { kind = "pipe"; source = "visible"; command = [ "${felis-nix-log}/bin/felis-nix-log" ]; }; }; }; }; }; }; }
3.3.14.1.1. Session and hint helpers#
The keymap points at six shell programs, none touching the module system; their
writeShellApplication bodies would bury the configuration if they sat in
default.nix, so they live in a sibling utilities.nix handed the felis
client, spoor, and Neovim. Besides felis-switch and the two hints above, it
holds felis-kill, the multi-select cousin that tears sessions down;
felis-grep, a cross-session scrollback search the built-in per-session facet
can't do; and felis-scrollback, which opens the scrollback pipe region
read-only in Neovim so editor motions replace the pager.
{ lib, pkgs, package, neovim, spoor, }: let # Wraps list/switch in an fzf picker: the built-in # switch_{next,previous}_session only steps in daemon order, so # jumping to a known session otherwise means retyping a 32-hex id. # The preview shows each session's live grid (capture --ansi), so # the choice is by content. felis-switch = pkgs.writeShellApplication { name = "felis-switch"; runtimeInputs = [ package pkgs.fzf pkgs.gawk ]; text = '' list=$(felis sessions list) if [ -z "$list" ] || [ "$list" = "no sessions" ]; then echo "no sessions" >&2 exit 0 fi # Drop this window's own session: switching to where you # already are is a no-op, so it only clutters the list. selection=$( printf '%s\n' "$list" \ | awk -v self="''${FELIS_SESSION_ID:-}" '$1 != self' \ | fzf --ansi \ --with-nth=2.. \ --prompt='felis session> ' \ --preview='felis sessions capture {1} --ansi 2>/dev/null || echo "(attached — preview unavailable)"' ) || exit 0 id=''${selection%% *} [ -n "$id" ] && felis sessions switch "$id" ''; }; # Multi-select cousin of felis-switch for tearing sessions down. # The same `capture --ansi` preview identifies a parked session by # its screen before it is killed. felis-kill = pkgs.writeShellApplication { name = "felis-kill"; runtimeInputs = [ package pkgs.fzf pkgs.gawk ]; text = '' list=$(felis sessions list) if [ -z "$list" ] || [ "$list" = "no sessions" ]; then echo "no sessions" >&2 exit 0 fi selection=$( printf '%s\n' "$list" \ | fzf --ansi \ --multi \ --with-nth=2.. \ --prompt='kill session(s)> ' \ --preview='felis sessions capture {1} --ansi 2>/dev/null || echo "(attached — preview unavailable)"' ) || exit 0 printf '%s\n' "$selection" \ | awk 'NF {print $1}' \ | while read -r id; do felis sessions kill "$id" done ''; }; # Cross-session scrollback grep; the built-in ctrl+shift+f facet # searches only the focused session. Dump every session's # scrollback, fuzzy-filter, then switch to the chosen line's # session. A session that refuses a capture is skipped, not fatal. felis-grep = pkgs.writeShellApplication { name = "felis-grep"; runtimeInputs = [ package pkgs.fzf pkgs.gawk ]; text = '' matches=$( felis sessions list \ | awk 'NF {print $1}' \ | while read -r id; do felis sessions capture "$id" --scrollback 2>/dev/null \ | awk -v id="$id" 'NF {print id"\t"$0}' done ) if [ -z "$matches" ]; then echo "no scrollback" >&2 exit 0 fi selection=$( printf '%s\n' "$matches" \ | fzf --delimiter='\t' --with-nth=2.. --prompt='grep scrollback> ' ) || exit 0 id=$(printf '%s' "$selection" | awk -F'\t' '{print $1; exit}') [ -n "$id" ] && felis sessions switch "$id" ''; }; # Target for the scrollback `pipe` keybind. felis writes the region # to a temp file and passes the path as the last argv slot (stdin is # the transient session's tty), so open it read-only in Neovim for # editor motions instead of a pager. `-R` plus noswapfile keeps it a # throwaway view; `G` lands at the newest line. felis-scrollback = pkgs.writeShellApplication { name = "felis-scrollback"; runtimeInputs = [ neovim ]; text = '' exec nvim -R \ -c 'setlocal noswapfile' \ -c 'normal! G' \ "''${1:?no region file}" ''; }; # kitty-style URL hints for the plain-text URLs felis won't make # clickable (only OSC 8 links are). spoor reads the visible region # from the temp-file path in the last argv slot, keeping stdin free # for its /dev/tty overlay; `--action open` hands the pick to the # platform opener. Open-only: the transient pipe session sees only # its own FELIS_SESSION_ID, not the originating window's, so a pick # can't be routed back to the prompt. felis-hints = pkgs.writeShellApplication { name = "felis-hints"; runtimeInputs = [ spoor ] ++ lib.optional pkgs.stdenv.hostPlatform.isLinux pkgs.xdg-utils; text = '' exec spoor --preset url --action open "''${1:?no region file}" ''; }; # felis port of the kitty `nix log` hint: pick a `nix log <drv>` on # screen and page its build log. grep + fzf rather than a label # overlay because a hint picker can't disable its built-in path # matchers and the point is matching *only* the drv. A single # failed-build drv skips the picker entirely. felis-nix-log = pkgs.writeShellApplication { name = "felis-nix-log"; runtimeInputs = [ pkgs.nix pkgs.fzf pkgs.gnused pkgs.gawk pkgs.less ]; text = '' mapfile -t drvs < <( grep -oE 'nix log /nix/store/[a-z0-9]{32}-[^ ]+\.drv' "''${1:?no region file}" \ | sed 's/^nix log //' \ | awk '!seen[$0]++' ) case ''${#drvs[@]} in 0) echo "no 'nix log <drv>' on screen" >&2; exit 0 ;; 1) drv=''${drvs[0]} ;; *) drv=$(printf '%s\n' "''${drvs[@]}" | fzf --prompt='nix log> ') || exit 0 ;; esac [ -n "$drv" ] && nix log "$drv" | less -R ''; }; in { inherit felis-switch felis-kill felis-grep felis-scrollback felis-hints felis-nix-log ; }
3.3.15. Darwin#
3.3.15.1. Spotlight#
I don't use Spotlight search, yet its indexer (mdworker) keeps consuming CPU, disk
I/O, and disk space in the background, so I turn indexing off by default. Hosts that
want it back can set enableIndex = true. nix-darwin has no option for this, so the
module drives mdutil from an activation script.
{ ... }: { flake.modules.darwin.spotlight = { config, lib, ... }: let cfg = config.my.services.spotlight; in { <<spotlight-options>> <<spotlight-config>> }; }
3.3.15.1.1. Options#
options.my.services.spotlight = { enableIndex = lib.mkOption { type = lib.types.bool; default = false; description = "Whether Spotlight indexes the volumes on this machine."; }; };
3.3.15.1.2. Configuration#
mdutil targets all volumes with -a rather than just /: modern macOS splits the
boot disk into a read-only System volume (/) and a writable Data volume, so
mdutil -i off / silently leaves the Data volume indexing. When disabling,
mdutil -E also erases the orphaned index stores to reclaim disk space.
config = { system.activationScripts.extraActivation.text = if cfg.enableIndex then '' echo "enabling spotlight indexing..." mdutil -i on -a &> /dev/null '' else '' echo "disabling spotlight indexing..." mdutil -i off -d -a &> /dev/null mdutil -E -a &> /dev/null ''; };
3.4. Scripts#
Scripts used in flake derivations, pre-commit hooks, and Makefile recipes. Extracted to separate files for proper syntax highlighting and independent invocability.
org-to-html.el drives the documentation export. Four choices shape it. First,
htmlize emits org-* class names rather than inline colors, so the palette lives
in assets/org-html.css and the dark theme can restyle code; that stylesheet and
the script beside it (assets/org-html.js, which adds a breadcrumb bar, an
on-this-page rail, and per-block copy buttons) are inlined into each page, so an
exported page makes no external request. Second, tree-sitter fontifies Nix
blocks, which is why nix-ts-mode and its grammar are loaded before export.
Third, every code block that tangles is headed by a link to the file it produces.
Literate configuration only pays off if a reader can get from the prose to the
real artifact, and nothing in an exported block says where it lands. Recovering
that is harder than it looks: #+INCLUDE splices the child documents into this
one and discards where each block came from, while :tangle paths are written
relative to the document that declares them, and the candidate paths that remain
are not always distinguishable by which one exists. So the export reads each
document on its own, where its directory is still known, and keys the result on
the block body.
Fourth, noweb references link to the block that defines them, and a referenced
block is headed by its own name. A tangled file is assembled from expansions
scattered across the whole document, so following one by reading is otherwise a
search. The reference is swapped for a placeholder before fontification and
restored afterwards, because nix-ts-mode reads a reference as a search path and
scatters it over syntax spans that no rewriting of the fontified markup can
reliably put back together.
(require 'cl-lib) (require 'org) (require 'ox-html) (require 'htmlize) (require 'nix-ts-mode) (add-to-list 'org-src-lang-modes '("nix" . nix-ts)) (setq treesit-font-lock-level 4) ;; Emit `org-*' class names on syntax spans instead of inline color styles, so ;; assets/org-html.css owns the palette and can restyle code for dark mode. ;; Inline colors would freeze one theme into the markup. (setq org-html-htmlize-output-type 'css) ;; Export five headline levels as real headings (default is 3). Deeper sections ;; would otherwise become plain lists with no anchor, and the sticky breadcrumb ;; only pins actual headings; five keeps every level within valid h2..h6 tags. (setq org-export-headline-levels 5) ;; The frame styling is my own; drop Org's default <style> and scripts. (setq org-html-head-include-default-style nil org-html-head-include-scripts nil) ;; Don't abort the whole documentation build over one dangling link. The ;; Japanese document is generated by po4a, so a paragraph that is still ;; untranslated falls back to English while its target heading is already ;; localized, leaving a fuzzy link with no matching anchor. Export it as plain ;; text instead of erroring so translation lag never takes the site down. (setq org-export-with-broken-links t) ;; Inline the stylesheet and script rather than <link>/<script src>-ing them, so ;; each exported file (index.html and ja/index.html live at different depths) is ;; self-contained and needs no path juggling or extra request. The script only ;; enhances (breadcrumb bar, on-this-page rail, copy buttons); the page is ;; readable without it. (defun dotfiles-slurp (path) (with-temp-buffer (insert-file-contents path) (buffer-string))) (setq org-html-head (concat "<style>\n" (dotfiles-slurp "assets/org-html.css") "</style>\n" "<script>\n" (dotfiles-slurp "assets/org-html.js") "</script>")) ;; Fold the table of contents: rewrite Org's #table-of-contents <div> into a ;; native <details> so the long, deeply nested TOC does not push the body down. ;; The block contains only list markup, so the first "</div></div>" reliably ;; closes it. The title capture keeps this language-agnostic (en/ja). (defun dotfiles-html-collapse-toc (output _backend _info) (replace-regexp-in-string (concat "<div id=\"table-of-contents\"[^>]*>\n" "<h2>\\([^<]*\\)</h2>\n" "<div id=\"text-table-of-contents\"[^>]*>\n" "\\(\\(?:.\\|\n\\)*?\\)\n" "</div>\n</div>") (concat "<details id=\"table-of-contents\" class=\"toc\" open>\n" "<summary>\\1</summary>\n" "<div id=\"text-table-of-contents\" role=\"doc-toc\">\n" "\\2\n</div>\n</details>") output t)) (add-to-list 'org-export-filter-final-output-functions #'dotfiles-html-collapse-toc) ;; Head every code block with the file it tangles to, linked to GitHub. ;; ;; The target cannot be read off the block at export time. `#+INCLUDE' splices ;; the child documents into configuration.org and leaves no trace of where each ;; block came from, while `:tangle' paths are relative to the document that ;; declares them (modules/configuration.org writes "features/nix.nix" for ;; modules/features/nix.nix). Resolving by "which candidate path exists" is ;; ambiguous: overlays/configuration.org tangles to "default.nix", and both ;; overlays/default.nix and modules/default.nix are real files. So read each ;; document separately, while its directory is still known, and key on the block ;; body, which #+INCLUDE copies verbatim. (defconst dotfiles-source-url "https://github.com/natsukium/dotfiles/blob/main/") ;; Documents that declare `:tangle' targets, and the prefix their paths are ;; relative to; mirrors the addprefix calls in the Makefile's tangle rules. ;; modules/features/emacs/{early-init,init}.org are left out on purpose: their ;; blocks carry no `:tangle' header, and Nix assembles them into init.el at ;; build time, so no file in the repository corresponds to them. (defconst dotfiles-tangle-sources '(("configuration" . "") ("modules/configuration" . "modules/") ("overlays/configuration" . "overlays/"))) (defvar dotfiles-tangle-map nil) (defun dotfiles-src-block-target (src-block prefix) "Repo-relative file SRC-BLOCK tangles to, or nil when it tangles nowhere." (let ((params (or (org-element-property :parameters src-block) ""))) ;; `\s-' rather than a literal space on purpose: the Makefile finds what to ;; tangle by grepping this document for a `:tangle' header followed by a ;; space, so a space here would offer it this regexp as a file to build. (when (string-match ":tangle\\s-+\\([^ ]+\\)" params) ;; Capture before the next `string-match' overwrites the match data. (let ((target (match-string 1 params))) (unless (or (equal target "no") ;; Never exported, so never rendered. Skipping these also ;; keeps the boilerplate stubs, which repeat verbatim across ;; documents, from colliding in the map. (string-match ":exports\\s-+none" params)) (concat prefix target)))))) (defun dotfiles-build-tangle-map (suffix) "Map each exported block body to the file it tangles to. SUFFIX is \"\" for the English document and \".ja\" for the translated one. The map is rebuilt per language because po4a translates comments inside code, which changes the body it is keyed on." (let ((map (make-hash-table :test 'equal))) (dolist (source dotfiles-tangle-sources map) (with-current-buffer (find-file-noselect (concat (car source) suffix ".org")) (org-element-map (org-element-parse-buffer) 'src-block (lambda (src-block) (let ((target (dotfiles-src-block-target src-block (cdr source)))) (when target (let* ((body (org-element-property :value src-block)) (previous (gethash body map))) ;; Keying on the body is only sound while no two exported ;; blocks share one. Fail loudly rather than link to the wrong ;; file if that ever stops holding. (when (and previous (not (equal previous target))) (error "Blocks tangling to %s and %s have identical bodies" previous target)) (puthash body target map)))))))))) ;; Turn noweb references into links to the block that defines them, so the ;; expansions a tangled file is assembled from can be followed by reading. (defvar dotfiles-noweb-names nil "Block names that unambiguously identify one block in the current export.") (defun dotfiles-collect-noweb-names (tree _backend _info) "Record every block name defined exactly once in TREE. A name used twice cannot be anchored: names are scoped to the document that declares them, but #+INCLUDE merges every document into one page, where an id has to be unique. nix-config, defined by both configuration.org and modules/configuration.org, is the only one here, and stays unlinked." (let ((counts (make-hash-table :test 'equal))) (org-element-map tree 'src-block (lambda (src-block) (let ((name (org-element-property :name src-block))) (when name (puthash name (1+ (gethash name counts 0)) counts))))) (setq dotfiles-noweb-names (make-hash-table :test 'equal)) (maphash (lambda (name count) (when (= count 1) (puthash name t dotfiles-noweb-names))) counts)) tree) (add-to-list 'org-export-filter-parse-tree-functions #'dotfiles-collect-noweb-names) (defconst dotfiles-noweb-token "nowebref0%s0" "Stand-in for a noweb reference while its block is fontified.") (defun dotfiles-noweb-anchor (name) "Element id for the block that defines noweb reference NAME. Namespaced because a block is usually named after the section documenting it, so its name and that heading's slug are the same string. Headings win the bare name: theirs are the URLs worth sharing." (concat "noweb-" name)) (defun dotfiles-noweb-html (name) "Rendered reference to NAME, linked when NAME is anchored." (if (gethash name dotfiles-noweb-names) (format "<a class=\"noweb-ref\" href=\"#%s\"><<%s>></a>" (dotfiles-noweb-anchor name) name) (format "<<%s>>" name))) (defun dotfiles-fontify-noweb (original code lang &rest args) "Swap noweb references out of CODE, fontify, then restore them as links. Rewriting the fontified markup instead is not viable: nix-ts-mode reads a reference as a search path and splits it across spans seven different ways depending on the surrounding syntax, and three of those straddle span boundaries, so replacing them would unbalance the markup. A bare identifier is a single token in any grammar, so it survives fontification in one piece and can be swapped back without touching a tag." (let (names) (setq code (replace-regexp-in-string "<<\\([a-zA-Z0-9_-]+\\)>>" (lambda (match) (let ((name (match-string 1 match))) (push name names) (format dotfiles-noweb-token name))) code t t)) (let ((html (apply original code lang args))) (dolist (name (delete-dups names) html) (setq html (replace-regexp-in-string (regexp-quote (format dotfiles-noweb-token name)) (dotfiles-noweb-html name) html t t)))))) (advice-add 'org-html-fontify-code :around #'dotfiles-fontify-noweb) (defconst dotfiles-src-lang-names '(("emacs-lisp" . "elisp")) "Header text for languages whose Org name reads poorly.") (defconst dotfiles-src-container "<div class=\"org-src-container\">\n") (defun dotfiles-html-src-block-head (original src-block contents info) "Prepend a header naming what SRC-BLOCK produces. A block that tangles links to its file, a block that noweb references point at shows and anchors that name, and the rest name their language." (let ((html (funcall original src-block contents info))) (if (not (string-prefix-p dotfiles-src-container html)) html (let* ((target (gethash (org-element-property :value src-block) dotfiles-tangle-map)) (name (org-element-property :name src-block)) (anchor (and name (gethash name dotfiles-noweb-names) name)) (language (org-element-property :language src-block)) (label (cond (target (format "<a class=\"src-path\" href=\"%s%s\">%s</a>" dotfiles-source-url target (org-html-encode-plain-text target))) (anchor (format "<span class=\"src-noweb\"><<%s>></span>" (org-html-encode-plain-text anchor))) (t (format "<span class=\"src-lang\">%s</span>" (org-html-encode-plain-text (or (cdr (assoc language dotfiles-src-lang-names)) language ""))))))) (concat dotfiles-src-container (if anchor (format "<div class=\"src-head\" id=\"%s\">" (dotfiles-noweb-anchor anchor)) "<div class=\"src-head\">") label "</div>\n" (substring html (length dotfiles-src-container))))))) (advice-add 'org-html-src-block :around #'dotfiles-html-src-block-head) ;; Anchor headings by a slug of their title instead of the generated org<hash>. ;; Those hashes are derived from buffer positions, so every edit above a heading ;; renames it and any URL anyone saved stops working. A slug also says where it ;; lands. Org routes heading ids, the table of contents and internal [[*Heading]] ;; links through `org-export-get-reference', so overriding it moves all three ;; together and nothing is left pointing at the old id. (defvar dotfiles-slug-by-heading nil) (defvar dotfiles-slug-taken nil) (defun dotfiles-slugify (text) "Lowercase TEXT with runs of non-alphanumerics turned into single hyphens. Japanese headings survive this: `[:alnum:]' matches CJK, so the translated document gets readable anchors of its own rather than empty ones." (let ((slug (downcase text))) (setq slug (replace-regexp-in-string "[^[:alnum:]]+" "-" slug)) (replace-regexp-in-string "^-+\\|-+$" "" slug))) (defun dotfiles-title-slug (headline) "Slug of HEADLINE's own title, ignoring where it sits in the outline." (let ((slug (dotfiles-slugify (substring-no-properties (org-element-interpret-data (org-element-property :title headline)))))) (if (string-empty-p slug) "section" slug))) (defun dotfiles-heading-slug (headline) "Slug for HEADLINE, qualified by its parent. Every heading is named parent-then-self rather than only the ones that would otherwise clash. Sixteen titles repeat in this document, and naming a heading after itself alone means whether it keeps that name depends on what else exists: adding a second \"Configuration\" earlier renames the one already published. Including the parent makes a heading's URL a function of its own title and its parent's, so an edit elsewhere in the document cannot rename it. It also empties the collision set outright, leaving the numeric suffix below unreachable in practice — it stays for the case of two same-named siblings." (let* ((self (dotfiles-title-slug headline)) (parent (org-element-parent headline)) (base (if (and parent (eq (org-element-type parent) 'headline)) (concat (dotfiles-title-slug parent) "-" self) self)) (slug base) (suffix 2)) (while (gethash slug dotfiles-slug-taken) (setq slug (format "%s-%d" base suffix)) (setq suffix (1+ suffix))) (puthash slug t dotfiles-slug-taken) slug)) ;; Slugs are assigned here, in one pass over the finished tree, rather than when ;; Org first asks for a reference. Org resolves references lazily, so a link ;; transcoded early can claim a name ahead of the heading that reads best with ;; it: left to that order a sixth-level "Configuration" took the bare ;; `configuration' and the top-level chapter became `configuration-2'. Walking ;; the tree in document order makes the result depend on the document alone. (defvar dotfiles-english-headings nil "The English document's headings as (SLUG . LEVEL), in document order.") (defvar dotfiles-translating nil "Non-nil while exporting an edition that should reuse the English anchors.") (defun dotfiles-assign-heading-slugs (tree _backend _info) "Give every heading in TREE its slug, outermost and earliest first. A translation is anchored by the English slugs rather than its own, so the same fragment addresses the same section in both editions and switching language is a matter of inserting the language into the path. po4a translates the document in place and cannot add or drop a heading, which is what makes matching them up by position sound — and is checked below, because a shifted match would silently point every anchor at the wrong section." (setq dotfiles-slug-by-heading (make-hash-table :test 'eq)) (setq dotfiles-slug-taken (make-hash-table :test 'equal)) (let ((headings (org-element-map tree 'headline #'identity))) (if (not dotfiles-translating) (progn (dolist (headline headings) (let ((slug (or ;; An explicit :CUSTOM_ID: is the author pinning a URL. (org-element-property :CUSTOM_ID headline) (dotfiles-heading-slug headline)))) (puthash headline slug dotfiles-slug-by-heading))) (setq dotfiles-english-headings (mapcar (lambda (headline) (cons (gethash headline dotfiles-slug-by-heading) (org-element-property :level headline))) headings))) (unless (= (length headings) (length dotfiles-english-headings)) (error "Translation has %d headings, English has %d" (length headings) (length dotfiles-english-headings))) (cl-loop for headline in headings for (slug . level) in dotfiles-english-headings do (unless (= (org-element-property :level headline) level) (error "Translated outline diverges at %s" slug)) do (puthash headline slug dotfiles-slug-by-heading)))) tree) (add-to-list 'org-export-filter-parse-tree-functions #'dotfiles-assign-heading-slugs) (defun dotfiles-export-get-reference (original datum info) "Give headings a slug reference, leaving every other element to ORIGINAL." (or (and (eq (org-element-type datum) 'headline) (gethash datum dotfiles-slug-by-heading)) (funcall original datum info))) (advice-add 'org-export-get-reference :around #'dotfiles-export-get-reference) ;; A heading is only linkable if the reader can get at its URL, so each one ends ;; with a link to itself. It is styled to appear on hover or keyboard focus. (defun dotfiles-html-headline-anchor (original headline contents info) "Append a self link to HEADLINE's heading tag. The heading tag is found by search rather than at the start of the string, because Org wraps it in an outline-container div." (let ((html (funcall original headline contents info))) (if (not (string-match "<h\\([1-6]\\) id=\"\\([^\"]+\\)\">" html)) html (let* ((level (match-string 1 html)) (id (match-string 2 html)) (closing (concat "</h" level ">")) (position (string-search closing html))) (if (not position) html (concat (substring html 0 position) "<a class=\"heading-anchor\" href=\"#" id "\"" " aria-label=\"Link to this section\">#</a>" (substring html position))))))) (advice-add 'org-html-headline :around #'dotfiles-html-headline-anchor) (defun dotfiles-export-html (lang) "Export the LANG edition of the configuration document." ;; po4a names the translations configuration.<lang>.org; English is the ;; untranslated original and carries no infix. (let ((suffix (if (equal lang "en") "" (concat "." lang)))) (setq dotfiles-translating (not (equal lang "en"))) (when (and dotfiles-translating (null dotfiles-english-headings)) (error "Export English before %s: its anchors come from the original" lang)) (setq dotfiles-tangle-map (dotfiles-build-tangle-map suffix)) (find-file (concat "configuration" suffix ".org")) (org-html-export-to-html))) ;; Which editions to export, set by scripts/build-html.sh. Building English ;; alone is the fast path while iterating on prose or styling: it skips both the ;; second export and the po4a run that would have to precede it. (dolist (lang (split-string (or (getenv "DOTFILES_HTML_LANGS") "en ja"))) (dotfiles-export-html lang))
build-html.sh wraps that export so the same command produces the published
site and a local preview. The build used to live in the Nix derivation, which
made previewing a stylesheet edit cost a full nix build: around a minute of
sandbox setup and source copying to wrap an export that takes seven seconds.
Now the derivation only calls this script, and iterating locally runs it
directly.
Emacs writes each page beside its source document, so the script moves the results into an output directory and deletes what Emacs left behind; kept where they land, they would show up as untracked files after every build.
set -euo pipefail output=build langs="en ja" while [ $# -gt 0 ]; do case $1 in -o | --output) output=$2 shift 2 ;; -l | --langs) langs=$2 shift 2 ;; *) echo "usage: build-html.sh [--output DIR] [--langs 'en ja']" >&2 exit 1 ;; esac done cd "$(dirname "$0")/.." # Translations are anchored by the English slugs, so English is always built and # always built first. Asking for a translation alone would otherwise fail. translations=$(tr ' ' '\n' <<<"$langs" | grep -vx -e en -e '' | tr '\n' ' ' || true) langs="en $translations" # po4a regenerates the translated Org documents from po/ja.po, so only a build # that includes a translation needs it. Skipping it is what lets an # English-only build run outside the dev shell, the only place po4a is # installed. if [ -n "$translations" ]; then po4a po4a.cfg fi DOTFILES_HTML_LANGS="$langs" emacs --batch -l scripts/org-to-html.el for lang in $langs; do if [ "$lang" = en ]; then exported=configuration.html published=$output/index.html else exported=configuration.$lang.html published=$output/$lang/index.html fi # mkdir then install, rather than install -D, because BSD install has no -D # and this runs on macOS as well as in the Linux sandbox. mkdir -p "$(dirname "$published")" install -m644 "$exported" "$published" rm -f "$exported" done
# Usage: check-git-changes <message> [git-diff-args...] # Exits 1 if git diff finds changes, with instructions to stage them. set -euo pipefail message="$1" shift changed=$(git diff --name-only "$@") if [ -n "$changed" ]; then echo "$message" echo "Changed files:" echo "$changed" echo "" echo "Please stage the changes and commit again:" echo " git add $changed" exit 1 fi
set -euo pipefail po4a po4a.cfg check-git-changes "po4a updated translation files." -- po/ '*.ja.org'
set -euo pipefail make -B tangle -j check-git-changes "Org files were out of sync and have been auto-tangled."
(require 'ox-md) (re-search-forward "^\\* Philosophy") (org-md-export-to-markdown nil t)
The org-export-with-author setting is explicitly disabled because
configuration.org has no #+AUTHOR: keyword, so Org falls back to the
Emacs variable user-full-name. On macOS, this variable is populated from
the system directory service (dscl / getpwuid), producing an unwanted
#+author: line. On Linux, especially in CI environments, the value is
typically empty, so the line is omitted. Disabling the setting ensures
consistent output across platforms.
(require 'ox-org) (let ((org-export-select-tags (list "readme")) (org-export-with-author nil) (org-export-with-tags nil) (org-export-time-stamp-file nil)) (org-export-to-file 'org export-readme-dest))
4. Development#
This repository provides a Nix development shell with all the tools needed for working on the configurations. Enter the shell by running:
nix develop
The shell includes infrastructure tools (Terraform, sops, ssh-to-age),
translation tools (po4a, gettext), and build utilities (nix-fast-build).
On entry, it automatically sets up pre-commit hooks, configures MCP servers,
and syncs CLAUDE.md from the literate source.
{ ... }: { perSystem = { config, pkgs, ... }: let terraform' = pkgs.terraform.withPlugins (p: [ p.carlpett_sops p.cloudflare_cloudflare p.determinatesystems_hydra p.hashicorp_aws p.hashicorp_external p.hashicorp_null p.integrations_github p.oracle_oci ]); in { devShells = { default = pkgs.mkShell { packages = with pkgs; [ aws-vault nix-fast-build sops ssh-to-age terraform' <<translation-packages>> ]; shellHook = config.pre-commit.installationScript + config.mcp-servers.shellHook + '' echo "Syncing CLAUDE.md..." make CLAUDE.md >/dev/null 2>&1 || echo "Warning: Failed to generate CLAUDE.md" ''; }; terraform = pkgs.mkShell { packages = [ terraform' ]; }; }; }; }
4.1. Pre-commit hooks#
Hooks run by git-hooks.nix on every commit. prek is used as the runner
because it is the actively-maintained Rust port of pre-commit, with a faster
cold start and parallel hook execution out of the box.
check-org-tangle is the only hook with priority = 0 because it must run
before everything else: if Org files are out of sync, formatters and linters
downstream would otherwise fail against stale tangle output. Every other hook
keeps the default priority and runs in parallel under prek.
{ inputs, ... }: { imports = [ inputs.git-hooks.flakeModule ]; perSystem = { pkgs, ... }: { pre-commit = { check.enable = true; settings = { package = pkgs.prek; src = ../..; hooks = let check-git-changes = pkgs.writeShellApplication { name = "check-git-changes"; runtimeInputs = [ pkgs.git ]; text = builtins.readFile ../../scripts/check-git-changes.sh; }; emacs-with-org = (pkgs.emacsPackagesFor pkgs.emacs).emacsWithPackages (epkgs: [ epkgs.org ]); in { actionlint = { enable = true; priority = 10; }; oxlint = { enable = true; priority = 10; }; lua-ls = { enable = false; priority = 10; }; nil = { enable = true; priority = 10; }; shellcheck = { enable = true; priority = 10; }; treefmt = { enable = true; priority = 10; }; typos = { enable = true; priority = 10; excludes = [ ".sops.yaml" "homes/shared/gpg/keys.txt" "secrets.yaml" "secrets/default.yaml" "hosts/nixos/tarangire/facter.json" "systems/shared/hercules-ci/binary-caches.json" ]; settings.configPath = "typos.toml"; }; yamllint = { enable = true; priority = 10; excludes = [ "secrets/default.yaml" "secrets.yaml" ]; settings.configData = "{rules: {document-start: {present: false}}}"; }; po4a = { enable = true; name = "po4a"; description = "Update translations with po4a"; priority = 10; entry = pkgs.lib.getExe ( pkgs.writeShellApplication { name = "check-po4a"; runtimeInputs = [ pkgs.po4a pkgs.gettext check-git-changes ]; text = builtins.readFile ../../scripts/check-po4a.sh; } ); files = "(\\.org|po/.*\\.po)$"; pass_filenames = false; }; "check-org-tangle" = { enable = true; name = "check-org-tangle"; description = "Verify org files are tangled and synchronized"; # Ensure this hook runs before all other hooks priority = 0; entry = pkgs.lib.getExe ( pkgs.writeShellApplication { name = "check-org-tangle"; runtimeInputs = [ emacs-with-org pkgs.gnumake check-git-changes ]; text = builtins.readFile ../../scripts/check-org-tangle.sh; } ); files = "\\.org$"; pass_filenames = false; }; }; }; }; }; }
4.2. Formatting#
Formatters are aggregated via treefmt-nix and invoked from the treefmt
pre-commit hook, so a single declaration covers both editor integrations and
the commit-time check.
{ inputs, ... }: { imports = [ inputs.treefmt-nix.flakeModule ]; perSystem = _: { treefmt = { projectRootFile = "flake.nix"; programs = { oxfmt.enable = true; nixfmt.enable = true; shfmt.enable = true; stylua.enable = true; taplo.enable = true; terraform.enable = true; yamlfmt.enable = true; }; }; }; }
4.3. MCP Servers#
Configuration for mcp-servers-nix, enabled in the development shell.
The flavors.claude-code preset generates a .mcp.json file compatible with
Claude Code's expected format.
Enabled servers:
nixos: NixOS package/option search and Home Manager documentationterraform: Terraform registry lookup for providers, modules, and policiesgrafana: Query dashboards, datasources, and metrics from the home server's Grafana instance
The passwordCommand option retrieves secrets at runtime using rbw (Bitwarden CLI),
avoiding plaintext credentials in the repository.
{ inputs, ... }: { imports = [ inputs.mcp-servers.flakeModule ]; perSystem = _: { mcp-servers = { flavors.claude-code.enable = true; programs = { nixos.enable = true; terraform.enable = true; grafana = { enable = true; env = { GRAFANA_URL = "http://monitor.home.natsukium.com"; GRAFANA_USERNAME = "admin"; }; passwordCommand = { GRAFANA_PASSWORD = [ "rbw" "get" "grafana" ]; }; }; }; }; }; }
4.4. Translation#
This project uses po4a to manage translations.
4.4.1. Requirements#
The required packages are included in the development shell.
gettext po4a
gettext: provides msgfmt and other internationalization utilitiespo4a: po4a >= 0.74 is required for Org mode support.
4.4.2. Translation workflow#
4.4.2.1. Create po4a configuration#
Configure the target language,
location for generated po files,
and documents
to translate as follows.
The -k 0 option forces output of translated files
even if the translation is incomplete
(default threshold is 80%).
[po4a_langs] ja [po4a_paths] po/dotfiles.pot $lang:po/$lang.po [type: org] configuration.org $lang:configuration.$lang.org opt:"-k 0" [type: org] .github/README.org $lang:.github/README.$lang.org opt:"-k 0" [type: org] modules/features/emacs/init.org $lang:modules/features/emacs/init.$lang.org opt:"-k 0" [type: org] modules/features/emacs/early-init.org $lang:modules/features/emacs/early-init.$lang.org opt:"-k 0" [type: org] overlays/configuration.org $lang:overlays/configuration.$lang.org opt:"-k 0" [type: org] modules/configuration.org $lang:modules/configuration.$lang.org opt:"-k 0"
For detailed information about po4a.cfg configuration, see man po4a.
4.4.2.2. Create/Update po#
When documents are updated and you need to create/update po files,
run the following command.
This generates template (pot) and po files for each language
at the paths configured in po4a.cfg.
po4a --no-translations po4a.cfg
4.4.2.3. Translate#
Edit the target language po using a po editor. Popular options include Emacs po-mode, poedit, GNOME's Gtranslator, and KDE's Lokalize.
4.4.2.4. Create/Update translation file#
After completing translations, generate files with the following command. Since po files are also updated at this time, in practice you only need to run this command.
po4a po4a.cfg