Gone Fishing

This weekend I explored the Fish Shell (a modern interactive Unix shell). I've been dabbling with it for a while, particularly enjoying its interactive features: command suggestions, the TAB-completion menu, the fact that it Just Works™ out of the box, and understands commands and options by groking their man-pages.

I've wanted to port a few of my bash functions which I've been missing: my ssh-pass to load SSH keys onto the Agent's keyring with passphrase supplied by pass, and an alias to get my 1password passphrase from pass as well. Also some directory navigation shortcuts that I've used since my university days. I figured these would be a good introduction to how fish's scripting works, and I was right


I started with something simple: my wtfo function which runs wtf with it's -o option to include obscene abreviations. The hack is to change the error when it still can't find any expansion (“nothing inappropriate”). Here's my original bash version, listed with my list meta-function:

[mjl@jazz:~/hax/gitlab.com/milohax/milosophical-me]$ list wtfo
λ:      function wtfo   --> /Users/mjl/.dotfiles/source/20_env.sh:33
    "Look up an abbreviation, including obscene meanings";
wtfo () 
{ 
    FUNCDESC="Look up an abbreviation, including obscene meanings";
    wtf -o ${@} | sed 's/nothing appropriate/nothing inappropriate/'
}

A few of things that I notice with this bash function:

  • I keep a description of the function in $FUNCDESC
  • The list function describes the function with it's $FUNCDESC, and then prints the bash-tokenized definition of the function using type
  • The functions arguments are available as $1, $2, and so on, or ${@} for all of them strung together
  • Variables in bash can optionally be expanded using the {} brackets, which avoids expansion issues when values have spaces
  • I used sed (the stream editor) to substitute the error which wtf will print when it doesn't find a match, for my cute hack. The s/something/else/ syntax is a sed command — derived from ed — to substitute the first string between the /s with the second string between the other /s
  • The function begins with a redundant () to list arguments, and also the body block is surrounded by { and } which has overloaded, different meaning to the {} for variable expansion

All of this is arcane, and is just a small taste of why I dislike bash, csh, zsh and so on. They use complicated syntax which dates from a time when it took a long time to print things out on paper terminals (which can't be easily edited, unlike a video terminal), and when computer cycles were precious, so command parsing had to be simple. It's clunky and awkward to use, difficult to read, and just plain unfriendly, and it's not clear what it actually means unless you're a Unix guru.

Fish, is the Friendly interactive shell. See how friendly it is with the fish version of my wtfo function, listed using fish's built-in functions function:

mjl@jazz ~/h/g/m/milosophical-me (fishing)> functions wtfo
# Defined in /Users/mjl/.config/fish/functions/wtfo.fish @ line 1
function wtfo --wraps=wtf --description 'Look up an abbreviation, including obscene meanings'
   string replace 'nothing appropriate' 'nothing inappropriate' (wtf -o $argv)
end

Here's what I notice about the fish function:

  • Fish can tell you which file contains the function's definition
  • The function's description is supplied as the argument to the --description option
    • this is used by fish during command completion, so it should be short, more on this later
  • The functions function will list all functions, or just the specified one. When it lists, it includes the description as part of the definition
  • The function's arguments are available as $argv which is an array -- also no need to use the funky brakets because fish understands spaces in things
  • fish can do string manipulation with its built-in function string. The sub-command replace will replace occurences of the first argument in the string, with the second argument
  • I've used command substitution (in the () parenthesis) for the call to wtf, passing it the function's arguments. I could have piped it like the original too. Note that the substitution syntax is different to bash's, and I like it: there's no dollar sign, because we're not doing anything with variables
  • The --wraps=wtf tells fish to use the same TAB-completions for the function's options as the command it wraps, which in turn are generated from the wtf command's man-page

All the functionality (and more) which I added to bash, I get for free with fish, and with a nicer syntax that is much easier to read.

Directory changing aliases

In bash, I have these aliases:

alias ..='cd ..'
alias ...='cd ../..'
alias ....='cd ../../..'
alias .....='cd ../../../..'
alias ......='cd ../../../../..'
alias .......='cd ../../../../../..'
alias ........='cd ../../../../../../..'
alias .........='cd ../../../../../../../..'
alias ..........='cd ../../../../../../../../..'
alias ...........='cd ../../../../../../../../../..'

They let me change up directories by just typing .. to go up one dir, or .... to go up three. I saw them at university, and thought they were neat (we used the C-shell on SunOS). Then when I became a Java developer they were essential. Now I'm just attached to them.

Fish doesn't actually have aliases. It has an alias command that makes a function to wrap whatever you are aliasing. So the definitions aren't so compact as above. Here are a few of them (I made eight, maybe overkill...):

# Defined in /Users/mjl/.config/fish/functions/...fish @ line 1
function ..  --description 'Change up 2 parent dirs'
  ../
end
# Defined in /Users/mjl/.config/fish/functions/....fish @ line 1
function ... --wraps=../../ --wraps=../../../ --description 'Change up 2 parent dirs'
  ../../
end
# Defined in /Users/mjl/.config/fish/functions/.....fish @ line 1
function .... --wraps=../../ --wraps=../../../../ --description 'Change up 3 parent dirs'
  ../../../
end

… and so on. All those --descriptions seem verbose, but they buy me this TAB-completion:

mjl@jazz ~/h/g/m/milosophical-me (fishing)> ....
..     (Change up 1 parent dir)  .....    (Change up 4 parent dirs)  ........  (Change up 7 parent dirs)
...   (Change up 2 parent dirs)  ......   (Change up 5 parent dirs)  ../               (Directory, 128B)
....  (Change up 3 parent dirs)  .......  (Change up 6 parent dirs) 

Also — and this is my favourite partdirectories are executable! So you don't have to use cd, just put a / at the end! Finally the execute-permissions-bit has real meaning, not a double-meaning. This works for any directory, not just the .. pointer to the parent.

Something more complex: ssh-pass

Here is my first go at a fish-version of ssh-pass:

mjl@jazz ~/h/g/m/milosophical-me (fishing)> list ssh-pass
# Defined in /Users/mjl/.config/fish/functions/ssh-pass.fish @ line 1
function ssh-pass --description 'Add specified SSH keys to the SSH Agent, from pass(1)'
#Each key's passphrase is retrieved from the Unix password store (pass), and
#given to ssh-add(1) via the SSH_ASKPASS mechanism. This relies upon the keys
#having the same path names in both your key directory ($SSH_KEYDIR), and
#your password store."

  if not set --query argv[1]
    error "$_: no SSH key specified."
    usage $_ "<key> [...]"
    return 1
  end 

  set --query DISPLAY; or set --export DISPLAY dummy

  pushd $SSH_KEYDIR; or return 2
    set --local KEY
    for KEY in $argv
      set --export SSH_ASKPASS (mktemp -t ssh-askpassXXX)
      echo "\
#!/bin/sh
pass ssh/$KEY|head -1
" > $SSH_ASKPASS
      chmod +x $SSH_ASKPASS
      ssh-add $SSH_KEYDIR/$KEY < /dev/null
      rm $SSH_ASKPASS
    end
    set --erase SSH_ASKPASS
  popd
end

My observations:

  • I made a small “alias” (function) list, to list functions
  • The comments are included in the listing, which nice! I can have more extended comments to explain a function, and keep the --description short
  • if not set --query argv[1], instead of if test -z ${1}
  • Clear or (and and) functions, instead of weird special syntax || and &&
  • Fish doesn't have here-docs. Instead you can use multi-line values, which are even better! That's how the temporary askpass script is written
  • No meaningless $ for things which aren't variables, or {} around things
  • In fish, use $_ instead of bash's $FUNCNAME, though this is deprecated in favor of status current-command, so I should make it set FUNCNAME (status current-command)

This function does not have shell completion for my keys, like my bash function does. I still need to look into that: for now I am happy to use fish's command suggestions that come from the shell history.

My ssh-pass function uses two meta-functions that I also ported from bash:

mjl@jazz ~/h/g/m/milosophical-me (fishing)> list error
# Defined in /Users/mjl/.config/fish/functions/error.fish @ line 1
function error --description 'Echo arguments to STDERR'
  isatty; and set_color red
  echo $argv 1>&2
  isatty; and set_color normal
end

and

mjl@jazz ~/h/g/m/milosophical-me (fishing)> list usage
# Defined in /Users/mjl/.config/fish/functions/usage.fish @ line 1
function usage --description 'Print usage signature of a function'
  if not set --query argv[2]
    error "$_: missing function name and arguments"
    usage $_ "<function_name> <arguments_spec>"
    return 1
  end

  isatty; and set_color yellow
  echo Usage: $argv
  isatty; and set_color normal
  echo Description: (describe $argv[1])
end

The best part about these is set_color with colour names, instead of having to echo -e some-ANSI-control-character sequence.

My usage function uses my describe function to pick out the function description:

mjl@jazz ~/h/g/m/milosophical-me (fishing)> list describe
# Defined in /Users/mjl/.config/fish/functions/describe.fish @ line 1
function describe --description 'Print a function description'
  if not set --query argv[1]
    error "$_: no function specified"
    usage $_ "<function>"
    return 1
  end

  functions $argv[1] | grep "\-\-description" | head -1 \
    | string replace "function $argv[1] --description " ""
end

Note that this uses grep only because string match works a line at a time. I could probably re-write this in a shell loop that escapes when --description is found.

More fishing to be done

As you can tell by my enthusiasm, I am having such a lot of fun writing fish functions, and I think they look beautiful. I'm going to do some more, and then look into some of the extra fish tools for organizing and sharing my functions among my different computers.

Happy hacking!