zsh와 작별하고 fish로 완전히 전환
fish라는 이름은 사실 아주 오래전에 들어봤습니다. 예전에 oh-my-zsh를 처음 쓰기 시작했을 때, oh-my-zsh의 많은 편리한 기능들, 예를 들어 기록을 바탕으로 회색으로 제안을 보여 주는 autosuggestions, 입력할 때의 구문 강조, 더 똑똑한 자동 완성 같은 것들이 사실은 zsh에서 fish가 기본 제공하는 능력을 재현한 것이라는 걸 알게 되었습니다.
나중에 oh-my-zsh에서 zinit으로 갈아탄 것도 속도 때문이었습니다. zinit은 확실히 빨랐지만, 속도를 위해 설정에는 온통 wait, lucid, atinit 같은 것들로 가득했고, 자동 완성은 따로 compinit 의 시점까지 정해야 했으며, 얼마 지나면 다시 유지보수해야 했고, 머릿속 부담이 아주 컸습니다. 그때 무엇을 하려 했는지 늘 기억나지 않았거든요😭.
얼마 전 fish가 4.0부터 Rust로 완전히 다시 작성되었다는 걸 우연히 보게 되었습니다. 저는 Rust로 만들어진 것에 늘 호감이 조금 더 가서, 바로 설치해서 써 봤습니다. 굳이 말하자면, 이름값을 하는 fish답게 플러그인이 하나도 없는데도 제가 zsh에서 몇 년이나 씨름하며 겨우 설정했던 기능들을 이미 갖추고 있었습니다. 그래서 며칠 지나지 않아 기본 셸을 통째로 fish로 바꿨습니다.
설치 후 바로 사용
막 설치한 fish는 아무 설정도 없는데도 벌써 이런 것들을 갖추고 있었습니다.
- 자동 제안. 기록과 현재 디렉터리의 파일을 바탕으로 입력할 때 회색 글자로 제안을 보여 주고, 오른쪽 방향키를 누르면 받아들이며,
Alt + →는 단어 하나만 받아들입니다. - 구문 강조. 명령이 존재하면 한 가지 색으로 표시하고, 존재하지 않으면 바로 빨간색으로 표시하며, 경로가 존재하면 밑줄도 그어 줍니다.
- 자동 완성. 많은 명령의 자동 완성은 fish가 man page에서 자동으로 분석해 내기 때문에 파라미터 설명까지 포함되어 있고, Tab 한 번이면 볼 수 있습니다.
- 시작 속도. 지연 로딩 같은 체계가 필요 없고, 열면 바로 뜹니다.
이것들은 zsh에서는 각각 zsh-autosuggestions, zsh-syntax-highlighting, zsh-completions에 fzf-tab까지 더해야 구현할 수 있었고, 그것들의 로드 순서까지 신경 써서 정해야 했습니다.
설치
제 Mac은 지금 nix-darwin으로 관리하고 있어서 fish도 그것으로 설치합니다. nix-darwin의 programs.fish.enable 는 fish를 설치하고 Nix 환경까지 설정해 주며, environment.shells 는 fish를 /etc/shells 에 추가합니다.
{ pkgs, ... }:
{
programs.fish.enable = true;
environment.shells = [ pkgs.fish ];
}
darwin-rebuild switch 를 실행한 뒤, 다시 chsh 로 기본 셸을 바꿉니다.
chsh -s /run/current-system/sw/bin/fish
여기서는 /run/current-system/sw/bin/fish를 사용합니다. 이것은 /nix/store 안의 특정 경로가 아니라, 항상 현재 시스템 버전을 가리키는 링크이기 때문입니다. 그래서 나중에 fish가 업그레이드되어도 chsh를 다시 실행할 필요가 없습니다.
설정 파일 구조
fish의 설정은 모두 ~/.config/fish 아래에 있습니다. zsh가 모든 것을 하나의 .zshrc 에 밀어 넣는 것과 달리, fish는 설정을 나누는 것을 원래부터 권장합니다.
~/.config/fish/
├── config.fish # 主配置
├── conf.d/ # 启动时会自动加载的片段
│ ├── 05-env.fish
│ ├── 10-colors.fish
│ ├── 20-abbrs.fish
│ └── 30-integrations.fish
├── functions/ # 一个文件一个函数,用到的时候才加载
│ ├── dog.fish
│ ├── git-amend-date.fish
│ ├── list_node_modules.fish
│ └── yy.fish
└── fish_plugins # fisher 的插件列表
conf.d 안의 파일은 파일 이름 순서대로 로드되기 때문에 앞에 숫자를 붙였습니다. functions 는 fish의 자동 로드 디렉터리로, 함수가 처음 호출될 때 fish가 해당 파일을 읽습니다. 그래서 함수를 아무리 많이 작성해도 시작 속도가 느려지지 않습니다. 이는 제가 예전에 zinit에서 모듈을 나눈 사고방식과 비슷한데, 다만 fish에서는 기본 지원된다는 점이 다릅니다.
config.fish
메인 설정에는 주로 PATH 와 몇 가지 도구의 초기화가 들어 있습니다.
set -g fish_greeting
fish_add_path -g /run/current-system/sw/bin /nix/var/nix/profiles/default/bin
fish_add_path -g $HOME/bin $HOME/go/bin $HOME/.local/bin $HOME/.cargo/bin
if test -x /opt/homebrew/bin/brew
fish_add_path -g /opt/homebrew/bin /opt/homebrew/sbin
end
set -gx LANG C.UTF-8
set -gx LC_ALL C.UTF-8
set -gx FZF_DEFAULT_COMMAND 'fd --type file --hidden --no-ignore'
if status is-interactive
set -gx GPG_TTY (tty)
if type -q mise
mise activate fish | source
end
if type -q starship
starship init fish | source
end
if type -q zoxide
zoxide init fish | source
end
if type -q direnv
direnv hook fish | source
end
end
set -g fish_greeting 는 fish가 시작할 때마다 출력하는 환영 문구를 끕니다. fish_add_path 는 자동으로 중복을 제거하고, 경로가 존재하지 않으면 그냥 건너뛰어서, zsh에서 한 줄씩 export PATH=... 쓰는 것보다 훨씬 깔끔합니다.
status is-interactive 는 현재 셸이 대화형인지 판단하는 데 사용합니다. 대화형일 때만 starship, zoxide 같은 것들을 초기화하면 되므로, fish로 스크립트를 실행할 때는 생략할 수 있습니다. type -q 는 명령이 존재하는지 확인합니다. 덕분에 starship이 아직 설치되지 않은 머신으로 옮겨도 오류가 나지 않습니다.
변수와 별칭
fish의 변수는 set 로 설정하며, -g 는 전역 변수이고, -x 는 환경 변수로 내보낸다는 뜻입니다. 그래서 zsh의 export FOO=bar 는 fish에서 이렇게 씁니다: set -gx FOO bar.
별칭 쪽은, fish에 alias 외에도 abbr가 있는데, 이는 약어입니다.
status is-interactive; or return
abbr -a fzf fzfp
abbr -a cat bat
abbr -a c clear
alias ls 'eza --group-directories-first'
alias la 'eza -la --group-directories-first'
alias ll 'eza -l --group-directories-first'
alias l 'eza -1 --group-directories-first'
abbr 와 alias 가 다른 점은, cat 을 입력하고 스페이스를 누르면 명령줄에서 바로 bat로 확장된다는 것입니다. 따라서 실제 실행되는 명령과 기록에 남는 명령은 모두 확장된 뒤의 완전한 명령이라서, 다른 사람이 제 터미널 녹화를 봐도 알아볼 수 있습니다. 저는 지금 보통 어떤 명령을 대체하고 싶을 때 abbr를 쓰고, ls 처럼 기본 인자 몇 개만 추가하고 싶은 경우에는 alias 를 사용합니다.
zsh 함수 옮겨 오기
fish의 문법은 bash, zsh와 호환되지 않아서 기존 함수를 모두 다시 작성해야 했습니다. 다행히 대부분의 함수가 길지 않아서 고치는 데 오래 걸리지 않았습니다. 현재 DNS가 Surge인지 판단하는 제 dog 함수를 예로 들면, zsh에서는 이렇습니다.
DOG_SURGE_DNS="198.18.0.2"
DOG_DEFAULT_DNS="https://dns.alidns.com/dns-query"
function dog() {
if [[ "$@" == *@* ]]; then
doggo "$@"
else
current_dns=$(cat /etc/resolv.conf | grep '^nameserver' | awk '{print $2}' | head -n 1)
if [ "$current_dns" != "$DOG_SURGE_DNS" ]; then
doggo "$@"
else
doggo "$@" @"$DOG_DEFAULT_DNS"
fi
fi
}
fish로 바꾼 뒤에는 functions/dog.fish 에 둡니다.
function dog --description 'Run doggo with Surge-aware DNS fallback'
set -l dog_surge_dns 198.18.0.2
set -l dog_default_dns https://dns.alidns.com/dns-query
if string match -rq '@' -- "$argv"
doggo $argv
return
end
set -l current_dns (awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf)
if test "$current_dns" != "$dog_surge_dns"
doggo $argv
else
doggo $argv @"$dog_default_dns"
end
end
몇 가지 눈에 띄는 차이가 있습니다. 인자는 $argv이고, $@가 아닙니다. 명령 치환에는 괄호 (...)를 사용하고, $(...) 는 사용하지 않습니다. if 와 function 은 모두 end 로 끝나며, fi 와 중괄호가 없습니다. 문자열 처리에는 내장 string 명령이 있는데, string match -rq 는 정규식으로 매칭하고, 결과만 반환하며 출력하지 않습니다. 또한 fish의 변수는 기본적으로 공백으로 분리되지 않아서, zsh처럼 곳곳에 따옴표를 붙일 필요도 없습니다.
함수에는 --description을 붙일 수도 있는데, 자동 완성할 때 표시되어서 한참 뒤에 다시 봐도 이 함수가 무엇을 하는지 알 수 있습니다.
색상
fish의 기본 색 구성은 제가 예전에 쓰던 zsh-syntax-highlighting과 조금 달라서, 수년 동안 써 온 색 구성이 갑자기 바뀌니 꽤 어색했습니다. 그래서 저는 conf.d/10-colors.fish 에서 강조 색상을 zsh 쪽과 비슷하게 맞췄습니다.
status is-interactive; or return
set -g fish_color_command green
set -g fish_color_keyword yellow
set -g fish_color_quote yellow
set -g fish_color_error red --bold
set -g fish_color_comment brblack --bold
set -g fish_color_autosuggestion brblack
set -g fish_color_valid_path --underline
명령은 초록색, 잘못 입력하면 굵은 빨간색, 자동 제안은 어두운 회색으로 예전과 똑같아서, 넘어온 뒤에도 거의 차이를 느낄 수 없습니다.
플러그인
fish에 기본으로 있는 것만으로도 충분하지만, 몇 가지는 여전히 갖고 싶었습니다. fish의 플러그인 관리자로는 fisher를 사용합니다. 아주 간단해서 플러그인 목록은 fish_plugins 파일에 한 줄에 하나씩 적습니다.
jorgebucaran/fisher
PatrickF1/fzf.fish
jhillyerd/plugin-git
xxxbrian/fzf-tab.fish
이 파일을 고친 뒤 fisher update 를 실행하면 목록에 따라 플러그인을 설치하거나 삭제합니다.
fzf.fish
fzf.fish 는 fish에 fzf 단축키 세트를 추가해 기록, 파일, git log, git status, 프로세스, 변수를 검색할 수 있게 해 줍니다. 저는 제 습관에 맞게 키를 다시 바인딩했습니다.
if type -q fzf_configure_bindings
fzf_configure_bindings --history=\cr --directory=\e\cf --git_log=\e\cl --git_status=\e\cs --processes=\e\cp --variables=\cv
end
그중 가장 자주 쓰는 것은 Ctrl + R 로 기록을 검색하는 것이며, zsh에서 fzf를 쓰던 방법과 같습니다.
plugin-git
plugin-git 는 oh-my-zsh의 git 플러그인에 있던 약어 세트를 fish로 옮겼습니다. gst, gco, gcmsg 같은 것들이 여전히 있습니다. 게다가 fish에서는 이것들이 abbr 로 구현되어 있어서, 입력하면 바로 완전한 git 명령으로 확장됩니다.
fzf-tab.fish
zsh에서는 fzf-tab에 크게 의존했습니다. Tab을 누를 때 fzf로 자동 완성을 선택하고, 파일과 디렉터리를 미리 볼 수 있었죠. fish에서는 대응하는 플러그인이 fifc인데, 사용법이 비슷하고 각종 미리보기도 지원합니다.
그런데 제가 지금 쓰는 터미널은 Ghostty입니다. fifc가 Ghostty에서 이미지를 미리 볼 때 몇 가지 문제가 있습니다. 예를 들어 이미지가 화면에 남아 있거나, 이미지와 텍스트 미리보기 사이를 전환할 때 깜빡이는 문제입니다. 그래서 저는 포크해서 xxxbrian/fzf-tab.fish로 바꿨습니다. 주로 이미지 미리보기가 Ghostty가 지원하는 kitty 그래픽 프로토콜을 사용하도록 했고, 미리보기를 전환할 때 이전 이미지를 먼저 지우도록 했으며, 미디어 파일 미리보기도 훨씬 매끄러워졌습니다.
이 플러그인은 자동 완성 시 파일을 바로 열 때 사용할 편집기 하나만 설정하면 됩니다. 저는 Helix를 우선 사용하고, 없으면 Neovim으로 대체하도록 했습니다.
if not set -q fifc_editor
if type -q hx
set -gx fifc_editor hx
else if type -q nvim
set -gx fifc_editor nvim
end
end
POSIX 호환
fish의 가장 자주 언급되는 단점은 POSIX와 호환되지 않는다는 점입니다. bash와 zsh의 문법은 fish에서 그대로 쓸 수 없는 경우가 많습니다. 예를 들어 인터넷에서 복사한 한 줄짜리 export FOO=bar && some_command나, for ... do ... done 이 들어간 반복문을 fish에 붙여 넣으면 오류가 납니다.
하지만 이 일이 제게 미치는 영향은 생각보다 훨씬 작았습니다. 스크립트 파일에는 원래 자체 shebang이 있어서, #!/bin/bash 가 적힌 스크립트는 fish에서 실행해도 여전히 bash가 실행을 맡으므로 전혀 영향을 받지 않습니다. 평소에 마주치는 것은 명령줄에서 임시로 입력하는 것들뿐입니다. 요즘은 조금 복잡한 파이프 명령과 스크립트는 대부분 AI에게 작성하게 하고, 저는 fish를 쓴다고 말하거나 아예 bash 스크립트로 작성하게 한 뒤 실행하면 되므로 문제도 크지 않습니다. 정말 필요할 때는 잠깐 bash 나 zsh 를 여는 것으로 끝입니다.
현재
이제 fish는 제 기본 셸입니다. zsh와 zinit 설정도 지우지 않고 남겨 두어서, 혹시 언젠가 필요하면 언제든 되돌아갈 수 있지만, 며칠 써 본 바로는 그럴 필요가 없어 보입니다.
돌이켜 보면, oh-my-zsh에서 zinit으로 넘어오면서 저는 zsh를 더 빠르고 더 쓰기 좋게 만드는 데 많은 시간을 썼는데, fish는 처음부터 이미 그런 상태였습니다. 설정 파일도 예전의 그 많던 zi ice에서 몇 개의 명확한 작은 파일로 바뀌었습니다.