自作コマンドで cd したい！

# TL;DR

```
mycmd project_name
```

というコマンドを実行したときに`project_name`として登録したディレクトリに移動できたら便利。助かる。嬉しい。

案外一筋縄ではいかず泥臭くなっており、「cd したい」と題打ってはいますが、Pythonで自作コマンドを作るときに使えそうなテクニックをいくつか組み合わせた感じになっています。

読むのが面倒臭い人は

```
alias project_name="cd ~/develop/project1"
```

で代替するのが正攻法だと思います。結城浩先生の [yukitask](https://yukitask.hyuki.net/) はこっちの正攻法で実現しています。Simple is the best。

# 動機
CLIをいっぱい叩く人は自作コマンドを作りたくなるのは道理です。中でも地味に面倒臭いのがプロジェクト間のディレクトリの移動です。作業開始時、切り替え時などに頻繁に行う割に

1. なんて名前だったっけ？
2. 頭文字をタイプ
3. `ls`と`cd`とTabを連打

というストレスフルな動作が入ります。`pushd`,`popd`を使うと多少の効率化はできるものの、それでもやはり面倒臭いです。

そこで、プロジェクトのディレクトリで

```
$ proj -i devel
```

を入力することでこのディレクトリを`devel`という名前で登録し、

```
$ proj devel
```

と入力することでいつでも`devel`のディレクトリに移動できるようにしたいです。できたら嬉しいですね。

# 何が問題か？
**自作コマンドでシェルのカレントディレクトリを変更することは基本的にはできません**。数時間くらい調べたのですが見つからなかったので「それOOでできるよ」とご存知でしたら教えていただけると助かります。

ちなみにコマンドの alias が増えることを恐れなければ

```
alias devel="pushd ~/develop/project1"
```

という記述を`.bashrc`なり`.zshrc`なりに追加すれば

```
$ devel
```

と入力するだけで`~/develop/project1`へ`pushd`により移動することが可能です。結城浩先生の [yukitask](https://yukitask.hyuki.net/) はこの方法を取っています。

私は alias が他のコマンドと被るかどうか気遣いながら名前をつけるのが面倒だったことと、「あくまでも`proj`というコマンドにより操作している」ということを明確にしたかったので、最初に`proj`とコマンド名を宣言する方法にこだわることにしました。

# 実装
環境は

* OS: macOS Catalina
* terminal: iTerm2
* shell: zsh
* コマンド実装: Python

で行いますが、これから説明する方法は汎用性を考慮しており、シェルスクリプトの互換性さえあれば大抵どんな環境でも動くと思います。互換性がなかった場合も大筋は変わらないはずなのでご自身で多少改変してください。

キーになるのは

1. Pythonで実装されたコマンド本体としての`proj`
2. 本物の`proj`コマンドをオーバーラップするシェルスクリプトの`proj`

という2段階の構造で、シェルスクリプトのほうは`cd`コマンドを実行するためだけに存在し、他の高度な操作はPythonで組まれたコマンドが担当します。

ややこしいので、説明する間はPythonで実装したコマンドを`proj`、シェルスクリプトで実装したコマンドを`proj.sh`としておきます。実際に実装するときは拡張子は消しても大丈夫です。

大まかな流れとしては

1. `proj devel`という入力を`proj.sh`が受け取る。
2. `proj.sh`が`proj`を呼び出し移動先の情報を取得。
3. `proj.sh`が`cd`コマンドを実行してシェルのカレントディレクトリを変更する。

ということになります。実際にやろうとするといくつか注意点があるので、順に見ていきます。

## 1. シェルスクリプトでシェルのカレントディレクトリを変更する
`proj.sh`に以下の内容を記述します。

```shell:proj.sh
#!/bin/sh
cd test
```

これはシェルのカレントディレクトリを`test`に変更することを意図したスクリプトです。これを実行するには

```
$ ./proj.sh
$ source proj.sh
```

の2種類がありますが、`source`のほうを使ってください。`source`は現在のシェルでスクリプトを実行しますが、`.`は新しく生成したプロセスでシェルを起動してスクリプトを実行します。

つまり`./proj.sh`を実行すると`cd`は新しいシェルで実行され、スクリプトの実行が終わるとそのシェルは破棄されるので、`cd`の結果は現在のシェルに反映されません。`source`を使うとこの問題を回避できます。

## 2. proj という名前で Python のコマンドを登録する
自作コマンドの開発ディレクトリは以下のような構造にします。

```
proj
├── main.py
└── setup.py
```

そして`setup.py`に以下のコードを記述してください。

```python:setup.py
from setuptools import setup

setup(
    install_requires=[],
    entry_points={
        "console_scripts": [
            "proj = main:main"
        ]
    }
)
```
これで`proj`というコマンドを実行すると`main.py`の`main()`関数が呼び出されるようになります。開発中に手元の環境にこのコマンドをインストールするには`proj`のディレクトリで

```
$ pip install -e .
```

を実行します。

`main.py`は最初は適当なプログラムを書いておけばいいです。

```python:main.py
def main():
    print('test')

if __name__ == '__main__':
    main()
```

## 3. シェルスクリプトと設定ファイルの置き場所
`proj`のコマンドは設定ファイルを必要とするので、シェルスクリプト`proj.sh`も設定ファイルと同じ場所に置いておけばよいでしょう（そのほうがユーザがカスタマイズできて親切です）。

コマンドの設定ファイルは`~/.config/proj`という隠しディレクトリに保存します。`~/.config`はコマンドの設定を置いておくために一般的に用いられるディレクトリで、ここに置くのがお行儀がよいかと思われます。構成例は以下のような感じです。

```
~/.config/proj
├── projrc
├── proj.sh
└── projects.json
```

`projrc`はシェルで`proj`の設定を有効化するための`.zshrc`相当の設定ファイルです。`.zshrc`に以下のコマンドを追記して zsh 起動時に`projrc`が読み込まれるようにしておいてください。

```:~/.zshrc
source ~/.config/proj/projrc
```

`proj.sh`は『1. シェルスクリプトでシェルのカレントディレクトリを変更する』で作ったシェルスクリプトです。`projects.json`には`devel`がどのディレクトリを指しているのかといった情報を記録しておきます。

## 4. proj.sh から proj コマンドを呼び出す（一時的に alias を剥がす）
`proj devel`と入力したときに`proj.sh`が呼び出されるためには、当然

```
alias proj='source ~/.config/proj/proj.sh'
```

のような alias を作成することになります。しかし`source`コマンドで実行しているシェルスクリプトは現在のシェルで実行されるので、alias も反映されますから、シェルスクリプト内で`proj`を実行すると再び`proj.sh`が実行されてしまい、Pythonで実装したコマンドを実行することはできません。

bash および zsh では、バックスラッシュ`\`をコマンドの先頭につけることで alias を剥がすことができます。つまり上の`alias`を設定したあとでも

```
$ \proj
```

を実行すればPythonで実装したほうのコマンドが叩かれます。シェルスクリプト内でもこのルールは変わりません。したがって以下の手順で`proj.sh`で`proj`コマンドをオーバーラップすることが可能です。

まず`projrc`で alias を貼ります。

```:~/.config/proj/projrc
alias proj='source ~/.config/proj/proj.sh'
```

次に`proj.sh`で以下のように`proj`コマンドを呼び出します。

```shell:~/.config/proj/proj.sh
#!/bin/sh
result=$(\proj)  # $() は()内のコマンドを実行して標準出力を結果として返す
cd $result
```

これで大まかな準備は完了です。シェルを再起動して

```
$ proj
```

を実行すると`cd test`を実行するのと同じ結果が得られるはずです。

## 5. シェルスクリプトとコマンドを改良してコマンドライン引数を捌く
現状ではコマンドライン引数を受け取ることはできません。任意のディレクトリを登録して移動できるようにするためにはもうひと工夫必要です。

いまの想定は

```
$ proj devel
```

というコマンドのときだけ`cd`が発動し、それ以外の引数、たとえば

```
$ proj -i devel
$ proj --show-todo
```

といった他のコマンドではコマンドライン引数を素通りさせて`proj`にそのまま渡したいわけです。これを実現するためには、シェルスクリプトとコマンドの両方でコマンドライン引数を解析せねばなりません。

簡単のため、ディレクトリ移動を伴う場合はコマンドライン引数をひとつしか取らず、またその引数はプロジェクト名を指しているものと決めてしまいましょう。そうしておけばとりあえず、引数の個数で条件分岐ができます。

```shell:~/.config/proj/proj.sh
#!/bin/sh
if test $# -eq 1 ; then
    result=$(\proj --echo $1)
    cd $result
else
    \proj "$@"
fi
```

順に解説します。`$#`はコマンドライン引数の個数が格納された変数であり、`$1`は1個目のコマンドライン引数が格納された変数で、`proj devel`を実行すると`devel`という文字列が格納されています。`proj`コマンドは`proj --echo [project_name]`でそのプロジェクトのディレクトリを標準出力に出力するように設計されていれば、結果は`result`に格納されて`cd $result`で所望のディレクトリに移動できます。

また`"$@"`はすべてのコマンドライン引数を展開するので、`proj "$@"`はコマンドライン引数をすべて`proj`コマンドに引き渡すことになります。

しかしまだ以下のような問題があります。

1. `proj --echo [project_name]`で指定した`project_name`が登録されていないときどうするか。
2. `proj --echo [project_name]`が予期せぬエラーで終了したらどうするか。

まずは 1 の『登録されていないプロジェクトが指定されたらどうするか』という問題に対処するためにPythonのコマンドのコードも変更していきましょう。

```python:main.py
import argparse

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--echo',       # オプション名
                        nargs='?',      # このオプションの引数は0個または1個
                        default=None,   # オプションが指定されなかったとき None
                        const=''        # オプションの引数が0個のとき ''
                       )
    args = parser.parse_args()

    # プロジェクトはとりあえず仮置き
    projects = {
        'devel': '~/develop',
        'test1': '~/develop/test1',
        'test2': '~/develop/test2',
    }

    if args.echo is not None:
        if args.echo in projects:
            print(projects[args.echo])
            sys.exit(0)
        else:
            print(f'Error: project \'{args.echo}\' is not registered.')
            sys.exit(1)

    print('other options')
    sys.exit(0)
```

exit code に 1 を指定するのはよくない、という考えもあるようですが、指定されたプロジェクト名が存在しないことは以降の処理を続行不能な一般的なエラーなので 1 でもいいのではと思います。気に入らなければご自身で変更してください。

ちなみに現時点でコマンドを再インストールして

```
$ proj -s
```

など存在しないオプションを指定すると、シェルスクリプトから`\proj --echo -s`が実行されて`argparse`のライブラリがエラーを吐きます。これが 2 の『予期せぬエラーで終了したらどうするか』の問題にあたります。プログラムがエラーで終了すると exit code は 0 以外の値を取ります。実際、`argparse`はオプションのパースエラーに際して exit code を 2 に設定してプログラムを終了します。

つまり exit code が 0 なら成功で 0 以外ならエラーです。exit code はシェルスクリプトでも取得できるので、これを見てやれば`cd`を実行するタイミングが掴めます。

```shell:~/.config/proj/proj.sh
#!/bin/sh
if test $# -eq 1 ; then
    result=$(\proj --echo $1)
    exit_code=$?         # $? は最後に実行したコマンドの exit code を格納した変数
    if test $exit_code -eq 0 ; then
        cd $result
    elif test $exit_code -eq 1 ; then
        echo $result
    fi
else
    \proj "$@"
fi
```

上記のスクリプトでほぼ十分なのですが、いろいろ試行錯誤した結果、`argparse`のエラーを綺麗に表示するには`proj -s`など単一オプション指定は素通りさせて`else`のブロックで処理するほうがよさそうでした。つまり最初のif文でオプションを弾く正規表現を追加するとよいです。

```shell:~/.config/proj/proj.sh
#!/bin/sh
if test $# -eq 1 && [[ $1 =~ ^[^\-] ]] ; then
    result=$(\proj --echo $1)
    exit_code=$?         # $? は最後に実行したコマンドの exit code を格納した変数
    if test $exit_code -eq 0 ; then
        cd $result
    elif test $exit_code -eq 1 ; then
        echo $result
    fi
else
    \proj "$@"
fi
```

自作コマンドで`cd`を可能にする部分はこれで完成です。お疲れ様でした。プロジェクトの登録とかは`~/.config/proj/projects.json`に読み書きするだけなので、好きにやってください。

## 6. pyenv による仮想化を回避する
コマンドを作るためにPythonを使っている場合に限った話ではあるのですが、pyenvによる仮想化を行っていると`proj`コマンドはインストール時にpyenvにより仮想化された環境に紐づけられます。つまり

```
$ pyenv local 3.6.1
$ pip install -e .
```

で`proj`をインストールした後で仮装環境を切り替えた場合、

```
$ pyenv local 3.8.1
$ proj devel
pyenv: proj: command not found

The `proj' command exists in these Python versions:
  3.6.1

```

と command not found になってしまいます。これを回避するには、`pyenv global`で指定している環境に`proj`コマンドをインストールし、`proj`コマンドを叩くときはどんなディレクトリからでもグローバル環境の`proj`コマンドを直接叩きに行くようにシェルスクリプトを変更します。

```shell:~/.config/proj/proj.sh
#!/bin/sh

gpython=${PYENV_ROOT}/versions/$(pyenv global)/bin/python
gproj=${PYENV_ROOT}/versions/$(pyenv global)/bin/proj

if test $# -eq 1 && [[ $1 =~ ^[^\-] ]] ; then
    result=$(exec $gpython $gproj --echo $1)
    exit_code=$?
    if test $exit_code -eq 0 ; then
        cd $result ; pwd
    elif test $exit_code -eq 1 ; then
        echo $result
    fi
else
    (exec $gpython $gproj "$@")
fi
```