Version Resolution:不是认 stable/latest 名字,而是认计算出的版本键
对应官方文档:claude-code-docs/docs/plugin-marketplaces.md 里的 Version resolution and release channels。
一句话讲清楚
官方文档建议你使用两个 Marketplace(比如一个指向 stable 分支,一个指向 latest 分支)来作为发布渠道 (Release Channels)。
但如果你深入研究 Claude Code 的运行时代码,你会发现:它根本不理解“Release Channel”这个概念。
在本地运行时,系统只看两件事:
- 解析路径 (Resolution):Marketplace 条目最终指向哪个插件源 (Source)?
- 计算版本 (Versioning):这个源最后能算出什么样的版本键 (Version Key)?
所有的缓存路径、更新判定、自动升级逻辑,全部围绕着这个计算出来的 version 字符串转。所谓“渠道”,在运行时只是同一个插件 ID 被解析到了不同的 version,从而落入不同的本地缓存目录。
实现机制
版本解析的核心由 pluginVersioning.ts 驱动,主要分为四个关键逻辑:
1. 严格的优先级算法 (The Hierarchy)
calculatePluginVersion(...) 遵循一套铁律来决定一个插件的版本:
- 第一优先级:
plugin.json中的version字段。 - 第二优先级:Marketplace 条目中提供的
version字段。 - 第三优先级:Git Commit SHA (对于 Git/GitHub 源)。
- 最后兜底:
unknown。 这解释了为什么官方文档警告:如果你的两个分支指向同一个plugin.json版本号,系统会认为它们是同一个插件,从而不会触发更新。
2. Git-subdir 的路径哈希 (Monorepo Collision)
对于 Monorepo 场景(即一个仓库下有多个插件),如果只用 Commit SHA 作为版本,会导致所有插件都撞在同一个缓存目录下。 为了解决这个问题,calculatePluginVersion 对 git-subdir 源采用了一种特殊的复合版本键: {ShortSHA}-{PathHash} 其中 PathHash 是对插件在仓库中的子路径进行标准化后的 SHA256 前 8 位。这确保了同一个提交下的不同插件在本地物化时拥有独立的命名空间。
3. 版本即路径 (Versioning is Caching)
getVersionedCachePath(...) 直接将计算出的 version 变成磁盘路径的一部分: ~/.claude/plugins/cache/{marketplace}/{plugin}/{version}/ 这意味着版本号不是展示给用户看的元数据,而是插件在本地物料层中的物理 ID。两个 Channel 只要算出的版本不同,本地自然会落到两个物理隔离的目录。
4. 基于版本的更新判定 (AlreadyUpToDate Check)
在 updatePluginOp(...) 中,更新判定逻辑非常直接:
- 重新拉取 Marketplace 条目。
- 对新的 Source 重新运行
calculatePluginVersion算出一个newVersion。 - 如果
newVersion === currentVersion或者对应的installPath已经存在,判定为alreadyUpToDate: true。 这就意味着,即使你的 Marketplace 指向的分支名字变了,或者远程仓库里有新的提交,只要算出的version字符串没变,本地就不会执行任何更新操作。
使用时的关键约束
- 本地没有“渠道”状态机:Claude Code 不会记住你目前是在“Stable 渠道”还是“Latest 渠道”。它只知道你目前正运行在某个特定的版本键下。
- Manifest Version 永远优先:千万不要在 Marketplace Entry 和
plugin.json里写不同的版本号。后者总是会覆盖前者,导致你的渠道版本设置静默失效。 - 版本不一定是 Semver:对于 Git 源,版本号通常是短 SHA;对于子目录源,它是带有路径哈希的复合键。
- 影子版本 (Shadowing):如果两个不同的 Marketplace 条目碰巧指向了同一个 Source 且算出了相同的 Version,它们会共享同一个本地缓存目录。这在开发多 Marketplace 方案时需要格外注意。
继续探索
- 如果你想看这些版本化目录是如何被物理隔离和缓存的,请看
plugin-caching-and-resolution.md。 - 如果你更关心如何通过 Marketplace 的 Reconcile 过程来切换这些版本,请看
marketplace-and-discovery.md。
源码锚点
claude-code-opensource/src/utils/plugins/pluginVersioning.ts
📄 src/utils/plugins/pluginVersioning.ts
export async function calculatePluginVersion(
pluginId: string,
source: PluginSource,
manifest?: PluginManifest,
installPath?: string,
providedVersion?: string,
gitCommitSha?: string,
): Promise<string> {
// 1. Use explicit version from plugin.json if available
if (manifest?.version) {
logForDebugging(
`Using manifest version for ${pluginId}: ${manifest.version}`,
)
return manifest.version
}
// 2. Use provided version (typically from marketplace entry)
if (providedVersion) {
logForDebugging(
`Using provided version for ${pluginId}: ${providedVersion}`,
)
return providedVersion
}
// 3. Use pre-resolved git SHA if caller captured it before discarding the clone
if (gitCommitSha) {
const shortSha = gitCommitSha.substring(0, 12)
if (typeof source === 'object' && source.source === 'git-subdir') {
// Encode the subdir path in the version so cache keys differ when
// marketplace.json's `path` changes but the monorepo SHA doesn't.版本计算的核心逻辑,包括 `git-subdir` 路径哈希的实现规则。
claude-code-opensource/src/utils/plugins/pluginLoader.ts
📄 src/utils/plugins/pluginLoader.ts
type CommandMetadata,
PluginHooksSchema,
PluginIdSchema,
PluginManifestSchema,
type PluginMarketplaceEntry,
type PluginSource,
} from './schemas.js'
import {`getVersionedCachePath` 的实现,揭示了版本是如何转化为物理存储结构的。
claude-code-opensource/src/services/plugins/pluginOperations.ts
📄 src/services/plugins/pluginOperations.ts
export const VALID_INSTALLABLE_SCOPES = ['user', 'project', 'local'] as const
/** Installation scope type derived from VALID_INSTALLABLE_SCOPES */
export type InstallableScope = (typeof VALID_INSTALLABLE_SCOPES)[number]
/** Valid scopes for update operations (includes 'managed' since managed plugins can be updated) */
export const VALID_UPDATE_SCOPES: readonly PluginScope[] = [
'user',
'project',
'local',
'managed',
] as const
/**
* Assert that a scope is a valid installable scope at runtime
* @param scope The scope to validate
* @throws Error if scope is not a valid installable scope
*/
export function assertInstallableScope(
scope: string,
): asserts scope is InstallableScope {
if (!VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope)) {
throw new Error(
`Invalid scope "${scope}". Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}`,
)
}
}`updatePluginOp` 的更新判定算法,它是发布渠道机制生效的最后关口。
claude-code-opensource/src/utils/plugins/validatePlugin.ts
📄 src/utils/plugins/validatePlugin.ts
const MARKETPLACE_ONLY_MANIFEST_FIELDS = new Set([
'category',
'source',
'tags',
'strict',
'id',
])
export type ValidationResult = {
success: boolean
errors: ValidationError[]
warnings: ValidationWarning[]
filePath: string
fileType: 'plugin' | 'marketplace' | 'skill' | 'agent' | 'command' | 'hooks'
}在安装时对 manifest version 和 entry version 冲突进行警告的逻辑。
claude-code-opensource/src/utils/plugins/pluginAutoupdate.ts
📄 src/utils/plugins/pluginAutoupdate.ts
export type PluginAutoUpdateCallback = (updatedPlugins: string[]) => void
// Store callback for plugin update notifications
let pluginUpdateCallback: PluginAutoUpdateCallback | null = null
// Store pending updates that occurred before callback was registered
// This handles the race condition where updates complete before REPL mounts
let pendingNotification: string[] | null = null
/**
* Register a callback to be notified when plugins are auto-updated.
* This is used by the REPL to show restart notifications.
*
* If plugins were already updated before the callback was registered,
* the callback will be invoked immediately with the pending updates.
*/
export function onPluginsAutoUpdated(
callback: PluginAutoUpdateCallback,
): () => void {
pluginUpdateCallback = callback
// If there are pending updates that happened before registration, deliver them now
if (pendingNotification !== null && pendingNotification.length > 0) {
callback(pendingNotification)
pendingNotification = null
}
return () => {
pluginUpdateCallback = null
}自动更新是如何通过“刷新源 + 重算版本”来隐式驱动“渠道升级”的。