macOSのターミナル(bash)のプロンプトを変えました。

# macOSのbashの設定を解析
利用しているのは macOS High Sierra (10.13.5) です。(現在はmacOS Catalina  10.15.1にアップデート済み)
単にターミナルのプロンプトに色をつけたかっただけなんですが、いろいろ調べてしまったので、備忘録を残します。

## ターミナル
私のmacOSのターミナルはデフォルトで`/bin/bash`を起動しています。[^1]
従って、bashの設定を変更すれば、プロンプトを変えられます。
Catalina以降のOSで新規に作成されたユーザーは、デフォルトのシェルがZshです。その場合は、ログインシェルをBashに変更するか、この記事は無視して、他を当たってください。`echo $0`と打つと、現在のシェルを確認できます。

[^1]: Catalinaから、デフォルトのシェルがZshに変わっていまが、それ以前のOSからCatalinaにアップデートした場合は、Bashが使われたままなので、Catalina上でも本記事の内容が有効です。Catalina以降で新規ユーザーを作るとZshが使われます。システム環境設定のユーザーとグループから自分のアカウントの詳細オプションでログインシェルを変えることができます。

## bashの設定ファイル
いろんな記事に、`~/.bashrc`ファイルに設定を記述して、`~/.bash_profile`ファイルから読み込ませる、と書いてあります。
結論からいうと、**この方法が正しい**です。
しかし、macOSには、最初はどちらのファイルもありません。どうやってデフォルトのプロンプトを指定しているのだろう、と思ってしまったのが運の尽きで、~~時間を無駄に費やして~~シェルの仕様を勉強してしまいました。

`~/.bashrc`、`~/.bash_profile`についてはこちらにLinuxの良い解説があります。
[.bash_profileと.bashrcについて by @shyamahira](https://qiita.com/shyamahira/items/260862743e4c9794b5d2)
ここには、
> とくに、Linuxデスクトップ環境では、テスクトップ上で新しいコマンド端末を開いた際は`.bashrc`のみが実行されます。

と書かれています。

macOSはLinuxデスクトップ環境とは異なるようです。
macOSのターミナルで新規ウィンドウを作ると、毎回`~/.bash_profile`が実行され、`~/.bashrc`は実行されません。
ターミナルのプロンプトで`bash`と打つと、子プロセスとしてbashが起動し、そのときは`~/.bashrc`が実行されます。
このため、`~/.bash_profile`から`~/.bashrc`を読み込ませれば、どちらの場合でも`/.bashrc`が有効になります。

## macOSのbashのデフォルト設定

さて、macOSのbashのデフォルト設定はどこでされているのでしょうか。
調べていたら、Appleの公式ドキュメントに以下の記述がありました。

> **`~/.profile`**:  全ログインシェルで自動的に実行される。
> **`~/.bash_profile`**:  `.profile`と似ているが、bashでだけ実行される。
> **`~/.bashrc`**: ログインしないbash (コマンドラインで`bash`と打つか、`#!/bin/bash`で始まるスクリプトを実行)のときに実行される。

出典：[Login Script](https://developer.apple.com/library/archive/documentation/OpenSource/Conceptual/ShellScripting/BeforeYouBegin/BeforeYouBegin.html#//apple_ref/doc/uid/TP40004268-CH1-SW15)

しかし、`~/.profile`ファイルも存在しませんでした。

Qiitaでこちらの記事もみつけました。
[.bash_profile ? .bashrc ? いろいろあるけどこいつらなにもの？ by @hirokishirai](https://qiita.com/hirokishirai/items/5a529c8395c4b336bf31)
しかし、OSについての記載がないのと、`~/.profile`の説明が上述のAppleの記載とは違っていたりして、より闇が深まってしまいました。
でも大事なヒントが得られました。`/etc/profile`です。

## /etc/profile
このファイルはmacOSにも最初からありました。

```bash:/etc/profile
# System-wide .profile for sh(1)

if [ -x /usr/libexec/path_helper ]; then
	eval `/usr/libexec/path_helper -s`
fi

if [ "${BASH-no}" != "no" ]; then
	[ -r /etc/bashrc ] && . /etc/bashrc
fi

```

コメントをみても、どうやらこのファイルは使われていそうです。
`${BASH-no}`がよくわからなかったのですが、試しに`echo ${BASH-no}`と打ってみたら`/bin/bash/`と表示されました。どうやら、環境変数`$BASH`が定義されていればその内容、なければ"no"になるようです。`$BASH`はbashの予約環境変数なので必ず定義されています。ということは、`/etc/bashrc`ファイルがあれば、実行されている筈です。

## /etc/bashrc

`/etc/bashrc`ファイルもありました。

```bash:/etc/bashrc
# System-wide .bashrc file for interactive bash(1) shells.
if [ -z "$PS1" ]; then
   return
fi

PS1='\h:\W \u\$ '
# Make bash check its window size after a process completes
shopt -s checkwinsize

[ -r "/etc/bashrc_$TERM_PROGRAM" ] && . "/etc/bashrc_$TERM_PROGRAM"
```

どうやらここでデフォルトプロンプトを指定している様です。
`$TERM_PROGRAM`環境変数は`Apple_Terminal`となっていて、`/etc/bashrc_Apple_Terminal`ファイルもありました。中身は長いので折りたたんで置きますが、ターミナルがレジュームできるのはこの定義によるようです。(いいかげんですが、コメントを訳しておきます。)

<details><summary>/etc/bashrc_Apple_Terminal</summary>
<div>

```bash:/etc/bashrc_Apple_Terminal
# Terminalのbashサポート

# ワーキングディレクトリ
#
# terminalにプロンプト毎の現在のワーキングディレクトリを伝える

if [ -z "$INSIDE_EMACS" ]; then
    update_terminal_cwd() {
	# "file:"スキームURLのディレクトリを識別する。
	# ローカルパスとリモートパスの区別のためホスト名を含む。
	
	# パス名のパーセントエンコード
	local url_path=''
	{
	    # LC_CTYPE=C をテキストを１バイトずつ処理するために使う。to process text byte-by-byte. Ensure that
	    # LC_ALL セットされていると邪魔をするので注意。
	    local i ch hexch LC_CTYPE=C LC_ALL=
	    for ((i = 0; i < ${#PWD}; ++i)); do
		ch="${PWD:i:1}"
		if [[ "$ch" =~ [/._~A-Za-z0-9-] ]]; then
		    url_path+="$ch"
		else
		    printf -v hexch "%02X" "'$ch"
		    # printfは127以上の値をマイナスとして扱ってFFで
		    # パディングするので切り詰める。
		    url_path+="%${hexch: -2:2}"
		fi
	    done
	}
	
	printf '\e]7;%s\a' "file://$HOSTNAME$url_path"
    }
    PROMPT_COMMAND="update_terminal_cwd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
fi


# レジュームサポート: シェル状態の保存/復元
#
# Terminalはターミナルセッション毎にユニークなIDを割り振って、
# 環境変数TERM_SESSION_IDで伝える。これにより、Terminalから
# 起動されるプログラムは、レジューム有効なTerminalが停止・再開するときに、
# アプリ固有の状態を保存し復元することができる。
#
# 次のコードは、シェルの保存/復元メカニズムを定義する。ユーザーは
# カスタム状態をshell_session_save_user_state関数を定義して
# 復元コマンドをセッションファイルに書くことで追加できる。例えば、
# 変数を保存するには:
#
#   shell_session_save_user_state() { echo MY_VAR="'$MY_VAR'" >> "$SHELL_SESSION_FILE"; }
#
# シェルが起動するときに、そのセッションファイルは実行される。
# 古いファイルは定期的に削除される。
#
# デフォルトの挙動は、bashコマンド履歴をターミナルセッション毎に
# 保存して復元するよう手配する。そして、
# 新しいセッションのためにコマンドをグローバル履歴にもマージする。
# このため、HISTSIZE と HISTFILESIZE を大きい値にしておくことが
# 推奨される。
#
# この挙動を無効にして単一の履歴を共有したかったら、
# SHELL_SESSION_HISTORY を 0 にするとよい。よくあるカスタマイズとして、 # 各プロンプトで履歴を操作して新しいコマンドを実行中のシェル間で
# 共有するというのがあるが、それには、
# 'shopt -s histappend'; を含めればよい。したがって、もしhistappend
# シェルオプションが有効だったら、デフォルトでセッション毎の履歴は無効になる。
# SHELL_SESSION_HISTORY を 1 にして、明示的に有効にすることもできる。
#
# セッション毎のコマンド履歴の実装は、共有グローバルコマンド履歴を
# 使っていて、HISTTIMEFORMAT変数 と互換性がない。-- 
# タイムスタンプが履歴の異なる場所に一貫性なく適用されてしまうため;
# したがって、もしHISTTIMEFORMATが定義されていたら、
# デフォルトでセッション毎の履歴は無効になる。
#
# この機能は PROMPT_COMMAND を使って各セッションの最初から
# セッション毎の履歴が有効になるようにしていることに注意すること。もし
# PROMPT_COMMAND をカスタマイズしたら以下の値を含めること。つまり、
#
#   PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }your_code_here"
#
# そうしないと、最初の復元まで、セッション毎の履歴が反映されなく
# なるだろう。
#
# この保存・復元メカニズムは以下のファイルがあったら無効になる。
#
#   ~/.bash_sessions_disable

if [ ${SHELL_SESSION_DID_INIT:-0} -eq 0 ] && [ -n "$TERM_SESSION_ID" ] && [ ! -e "$HOME/.bash_sessions_disable" ]; then
    
    # この設定を一回以上行わないこと。(これはユーザーの~/.bash_profile
    # で/etc/profileを呼び出さなければ起こらないし、それは
    # 普通は冗長で必要ない。).
    SHELL_SESSION_DID_INIT=1
    
    # セッションディレクトリ/ファイルを設定する
    SHELL_SESSION_DIR="$HOME/.bash_sessions"
    SHELL_SESSION_FILE="$SHELL_SESSION_DIR/$TERM_SESSION_ID.session"
    mkdir -m 700 -p "$SHELL_SESSION_DIR"
    
    #
    # 前のセッション状態を復元する
    #
    
    if [ -r "$SHELL_SESSION_FILE" ]; then
	. "$SHELL_SESSION_FILE"
	rm "$SHELL_SESSION_FILE"
    fi
    
    #
    # 注: 終了コードの中や、ユーザースタートアップファイルの後に
    # 実行されるコマンドは何であれ起動には絶対パスを使うこと。
    # サーチパスが変更されているかもしれないので。
    #
    
    #
    # セッション毎のコマンド履歴を手配する
    #
    
    shell_session_history_allowed() {
	# セッション毎の履歴が有効なときはリターンする。
	if [ -n "$HISTFILE" ]; then
	    # もしこのデフォルトがオフなら、後でチェックできるように
	    # ここではセットしない。オンなら、そのままにする。
	    local allowed=0
	    if shopt -q histappend || [ -n "$HISTTIMEFORMAT" ]; then
		allowed=${SHELL_SESSION_HISTORY:-0}
	    else
		allowed=${SHELL_SESSION_HISTORY:=1}
	    fi
	    if [ $allowed -eq 1 ]; then
		return 0
	    fi
	fi
	return 1
    }
    
    if [ ${SHELL_SESSION_HISTORY:-1} -eq 1 ]; then
	SHELL_SESSION_HISTFILE="$SHELL_SESSION_DIR/$TERM_SESSION_ID.history"
	SHELL_SESSION_HISTFILE_NEW="$SHELL_SESSION_DIR/$TERM_SESSION_ID.historynew"
	SHELL_SESSION_HISTFILE_SHARED="$HISTFILE"
	
	shell_session_history_enable() {
	    (umask 077; /usr/bin/touch "$SHELL_SESSION_HISTFILE_NEW")
	    HISTFILE="$SHELL_SESSION_HISTFILE_NEW"
	    SHELL_SESSION_HISTORY=1
	}
	
	# もしセッション履歴が既にあって空でない場合、それを使う。
	# そうでないなら、ユーザーがこれを有効・無効にした事を
	# 検出するまでは、共有履歴を使う。
	if [ -s "$SHELL_SESSION_HISTFILE" ]; then
	    history -r "$SHELL_SESSION_HISTFILE"
	    shell_session_history_enable
	else
	    # 最初のプロンプトで、セッション毎の履歴が有効か調べる。
	    # ユーザースクリプトの実行が完了するまで遅らせて、
	    # ユーザーが参加する・しないを選べるようにする。もしこれが
	    # 実行されなかったら(ユーザーがPROMPT_COMMANDを置き換え
	    # てしまったとき)、シェルの終了時にチェックするが、
	    # 最初の復元まではセッション毎の履歴を開始しない。 
	    
	    shell_session_history_check() {
		if [ ${SHELL_SESSION_DID_HISTORY_CHECK:-0} -eq 0 ]; then
		    SHELL_SESSION_DID_HISTORY_CHECK=1
		    if shell_session_history_allowed; then
			shell_session_history_enable
		    fi
		    # 可能ならこのチェックを外す。そうしない場合は、
		    # 1回だけ確認するのを上述の変数に依存する。
		    if [ "$PROMPT_COMMAND" = "shell_session_history_check" ]; then
			unset PROMPT_COMMAND
		    elif [[ $PROMPT_COMMAND =~ (.*)(; *shell_session_history_check *| *shell_session_history_check *; *)(.*) ]]; then
			PROMPT_COMMAND="${BASH_REMATCH[1]}${BASH_REMATCH[3]}"
		    fi
		fi
	    }
	    PROMPT_COMMAND="shell_session_history_check${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
	fi
	
	shell_session_save_history() {
	    # 新しい履歴を中間ファイルに保存してコピーできるようにする。
	    shell_session_history_enable
	    history -a
	    # もしセッション履歴がない場合は共有履歴をコピーしてくる。
	    if [ -f "$SHELL_SESSION_HISTFILE_SHARED" ] && [ ! -s "$SHELL_SESSION_HISTFILE" ]; then
		echo -ne '\n...copying shared history...'
		(umask 077; /bin/cp "$SHELL_SESSION_HISTFILE_SHARED" "$SHELL_SESSION_HISTFILE")
	    fi
	    # 新しい履歴をセッション毎の履歴と共有履歴ファイルに保存する。
	    echo -ne '\n...saving history...'
	    (umask 077; /bin/cat "$SHELL_SESSION_HISTFILE_NEW" >> "$SHELL_SESSION_HISTFILE_SHARED")
	    (umask 077; /bin/cat "$SHELL_SESSION_HISTFILE_NEW" >> "$SHELL_SESSION_HISTFILE")
	    : >| "$SHELL_SESSION_HISTFILE_NEW"
	    # 履歴ファイルサイズ上限があったらファイルに適用する。
	    if [ -n "$HISTFILESIZE" ]; then
		echo -n 'truncating history files...'
		HISTFILE="$SHELL_SESSION_HISTFILE_SHARED"
		HISTFILESIZE="$HISTFILESIZE"
		HISTFILE="$SHELL_SESSION_HISTFILE"
		HISTFILESIZE="$size"
		HISTFILE="$SHELL_SESSION_HISTFILE_NEW"
	    fi
	    echo -ne '\n...'
	}
    fi
    
    #
    # シェル終了時にセッション状態を保存するよう手配する。
    #
    
    shell_session_save() {
	# 現在の状態を保存する。
	if [ -n "$SHELL_SESSION_FILE" ]; then
	    echo -n 'Saving session...'
	    (umask 077; echo 'echo Restored session: "$(/bin/date -r '$(/bin/date +%s)')"' >| "$SHELL_SESSION_FILE")
	    declare -F shell_session_save_user_state >/dev/null && shell_session_save_user_state
	    shell_session_history_allowed && shell_session_save_history
	    echo 'completed.'
	fi
    }
    
    # 古いセッションファイルを削除する(1日一回まで)
    SHELL_SESSION_TIMESTAMP_FILE="$SHELL_SESSION_DIR/_expiration_check_timestamp"
    shell_session_delete_expired() {
	if ([ ! -e "$SHELL_SESSION_TIMESTAMP_FILE" ] || [ -z "$(/usr/bin/find "$SHELL_SESSION_TIMESTAMP_FILE" -mtime -1d)" ]); then
	    local expiration_lock_file="$SHELL_SESSION_DIR/_expiration_lockfile"
	    if /usr/bin/shlock -f "$expiration_lock_file" -p $$; then
		echo -n 'Deleting expired sessions...'
		local delete_count=$(/usr/bin/find "$SHELL_SESSION_DIR" -type f -mtime +2w -print -delete | /usr/bin/wc -l)
		[ "$delete_count" -gt 0 ] && echo $delete_count' completed.' || echo 'none found.'
		(umask 077; /usr/bin/touch "$SHELL_SESSION_TIMESTAMP_FILE")
		/bin/rm "$expiration_lock_file"
	    fi
	fi
    }
    
    # 終了時に保存したセッション状態を更新する。
    shell_session_update() {
	shell_session_save && shell_session_delete_expired
    }
    trap shell_session_update EXIT
fi
```

</div>
</detail>                                                                     

`/etc/bashrc`ファイルの属性は完全に読み込み専用で、`/etc/bashrc_Apple_Terminal`ファイルはスーパーユーザ以外読み込み専用となっていて、いずれも直接変更するのは気が引けました。
そこで、結局他の記事に示唆されているように、`~/.bash_profile`と`~/.bashrc`を新たに作成することにしました。

## bashのプロンプト仕様
bashのプロンプトは、環境変数 `$PS1` に指定する文字列で指定します。
デフォルトは`/etc/bashrc`にあるとおり、`\h:\W \u\$ `です。

<details>
<summary>文字列の詳細仕様</summary>
<div>

| 記号 | 意味 |
|:-:|:--|
|\a | ベルキャラクタ。プロンプトを表示するとき音が鳴る。|
|\d | 日付（曜日　月　日）|
|\D{format} | starttimeコマンドの結果を表示する。formatを空にするとローカル時間になる。波括弧は必要。 |
|\e | エスケープキャラクター "\" そのもの |
|\h | ホスト名(最初の "." まで)。コンピュータ名になる。|
|\H | ホスト名|
|\j | シェルが管理しているジョブの数|
|\l | シェルのターミナルデバイスのベース名 (ttys003等)|
|\n | 改行|
|\r | キャリッジリターン|
|\s | シェルの名前(-bash)|
|\t | 時間。24時間制(例: 23:55:59）|
|\T | 時間。12時間制(例: 11:55:59)|
|\@ | 時間。12時間制(例: 11:55 PM)|
|\A | 時間。24時間制(例: 23:55)|
|\u | ユーザー名|
|\v | シェルのバージョン|
|\V | シェルのバージョン＋パッチレベル|
|\w | 現在のフォルダ。`$HOME`(ホームディレクトリ)と一致する部分は~にする。`$PROMPT_DIRTRIM`変数を使う。|
|\W | `$PWD`のベース名。`$HOME`と一致する部分は~にする。
|\! | コマンドの履歴番号|
|\# | コマンドのコマンド番号|
|\$ | スーパーユーザーでは"#",それ以外は"$"|
|\nnn | アスキーコードnnnで指定した文字|
|\\ | バックスラッシュ"\"|
|\[ | 表示しない文字の開始。ターミナル制御シーケンスを含めるのに使う。|
|\] | 表示しない文字の終了|

出展:[Bash Reference Manual 6.9 Controlling the Prompt](https://www.gnu.org/software/bash/manual/html_node/Controlling-the-Prompt.html)

</div>
</details>

`PS1`以外に`PS2`、`PS3`、`PS4` というのもあり、それぞれ特定の状態でのプロンプト表示に使われます。とりあえず、`PS1`だけ変えます。

私は">"が好きなのと、プロンプトを目立たせたいので、二行にする事にしました。

```
PS1="\t(\w)\n>"
```

## 色をつける

プロンプトに色をつける記事も既にいくつかあります。
[Macのbash(ターミナル)を鮮やかにする by @yoshi-yoshi](https://qiita.com/yoshi-yoshi/items/a0c76f660572652cd55c)
[macのターミナルの文字色を変更する by @hirose](https://qiita.com/hirose/items/bbfeeb3fe50725f2a7a7)
どちらの記事でも、`~/.bashrc`に設定を記述して、`~/.bash_profile`から読み込ませています。

色はANSIエスケープシーケンスとして定義されているそうで、英語Wikipediaに解説がありました。
[ANSI Escape Code](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)
3/4bitの定義をみると、`\033[文字色;背景色m`として色を指定し、戻すには`\033[0m`とすればよいようです。
また、これらが表示される文字ではない事を示すために`\[`と`\]`で囲む必要もあります。
<details><summary>3/4bit指定色の一覧</summary>
<div>

|色 |文字色|背景色|CSS|
|--:|:-:|:-:|:--|
|黒|30|40|`rgb(0,0,0)`|
|赤|31|41|`rgb(194,54,33)`|
|緑|32|42|`rgb(37,188,36)`|
|黄|33|43|`rgb(173,173,39)`|
|青|34|44|`rgb(73,46,225)`|
|紫|35|45|`rgb(211,56,211)`|
|水|36|46|`rgb(51,187,200)`|
|白|37|47|`rgb(203,204,205)`|
|明黒|90|100|`rgb(129,131,131)`|
|明赤|91|101|`rgb(252,57,31)`|
|明緑|92|102|`rgb(49,231,34)`|
|明黄|93|103|`rgb(234,236,35)`|
|明青|94|104|`rgb(88,51,255)`|
|明紫|95|105|`rgb(249,53,248)`|
|明水|96|106|`rgb(20,240,240)`|
|明白|97|107|`rgb(233,235,235)`|

出典：[ANSI Escape Code](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)

</div>
</details>

より複雑な8bit/24bitの指定もできるようです。

これらを参考に以下のようにファイルを作成しました。

```bash:~/.bash_profile
if [ -r ~/.bashrc ]; then
    . ~/.bashrc
fi
```

```bash:~/.bashrc
PS1="\[\033[97;100m\] \t (\w) \[\033[0m\]\n>"
```

## 結果
<img width="570" alt="bash_profile_result.png" src="https://qiita-image-store.s3.amazonaws.com/0/137262/91e87d6b-9105-9457-07f7-d954bea3bd27.png">


実は最初、他の記事を参考に調べ始めたときには、`$PS1`の設定を`~/.bash_profile`に書いておくだけでよさそうだと勘違いしていました。ターミナルのウィンドウを新たに起動したときは思ったように反映されましたが、プロンプトで`bash`と打ったときにはプロンプトが`"\s-\v\$ "`に変わってしまいました。このプロンプトはPS1のデフォルト値です。環境変数なのでてっきり親プロセスの情報を引き継ぐかと誤解したのですが、そんな筈はなく、`.bashrc`ファイルがない場合はデフォルトが適用されます。
結局、ログインシェルかどうかで適用するシェルスクリプトを変えるbashの仕組み上、`~/.bash_profile`から`~/.bashrc`を読み込ませるようにする事が必要だということが分かりました。

色を変えるだけの事で、ずいぶん長く遊んでしまいましたが、これで多少ターミナル画面が見やすくなったような気がします。

## OSのアップデートについて
macOSをアップデートしても、シェル環境は維持されます。プロンプトの設定もこれまで維持されてきました。Catalinaでデフォルトのログインシェルが変わりましたが、既存のユーザは維持されました。Bashのバージョンもずっと古いままで、アップデートされることはありませんでした。今後も、おそらく維持されるでしょう。開発者目線の嬉しい計らいだなと思います。