stdout × shell integrationで実現する「入力欄に戻るCLI」cmdnote を作った
最近、TryHackMeにハマっていて、nmapなどのオプションが多いコマンドを使うことがよくあります。
その作業の中で、よく使うコマンドを何度も思い出す必要があり、メモを見返すのが面倒になってきていました。
しかも必要なのはコマンド本体だけではなく、用途や注意点、置き換えるべき引数まで含めた「使い方」ごと残しておきたいと考えていました。
そこで、コマンドをカテゴリごとに管理し、その場で選べるTUIツールをRustで作りました。
完全に自分向けの仕様ですが、興味があれば参考にしてみてください。
とにかく実現したかったのは、一覧を見るだけでなく、説明やメモも表示でき、選んだコマンドをそのままシェルの入力欄に挿入することです。
ただし、ここで少し厄介なのが「入力欄に戻す」という部分でした。
この記事では ratatui で3ペインのTUIを作った話に加えて、「なぜ stdout + shell integration という形にしたのか」も説明します。
とりあえず完成品
ざっくりいうと自分で好きなコマンド登録しておいて、ターミナルに定型文を挿入してくれるツールです。

先に使い方
通常利用では、次の2つに固定して使う想定です。
- 本体コマンドは
cmdnote。普段はCtrl-gから呼び出せるようにします - 設定ファイルは
~/.config/cmdnote/commands.toml
zshで使う手順は次のとおりです。
cargo install --git https://github.com/KASAHARA-Kyohei/cmdnote.git
cmdnote init
cmdnote install-shell zsh
exec zsh
これで以後は以下が使えます。
-
cmdnoteでTUIを直接起動 -
Ctrl-gで入力欄連携つきで起動
bashでも同様に使えますが、本記事ではzshを前提に説明します。
# bashならこんな感じ
cargo install --git https://github.com/KASAHARA-Kyohei/cmdnote.git
cmdnote init
cmdnote install-shell bash
exec bash
cmdnote 単体で完結させなかった理由
最初にやりたかったのは、cmdnote を起動してコマンドを選んだら、そのまま現在のシェルの入力欄に文字列を戻すことでした。
見た目だけなら単純に思えますが、実際にはここが難しいポイントです。
cmdnote は子プロセスとして動作するため、親である zsh の入力バッファを安全に直接書き換えることはできません。
そのため、cmdnote 単体で無理に完結させようとすると、どうしても環境依存の強い方法に寄りがちになります。
このプロジェクトにも insert モードはありますが、これは TIOCSTI を使った実験的な補助機能として扱っています。
配布用の正式ルートとして採用したのは、次の分担です。
-
cmdnote本体は選択結果をstdoutに返す - 入力欄への挿入は zsh / bash 側の仕組みに任せる
この方針にすると、アプリ側は「何を返すか」に集中できます。
一方で、入力欄への反映はshell側の正規の仕組みで処理できます。
shell integration は必要だが、ユーザーには意識させない
親シェルの入力欄を書き換えるには、最終的にzsh / bash側で関数を読み込む必要があります。
内部的には source <(cmdnote shell-init zsh) のような処理が必要です。
ただし、毎回これを手で書かせるのは手間です。
そこで cmdnote install-shell zsh|bash を用意し、~/.zshrc や ~/.bashrc への追記を自動化しました。
zsh側で実際に読み込まれるのは、概ね次のようなwidgetです。
cmdinsert() {
local selected
selected="$(cmdnote </dev/tty)" || return
[[ -n "$selected" ]] || return
LBUFFER+="$selected"
zle reset-prompt
}
zle -N cmdinsert
bindkey '^g' cmdinsert
ポイントは、cmdnote の返り値を受け取ってから LBUFFER に差し込んでいる点だけで、処理自体はシンプルです。
ユーザーから見ると cmdnote install-shell zsh を一度実行するだけで済むため、source の存在を強く意識する必要がありません。
アプリの構成
UIは ratatui + crossterm で作った3ペイン構成です。
- 左にカテゴリ一覧
- 中にコマンド一覧
- 右に説明やメモの詳細
一覧から選ぶだけならシンプルですが、実際には次の機能も含めています。
- 全文検索
- add / edit / delete
- クリップボードコピー
- プレースホルダ入力
たとえば nmap -sS -T4 {target} のようなコマンドを選択した場合、そのまま返すのではなく、{target} を埋める簡易フォームを表示します。
単なるテンプレ一覧で終わらず、「実際に使うところまで持っていく」ことを意識しました。
やって良かったこと
保存先は .config を本命にした
通常利用の保存先は ~/.config/cmdnote/commands.toml に固定しています。
読み込み・保存ともにここを使用します。
一方で assets/sample_commands.toml は初期化用サンプルとしてのみ使用します。
cmdnote init 実行時の元データであり、通常利用時に毎回参照するわけではありません。
この整理により、「どのファイルが本命か」が明確になります。
保存責務は store.rs に集約した
保存先の解決、読み込み、初期化、書き込みは store.rs にまとめています。
状態管理と永続化の責務を分離することで、add / edit / delete の拡張時も見通しが保ちやすくなります。
保存時は一時ファイルに書き込んでから rename する方式にしています。
TOML1枚でも、破損状態を残さないようにするためです。
通常利用と開発用の導線は分けた
混乱しやすかったポイントです。
cargo run や CMDNOTE_CONFIG は開発時には便利ですが、通常利用の説明に混ぜると分かりづらくなります。
そのため、以下のように分けています。
- 通常利用:
cmdnoteと~/.config/cmdnote/commands.toml - 開発用:
cargo runとCMDNOTE_CONFIG
開発中にはどう使っていたか
通常利用と開発用は分けた
cmdnote には「普段使い」と「開発中の動作確認」で、設定の扱い方が2パターンあります。
-
通常利用
- コマンド:
cmdnote - 設定:
~/.config/cmdnote/commands.toml
- コマンド:
-
開発用
- コマンド:
cargo run - 設定:
CMDNOTE_CONFIGで任意パスを指定
- コマンド:
この2つを混ぜて説明すると、「今どの設定を読んでいるのか」が分かりにくくなります。
そのため、用途ごとに完全に分けています。
開発中の使い方
開発中は、本番の設定ファイルを汚さないように、別パスの設定ファイルを使って動かします。
CMDNOTE_CONFIG=./tmp/commands.toml cargo run -- init
CMDNOTE_CONFIG=./tmp/commands.toml cargo run
これは
-
./tmp/commands.tomlを設定ファイルとして初期化する - そのファイルを使って
cmdnoteを起動する
という意味で「テスト用の設定で安全に試す」ための方法です。
shell連携をその場だけ試したい場合は、次のように一時的に読み込むこともできます。
source <(CMDNOTE_CONFIG=assets/sample_commands.toml cargo run -- shell-init zsh)
これは .zshrc を書き換えずに、一時的にshell integrationを有効化する方法です。
使ってみてよかった点
実際に使ってみると、単なるコマンド保存以上に次の点が便利でした。
- コマンドと説明をセットで管理できる
-
favoriteでよく使うものをまとめられる -
Ctrl-gから呼び出せるため、作業フローを崩さない -
/でコマンド検索
一方で、割り切っている点もあります。
- shell integrationは入力欄に戻したいなら必須
- 設定はTOML1枚に限定している
多機能ランチャーではなく、コマンドを育てていくためのTUIツールという位置付けです。
まとめ
cmdnote を作ってみて、最も重要だったのはTUI本体よりも「選択結果をどう返すか」という設計でした。
アプリ単体で完結させるのではなく
- 本体は
stdoutを返す - shell側で入力欄へ反映する
と分離したことで、実装も説明もシンプルになりました。
Rustで小さなTUIツールを作る際は、描画や状態管理だけでなく、「他の環境とどう連携するか」を先に決めておくと後で迷いにくくなりそうです。
Discussion