寫於 2026 年 8 月 18 日,9 月才上線(部落格的發佈額度在 8 月中用完了,要等 9 月 1 日重置才發得出去)。文中的版本號與「目前」都指 8 月 18 日的狀態,Claude Code 更新很快,你讀到時可能已經又改過了。

1
Dependency "secrets-vault@acme-tools" has no git tag satisfying ~2.1.0

這行錯誤訊息很短,但它把 Claude Code 解析外掛依賴的整套機制都露出來了。裡面有四個資訊:依賴的名字、它屬於哪個 marketplace、版本要求寫成 semver range,還有最關鍵的那個字,tag。這裡沒有註冊中心,版本號是從 git tag 讀出來的。

把這件事拆開來看,比先講「這個功能可以幹嘛」有用。因為依賴解析這種東西,只要你不知道它底層怎麼算,用起來就會在某天早上收到一個看不懂的錯誤。

第一層:依賴寫在哪,長什麼樣

檔案是外掛自己的 .claude-plugin/plugin.json,欄位叫 dependencies,是個陣列:

1
2
3
4
5
6
7
8
{
"name": "deploy-kit",
"version": "3.1.0",
"dependencies": [
"audit-logger",
{ "name": "secrets-vault", "version": "~2.1.0" }
]
}

陣列裡可以放兩種東西。放裸字串,官方的說法是這樣:

An entry can be a bare string with only the plugin name, like "audit-logger" in the example above, which depends on whatever version that plugin’s marketplace provides.

裸字串等於「隨便,marketplace 給我什麼版本我都收」。放物件就能加約束,物件有三個欄位:

name 是外掛名,預設在同一個 marketplace 裡找。version 是 semver range,~2.1.0^2.0>=1.4=2.1.0 都合法。marketplace 用來指到別的 marketplace,但這條路預設是封住的,等一下講。

version 那欄的解析規則照抄原文:

The dependency is fetched at the highest tagged version that satisfies this range.

滿足 range 的最高版本。還有一條容易忽略的:pre-release 預設被排除,除非你的 range 自己寫進 pre-release 後綴,像 ^2.0.0-0 這樣。這代表你在 beta 的依賴不會被意外裝進去,但也代表你想測 beta 就得明確寫出來。

第二層:版本從哪裡來

前面那行錯誤訊息說 has no git tag satisfying,所以版本的來源就是 tag。tag 怎麼命名,這是硬規定:

Tag each release as {plugin-name}--v{version}, where {version} matches the version field in that commit’s plugin.json.

兩個連字號,不是一個。secrets-vault--v2.1.0 這樣。這個格式看起來很囉唆,但一個 repo 裡可能放好幾個外掛,tag 得帶名字才分得開。

有指令幫你做:

1
claude plugin tag --push

它不只是 git tag 的包裝。跑下去之前它會驗外掛內容、檢查 plugin.json 的版本跟 marketplace entry 對不對得上、要求外掛目錄的工作區是乾淨的、tag 已經存在就直接拒絕。成功的話最後兩行是 Created tag secrets-vault--v2.1.0Pushed to origin

不加 --push 它會印出你該自己跑的 git push 指令;--dry-run 只印不做;要推到別的 remote 用 --remote。等價的手動寫法就是 git tag secrets-vault--v2.1.0,指令幫你擋的是那幾個檢查。

發版前先跑 --dry-run 看一眼 tag 名稱是划算的,因為版本號打錯這件事,等別人裝到 no-matching-tag 才發現就太晚了。

第三層:兩個外掛同時要求同一個依賴

這一層是整套機制裡最值得記的。假設 A 跟 B 都依賴 secrets-vault,但要求的 range 不一樣,Claude Code 會去算交集。官方給了三種結果:

Plugin A requires Plugin B requires Result
^2.0 >=2.1 One install at the highest 2.x tag at or above 2.1.0. Both plugins load.
~2.1 ~3.0 Install of plugin B fails with range-conflict. Plugin A and the dependency stay as they were.
=2.1.0 none The dependency stays at 2.1.0. Auto-update skips newer versions while plugin A is installed.

第一列是好狀況:兩個 range 有交集,只裝一份,兩個外掛都活。

壞狀況在第二列,而且壞得很有紀律。~2.1~3.0 沒有交集,此時失敗的是後裝的那個,而 A 跟依賴維持原狀。這個設計值得停下來想一秒:它選擇讓新的安裝失敗,而不是為了滿足 B 去動已經能跑的 A。已經在跑的東西不會因為你裝了一個新玩具而壞掉。

第三列是釘死版本的副作用。你寫 =2.1.0,auto-update 在你這個外掛還裝著的期間就會跳過那個依賴的新版。想鎖就鎖得住,代價是整台機器上這個依賴都跟著不動。

順著這條線還有一個保護:想停用被依賴的外掛,Claude Code 會擋你,而且錯誤訊息把指令串好給你:

1
2
secrets-vault is still required by deploy-kit. Disable that plugin first, or
disable everything together: claude plugin disable deploy-kit@acme-tools && claude plugin disable secrets-vault@acme-tools

第四層:跨 marketplace 的信任不會鏈式傳遞

依賴預設在同一個 marketplace 裡解析。要跨出去,得在根 marketplace.claude-plugin/marketplace.json 裡開白名單:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"name": "acme-tools",
"owner": { "name": "Acme" },
"allowCrossMarketplaceDependenciesOn": ["acme-shared"],
"plugins": [
{
"name": "deploy-kit",
"source": "./deploy-kit",
"dependencies": [
{ "name": "audit-logger", "marketplace": "acme-shared" }
]
}
]
}

「根 marketplace」的定義是關鍵,原文寫得很清楚:

The root marketplace is the one that hosts the plugin the user is installing; only its allowlist is consulted, so trust does not chain through intermediate marketplaces.

只看使用者實際安裝的那個 marketplace 的白名單。中間層信任了誰,不算。

這條規則的形狀跟門禁卡很像。你刷卡進大樓,管理室只認你這張卡的權限;你朋友在裡面幫你開門這件事,不會讓你自動有進機房的權限。沒開白名單就裝的話,安裝會失敗並且錯誤訊息會直接點名你該去設哪個欄位。

第五層:孤兒依賴與四種錯誤碼

被自動裝進來的依賴,在上游外掛移除後會變孤兒。清掉它們:

1
claude plugin prune

或是移除的時候順手清:

1
claude plugin uninstall deploy-kit --prune

有兩個行為要知道。第一,沒東西可清的時候它會印 Nothing to prune 加上原因就結束,官方特別註明這是全新安裝下的預期輸出,不是錯誤。第二,你自己手動裝的外掛永遠不會被 prune 掉,它只清那些透過別人的 dependencies 自動裝進來的。旗標有 --scope project--scope local--dry-run-y;非 TTY 環境不加 -y 就只列不刪。

錯誤碼一共四種,讀懂它們比背指令有用:

dependency-unsatisfied 是依賴沒裝,或者裝了但被停用。range-conflict 是版本要求沒辦法合併,訊息會說明原因是「沒有版本滿足所有 range」、「range 的 semver 語法無效」還是「合起來太複雜算不出交集」。dependency-version-unsatisfied 是裝的那個版本掉在你宣告的 range 外面。no-matching-tag 就是開頭那行錯誤,repo 裡沒有滿足 range 的 {name}--v* tag。

要程式化地檢查,跑 claude plugin list --json。有問題的外掛會多一個 errors 欄位;乾淨載入的外掛不會有這個欄位,不是給你一個空陣列。寫檢查腳本的時候這個差別會咬人。

最後一個例外要記著:npm、archive、command 這幾種來源的依賴,version 約束管不到抓哪一版,因為 tag 解析只對 git 來源有效。版本不合就把外掛停用並標 dependency-version-unsatisfiedcommand 來源的依賴 Claude Code 永遠不會幫你裝。

拆完了,回頭看它能做什麼

機制看完,用途反而變得很好推。

最直接的一個是收斂共用層。我自己維護的 CREW marketplace 裡有 bug-workflowfeature-workflow 兩個外掛,共用的 Notion 設定與 ID 對照散在兩邊,改一次要記得改兩處。照上面的機制,把共用部分抽成 crew-shared,兩個外掛都宣告 { "name": "crew-shared", "version": "~1.2.0" },共用層發 patch 版兩邊自動吃到,發 minor 版就得明確改 range,不會靜默地一次弄壞兩邊。

第二個用法比較意外:一個只有 dependencies 的外掛,本身就是一包團隊標準配備

1
2
3
4
5
6
7
8
9
10
11
{
"name": "backend-standard",
"version": "1.0.0",
"description": "Standard plugin set for backend engineers",
"dependencies": [
"secrets-vault",
"deploy-kit",
{ "name": "db-migrate", "version": "^3.0" },
"oncall-runbook"
]
}

官方明講 manifest 除了必填的 name 之外可以只有一個 dependencies 陣列。新人 onboarding 從此是一行指令,之後團隊多加工具就發 bundle 的新版。

要讓成員真的拿到新版有個前提:非 Anthropic 的 marketplace 預設關閉 auto-update。所以要嘛在 /plugin 裡把那個 marketplace 的 auto-update 開起來,要嘛請大家跑 claude plugin update backend-standard/reload-plugins。全公司鋪開的做法是把 bundle 外掛寫進 managed settings 的 enabledPlugins

邊界

版本要求我只查到一條明確的:本地資料夾型 marketplace 的 tag 解析需要 Claude Code v2.1.196 或更新。dependencies 欄位、claude plugin tagclaude plugin pruneallowCrossMarketplaceDependenciesOn 各自從哪個版本開始有,官方文件沒標,我沒查證到。文件裡也沒看到 beta 字樣,但我同樣沒查證它是不是已經 GA。

另外老實說:上面的機制我是讀文件拆出來的,crew-shared 那個重構我還沒動手做。等真的把共用層抽出去、踩到第一個 range-conflict,會有更值得寫的東西。

那個「兩個外掛要求打架時,讓後裝的失敗」的設計選擇,我猜是從其他套件管理器的歷史學來的。要驗證這個猜測,得去翻他們的設計討論,這篇沒查。

一張帶得走的圖

整套機制縮成三句就記得住:版本從 git tag 讀,多個約束取交集,孤兒依賴要自己掃。

門禁卡那個類比可以一路帶到最後。你的外掛能不能拿到它要的依賴,過的是三道門:tag 在不在、range 合不合得起來、跨 marketplace 的白名單上有沒有你的名字。卡在哪一道,訊息都會講:前兩道分別是 no-matching-tagrange-conflict,第三道則是一則會點名你該去設哪個欄位的安裝錯誤。

今天能做的最小實驗只要一行:在已經裝了幾個外掛的機器上跑一次 claude plugin list --json,拿結果對照上面那四個錯誤碼。比讀文件快得多。

來源