Using fish interactively with zsh as the default shell on macos

I have been migrating my macos system’s homebrew setup over to home manager, and I ran into a problem: using fish as my interactive shell without changing the default login shell from zsh. Here’s one solution, which I hope can help someone else.

With homebrew, I managed this with my terminal, Wezterm. Wezterm has a configuration option which allows a command to be run instead of the user’s shell upon starting:

default_prog = { "/opt/homebrew/bin/fish", "-l" }

So initially, I tried to do the same with the installation of fish through nixpkgs:

default_prog = { "/Users/david/.nix-profile/bin/fish", "-l" }

While this would use fish as the interactive shell when launching Wezterm, it would skip the initialization of configuration files for both zsh and fish. The prompt was the default, the session variables were unset, there were no aliases or abbreviations, zoxide and fzf were not initializated, and so on. Perhaps this would have worked with other terminal emulators’ comparable options to set the shell they use.

My solution is a modification to the conditional block recommended by the Arch Linux wiki, which is also presented on the NixOS wiki entry for fish. Here’s that bit of bash first:

if [[ $(ps --no-header --pid=$PPID --format=comm) != "fish" && -z ${BASH_EXECUTION_STRING} && ${SHLVL} == 1 ]]
    shopt -q login_shell && LOGIN_OPTION='--login' || LOGIN_OPTION=''
    exec fish $LOGIN_OPTION

The conditional block checks whether the parent process of the current shell is fish. This allows you to invoke zsh from fish. Without it, doing so would simply run exec fish -l once more.

Unfortunately, as written, it presents two problems. First, being BSD-derived, MacOS's ps command accepts different options. Second, this is a script intended for bash, not zsh. MacOS has used zsh as its default shell for years, and I did not want to change it to bash to try to solve this problem.

The solution I found is to add the following to programs.zsh.initExtra:

programs.zsh = {
  initExtra = ''
    if [[ $(ps -o command= -p "$PPID" | awk '{print $1}') != 'fish' ]]
        exec fish -l

Breaking it down:

  • ps lists the processes running with the same user and terminal as
    the one invoking it.
  • -o command= (or, alternatively -o comm= or -o 'comm=' )
    replaces both the --format=comm and --no-header options. The
    '=' after 'command' leaves off the header.
  • -p $PPID selects processes with a PID equal to $PPID, which is
    a zsh-provided shell parameter of the parent process ID.
  • | awk '{print $1}' pipes the output of the ps command to awk
    in order to select only the first column. This is necessary if the
    parent process command was 'fish -l'.
  • != fish compares the parent process command to 'fish' and, if it
    wasn't fish, then it executes fish.

If anything seems amiss, or if you know of a better (or even just alternative) solution, please do share. I’d like to add the alternative conditional block to the NixOS wiki entry for fish. But I’ll wait to see whether anyone has suggestions here first.


Feel free to contribute the snippet to the wiki for zsh users. :slight_smile:

1 Like

I use iTerm2 and did not need that. (It calls the shell as if it was a login shell so I don’t see the flags now - but I think I remember what I used tom use under MacPorts)

Note that GUI programs launched from Dock or Finder do not use a login shell unless you explicitly tell the shell to do so,

I think the issue here is that the fish shell needs to run as interactive as well as login. Missing aliases oxide etc does suggest this.
Try note I don’t know how Western deals with multiple arguments to a program so the last bit might need to be set as serrate arguments.

default_prog = { "/Users/david/.nix-profile/bin/fish", "-l -i"  }

Western might well run the default shell as that

Thanks for your suggestions, Mark. I tried with the interactive flag, both as a separate argument and together with "-l". No difference, unfortunately. However, I have the same problem with iterm2, using .nix-profile/bin/fish as a custom shell, which leads me to suspect that it’s a problem with how I have home manager setup.

When I launch a new terminal, either with an skhd hotkey set to the wezterm in my .nix-profile/bin (I have tried with the brew-installed binary, too) or through Spotlight for iterm2, I get nearly the exact same result. With iterm2, I also get an error logged to the console: the pyenv binary is missing. I do not get this with wezterm, which somehow still sources pyenv installed with homebrew. (I’m managing pyenv with home manager.) Neither source other nixpkgs.

If I then run zsh, it loads as usual. Running fish on top of zsh works fine. This is why I thought that maybe my zsh config needed to be sourced first. At this point, pyenv is now sourced from .nix-profile/bin in both wezterm and iterm2.

I have switched back to the method of having exec fish -l in my .zshrc, wezterm works as I’d like, but providing fish as a custom shell to iterm2 does not. Using the default shell (/bin/zsh) with iterm2 runs fish as I’d expect.

To help more I think we need to see your fish setup - also are you just using home manager and not nix to setup /etc/fish

Thank you for offering to take a look. Here is my config so far. I am only using home-manager at the moment. (And only on macos currently. I’m curious to try it out on Silverblue next…) There’s no /etc/fish entry. Is that something that can/should be managed with nix-darwin?

This is from my experience - looking at the home-manager fish code now it has changed from when I set this up and should be working better.

re /etc

/etc/fish can be created via nix-darwin

In your case did the nix installer update any xsh files in /etc - I think it does and could explain why zsh works.

re aliases - looking at them - are most failing because they can’t find the executable they are covering - I think your cd? aliases should be working.

Also there are long threads about fish not getting the correct path. I have copied this from several
I have this in my home: The nix-daemon stuff is via bablefish, you can source the bash version using the bass plugin = mkIf pkgs.stdenv.isDarwin ''

        # Nix
        if test -e '/nix/var/nix/profiles/default/etc/profile.d/'
            source '/nix/var/nix/profiles/default/etc/profile.d/'
            # following is single user
            # source '/nix/var/nix/profiles/default/etc/profile.d/'
        # End Nix

        ################### Nix
        # Essential workaround for clobbered `$PATH` with nix-darwin.
        # Without this, both Nix and Homebrew paths are forced to the end of $PATH.
        # <>
        # <>
        # A previous version of this snippet also included:
        #   - /run/wrappers/bin
        #   - /etc/profiles/per-user/$USER/bin # mwb needed if useGlobalPkgs used.
        if test (uname) = Darwin
            fish_add_path --prepend --global \
              "${config.xdg.stateHome}/nix/profile/bin" \
              /etc/profiles/per-user/$USER/bin \
              /run/current-system/sw/bin \
1 Like

Yes. This is exactly right. And so adding the first block you provided for to source resolves the missing executables.

The second block does reorder my path, none of the four directories listed exist in my system, though.

However, the fish plugins I install from nixpkgs and enable as plugins via home-manager—tide and bass—were absent. They become accessible upon running fish (so now I’m in a fish shell on top of fish -l shell). So I compared the $fish_function_path variable before and after running zsh. The missing directory that made the difference was:


Upon adding this to fish_function_path, both tide and bass were made available.

The one remaining issue is loading the tide prompt configuration. tide stores its configuration in .config/fish/fish_variables. This file is not read until I run fish manually. Following this fish n’ nix user, I tried moving the tide-related variables into their own file to be placed into .config/fish/conf.d/ via xdg.configFile. This doesn’t fix the issue. And to be clear, tide is being loaded fine, only my saved configuration variables in .config/fish are not.

iterm2 with custom shell set ot fish has the same behavior.

Any ideas? I think I’ll try starship again and, if it works, try to see how it differs. And thank you again for troubleshooting this with me : )

From Introduction — fish-shell 3.7.0 documentation
The files in cone.d need to have the extension .fish

FIXED IT! Everything in conf.d had the .fish file extension. But you inspired me. I had left off “.src” from my declaration of from where to source the plugin: = {
  plugins = [
      name = "tide";
      src = pkgs.fishPlugins.tide.src;

where pkgs.fishPlugins.tide is declared in home.packages.

I was curious about what this changed, since without that “.src” I was still getting a tide prompt. It just wasn’t using the tide_* variables (which were being set) until running fish again. Following the plugin_dir path set in ~/.config/fish/conf.d/, I found that none of the expected directories existed for that script. It had a share/fish/... structure. Now with the proper declaration of the plugin src, however, it links to a correctly setup directory.

This requirement does not seem to impact all plugins. I tried out async-prompt (with starship) in the same manner without problem. I will check whether this fixes the unavailability of bass until I added ~/.nix-profile/share/fish/vendor_functions.d to my path.

Thank you so much for your help : )

That src is where nixpgs has put the .fish file in the derivation of the plugin - use NixOS Search to search for a plugin and see how it is setup in the nix source

Ah, I think I see now.

Just thinking out loud here. There’s a postInstall step for both tide and bass, which copies those plugins’ functions to $out/share/fish/vendor_functions.d/. So that must have been why the directory referenced in the tide setup script wasn’t the output of src but instead share/fish/vendor_functions.d/. (And perhaps this explains why my earlier quasi-fix of adding that directory to my fish_function_path made the bass and tide executables available.) Now that my plugin declaration references the src, which is a fetchFromGitHub process, the plugin directory that tide’s setup sources is the git repo.