15 KiB
15 KiB
dotfiles-nixos
One flake that builds every machine the user owns.
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in .claude/CONTEXT.md; the current deliverable's spec is .claude/spec/laptop-mvi.md.
Conventions
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
End the comment with a
— Claudesign-off. (A dedicated bot account may replace this later; until then, the sign-off is the only marker.) - Commit messages follow Conventional Commits, specified in
docs/conventional-commits.md. Scope is the module or host the change belongs to (fish,nvim,neogaia), omitted for repo-wide changes. Keep messages free of Gitea-specific references: this repository is mirrored to GitHub, where issue and pull-request numbers resolve to unrelated things. - When a graphical application is added, give it a
window-rewriteicon mapping inmodules/desktop/waybar.nix. Without one its windows fall back to the generic default glyph on the workspace indicator instead of showing a recognisable per-application icon. Match on the window class, whichhyprctl clients -j | jq -r '.[].class' | sort -ulists for the running session.
Gotchas
- This repo is developed on
neogaia, which now runs the NixOS it builds. Flakes and the chaotic substituter come from this flake's ownnix.settings, so noNIX_CONFIGexport or per-command--extra-experimental-featuresis needed, and building a toplevel withboot.kernelPackages = linuxPackages_cachyosfetches the kernel fromnyx-cacherather than compiling it. Both were true only while the machine still ran CachyOS against a distro Nix daemon. - The substituters a
nix buildfetches from are the daemon's (/etc/nix/nix.conf), not thenix.settingsof the config being built — those only govern the built system. The two coincide here because the dev host runs this flake; they diverge on any machine that does not. - Git identity is declared in the flake by
modules/git.nix, which writesalexion <contact@alexion.dev>— the identity all history uses — on any host enablingmodules.git. Every new host has to enable it, so that a host reads as a full checklist of what it carries. It is deployed onneogaiaand verified: a commit in a repository outside this checkout is authoredalexion <contact@alexion.dev>with no override. Verify it that way rather than from this checkout, whose.git/configcarries the same identity and would mask a broken module. Home-manager writes~/.config/git/config, and~/.gitconfigis a second global file that git also reads, outranking it on any key set in both.~/.gitconfigcurrently holds only ateacredential helper and nouser.*, so it does not shadow the identity, but it is undeclared and will not survive a reimage. git config --globalis a listing and writing filter over~/.gitconfigalone, not a view of what git resolves. With both global files present it prints only~/.gitconfig, which reads as proof that~/.config/git/configis being ignored entirely. It is not: drop--globaland both files appear, each key resolving to the last file that sets it. Agit config --global <key> <value>write also lands in~/.gitconfig, the file that outranks the flake-managed one.- The primary build/verify seam for any Host is
nix flake check, which buildschecks.x86_64-linux.<host>(the system toplevel); cheap targeted checks usenix eval .#nixosConfigurations.<host>.config.... - A flake only sees git-tracked files, so a new file that has not been
git added is invisible to evaluation even though it exists on disk. The failure names the path and reads as if the file were missing:error: Path 'secrets/shared.yaml' does not exist in Git repository. Staging is enough; the file need not be committed. - chaotic-nyx must not follow our
nixpkgs, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults toonTopOf = "flake-nixpkgs", the cache-friendly path). That is what lets thenyx-cache.chaotic.cxbinary cache hit instead of compiling the CachyOS kernel from source; the tradeoff is that chaotic packages do not see ourunstable/stableoverlays. - The remote is self-hosted Gitea (
git.alexion.dev), and the forge CLI isgitea-axirather thantea.gitea-axiresolves the repository from theoriginremote and discovers credentials from atealogin whose host matches the remote, so both are implicit inside a checkout. It is installed onneogaiabymodules.gitea-axi, and verified:gitea-axirun from this checkout renders thealexion/dotfilesdashboard authenticated, so the claude-codeSessionStarthook that runs it now resolves to a real binary rather than a missing one. The package wraps the binary sogitandteaare reachable without being onPATH, while still preferring the operator's own where present. Credentials:~/.config/tea/config.ymlholds a token-bearing login namedalexion, whichgitea-axiuses and which also opens pull requests directly withnix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> .... The--repoflag is required on that path, sincetearesolvesoriginonly for a login whose SSH host matches. The same token reads PR discussion, whichteaitself does poorly:tea pr <n> --commentsprints only the body, and-f commentsreturns no comments field at all. Use the API instead, taking the token from.logins[] | select(.name=="alexion") | .token. Review comments are not at/issues/<n>/comments— that endpoint holds only top-level discussion and is usually empty. Inline comments need two calls:/pulls/<n>/reviewsfor the review ids, then/pulls/<n>/reviews/<id>/commentsfor the bodies, whosepathanddiff_hunkfields say what each one is attached to. A review row with an emptybodyis the normal shape when the operator left only inline comments. - SSH host keys (
ssh_host_<type>_key, served by the daemon from/etc/sshor a secret) are not user authentication keys (~/.ssh/id_ed25519, offered to a remote server). Thessh_host_prefix is OpenSSH's own name for the former, and theroot@<host>trailing field in a.pubis a free-text comment stamped byssh-keygenat generation time, not a claim about which account uses the key. On this machine the two are provably distinct: the daemon presentsSHA256:2ysuBX0+Z6GbdCTujz5JHX6rqnJzIyWhYNrxdhhGwEM, while pushes togit.alexion.devauthenticate withSHA256:nEhHwtHDnLlsuFxyfp+cETgHUZ8xDMxaPVmYM5vuCkA. Renaming host keys after user keys, or vice versa, is therefore always wrong. ~/.claude/skillsis generated by home-manager withrecursive = true, so the directories are real and writable but every leaf file is a read-only symlink into the store. Editing a skill in place fails; its source ismodules/claude-code/skills/<name>/here, applied by a rebuild. Creating a new file under~/.claude/skills/succeeds silently and is the trap — it stays outside the repo and reaches no other machine. Copying out of that tree needscp -rLpluschmod -R u+w: a plaincp -rcopies the symlinks, putting store paths into the destination, and dereferenced files keep the store's read-only mode.home-manager.users.<user>.home.fileis keyed by absolute path, not by a path relative to the home directory. Evaluatinghome.file.".claude/CLAUDE.md"fails with "does not provide attribute"; the working key ishome.file."/home/alexion/.claude/CLAUDE.md". List the real keys withnix eval --json .#nixosConfigurations.<host>.config.home-manager.users.<user>.home.file --apply builtins.attrNamesrather than guessing one. A key's.sourceis the input file, whose store path differs from the deployed symlink's target (home-manager copies it to ahm_-prefixed path) even though the contents match.- nixpkgs
vimPlugins.nord-nvimisshaunsingh/nord.nvim(norequire("nord").setup()); the config wantsgbprod/nord.nvim, which is packaged asvimPlugins.gbprod-nord. - nixpkgs
vimPlugins.nvim-treesittertracks the rewrittenmainbranch: there is norequire("nvim-treesitter.configs").setup{ensure_installed,highlight,indent}. Under nixvim, useplugins.treesitterwithhighlight.enable/indent.enableandgrammarPackages = with config.programs.nixvim.plugins.treesitter.package.builtGrammars; [ ... ]— the module's ownpackage.builtGrammars, notpkgs.vimPlugins.nvim-treesitter.*(whose query files can mismatch). The module targets the main branch and enables features via neovim-native APIs (vim.treesitter.start(),require'nvim-treesitter'.indentexpr()). - Neovim is configured via nixvim (flake input
nixvim, consumed asinputs.nixvim.homeModules.nixvimadded tohome-manager.sharedModules, config underhome-manager.users.<user>.programs.nixvim).nixvim.inputs.nixpkgs.follows = "nixpkgs"is set; nixvim then emits a benign eval warning that its pinned nixpkgs differs from the followed one — builds and runs fine, do not "fix" it by dropping the follows. - To reference the nixvim-built package's own attrs (e.g. treesitter
builtGrammars) inside our NixOS module, givehome-manager.users.<user>the module-function form (hm: { programs.nixvim = { ... hm.config.programs.nixvim... }; }), since the outerconfigis the NixOS config, not the home-manager one. - The agent's Bash sandbox blocks
sudoand swallows it into a bare exit 1 with no stderr, which looks identical to the command itself failing. Re-run with the sandbox disabled to see the real error (sudo: a password is required) before diagnosing anything else. Separately,nixos-generate-config --show-hardware-configneeds root on this machine even just to print: unprivileged it dies atFailed to retrieve subvolume info for /, because the root filesystem is btrfs. - Sudo's credential cache is keyed per user rather than per terminal (
timestamp_type=global, 60-minute window, declared by the claude-code module), so an authentication made in one terminal counts for commands the agent runs. Warming it withsudo -vthrough the agent's own shell — including the!prefix — never works: that shell has no controlling terminal, and sudo reportsa terminal is required to read the password. It has to be a separate terminal. APreToolUsehook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling; a failure without that message is the sandbox, not the cache. - An
mkOptionof a list or attribute-set type is not mandatory the way a scalar one is. Those types carry anemptyValue, so an option declared with nodefaultand never set evaluates to[ ]or{ }instead of failing with "option used but not defined". A declaration that is genuinely required cannot be expressed by omitting the default — it needs an assertion, or a default chosen so that the silent case is the safe one. This bites hardest where the empty value is itself dangerous, such as a list of authorized SSH keys, where it means a machine nobody can reach. home-manager.users.<user>cannot be assigned twice at the same level in one module:home-manager.users.${user}.home.packagesalongsidehome-manager.users.${user}.programs.xfails witherror: dynamic attribute 'alexion' already defined. The interpolated key makes it a dynamic attribute, which nix will not merge the way it merges static paths. Nest both under a singlehome-manager.users.${user} = { ... }.- Verifying a nixvim change headless:
programs.nixvim.build.package's wrapper has no-u, so running$OUT/bin/nvimloads the caller's~/.config/nvim(the dev host's real config), not the built config — silently. To exercise the built config, launch with-u "$(nix build --no-link --print-out-paths .#…programs.nixvim.build.initFile)"and a scratchHOME/XDG_CONFIG_HOME.conceallevelis window-local: set it withopt_local/vim.wo, nevervim.bo[buf](which errors). - Host GPUs:
neogaiais Intel andzeus(the desktop) is AMD.raichu, a server with no desktop, is the only Nvidia machine.laptop-mvi.md's out-of-scope line calls zeus Nvidia, but that is stale and the document is kept historical and unchanged, so do not infer any host's GPU from it. The corrected fact lives in ADR 0003 and thehyprland-desktopspec. - The home-manager
wayland.windowManager.hyprlandmodule defaultsconfigTypeto"lua"athome.stateVersion>= 26.05, writinghyprland.luathrough anhl.*Lua API instead of the nativehyprland.conf. The Lua backend mangles$mod-style variables and INIbind=strings into invalid Lua (hl.$mod("SUPER")), and does not fail the build, since the config is only text. SetconfigType = "hyprlang"to get the nativehyprland.confwhose variable and bind syntax the usual settings are written in. Render the file to check which format is in effect:nix build --print-out-paths .#nixosConfigurations.<host>.config.home-manager.users.<user>.xdg.configFile.\"hypr/hyprland.conf\".source(only the enabledconfigType's key exists). - An invalid Hyprland dispatcher or config-option name never fails the nix build, since
hyprland.confis only text, so it surfaces only when the compositor loads the file at login. The build/render check is therefore blind to it, and the real test is a running session (or reading~/.config/hypr/hyprland.confagainst the running package's own names). Two that bit on 0.55.4: the dwindle split actionstogglesplit,swapsplit, andpseudoare layout messages reached through thelayoutmsgdispatcher (bind = $mod, T, layoutmsg, togglesplit), not top-level dispatchers, and the olddwindle:pseudotileoption is gone. Confirm names against the pinned package rather than the wiki, whose "latest" drifts from it. The config can in fact be checked offline:Hyprland --verify-config -c <rendered-conf>parses the file and printsconfig okor the exactline N:error without a running compositor, so a rule change is provable before login rather than only at it. - Hyprland 0.55.4 uses windowrule v3 syntax, which is not the
windowrule = float, class:^(re)$form the wiki still shows. A flatwindowrule =entry is a comma-separated list offield valuetokens, each of which must carry a value: matchers take amatch:prefix and effects are bare, so floating one app iswindowrule = float 1, match:class ^(com\.gabm\.satty)$. The old form fails at load withinvalid field float: missing a value, because the effect token has no value.windowrulev2is removed and errors as deprecated.