3ヶ月前にこちらの記事 Claude Code Skills 共有管理 — 4つのAIツールを1リポジトリで統一する方法 で、Claude Code・Codex・OpenClaw・Antigravity の skills を 1 リポジトリにまとめる symlink 設計を紹介しました。あの記事の setup-skills.sh を 3ヶ月運用した結果、初版では気づけなかった落とし穴をいくつか踏みました。本記事はその続編として、冪等性まわりの実装ディテールに絞ってまとめます。
「あの記事を読んでない人は、まず前作をどうぞ」というのが正直なところで、4 ツールのパス規約・AGENT_CONFIGS 配列・symlink 共有のメリットあたりは 前作 の表で押さえてあります。本記事では繰り返しません。なお OpenClaw は別記事で扱う事情があり、ここでは Claude Code・Codex・Antigravity の 3 ツールに絞ります(OpenClaw の Docker 移行で symlink 元が変わったので、続編をもう 1 本書く予定です)。
3ヶ月前の自分が雑に ln -sfn で済ませてたのが、いま手元で循環 symlink を量産してます。順を追って供養していきます。
この記事で扱うこと
- 「すでに何かある」を
mvでバックアップしてしまったときの backup 地層問題 - BSD ln(macOS)の dereference 挙動と
-sfnだけでは取りこぼす場面 config.tomlをGENERATED_MARKERで「自動生成か手書きか」判定する設計- Codex runtime がファイルに書き戻すから
rules/default.rulesを symlink にしない、という判断基準
skills ディレクトリのパス規約(Antigravity だけ ~/.gemini/antigravity/skills で 1 段深い、など)は前作の表に任せます。引き続き AGENT_CONFIGS を配列で持ってループで処理する骨格は変えていません。
落とし穴 1: mv でバックアップを作ると、backup 地層ができる
前作の setup-skills.sh では、ターゲットパスに実ディレクトリがあったら mv で *.backup.YYYYMMDDHHMMSS に退避してから symlink を張る 方針でした。
# 前作(初版)のロジック
if [ -e "$target_path" ] && [ ! -L "$target_path" ]; then
backup="${target_path}.backup.$(date +%Y%m%d%H%M%S)"
mv "$target_path" "$backup"
fi
ln -sfn "$SKILLS_REPO" "$target_path"
これ、初回は綺麗に動きます。問題は 3ヶ月後で、ホームディレクトリに ~/.claude/skills.backup.20260227142301 みたいなものが溜まり始めます(手元で実際に 5 つ見つかりました)。du で見ると合計数百 MB あって、中身は古い skill のスナップショット。消していいのか判断できず、しばらく放置されます。
判断できない理由はシンプルで、「いつ、なんで mv されたか」の文脈が backup ディレクトリ名にも setup-skills.sh のログにも残らないからです。date +%Y%m%d%H%M%S だけ残しても、半年後の自分には「2026 年 2 月 27 日 14 時 23 分に何があった?」は分からない。
そこで現行版では「自動 mv はしない・手動退避を促して return 1」に倒しました。
elif [[ -d ${target_path} ]]; then
log_warn " Directory exists at ${target_path}"
log_warn " Please manually backup/remove if you want to use shared skills"
return 1
fi
初版に比べると不親切ですが、return 1 で止まれば人間が見に行くので、結果的に backup 地層は形成されません。「自動で消えないこと」よりも「気づかないうちに溜まらないこと」を優先、というトレードオフです。「全部 try/catch で握ったら、握った例外がどこで起きたか分からなくなる」のと似た構造で、雑なフェイルセーフは雑な負債になります。
落とし穴 2: BSD ln の dereference 挙動と、-sfn の取りこぼし
前作のスクリプトで ln -sfn を使っていたのは、「既存の symlink がディレクトリを指している場合に中に新しい symlink が作られる」罠を避けるためでした。-n フラグで symlink を「直リンク」として扱わせる、という GNU coreutils と共通のセオリーです。
ところが手元では、これだけでは取りこぼす場面に遭遇しました。Google Workspace 関連の別 dotfiles プラグインで、activation の symlink 生成にこういうコードがあって:
# 末尾 / 付きの plugin_dir + 既存 directory symlink に対して
ln -sf "$src" "${plugin_dir}/"
BSD ln(macOS の /bin/ln)は、${plugin_dir}/ のように末尾 / を付けると link を辿って中に新しい link を作る 挙動になります。-n を付けても、末尾 / がある時点で「中に入れろ」と解釈される。結果、hermes/plugins/path_guard/path_guard のような循環 symlink が静かに増殖して、nix run .#update のたびに untracked として残り続けます(git status を開くたびに「お前まだいたのか」と思うやつ)。
修正は 2 行で、
- 末尾
/を必ず剥がす:plugin_dir="${plugin_dir%/}" - 既存 symlink は
rm -fで必ず消してからln -sし直す:ln -sfの暗黙更新に頼らない
ln -sfn の -n だけに頼っていた前作の感覚で書くと、BSD ln では一手取りこぼすことがある、という話です。コミット履歴を遡ると fix(hermes): plugin 用 activation で循環 symlink を生成しないようにする で同じ修正をしているので、3ヶ月前の自分は同じ trap に 2 回踏みに行っているわけです(学ばない)。
落とし穴 3: config.toml を cat で生成すると、人間の手書きが消える
前作の setupCodex の活性化スクリプトでは、config.base.toml + config.local.toml を cat でつないで config.toml を作る方針でした。
# 前作(初版)の発想
cat "$DOTFILES_CODEX/config.base.toml" \
"$CODEX_DIR/config.local.toml" \
> "$CODEX_DIR/config.toml"
この発想自体は今も変えていません。base / local の責務分離は 前作の表 のとおりで、共有可能な設定と機密 secrets を 2 ファイルに切るのは正しい。問題は、人間が ~/.codex/config.toml を直接編集していたら、その編集が次回 activation で黙って消える ことです。
これは普通に起きます。「ちょっと model_reasoning_effort を一時的に上げて検証したい」みたいなとき、config.local.toml を経由するのは面倒なので config.toml を直接書き換えたくなる。半日後に nix run .#update を走らせると、その編集はなかったことになっています。
対策として現行版では、config.toml の先頭行に GENERATED_MARKER を書く・次回 activation でその marker を見て「自動生成か手書きか」を判定する 方式に変えました。
GENERATED_MARKER="# AUTO-GENERATED BY DOTFILES CODEX SETUP"
# 既存 config.toml の先頭行を確認
if [ -f "$CODEX_DIR/config.toml" ] && \
[ "$(head -n 1 "$CODEX_DIR/config.toml" 2>/dev/null)" != "$GENERATED_MARKER" ]; then
# 手書きの可能性があるので、消さずに backup 退避
backup="$CODEX_DIR/config.toml.backup.$(date +%Y%m%d%H%M%S)"
cp "$CODEX_DIR/config.toml" "$backup"
echo "Backed up existing config.toml to $backup"
fi
# 改めて base + local で再生成
{
echo "$GENERATED_MARKER"
echo "# Edit dotfiles/codex/config.base.toml for shared settings."
echo "# Edit ~/.codex/config.local.toml for local secrets and overrides."
echo ""
cat "$CODEX_DIR/config.base.toml"
if [ -s "$CODEX_DIR/config.local.toml" ]; then
echo ""
echo "# ---- Local overrides ----"
cat "$CODEX_DIR/config.local.toml"
fi
} > "$tmp_config"
mv "$tmp_config" "$CODEX_DIR/config.toml"
chmod 600 "$CODEX_DIR/config.toml"
ポイントは 2 つ。
- 先頭が marker でない
config.tomlは backup 退避してから再生成。手書き編集を黙って消さない。 extractedロジックで、手書きの[projects.*]/[mcp_servers.*]セクションをconfig.local.tomlに救出する。これは初回 migration 用の機能で、既存の手書き Codex 設定を初めて nix 管理に取り込む際に効きます。
落とし穴 1 で「自動 mv はやめた」と言ったばかりで、ここでは「backup 退避する」のは矛盾に見えますが、判断基準は別です。skill ディレクトリは「同じ場所に新しい symlink を張れば次回から問題なく動く」ものなので backup を残す動機が薄い。一方 config.toml は「人間の編集が消えたかどうか」を後から確認したいので、backup を残す動機がある。後者は backup ファイルが 1 個ずつなので地層化もしません(base + local の責務分離自体については 前作 の「機密情報は .local ファイルに分離」項を参照)。
落とし穴 4: Codex runtime がファイルに書き戻すから、rules/default.rules は symlink にしない
最後がいちばん地味で、いちばん面倒なやつ。Codex の ~/.codex/rules/default.rules を symlink にすると、Codex 本体が起動中に「学習結果」をこのファイルに書き戻すケースがあり、その差分がそのまま dotfiles リポジトリに乗ります。
# やったらダメな書き方
ln -sfn "$DOTFILES_CODEX/rules/default.rules" "$CODEX_DIR/rules/default.rules"
これをやると、cd ~/ghq/github.com/.../dotfiles && git status が毎回ゴミだらけになります。Codex 利用中の一時的な状態が dotfiles のワークツリーに漏れる、というだけのことなんですが、nix 管理だと毎日 nix run .#update 前に git status を確認する習慣がついているので、毎日視界の隅に汚れが出るのは精神的に響きます。
現行版では rules/ だけ、「初回コピー」「以後は触らない」というポリシーに変えています。
# rules は runtime で更新されるためローカル実体ファイルを保持する
rules_dir="$CODEX_DIR/rules"
mkdir -p "$rules_dir"
rules_target="$rules_dir/default.rules"
if [ -L "$rules_target" ]; then
rm "$rules_target" # 過去に symlink にしてしまっていたら剥がす
fi
if [ ! -f "$rules_target" ] && [ -f "$DOTFILES_CODEX/rules/default.rules" ]; then
cp "$DOTFILES_CODEX/rules/default.rules" "$rules_target"
fi
ここから抽象化すると、判断基準は 1 行で済みます。ツールが起動中にそのファイルに書き戻す可能性があるなら、symlink にしない。symlink にすると、ローカルの runtime 状態が dotfiles リポジトリに漏れて、git の世界に出てきます。
似たケースをほかにも挙げると:
| ファイル | symlink か copy か | 理由 |
|---|---|---|
config.base.toml | 永続 symlink | dotfiles の更新を即反映したい・ツールは書き戻さない |
config.local.toml | 初回 copy | ホスト固有の secrets。dotfiles 側で上書きしたくない |
config.toml | 起動ごとに再生成 | base + local の merge 結果 |
prompts/, policy/ | 永続 symlink | 静的アセット、ツールは書き戻さない |
rules/default.rules | 初回 copy | Codex runtime が書き戻す → symlink にすると dotfiles を汚す |
auth.json, history.jsonl, sessions/ | 管理しない | runtime データそのもの。dotfiles に乗せる動機がない |
3ヶ月前の自分は「全部 symlink でええやん」と思っていましたが、3ヶ月後に「全部 symlink にしたら git status がノイズだらけ」と気づきます。ツール側がファイルに書き戻すかどうか を 1 個ずつ確認するの、面倒ですが、後から git status を見るたびにイラっとするコストを考えると先にやっておいたほうが安いです。
まとめ
前作 を 3ヶ月運用して気付いたのは、初版の「動くスクリプト」と運用版の「冪等なスクリプト」のあいだには、4 つの分かれ目があるということです。
- 自動
mvバックアップは作らない — backup 地層を作ると半年後に判断不能になる - BSD ln では
rm -fしてからln -s—-sfnだけでは末尾/の dereference を防げない config.tomlはGENERATED_MARKERで自動生成判定 — 人間の手書きを黙って消さない- runtime が書き戻すファイルは symlink にしない — git status をノイズで汚さない
逆に、ここまで仕込んでおけば、ツールが 4 つ目に増えたとき(このペースだと年内にもう 1 つ生えそう)、AGENT_CONFIGS に 1 行足して nix run .#update を回すだけで済みます。初版は雑でいい、痛い目を見たら 1 つずつガードを足す、で十分間に合います。3ヶ月前の自分には言いたいことが少しありますが、まあ、そのときは「半年後の自分が苦しむ」と書いてあっても聞かなかったでしょうし。



