todoke
┌──────┐ ┌────────┐ ╭──▶ nvim
│ file │ ──▶ │ todoke │ ──▶ ├──▶ code
└──────┘ └────────┘ ╰──▶ script / …
todoke takes one or more file paths and decides what to do with each of
them — by regex-matching the path against a TOML ruleset. A rule can target
a long-running neovim (reused via msgpack-RPC), any generic CLI editor, or a
raw shell script. Perfect as your OS default program for text files, as
$EDITOR, or as a standalone file handler.
It is the successor to edtr / hitori.vim, generalized
from "editor router" into a full rule-driven dispatcher.
Features
- Rule-based routing: regex patterns in TOML decide what handles each file. Different paths → different handlers (VSCode for one project, nvim for another, a shell script for a third).
- Single-instance neovim via named pipes / unix sockets:
todokeconnects to a running nvim and sends:editover msgpack-RPC. Works on Windows via\\.\pipe\...— no Deno, no plugin framework, no cold start. - Sync or async per rule:
sync = trueblocks until the handler exits (perfect forgit commit),sync = falsefires and forgets (perfect for double-clicking files in the OS file explorer). - Tera templating throughout the config:
{{ file_path }},{{ env.HOME }},{% if is_windows() %}…{% endif %}, structural conditionals that include whole editor / rule blocks, every Tera filter. - Generic CLI support: any command-line tool works (
code,vim,helix,subl,emacsclient,bat,pandoc, …) without custom code. edtrcompatibility: same embedded default config, same config schema. Existingedtrusers migrate by renaming the config directory (see below).- Fast: static Rust binary, cold start in milliseconds. On Windows this is often 10–100× faster than denops-based alternatives.
Install
Binary lives at ~/.cargo/bin/todoke. Make sure that's on your PATH.
Quick start
todoke works out of the box with a bundled default config — it routes
everything to a single shared neovim instance, except $EDITOR-callback
files (COMMIT_EDITMSG etc.) which always get a fresh sync = true
instance so git commit works.
To customize, drop a file at:
- Linux / macOS / Windows:
~/.config/todoke/todoke.toml
Minimal example:
# ~/.config/todoke/todoke.toml
# kind = "neovim" opts into msgpack-RPC reuse; "exec" (default) just spawns.
[]
= "neovim"
= "nvim"
= '{% if is_windows() %}\\.\pipe\nvim-todoke-{{ group }}{% else %}/tmp/nvim-todoke-{{ group }}.sock{% endif %}'
[]
= "code"
[]
= ["--reuse-window"]
= ["--new-window"]
[]
= "firefox"
# A second firefox target specifically for issue: inputs — the URL is
# constructed from the capture group, so append_inputs = false tells the
# exec backend not to tack the raw "issue:42" onto the command line as a
# second positional.
[]
= "firefox"
= false
= ["https://github.com/yukimemi/todoke/issues/{{ cap.1 }}"]
# Git-ref target: opens the GitHub tree browser at a branch / tag / sha.
[]
= "firefox"
= false
= ["https://github.com/yukimemi/todoke/tree/{{ input }}"]
# git commit, rebase, etc. — always a blocking fresh nvim.
[[]]
= "editor-callback"
= '(?i)/(COMMIT_EDITMSG|MERGE_MSG|git-rebase-todo)$'
= "nvim"
= "new"
= true
# GitHub URLs → firefox (URL is auto-appended by the exec backend)
[[]]
= "gh"
= '^https?://(www\.)?github\.com/'
= "firefox"
# Route files under ~/src/company/ to VSCode.
[[]]
= "work"
= '/src/company/'
= "code"
= "remote"
# Raw strings (neither URL nor existing file) also fall through to rules —
# capture groups are available to the handler as `{{ cap.1 }}` / `{{ cap.name }}`.
[[]]
= "gh-issue"
= '^issue:(\d+)$'
= "gh-issue"
# Git refs — branch names, tags, short SHAs, etc. Useful with `--as raw`
# when a file of the same name exists in the cwd.
[[]]
= "gh-ref"
= '^(HEAD|main|master|develop|v?\d+\.\d+\.\d+|[0-9a-f]{7,40})$'
= "gh-ref"
# Default: everything else goes to the shared nvim.
[[]]
= "default"
= '.*'
= "nvim"
= "default"
= "remote"
Then:
# Open any file in the right handler
# URLs work too — same rule engine routes them to a browser, a browser
# profile, or any CLI that accepts URLs.
# Raw strings match rules too. Captures are available as {{ cap.N }}.
# Force interpretation with --as when auto-detection would get it wrong.
# Example: a file named "HEAD" exists in cwd but you want "HEAD" routed
# as a raw git-ref string to whatever rule handles refs.
# See which rule would match, without actually dispatching
# Same dispatch logic, don't execute
# Lint the config for common footguns
Recipe: one target, many variants
Neovim has several front-ends — nvim itself, neovide, nvim-qt, … —
and you'll probably want to swap between them without rewriting rules.
Because the whole config is pre-rendered through Tera, a list in [vars]
plus a single conditional covers every combination:
[]
# Swap this line to switch front-ends.
= "neovide"
# Wrappers that forward CLI args to an embedded nvim only after `--`.
# Raw `nvim` is not in this list because it would treat args after `--`
# as filenames.
= ["neovide", "nvim-qt"]
[]
= "neovim"
= "{{ vars.gui }}"
= '{% if is_windows() %}\\.\pipe\nvim-todoke-{{ group }}{% else %}/tmp/nvim-todoke-{{ group }}.sock{% endif %}'
{% if vars.gui in vars.wrapper_guis %}
[]
= ["--"]
{% endif %}
[[]]
= '.*'
= "gui"
= "remote"
vars.gui = "nvim"→nvim FILE --listen PIPEvars.gui = "neovide"→neovide FILE -- --listen PIPEvars.gui = "nvim-qt"→nvim-qt FILE -- --listen PIPE
One target definition, three valid command lines. Adding a new wrapper in
the future is one entry in wrapper_guis.
Recipe: categorized match patterns
match accepts either a single regex string or an array. The array form
is OR-matched (hit any → rule fires) and is the right shape when a rule's
intent spans several unrelated sources — $EDITOR-callback files are a
classic example because every tool sprinkles its own filename convention:
[[]]
= "editor-callback"
= [
# git
'(?i)/(COMMIT_EDITMSG|MERGE_MSG|TAG_EDITMSG|EDIT_DESCRIPTION|git-rebase-todo|NOTES_EDITMSG|\.gitmessage)$',
# svn / hg
'(?i)/svn-commit\.tmp$',
# Claude Code prompt temp files
'(?i)/claude-prompt-.*$',
]
= "nvim-term"
= "new"
= true
Each bucket is its own readable regex; extending for a new tool is
appending one line with a # new-tool comment instead of threading
another alternation into a long single-string pattern.
As $EDITOR
The bundled default config is compatible with every $EDITOR=… caller I
know of (git, crontab, visudo, fc, mutt, …).
As OS default program (Windows)
Right-click a .txt → Open with → Choose another app → Browse → point at
todoke.exe. todoke honors the rules and opens the file in the correct
handler, spawning a new console if the target is a TUI.
Configuration reference
[vars]
User-defined variables available as {{ vars.NAME }} in every other
template:
[]
= "/home/me/src"
[todoke.<name>]
A delivery target (the value behind a rule's to = "<name>").
| field | type | required | meaning |
|---|---|---|---|
kind |
"exec" / "neovim" |
no (default "exec") |
"exec" spawns the command; "neovim" reuses a running nvim via msgpack-RPC |
command |
string | yes | the handler binary (PATH-resolved) |
listen |
string | neovim | socket / named pipe path for RPC |
args |
table of <mode> → array<string> |
no | args injected based on rule.mode; args.default is the fallback when no key matches |
append_inputs |
bool | true |
exec kind only: whether each input's display string is appended as a trailing positional arg after args. Set to false when args already reference the input via {{ input }} / {{ cap.N }} and you don't want the raw value passed twice. |
env |
table | no | env vars passed to the spawned handler |
[[rules]]
| field | type | default | meaning |
|---|---|---|---|
name |
string | rule[N] |
human-readable label (shown in check) |
match |
regex string or [regex] |
required | pattern(s) against the input; files are normalized to / before matching, URLs and raw strings are matched as-is |
exclude |
regex string or [regex] |
none | when any exclude hits, the rule is skipped even if match hits — todoke falls through to the next rule |
to |
string (Tera-templated) | required | key into [todoke.*] |
group |
string | "default" |
instance identity (one nvim per group) |
mode |
string | "remote" |
free-form; "remote" / "new" are reserved for neovim behavior, otherwise used only to pick args.<mode> |
sync |
bool | false |
true = block until handler exits |
Template context
Available in rule.group, rule.to, todoke.*.command, todoke.*.listen,
todoke.*.args.*:
| variable | example | populated for |
|---|---|---|
input |
/tmp/foo.md or https://… |
always |
input_type |
"file" / "url" / "raw" |
always |
file_path |
C:/Users/you/notes/todo.md |
file inputs |
file_dir |
C:/Users/you/notes |
file inputs |
file_name |
todo.md |
file inputs |
file_stem |
todo |
file inputs |
file_ext |
md (no leading dot) |
file inputs |
url_scheme |
https |
URL inputs |
url_host |
github.com |
URL inputs |
url_port |
443 or empty |
URL inputs |
url_path |
/yukimemi/todoke |
URL inputs |
url_query |
tab=rs or empty |
URL inputs |
url_fragment |
top or empty |
URL inputs |
command_* |
same five fields for the target command | always |
cwd |
current working directory | always |
group |
resolved group | phase 3 |
rule |
resolved rule name | phase 3 |
cap.0 |
full match of the match regex |
when a rule matched |
cap.1 / cap.2 / … |
numbered capture groups | when defined |
cap.<name> |
named capture groups (?P<name>…) |
when defined |
vars.<key> |
your [vars] entries |
always |
env.<KEY> |
process env at todoke invocation | always |
kind = "neovim" targets accept file inputs only — URLs and raw
strings routed to a neovim target are logged and skipped. Route those to
a kind = "exec" target (e.g. a browser for URLs, any CLI that consumes
the raw string for "raw").
And these todoke-specific Tera functions:
is_windows(),is_linux(),is_mac()— booleans for OS branching.
Plus everything Tera ships — replace, split, join, length, now(),
structural {% if %} / {% elif %} / {% else %} blocks around editor
and rule sections, and all other stock Tera features.
CLI reference
todoke [FILES]... # dispatch files per rules (default action)
todoke check <FILES>... # dry-run: show matched rule per file
todoke doctor # lint the config for common footguns
todoke completion <shell> # emit shell completion script
todoke --help
todoke --version
# v0.2+:
todoke list # list alive handler instances
todoke kill <group> | --all # terminate instances
todoke config path | edit | validate | show
Flags:
-c, --config <PATH>— override config path-E, --editor <NAME>— bypass rule, force handler-G, --group <NAME>— bypass rule, force group--dry-run— print the resolved plan without executing-v, --verbose—-v= info,-vv= debug,-vvv= trace
Logging is also controllable via RUST_LOG.
Roadmap
- v0.1 (this release): core dispatch, neovim + generic backends,
check,doctor,completion, default config,$EDITORcompatibility, colored output. - v0.2:
list/kill/config edit|validate|show,open/send, neovimremote + syncvianvim_buf_attach. - v0.3:
scripteditor kind — run arbitrary shell commands as a handler, turning todoke into a general "open with rules" tool for any file type (previewer, formatter, pipeline, …).
Heritage
todoke extends edtr, which was itself a Rust rewrite of
hitori.vim. The lineage:
hitori.vim(denops): single-instance vim plugin, vim/neovim-only, slow on Windows.edtr: Rust rewrite, editor-agnostic, fast on all platforms.todoke:edtrplus broader scope — any command-line handler (not just editors), any file type. The name 「届け」 means deliver in Japanese.
License
MIT — © 2026 yukimemi.