Fish shell

A command line shell that does not think that the problem is you

2019-03-30 — 2025-11-18

Wherein the author’s migration to fish is described, its opinionated design and PATH peculiarities are noted, and SSH‑agent setup and backgrounding limitations are documented.

computers are awful
faster pussycat
macos
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’m gradually switching to fish after I accidentally lost a lot of precious data because of a quirk in bash syntax. Long, boring story. 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 Modifying any settings with GUI

fish_config

2.3 Traditional config

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

2.4 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.5 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. [TODO clarify]

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 [TODO clarify]

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

to ~/.config/fish/config.fish. [TODO clarify]

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 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 For loops

while true
  echo "Loop forever"
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 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.

15 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.