Porting a fish config to nushell: two bugs that only show up in a real shell
I’ve run fish as my daily shell for years, with a chezmoi-managed config covering PATH setup, tool integrations (starship, zoxide, atuin, mise), a couple dozen abbreviations, and a handful of custom functions. Nushell sat next to it as an unconfigured skeleton, installed but never actually used. This week I brought it up to parity.
The goal isn’t to replace fish. I reach for the two shells for different things: fish stays my interactive default, and nu comes out when I want structured data pipelines instead of text munging. What I wanted was for the everyday stuff to feel identical regardless of which one I’m sitting in, so switching between them never costs me a second thought. My starship prompt shows which shell is active, so the one visible difference is a label, not a change in muscle memory.
The mechanical part went about how you’d expect: nu has no conf.d autoload convention like fish, so the config splits into a handful of explicitly-sourced files instead of one-file-per-concern. Some things translate directly (alias/def for fish’s abbr). Some things don’t translate at all and needed a different mechanism entirely, most notably cd: fish’s version fuzzy-jumps via zoxide and prints a listing afterward, and nu simply has no way to override cd and still call through to the real builtin once you’ve shadowed it. The fix there was to stop trying to override cd and hook $env.config.hooks.env_change.PWD instead, which fires on any directory change (cd, z, cd -) rather than one specific overridden function. That’s arguably the better design anyway.
None of that is the interesting part, though. The interesting part is what only showed up once I stopped testing with nu -c "..." and started testing in an actual interactive session.
Lesson one: nu -c doesn’t load config.nu
I’d been verifying every change with commands like:
nu -c "print (which ll | length)"
This works fine when you pass explicit --config/--env-config flags, which I’d been doing during the initial build-out. But plain nu -c "...", without those flags, only loads env.nu. It does not load config.nu by default. env.nu sets environment variables ($env.EDITOR, PATH entries), so those checks kept passing. config.nu is where custom commands, aliases, and the prompt live, so anything that depended on it was silently untested the whole time I thought I was testing it.
The failure mode when this went live was exactly what you’d guess: ll not found, wrong prompt, no custom commands at all. Once I switched to nu -i (interactive mode) for verification, both remaining bugs below showed up immediately.
Lesson two: nu resolves config paths per-OS, not just per-XDG-spec
Fish always reads ~/.config/fish, full stop, on every platform. Nushell doesn’t work that way. On macOS, absent an explicit XDG_CONFIG_HOME, nu defaults to ~/Library/Application Support/nushell/ for its config, not ~/.config/nushell/. My chezmoi setup deploys everything to ~/.config/nushell (the XDG-conventional location, matching fish, matching the rest of the dotfiles), so nu was quietly reading a completely different, auto-generated stock config that had been sitting there since the first time nu ever ran without finding a real one.
Two ways to fix this: export XDG_CONFIG_HOME at the session level so every XDG-aware tool on the system follows the convention, or narrowly redirect just nu. I went with the narrow fix, a chezmoi-managed symlink:
~/Library/Application Support/nushell -> ~/.config/nushell
darwin-only, since ~/Library doesn’t exist on Linux and nu already does the XDG-conventional thing there without help. One nice side effect: nu’s data-dir and config-dir turned out to be the same path on macOS, so this single symlink also covers the vendor/autoload directory nu uses for auto-loaded completions, not just config.nu itself.
Lesson three: closures resolve names at parse time, not call time
This one was subtler. config.nu sets $env.config.hooks.env_change.PWD to a closure that calls a custom command, and separately sets the external completer to a closure that calls a has-cmd helper. Both commands come from files imported with use further down in the same script. My assumption was that a closure, being a value assigned at runtime, would resolve the names inside its body when the closure actually runs, i.e. dynamic scoping. That’s wrong for nu: command name references inside a closure literal are resolved at the point the closure is parsed, using whatever’s lexically in scope at that point in the file. It doesn’t matter that the closure isn’t invoked until much later, well after the imports have executed. If the use statement comes after the closure literal in the source, the closure can’t see it.
The error is unambiguous once you hit it in a real session:
Error: nu::shell::external_command
× External command failed
Command `on-directory-change` not found
help: A command with that name exists in module `commands`. Try importing it with `use`
The fix was mechanical once diagnosed: move the use statements to the very top of config.nu, before anything that might reference them in a closure body.
Takeaway
Every one of these three lessons boils down to the same thing: config-loading semantics for a shell are not something you can fully verify by running snippets through -c. You have to load the thing the way a real terminal would. I’d tested this port fairly thoroughly through the build-out, and still shipped a config that failed on first real use, twice, for reasons that had nothing to do with the actual fish-to-nu translation work and everything to do with assumptions about how nu itself loads and scopes things.