todoコマンドを作る
todoリストをCLIで管理したくなったので、todoコマンドを自作することにしました。
できあがったものは↓こちらです。
https://github.com/watarukura/todo
アイデアをまとめる¶
todo list- todoの一覧を表示する
- fzfで選択してtodoファイルを開く
todo cd- todo管理ディレクトリに移動する
todo $title- todoを追加する
todo $title $project- 複数プロジェクトを並行して進めることがあるため、目印としてprojectを設定できるようにする
todo done- fzfで選択してfinished_atに当日の日付を入れ、doneディレクトリ配下に移動する
- todoは1件1ファイルとする
- メタデータはfront matterで登録する
- title: タイトル
- created_at: todo開始日
- finished_at: todo終了日
- project: プロジェクト
- todoファイル以外のデータストアは使用しない
シェルスクリプトでプロトタイプを作る¶
Front Matter | yq
yqコマンドでfront matterが読めるようなので、これを使うことにします。
↓こんな感じでfront matterから取得したり、更新したりできます。
❯ cat <<EOF > sample.md
---
title: hoge
---
body
EOF
❯ yq --front-matter=extract '.title' sample.md
hoge
❯ yq --front-matter=process '. + {"created_at": now} ' sample.md
---
title: hoge
created_at: 2026-02-09T09:02:38+09:00
---
body
↓出来上がりはこれ。.zshrcに書き込みます。
function todo() {
local todo_dir="$HOME/Documents/todo"
local today=$(date +%Y-%m-%d)
local escaped_filename=$(echo $1 | sed -e 's;/;_;g' -e 's;\x0;;g')
if [[ $# -eq 1 ]]; then
if [[ "$1" == "list" ]]; then
ls "$todo_dir/"*.md |
xargs basename |
while read -r todo_file; do
todo_file="$todo_file" \
yq --front-matter=extract \
'[.title, .created_at, .project|"", strenv(todo_file)] |@tsv' \
"$todo_dir/$todo_file" |
fzf | cut -f4 | xargs -I{} $EDITOR "$todo_dir/{}"
done
return
fi
if [[ "$1" == "cd" ]]; then
cd $todo_dir
return
fi
if [[ "$1" == "done" ]]; then
donefile=$(ls "$todo_dir/"*.md | xargs -I{} basename {} | fzf)
today="$today" \
yq --front-matter=process '. + {"finished_at": strenv(today)}' "$todo_dir/$donefile" -i
mkdir -p "$todo_dir/done/$today/"
mv "$todo_dir/$donefile" "$todo_dir/done/$today/"
return
fi
if [[ -e $todo_dir/$1.md ]]; then
$EDITOR "$todo_dir/$1".md
return
fi
printf -- "---\n{}\n---\n" >"$todo_dir/$escaped_filename.md"
title="$1" today="$today" \
yq --front-matter=process \
'. + {"title": strenv(title), "created_at": strenv(today)}' \
"$todo_dir/$escaped_filename.md" -i
$EDITOR "$todo_dir/$escaped_filename".md
elif [[ $# -eq 2 ]]; then
printf -- "---\n{}\n---\n" >"$todo_dir/$escaped_filename.md"
title="$1" today="$today" project="$2" \
yq --front-matter=process \
'. + {"title": strenv(title), "created_at": strenv(today), "project": strenv(project)}' \
"$todo_dir/$escaped_filename.md" -i
$EDITOR "$todo_dir/$escaped_filename".md
else
cd $todo_dir
printf -- "---\n{}\n---\n" | $EDITOR
cd -
fi
}
これで、動くものはできました。
AI Agentに読ませて別言語に翻訳する¶
シェルスクリプトだとテストが書きにくいですし、テストがないと、機能追加して壊れることがしょっちゅう発生します。
(もちろん、書けないということはないですが...)
ということで、Goに書き換えます。
Junie-CLIのpreviewに申し込んだところ、利用できるようになったので使ってみます。
2026-02-08現在、まだTUIモードだと英語しか使えないので、I want to translate this bash script to go.と書いた上で↑のスクリプトを貼り付けました。
テストも、Write down unit test.と指示して書いてもらいました。
機能追加・修正¶
シェルスクリプトのときはcd $todo_dirで良かったtodo cdですが、Goから実行するときは動きません。
この case "cd" は cd サブコマンドの処理です。Goで実行するCLIは別プロセスなので、そこで cd しても親シェルの作業ディレクトリは変わりません。元のbashスクリプトは「関数」だったため cd $todo_dir がそのまま効きましたが、Goでは同じことはできないため、代わりに todoDir を表示して return しています。
https://github.com/twpayne/chezmoi/blob/43ba0344d8465d916a006022a697e215204a90a9/internal/cmd/cdcmd.go
chezmoi cdにインスパイアされた機能なので、chezmoiのコードをJunieに読ませて解説してもらいました。
chezmoi cd の仕組みは「親シェルの cwd を変える」のではなく、「指定ディレクトリを cwd にしたサブシェルを起動する」です。コード上の流れはこうです。
• runCDCmd で CHEZMOI_SUBSHELL=1 をセット
• 引数がなければ source ディレクトリ、引数があれば解決してそのディレクトリを決定
• そのディレクトリが存在していてディレクトリであることを確認
• c.run(dir, cdCommand, cdArgs) でシェルを起動(cwd を dir にして起動)
cdCommand / cdArgs は設定で上書きできますが、未設定なら現在のユーザーシェル(shell.CurrentUserShell())を使って起動します。つまり、
• chezmoi cd を実行すると source ディレクトリで新しいシェルが開く
• そのシェルを exit すると元の場所に戻る
という挙動です。親シェルの cwd は変わらないので、cd っぽい体験は「サブシェルを開いているから」実現されています。
なるほどなぁ...。
確かに、chezmoi cdしたあと、exitすると元のディレクトリに戻ってきます。
この挙動でも特に不便そうな感じはしないので、同じ挙動にします。
func runSubshell(dir string) {
// Spawn the user's shell as a subshell in the target directory.
shell := os.Getenv("SHELL")
if shell == "" {
shell = "sh"
}
cmd := exec.Command(shell)
cmd.Dir = dir
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
まとめ¶
思いついてから使えるものが手に入るまでのリードタイムが短いとやる気が持続していいですね。
とりあえず、しばらく使ってみようと思います。