# 用 CLAUDE_PROJECT_DIR 把詞彙表移出 Claude Code skill — 寫入 .claude/ 為何一律要核准

> Claude Code 把 .claude/ 視為受保護路徑，允許規則無法事先核准那裡的寫入。這篇記錄如何用 CLAUDE_PROJECT_DIR 把會被改寫的詞彙表移出去。

- Source: https://oharu121.com/zh-tw/blog/claude-code-project-dir-skill-glossary-approval-prompts/
- Published: 2026-08-17T10:58:35+09:00
- Tags: Claude Code, 開發工具, 自動化

---
## 引言

這個部落格由一個 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 的四個地方各寫了一次。

```text title=".claude/skills/blog/SKILL.md"
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/**)` 這樣的項目，上表中各模式的結果也不會改變。

**順序本身就是機制。**權限規則不是被受保護路徑檢查蓋過去，而是根本不會被讀到。為這些寫入寫的規則，會以一副正確的樣子留在檔案裡，永遠比對不到任何東西。

*Figure — PermissionOrder: 寫入 `.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 依然可攜，而它所綁定的資料現在明確屬於這個專案，這正是那個變數的用途。

*Figure — GlossaryReference: 檔案換了目錄，skill 換了指向它的方式。寫回的規則完全沒有變。*

散落在五個 skill 檔案裡的七處參照，收斂成同一種寫法：

```text title=".claude/skills/blog/SKILL.md"
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` 區塊，並用註解寫下什麼條件會讓這個項目變成錯的。

接著，消除重複這件事在隔壁的檔案又上演了一次。代理人寫的解析器宣告了一張欄位標題對應語系的表：

```ts title="scripts/lib/glossary.ts"
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，裡面只寫那兩個變數，然後呼叫它：

```text
PROJECT_DIR=…/scratchpad/probe-proj
SKILL_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` 能觸及的一切，早就被隔壁那條規則事先核准了。結論成立，理由卻不成立，而那就是下一節。

*Figure — RuleShadowing: 單看任何一個檔案都看不出這件事，所以那條收窄的規則看起來像是有在運作。*

於是我請它把 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](https://code.claude.com/docs/en/permission-modes)
- [Claude Code skills reference, with the table of string substitutions and the v2.1.196 requirement for CLAUDE_PROJECT_DIR](https://code.claude.com/docs/en/skills)
- [Anthropic's skill authoring best practices, which assumes supporting files are bundled inside the skill directory](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
