「ターミナル、結局どこに何を書いたらいいんだっけ」。GPU アクセラのあるエミュレータ、ペイン分割するマルチプレクサ、シェル、プロンプト、補完支援、ls の代替、cat の代替、cd の代替……ひとつひとつは分かるんですが、いざ Nix で配ろうとすると、「設定ファイルは home.file、シェル略語は programs.fish、パッケージは common/packages.nix、launchd は darwin/...」と、置き場所が増えるたびに記憶があやふやになります(半年後の自分は他人だと思ったほうが安全です)。
「モダン CLI 全部入れろって聞くけど、実際どうなの?」という温度感に正直に答えると、個々のツールよりも、設定の住所をどう決めるかのほうが寿命に効きます。ツールは流行り廃りで入れ替わる前提でいい。住所のルールだけ、半年後の自分が読み返せる形で固定しておく、というのが運用していて効きました。
前回までで macOS の開発環境を nix-darwin + Home Manager でコードに固める基本と、home.activation を冪等にして symlink 入れ子を防ぐ実装パターンを書きました。今回はその第 3 弾、system.defaults でも home.activation でもない、もう一つの主戦場——ターミナル一式 に踏み込みます。
この記事は、Ghostty(エミュレータ)+ zellij(マルチプレクサ)+ Fish(シェル)+ Starship(プロンプト)+ モダン CLI 群(eza / bat / fd / ripgrep / fzf / zoxide / mise)を、設定の性質ごとに置き場所を分けて Nix から束ねて配る、実際に稼働している構成のメモです。コードはすべて実機で動いている dotfiles から引いています。
この記事で学べること
- Ghostty / zellij / Fish / Starship / モダン CLI 群の役割分担と、Nix のどこに何を書くかの置き場所マップ
- Ghostty の Quick Terminal と
macos-option-as-altで Alt を Meta として zellij に渡す設定 default_shell "fish"とusers.users.${username}.shell = pkgs.fishを揃えてログインシェルを Nix 管理下に入れる方法programs.fish.shellAbbrsに Nix 式の attrset で略語を注入し、Linux/Darwin で共有する設計lib/cli-packages.nixでフルセット(host)と最小セット(container)をmodeで切り替えるパターン
前提条件
nix-darwin+home-managerを flake で input 済み- 前回の
home.file基本パターンを一度書いたことがある - Apple Silicon の macOS(
aarch64-darwin)で検証。Linux マシン側でもcommon/packages.nix経由で同じ略語・同じパッケージが使えるように設計しています
全体図:何をどこに書くか
最初に、置き場所マップだけ提示します。これが頭に入っていれば、後の章は「該当箇所をどう書くか」だけの話になります。
| 設定の性質 | 配置場所 | 例 |
|---|---|---|
| 静的なテキスト設定ファイル | home-manager/home/default.nix の home.file で recursive = true symlink | .config/ghostty/config、.config/zellij/config.kdl、.config/mise/config.toml |
| シェル略語・関数(Nix 式) | home-manager/programs/fish.nix の programs.fish.shellAbbrs / .functions | ls = "eza ..."、yy(yazi で cwd 引き継ぎ) |
| CLI ツール(パッケージ) | lib/cli-packages.nix(host / container 切替)→ home-manager/home/default.nix から参照 | bat / eza / fd / fzf / mise / ripgrep / starship / zellij / zoxide |
| GUI アプリ(Cask) | darwin/default.nix の homebrew.casks | ghostty |
| ログインシェル指定 | darwin/default.nix の users.users.${username}.shell と programs.fish.enable | pkgs.fish |
「性質ごとに置き場所が違う」ということだけ覚えておけば、新しいツールが増えたときに「これはどこに書く?」で悩む時間はだいぶ減ります。覚えるのは置き場所のルール 1 つで、個別の構文はその都度 ref を見ればいい、というのが Nix を運用していて効いた割り切りでした(人間の記憶ではなく、構造に覚えさせる方針です)。
Ghostty 側:GPU アクセラと Quick Terminal を Nix 経由で
Ghostty は GPU アクセラ対応の比較的新しいターミナルエミュレータで、設定はテキストファイル ~/.config/ghostty/config を読みます。Nix 管理下に入れるなら、本体は homebrew.casks から Cask で配り、設定ファイルだけ home.file で symlink する、という分担になります。
# darwin/default.nix
homebrew = {
enable = true;
casks = [
"ghostty"
# ... 他の Cask
];
};
# home-manager/home/default.nix
home.file = {
".config/ghostty" = {
source = ./file/ghostty;
recursive = true;
};
# ... 他の .config 配下
};
recursive = true でディレクトリ単位ではなくファイル単位の symlink になり、Ghostty が将来サブファイル(テーマファイルなど)を増やしても巻き添えで壊れにくくなります。前回扱った「キャッシュがリポジトリに書き戻る事故を防ぐ」のと同じ理由です。
実際の config 中身はこれだけ(短いです)。
# Appearance
background-opacity = 0.85
background-blur-radius = 20
# Option キーを Alt(Meta)として動作させる(zellij のキーバインド用)
macos-option-as-alt = true
# Quick Terminal
quick-terminal-screen = main
keybind = global:alt+space=toggle_quick_terminal
# Alt+Arrow をデフォルトの単語移動(ESC b/f)から解放し、zellij に渡す
keybind = alt+right=unbind
keybind = alt+left=unbind
ポイントは 2 つあります。
1 つ目は macos-option-as-alt = true。macOS の Option キーはデフォルトでは特殊文字入力(option + a → å など)に割り当たっています。これを Alt(Meta)として通すよう Ghostty に明示しないと、後段の zellij が Alt キーを使ったキーバインドを受け取れません(オシャレな記号が出るだけで、ペインも動かない、という静かな絶望)。
2 つ目は quick-terminal-screen = main + keybind = global:alt+space=toggle_quick_terminal。グローバルショートカット(Alt+Space)でメインディスプレイにスライド表示される「Quick Terminal」を有効にしています。Slack やブラウザ作業中に「ちょっとコマンド打ちたい」が出たとき、ウィンドウ切り替えなしで降りてくるターミナルが手前に来ます。地味な機能のはずが、Cmd+Tab で目を泳がせる回数が確実に減って、1 日の累計だと結構な時間が浮きます(睡眠時間に換算すると数分、ぐらいの規模ですが、それでも効きます)。
最後の alt+right=unbind / alt+left=unbind は、Ghostty 側のデフォルトキーバインド(Alt+Arrow で ESC b / ESC f、つまり単語単位のカーソル移動)を意図的に外して、Alt+矢印を zellij に渡すための設定です。レイヤーが Ghostty → zellij → fish と 3 枚あるので、どの層で誰が何を受け取るかを意図的に設計しないと、キーバインドの取り合いが起きます。
zellij 側:tmux からの移植を home.file で配る
シェルのペイン分割・タブ・セッション管理は zellij に寄せています。Rust 製で動作が軽く、設定が KDL ファイル一つで完結するのが選定理由です。.tmux.conf がリポジトリ内に残ってはいますが、現役運用は zellij、.tmux.conf は参考資料の置物、というのが今の実態です。
zellij 設定ファイルも Ghostty と同じパターンで配ります。
# home-manager/home/default.nix
home.file = {
".config/zellij" = {
source = ./file/zellij;
recursive = true;
};
};
中身(config.kdl)の冒頭にはコメントで出自を明記してあります。
// Zellij config — tmux.conf からの移植
// 大西配列: t=left, n=down, r=up, s=right
// テーマ・外観
theme "default"
default_layout "compact"
default_shell "fish"
pane_frames false
default_shell "fish" の宣言は地味ですが重要で、ここを書いておかないとログインシェルが zsh のままだと意図せず zsh が立ち上がる、というシェル不一致が起きます。実際にこれを踏んだ修正記録があって、zellij が新ペイン/タブで $SHELL を起動する際、ログインシェルが zsh のままだったため意図せず zsh が立ち上がっていた という症状でした。対策として、後述の users.users.${username}.shell = pkgs.fish でログインシェルを fish に統一しつつ、zellij 側にも default_shell "fish" を明示する二段構えにしてあります。「片方だけ」では足りないんですよね、こういうの。
キーバインドはこんな雰囲気で並びます。
keybinds clear-defaults=true {
shared_except "locked" {
// ペイン移動(大西配列: Alt+t/n/r/s)
bind "Alt t" { MoveFocus "Left"; }
bind "Alt n" { MoveFocus "Down"; }
bind "Alt r" { MoveFocus "Up"; }
bind "Alt s" { MoveFocus "Right"; }
// ペイン分割
bind "Alt Right" { NewPane "Right"; }
bind "Alt Down" { NewPane "Down"; }
// セッション操作(tmux 互換プレフィックス)
bind "Ctrl a" { SwitchToMode "Tmux"; }
}
}
Ctrl a を tmux 互換のプレフィックスに割り当ててあって、長年の指の記憶を活かせるようにしてあります(指は脳より頑固です)。
Fish 側:shellAbbrs を Nix 式の attrset で注入する
シェルは Fish。Nix から programs.fish モジュール経由で設定する利点は、シェル略語(abbr)と関数を Nix の attrset として書ける ことです。これが効きます。
# home-manager/programs/fish.nix
{ pkgs, ... }:
let
common = import ./common.nix;
shellCommon = import ./shell-common.nix { inherit pkgs; };
in
{
programs.fish = {
enable = true;
shellInit = ''
# PATH設定
fish_add_path $HOME/.nix-profile/bin
${shellCommon.getPathConfig.darwin}
${shellCommon.getPathConfig.linux}
starship init fish | source
zoxide init fish | source
mise activate fish | source
# ローカル設定を読み込む
if test -f ~/.config/fish/config.fish.local
source ~/.config/fish/config.fish.local
end
'';
functions = {
# yazi でカレントディレクトリを変更
yy = ''
set tmp (mktemp -t "yazi-cwd.XXXXXX")
yazi $argv --cwd-file="$tmp"
if set cwd (cat -- "$tmp"); and [ -n "$cwd" ]; and [ "$cwd" != "$PWD" ]
cd -- "$cwd"
end
rm -f -- "$tmp"
'';
};
shellAbbrs = common.shellSortcuts;
};
}
ポイントは 3 つあります。
1 つ目、shellInit の中で starship init fish | source / zoxide init fish | source / mise activate fish | source を順に流しています。Starship(プロンプト)、zoxide(賢い cd)、mise(言語ランタイム管理)はそれぞれ「シェル起動時に init コマンドを実行して関数群を読み込む」型のツールで、Fish の shellInit がその受け皿になります。
2 つ目、shellAbbrs = common.shellSortcuts の右辺は別ファイル(programs/common.nix)から import した attrset です。Linux 側と Darwin 側で同じ略語を使いたいので、共有可能な場所に切り出してあります。common.nix の中身はだいたいこんな感じです(一部抜粋)。
# home-manager/programs/common.nix
{
shellSortcuts = {
# eza を ls として利用
ls = "eza --icons --git --time-style relative -la";
lt = ''eza --icons --git --time-style relative --tree -aI "node_modules|.git|.cache"'';
# bat を cat として利用
cat = "bat";
# rip を rm として利用(復元可能)
rm = "rip";
# 選択した過去の実行コマンドをクリップボードにコピー
h = "echo -n $(history | fzf +s --layout=reverse) | pbcopy";
# 選択したローカルリポジトリリストへの移動を g と定義
g = ''cd "$(ghq list --full-path | fzf --layout=reverse --preview 'eza --icons --git --time-style relative -la {1}')"'';
# git worktree 移動(fzf で選択、ブランチ名表示)
w = ''cd "$(git worktree list | awk '{print $3, $1}' | sd '\[|\]' "" | fzf --layout=reverse --prompt 'WORKTREE>' --with-nth=1 --preview 'git -C {2} log --oneline -20' | awk '{print $2}')"'';
# lazygit
lg = "lazygit";
# yazi
y = "yazi";
};
}
ls = "eza --icons --git --time-style relative -la" のように、標準コマンドそのままの名前で別ツールを呼び出す形にしておくと、頭の中の指の動きを変えずに新ツールに移行できます。rm = "rip" は地味に救われた回数が多くて、rip は削除を即時破棄ではなくゴミ箱送りにする実装なので、rm -rf で深夜に泣くタイプの事故を構造的に防げます(脳より構造で守るのが安全側)。
3 つ目、functions には yy のような複行関数も同居させられます。yy は yazi(TUI ファイラ)で選んだディレクトリにシェルの cwd を引き継ぐラッパーです。「ファイラで移動 → シェルに戻ったら元の場所」というよくある詰みを、mktemp + --cwd-file 経由で解消しています。
CLI ツール群:lib/cli-packages.nix で host / container を 1 ソース化
Fish の shellAbbrs から eza / bat / fd / fzf / ripgrep / lazygit あたりが当たり前のように呼ばれているので、これらは Nix から確実にインストールされている必要があります。パッケージリストは lib/cli-packages.nix に切り出して、mode 引数で host 用フルセットと container 用最小セットを切り替え られるようにしてあります。
# lib/cli-packages.nix
{
pkgs,
mode ? "host",
}:
let
# 共通 (host / container 両方)
common = with pkgs; [
bat
coreutils
curl
eza
fd
fzf
gh
ghq
git
jq
lazygit
mise
rip2
ripgrep
sd
starship
tldr
which
zellij
zoxide
];
# host のみ (開発マシン専用、container には不要)
hostOnly = with pkgs; [
act
fastfetch
ffmpeg
flyctl
opentofu
procs
rclone
];
in
if mode == "host" then
common ++ hostOnly
else if mode == "container" then
common
else
throw "cli-packages.nix: unknown mode '${mode}', expected 'host' or 'container'"
この切り出しは、もともと「同じ CLI tool セットをローカル開発マシンと Docker コンテナの両方で使いたい」というモチベーションで作りました。共通部分(common)はどちらでも使い、ホスト固有のもの(hostOnly、たとえば ffmpeg のような大物)はコンテナイメージから除外する、という分担です。home-manager 側は mode = "host" で参照します。
# home-manager/home/default.nix
{
cliPackages = import ../../lib/cli-packages.nix {
inherit pkgs;
mode = "host";
};
}
切り出し時に注意したのは、refactor 自体を behavior-preserving に保つことです。実際の移行コミットメッセージにも nix store diff-closures で差分なしを確認済み という旨が残っていて、nix store diff-closures ./result-before ./result-after で closure 内容が完全に同一であることを検証してから本体差し替えをやりました。設定リファクタリングは「動いていたはず」が「壊れている」に化けやすいので、closure 比較を回しておくと安心です(精神衛生に効きます)。
ログインシェルを Nix 管理下に:pkgs.fish を $SHELL にする
ターミナルを束ねる仕上げとして、ログインシェル自体を Nix 管理下にしておくのが効きます。darwin/default.nix 側に以下を書きます。
# darwin/default.nix
{
# シェルの有効化設定
programs.fish.enable = true; # デフォルトシェルとして fish を有効化
programs.zsh.enable = false; # zsh は無効化
# ログインシェルを fish に変更($SHELL=fish になる)
users.users.${username} = {
shell = pkgs.fish;
home = "/Users/${username}";
};
# Touch ID で sudo を有効化
security.pam.services.sudo_local.touchIdAuth = true;
}
users.users.${username}.shell = pkgs.fish が肝心で、ここまで書いて初めて $SHELL が /run/current-system/sw/bin/fish のような Nix 管理下のパスを指します。これをやらずに programs.fish.enable = true だけだと、Fish はインストールされているのに $SHELL は zsh のまま、という宙ぶらりん状態が起きます(enable = true を書いたから安心、と思いきや、ログインシェルは別経路で決まっているわけです)。
そして前述のとおり、zellij 側にも default_shell "fish" を明示することで、二重保険になっています。「$SHELL を見るやつ」「/etc/passwd の shell を見るやつ」「自前で default_shell を持つやつ」が混在していて、片方だけ揃えても、もう片方が古い設定で起動してくる、というすれ違いを防ぐためです。
launchd は lib.optionalAttrs pkgs.stdenv.isDarwin で囲んでおく
ターミナルそのものとは少しズレますが、関連して触れておくべき設定があります。バックグラウンドエージェント(ログイン時に常駐させたいツール)の launchd 設定は、必ず lib.optionalAttrs pkgs.stdenv.isDarwin { ... } で囲んで、Linux 構成に影響が出ないようにします。KeepAlive の Crashed + 非 0 exit と ThrottleInterval=30 を組み合わせて、Docker Desktop の起動遅延や一時的なネットワーク断にも自動復旧する設計です。
これは前回扱ったhome.activation の冪等性の話とも繋がっています。マルチプラットフォーム構成では「この設定はどの OS で有効か」を明示しないと、別 OS のビルドが巻き添えで壊れる、という同じ思想が貫かれています。
設計のまとめ:性質で置き場所を分ける
最後に、冒頭の置き場所マップをもう一度貼っておきます。
| 設定の性質 | 配置場所 |
|---|---|
| 静的なテキスト設定ファイル(KDL / TOML / 独自フォーマット) | home.file で recursive = true symlink |
| シェル略語・関数(プログラマブル / 共有したい) | programs.fish.shellAbbrs / .functions に Nix 式で注入 |
| CLI ツール本体(パッケージ) | lib/cli-packages.nix で host / container を mode 切替 |
| GUI アプリ(Cask) | darwin/default.nix の homebrew.casks |
| ログインシェル / ユーザー設定 | darwin/default.nix の users.users.${username} |
| OS 固有のサービス(launchd) | lib.optionalAttrs pkgs.stdenv.isDarwin で囲む |
この分け方の利点は、「新しいツールを増やしたい」と思ったときに、どこに書けばいいかを 1 秒で決められる ことです。Ghostty のテーマを変えたい → .config/ghostty/config を編集(home.file 経由で配られる)。新しい略語が欲しい → programs/common.nix の shellSortcuts に 1 行追加。新しい CLI を試したい → lib/cli-packages.nix の common に名前を足す。それだけ。
逆に言うと、この分け方が崩れると—たとえばシェル略語を .config/fish/config.fish 側に直書きしてしまうと—Linux/Darwin で同じ略語が使えなくなったり、programs.fish の宣言と直書きの両方で同じ略語を別定義してしまったり、という「どっちが効いてるんだっけ問題」が静かに発生します。
まとめ
Ghostty + zellij + Fish + Starship + モダン CLI 群、というターミナル一式を Nix で束ねるとき、効くのは「設定の性質で置き場所を分ける」というルールひとつでした。静的テキストは home.file、シェル略語・関数は programs.fish に Nix 式で注入、CLI パッケージは lib/cli-packages.nix で host / container を切替、GUI は homebrew.casks、ログインシェルは users.users.${username}、OS 固有は lib.optionalAttrs で囲む。
新しい Mac を開いたとき nix run .#update 一発でターミナル一式が揃う、という気持ちよさは、毎日 1 mm ずつ性質ごとに正しい置き場所へ書き込んできたから手に入っているものです。次に新しいツールを足すときに「これはどこに書く?」で 1 秒迷わない構造が、半年後の自分にとって一番のリターンだと思っています。
次回は今回触れなかった home.activation の応用編か、Nix で配るには無理がある領域(IDE のローカルキャッシュ、ログイン項目、認証 keychain あたり)のどちらかを書く予定です。



