chezmoi で AI エージェントの設定を一つの原稿から配布する

先日, CyberAgent Developers Blog にて私が執筆した以下の記事が公開された.

本記事では, 上記記事の宣伝と, Claude Code と Codex CLI という複数の AI エージェントの設定を chezmoi で一元管理するにあたって採った設計判断について技術的な観点からまとめている. 併せて, その過程で踏んだ上流ツールの性能問題を切り分けて修正まで持っていった話にも触れる.

背景と課題

AI エージェントを日常的に使うようになると, その設定がホームディレクトリ配下へ際限なく増えていく. Claude Code なら CLAUDE.md, Codex CLI なら AGENTS.md というように, 同じ趣旨の指示を書くファイルがツールごとに別の名前と別の場所で要求される. skills やポリシーの類も同様に, ツールごとのディレクトリへ分かれて置かれる.

素朴な対処はコピーだが, これは以下の問題を生む.

  • コピーした時点で独立した 2 ファイルになり, 片方だけ更新されて内容がずれる
  • 私用マシンと業務マシンの 2 台に配ると, 同期を保つべきファイルが 4 つに増える
  • ずれたことに気付く契機がない. エージェントの挙動が違うと感じて初めて発覚する

エージェントへの指示には「どのコマンドを実行してよいか」「どこまで自動で書き込んでよいか」が含まれる. そのため設定のずれが単なる不便では済まず, 事故に直結しうる. そこで共通ルールについては単一の原稿を正とし, そこから各ツール向けの形へ生成して配る構成を採ることにした. 一方で, ツール固有の設定まで無理に共通化すると今度は表現できない差分が出る. 「共通化する範囲をどこで切るか」が設計上の主要な論点であった.

chezmoi について

chezmoi は, source state と target state を分離して扱う dotfile 管理ツールである. リポジトリ側に置く source state は必ずしも配布物そのものではなく, chezmoi apply の時点でホームディレクトリ上の target state へ変換される. 今回の用途において有効だったのは以下の性質である.

  • Go template を扱えるため, source state を「生成器」として書ける
  • .chezmoi.homeDir.chezmoi.os, ホスト名, 独自定義の data を参照して環境差分を吸収できる
  • chezmoi diffchezmoi apply --dry-run --verbose により, 適用前に差分を確認できる
  • run_after_ などのスクリプトで, 適用後の処理をフックできる

単なる symlink farm 型のツールでは「単一の原稿から複数の成果物を生成する」要件を満たせない. 今回はその生成が構成の中心にあったため, テンプレート機構を持つ chezmoi を土台に選んだ.

一つの原稿から CLAUDE.md と AGENTS.md を生成する

共通ルールは .chezmoitemplates/agent-instructions/base.md.tmpl に一本化した. ここへ置いたのは, 特定のツールに依存しない次のような規約である.

  • コードを変更したら format, lint, test を通すこと
  • 破壊的な Git 操作や外部サービスへの書き込みを, 確認なしに実行しないこと
  • レビューでは正しさ, 保守性, 運用時の失敗モードを見ること

各ツール向けのファイルは, この共通テンプレートを読み込むだけの薄いラッパとして書く. 読み込む際に, そのツールのホームディレクトリを引数として渡す.

{{ template "agent-instructions/base.md.tmpl"
  (dict "agent_home" (printf "%s/.claude" .chezmoi.homeDir))
}}

AGENTS.md.tmpl 側は .codex を渡す点だけが異なる. この形にしたことで, 共通ルールを直す場所は一箇所に固定される. 一方で hooks, permissions, agents 定義といったツール固有の設定はラッパ側に残るため, 共通化できないものを無理に共通化する必要もない. 「共通部分をテンプレートへ, 差分をラッパへ」という切り方が, 先述した論点に対する回答である.

skills のようにディレクトリ単位で持つ資産については, 実体を一方へ寄せ, もう一方は symlink テンプレートで参照する形にした. symlink 側のソースには, リンク先のパスを出力するテンプレートを書く.

{{ .chezmoi.homeDir }}/.claude/skills/review/SKILL.md

内容が完全に一致するものはこれで二重管理を避けられる. 逆に, ツール固有の書き分けが必要になった時点で symlink をやめて実体を分ければよい. 最初から全てを実体で持つのではなく, 差分が生じたものだけを実体へ昇格させる運用にしている.

chezmoi と APM の責務を分ける

skills や instructions を配布する手段としては, APM (Agent Package Manager) も存在する. 両者は競合するように見えるが, 扱う対象の性質が異なるため以下のように責務を分けた.

chezmoi APM
対象 ホームディレクトリ全体の土台 再利用可能なエージェント primitive
具体例 非公開のポリシー, 各ツールの設定, マシン差分, 暗号化, セットアップスクリプト 公開パッケージとして配れる skills, instructions
提供する機能 生成と配置, 環境差分の吸収 依存解決, lockfile, スキャン

判断軸は「環境固有か, パッケージとして再利用できるか」である. 私の手元の環境にしか意味を持たない設定は chezmoi が持ち, 他プロジェクトや他者へ配れるものは APM が持つ.

両者は独立に動かすのではなく, chezmoi の run_after_ スクリプトから APM を起動して接続している.

#!/bin/sh
set -eu

if command -v apm >/dev/null 2>&1; then
  apm install --global
  apm compile --global
fi

これにより, chezmoi apply 1 回でホームディレクトリの状態とパッケージの状態がそろう.

秘密情報を公開リポジトリへ持ち込まない

dotfile を公開リポジトリで管理する以上, 秘密情報の扱いは避けて通れない. ここで注意したいのが, .chezmoiignore は管理対象からの除外であって暗号化ではないという点である. 除外だけに頼ると, 除外し忘れた瞬間に平文がコミットされる.

採った対策は以下のとおりである.

  • 秘密そのものではなく, 取得方法をコミットする. パスワードマネージャからの参照をテンプレートに書き, chezmoi apply の時点で解決する
  • 手元に持たざるを得ない値は age や GPG で暗号化してから source state へ置く
  • preksecretlint を組み合わせ, コミット時に自動検査する
  • 権限の強い skill や社内限定のポリシーは, そもそも別の非公開リポジトリへ分ける

検査は最後の防波堤であり, 設計側で「平文を置かない」ことを先に決めておく方が確実である.

APM の性能問題を切り分けてアップストリームへ返した

構築の途中, apm install --global --target claudeIntegrating local .apm/ content... の表示のまま長時間止まる事象に遭遇した. 一方で検証用の環境では同じコマンドが数秒で完了する. ローカル固有の問題であることは明らかだったため, HOME を段階的に組み替えて切り分けた.

  1. 一時ディレクトリを HOME とし, ~/.apm のみを置いて実行する
  2. そこへ ~/.claude を加えて実行する
  3. さらに ~/.codex を加えて実行する

いずれも 1.7 秒から 4.1 秒で完了した. つまり ~/.claude の内容量そのものではなく, 原因は実 HOME 配下の広大な走査にある可能性が高いと絞り込めた.

macOS のプロセスサンプリングを取ると os.scandir の呼び出しが繰り返し現れていた. APM のソースを追うと, user scope では deploy root が Path.home() になり, ~/.apm が存在する場合の local integration が そこを起点に primitive を再帰的に探索する実装になっていた. 私の HOME には無関係なディレクトリが大量にあるため, ここで時間を使い切っていた.

この内容を issue #830 として報告し, 修正は PR #850 として取り込まれた. 探索範囲を $HOME から ~/.apm/ に限定しつつ, 既存の integrator との互換性のために install_path は元の値を保つ形になっている. この修正は APM 0.9.3 に含まれている.

ここから得られた知見は, 生成物ではなく生成器を共有する構成では, 生成器側の性能特性が環境依存で顕在化するということである. 再現しない不具合に見えても, 環境の差分を機械的に削っていけば原因の所在は特定できる.

成果と展望

この構成によって, 以下の成果が得られた.

  • 共通ルールを一箇所直せば両ツールへ反映されるため, 「片方だけ更新し忘れる」事故がなくなった
  • 新規マシンの立ち上げが chezmoi init --apply に集約され, 環境構築が再現可能になった
  • chezmoi diff により, 適用前に何が変わるかを確認できるようになった

三点目は, エージェント設定においては特に重要である. 指示ファイルの変更は実行を許可するコマンドや自動化の範囲に直結するため, 差分を読まずに適用するのは危うい. source state と target state が分離しているという chezmoi の性質が, そのままレビュー可能性として効いている.

現時点の構成は完成品ではなく, 動くところまで来た試作である. 今後は共有 skill の拡充と, 設定変更に対するレビューフローの整備, および chezmoi と APM の境界の見直しを継続していく予定である.

最後に

本記事は, 私が個人として日常的に AI エージェントを使う中で直面した課題に対し, 設計と実装, 運用に取り組んだ経験をもとに執筆したものである. 記事内で紹介している構成や手法は, 複数のエージェントを併用している環境であれば応用可能であると考えている. 興味を持たれた方は, ぜひ上記のリンクから記事全文をご覧いただきたい.


活動継続のためのご支援を募集しています