Every time you launch Claude Code it may silently fix stale settings, remap deprecated model
aliases, move fields between files, or re-surface UI dialogs — all before you see the first
prompt. This is the migration system: a small but precisely designed set of
one-shot functions that run inside runMigrations() in main.tsx.
src/migrations/*.ts ·
src/main.tsx (lines 323–352) ·
src/utils/config.ts ·
src/utils/releaseNotes.ts
Migrations in Claude Code are not database schema migrations. There is no migration table, no rollback, and no runner framework. Instead, every migration function is idempotent by design — it detects its own pre/post conditions and exits immediately if the work is already done. The entire set runs at every startup, protected by a single version number that short-circuits the whole block once all migrations have been applied.
Settings promotions
Move fields from ~/.claude.json (GlobalConfig) into settings.json
Model alias upgrades
Remap stale or removed model strings to current aliases in userSettings
Config key renames
Rename implementation-detail keys that leaked into public config
One-shot resets
Clear flags to re-surface dialogs when the UX changes and users need a second chance to choose
Async file migrations
Move config data into separate files (changelog cache) without blocking the UI
runMigrations()
All synchronous migrations are wrapped in a single function defined in main.tsx.
It is called during the Commander preAction hook — after config is loaded, before
the REPL starts.
// main.tsx — line 323
// @[MODEL LAUNCH]: Consider any migrations you may need for model strings.
// See migrateSonnet1mToSonnet45.ts for an example.
// Bump this when adding a new sync migration so existing users re-run the set.
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') { // internal Anthropic builds only
migrateFennecToOpus();
}
// Stamp the version so we skip next time
saveGlobalConfig(prev => prev.migrationVersion === CURRENT_MIGRATION_VERSION
? prev
: { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION });
}
// Async migration — fire-and-forget, non-blocking
migrateChangelogFromConfig().catch(() => {});
}
Three design decisions stand out here:
- Version gate:
migrationVersionis stored in~/.claude.json. Once it equalsCURRENT_MIGRATION_VERSION, the entire sync block is skipped — avoiding 11 redundantsaveGlobalConfiglock+re-read cycles on every startup. - Version bump rule: The comment explicitly says — bump the constant whenever you add a new sync migration so existing users re-run the full set.
- Async separation:
migrateChangelogFromConfigis async and involves file I/O, so it runs fire-and-forget after the sync block.
runMigrations() is called in the Commander preAction hook inside main.tsx,
directly after init() and right before loadRemoteManagedSettings().
A profileCheckpoint('preAction_after_migrations') call immediately follows it for latency tracking.
There are currently 11 sync migration functions plus one async one. Here is every file with what it does, the idempotency mechanism, and which config storage it touches.
| File | Category | What it does | Idempotency guard |
|---|---|---|---|
migrateAutoUpdatesToSettings.ts |
Settings | Moves a user-disabled autoUpdates flag from GlobalConfig into userSettings.env.DISABLE_AUTOUPDATER = "1". Also sets process.env immediately so the change is live without a restart. |
Skips if globalConfig.autoUpdates !== false or if the flag was set by native protection. Removes the field from GlobalConfig on success. |
migrateBypassPermissionsAcceptedToSettings.ts |
Settings | Moves bypassPermissionsModeAccepted out of GlobalConfig into userSettings.skipDangerousModePermissionPrompt. The old name leaked implementation details into the user-facing config file. |
Skips if bypassPermissionsModeAccepted is not in GlobalConfig. Checks hasSkipDangerousModePermissionPrompt() before writing to avoid overwriting an existing value. |
migrateEnableAllProjectMcpServersToSettings.ts |
Settings | Moves three MCP server approval fields (enableAllProjectMcpServers, enabledMcpjsonServers, disabledMcpjsonServers) from the project config into localSettings. Merges array fields with deduplication to avoid losing existing data. |
Skips if none of the three fields are present in project config. For enableAllProjectMcpServers, checks whether the target setting is already set before writing. |
resetProToOpusDefault.ts |
Reset | Records a timestamp (opusProMigrationTimestamp) for Pro subscribers on first-party who had no custom model set, so the UI can show a one-time notification that Opus 4.5 is now their default. |
Completion flag: globalConfig.opusProMigrationComplete. Immediately marks complete for non-Pro or non-firstParty users. |
migrateSonnet1mToSonnet45.ts |
Model | Pins users who had sonnet[1m] saved in userSettings to the explicit sonnet-4-5-20250929[1m] string. Needed because Sonnet 4.6 1M was offered to a different user group than Sonnet 4.5 1M. Also updates the in-memory main loop model override if it is already set. |
Completion flag: globalConfig.sonnet1m45MigrationComplete. |
migrateLegacyOpusToCurrent.ts |
Model | Rewrites explicit Opus 4.0/4.1 model strings (claude-opus-4-20250514, claude-opus-4-1-20250805, etc.) to the opus alias in userSettings. Also records legacyOpusMigrationTimestamp so the UI can show a one-time notification. Only runs for first-party users with legacy remap enabled. |
Reads and writes the same source (userSettings), making it self-idempotent — after migration the model string no longer matches, so it exits early. |
migrateSonnet45ToSonnet46.ts |
Model | Upgrades Pro/Max/Team Premium users pinned to Sonnet 4.5 explicit strings back to the sonnet (or sonnet[1m]) alias, which now resolves to 4.6. Skips brand-new users (numStartups <= 1) to avoid showing a notification to people who never used 4.5. |
Self-idempotent: only writes if userSettings.model matches a Sonnet 4.5 string. Gate: first-party + Pro/Max/TeamPremium only. |
migrateOpusToOpus1m.ts |
Model | For Max/Team Premium subscribers on first-party, upgrades userSettings.model = 'opus' to 'opus[1m]' when the Opus 1M merge is enabled. If opus[1m] resolves to the same model as the default, it clears the field instead (no unnecessary pin). |
Self-idempotent: only writes if model is exactly 'opus'. Gate: isOpus1mMergeEnabled(). |
migrateReplBridgeEnabledToRemoteControlAtStartup.ts |
Config | Renames the internal config key replBridgeEnabled to remoteControlAtStartup. The old name leaked an implementation detail into the public config file. Uses an untyped cast to access a key no longer in the TypeScript type. |
Skips if replBridgeEnabled is not present, or if remoteControlAtStartup is already set. |
resetAutoModeOptInForDefaultOffer.ts |
Reset | Clears skipAutoPermissionPrompt for users who accepted the old 2-option Auto Mode dialog but never set auto as their default mode. This re-surfaces the dialog so they see the new "make it my default" option. Only fires when the TRANSCRIPT_CLASSIFIER feature flag is active. |
Completion flag: globalConfig.hasResetAutoModeOptInForDefaultOffer. Also guarded by getAutoModeEnabledState() === 'enabled'. |
migrateFennecToOpus.ts |
Model | Internal Anthropic only (USER_TYPE === 'ant'). Migrates removed "fennec" model aliases (fennec-latest, fennec-latest[1m], fennec-fast-latest, opus-4-5-fast) to their Opus equivalents including fast mode. |
Self-idempotent: only acts if model starts with a fennec prefix. Only writes userSettings. |
releaseNotes.ts: migrateChangelogFromConfig() |
Config (async) | Moves the cached changelog string from globalConfig.cachedChangelog into a separate file on disk. Runs async so it never blocks the UI. Uses wx write flag so it only creates the file if it does not already exist. |
Skips if globalConfig.cachedChangelog is not set. File write uses flag: 'wx' (create-only) to avoid clobbering existing data. |
Every migration must be safe to call multiple times. Two patterns emerge across the codebase:
Pattern A — Completion flag in GlobalConfig
Used when a migration needs to run exactly once but the "already done" state is not
self-evident from the data (e.g. resetProToOpusDefault, migrateSonnet1mToSonnet45).
A boolean or timestamp is written to ~/.claude.json and checked at the top of the function.
// migrateSonnet1mToSonnet45.ts — completion flag pattern
export function migrateSonnet1mToSonnet45(): void {
const config = getGlobalConfig()
if (config.sonnet1m45MigrationComplete) {
return // already done — exit immediately
}
const model = getSettingsForSource('userSettings')?.model
if (model === 'sonnet[1m]') {
updateSettingsForSource('userSettings', {
model: 'sonnet-4-5-20250929[1m]',
})
}
saveGlobalConfig(current => ({
...current,
sonnet1m45MigrationComplete: true,
}))
}
Pattern B — Self-idempotent (data speaks for itself)
Used when the migration condition is a direct check on the current value — if the data has
already been migrated, the check simply returns false and nothing is written (e.g. all model
alias migrations). Reading and writing the same settings source (userSettings) is
key to this pattern — the comment in migrateLegacyOpusToCurrent.ts explains:
"Reading and writing the same source keeps this idempotent without a completion flag, and
avoids silently promoting 'opus' to the global default for users who only pinned it in one
project."
// migrateLegacyOpusToCurrent.ts — self-idempotent pattern
export function migrateLegacyOpusToCurrent(): void {
if (getAPIProvider() !== 'firstParty') return
if (!isLegacyModelRemapEnabled()) return
const model = getSettingsForSource('userSettings')?.model
if (
model !== 'claude-opus-4-20250514' &&
model !== 'claude-opus-4-1-20250805' &&
model !== 'claude-opus-4-0' &&
model !== 'claude-opus-4-1'
) {
return // data already clean — nothing to do
}
updateSettingsForSource('userSettings', { model: 'opus' })
saveGlobalConfig(current => ({
...current,
legacyOpusMigrationTimestamp: Date.now(),
}))
logEvent('tengu_legacy_opus_migration', { from_model: model })
}
Claude Code has multiple settings sources that are merged in priority order:
userSettings → projectSettings → localSettings → policySettings.
Migrations are deliberately scoped to only touch userSettings (and occasionally localSettings).
The comment in nearly every model migration file repeats the same rationale:
parseUserSpecifiedModel. Reading and writing the same source keeps this idempotent
without a completion flag, and avoids silently promoting 'opus' to the global default for
users who only pinned it in one project."
This constraint prevents a subtle class of bugs: if a migration read from merged
settings, it might see a project-scoped setting and "helpfully" write that value into the
global userSettings, suddenly making a per-project preference the new default
everywhere.
Alongside the startup migration functions, config.ts contains a lower-level
migrateConfigFields() that runs every time the config file is read from disk.
This handles the oldest schema changes — before the current versioned migration system existed.
// config.ts — runs on every config read
function migrateConfigFields(config: GlobalConfig): GlobalConfig {
if (config.installMethod !== undefined) {
return config // already migrated
}
// autoUpdaterStatus is removed from the type but may exist in old configs
const legacy = config as GlobalConfig & {
autoUpdaterStatus?: 'migrated' | 'installed' | 'disabled' | 'enabled' | ...
}
switch (legacy.autoUpdaterStatus) {
case 'migrated': installMethod = 'local'; break
case 'installed': installMethod = 'native'; break
case 'disabled': autoUpdates = false; break
...
}
return { ...config, installMethod, autoUpdates }
}
There is also a removeProjectHistory() function that strips the old inline
history field from project configs on every read — this field was migrated to
separate history.jsonl files, but old configs still carry the field.
migrateConfigFields) is used for very old schema
changes where the old field name no longer exists in the TypeScript type — requiring an
untyped cast to access it. New migrations go in the migrations/ directory and
run via runMigrations().
Migrations are observable. Most functions call logEvent() to record what
happened to Anthropic's telemetry pipeline. This lets the team know when a migration is
still being applied to users in the wild vs. when it is safe to remove the migration code.
| Event name | Migration |
|---|---|
tengu_migrate_autoupdates_to_settings | migrateAutoUpdatesToSettings |
tengu_migrate_autoupdates_error | migrateAutoUpdatesToSettings (error path) |
tengu_migrate_bypass_permissions_accepted | migrateBypassPermissionsAcceptedToSettings |
tengu_migrate_mcp_approval_fields_success | migrateEnableAllProjectMcpServersToSettings |
tengu_migrate_mcp_approval_fields_error | migrateEnableAllProjectMcpServersToSettings (error path) |
tengu_reset_pro_to_opus_default | resetProToOpusDefault |
tengu_legacy_opus_migration | migrateLegacyOpusToCurrent |
tengu_sonnet45_to_46_migration | migrateSonnet45ToSonnet46 |
tengu_opus_to_opus1m_migration | migrateOpusToOpus1m |
tengu_migrate_reset_auto_opt_in_for_default_offer | resetAutoModeOptInForDefaultOffer |
Notably, migrateSonnet1mToSonnet45 and migrateReplBridgeEnabledToRemoteControlAtStartup
do not emit analytics events — they are considered lower-risk housekeeping.
The source code even contains a comment pointing future engineers in the right direction:
// @[MODEL LAUNCH]: Consider any migrations you may need for model strings. See migrateSonnet1mToSonnet45.ts for an example.
The recipe from the existing codebase:
- Create
src/migrations/myNewMigration.tswith a single exported function. - Choose an idempotency strategy: completion flag in GlobalConfig, or self-idempotent data check.
- Only read from and write to
userSettings(orlocalSettingsfor project-scoped data). Never read merged settings. - Call
logEvent('tengu_my_migration_name', {...})with relevant metadata. - Wrap the body in try/catch and call
logErrorin the catch — migrations must never throw and break startup. - Import the function in
main.tsxand add it inside theif (migrationVersion !== CURRENT_MIGRATION_VERSION)block. - Bump
CURRENT_MIGRATION_VERSIONso existing users re-run the updated set.
// Template: src/migrations/myNewMigration.ts
import { logEvent } from '../services/analytics/index.js'
import { logError } from '../utils/log.js'
import {
getSettingsForSource,
updateSettingsForSource,
} from '../utils/settings/settings.js'
export function myNewMigration(): void {
// self-idempotent guard: check if work is already done
const model = getSettingsForSource('userSettings')?.model
if (model !== 'old-alias') return
try {
updateSettingsForSource('userSettings', { model: 'new-alias' })
logEvent('tengu_my_new_migration', {})
} catch (error) {
logError(new Error(`Failed to run myNewMigration: ${error}`))
}
}
Key Takeaways
runMigrations()inmain.tsxis the single entry point — a flat list of function calls guarded byCURRENT_MIGRATION_VERSION = 11.- The version gate in
globalConfig.migrationVersionprevents re-running 11 config saves on every startup once all migrations have been applied. - Two idempotency strategies: completion flag in GlobalConfig for non-self-evident one-shots, and self-idempotent data checks for cases where the data speaks for itself.
- All model migrations only touch
userSettings(never merged settings) to avoid accidentally globalizing a project-scoped model preference. - Migrations must never throw — errors are caught, logged, and silently swallowed to avoid breaking startup.
- Most migrations call
logEvent()so Anthropic can track when the old data shapes have fully disappeared from the installed base. - Adding a migration requires bumping
CURRENT_MIGRATION_VERSIONso existing users who already passed the gate will re-run the updated set. - Async migrations (file I/O like
migrateChangelogFromConfig) are fire-and-forget after the synchronous block.
Knowledge Check
getGlobalConfig().migrationVersion === CURRENT_MIGRATION_VERSION?userSettings directly rather than from merged settings?migrateAutoUpdatesToSettings calls process.env.DISABLE_AUTOUPDATER = '1' after writing to userSettings. Why?