😽

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 runCMDNOTE_CONFIG は開発時には便利ですが、通常利用の説明に混ぜると分かりづらくなります。

そのため、以下のように分けています。

  • 通常利用: cmdnote~/.config/cmdnote/commands.toml
  • 開発用: cargo runCMDNOTE_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ツールを作る際は、描画や状態管理だけでなく、「他の環境とどう連携するか」を先に決めておくと後で迷いにくくなりそうです。

https://github.com/KASAHARA-Kyohei/cmdnote

Discussion