LoginSignup
6
5

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-02-17

TL;DR

mycmd project_name

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

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

読むのが面倒臭い人は

alias project_name="cd ~/develop/project1"

で代替するのが正攻法だと思います。結城浩先生の yukitask はこっちの正攻法で実現しています。Simple is the best。

動機

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

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

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

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

$ proj -i devel

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

$ proj devel

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

何が問題か?

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

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

alias devel="pushd ~/develop/project1"

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

$ devel

と入力するだけで~/develop/project1pushdにより移動することが可能です。結城浩先生の yukitask はこの方法を取っています。

私は 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.shprojを呼び出し移動先の情報を取得。
  3. proj.shcdコマンドを実行してシェルのカレントディレクトリを変更する。

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

1. シェルスクリプトでシェルのカレントディレクトリを変更する

proj.shに以下の内容を記述します。

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に以下のコードを記述してください。

setup.py
from setuptools import setup

setup(
    install_requires=[],
    entry_points={
        "console_scripts": [
            "proj = main:main"
        ]
    }
)

これでprojというコマンドを実行するとmain.pymain()関数が呼び出されるようになります。開発中に手元の環境にこのコマンドをインストールするにはprojのディレクトリで

$ pip install -e .

を実行します。

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

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.shprojコマンドをオーバーラップすることが可能です。

まずprojrcで alias を貼ります。

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

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

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

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

$ proj

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

5. シェルスクリプトとコマンドを改良してコマンドライン引数を捌く

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

いまの想定は

$ proj devel

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

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

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

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

~/.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のコマンドのコードも変更していきましょう。

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を実行するタイミングが掴めます。

~/.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文でオプションを弾く正規表現を追加するとよいです。

~/.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コマンドを直接叩きに行くようにシェルスクリプトを変更します。

~/.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
6
5
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5