学习工作台文章阅读
工作台/文章/第 4 章:配置迁移即代码 — 把每一次破坏性配置改动写成一段小函数
返回文章列表
2026年7月11日20 分钟

第 4 章配置迁移即代码把每一次破坏性配置改动写成一段小函数

墨圆
墨圆团队发布于 2026年7月11日

来源与授权

本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。

本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。

本章是《深入 Claude Code 源码》系列第 4 章。我们将剖析 migrations/ 目录下 11 个迁移文件与 main.tsxrunMigrations() 调度,看 Claude Code 如何把"产品在用户身后偷偷换零件"这件事写成一组幂等、可独立 review、由版本号守护串行执行的小函数。

为什么配置迁移值得单独一篇?

一款活跃迭代的 CLI 工具,避不开"在用户身后改用户的配置"这件事。Claude Code 这一年多里发生过的就有:

  1. 模型默认值会换 —— Pro 用户的默认从 Sonnet 翻成 Opus,老用户启动时不能莫名其妙被切到一个他没主动选过的模型
  2. 模型别名会重映射 —— sonnet 半年前指 Sonnet 4.5,今天指 Sonnet 4.6,老用户当时的意图必须保留
  3. 字段会改家 —— autoUpdates 之类的字段要从 ~/.claude.json 搬到 ~/.claude/settings.json,且不能丢、不能在两处同时存在
  4. 键名会改字面 —— replBridgeEnabled 这种实现细节漏出来的键名要换成 remoteControlAtStartup 这种用户文档里能写的名字
  5. 弹窗会重弹 —— 老的两选项 AutoMode 弹窗加了第三个选项之后,要让接受过老弹窗的人再看一次

这些动作如果直接做、不管历史,老用户的体验就会在某次升级后突然错乱:模型莫名其妙变了、自动更新莫名其妙打开了、/model 屏显示的是一串再也对不上的 ID。如果用一个"声明式迁移 DSL"去描述?太死板了 —— 上面第 5 项那种"为了避开另一段状态机的反相边而只对 'enabled' 状态生效"的精确条件,DSL 写不出来。如果每次发版前写个一次性脚本跑一遍?也不行 —— 这是产品生命周期里持续发生的事,不是某一个版本的搬家动作。

Claude Code 的答案是:把每一次破坏性的配置改动都写成一段独立的、幂等的、由版本号守护的小函数,串行串在启动时一个叫 runMigrations 的内部钩子里migrations/ 目录里那 11 个文件,就是这一年多里所有"在用户身后改配置"动作的全部历史账:

TEXT
migrateAutoUpdatesToSettings.ts
migrateBypassPermissionsAcceptedToSettings.ts
migrateEnableAllProjectMcpServersToSettings.ts
migrateFennecToOpus.ts
migrateLegacyOpusToCurrent.ts
migrateOpusToOpus1m.ts
migrateReplBridgeEnabledToRemoteControlAtStartup.ts
migrateSonnet1mToSonnet45.ts
migrateSonnet45ToSonnet46.ts
resetAutoModeOptInForDefaultOffer.ts
resetProToOpusDefault.ts

11 个文件全部是顶层文件——没有子目录、没有公共基类、没有"框架",每个文件导出一个同名的无参函数,函数名就是它做的那件事。这种克制的组织方式背后有一个很清楚的意图:每一次破坏性的配置改动,都是一段可以被独立 review、独立删除、独立写测试的小代码片段。你不需要去理解一个"迁移引擎"——只需要打开你感兴趣的那个文件,从头读到尾,三五十行内它就讲完了自己的故事。

11 个迁移函数速查表

函数分组触发条件一句话作用错误处理
migrateAutoUpdatesToSettings搬家(第 2 节)无条件~/.claude.jsonautoUpdates 搬到 ~/.claude/settings.jsontry/catch + logError
migrateBypassPermissionsAcceptedToSettings搬家(第 2 节)无条件bypassPermissionsModeAccepted 搬到 settingstry/catch + logError
migrateEnableAllProjectMcpServersToSettings搬家(第 2 节)无条件enableAllProjectMcpServers 搬到 settingstry/catch + logError
resetProToOpusDefault模型字符串(第 3 节)无条件Pro 默认从 Sonnet 翻成 Opus,靠横幅时间戳兜底幂等早退
migrateSonnet1mToSonnet45模型字符串(第 3 节)无条件sonnet[1m] 别名展开成 sonnet-4-5-20250929[1m] 显式串幂等早退
migrateLegacyOpusToCurrent模型字符串(第 3 节)无条件claude-opus-4-0 这种远古串收敛到 opus 别名幂等早退
migrateSonnet45ToSonnet46模型字符串(第 3 节)无条件sonnet-4-5-* 卷回 sonnet 别名幂等早退
migrateOpusToOpus1m模型字符串(第 3 节)无条件opus 升级成 opus[1m]幂等早退
migrateFennecToOpus模型字符串(第 3 节)仅内部版("external" === 'ant'把内部代号模型迁到 opus幂等早退
migrateReplBridgeEnabledToRemoteControlAtStartup键名清理(第 4 节)无条件replBridgeEnabledremoteControlAtStartup幂等早退
resetAutoModeOptInForDefaultOffer键名清理(第 4 节)feature('TRANSCRIPT_CLASSIFIER')让接受过老两选项弹窗的用户重看新三选项try/catch + 内层 completion flag

本章按下面的顺序拆这 11 个函数:先看 main.tsx 里那条把它们串起来的流水线(第 1 节),再分三组挨个看 —— 把字段从一处搬到另一处的 3 个(第 2 节)、围绕模型字符串做重命名的 6 个(第 3 节)、围绕"旧键名 / 旧弹窗选项"做清理的 2 个(第 4 节),最后把贯穿全章的几条纪律收束起来(第 5 节)。


全景图:11 个迁移函数与 main.tsx 的关系

流程图
图表进入视野后渲染

一、流水线:一个版本号守护的串行入口

所有 11 个迁移并不是各自找时机各自跑的,它们被集中在 main.tsx 的一个内部函数里,由一个叫 migrationVersion 的小数字守护。下面这段就是流水线的全貌(main.tsx:323-352):

TYPESCRIPT
const CURRENT_MIGRATION_VERSION = 11;
function runMigrations(): void {
  if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) {
    migrateAutoUpdatesToSettings();
    migrateBypassPermissionsAcceptedToSettings();
    migrateEnableAllProjectMcpServersToSettings();
    resetProToOpusDefault();
    migrateSonnet1mToSonnet45();
    migrateLegacyOpusToCurrent();
    migrateSonnet45ToSonnet46();
    migrateOpusToOpus1m();
    migrateReplBridgeEnabledToRemoteControlAtStartup();
    if (feature('TRANSCRIPT_CLASSIFIER')) resetAutoModeOptInForDefaultOffer();
    if ("external" === 'ant') migrateFennecToOpus();
    saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION
      ? prev
      : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION });
  }
  migrateChangelogFromConfig().catch(() => {});
}

短短 30 行,把这个子系统的全部纪律都暴露出来了。让我们一条一条看。

先是一个守门员。 if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) 这一行是整个流水线的开关:每次启动时读全局配置里那个名叫 migrationVersion 的小数字,跟代码里硬编码的 CURRENT_MIGRATION_VERSION = 11 比一下,对得上就整段跳过,对不上就把下面所有迁移再跑一遍。注意这不是"只跑没跑过的那几个"——而是把整组迁移再走一次,最后把版本号刷成新值。文件顶部的注释把这个意图说得很直白:「Bump this when adding a new sync migration so existing users re-run the set.」你新加了一个迁移、把版本号从 11 变成 12,已有用户下次启动时这 12 个迁移就会从头到尾再走一遍。所有迁移都得能重复跑而不出错——这条隐含的契约由下文将看到的"幂等"模式承担。

接着是一段固定顺序。 9 个无条件迁移 + 2 个带条件的,顺序写死在源码里,没有依赖图、没有拓扑排序。这个顺序不是随便排的,它隐含了几条事实关系:resetProToOpusDefault 要先于所有 Sonnet/Opus 模型字符串改名跑,因为它会基于"用户当时有没有自定义模型"决定要不要弹横幅;migrateSonnet1mToSonnet45 要先于 migrateSonnet45ToSonnet46,因为它产出的是 sonnet-4-5-20250929[1m] 这种显式串、正好是后者要往回卷成 sonnet[1m] 别名的输入;migrateLegacyOpusToCurrentmigrateOpusToOpus1m 一前一后,前者把 claude-opus-4-0 这种远古串收敛到 opus 别名,后者再把 opus 升级成 opus[1m]。顺序就是事实顺序,不需要装出 DAG 的样子。

然后是两段条件分支。 feature('TRANSCRIPT_CLASSIFIER') 是一道编译期 / 运行期同款的开关,命中才跑 resetAutoModeOptInForDefaultOffer;写得最有意思的是那段 if ("external" === 'ant'),这是 bundler 留下的字符串折叠点——发到外部用户的 bundle 里这一支永远是死代码、migrateFennecToOpus 被裁掉,而内部 ant 用户的 bundle 里这一支永远进入、迁移会跑。这是 Claude Code 把"内部独有逻辑"与"外发产物"用同一份源码一起维护的典型手法(详见第 22 章 Feature Flag 与编译期优化)。

最后是落版本号。 saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION ? prev : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }) 这一行也有讲究:内层先比一次再决定要不要返回新对象,避免一次空写——saveGlobalConfig 的 updater 如果返回的是同一个对象引用,下游能省一次磁盘 IO 与一次内存中"已经一致就别落盘"的判断。这种"防御性同等性"在 11 个迁移内部到处都是。

落版本号之后还有一行:migrateChangelogFromConfig().catch(() => {})——这是一个 fire-and-forget 的异步迁移,跟 sync 流水线分开放,自己 catch 掉错误。它不参与版本号机制,每次启动都会试一次、出错就吞掉。这是 Claude Code 给"非阻塞、可重试、不影响启动"的迁移留的另一条路。

把这段读完,你已经掌握了这一章的全部"框架"。剩下的事情就是逐个打开 migrations/ 目录下那 11 个文件,看每段历史债是怎么算的。


二、把字段搬家:3 个 settings.json 落地迁移

第一组的 3 个迁移做的是同一件事——把一个原来住在 ~/.claude.json(GlobalConfig)或项目配置里的字段,搬到 ~/.claude/settings.json(userSettings)或项目 local settings 里。为什么要搬?读读第 17 章你会记得,settings.json 是 Claude Code 面向用户的"正经"配置文件:有多层合并、有企业策略覆盖、有运行时变更监听。而 ~/.claude.json 这个 GlobalConfig 早年承担了太多"既存"职责,里面混了一堆其实是用户偏好的字段。把它们搬到正经家里,整个配置体系才能保持一致。

这一组的三段代码长得非常像,套路是:「读老地方 → 写新地方 → 删老地方」。我们挨个看。

2.1 migrateAutoUpdatesToSettings:把"我不要自动更新"搬到环境变量

第一个文件 migrations/migrateAutoUpdatesToSettings.ts 处理的是 autoUpdates 字段。旧版本里,用户在 /config 屏关掉自动更新,会把 autoUpdates: false 写到全局配置;新版本统一改用环境变量 DISABLE_AUTOUPDATER=1 来表达这件事——理由是原生安装包的更新链路也要消费这个信号,环境变量比全局 JSON 更适合做"全局禁用"开关。函数的核心判断是这样的(migrations/migrateAutoUpdatesToSettings.ts:18-23):

TYPESCRIPT
// Only migrate if autoUpdates was explicitly set to false by user preference
// (not automatically for native protection)
if (
  globalConfig.autoUpdates !== false ||
  globalConfig.autoUpdatesProtectedForNative === true
) {
  return
}

注意这两个条件叠起来读:只有当用户明确把 autoUpdates 设成 false、并且不是"原生保护"自动设上的,才会进入迁移。任何"我从来没动过"或"被保护机制顺手设上的"情况都早退。这一段是这一组三个迁移里写得最克制的——它意识到「false 这个值"用户写的"还是"机器写的"」是两件不同的事。

判断通过之后,函数做三件事:往 userSettings 的 env 子对象里写一条 DISABLE_AUTOUPDATER: '1'、立刻把 process.env.DISABLE_AUTOUPDATER 也设上(让当前这次启动就生效,不等下次)、最后把 autoUpdatesautoUpdatesProtectedForNative 这两个旧字段从 GlobalConfig 里删掉。删除用的是 ES 解构里那个 { autoUpdates: _, autoUpdatesProtectedForNative: __, ...rest } 模式——这是这一组迁移里反复出现的句式,比 delete obj.foo 更适合配合不可变 updater。整段还包在 try/catch 里,失败就 logError 一行再发一个 tengu_migrate_autoupdates_error 事件,不抛——迁移失败绝不应该阻塞用户启动

2.2 migrateBypassPermissionsAcceptedToSettings:把"我同意了危险模式"搬到 settings

第二个文件比第一个还要短,只有 40 行。它处理的是一个用户曾经在权限弹窗里点过"我接受危险模式提示"之后留下的标记 bypassPermissionsModeAccepted。这个标记原来住在 GlobalConfig 里,名字也带着实现细节("bypass permissions mode accepted"这种词不是给用户看的);新版本把它换成 settings.json 里的 skipDangerousModePermissionPrompt,名字更直白。代码骨架与第一个迁移如出一辙(migrations/migrateBypassPermissionsAcceptedToSettings.ts:21-34):

TYPESCRIPT
try {
  if (!hasSkipDangerousModePermissionPrompt()) {
    updateSettingsForSource('userSettings', {
      skipDangerousModePermissionPrompt: true,
    })
  }

  logEvent('tengu_migrate_bypass_permissions_accepted', {})

  saveGlobalConfig(current => {
    if (!('bypassPermissionsModeAccepted' in current)) return current
    const { bypassPermissionsModeAccepted: _, ...updatedConfig } = current
    return updatedConfig
  })
} catch (error) {
  logError(
    new Error(`Failed to migrate bypass permissions accepted: ${error}`),
  )
}

但有两个值得指出的细节。第一,写入新键之前先调了一次 hasSkipDangerousModePermissionPrompt() ——只有当新键还不存在时才写。这一招的用意是不要覆盖用户后来通过别的途径(手动编辑 settings.json、或者通过另一台机器的 settings sync 同步过来)已经设过的值。一句小检查,挡掉了"新老来源都被设过,迁移悄无声息地把新的盖成旧的"这条故障路径。第二,删除旧键时 updater 里同样多了一道 if (!('bypassPermissionsModeAccepted' in current)) return current 的同等性短路——返回同一引用,避免 GlobalConfig 出现空写。这是这一组迁移共用的一条"反复跑不出问题"的纪律。

2.3 migrateEnableAllProjectMcpServersToSettings:MCP 三个字段一起搬

第三个文件 migrations/migrateEnableAllProjectMcpServersToSettings.ts 是 118 行,比前两个都长。原因是它一次搬三个字段:enableAllProjectMcpServers(布尔)、enabledMcpjsonServers(字符串数组)、disabledMcpjsonServers(字符串数组)。这三个字段都跟"项目级 MCP 服务器的批准状态"有关,原来住在 project config 里,新版本统一搬到 local settings。

复杂之处不在判断,而在两个数组字段的合并:用户可能此前已经通过别的途径在 local settings 里维护了一份 enabledMcpjsonServers 数组,迁移不应该把它整个覆盖。代码的写法是先读、再合并、再写(migrations/migrateEnableAllProjectMcpServersToSettings.ts:60-71):

TYPESCRIPT
// Migrate enabledMcpjsonServers if it exists
if (hasEnabledServers && projectConfig.enabledMcpjsonServers) {
  const existingEnabledServers =
    existingSettings.enabledMcpjsonServers || []
  // Merge the servers (avoiding duplicates)
  updates.enabledMcpjsonServers = [
    ...new Set([
      ...existingEnabledServers,
      ...projectConfig.enabledMcpjsonServers,
    ]),
  ]
  fieldsToRemove.push('enabledMcpjsonServers')
}

new Set([...existing, ...incoming]) 去重,再展开回数组。disabledMcpjsonServers 的处理方式一字不改。布尔字段 enableAllProjectMcpServers 走的是另一条逻辑——只有当 settings 里还没设过这个键时才把 project config 的值写进去("已迁移过就不要回写、只清理 project config 里的残留")。所有三个字段处理完之后,一次性把它们从 project config 里 destructure-out 删掉,最后发一条 tengu_migrate_mcp_approval_fields_success 事件并带上迁移的字段数。

这三个迁移合在一起,演示了 Claude Code 处理"字段搬家"这件事的全部套路:判断老地方有没有 / 检查新地方有没有覆盖风险 / 数组型字段做去重合并 / 单值型字段做存在性短路 / 写新地方 / 删老地方 / 发事件 / 失败不抛。下次再有什么字段要搬,照这个模板写一个新文件、加进流水线、把版本号 +1,就完事了。


三、给模型换名字:5 个模型字符串迁移

第二组的 5 个迁移围绕同一件事:Claude Code 在过去一年多里把模型别名重新洗了几遍牌,老用户 settings 里的字符串要跟着卷。这件事比"字段搬家"更微妙——一个用户半年前把 sonnet 钉死在 settings.json 里,他当时想要的是 Sonnet 4.5;今天 sonnet 这个别名已经指向了 Sonnet 4.6,如果什么都不做,这个用户会无声无息地被换到一个他没主动选过的模型上。模型迁移要做的,是让用户当时的意图在别名重映射之后依然得到保留。

5 个文件按时间顺序依次是:resetProToOpusDefaultmigrateSonnet1mToSonnet45migrateLegacyOpusToCurrentmigrateSonnet45ToSonnet46migrateOpusToOpus1m,再加一个内部专用的 migrateFennecToOpus——共 6 段,把模型字符串的演化全部写在源码里。

3.1 共同纪律:改字符串的迁移都只动 userSettings、不动合并值

5 段改写模型字符串的迁移里最反复出现的一行代码是 getSettingsForSource('userSettings')?.model——而不是 getCurrentModel()、不是合并后的最终值。这个选择有非常具体的理由,migrations/migrateFennecToOpus.ts:14-17 那段注释把它写得最清楚:

TYPESCRIPT
// Only touches userSettings. Reading and writing the same source keeps this
// idempotent without a completion flag. Fennec aliases in project/local/policy
// settings are left alone — we can't rewrite those, and reading merged
// settings here would cause infinite re-runs + silent global promotion.

两条理由叠在一起:第一,读写同一个源(userSettings)才能让迁移自然幂等——你写完之后下次再跑同一段代码,源里读到的就是新值,不会再次进入分支。第二,如果读合并后的值(merged settings)来决定要不要往 userSettings 里写,会出现一个静默的"提权"——某个用户只在项目级 settings 里钉了 fennec-latest,merged 视图里看到的也是这串字符,迁移就会把对应的新值 opus 写到 userSettings、把这个原本只在某一个项目里有效的模型偏好"提"成全局默认。后果是:用户在别的项目里也突然被切到新模型。migrateSonnet1mToSonnet45 / migrateLegacyOpusToCurrent / migrateSonnet45ToSonnet46 / migrateOpusToOpus1m / migrateFennecToOpus 这 5 段改写模型字符串的迁移都老老实实地只摸 userSettings.model,project / local / policy 层的旧串留给运行时的 parseUserSpecifiedModel 现场重映射处理。

例外是 resetProToOpusDefault——它不改字符串,只决定要不要给当前用户立一个一次性横幅时间戳,所以它走的是另一条路:用 getSettings_DEPRECATED()migrations/resetProToOpusDefault.ts:26-30)读合并后的 settings 判断"用户是否设过自定义 model"。这里读 merged 是安全的——它不向 userSettings 写 model 字段,因此不会触发上面那条"silent global promotion"。这条例外也说明:「只读写 userSettings」是给"改字符串"迁移的纪律,不是迁移目录的全局纪律。

3.2 resetProToOpusDefault:Pro 用户的默认从 Sonnet 翻成 Opus

migrations/resetProToOpusDefault.ts 是 51 行的一段。它做的并不是改字符串,而是给 Pro 订阅用户记录一个"我们改过你的默认值"的时间戳,用来在 REPL 顶部弹一个一次性横幅。逻辑是:当且仅当用户是 1P(firstParty)+ Pro 订阅 + settings.model 没设过(一直跟随默认)时,把当前时间写到 opusProMigrationTimestamp;其它情况只把 opusProMigrationComplete 标记上完事。

这段代码的"故事"完全靠两个嵌套 if 撑起来:第一个 if 兜住所有"不适用此次默认值切换"的用户(非 1P、非 Pro),它们也要标记成已完成、不然下次启动还会再走一遍;进入第二个 if 之后再看 settings?.model === undefined,未设过的发横幅、设过的安静通过。注意这里它读 settings 用的是 getSettings_DEPRECATED()migrations/resetProToOpusDefault.ts:26-30)——也就是合并后的 settings,不是 userSettings 单源——这是上一节"只读写 userSettings"那条纪律的例外:它不向 settings 里写 model 字段,只用 merged 视图判断"用户当前生效的默认是不是没设过"来决定要不要立横幅,因此不存在静默全局提权的风险。没有 try/catch、没有兜底——如果 getGlobalConfig() / saveGlobalConfig / getSettings_DEPRECATED() 任一抛错,错会冒到 runMigrations 的 caller,被外层版本号"先 +1 再循环执行"的设计兜成"这一轮整体作废,下次启动重来"(详见 第 1 节)。每一条分支都对应一条 tengu_reset_pro_to_opus_default 事件并带上 skipped / had_custom_model 标记——你能从遥测里精确统计有多少人是被这个迁移波及到的、有多少人是早就钉了自己的模型不受影响的。

3.3 migrateSonnet1mToSonnet45:从别名锁定到显式串

这段是 main.tsx 那段注释里点名推荐的"教科书例子"。要解决的问题是:用户原本把 sonnet[1m] 写在 settings 里,意思是"Sonnet 4.5 + 1M 上下文";后来 sonnet 别名被升到 4.6 了,但 4.6 1M 是另一个用户群、跟 4.5 1M 不是一回事——为了让原来用 4.5 1M 的人继续用 4.5 1M,得把 sonnet[1m] 替换成显式串 sonnet-4-5-20250929[1m]。代码是这样的(migrations/migrateSonnet1mToSonnet45.ts:31-42):

TYPESCRIPT
const model = getSettingsForSource('userSettings')?.model
if (model === 'sonnet[1m]') {
  updateSettingsForSource('userSettings', {
    model: 'sonnet-4-5-20250929[1m]',
  })
}

// Also migrate the in-memory override if already set
const override = getMainLoopModelOverride()
if (override === 'sonnet[1m]') {
  setMainLoopModelOverride('sonnet-4-5-20250929[1m]')
}

这段值得圈出来的是文件顶部的一处反常——它不是只靠"读写同源"实现幂等的,而是另外用了一个 sonnet1m45MigrationComplete 标记位(migrations/migrateSonnet1mToSonnet45.ts:27-29):

TYPESCRIPT
const config = getGlobalConfig()
if (config.sonnet1m45MigrationComplete) {
  return
}

为什么这一段要用完成标记?看代码的下半段:除了改 settings 之外,它还把进程内的 in-memory override(getMainLoopModelOverride())也跟着改了。这个 override 是来自 --model 命令行参数的运行时状态,不是 settings 里持久化的偏好——如果不立 flag,下一次启动 --model sonnet[1m] 这个用法的合法性判断和这条迁移会撞车。立 flag 是为了让 override 的迁移只发生一次,不在每次启动时反复改写一个本应来自命令行的临时值。这是这一组里唯一一个用了显式 completion flag 的迁移,原因专门写在它对 in-memory 状态的处理上。

3.4 migrateLegacyOpusToCurrent:把远古串收敛到 opus 别名

migrations/migrateLegacyOpusToCurrent.ts 处理的是另一组"古董字符串"。它的判断是 4 选 1:

TYPESCRIPT
if (
  model !== 'claude-opus-4-20250514' &&
  model !== 'claude-opus-4-1-20250805' &&
  model !== 'claude-opus-4-0' &&
  model !== 'claude-opus-4-1'
) {
  return
}

命中其中任何一个就改写为 opus,并把这次迁移的时间戳 legacyOpusMigrationTimestamp 写进 GlobalConfig 用于横幅,再发一条带 from_model 字段的 tengu_legacy_opus_migration 事件。函数顶上还有两道前置闸——getAPIProvider() !== 'firstParty' 早退,以及 isLegacyModelRemapEnabled() 这个 feature flag——这一段的服务对象只是 1P 用户、且在 legacy remap 功能开启的窗口内。函数顶上注释里那句「parseUserSpecifiedModel now silently remaps these at runtime anyway — this migration cleans up the settings file so /model shows the right thing」是这段代码的灵魂:运行时其实已经在替你把这些古董串映射到正确的模型上了,迁移做的是把 settings 文件本身洗干净,让 /model 屏看到的是当前合法的别名,不要再显示一串再也对不上的旧 ID。

3.5 migrateSonnet45ToSonnet46:把显式串卷回 sonnet 别名

3.3 留下了 sonnet-4-5-20250929[1m] 这种显式串,现在 4.6 全量发布、sonnet 别名指向 4.6 了,要把当年被钉死到 4.5 显式串的 Pro/Max/Team Premium 1P 用户卷回别名,让他们重新跟随 sonnet(也就是跟随后续的别名升级)。判断同样是 4 选 1(migrations/migrateSonnet45ToSonnet46.ts:39-43):

TYPESCRIPT
if (
  model !== 'claude-sonnet-4-5-20250929' &&
  model !== 'claude-sonnet-4-5-20250929[1m]' &&
  model !== 'sonnet-4-5-20250929' &&
  model !== 'sonnet-4-5-20250929[1m]'
) {
  return
}

命中之后看尾巴有没有 [1m],分别替换成 sonnet[1m]sonnet 写回去。这段还有一个小心思:横幅时间戳只对老用户写(config.numStartups > 1),对启动次数仅 1 的新用户跳过——"刚装上 Claude Code 的人从来没体验过旧默认,不该看到迁移横幅"。这种"对新装的人不打扰"的判断分散在好几个迁移里。

3.6 migrateOpusToOpus1m:把 opus 升级成 opus[1m]

最后这个模型迁移服务的是合并后的 Opus 1M 体验——Max / Team Premium 这类用户在 1P 上 Opus 跟 Opus 1M 已经合并成一个体验,要把钉在 opus 别名上的人升到 opus[1m]。Pro 订阅被显式跳过(他们在 /model 屏里还能看到 Opus 与 Opus 1M 两个独立选项)。最有意思的是写回那一段(migrations/migrateOpusToOpus1m.ts:34-40):

TYPESCRIPT
const migrated = 'opus[1m]'
const modelToSet =
  parseUserSpecifiedModel(migrated) ===
  parseUserSpecifiedModel(getDefaultMainLoopModelSetting())
    ? undefined
    : migrated
updateSettingsForSource('userSettings', { model: modelToSet })

这一段先把目标串 opus[1m] 和"当前的默认值"分别送进 parseUserSpecifiedModel 做结构化解析,如果两者解析结果相同就把字段写成 undefined(也就是从 settings 里清掉),否则才写显式串。换句话说:如果"目标"恰好就是这个用户的默认,那让他跟随默认即可;如果他的默认是别的、却要在 settings 里钉死这个,才落字符串。这是上文一直在讲的"不要做静默全局提权"的另一种形态——能用默认就用默认,不要在 settings 里多留一笔。

3.7 migrateFennecToOpus:内部 ant 用户专用

第 6 段是只对内部 ant 用户生效的(在 main.tsx 的 if ("external" === 'ant') 死代码守护下编进 ant bundle、对外发产物完全裁掉)。它处理的是内部 fennec 系列别名往 opus 别名上卷的工作:fennec-latest[1m] → opus[1m]fennec-latest → opusfennec-fast-latestopus-4-5-fast → opus[1m] + fastMode:true,逐条 startsWith 匹配。这里也展示了"读写同源 + 不立 flag"那一招——文件顶部那段注释(3.1 引用过)原话就是为这段服务的。

把 6 段模型迁移连起来读,你能看到 Claude Code 处理"模型字符串演化"这件事的一整套思路:别名升级时,先把老用户从旧别名映射到当时对应的显式串(保留意图)→ 等下一个别名变动时,再把符合条件的人卷回新别名(跟随升级)→ 期间一直只读写 userSettings,不动其它层,不读 merged。同样的模式可以扩到 fast mode 等正交维度。


四、改名、清理、重置:2 个剩下的迁移

最后一组的 2 个迁移不属于上面两类,但都演示了"配置迁移即代码"这种风格下一些独特的小手段。

4.1 migrateReplBridgeEnabledToRemoteControlAtStartup:改键名

最简单的一段,22 行。要做的事是把 GlobalConfig 里那个名叫 replBridgeEnabled 的键改成 remoteControlAtStartup——前者是实现细节漏到用户配置里,后者更直白。整段就是一次 saveGlobalConfigmigrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts:11-21):

TYPESCRIPT
saveGlobalConfig(prev => {
  // The old key is no longer in the GlobalConfig type, so access it via
  // an untyped cast. Only migrate if the old key exists and the new key
  // hasn't been set yet.
  const oldValue = (prev as Record<string, unknown>)['replBridgeEnabled']
  if (oldValue === undefined) return prev
  if (prev.remoteControlAtStartup !== undefined) return prev
  const next = { ...prev, remoteControlAtStartup: Boolean(oldValue) }
  delete (next as Record<string, unknown>)['replBridgeEnabled']
  return next
})

注意三件事:旧键已经从 GlobalConfig 的 TypeScript 类型里删掉了,所以读它的时候用 (prev as Record<string, unknown>)['replBridgeEnabled'] 这种穿透写法(类型已经"忘了"它,但运行时数据里它可能还在);同等性短路两道——旧键不存在直接返回 prev,新键已经设过也返回 prev,避免覆盖用户后来主动改过的值;最后 delete 改写在新对象 next 上而不是原对象 prev 上,保住 immutable updater 的语义。

这一段的"教学价值"在于:当你删除一个已经从源码 type 里抹掉的字段时,类型系统帮不了你,但你不能装作它不存在。 Claude Code 选择用 Record<string, unknown> 这种最朴素的穿透写法直面这个事实,而不是另外造一个"legacy type"去维护。

4.2 resetAutoModeOptInForDefaultOffer:重新弹一次老弹窗

migrations/resetAutoModeOptInForDefaultOffer.ts 服务的场景很窄。它要做的事是:之前用户在两选项的 AutoModeOptInDialog 弹窗里选了"接受"、于是 skipAutoPermissionPrompt 被设上了;后来这个弹窗加了第三个选项"把这个当我的默认模式"——为了让那些之前接受过老弹窗的人也能看到新选项,需要清掉那个标记位,让弹窗重新弹一次。

这一段独特在它的守门员条件比别的迁移多得多(migrations/resetAutoModeOptInForDefaultOffer.ts:26-36):

TYPESCRIPT
if (feature('TRANSCRIPT_CLASSIFIER')) {
  const config = getGlobalConfig()
  if (config.hasResetAutoModeOptInForDefaultOffer) return
  if (getAutoModeEnabledState() !== 'enabled') return

  try {
    const user = getSettingsForSource('userSettings')
    if (
      user?.skipAutoPermissionPrompt &&
      user?.permissions?.defaultMode !== 'auto'
    ) {
      updateSettingsForSource('userSettings', {
        skipAutoPermissionPrompt: undefined,
      })

外层一个 feature flag、一个 completion flag(防止反复重置)、一个 getAutoModeEnabledState() === 'enabled' 的精确状态闸。这里的关键在源码里那段长注释解释的:如果对 'opt-in' 状态的用户也清这个标记,会因为 permissionSetup.ts:988 的另一段逻辑,反而把 auto 模式从权限轮播里去掉、弹窗变得永远不可达——这个迁移就会自我反噬。它只对 'enabled' 状态的人生效,是为了避开那条状态机的反相边。为了一个看似简单的清理动作得反向追到另一段状态机里,这种小心翼翼是迁移代码里最容易出错、也最不能省的地方。

completion flag 的落盘

这一段还有一个细节值得指出。它落 completion flag 的时候用了一个嵌套同等性短路

TYPESCRIPT
saveGlobalConfig(c => {
  if (c.hasResetAutoModeOptInForDefaultOffer) return c
  return { ...c, hasResetAutoModeOptInForDefaultOffer: true }
})

外层流水线已经有一个版本号守护,为什么这里还要再立一个 hasResetAutoModeOptInForDefaultOffer?因为这段被 feature('TRANSCRIPT_CLASSIFIER') flag 圈起来——如果未来这个 feature 被开关切回去,外层版本号已经"以为这一段跑过",再切回去就跑不到了;只有迁移自带的 completion flag 才能精准记下"这一段对这个用户究竟有没有真的兑现过"。换句话说,外层流水线的版本号管"这一轮",迁移内部的 completion flag 管"这一段对这一个用户"——两个层次的幂等彼此独立,不冲突也不可互相替代。


总结:贯穿 11 个文件的几条纪律

把 11 个迁移逐个读完之后,你会发现 migrations/ 目录提炼出的是几条贯穿全部文件的纪律。

第一,「配置迁移即代码」是对"声明式迁移 DSL"这条路的明确拒绝。Claude Code 没有把迁移写成 JSON / YAML 配置、没有"from / to / when"这种字段,也没有一个迁移引擎去解释执行。每段迁移就是一个 TypeScript 函数——它能读源码里任何 helper、能调任何 feature flag、能查任何订阅类型、能拿到 in-memory override。把迁移当成普通代码来写,换来的就是"任意复杂的条件 / 任意精确的退出 / 任意细的遥测埋点"——上面 4.2 那种"为了避开另一段状态机的反相边而精确卡 'enabled'"的条件,没有 DSL 写得出来。

第二,所有迁移共享同一组幂等手法:读写同源、存在性短路、destructure-rest 删字段、同等性返回原引用避免空写。这一组手法不需要一个继承体系或抽象基类——每个文件 30 到 120 行内自己把它们用一遍,下一个写迁移的人照葫芦画瓢即可。

第三,外层版本号 + 内层 completion flag 是两个独立维度的幂等。外层管"这一轮发起人会不会再被叫",内层管"这一段对这个用户究竟有没有真的落过盘"。大部分迁移只靠外层 + 读写同源就够了;只有跨进程状态(in-memory override)、或者带 feature flag 开关(TRANSCRIPT_CLASSIFIER)的情况,才额外立一个内层 flag。

第四,迁移不能让错冒到 caller——但实现这一点的手段有两套,分布并不均匀。11 个迁移里只有 4 个 (migrateAutoUpdatesToSettings:25-60migrateBypassPermissionsAcceptedToSettings:21-39migrateEnableAllProjectMcpServersToSettings:33-117resetAutoModeOptInForDefaultOffer:31-49) 显式地用 try/catch 把 settings/config 写入吞住、走 logError;剩下 7 个 (migrateSonnet1mToSonnet45:31-47migrateLegacyOpusToCurrent:48-56migrateSonnet45ToSonnet46:48-66migrateOpusToOpus1m:34-42migrateFennecToOpus:23-44migrateReplBridgeEnabledToRemoteControlAtStartup:11-21resetProToOpusDefault:18-50) 没有任何 try/catch——它们靠的是幂等 + 早退:写入要么命中"读写同源 → 下一次走早退",要么命中"completion flag 已立 → 下一次走早退",所以即使中途异常,下一次启动重跑同一段也不会比跑一次更糟。runMigrations 顶部的版本号比对只看"等不等于",不看"大于小于"——失败那一轮的版本号没写成功,下次启动会被外层重新触发一遍;成功那一轮的版本号写上之后,整组迁移就此沉默。两套机制合起来,效果就是"整组迁移设计目标是重复跑一万次也不会比跑一次更糟",但实现路径不是统一的 try/catch,而是显式 try/catch(4 段)+ 幂等早退靠版本号兜底(7 段)

第五,模型迁移这一类把"用户当时的意图"作为不可侵犯的不变量来守护。改写字符串那 5 段都只读写 userSettings、不读 merged、不动 project/local/policy、对新装的人不打扰、能用默认就清字段不写显式串;唯一的例外是 resetProToOpusDefault——它不改字符串、只决定横幅时间戳,因此可以用 getSettings_DEPRECATED() 读 merged 来判断"用户的默认是不是没设过",且不会触发那条 silent global promotion。这一整套小心思加起来,让 Claude Code 可以在不挑事的前提下一年里把模型别名洗几次牌。

这一章想留下的最直白的一句话是:当一段产品逻辑要在用户身后改用户的配置时,它要么足够小心、要么干脆别改migrations/ 目录里的 11 个文件示范的是前者,每一个文件都把"我改了什么、为什么改、怎么保证不出错、怎么保证下次不再误改"写在了同一处。读完这 11 个文件,你也就读完了 Claude Code 处理"产品在用户身后偷偷换零件"这件事的全部答案。


下一章预告

第 5 章:QueryEngine 与对话主循环 — 一次完整 AI 交互的心跳

我们将深入 QueryEngine 与 query.ts 这 1729 行的核心文件,揭示一次完整的 AI 对话是如何被驱动的——从消息组装、API 调用、工具执行到错误恢复。