feat(nixos): migrate system configuration

This commit is contained in:
2026-08-30 19:40:21 -04:00
parent c5f34e9460
commit 7a6dc4d6f3
101 changed files with 6247 additions and 0 deletions

View File

@@ -0,0 +1,26 @@
{
config,
lib,
...
}:
# direnv for the primary user, configured through home-manager.
let
cfg = config.modules.shell.direnv;
user = config.user.name;
in
{
options.modules.shell.direnv.enable =
lib.mkEnableOption "direnv for the primary user, with nix-direnv shell caching";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.direnv = {
enable = true;
silent = true;
# nix-direnv caches the evaluated shell so re-entering a directory is instant
# instead of re-running `nix develop`, and adds the `use flake` stdlib helper.
nix-direnv.enable = true;
stdlib = builtins.readFile ./repo-flake.sh;
};
};
}

View File

@@ -0,0 +1,33 @@
if declare -f use_flake >/dev/null; then
__dotfiles_use_flake_definition="$(declare -f use_flake)"
eval "${__dotfiles_use_flake_definition/use_flake/__dotfiles_nix_direnv_use_flake}"
unset __dotfiles_use_flake_definition
use_flake() {
local flake_expr="${1:-.}"
if [[ "$flake_expr" == //* ]]; then
local git_root
if ! git_root="$(git rev-parse --show-toplevel 2>/dev/null)"; then
log_error 'use flake // requires a Git repository'
return 1
fi
local flake_suffix="${flake_expr#//}"
if [[ -n "$flake_suffix" && "$flake_suffix" != \#* && "$flake_suffix" != /* ]]; then
flake_expr="path:${git_root}/${flake_suffix}"
else
flake_expr="path:${git_root}${flake_suffix}"
fi
shift
__dotfiles_nix_direnv_use_flake "$flake_expr" "$@"
return
fi
__dotfiles_nix_direnv_use_flake "$@"
}
else
log_error 'nix-direnv must define use_flake before the // wrapper loads'
return 1
fi

View File

@@ -0,0 +1,23 @@
# vi-style modal editing on the command line.
set -g fish_key_bindings fish_vi_key_bindings
set -gx EDITOR nvim
set -gx VISUAL nvim
# Render man pages through bat.
set -x MANROFFOPT "-c"
set -x MANPAGER "sh -c 'col -bx | bat -l man -p'"
# Tune the done plugin: only notify for commands past 10s, at low urgency.
set -g __done_min_cmd_duration 10000
set -g __done_notification_urgency_level low
# Prepend ~/.local/bin to PATH when it exists.
if test -d ~/.local/bin
fish_add_path ~/.local/bin
end
# Apply fish-compatible profile overrides if present.
if test -f ~/.fish_profile
source ~/.fish_profile
end

View File

@@ -0,0 +1,106 @@
{
config,
lib,
pkgs,
...
}:
# fish for the primary user, configured through home-manager.
let
cfg = config.modules.shell.fish;
user = config.user.name;
shortcuts = config.registry.shell-shortcut.${config.networking.hostName};
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
in
{
options.modules.shell.fish = {
enable = lib.mkEnableOption "fish as the user's shell, configured via home-manager";
defaultShell = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Set fish as the user's default login shell.";
};
};
config = lib.mkIf cfg.enable {
# System-level fish registers it in /etc/shells and installs vendor completions.
programs.fish.enable = true;
users.users.${user}.shell = lib.mkIf cfg.defaultShell pkgs.fish;
home-manager.users.${user} = {
home.packages = with pkgs; [
bat # backs the manpager
fastfetch # the shell greeting
];
programs.fish = {
enable = true;
preferAbbrs = true;
plugins = [
# Notify when a long command finishes.
{
name = "done";
src = pkgs.fishPlugins.done.src;
}
# Restore the !! and !$ history bindings.
{
name = "bang-bang";
src = pkgs.fishPlugins.bang-bang.src;
}
];
shellAliases = commandsOf (lib.filterAttrs (_name: shortcut: !shortcut.abbreviation) shortcuts);
shellAbbrs = commandsOf (lib.filterAttrs (_name: shortcut: shortcut.abbreviation) shortcuts);
functions = {
# Pick the logo that fits the terminal so the greeting never wraps.
# The full logo needs about 118 columns beside the info block, the
# compact one about 89, the info block alone about 71.
fish_greeting = ''
if test "$COLUMNS" -ge 118
fastfetch
else if test "$COLUMNS" -ge 89
fastfetch --logo NixOS_small
else
fastfetch --logo none
end
'';
# vi insert mode omits the emacs ctrl-f and ctrl-r bindings, so restore both.
# ctrl-f accepts an autosuggestion and ctrl-r opens the history pager.
# vi normal mode keeps its ctrl-r redo, since only insert mode is bound.
fish_user_key_bindings = ''
bind -M insert ctrl-f forward-char
bind -M insert ctrl-r history-pager
'';
history = {
description = "Show command history with timestamps";
body = "builtin history --show-time='%F %T '";
};
backup = {
description = "Copy <file> to <file>.bak";
argumentNames = "filename";
body = "cp $filename $filename.bak";
};
copy = {
description = "Copy a file, or recursively copy a source directory into a destination";
body = builtins.readFile ./functions/copy.fish;
};
deploy-to = {
description = "Deploy the current dotfiles branch to a host through target-local nixos-rebuild";
body = builtins.readFile ./functions/deploy-to.fish;
};
};
# Rendered by home-manager into ~/.config/fish/config.fish.
interactiveShellInit = builtins.readFile ./config.fish;
};
};
};
}

View File

@@ -0,0 +1,8 @@
set count (count $argv | tr -d \n)
if test "$count" = 2; and test -d "$argv[1]"
set from (echo $argv[1] | trim-right /)
set to (echo $argv[2])
command cp -r $from $to
else
command cp $argv
end

View File

@@ -0,0 +1,53 @@
if test (count $argv) -lt 1; or test (count $argv) -gt 2
echo "usage: deploy-to <host> [branch]" >&2
return 2
end
set host $argv[1]
set branch $argv[2]
if test -z "$branch"
set branch (git branch --show-current)
end
if test -z "$branch"
echo "deploy-to: could not determine the current branch" >&2
return 2
end
git rev-parse --show-toplevel >/dev/null 2>&1
or begin
echo "deploy-to: run this from a git checkout" >&2
return 2
end
set dirty_lines (git status --porcelain)
if test (count $dirty_lines) -gt 0
echo "deploy-to: refusing to deploy with a dirty local worktree" >&2
return 1
end
git fetch origin
or return $status
set remote_ref refs/remotes/origin/$branch
git rev-parse --verify --quiet $remote_ref >/dev/null
or begin
echo "deploy-to: origin/$branch does not exist" >&2
return 1
end
set local_head (git rev-parse HEAD)
set remote_head (git rev-parse $remote_ref)
if test "$local_head" != "$remote_head"
echo "deploy-to: local HEAD differs from origin/$branch" >&2
echo "deploy-to: push or reset the branch before deploying" >&2
return 1
end
set escaped_branch (string escape -- $branch)
set escaped_host (string escape -- $host)
set remote_command "cd ~/wrk/dotfiles && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
ssh -t $host $remote_command

View File

@@ -0,0 +1,25 @@
{
config,
lib,
...
}:
# git for the primary user, configured through home-manager.
let
cfg = config.modules.shell.git;
user = config.user.name;
in
{
options.modules.shell.git.enable =
lib.mkEnableOption "git for the primary user, carrying the operator's commit identity";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.git = {
enable = true;
# Git will not guess a name and address from the login and hostname.
# Without these a commit fails outright with `Author identity unknown`.
settings.user.name = "alexion";
settings.user.email = "contact@alexion.dev";
};
};
}

View File

@@ -0,0 +1,47 @@
{
config,
lib,
pkgs,
...
}:
# Herdr, a terminal multiplexer for coding agents.
let
cfg = config.modules.shell.herdr;
user = config.user.name;
in
{
options.modules.shell.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
home-manager.users.${user} = {
home.packages = [ pkgs.herdr ];
xdg.configFile."herdr/config.toml".text = ''
[keys]
prefix = "ctrl+space"
detach = "prefix+d"
reload_config = "prefix+r"
new_workspace = "prefix+c"
new_tab = "prefix+shift+c"
rename_workspace = "prefix+comma"
rename_tab = "prefix+<"
split_vertical = "prefix+backslash"
split_horizontal = "prefix+minus"
switch_workspace = "prefix+1..9"
switch_tab = "prefix+shift+1..9"
focus_pane_left = "prefix+h"
focus_pane_down = "prefix+j"
focus_pane_up = "prefix+k"
focus_pane_right = "prefix+l"
[ui]
prompt_new_tab_name = false
[experimental]
kitty_graphics = true
'';
};
};
}

View File

@@ -0,0 +1,36 @@
-- Imperative configuration that has no typed nixvim option: the colorscheme call and autocmds.
-- gbprod/nord.nvim.
require("nord").setup({
transparent = true,
})
vim.cmd.colorscheme("nord")
-- Conceal markdown syntax in markdown buffers.
-- conceallevel is window-local, so it is set with opt_local when the filetype
-- is applied to the buffer's window.
vim.api.nvim_create_autocmd("FileType", {
pattern = "markdown",
callback = function()
vim.opt_local.conceallevel = 2
end,
})
-- Soft-wrap prose buffers without inserting line breaks.
vim.api.nvim_create_autocmd("FileType", {
pattern = { "gitcommit", "markdown", "text" },
callback = function()
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.breakindent = true
end,
})
-- Turn line blame off again once the Neogit status buffer is closed.
vim.api.nvim_create_autocmd("BufUnload", {
callback = function(args)
if vim.bo[args.buf].filetype == "NeogitStatus" then
require("gitsigns").toggle_current_line_blame(false)
end
end,
})

View File

@@ -0,0 +1,204 @@
{
config,
lib,
pkgs,
inputs,
...
}:
# Neovim for the primary user, configured declaratively through nixvim.
let
cfg = config.modules.shell.nvim;
user = config.user.name;
in
{
options.modules.shell.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName} = {
vi = "nvim";
vim = "nvim";
};
home-manager.sharedModules = [ inputs.nixvim.homeModules.nixvim ];
home-manager.users.${user} = hm: {
programs.nixvim = {
enable = true;
nixpkgs.useGlobalPackages = true;
extraPackages = with pkgs; [
git # neogit and gitsigns shell out to git
ripgrep # backs the picker's live grep
fd # backs the picker's file finder
];
globals.mapleader = " ";
globals.clipboard = "osc52"; # built-in OSC 52 provider, no external binary needed
opts = {
clipboard = "unnamedplus";
number = true;
relativenumber = true;
shiftwidth = 2;
tabstop = 2;
expandtab = true;
mouse = "a";
undofile = true;
ignorecase = true;
smartcase = true;
splitright = true;
splitbelow = true;
wrap = false;
scrolloff = 8;
cursorline = true;
};
keymaps = [
{
mode = "n";
key = "<C-h>";
action = "<C-w>h";
options.desc = "Move focus left";
}
{
mode = "n";
key = "<C-j>";
action = "<C-w>j";
options.desc = "Move focus down";
}
{
mode = "n";
key = "<C-k>";
action = "<C-w>k";
options.desc = "Move focus up";
}
{
mode = "n";
key = "<C-l>";
action = "<C-w>l";
options.desc = "Move focus right";
}
{
mode = "n";
key = "<Esc>";
action = "<cmd>nohlsearch<CR>";
options.desc = "Clear search highlight";
}
{
mode = "n";
key = "j";
action = "gj";
options.desc = "Move down by display line";
}
{
mode = "n";
key = "k";
action = "gk";
options.desc = "Move up by display line";
}
{
mode = "n";
key = "<leader>e";
action = "<cmd>Oil<CR>";
options.desc = "Open file browser";
}
{
mode = "n";
key = "<leader>f";
action.__raw = "function() require('snacks').picker.files() end";
options.desc = "Find files";
}
{
mode = "n";
key = "<leader>s";
action.__raw = "function() require('snacks').picker.grep() end";
options.desc = "Search text";
}
{
mode = "n";
key = "<leader>b";
action.__raw = "function() require('snacks').picker.buffers() end";
options.desc = "Switch buffer";
}
{
mode = "n";
key = "<leader>g";
action.__raw = ''
function()
require('gitsigns').toggle_current_line_blame(true)
require('neogit').open()
end
'';
options.desc = "Open git (Neogit)";
}
];
plugins = {
gitsigns = {
enable = true;
settings.current_line_blame = false;
};
neogit.enable = true;
diffview.enable = true; # neogit's diff integration
oil = {
enable = true;
settings.view_options.show_hidden = true;
};
snacks = {
enable = true;
settings = {
picker.enabled = true;
notifier.enabled = true;
input.enabled = true;
};
};
which-key.enable = true;
render-markdown.enable = true;
treesitter = {
enable = true;
highlight.enable = true;
indent.enable = true;
grammarPackages = with hm.config.programs.nixvim.plugins.treesitter.package.builtGrammars; [
nix
lua
bash
fish
markdown
markdown_inline
rust
python
java
kotlin
c
cpp
html
css
javascript
typescript
go
];
};
};
# `gbprod-nord` provides the `require("nord").setup()` API used in `./config.lua`.
# nixpkgs packages `shaunsingh/nord.nvim` as `nord-nvim`, which is a different plugin.
extraPlugins = [ pkgs.vimPlugins.gbprod-nord ];
extraConfigLua = builtins.readFile ./config.lua;
};
};
};
}

View File

@@ -0,0 +1,33 @@
{
config,
lib,
...
}:
# tmux for the primary user, configured through home-manager.
# Settings without a home-manager option are read from ./extra.conf.
let
cfg = config.modules.shell.tmux;
user = config.user.name;
in
{
options.modules.shell.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
home-manager.users.${user}.programs.tmux = {
enable = true;
prefix = "C-Space";
keyMode = "vi";
mouse = true;
baseIndex = 1;
clock24 = true;
escapeTime = 10; # short Esc delay so exiting insert mode in nvim isn't laggy.
historyLimit = 10000;
terminal = "tmux-256color";
extraConfig = builtins.readFile ./extra.conf;
};
};
}

View File

@@ -0,0 +1,43 @@
# Copy-mode selections land in the system clipboard via the terminal's own
# OSC52 escape sequence.
set -g set-clipboard on
bind -T copy-mode-vi v send -X begin-selection
bind -T copy-mode-vi y send -X copy-selection-and-cancel
bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-and-cancel
# \ splits the pane side-by-side, - splits it stacked.
# Both open the new pane in the current pane's directory, as does c for a new window.
unbind %
unbind '"'
bind \\ split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
set -g renumber-windows on
bind r source-file ~/.config/tmux/tmux.conf \; display-message "tmux.conf reloaded"
# Advertise RGB (true-color) capability for every terminal type.
set -ag terminal-overrides ",*:RGB"
# monitor-bell flags a background window in the status bar when it rings the
# terminal bell while unfocused.
# bell-action=none suppresses the actual BEL (no beep or flash).
# The per-window status highlight is independent of it.
setw -g monitor-bell on
set -g bell-action none
# Minimal status bar: session name plus window list only.
# Colours are left to Stylix.
set -g status-position bottom
set -g status-left " #S "
set -g status-left-length 20
set -g status-right ""
setw -g window-status-current-format " #I:#W "
setw -g window-status-format " #I:#W "

View File

@@ -0,0 +1,32 @@
{
config,
lib,
pkgs,
...
}:
# Yazi, a terminal file manager.
let
cfg = config.modules.shell.yazi;
user = config.user.name;
in
{
options.modules.shell.yazi.enable = lib.mkEnableOption "Yazi, a terminal file manager";
config = lib.mkIf cfg.enable {
home-manager.users.${user}.programs.yazi = {
enable = true;
enableFishIntegration = true;
extraPackages = with pkgs; [
fd
ffmpegthumbnailer
fzf
imagemagick
jq
poppler
ripgrep
unar
zoxide
];
};
};
}