
用 CLAUDE_PROJECT_DIR 把詞彙表移出 Claude Code skill — 寫入 .claude/ 為何一律要核准
Claude Code 把 .claude/ 視為受保護路徑,允許規則無法事先核准那裡的寫入。這篇記錄如何用 CLAUDE_PROJECT_DIR 把會被改寫的詞彙表移出去。
本頁目錄
引言
這個部落格由一個 Claude Code skill 撰寫,它會把每篇文章翻成日文與繁體中文,而讓這些翻譯保持一致的,是一份詞彙表:244 個詞、三個欄位,每個詞只綁定一種譯法。skill 有一條規則:譯者只要做過判斷的詞,就要在同一次變更裡加進去。也就是說,這條流程幾乎每寫一篇文章都會寫入那個檔案。而每一次寫入都會停下來,要求我核准。
被打斷只是每天的小煩躁。真正讓我在意的,是發現這個檔案擺錯了地方。它躺在 .claude/skills/blog/ 裡,和那些寫著指示的檔案並排,但它並不是指示。它是流程會讀、也會往裡面追加的資料,卻一直待在設定用的目錄裡假裝自己是散文。
解法不是權限規則,因為沒有任何權限規則做得到。.claude/ 是受保護路徑,把檔案移出去是唯一可行的答案。skill 之後透過 `${CLAUDE_PROJECT_DIR}` 指向它,這是官方文件給 skill 用來指稱「不屬於自己的檔案」的方式。
本文整理三件事:看起來最理所當然的解法為什麼行不通、我在出貨前攔下的那條彎路,以及這次搬移暴露出來的兩個重複常數。
skill 裡唯一會被寫入的檔案
blog skill 由 10 個 markdown 檔組成。其中 9 個在告訴模型該怎麼做:怎麼安排敘事、程式碼圍籬怎麼寫、每個語系有哪些必要標題。剩下那 1 個是一張查詢表。
這個差別在目錄清單上看不出來,但在 git 歷史裡立刻現形。最近 10 次動到 glossary.md 的提交,全部都是發布文章的提交,因為「寫回」這條約定在 skill 的四個地方各寫了一次。
6. **Terminology comes from …** Read it before any translate or review pass, and add terms you had to decide on.每跑一次就被追加內容的檔案,本質上已經是資料庫。**另外 9 個檔案由人刻意編輯,一年幾次;這一個由機器編輯,一個月幾次。**同一個目錄,生命週期正好相反。
權限規則無法事先核准受保護路徑
最直覺的第一步,是把它當成設定問題處理。Claude Code 有允許清單,寫入的目標又是固定的,那麼加一條指名該檔案的項目,核准提示應該就會停。代理人於是去找該寫哪一條規則。
這是錯的,而且錯在一個「把 settings.json 讀爛也看不出來」的地方。文件明講寫入 .claude/ 底下*「除了 bypassPermissions 模式以外,永遠不會自動核准」*,接著把繞道也一併堵死:
設定檔裡的
permissions.allow規則不會事先核准對受保護路徑的寫入。安全檢查在 Claude Code 評估設定中的允許規則之前就會執行,所以即使在~/.claude/settings.json或.claude/settings.json裡放上像Edit(.claude/**)這樣的項目,上表中各模式的結果也不會改變。
**順序本身就是機制。**權限規則不是被受保護路徑檢查蓋過去,而是根本不會被讀到。為這些寫入寫的規則,會以一副正確的樣子留在檔案裡,永遠比對不到任何東西。
.claude/ 底下的檔案會停在第一階段,所以原本該涵蓋它的規則永遠走不到。還有哪些路徑受保護
這份清單上不只有 .claude/。.git、.vscode、.idea、.husky、.cargo、.devcontainer、.yarn 和 .mvn 同樣受保護,唯一的例外是 .claude/worktrees,因為那是 Claude Code 放自己 git worktree 的地方。
**所以根本沒有什麼設定可以寫。**只能把檔案搬走。
CLAUDE_PROJECT_DIR 指的是專案的檔案,不是 skill 的檔案
代理人第一版的搬移方案,是把每一處參照都改寫成 ../../../docs/glossary.md。我在那裡把它攔下來,請它去查實際的慣例而不是憑印象,事後證明這個判斷正確了兩次。
調查找到的第一件事,反而是不利於這個計畫的。Anthropic 的 skill 撰寫指南預設輔助檔案就放在 skill 目錄底下的 reference/ 或 scripts/ 裡,而一般建議是:把東西包在一起,才能讓 skill 保持可攜與自足。那一頁完全沒有考慮放在儲存庫其他地方的檔案。
第二件事才是官方認可的例外。Claude Code 會把三個變數替換進 skill,其中一個正是為了這種情況而存在:
| 變數 | 展開成什麼 | 從哪個版本開始 |
|---|---|---|
`${CLAUDE_SKILL_DIR}` |
放著這個 skill 的 SKILL.md 的目錄 |
未載明 |
`${CLAUDE_PROJECT_DIR}` |
專案根目錄 | v2.1.196 |
`${CLAUDE_PLUGIN_ROOT}` |
外掛的安裝目錄,只適用於外掛的 skill | 未載明 |
**動用中間那個之前,先確認你的版本。**它是三個裡面最新的,在更舊的版本上根本不會被替換:它會以 `${CLAUDE_PROJECT_DIR}` 這串原文抵達模型,沒有錯誤,輸出裡也沒有任何線索說明路徑為什麼沒解析出來。與其之後才發現「skill 安靜地找不到自己的資料」,不如一開始就知道。
文件把中間這個變數描述為用來參照*「專案內的指令稿或檔案,且不受 skill 安裝位置影響」。這正是這裡的情況,而且嚴格來說比代理人原本想用的相對路徑更好,因為同一頁也寫著工作目錄會「在 Claude 執行 cd 時移動」*。相對路徑在有東西改變目錄之前都是對的;變數則一直都對。
**一開始就沒有在冒可攜性的險。**詞彙表裝的是這個儲存庫的用語,本來就不是能帶著走的內容。skill 依然可攜,而它所綁定的資料現在明確屬於這個專案,這正是那個變數的用途。
散落在五個 skill 檔案裡的七處參照,收斂成同一種寫法:
6. **Terminology comes from `${CLAUDE_PROJECT_DIR}/docs/glossary.md`** — outside this skill on purpose, because the pipeline appends to it and `.claude/` writes always prompt.搬走這個檔案之後暴露出來的事
有兩件事,只因為檔案擺在原本的位置才安靜地成立。
**章節標題的第二份副本。三個標題在每個語系都是固定字串,而 scripts/prose-check.ts 自己也存了那九個字串,上面還有一行註解寫著「per the table in glossary.md」。這行註解同時點名了重複、也解釋了原因:指令稿沒有立場伸手進 skill 目錄,所以它讀不到那張表本身。沒有任何東西驗證這兩份副本一致。**它們確實一致,但那是運氣。**因為改詞彙表和改檢查器是兩個各自獨立的動作,少做其中一個,另一個不會察覺。搬到 .claude/ 外面之後,指令稿讀得到那張表,第二份副本就消失了。
**一個方向相反的 CI 缺口。**工作流程會略過 .claude/**,所以只動用語的提交從來不會觸發 CI。把檔案搬出去之後,為了出貨一個位元組完全相同的網站,安裝、檢查、建置到正式環境部署會整套跑起來。我在同一次變更裡把 docs/** 加進兩個 paths-ignore 區塊,並用註解寫下什麼條件會讓這個項目變成錯的。
接著,消除重複這件事在隔壁的檔案又上演了一次。代理人寫的解析器宣告了一張欄位標題對應語系的表:
const COLUMN_LOCALE: Record<string, Locale> = { English: 'en', 日本語: 'ja', 繁體中文: 'zh-tw',};這三個語言自稱早就以 LOCALE_LABEL 的形式存在於 src/i18n/config.ts,一個位元組都不差,而且就在這個新檔案往上四行才剛 import 的模組裡。一個目的就是刪掉重複常數的變更,自己帶進了一個重複常數。程式碼審查在合併前抓到了,現在它改成從既有的紀錄推導出來。不過真正值得記住的是這件事:消除重複往往只是把它搬到別處,而不是真的移除,而搬過去的那份副本最難被看見的時刻,就是它落在讓它變得多餘的那行 import 旁邊。
與其相信版本號,不如驗證替換本身
`${CLAUDE_PROJECT_DIR}` 需要 Claude Code v2.1.196 以上。當時這台機器是 2.1.193,差了三個修訂號,所以第一版的每一處參照都附了一段備援說明,寫出從儲存庫根目錄算起的路徑。
升到 2.1.233 之後,從版本數字看,這個功能應該可用了。但那只證明功能有出貨,不代表它在 skill 的 markdown 裡真的會觸發,於是代理人在一個暫時的專案裡做了一個用完就丟的 skill,裡面只寫那兩個變數,然後呼叫它:
PROJECT_DIR=…/scratchpad/probe-projSKILL_DIR=…/scratchpad/probe-proj/.claude/skills/probe兩個都以真實的絕對路徑回來了,這表示那段備援文字描述的是一個再也不會發生的分支。我請它把那段刪掉。描述不可能狀態的指示並不是免費的:SKILL.md 每次呼叫 /blog 都會載入,而寫著不可能發生的情況的說明,正是讀者學會略過那些可能發生的說明的原因。
過程中還冒出一個這次探測原本沒打算找的細節。撰寫本文時,代理人在呼叫 skill 時把 `${CLAUDE_PROJECT_DIR}` 當成字面引數傳進去,結果它抵達時已經展開成絕對路徑。替換不只作用在檔案裡寫下的內容,也作用在被插進 skill 內容裡的引數。
第二條什麼也沒做的權限規則
受保護路徑帶來的結論是:一條規則可以躺在設定檔裡,卻從來沒被讀過。這個念頭一旦成形,就會想看看還有沒有別的。
這個儲存庫有兩個設定檔:納入版控的 settings.json,以及被 gitignore 排除的 settings.local.json。後者已經漂移成前者的近乎副本,94 行裡裝著自己的一份 84 條允許規則。其中有兩條在納入版控的那個檔案裡並不存在:
| 只存在於 local 檔案的規則 | 它實際上在做什麼 |
|---|---|
Bash(pkill -f "astro preview") |
什麼也沒做,因為真正的命令列是 astro.mjs preview |
Bash(pnpm exec *) |
悄悄把版控那份收窄成 Bash(pnpm exec astro *) 的規則又放寬回去 |
有意思的是第二條。權限規則會跨設定檔合併,所以只在其中一個檔案裡收窄規則,只要另一個檔案還留著更寬的那份副本,就完全沒有效果。
依這個讀法,版控裡的 pnpm exec astro * 什麼也沒做,因為 pnpm exec 能觸及的一切,早就被隔壁那條規則事先核准了。結論成立,理由卻不成立,而那就是下一節。
於是我請它把 local 檔案削到只剩它獨有的那一項 additionalDirectories。代理人不是靠閱讀驗證,而是靠集合運算:刪掉的 84 條裡有 82 條仍然存在於版控的檔案中,不存在的那 2 條,正好就是那條死掉的規則和那個過寬的授權。然後它回報說這次清理是安全的。
驗證量到的是另一個性質
下一個瀏覽器自動化的呼叫要求核准。再下一個也是。
把 settings.local.json 還原之後,核准提示就停了,而這期間 settings.json 完全沒動過。如果兩個檔案裝著同一條規則,這個結果就很奇怪。答案在權限文件裡:
專案的
.claude/settings.json裡的permissions.allow規則與permissions.additionalDirectories項目會授予能力,因此 Claude Code 只會在你接受該資料夾的工作區信任對話框之後才套用它們。
這個儲存庫從來沒有被信任過。~/.claude.json 裡它的 hasTrustDialogAccepted 是 false,也就是說**版控那個檔案裡的每一條允許規則都被擱置著。**被 gitignore 排除的那個檔案扛著的,是兩邊共有的那 82 條;只存在於版控檔案裡的其餘幾條,哪裡都沒有生效。local 那份副本能繞過這道關卡,有一個明確的理由:Claude Code 會執行 git,用來分辨哪個是你自己的檔案、哪個是儲存庫提供的檔案;而一旦 local 檔案也被納入追蹤,它的規則同樣會被擱置。
為什麼把 local 檔案納入版控也沒用
這個細節否定掉了看起來最整齊的解法。為了不讓兩個檔案繼續漂移而把 settings.local.json 納入版控,只會讓它變成儲存庫提供的檔案,被同一道關卡擋在後面,最後兩個檔案都被擱置,沒有一條規則生效。
**那次集合運算是正確的,也是沒有用的。**它比較的是兩個檔案的內容,並因為某條規則出現在另一個檔案裡,就判定它有被涵蓋。但「一個檔案的規則到底會不會被套用」並不是它內容的性質,所以再怎麼比對內容都抓不到這件事。這道關卡還是非對稱的,而那正是值得記住的部分:deny 在任何範圍都不會被擱置。不被信任的儲存庫可以限制你能做什麼,卻永遠不能放寬。
要察覺這件事之所以中間隔了兩次核准提示,是有原因的。被人核准過的提示,和一開始就事先核准的呼叫,從代理人這一側看是分不出來的,兩者都回傳一個成功的工具結果。代理人針對一個我剛剛親手核准的呼叫,回報說「沒有出現提示」,直到我把這件事講出來才被修正。
總結
- **
.claude/是受保護路徑。**除了 bypassPermissions 模式,那裡的寫入永遠不會自動核准,而且安全檢查跑在讀取permissions.allow之前,所以像Edit(.claude/**)這樣的規則不是被蓋過去,而是根本沒被讀到。 - **流程會寫入的檔案,不該放在 skill 目錄裡。**一年被人編輯幾次的 9 個指示檔案,和一個月被機器編輯幾次的 1 張表,就算在清單上長得一樣,生命週期也是相反的。
- **
`${CLAUDE_PROJECT_DIR}`是官方認可的出路。**它指向專案裡的檔案而不受 skill 安裝位置影響,也不像相對路徑那樣怕工作目錄被改。需要 v2.1.196 以上。 - **驗證替換本身,不要從版本號推論。**一個只寫著那個變數、用完就丟的 skill,一次呼叫就能給你答案。
- **不去確認的話,消除重複只是搬家。**用來取代重複常數的東西,在讓它變得多餘的那行 import 下方四行,又重新宣告了一個既有的常數。
- **權限規則會合併,所以收窄的規則可能在別處被悄悄放寬。**任何一個設定檔單獨看,都不代表實際生效的狀態。
- **納入版控的
.claude/settings.json在資料夾被信任之前什麼都不會給。**它的allow會被擱置,deny則在任何範圍都不會。在斷定某條規則有生效之前,先確認hasTrustDialogAccepted;也不要想用把settings.local.json納入版控的方式繞過這道關卡,那只會把那個檔案一起拉到關卡後面。 - **比對檔案內容無法告訴你什麼正在生效。**某條規則存在於另一個檔案,不代表它因此就有作用,而這個差別對任何只讀檔案的檢查來說都是看不見的。
參考連結
- Claude Code permission modes, including the protected paths section stating that allow rules are evaluated after the safety check
- Claude Code skills reference, with the table of string substitutions and the v2.1.196 requirement for CLAUDE_PROJECT_DIR
- Anthropic’s skill authoring best practices, which assumes supporting files are bundled inside the skill directory