todoke — rule-driven file dispatcher

自作のファイル・URL ディスパッチャ todoke (届け)Claude Code と一緒に作りました。Rust 製で、入力された引数(ファイルパス・URL・任意の文字列)を TOML で書いたルールに照らして、対応するエディタ・ブラウザ・スクリプトに引き渡す CLI です。名前のとおり、引数を然るべき相手に**「届け」**ます。

ちなみに、漫画『君に届け』が好きです。

なぜ作ったのか

きっかけは Neovim を $EDITOR にしたときの、ちょっとした困りごとが積み重なっていたことです。

  • git commit のように呼び出し元をブロックしないと動かないものは、新しい nvim を起動して終了を待ちたい
  • 一方、ファイルマネージャからファイルをダブルクリックしたときは、起動済みの nvim に :edit で送り込みたい
  • プロジェクトによっては Neovim ではなく VSCode で開きたいものもある
  • 開く URL によってブラウザを使い分けたい(Gmail は Chrome、GitHub は Edge、みたいに)

エディタ側の振り分けについては、以前は自作の hitori.vim を使っていました。$EDITOR=nvim で起動した Neovim 内から「これは別の起動済み nvim に転送すべきか / このまま開くべきか」を判定して、必要なら起動済み nvim にバッファを送り直す、という仕組みです。動作自体はちゃんとしていたのですが、

  • 判定のためにいったん Neovim を起動して denops.vim を立ち上げる必要があり、毎回 Deno のコールドスタートが入って体感が重かった
  • 判定ロジックが Vim プラグイン内に閉じているので、Neovim 以外のターゲット(VSCode に流したい / URL はブラウザに渡したい / 独自スキームを処理したい)にスケールしない
  • Windows のファイル関連付けに置きづらい(Neovim を起動してから判定、という順序がそもそも噛み合わない)

要するに、判定は Neovim の外でやらないと、速度面でも対応範囲の面でも厳しい、というのが結論でした。

そこで、「入力を受け取って、ルールに従って正しい相手に届けるだけ」 の単機能ツールを Neovim の外側に切り出すことにしました。それが todoke です。判定が CLI 側で完結するので、$EDITOR 呼び出しはミリ秒で終わるし、Neovim 以外のターゲットにも横展開できます。

todoke の特徴

  • ルールベースのルーティング — TOML の正規表現で各入力を届ける。ファイル / URL / 任意文字列のすべてに対応
  • Neovim インスタンスの再利用kind = "neovim" のターゲットは msgpack-RPC で起動中の nvim にぶら下がり、:edit を送る。Windows でも \\.\pipe\... 名前付きパイプで動く
  • 同期 / 非同期をルールごとに指定sync = true ならハンドラ終了までブロック(git commit 用)、sync = false なら投げて即終了(OS の関連付け用)
  • Tera テンプレート対応command / listen / args / group などすべての値で {% if is_windows() %}{{ env.HOME }} が使える
  • 任意の CLI に対応code / helix / subl / emacsclient / firefox / bat …いずれもプラグイン不要
  • $EDITOR 互換gitcrontabvisudofcmutt などの $EDITOR 呼び出し側との互換性を最優先で設計
  • Windows の OS 既定プログラム にも設定可能。GUI ターゲットは cmd ウィンドウを点滅させずに起動できる
  • 静的バイナリ — Rust 製でコールドスタートはミリ秒オーダー

インストール

sh
1
cargo install todoke

~/.cargo/bin/todoke にバイナリが置かれるので、PATH に追加しておきます。

クイックスタート

todoke は設定ファイルがなくても動きます。バンドルされたデフォルト設定が、COMMIT_EDITMSG などの $EDITOR コールバックは新しい nvim で sync = true 起動、それ以外はすべてひとつの共有 nvim にルーティングする、というルールを最初から持っています。

$EDITOR を todoke にしてみるだけでも体験できます。

sh
1
2
3
export EDITOR=todoke
git commit              # → COMMIT_EDITMSG を nvim mode=new sync=true で開く
todoke notes.md         # → 起動中の nvim に :edit notes.md を送り込む

カスタマイズしたければ todoke config init~/.config/todoke/todoke.toml に内蔵 default 設定を書き出してくれるので、そこから編集するのが手っ取り早いです (一度書き出したファイルは todoke 側からは絶対に上書きされません)。todoke config edit なら書き出し + $EDITOR での起動を一発でやってくれます。

設定例

ここでは複数のターゲットとルールを組み合わせた、実用的な todoke.toml の例を紹介します。

toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# ============================================================
# ターゲット定義 (届ける相手)
# ============================================================

# kind = "neovim" にすると msgpack-RPC で起動中の nvim を再利用する
[todoke.nvim]
kind = "neovim"
command = "nvim"
listen = '{% if is_windows() %}\\.\pipe\nvim-todoke-{{ group }}{% else %}/tmp/nvim-todoke-{{ group }}.sock{% endif %}'

[todoke.code]
command = "code"
[todoke.code.args]
remote = ["--reuse-window"]
new    = ["--new-window"]

# Chrome / Edge を別ターゲットとして登録
[todoke.chrome]
command = "chrome"
gui = true                # Windows: cmd ウィンドウを出さずに起動

[todoke.edge]
command = "msedge"
gui = true

# ============================================================
# ルール (どの入力をどのターゲットに届けるか)
# ============================================================

# git commit / rebase / merge は常に新規 nvim をブロック起動
[[rules]]
name = "editor-callback"
match = '(?i)/(COMMIT_EDITMSG|MERGE_MSG|git-rebase-todo)$'
to = "nvim"
mode = "new"
sync = true

# Gmail は Chrome で開く
[[rules]]
name = "gmail"
match = '^https?://mail\.google\.com/'
to = "chrome"

# GitHub は Edge で開く
[[rules]]
name = "github"
match = '^https?://(www\.)?github\.com/'
to = "edge"

# 仕事のリポジトリだけ VSCode で開く
[[rules]]
name = "work"
match = '/src/company/'
to = "code"
mode = "remote"

# それ以外の URL は Edge にフォールバック
[[rules]]
name = "url-default"
match = '^https?://'
input_type = "url"
to = "edge"

# 残り (主にファイル) は共有 nvim にすべて流す
[[rules]]
name = "default"
match = '.*'
to = "nvim"
group = "default"
mode = "remote"

これでこういう使い分けができます。

sh
1
2
3
4
5
6
todoke notes.md                              # → 共有 nvim に :edit
todoke ~/src/company/foo.py                  # → VSCode で開く
todoke https://mail.google.com/mail/u/0/     # → Chrome
todoke https://github.com/yukimemi/todoke    # → Edge
todoke https://example.com                   # → Edge (URL fallback)
git commit                                   # → 新規 nvim (sync) で COMMIT_EDITMSG

Neovim の再利用: kind = "neovim"

todoke の中でいちばん力を入れた機能です。

kind = "neovim" のターゲットは、設定された listen パスにある msgpack-RPC ソケット / 名前付きパイプ越しに起動中の nvim にぶら下がって :edit を送るだけです。重要なのは、

  • Windows でも \\.\pipe\nvim-todoke-default のような名前付きパイプでそのまま動く
  • 起動済みの nvim がいなければ todoke が nvim --listen <path>自動的に起動する
  • group で nvim インスタンスを論理的に分けられる(group = "work"group = "private" で別 nvim になる)

つまり、OS のファイルマネージャからダブルクリックしたファイルが、いま開いている nvim のバッファとして開く という体験が、追加の Vim プラグイン無しで実現します。

さらに group を使えば、ひとつの nvim にすべて流すのではなく、用途別に複数の nvim インスタンスを使い分けることもできます。listen の中に {{ group }} を埋め込んでおけば、group が変わるたびに別の pipe / socket になって、別の nvim プロセスとして起動・再利用される、という仕組みです。

toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[todoke.nvim]
kind = "neovim"
command = "nvim"
# listen に {{ group }} を入れておくのが鍵 — group ごとに別 pipe / socket になる
listen = '{% if is_windows() %}\\.\pipe\nvim-todoke-{{ group }}{% else %}/tmp/nvim-todoke-{{ group }}.sock{% endif %}'

# 仕事のリポジトリは "work" グループ
[[rules]]
match = '/src/company/'
to = "nvim"
group = "work"
mode = "remote"

# プライベートは "personal" グループ
[[rules]]
match = '/src/yukimemi/'
to = "nvim"
group = "personal"
mode = "remote"

# その他は共有の "default"
[[rules]]
match = '.*'
to = "nvim"
group = "default"
mode = "remote"

仕事のファイルは仕事用 nvim、プライベートは別 nvim、それ以外は共有 nvim — どこに届くかは match で決まります。

入力の 3 種類: file / url / raw

todoke は引数を 3 つの種類に分類します。

種類 マッチ対象の文字列
file notes.md, Makefile, /tmp/new.md 正規化された絶対パス(/ 区切り)
url https://example.com URL そのもの
raw HEAD, main 引数の生文字列

ファイル / URL は形から自動判定されます。ファイルとして存在しない Makefilenewfile.txt も、ファイルらしい形であれば file として扱われるので、vim Makefile と同じ感覚で todoke Makefile が動きます。

HEADmain のような曖昧な文字列は、--todoke-as raw で明示するか、ルール側で input_type = "raw" に固定すれば「git ref を GitHub のツリービューで開く」みたいな運用ができます。

toml
1
2
3
4
5
[[rules]]
name = "gh-ref"
match = '^(HEAD|main|master|develop|v?\d+\.\d+\.\d+|[0-9a-f]{7,40})$'
to = "gh-ref"
input_type = "raw"      # ← ローカルの "main" ファイルと衝突しないよう raw に固定

エディタ系フラグの取り回し: passthrough

$EDITOR=todoke でいざ運用してみると、+42 file.txt のような vim スタイルのフラグを渡してくる呼び出し元が出てきます。todoke は何もしないと +42 を「ファイルパス」と誤認識してしまうので、「これはフラグなのでファイルや URL のような入力としては扱わず、引数としてターゲットに forward する」 と書く仕組みが必要になります。それが passthrough です。

toml
1
2
3
4
5
6
7
8
9
10
11
# どのフラグもターゲット非依存で素通しする
[[rules]]
name = "any-flag"
match = '^[-+]'
passthrough = true        # to は不要 — 同じ batch で他のルールが選んだターゲットに合流する

[[rules]]
name = "nvim-file"
match = '.*'
to = "nvim-term"
sync = true

これで todoke +42 foo.txtnvim +42 foo.txt として起動します。-c :set ft=md のような 値が次の argv にあるフラグには consumes-p a.txt b.txt c.txt のような 可変長 には consumes_until-- で分けるタイプには consumes_rest を使います。

toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[[rules]]
name = "nvim-c"
match = '^-c$'
to = "nvim-term"
sync = true
passthrough = true
consumes = 1                     # -c とその次の argv を 1 セットで素通し

[[rules]]
name = "nvim-p"
match = '^-[pOo]$'
to = "nvim-term"
sync = true
passthrough = true
consumes_until = '^[-+]'         # 次のフラグが来るまで argv を吸い続ける

{{ passthrough }}args の中で参照すると、フラグを 任意の位置に挿入できます。gvim のように --remote-silent <file> の前にフラグを置きたい、というケースに便利です。

toml
1
2
3
4
5
6
7
8
9
[todoke.gvim]
command = "gvim"
gui = true
[todoke.gvim.args]
default = [
  "--servername", "{{ group | upper }}",
  "{{ passthrough }}",                       # ← ここで素通しフラグを並べる
  "--remote-silent", "{{ input }}",
]

{{ passthrough }}単独の args 要素として書いた場合は inline 展開されるので、-c :set ft=md のような複数 argv のフラグも 1 つの "" にまとめられず、ちゃんと argv 単位で渡ります。

ちなみに passthrough と並んで、引数を空白で連結した全体を 1 つの正規表現でマッチさせる joined = true というモードもあります。+42 file.txt のような「フラグとファイルを 1 ルールで丸ごと拾いたい」といった用途向けです。詳細は README を参照してください。

[vars] で GUI を切り替える

Neovim の GUI フロントエンドには neovidenvim-qt、素の nvim (ターミナル) などの選択肢があって、気分で切り替えたいことがあります。todoke の [vars] セクションと Tera テンプレートを組み合わせると、1 行書き換えるだけでフロントエンドを差し替えられる構成になります。

toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[vars]
# ここを書き換えるだけで全部追従する
gui = "neovide"
# CLI 引数を embedded nvim に渡すために `--` 区切りが必要なラッパー型 GUI 一覧
wrapper_guis = ["neovide", "nvim-qt"]

[todoke.gui]
kind = "neovim"
command = "{{ vars.gui }}"
listen = '{% if is_windows() %}\\.\pipe\nvim-todoke-{{ group }}{% else %}/tmp/nvim-todoke-{{ group }}.sock{% endif %}'
# ラッパー GUI のときだけ gui = true (Windows の cmd ウィンドウを抑止)
# 素の nvim はターミナルが必要なので false にする
gui = {{ vars.gui in vars.wrapper_guis }}

{% if vars.gui in vars.wrapper_guis %}
# ラッパー型は本体 nvim への引数の前に `--` を挟む必要がある
[todoke.gui.args]
remote = ["--"]
{% endif %}

[[rules]]
match = '.*'
to = "gui"
mode = "remote"

vars.gui を変えるだけで、

  • "nvim"nvim FILE --listen PIPE (ターミナルで起動)
  • "neovide"neovide FILE -- --listen PIPE (GUI、-- 区切りあり)
  • "nvim-qt"nvim-qt FILE -- --listen PIPE (GUI、-- 区切りあり)

の 3 通りに切り替わります。{% if %} の中で [todoke.gui.args] テーブルそのものを条件包含できるのは、todoke が TOML パース前に全文を Tera に通している (= TOML 構造ごと条件分岐できる) ことの利点です。

新しいラッパー GUI を試したくなったら wrapper_guis に追加するだけで対応できる、というのも気に入っています。

CLI

todoke 自身のフラグはすべて --todoke- プレフィックスで、ロングオプションのみです。これは todoke$EDITOR の代わりに呼ばれたときに、呼び出し元(nvim・vim・helix)のフラグと絶対に衝突しないようにするための設計です。

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
todoke [INPUTS]...                 # ルールに従ってディスパッチ (デフォルト)
todoke check [INPUTS]...           # ドライラン: 実行せずに dispatch plan を表示
todoke doctor                      # 設定の健康診断
todoke list [--alive-only]         # 起動中のインスタンス一覧 (※ v2.0.0 時点で未実装)
todoke kill <GROUP> | --all        # インスタンスを終了        (※ v2.0.0 時点で未実装)
todoke config path                 # 設定ファイルパスを表示
todoke config init                 # 無ければ default を書き出す (idempotent)
todoke config edit                 # $EDITOR で開く
todoke config show [--rendered]    # 中身を表示 (--rendered で Tera 展開後)
todoke completion <shell>          # シェル補完スクリプト
todoke --todoke-config <PATH>      # 設定ファイルを上書き
todoke --todoke-to <NAME>          # ルールを無視してターゲット強制
todoke --todoke-group <NAME>       # ルールを無視してグループ強制
todoke --todoke-as <KIND>          # 入力種別を file / url / raw に強制
todoke --todoke-verbose            # ログレベルを上げる (繰り返し可)

checkdoctor を用意したのは、ルールが増えるとどれが当たるか分からなくなる、というのが個人的な経験で確実だったからです。todoke check ~/notes.md https://mail.google.com https://github.com/yukimemi と打てば、それぞれどのルールが当たってどのターゲットに飛ぶかが実行抜きで確認できます。

想定する使いどころ

$EDITOR として

sh
1
2
3
4
export EDITOR=todoke
git commit                         # editor-callback ルール → 新規 nvim sync
git rebase -i HEAD~3               # 同上
crontab -e                         # 同上 (CRONTAB 系のファイル名は default config で拾う)

Windows のファイル関連付けとして

.txt を右クリック → プログラムから開く → todoke.exe を選ぶだけです。gui = true のターゲットは cmd /c start を経由せずに起動するので、コンソールウィンドウが点滅しません。GUI な Neovim フロントエンド(neovide / nvim-qt)や VSCode をターゲットにするときに気持ちのよい体験になります。

URL / 独自 ID のディスパッチャとして

firefoxchrome の代わりに todoke を渡しておくと、ホストごとにブラウザを使い分けたり (Gmail は Chrome、GitHub は Edge、など)、URL のパスから別プロファイルに届けたり、というのが設定だけで作れます。

設計のポイント

batch という単位

todoke 内部では、入力をルール照合した結果、「同じターゲット × 同じグループに行く入力」が 1 つの batch にまとめられます。これによって todoke a.md b.md c.md を渡したときに、3 回別々に nvim へ RPC が飛ぶのではなく、1 回の :args a.md b.md c.md 相当でまとめて開く、という挙動が実現します。

passthrough ルールがフラグを集めて、その batch にマージするのもこの単位です。+42 a.txt b.txt+42 は a.txt の batch に入るし、a.txt b.txt 全体に対して +42 を効かせる挙動になります。

Tera を全箇所で使う

command / listen / args / group / to といったルール側のあらゆる文字列が Tera で展開されます。これによって、

  • listen = '{% if is_windows() %}\\.\pipe\...{% else %}/tmp/...{% endif %}' のような OS 分岐
  • command = "{{ vars.gui }}" のような変数経由の参照 ([vars] セクションで一括切り替え)
  • group = "{{ env.PROJECT_GROUP | default(value='default') }}" のような環境変数経由の動的グループ

がすべて宣言的な設定だけで書けます。

それに加えて、入力種別ごとに使える変数も用意されています。

  • file 入力: {{ file_path }} (正規化された絶対パス) / {{ file_dir }} / {{ file_name }} / {{ file_stem }} / {{ file_ext }} (拡張子、ドットなし)
  • URL 入力: {{ url_scheme }} / {{ url_host }} / {{ url_port }} / {{ url_path }} / {{ url_query }} / {{ url_fragment }}
  • 共通: {{ input }} (生の入力文字列) と {{ input_type }}、ルールの match で取った正規表現キャプチャの {{ cap.1 }} / {{ cap.<name> }}

たとえば「.xlsx ファイルは Excel.exe に渡す」はこれだけで書けます。

toml
1
2
3
4
5
6
7
8
[todoke.excel]
command = "C:/Program Files/Microsoft Office/root/Office16/EXCEL.EXE"
gui = true
args.default = ["{{ file_path }}"]    # 絶対パスに正規化済み

[[rules]]
match = '\.xlsx?$'
to = "excel"

URL 側も同じで、たとえば Edge を URL のホストごとに別プロファイルに分けたければ:

toml
1
2
3
4
5
6
7
8
9
10
11
12
[todoke.edge-per-host]
command = "msedge"
gui = true
args.default = [
  "--profile-directory={{ url_host }}",   # github.com / mail.google.com / ... ごとに別プロファイル
  "{{ input }}",
]

[[rules]]
match = '^https?://'
input_type = "url"
to = "edge-per-host"

shun / rvpm でやっていたのと同じ思想で、「設定は宣言的に、でもちょっとしたロジックも書けるように」という落としどころを TOML × Tera に求めた形です。

append_inputs / append_passthrough の auto

exec ターゲットの args{{ input }}{{ file_path }} が出てくると、todoke はそれを検知して末尾への自動 append を抑制します。{{ passthrough }} についても同様です。これは設定が直感的に書けるようにするための小さな仕掛けで、たとえば

toml
1
args.default = ["--new-window", "{{ input }}"]

と書くだけで、URL が末尾に二重に付かなくなります。明示的に append_inputs = false を書くこともできますが、9 割のケースでは auto 検知だけで済みます。

ロードマップ

リリース済み (v2.0.0):

  • コアディスパッチ、neovim / 汎用 exec バックエンド、$EDITOR 互換、Windows のファイル関連付け対応、カラー出力
  • check (dispatch plan のドライラン)、doctor (設定の静的解析)、completion
  • config サブコマンド一式 — path / init / edit / show

予定:

  • list / kill — 現状はスタブ (bail!("not implemented yet"))。起動中の nvim インスタンス一覧 / グループ単位での終了
  • neovim での remote + sync (nvim_buf_attach 経由) — 再利用 nvim のバッファが閉じるまでブロックする (現状は fresh-spawn の nvim でしか sync = true が効かない)
  • script ターゲット kind — 任意のシェルコマンドをハンドラにできるようにして、todoke を「ファイル種別ごとの汎用 open-with」ツールとして使えるようにする

おわりに

$EDITOR=todoke にしてからは、git commit でも、ファイルマネージャからのダブルクリックでも、URL を渡しても、すべて 1 個の todoke コマンドが然るべき相手に届けてくれるので、エディタまわりの取り回しがだいぶ楽になりました。

rvpm のときに使った Rust + Tera + Tokio の組み合わせに今回も助けられていて、設定が宣言的に書ける CLI ツールを少ないコードで作れる構成として、自分の中で定番化しつつあります。

Neovim を $EDITOR にしているけど git commit の体験が微妙、ファイルマネージャから開いたファイルが新しい nvim を立ち上げてしまうのが嫌、URL や独自 ID をシェルから直接ブラウザに飛ばしたい、といった嗜好に合う方は、ぜひ試してみてください — 君に届け!