From d293d4f8f2fb80337c6cdc0b44cc627f3ef6bd7c Mon Sep 17 00:00:00 2001 From: thinceller Date: Sun, 9 Aug 2026 03:12:54 +0900 Subject: [PATCH] =?UTF-8?q?fix(claude-code):=20settings.json=20/=20CLAUDE.?= =?UTF-8?q?md=20=E3=82=92=20symlink=20=E3=81=8B=E3=82=89=E3=82=B3=E3=83=94?= =?UTF-8?q?=E3=83=BC=E9=85=8D=E5=82=99=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit home-manager が張る nix store への symlink は store ファイルの mtime が 1970 のため、Claude Code の retention cleanup (housekeeping) に 「期限切れ」と判定されて削除される (cleanupPeriodDays=9999 でも、 判定が stat で symlink を辿るため)。また read-only symlink のままでは Claude 本体・デスクトップアプリからの設定書き込みも全て失敗する。 2026-06〜07 の ~/.claude/CLAUDE.md (user memory) 消失も同根。 - deploy.nix: upstream モジュールの home.file 配備を enable=false で 無効化し、activation (linkGeneration 後) で通常ファイルとして install。前回配備内容を baseline として保持し、Claude が実行時に 書いた変更は *.drift へ退避。claude-config-drift で差分確認できる - common.nix: darwin (default.nix) / oberon (server.nix) で重複していた settings を共通化。生成される settings.json の内容は全ホストで 変更前と同一 (store path 一致で確認) 参考: anthropics/claude-code の issue 52525 / 55485 / 15786 (nix store symlink の settings.json が壊れる既知報告) Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- home-manager/programs/claude-code/common.nix | 106 +++++++++++ home-manager/programs/claude-code/default.nix | 166 +++++------------- home-manager/programs/claude-code/deploy.nix | 113 ++++++++++++ home-manager/programs/claude-code/server.nix | 91 +--------- 5 files changed, 272 insertions(+), 206 deletions(-) create mode 100644 home-manager/programs/claude-code/common.nix create mode 100644 home-manager/programs/claude-code/deploy.nix diff --git a/CLAUDE.md b/CLAUDE.md index e51c6cec..0c1402ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,7 +155,7 @@ pkgs = import nixpkgs { #### 6. Claude Code Global Skills and User Memory Global (user-level, not project-level) custom skills for Claude Code are managed under `home-manager/programs/claude-code/`: - `skills/`: Custom skills (e.g., `playwright-cli`, `vault-capture`) -- `user-memory.md`: Global user memory for Claude Code (symlinked to `~/.claude/CLAUDE.md` via `memory.source`) +- `user-memory.md`: Global user memory for Claude Code (deployed to `~/.claude/CLAUDE.md` as a regular file copy via `deploy.nix`'s activation script, not a symlink — a nix store symlink's 1970 mtime gets treated as expired by Claude Code's retention cleanup and deleted) Skills are symlinked into `~/.claude/` via the `skillsDir` option, making them available globally across all projects. The `playwright-cli` skill provides structured Playwright CLI (`@playwright/cli`) command documentation for browser automation via `npx`. diff --git a/home-manager/programs/claude-code/common.nix b/home-manager/programs/claude-code/common.nix new file mode 100644 index 00000000..036d4292 --- /dev/null +++ b/home-manager/programs/claude-code/common.nix @@ -0,0 +1,106 @@ +# darwin (default.nix) / server (server.nix) 共通の Claude Code 設定。 +# ホスト固有分 (macOS sandbox, voiceEnabled, Mnemos, codex plugin 等) は +# それぞれの呼び出し元で `common.settings // { ... }` として上書き・追加する。 +{ pkgs }: +let + claudeCodePackage = import ./package.nix { inherit pkgs; }; + herdrIntegration = import ./herdr-hooks.nix { inherit pkgs; }; + herdrClaudeHooks = herdrIntegration.hooks; + statuslineScript = pkgs.writeShellScript "claude-statusline" ( + builtins.readFile ./statusline-command.sh + ); +in +{ + package = claudeCodePackage; + inherit herdrIntegration; + + settings = { + theme = "dark"; + autoCompactEnabled = false; + alwaysThinkingEnabled = true; + language = "japanese"; + autoMemoryEnabled = true; + cleanupPeriodDays = 9999; + + model = "opus"; + skipAutoPermissionPrompt = true; + useAutoModeDuringPlan = true; + # fullscreen (alt-screen) レンダラー。旧 env CLAUDE_CODE_NO_FLICKER=1 と等価。 + tui = "fullscreen"; + + permissions = { + allow = [ + "WebFetch" + "WebSearch" + "Bash(ls:*)" + "Bash(grep:*)" + ]; + ask = [ + "Bash(rm:*)" + "Bash(git merge:*)" + "Bash(git rebase:*)" + "Bash(git push:*)" + ]; + deny = [ + "Read(~/.ssh/**)" + "Read(.env*)" + "Bash(sudo:*)" + "Bash(git commit --no-gpg-sign:*)" + "Edit(~/.ssh/**)" + "Edit(.env*)" + ]; + defaultMode = "auto"; + }; + + env = { + BASH_DEFAULT_TIMEOUT_MS = "60000"; + BASH_MAX_TIMEOUT_MS = "180000"; + CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1"; + + # wrapper の --set DISABLE_AUTOUPDATER は wrapper 経由の起動しか守れない。 + # native binary (chrome-native-host 等) も settings.json の env は読むため、 + # ここで無効化しないと updater が ~/.local/bin/claude を再生成し + # Nix wrapper を PATH shadow する (2026-06-28, 2026-07-05 に再発)。 + DISABLE_AUTOUPDATER = "1"; + + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; + CLAUDE_CODE_NEW_INIT = "1"; + }; + + hooks = herdrClaudeHooks // { + # herdr の汎用 PreToolUse エントリ。 + PreToolUse = herdrClaudeHooks.PreToolUse ++ [ + { + matcher = "*"; + hooks = [ + { + type = "command"; + command = herdrIntegration.toolMetadataScript; + timeout = 10; + } + ]; + } + ]; + }; + + statusLine = { + type = "command"; + command = statuslineScript; + }; + + extraKnownMarketplaces = { + "thinceller-claude-plugins" = { + source = { + source = "github"; + repo = "thinceller/claude-plugins"; + }; + }; + }; + + enabledPlugins = { + # thinceller-claude-plugins + "git-toolkit@thinceller-claude-plugins" = true; + "engineering@thinceller-claude-plugins" = true; + }; + }; +} diff --git a/home-manager/programs/claude-code/default.nix b/home-manager/programs/claude-code/default.nix index b863408c..31a710cd 100644 --- a/home-manager/programs/claude-code/default.nix +++ b/home-manager/programs/claude-code/default.nix @@ -8,6 +8,9 @@ let inherit (userConfig) isPersonal; + common = import ./common.nix { inherit pkgs; }; + herdrClaudeHooks = common.herdrIntegration.hooks; + # ローカル ./skills 配下の各 skill ディレクトリを attrset として展開し、 # hunk パッケージ同梱の agent skill (hunk-review) をマージする。 # 上流の home-manager モジュールは `skills` を attrs か path の @@ -25,33 +28,20 @@ let vaultSessionLogWorker = pkgs.writeShellScriptBin "vault-session-log-worker" ( builtins.readFile ./scripts/vault-session-log-worker.sh ); - statuslineScript = pkgs.writeShellScript "claude-statusline" ( - builtins.readFile ./statusline-command.sh - ); checkUserMemoryScript = pkgs.writeShellScript "claude-check-user-memory" ( builtins.readFile ./hooks/check-user-memory.sh ); - - claudeCodePackage = import ./package.nix { inherit pkgs; }; - herdrIntegration = import ./herdr-hooks.nix { inherit pkgs; }; - herdrClaudeHooks = herdrIntegration.hooks; in { + imports = [ ./deploy.nix ]; + home.packages = lib.optionals isPersonal [ vaultSessionLogWorker ]; programs.claude-code = { enable = true; - package = claudeCodePackage; - - settings = { - theme = "dark"; - autoCompactEnabled = false; - alwaysThinkingEnabled = true; - language = "japanese"; - autoMemoryEnabled = true; - cleanupPeriodDays = 9999; + package = common.package; - model = "opus"; + settings = common.settings // { # 明示的なフォールバックチェーンを無効化 (空配列 = 指定なしと等価)。 # 設定キーは単数形 `fallbackModel` だが型は string 配列 # (CLI の --fallback-model はカンマ区切り)。 @@ -59,10 +49,6 @@ in # advisorModel = "fable"; # effortLevel = "xhigh"; voiceEnabled = true; - skipAutoPermissionPrompt = true; - useAutoModeDuringPlan = true; - # fullscreen (alt-screen) レンダラー。旧 env CLAUDE_CODE_NO_FLICKER=1 と等価。 - tui = "fullscreen"; # Claude Code 組み込み sandbox (macOS: Seatbelt)。 # cage と二重に Seatbelt をネストすると失敗するため、これを使うときは @@ -152,42 +138,11 @@ in }; }; - permissions = { - allow = [ - "WebFetch" - "WebSearch" - "Bash(ls:*)" - "Bash(grep:*)" - "Bash(playwright-cli:*)" - ]; - ask = [ - "Bash(rm:*)" - "Bash(git merge:*)" - "Bash(git rebase:*)" - "Bash(git push:*)" - ]; - deny = [ - "Read(~/.ssh/**)" - "Read(.env*)" - "Bash(sudo:*)" - "Bash(git commit --no-gpg-sign:*)" - "Edit(~/.ssh/**)" - "Edit(.env*)" - ]; - defaultMode = "auto"; + permissions = common.settings.permissions // { + allow = common.settings.permissions.allow ++ [ "Bash(playwright-cli:*)" ]; }; - env = { - BASH_DEFAULT_TIMEOUT_MS = "60000"; - BASH_MAX_TIMEOUT_MS = "180000"; - CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1"; - - # wrapper の --set DISABLE_AUTOUPDATER は wrapper 経由の起動しか守れない。 - # native binary (chrome-native-host 等) も settings.json の env は読むため、 - # ここで無効化しないと updater が ~/.local/bin/claude を再生成し - # Nix wrapper を PATH shadow する (2026-06-28, 2026-07-05 に再発)。 - DISABLE_AUTOUPDATER = "1"; - + env = common.settings.env // { # API がリクエストにフラグを立てた (refusal) ときの自動モデル切り替えを禁止。 # バイナリ 2.1.220 では refusal fallback の可否が # `!CLAUDE_CODE_DISABLE_REFUSAL_FALLBACK && !CLAUDE_CODE_NO_MODEL_FALLBACK` @@ -197,9 +152,6 @@ in # モデル不可用時の availability fallback まで潰す (= 黙って降格せずエラーになる)。 CLAUDE_CODE_DISABLE_REFUSAL_FALLBACK = "1"; - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; - CLAUDE_CODE_NEW_INIT = "1"; - # codex-plugin-cc が thread/start に sandbox: "read-only" 等を強制送信し、 # cage の中で codex 内部の Seatbelt をネストしようとして失敗するため、 # plugin 経由のときだけ sandbox を danger-full-access に切り替える。 @@ -211,7 +163,7 @@ in }; hooks = - herdrClaudeHooks + common.settings.hooks // { # user memory (~/.claude/CLAUDE.md) の消失検知。詳細は hooks/check-user-memory.sh 冒頭。 SessionStart = herdrClaudeHooks.SessionStart ++ [ @@ -224,19 +176,6 @@ in ]; } ]; - # herdr の汎用 PreToolUse エントリ。 - PreToolUse = herdrClaudeHooks.PreToolUse ++ [ - { - matcher = "*"; - hooks = [ - { - type = "command"; - command = herdrIntegration.toolMetadataScript; - timeout = 10; - } - ]; - } - ]; } // lib.optionalAttrs isPersonal { # Mnemos: vault へのセッションログ自動記録。 @@ -265,58 +204,47 @@ in ]; }; - statusLine = { - type = "command"; - command = statuslineScript; - }; - - extraKnownMarketplaces = { - "thinceller-claude-plugins" = { - source = { - source = "github"; - repo = "thinceller/claude-plugins"; - }; - }; - "hiroppy" = { - source = { - source = "github"; - repo = "hiroppy/tmux-agent-sidebar"; + extraKnownMarketplaces = + common.settings.extraKnownMarketplaces + // { + "hiroppy" = { + source = { + source = "github"; + repo = "hiroppy/tmux-agent-sidebar"; + }; }; - }; - } - // lib.optionalAttrs isPersonal { - "openai-codex" = { - source = { - source = "github"; - # PR #241 (CODEX_COMPANION_SANDBOX_MODE 対応) を取り込んだ fork。 - # upstream にマージされたら "openai/codex-plugin-cc" に戻す。 - repo = "thinceller/codex-plugin-cc"; + } + // lib.optionalAttrs isPersonal { + "openai-codex" = { + source = { + source = "github"; + # PR #241 (CODEX_COMPANION_SANDBOX_MODE 対応) を取り込んだ fork。 + # upstream にマージされたら "openai/codex-plugin-cc" に戻す。 + repo = "thinceller/codex-plugin-cc"; + }; }; }; - }; - enabledPlugins = { - # claude-plugins-official - "claude-code-setup@claude-plugins-official" = true; - "claude-md-management@claude-plugins-official" = true; - "plugin-dev@claude-plugins-official" = true; - "skill-creator@claude-plugins-official" = true; - "frontend-design@claude-plugins-official" = true; - "ralph-loop@claude-plugins-official" = true; - # "code-review@claude-plugins-official" = true; - # "pr-review-toolkit@claude-plugins-official" = true; - "discord@claude-plugins-official" = true; - - # thinceller-claude-plugins - "git-toolkit@thinceller-claude-plugins" = true; - "engineering@thinceller-claude-plugins" = true; + enabledPlugins = + common.settings.enabledPlugins + // { + # claude-plugins-official + "claude-code-setup@claude-plugins-official" = true; + "claude-md-management@claude-plugins-official" = true; + "plugin-dev@claude-plugins-official" = true; + "skill-creator@claude-plugins-official" = true; + "frontend-design@claude-plugins-official" = true; + "ralph-loop@claude-plugins-official" = true; + # "code-review@claude-plugins-official" = true; + # "pr-review-toolkit@claude-plugins-official" = true; + "discord@claude-plugins-official" = true; - # hiroppy - "tmux-agent-sidebar@hiroppy" = true; - } - // lib.optionalAttrs isPersonal { - "codex@openai-codex" = true; - }; + # hiroppy + "tmux-agent-sidebar@hiroppy" = true; + } + // lib.optionalAttrs isPersonal { + "codex@openai-codex" = true; + }; }; context = ./user-memory.md; diff --git a/home-manager/programs/claude-code/deploy.nix b/home-manager/programs/claude-code/deploy.nix new file mode 100644 index 00000000..56c91024 --- /dev/null +++ b/home-manager/programs/claude-code/deploy.nix @@ -0,0 +1,113 @@ +# settings.json / CLAUDE.md を symlink でなく通常ファイルとしてコピー配備する。 +# 理由: nix store への symlink は mtime=1970 のため Claude Code の retention cleanup に +# 「期限切れ」として削除される (cleanupPeriodDays=9999 でも、stat が symlink を辿るため +# 消える)。また read-only symlink では Claude 側からの設定書き込みが全て失敗する。 +# コピー化で両方解消し、実行時に Claude が書いた変更 (drift) は activation 時に +# *.drift へ退避して claude-config-drift で確認できるようにする。 +{ + pkgs, + lib, + config, + options, + ... +}: +let + # upstream モジュールのバージョン差異吸収: + # master は home.file のキーが "${configDir}/settings.json" (絶対パス)、 + # release-25.11 は ".claude/settings.json" (相対パス) + hasConfigDir = options.programs.claude-code ? configDir; + settingsKey = + if hasConfigDir then + "${config.programs.claude-code.configDir}/settings.json" + else + ".claude/settings.json"; + memoryKey = + if hasConfigDir then "${config.programs.claude-code.configDir}/CLAUDE.md" else ".claude/CLAUDE.md"; + # enable=false はリンク配備だけを止め、home.file..source の定義 + # (モジュールが jsonFormat.generate 等で組み立てた store path) はそのまま + # 読める、という home-manager の評価順序に依存している。 + settingsSource = config.home.file.${settingsKey}.source; + memorySource = config.home.file.${memoryKey}.source; + + claudeConfigDrift = pkgs.writeShellScriptBin "claude-config-drift" '' + set -euo pipefail + + jq="${pkgs.jq}/bin/jq" + diff="${pkgs.diffutils}/bin/diff" + + checkJson() { + local name="$1" baseline="$2" current="$3" out + if [ ! -f "$current" ]; then + echo "$name: 配備されていません ($current)" + return + fi + if [ ! -f "$baseline" ]; then + echo "$name: baseline なし (未 rebuild)" + return + fi + if out=$("$diff" -u --label nix-deployed --label current \ + <("$jq" -S . "$baseline") <("$jq" -S . "$current")); then + echo "$name: drift なし" + else + echo "$name: drift あり" + echo "$out" + fi + } + + checkText() { + local name="$1" baseline="$2" current="$3" out + if [ ! -f "$current" ]; then + echo "$name: 配備されていません ($current)" + return + fi + if [ ! -f "$baseline" ]; then + echo "$name: baseline なし (未 rebuild)" + return + fi + if out=$("$diff" -u --label nix-deployed --label current "$baseline" "$current"); then + echo "$name: drift なし" + else + echo "$name: drift あり" + echo "$out" + fi + } + + checkJson "settings.json" "$HOME/.claude/.nix-deployed-settings.json" "$HOME/.claude/settings.json" + checkText "CLAUDE.md" "$HOME/.claude/.nix-deployed-CLAUDE.md" "$HOME/.claude/CLAUDE.md" + + for drift in "$HOME/.claude/settings.json.drift" "$HOME/.claude/CLAUDE.md.drift"; do + if [ -f "$drift" ]; then + echo "前回 rebuild 時に退避された drift があります: $drift" + fi + done + + exit 0 + ''; +in +{ + # モジュールが生成する symlink 配備だけを無効化する (source の生成・内容はそのまま利用する) + home.file.${settingsKey}.enable = false; + home.file.${memoryKey}.enable = false; + + home.packages = [ claudeConfigDrift ]; + + # linkGeneration の後: 旧世代の symlink 掃除が終わってからコピーを置く + home.activation.claudeCodeDeployCopies = lib.hm.dag.entryAfter [ "linkGeneration" ] '' + claudeDeploy() { + local src="$1" dest="$2" baseline="$3" drift="$4" + if [ -f "$dest" ] && [ ! -L "$dest" ]; then + # 通常ファイルが baseline (前回配備した内容) と異なる = Claude が実行時に書いた変更 + if { [ ! -f "$baseline" ] || ! cmp -s "$dest" "$baseline"; } && ! cmp -s "$dest" "$src"; then + run cp -f "$dest" "$drift" + warnEcho "claude-code: $dest に実行時変更があったため $drift に退避しました (claude-config-drift で確認)" + fi + fi + run mkdir -p "$(dirname "$dest")" + run rm -f "$dest" + run install -m 600 "$src" "$dest" + run install -m 600 "$src" "$baseline" + } + claudeDeploy "${settingsSource}" "$HOME/.claude/settings.json" "$HOME/.claude/.nix-deployed-settings.json" "$HOME/.claude/settings.json.drift" + claudeDeploy "${memorySource}" "$HOME/.claude/CLAUDE.md" "$HOME/.claude/.nix-deployed-CLAUDE.md" "$HOME/.claude/CLAUDE.md.drift" + ''; +} diff --git a/home-manager/programs/claude-code/server.nix b/home-manager/programs/claude-code/server.nix index 06067902..7a9569e2 100644 --- a/home-manager/programs/claude-code/server.nix +++ b/home-manager/programs/claude-code/server.nix @@ -7,97 +7,16 @@ ... }: let - statuslineScript = pkgs.writeShellScript "claude-statusline" ( - builtins.readFile ./statusline-command.sh - ); - claudeCodePackage = import ./package.nix { inherit pkgs; }; - herdrIntegration = import ./herdr-hooks.nix { inherit pkgs; }; + common = import ./common.nix { inherit pkgs; }; in { + imports = [ ./deploy.nix ]; + programs.claude-code = { enable = true; - package = claudeCodePackage; - - settings = { - theme = "dark"; - autoCompactEnabled = false; - alwaysThinkingEnabled = true; - language = "japanese"; - autoMemoryEnabled = true; - cleanupPeriodDays = 9999; - - model = "opus"; - skipAutoPermissionPrompt = true; - useAutoModeDuringPlan = true; - tui = "fullscreen"; - - permissions = { - allow = [ - "WebFetch" - "WebSearch" - "Bash(ls:*)" - "Bash(grep:*)" - ]; - ask = [ - "Bash(rm:*)" - "Bash(git merge:*)" - "Bash(git rebase:*)" - "Bash(git push:*)" - ]; - deny = [ - "Read(~/.ssh/**)" - "Read(.env*)" - "Bash(sudo:*)" - "Bash(git commit --no-gpg-sign:*)" - "Edit(~/.ssh/**)" - "Edit(.env*)" - ]; - defaultMode = "auto"; - }; - - env = { - BASH_DEFAULT_TIMEOUT_MS = "60000"; - BASH_MAX_TIMEOUT_MS = "180000"; - CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1"; - DISABLE_AUTOUPDATER = "1"; - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1"; - CLAUDE_CODE_NEW_INIT = "1"; - }; + package = common.package; - hooks = herdrIntegration.hooks // { - PreToolUse = herdrIntegration.hooks.PreToolUse ++ [ - { - matcher = "*"; - hooks = [ - { - type = "command"; - command = herdrIntegration.toolMetadataScript; - timeout = 10; - } - ]; - } - ]; - }; - - statusLine = { - type = "command"; - command = statuslineScript; - }; - - extraKnownMarketplaces = { - "thinceller-claude-plugins" = { - source = { - source = "github"; - repo = "thinceller/claude-plugins"; - }; - }; - }; - - enabledPlugins = { - "git-toolkit@thinceller-claude-plugins" = true; - "engineering@thinceller-claude-plugins" = true; - }; - }; + settings = common.settings; memory.source = ./user-memory.md;