Fish shell

A command line shell that does not think that I am the problem

2019-03-30 — 2026-07-21

Wherein the Fish Shell Is Examined as an Alternative to Bash, Its Opinionated Design, Sarcastic Documentation, and Practical Configuration of PATH Variables, Plugins, and Python Environments Being Treated at Length.

apple
computers are awful
faster pussycat
plain text
POSIX

Not the aquatic creatures, but rather the command-line shell doohickey, which I like because it’s less annoying than bash. I switched to fish after I accidentally lost a lot of precious data because of a quirk in bash syntax. Long, boring story. The problem isn’t that it happened to me so much that far too many people have had the same experience. Fool me once, shame on me. Fool us collectively ten thousand times, shame on our civilization.

No longer must we face those same stupid errors! It’s time for new, exciting, different stupid errors.

Figure 1

fish has a strong fanbase and an opinionated design. If we dislike those design opinions, at least we might appreciate that its documentation expresses said opinions with healthy sarcasm. Such self-awareness is sorely absent from the drearily earnest nerdview of a typical gnu.org project.

I don’t agree with every design choice in fish, but I also think that having any kind of principled opinion is better than the design-by-accumulation-of-tradition-cruft that structures command-line shells.

1 Installing

gararine reports how to make fish the default shell with homebrew:

# check the fish path with `which fish`. In the examples below it was located at: `/opt/homebrew/bin/fish`.
# Add fish to the known shells
sudo sh -c `which fish` >> /etc/shells
# Set fish as the default shell
chsh -s `which fish`
# Add brew binaries in fish path
fish_add_path /opt/homebrew/bin
#To collect command completions for all commands run:
fish_update_completions

2 Configuration

2.1 Modifying the PATH

This is the most confusing part of fish for me. It’s worth reading some tutorials, e.g.

tl;dr:

To add a path, use the utility command fish_add_path.

fish_add_path /usr/local/bin

There’s no fish_remove_path. Weird.

There’s an alternative method: we update a path variable like any other variable, which is confusingly related to the method above. Adding a path? Say it’s /usr/local/bin. Put

set -gx PATH /usr/local/bin $PATH

In ~/.config/fish/config.fish, or (“universal” style)

set -U fish_user_paths /usr/local/bin $fish_user_paths

Removing a path?

set -gx PATH (string match -v /usr/local/bin $PATH)

🚧TODO🚧 Clarify the difference between $PATH and $fish_user_path, which depends on my understanding of how the content of $PATH magically replenishes itself, and on the difference between “universal” and “global” variables.

2.2 Where the system paths come from

Part of that magic replenishment is not fish at all. macOS ships path_helper, which reads /etc/paths and every file in /etc/paths.d, and which fish, bash, and zsh all honour. Quarto, TeX, and dotnet install themselves this way, which is why they resolve in every shell without appearing anywhere in my own config.

echo /opt/whatever/bin | sudo tee /etc/paths.d/whatever

It only ever appends, so it cannot put anything ahead of /usr/bin, and it wants sudo. Anything order-sensitive — pyenv shims, homebrew shadowing a system binary — has to stay in config.fish.

2.3 Modifying any settings with GUI

fish_config

2.4 Traditional config

Put commands in ~/.config/fish/config.fish.

2.5 Extremely traditional config

Aelius notes a neat hack to unify the config:

one of the things I like about fish is how there are sane defaults and I don’t need to have any config. Which works for me, because I have no interest in learning fish syntax. I just want a helpful shell, I don’t want to have to know yet another language, and I deeply resent fish every time it doesn’t process the line of posix sh I paste into it from a wiki…

After jumping between several different shells and rewriting my .profile a number of different times for a number of different shells, I came up with a way to decouple my environment config from the shell I use. My environment always works, I don’t have to learn fish or any other syntax.

I set /bin/dash as my login shell. the first line of my ~/.profile is ENV=$HOME/.shinit; export ENV. In any interactive shell, dash executes ~/.shinit, which contains one line:exec /usr/bin/fish.

Every config item I need from my shell goes into ~/.profile, written in easy, conventional posix sh— and I still get to use fish as my interactive shell, without having to go through the trouble of adopting its config to my system.

I don’t do this myself. I’m all in on fish.

2.6 ssh-agent

Optiligence notes that this minor alteration should work.

eval (ssh-agent -c)

Alternatively, check out danhper/fish-ssh-agent or ivakyb/fish_ssh_agent.

Installation:

wget https://gitlab.com/kyb/fish_ssh_agent/raw/master/functions/fish_ssh_agent.fish -P ~/.config/fish/functions/

Append the next line to ~/.config/fish/config.fish

fish_ssh_agent

We really need to verify that https://gitlab.com/kyb/fish_ssh_agent/raw/master/functions/fish_ssh_agent.fish isn’t malicious; this is high-security code.

To be safer, we can get a known-good (IMO) version like this:

wget https://github.com/ivakyb/fish_ssh_agent/raw/e09c21501c20730634ab80d6bc9329335eabe065/functions/fish_ssh_agent.fish -P ~/.config/fish/functions/

3 Plugins

We can hack fish. There are also popular plugin managers. As far as I can tell, a passable default is oh my fish

curl -L https://get.oh-my.fish | fish

Is fisher around too?

curl -sL https://git.io/fisher | source && fisher install jorgebucaran/fisher

It also handles omf plugins, apparently. The omf manual is brusque. See a helpful blog post. I’m currently running omf, but since it intrusively changed my prompt I’m grumpy about it. I switched to a better prompt, spacefish.

omf install spacefish

We do need the wacky powerline fonts.

sudo apt-get install fonts-powerline

There are various useful plugins that aren’t purely cosmetic. For example, fzf adds fuzzy history search. z does recency/frequency-based directory navigation.

4 Homebrew compatibility on Ubuntu

I use fish as my default shell, but Ubuntu automatically executes the bash startup script .profile on login. When I used homebrew, I ran into the following errors because it tried to run the fish init in a bash process:

bash: set: -g: invalid option
set: usage: set [-abefhkmnptuvxBCHP] [-o option-name] [--] [arg ...]
bash: set: -g: invalid option
...

This may be related to an intermittently reported bug in homebrew. The fix that worked for me was to change the automatically added line in .profile to:

eval $(SHELL=bash /bin/brew shellenv)

and to add

eval (env SHELL=fish /bin/brew shellenv)

to ~/.config/fish/config.fish.

5 Python environments

5.1 virtualenv

If I used virtualenv on Python I would need virtualfish to replace Python’s virtualenvwrapper.sh. Or I could switch to native Python 3 venv, which is more or less the same thing but works better and doesn’t support Python 2. But if we need to support Python 2 at this stage it’s probably because we’re in some weird enterprise environment with horrid legacy software, so hopefully we can farm this problem out to the tech support team. Either that or we’re barred from using fish by policy and this isn’t a problem.

5.2 Using Anaconda Python

We need to do some extra setup to use conda with fish.

~/miniconda3/bin/conda init fish

Or, for older versions,

source ~/miniconda3/etc/fish/conf.d/conda.fish

into ~/.config/fish/config.fish.

(Replace ~/miniconda3/ with the output of conda info --root if you used a non-standard install location)

6 Vars, expansions, extensions, suffixes

Wildcards are minimal: just *, **, brace expansion and mv a.{txt,html.

For more sophisticated string processing, we can define custom functions (which I never actually do) or use classic subcommands, which I do all the time. Usually, I want to rename files:

for file in (ls *.html)
  mv $file (basename $file .html).txt
end

or expansion via string, which is harder to remember.

for file in (ls *.html)
  mv $file (string replace -r "\.html\$" .txt $file)
end

Or use another fancy utility like rename.

7 Loops

while true
  echo "Loop forever"
end
for var in list
    command $var
end

8 Test exit status

Similar to Bash.

if test $status -eq 0
  echo yeah
end

To simply execute the second command if the first succeeds, the command we want is and, which is hard to google for:

../bin/something.sh foo; and cp foo ~/Dropbox/

9 Temporary variable setting: uses env

env FOO=BAR baz.sh

10 VS Code

VS Code requires launching it from the command line if we use fish on macOS.

11 Aliases, custom commands

Command, alias or abbr — which should we use?

  • Command lets us overwrite a command.
  • Alias creates an alias, but lacks autocomplete.
  • Abbr creates a kind of alias that expands and supports autocomplete.

12 Writing functions

13 Gotchas

“Sophisticated” backgrounding, i.e. anything other than single shell commands, isn’t supported in fish. Background jobs aren’t in the background. Specifically, functions and piped commands don’t work.

Workaround:

function bgFunc
  fish -c (string join -- ' ' (string escape -- $argv)) &
end

14 Coding agents and $SHELL

Fish is a pleasant login shell for humans, but machines find it challenging and are usually set up to use different shells. Claude Code hard-codes bash and/or zsh. In particular, it ignores $SHELL if $SHELL is fish and falls back to zsh.

The problem is that the agent still believes it is using fish shell and all kinds of hijinks follow when it tries to use fish syntax. One fix is to update ~/.claude/CLAUDE.md — to say

the Bash tool runs zsh -l -c, disregard the environment block

That is the whole fix, which surprised me, because I had expected $PATH to be broken too. zsh -l -c is a login shell but not an interactive one, so it reads ~/.zshenv, ~/.zprofile and ~/.zlogin, but not ~/.zshrc, and no fish config at any point. Wiping the environment shows how little it knows unaided:

env -i HOME=$HOME /opt/homebrew/bin/fish -ilc 'for p in $PATH; echo $p; end'
env -i HOME=$HOME /bin/zsh -ilc 'print -l $path'

Fish reports /opt/homebrew/bin, ~/.pyenv/shims, ~/.local/bin and ~/bin. Zsh reports none of them, so by rights the agent should not be able to find uv. It finds uv anyway.

The desktop app is not the explanation: its own PATH is the bare /usr/bin:/bin:/usr/sbin:/sbin that it gets from launchd. Yet the shell Claude Code executes in has my whole config.fish ordering, including ~/.pyenv/shims, which config.fish only adds under status --is-interactive. So Claude Code harvests $PATH by running my interactive fish, and then declines to run any of my commands in it. It trusts fish enough to ask what my environment is, but not enough to use it.

So there is nothing to fix, only somewhere to look first should uv ever go missing. It does feel flaky as hell to rest on an undocumented harvesting step. If it breaks in the future I will need to arse around with .zprofile and .zshrc and typeset -U and suchlike.

15 Python environments

In lazy mode, python environments manipulates paths and environment variables. We need extra configuration in the fish shell. pyenv needs

set -x PYENV_ROOT $HOME/.pyenv
set -x PATH $PYENV_ROOT/bin $PATH
status --is-interactive; and pyenv init --path | source
status --is-interactive; and pyenv init - | source

See also How to use nvm, rbenv, pyenv, goenv… with the fish shell.

Or ditch pyenv and use uv.

16 Incoming

Deleting history interactively:

history delete

Native parallelism?:

while true
    if test (count (jobs)) -lt 5
        dostuff &
    else
        wait # to wait for _any_ job
    end
end

NB: it only allocates five jobs at a time. Nifty.