自作の Neovim プラグインマネージャー rvpmClaude Code と一緒に作りました。Rust 製で、設定ファイル (config.toml) から静的な loader.lua を事前コンパイルする CLI ファーストな設計になっています。

rvpm demo

rvpm profile でプラグイン単位の Neovim 起動時間を phase ごとに可視化する TUI も付いています。

rvpm profile TUI

なぜ新しく作ったのか

以前、Deno + denops ベースの dvpm を作り、愛用していました(過去の記事キャッシュ機能の記事)。dvpm は denops との親和性も高く気に入っていたのですが、使い続けるうちにいくつか気になる点が出てきました。

  • 設定を TypeScript で書く前提なので、素の Neovim 設定と混ぜるときに壁がある
  • denops 起動前には遅延ロードが効かず、キャッシュ機能でカバーしていた
  • AI 時代、プラグインの追加・更新・削除といった管理操作はエディタの外からコマンドで叩けたほうが何かと都合が良い

ちょうど別プロジェクトで Rust を触っていたので、「設定は TOML、管理は CLI、Neovim 側はただ loader.lua を読むだけ」 という形を Rust で作ってみることにしました。

rvpm の特徴

  • CLI ファースト — プラグインの追加・更新・削除はすべてターミナルで完結
  • TOML 設定 — 宣言的で、Tera テンプレート対応
  • 事前コンパイルされた loader.luarvpm generate 時にプラグインディレクトリを walk してファイルリストを焼き込む。Neovim は固定の dofile() / source を並べるだけ
  • 豊富な遅延ロードトリガーon_cmd, on_ft, on_map, on_event, on_path, on_source, ColorSchemePre 自動検知、depends の解決まで実施
  • merge 最適化merge = true のプラグインは単一の runtimepath エントリに集約
  • プラグインブラウザ TUIrvpm browse で GitHub の neovim-plugin トピックを眺めながらインストール
  • AI CLI 連携rvpm add / rvpm tune で Claude / Gemini / Codex に [[plugins]] ブロックとフックファイル一式を設計させられる
  • エディタ起動への影響を抑える — CLI で管理する別プロセスなので、循環依存・存在しないプラグイン・設定ミスがあっても Neovim の起動を巻き添えにしにくい(rvpm 側は警告で止めない)

インストール

sh
1
2
3
4
5
# crates.io から
cargo install rvpm

# または最新 main から
cargo install --git https://github.com/yukimemi/rvpm

Releases ページに Linux (x86_64) / macOS (Intel / Apple Silicon) / Windows (x86_64) のビルド済みバイナリも置いてあります。

クイックスタート

sh
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 初期化 (config.toml を作り、init.lua に loader の読み込みを書き込む)
rvpm init --write

# 2. プラグインを追加
rvpm add folke/snacks.nvim
rvpm add nvim-telescope/telescope.nvim

# 3. GitHub の "neovim-plugin" トピックを TUI で探してインストール
rvpm browse

# 4. インストール済みプラグインを TUI で管理
rvpm list

# 5. config.toml を開いて細かい設定を書く
rvpm config

既存の Neovim 設定を壊さずに試す

rvpm は Neovim の $NVIM_APPNAME に対応しているので、既存の ~/.config/nvim/ をそのまま残した状態で、別の appname で試せます。

sh
1
2
3
4
# Bash / Zsh
NVIM_APPNAME=nvim-rvpm rvpm init --write
NVIM_APPNAME=nvim-rvpm rvpm add folke/snacks.nvim
NVIM_APPNAME=nvim-rvpm nvim
powershell
1
2
3
4
5
# PowerShell
$env:NVIM_APPNAME = "nvim-rvpm"
rvpm init --write
rvpm add folke/snacks.nvim
nvim

関係ファイルは次の 3 つのディレクトリに隔離されるので、気に入らなければこれらを削除するだけで跡形もなく消せます。

  • ~/.config/nvim-rvpm/ — Neovim 側の設定ディレクトリ(rvpm init --writeinit.lua をここに書き込む)
  • ~/.config/rvpm/nvim-rvpm/ — rvpm の config_root(config.toml、グローバル before.lua / after.lua
  • ~/.cache/rvpm/nvim-rvpm/ — rvpm の cache_root(plugin の clone と loader.lua

設定例

~/.config/rvpm/<appname>/config.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
[options]
concurrency = 10       # git 並列数 (デフォルト 8)
auto_clean  = true     # sync / generate で未参照プラグインを自動削除
url_style   = "full"   # config.toml に書くときは full URL で

# 即時ロード (on_* トリガーなし)
[[plugins]]
url = "folke/snacks.nvim"

# on_cmd があるので lazy = true が自動推論される
# depends は "このプラグインより先に読み込む" 依存関係
[[plugins]]
url     = "nvim-telescope/telescope.nvim"
depends = ["plenary.nvim"]
on_cmd  = ["Telescope"]

# ファイルタイプ + イベントで遅延ロード
[[plugins]]
url      = "neovim/nvim-lspconfig"
on_ft    = ["rust", "toml", "lua"]
on_event = ["BufReadPre"]

# キーマップで遅延ロード (mode と desc も指定可)
[[plugins]]
url  = "folke/which-key.nvim"
on_map = [
  "<leader>?",
  { lhs = "<leader>v", mode = ["n", "x"], desc = "Visual leader" },
]

# "他のプラグインの読み込み完了" を遅延トリガーに使う (on_source)
# snacks.nvim がロードされて rvpm_loaded_snacks.nvim が発火したら読み込む
[[plugins]]
url       = "folke/todo-comments.nvim"
on_source = ["snacks.nvim"]

on_* のどれか一つでも指定されていれば lazy は自動で true になります。

遅延ロードトリガー

キー 意味
on_cmd :Foo 実行で読み込む。bang・range・count・補完を保持
on_ft FileType イベント。発火後に再トリガーして ftplugin/ を走らせる
on_event Neovim イベント。"User Xxx"pattern = "Xxx" に自動展開
on_path BufRead / BufNewFile のグロブ一致
on_source 別プラグインが発火する rvpm_loaded_<name> User autocmd で読み込む
on_map キーマップ。文字列・テーブル(lhs / mode / desc)の両対応

lazy.nvim など主要なプラグインマネージャーが持っている遅延ロードトリガーには概ね対応しています。

Colorscheme の遅延ロード

lazy = true なプラグインが colors/*.vimcolors/*.lua を持っていると、ColorSchemePre のハンドラを rvpm generate 時に自動で登録します。設定で明示する必要はありません。

toml
1
2
3
4
5
6
7
8
[[plugins]]
url  = "folke/tokyonight.nvim"
lazy = true  # colors/ を持っているので ColorSchemePre が自動登録される

[[plugins]]
url  = "catppuccin/nvim"
name = "catppuccin"
lazy = true

複数のカラースキームを入れていても、起動コストはゼロで :colorscheme tokyonight のタイミングで初めて読み込まれます。

Tera テンプレート

config.toml 全体が TOML パース前に Tera で処理されるので、条件分岐や環境変数の参照ができます。

toml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[vars]
use_blink = true
use_cmp   = false

{% if vars.use_blink %}
[[plugins]]
url = "saghen/blink.cmp"
on_event = ["InsertEnter", "CmdlineEnter"]
{% endif %}

{% if vars.use_cmp %}
[[plugins]]
url = "hrsh7th/nvim-cmp"
on_event = "InsertEnter"
{% endif %}

{% if is_windows %}
[[plugins]]
url = "thinca/vim-winenv"
{% endif %}

{% if %} は「そもそも loader.lua に含めない」、cond = "..." は「含めるが実行時に Lua で判定」、という使い分けになっています。

コマンド一覧

コマンド 動作
rvpm sync [--prune] plugin を clone / pull して loader.lua を再生成
rvpm generate loader.lua のみ再生成(git 操作なし)
rvpm clean config.toml に無い plugin ディレクトリを削除
rvpm add <repo> plugin を追加して sync
rvpm update [query] plugin を git pull
rvpm remove [query] config.toml から plugin を削除してディレクトリも除去
rvpm edit [query] [--init|--before|--after] [--global] plugin ごとの Lua フックを $EDITOR で編集
rvpm set [query] ... lazy / merge / on_* / rev などを CLI から変更
rvpm config config.toml$EDITOR で開く
rvpm init [--write] loader.luainit.lua に繋ぎ込む
rvpm list [--no-tui] plugin を TUI で管理
rvpm browse GitHub neovim-plugin topic を TUI で閲覧

rvpm list — 管理 TUI

インストール済みプラグインの一覧表示と、そこからの操作を担う TUI です。

キー 動作
S 全プラグイン sync
u / U 選択プラグインを update / 全プラグインを update
d 選択プラグインを削除
e プラグイン固有フックを編集 (init.lua / before.lua / after.lua)
s プラグインオプションを変更 (lazy / merge / on_* など)
c config.toml$EDITOR で開く
b rvpm browse に切替
/ n N インクリメンタル検索
j k g G Ctrl-d Ctrl-u Vim ライクな移動
? ヘルプ

詳細は README を参照してください。

rvpm browse — プラグイン探索 TUI

GitHub API から neovim-plugin topic のリポジトリ(最大 ~300 件)を取得して、plugin 一覧と README プレビューの 2 ペインで表示します。端末の横幅が広ければ左右に、狭ければ上下にレイアウトが自動で切り替わります。

キー 動作
Tab リスト ↔ README のフォーカス切替
Enter 選択中のプラグインを config.toml に追加
/ ローカルインクリメンタル検索 (name + description + topics)
S GitHub API で再検索 (topic:neovim-plugin <query>)
s ソート切替 (stars / updated / name)
o プラグインの GitHub ページをブラウザで開く
l rvpm list に切替
R 検索キャッシュをクリアして再取得

既にインストール済みのプラグインには緑の が付き、Enter してもインストール済みである旨の警告だけが出て重複追加を防ぎます。検索結果は一定期間キャッシュされるので、同じ画面を何度も往復してもレスポンスは軽快です。

README プレビューは内蔵の markdown レンダラ(tui-markdown)で描画していますが、オプションで mdcat / glow / bat など外部レンダラにパイプすることもできます。

toml
1
2
3
[options.browse]
readme_command = ["mdcat"]
# readme_command = ["glow", "-s", "dark", "-w", "{{ width }}", "{{ file_path }}"]

ディレクトリ構成とユーザー設定

rvpm のファイル配置は次のようになっています。<appname>$RVPM_APPNAME$NVIM_APPNAME"nvim" の順で解決されるので、Neovim の appname 切り替えとそのまま揃います。

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
~/.config/rvpm/<appname>/                    ← config_root
├── config.toml                              ← プラグイン宣言と [options]
├── before.lua                               ← グローバル before (ユーザー設定前半)
├── after.lua                                ← グローバル after  (ユーザー設定後半)
└── plugins/<host>/<owner>/<repo>/           ← プラグイン固有のフック
    ├── init.lua                             ← rtp 追加前
    ├── before.lua                           ← rtp 追加後、plugin/* ソース前
    └── after.lua                            ← plugin/* ソース後

~/.cache/rvpm/<appname>/                     ← cache_root
├── plugins/
│   ├── repos/<host>/<owner>/<repo>/         ← plugin の clone 先
│   ├── merged/                              ← merge=true の共通 rtp
│   └── loader.lua                           ← 生成されるローダー
└── browse/                                  ← `rvpm browse` のキャッシュ

Windows でも %APPDATA% ではなく %USERPROFILE%\.config\... / %USERPROFILE%\.cache\... に配置されます。dotfiles をそのまま持ち運べる構造にしたかったのでこうしています。

ユーザー自身の設定も rvpm に任せられる

rvpm の考え方としてユーザーの Neovim 設定そのものも rvpm の管理下に置く、という使い方にしています。init.lua にはローダーの読み込み一行だけを書き、それ以外のユーザー設定は config_root/ 直下の before.lua / after.lua に分けて置く形です。

ファイル フェーズ 用途
~/.config/nvim/init.lua loader.luadofile(...) するだけ(rvpm init --write で自動生成)
{config_root}/before.lua Phase 3 プラグインよりに走らせたい設定 (vim.g.* など)
{config_root}/after.lua Phase 9 プラグインが揃ったに走らせたい設定(キーマップ、カラースキーム適用など)

これらは設定エントリなしで rvpm generate 時に自動検出されます。同じ仕組みでプラグイン固有のフックも plugins/<host>/<owner>/<repo>/init.lua / before.lua / after.lua を置くだけで拾われます。

lua
1
2
3
4
5
-- ~/.config/rvpm/nvim/plugins/github.com/nvim-telescope/telescope.nvim/after.lua
require("telescope").setup({
  defaults = { layout_strategy = "vertical" },
})
vim.keymap.set("n", "<leader>ff", "<cmd>Telescope find_files<cr>")

config.toml・グローバルフック・プラグイン固有フックを全部同じツリーの下に置けるので、dotfiles として ~/.config/rvpm/ ごと管理すれば Neovim 設定一式が rvpm にまとまります。

AI による rvpm add — Claude / Gemini / Codex 連携

rvpm add には、追加するプラグインの [[plugins]] ブロックの設計まるごと AI CLI に任せるモードがあります。options.ai = "claude" と書く(または --ai claude を都度指定する)だけで、プラグインの README / doc/ を読み込んだ AI が [[plugins]] エントリと per-plugin の init.lua / before.lua / after.lua までセットで提案してきます。

rvpm AI add

toml
1
2
3
[options]
ai = "claude"           # "off" (default) | "claude" | "gemini" | "codex"
ai_language = "ja"      # 説明文を日本語で返してもらう

rvpm add owner/repo 実行時の流れはこんな感じです。

  1. プラグインを clone(通常の add と同じ)
  2. 「rvpm の TOML スキーマ」「プラグインの README + doc/」「現在の config.tomlplugins/ ツリー」「既存の per-plugin フックファイル」をプロンプトに組み立てる
  3. 設定された AI CLI を claude -p / gemini -p / codex exec で one-shot 起動
  4. 返ってきた XML(<rvpm:plugin_entry> / <rvpm:init_lua> / <rvpm:before_lua> / <rvpm:after_lua> / <rvpm:explanation>)をパース
  5. 提案を表示して Apply / Chat / Hand off / Skip を選ばせる

Chat を選ぶと「depends に plenary を追加して」「これは eager で読み込みたい」のような要望をワンラインで投げて再提案させられます。Hand off を選ぶとここまでのやり取りを一時ファイルに保存して、その CLI のインタラクティブセッションに制御を渡します。AI CLI 側のファイル編集ツール(Claude Code なら Edit / Write)でそのまま続きを進められます。

rvpm add は新規追加時にしか動かないので、既に config.toml に登録済みのプラグインを AI に再設計させたい場合は rvpm tune <query> を使います。tune ではセクション単位で Use FRESH(クリーンな再設計で上書き)/ Use MERGED(既存の編集を残しつつ提案を取り込む)/ Keep existing(変更しない)が選べます。

なぜこの機能が AI と相性良く成立するのか

これがこの記事で一番書きたかった話です。AI add を雑な思いつきで載せたわけではなく、rvpm のディレクトリ構造とフックのルールが固定化されているからこそ成立しています。

  • プラグイン側の設定は [[plugins]] ブロックという単一のスキーマに集約されている。on_* トリガーや lazy / merge の意味は TOML 上で完結していて、Lua をどこに書くべきかで悩む余地がない
  • per-plugin のフックは {config_root}/plugins/<host>/<owner>/<repo>/ 以下の init.lua / before.lua / after.lua3 ファイルだけ。それぞれが走るタイミング(rtp 追加前 / plugin/* ソース前 / plugin/* ソース後)も 9 フェーズのローダーモデル上で固定
  • グローバルフックも {config_root}/before.lua / after.lua2 つに限定

この「置き場所と意味が一意に決まっている」ことのおかげで、

  • AI に渡すプロンプトをテンプレ化できる(スキーマと既存ファイルの内容をそのまま埋め込めば過不足ない)
  • AI 側も「どのフックに何を書けば、いつ走るか」を曖昧さなく出力できる
  • Apply 時に「[[plugins]] だけ FRESH を採用、after.lua は MERGED、before.lua は Keep」のような per-section の選択肢が成立する

逆にここがフリーフォーム(init.lua 一枚に何でも書く方式)だと、AI の出力もコンテキストに強く依存して再現性が下がりますし、ユーザーの既存設定との突き合わせも難しくなります。「リポジトリパスでフックファイルを分離する」という構造的な制約が、AI 時代になって思いがけず効いてきた、という話でした。

設計のポイント

9 フェーズのローダーモデル

rvpm generate が吐く loader.lua は次の 9 フェーズで構成されています。

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Phase 1: vim.go.loadplugins = false            -- Neovim の自動 source を抑止
Phase 2: load_lazy ヘルパ                       -- 遅延プラグインを実行時に読み込む関数
Phase 3: global before.lua                     -- ユーザーの before フック
Phase 4: 全プラグインの init.lua                 -- 依存順 / rtp 追加前
Phase 5: rtp:append(merged_dir)                -- merge=true 用の単一 rtp
Phase 6: eager プラグイン (依存順):
            rtp 追加 → before.lua
            plugin/**/*.{vim,lua} を source
            ftdetect/** を source
            after/plugin/** を source
            after.lua
            User autocmd "rvpm_loaded_<name>" 発火   -- on_source の待ち受け先
Phase 7: 遅延トリガー登録                        -- on_cmd / on_ft / on_map / ...
Phase 8: ColorSchemePre ハンドラ                 -- colors/ がある lazy プラグイン用
Phase 9: global after.lua

plugin/ / ftdetect/ / after/plugin/ のファイルリストは rvpm generate のタイミングで glob して loader.lua に焼き込んでいます。 そのため Neovim 起動時は glob が走らず、固定の dofile() / source を順次読み込むだけです。 I/O コストは CLI 側で払っておいて、エディタ起動側はそれを静的に消費するだけ、という分業にしたかったのがこの設計の出発点でした。

各フェーズが実測でどれくらいかかっているかは、冒頭で紹介した rvpm profile の TUI で phase ごと・プラグイン単位に可視化できます。

merge 最適化

merge = true のプラグインは、{cache_root}/plugins/merged/ に集約されて単一の vim.opt.rtp:append(merged_dir) になります。eager なプラグインをどれだけ積んでも &runtimepath が膨らまないのが利点です。

chezmoi 統合

自分は dotfiles を chezmoi で管理しているので、専用のオプションも追加しました。 options.chezmoi = true にするとすべての書き込み(config.toml、グローバルフック、プラグイン固有フック)を chezmoi の source 側に行い、その後 chezmoi apply --force <target> で target に反映するフローに切り替わります。

toml
1
2
[options]
chezmoi = true

技術的なポイント

Rust

Rust のエコシステムに乗せたことで得られたものが二つあります。

一つは Tera テンプレートエンジンです。 TOML という宣言的なフォーマットに寄せつつ、{% if %} による条件分岐や {{ vars.xxx }} / {{ env.XXX }} / {{ is_windows }} での変数展開が挟めるので、「設定は宣言的に、でもちょっとしたロジックは書きたい」という欲張りな要求を満たせています。dvpm 時代に TypeScript だから自由にできていた部分を、TOML に落としても失わずに済んだ形です。

もう一つは ratatui による TUI の表現力の部分です。 rvpm sync の進捗表示や rvpm list / rvpm browse のペイン分割、リアルタイムに更新される clone 状態など、ターミナル上でそれなりに見栄えのする画面が作れました。CLI ファーストを掲げるなら画面もちゃんと作り込みたかったので、ratatui の表現力に助けられています。

前身 dvpm との関係

rvpm は dvpm の後継プロジェクトという位置付けです。dvpm は denops ベースで「TypeScript で Vim/Neovim の設定を書く」という尖った方向性でしたが、rvpm は 「Neovim の標準的な Lua 設定に CLI で補助輪を付ける」 という立ち位置を取っています。

設計面では、volt から影響を受けています。(けっこう好きでした) volt は Go 製の CLI プラグインマネージャーで、$VOLTPATH/plugconf/<host>/<owner>/<repo>.vim というリポジトリ URL そのままのパスに各プラグインの設定ファイルを置く、という発想を持っていました。 rvpm の {config_root}/plugins/<host>/<owner>/<repo>/ 以下に init.lua / before.lua / after.lua を配置する仕組みは、ここから直接影響を受けています。 「プラグインごとの設定を vimrc (init.lua) から切り離して、リポジトリパスに対応したファイルとして独立管理する」という思想そのものが volt 由来です。 lazy.nvim からも影響を受けていて、遅延ロードトリガーの命名(on_cmd / on_ft / on_event / on_map)はそちらに寄せました。

volt の「CLI で完結」「リポジトリパスで plugin 設定を分離」「ユーザーの 設定自体もマネージャ管理下に置く」という哲学と、lazy.nvim の遅延ロード API 設計、そして dvpm で積んできた自前のプラグインマネージャー実装の経験 — この三つが rvpm のベースになっています。

おわりに

自分で使うためのツールなのでドッグフーディングは毎日しています。自分の dotfiles も ~/.config/rvpm/nvim/ に移行済みで、200 個近いプラグインを rvpm で管理しています。

Neovim プラグインマネージャー自体は世の中に優秀なものが既にたくさんありますが、「CLI でプラグイン管理を完結させたい」といった嗜好に合う方がいれば、ぜひ試してみてください!