macOS (work) and Arch/i3 (personal desktop), managed with GNU stow and driven by Taskfile.
Python, Terraform, YAML/Kubernetes and Jenkins are the day job, so that is what the editor is set up for. Neovim is a thin layer over LazyVim; the goal is to lean on upstream rather than hand-roll, so this repo stays small enough to still work after being ignored for a year.
git clone git@github.com:ned-si/dotfiles.git ~/repos/dotfiles
cd ~/repos/dotfiles
task brew # Homebrew toolchain (macOS)
task link # symlink config into $HOME for this platform
task tmux:plugins # tpm + tmux pluginstask link picks the package set from uname, so the same command is correct on
both machines. task link:check dry-runs it and shows what would change; task unlink reverses it.
Stow refuses to overwrite a real file, so if task link reports conflicts, move
the existing file aside first. task adopt does the opposite of what you
probably want: it pulls $HOME's version into the repo.
Two things cannot be automated:
gh auth login # required by octo.nvim and gh-dash
task brew:tex # Skim + BasicTeX, needs your sudo passwordOn first nvim launch, mason downloads ltex-ls-plus, which is a ~300MB
LanguageTool bundle. Let it finish before expecting grammar checking to work.
Every top-level directory is a stow package whose interior mirrors $HOME:
| package | lands at | notes |
|---|---|---|
zsh |
~/.zshrc, ~/.zshenv, ~/.zprofile, ~/.p10k.zsh |
|
tmux |
~/.tmux.conf |
|
nvim |
~/.config/nvim |
LazyVim |
git |
~/.gitconfig, ~/.config/git/ |
identity split by remote |
ssh |
~/.ssh/config |
no internal hosts, see below |
lazygit, gh-dash, ghostty |
~/.config/<name> |
|
bin |
~/.local/bin |
tmux-sessionizer |
linux-x11, i3, polybar, picom |
Arch only | |
rofi, autorandr, i3-layout-manager |
Arch only | |
macos-wm |
yabai/skhd/spacebar, never stowed automatically |
macos-wm is kept because the config is worth not losing, but it needs SIP
changes that a managed work machine will not allow. task link:macos-wm if that
ever changes.
Platform differences used to live on git branches. That meant every shared change
landed twice and the platform branches needed perpetual rebasing, so it is now a
package-selection problem solved in Taskfile.yml.
task check:all runs everything CI runs. Individually:
| task | checks |
|---|---|
check:shell |
zsh/bash syntax, shellcheck, and runs every listing alias |
check:lua |
stylua formatting |
check:markdown |
markdownlint on this repo's docs, MD013 at 80 columns |
check:tmux |
config loads, bindings are unique and reachable |
check:nvim |
starts clean, options/mappings/filetypes as configured |
check:stow |
every package links cleanly into an empty target |
check:ghostty |
config validates and the theme name resolves (macOS) |
CI runs the same tasks, so there is no separate CI logic to drift. Three jobs: Linux for shell/tmux/stow, a cached Linux job for nvim, and macOS for ghostty.
These are not hypothetical. Every check here exists because something silently
broke: a Ghostty theme name that never resolved for over a year, tmux bindings
spelled in a way no terminal can produce, a lualine theme that fell back without
anyone noticing, plugins renamed upstream, and l breaking because eza rejects
BSD ls flags. All of them were invisible until something was actually run.
They deliberately stop short of anything needing a language server or a real
terminal. <leader>ca on a grammar warning, Option-as-Alt reaching tmux, and
YAML schema resolution all need a live environment, and testing them in CI buys
flakiness rather than confidence.
Anything secret, generated, or specific to one host goes in an untracked file. All of these are optional and sourced only if they exist:
| file | for |
|---|---|
~/.zshenv.local |
environment and secrets, loaded early |
~/.zshrc.local |
interactive-only: aliases, functions |
~/.zprofile.local |
login-shell only |
~/.ssh/config.local |
work hosts and jump boxes |
~/.config/git/config.local |
anything not for publishing |
The split exists because ordering matters: .zshenv.local is read before the
powerlevel10k instant prompt, .zshrc.local after every widget is defined.
Generated rc files from other tooling are sourced from ~/.zshenv.local rather
than inlined here, so their updates do not churn this repo and nothing
work-specific ends up in a published file.
The default identity lives in the untracked ~/.config/git/config.local, since
this repo is published. Personal repos are matched on their remote URL rather
than their path, because they sit in ~/repos alongside work checkouts:
~/repos/dotfiles -> nedsi <nedsi@pm.me> (personal remote)
~/repos/<work repo> -> whatever config.local sets (default)
Both SSH keys live in the Bitwarden agent, so there is no key material on disk and nothing to configure per host.
Bindings that differ from LazyVim or from the old config:
| key | does | why |
|---|---|---|
<leader>j/k/l/; |
harpoon slots 1-4 | |
<leader>U |
undo tree | <leader>u is LazyVim's toggles group |
<leader>y |
yank to system clipboard | clipboard="", always explicit |
<leader>cy* |
YAML key-path tools | <leader>yy is already taken |
<leader>cyK |
pin buffer to the K8s schema | when path matching is wrong |
<leader>pd / <leader>pt |
peek definition / type | replaces lspsaga |
<leader>P* |
pull requests and issues | Po GH, Pb BB, Pj Jira |
<C-\> |
Kiro chat | |
<leader>- |
yazi | replaces ranger |
kiro-cli acp runs Kiro as an Agent Client
Protocol agent, and
agentic.nvim is an ACP client that
speaks to it. It reuses the CLI's existing auth, MCP servers, skills and
sub-agents, and sessions are shared: start a conversation in nvim and resume it
with kiro-cli --resume, or the reverse.
The previous config crashed on YAML because three plugins configured yamlls at
once and one of them applied the Kubernetes schema to every .yaml file. Now one
place configures it, Kubernetes is matched on paths that plausibly hold
manifests, and kubeconform does the actual validation including CRDs. Helm
templates go to helm-ls, because Go templates are not valid YAML and yamlls
flags every {{ }} as a syntax error.
octo.nvim handles personal GitHub. Bitbucket and Jira come from atlas.nvim,
which upstream describes as early and prone to breaking changes; it is isolated
in lua/plugins/forge.lua so it can be deleted without touching anything else.
Credentials come from ~/.zshenv.local:
export ATLAS_BITBUCKET_USER=... # Atlassian account email
export ATLAS_BITBUCKET_TOKEN=... # Atlassian API token
export ATLAS_JIRA_URL=https://<site>.atlassian.net
export ATLAS_JIRA_USER=...
export ATLAS_JIRA_TOKEN=...Imported but inert: jdtls attaches only to java and gradle buffers.
Homebrew's openjdk is keg-only, so .zshenv sets JAVA_HOME rather than asking
for the sudo symlink into /Library/Java/JavaVirtualMachines.
Compile and preview with <localleader>c / <localleader>v, play the MIDI with
<localleader>p. Playback renders through timidity rather than fluidsynth,
because fluidsynth needs a SoundFont that Homebrew does not ship.